Skip to content

Claim the cross-point forward interning §3 promises: one forward per group digest - #54

Merged
can-goodfire merged 2 commits into
can/protocol-refactorfrom
can/backend-forward-interning
Aug 31, 2026
Merged

can-goodfire merged 2 commits into
can/protocol-refactorfrom
can/backend-forward-interning

Conversation

@can-goodfire

Copy link
Copy Markdown
Collaborator

The gap

causalab/protocol/plan.py gives every forward group a digest that is the
content identity of everything determining its activations — network,
in-force writes, their operand reads' closures, input binding — and
deliberately leaves taps out of it, because reading layer 3 or layer 23 of
the same un-intervened forward is the same forward.

docs/intervention_protocol.md §3
sells that as a headline:

The planner content-dedups sub-values shared across points — shared harvests
and forwards fall out automatically (identical reads intern to one read).

and tests/protocols/07_weekdays_locate_scan_im.json's own description
spends it: a 32-layer × 2-position scan "plans 64 patched forwards plus one
shared counterfactual-harvest forward".

The planner held up its end, and tests/protocol/test_corpus.py already
pinned it (test_locate_scan_shares_per_layer_harvests: all 64
original/counterfactual groups intern to one digest). The reference
backend did not
. PytorchHooksBackend.execute was a flat
for point_raw, coords, digest in zip(request.points, …) loop calling
_execute_point once per point; it never called plan_point and never looked
at a digest. So the 07 scan ran 64 counterfactual harvest forwards where the
plan says 1
— advertised in the spec, tested at the planner, unclaimed at
execution. Model weights were already shared (load_model is
functools.lru_cache), so what leaked was forward compute, not loading.

Measured before / after

Corpus 07 driven through the real CLI at tiny scale (--set model.key=tiny-random-LlamaForCausalLM,
sites.target.layer={"sweep":[0,1]}, positions.tap={"sweep":[{"index":-1},{"index":-2}]}
→ 4 points × 2 groups). Forwards counted with a register_forward_pre_hook on
the loaded model, so the number is what the model saw, not what the backend
claims:

forwards
plan: group instances (sum(num_forwards)) 8
plan: distinct digests (interned_groups) 5
backend before 8
backend after 5

Saved tables are byte-identical across the change (same MD5 for
iia.json and logit_diff.json) — interning is a pure performance change.

At full 07 scale the plan arithmetic is 128 → 65, pinned by the new
test_locate_scan_owes_65_forwards_not_128.

What changed

  • plan.interned_groups(plans) merges same-digest groups into one carrying
    the union of their taps. That count is what a campaign owes; the merged
    group is also the one whose stop_after an eliding backend must read — the
    shared pass has to reach every tap any sharer asked for, so the union's
    deepest tap is the only correct elision depth. Noted on stop_after itself so
    a future eliding backend cannot get this wrong quietly. (Elision is not
    implemented in this backend today; the reference executor always runs full
    depth.)
  • neural/pytorch_hooks/backend.py plans the whole campaign before running
    anything (interning is a property of the point set), derives
    digest → union-of-tap-sites, and threads one ForwardCache through every
    point's executor. Group digests fold in a per-role (dataset, field)
    identity — exactly what determines a role's batch — so points reading
    different data never intern together.
  • PointExecutor._run_group consults the cache first. A group another point
    already ran under the same digest is served from the shared raw captures,
    before the positional gather and before any featurizer, so each point still
    resolves its own positions, featurizer, dims and produced_by stamp out of
    one shared forward. Per-point provenance in outputs.py is untouched.
  • RunResult.forwards reports what execution actually cost, so "the plan
    says N" and "the run paid M" are both observable.

Deliberately exempt

  • Decoding groups — their value is the continuation they produced, not
    activations a cache can replay; §4 exempts them from elision for the same
    reason. A decoding group still publishes its prompt-frame captures, so it
    can hand its prefill to a non-decoding point sharing the digest.
  • Grad-enabled groups (training minibatches) — their reads must stay
    attached to the graph the step differentiates, and their row slices are not
    the campaign's. They get no Interning handle at all.

The trade

Documented on ForwardCache: one shared pass holds every tapped address at
once where the per-point loop held one. Still a large net win — 32 passes
elided at layers 0..31 cost ~16× the single full-depth pass that replaces them.

Tests

New tests/neural/pytorch_hooks/test_forward_interning.py (4 tests, 2.4 s, CPU,
tiny-random):

  • test_a_shared_forward_group_runs_oncefails before this change with
    AssertionError: 8 forwards for a campaign whose plan has 5 distinct groups
    ,
    verified by temporarily passing interning=None.
  • test_interning_changes_no_number — one campaign request vs. one request per
    point (a single-point request has nothing to intern against, so it is a
    genuine before/after) and assert_frame_equal on both metric tables.
  • test_a_lone_point_still_runs_its_own_groups — interning must not drop a
    group a point genuinely needs.
  • test_the_plan_shares_a_group_the_points_do_not — the premise as data.

Plus 2 plan-level tests in tests/protocol/test_corpus.py beside the existing
interning tests (128 → 65 on real 07; interning a single point is the identity).

Checks

uv run pytest -m "not gpu and not slow and not golden": 17 failed, 1727
passed in 13.8 s
— byte-for-byte the same 17 failures as on the base branch
can/protocol-refactor (17 pre-existing: the [V10] metric-table .json
validation on 12_probe_variable/generate_metrics, the missing pandas
import in test_run_corpus.py, and 2 in test_metrics.py — exactly what #49
"make the stack's base green" exists to fix). Passed count is +6, my 6 new
tests. No regressions.

ruff check / ruff format --check: clean on everything I touched (the 2
pre-existing F821 pd errors in test_run_corpus.py are on the base and
untouched). basedpyright: 0 errors in all four changed modules.

Not run locally: the 8 golden-tier tests. They are the GPU tier by the
project's own marker (_device() skips without an accelerator), and on a Mac
torch.backends.mps.is_available() is true so they attempt a 60 GiB MPS
allocation — they already fail with MPS backend out of memory on the base
branch and swap-thrash the machine. They skip on CI (ubuntu, no accelerator).
Verifying those is a cluster job.

Base

Opened against can/protocol-refactor (#20), not main: the whole
causalab/protocol/ package and the pytorch-hooks backend exist only on that
stack, and can/protocol-refactor is its base (as #42/#48/#49/#51 also are).

🤖 Generated with Claude Code

`plan.py` gives every forward group a `digest` that is the content identity of
everything determining its activations — network, in-force writes, their
operand reads' closures, input binding — and deliberately leaves **taps out of
it**, because reading layer 3 or layer 23 of the same un-intervened forward is
the same forward. §3 of docs/intervention_protocol.md sells that as a headline
("shared harvests and forwards fall out automatically"), and corpus 07's
description spends it: a 32-layer x 2-position scan "plans 64 patched forwards
plus one shared counterfactual-harvest forward".

The planner held up its end, and tests/protocol/test_corpus.py pinned it. The
reference backend did not. `PytorchHooksBackend.execute` was a flat
`for point in request.points` loop calling `_execute_point` once per point; it
never called `plan_point` and never looked at a digest. So the 07 scan ran 64
counterfactual harvests where the plan said one — a guarantee advertised in the
spec, tested at the planner, and unclaimed at execution. Model weights were
already shared (`load_model` is lru_cached), so what leaked was forward compute,
not loading.

Why it belongs in the backend rather than the planner: §8 puts fusion, batching
and staging on the backend, and plan.py says so. The planner's job is to expose
the digests; spending them is execution's.

- `plan.interned_groups(plans)` merges same-digest groups into one carrying the
  **union** of their taps. That count is what a campaign owes (65 for 07 against
  the 128 a per-point loop pays), and the merged group is the one whose
  `stop_after` an eliding backend must read: the shared pass has to reach every
  tap any sharer asked for, so the union's deepest tap is the only correct
  elision depth. Noted on `stop_after` itself so a future eliding backend
  cannot get this wrong quietly.
- The backend plans the whole campaign before running anything (interning is a
  property of the point *set*), derives digest -> union-of-tap-sites, and
  threads one `ForwardCache` through every point's executor. Group digests fold
  in a per-role `(dataset, field)` identity, which is exactly what determines a
  role's batch — so points reading different data never intern together.
- `PointExecutor._run_group` consults the cache first. A group another point
  already ran under the same digest is served from the shared **raw** captures
  — before the positional gather and before any featurizer — so each point still
  resolves its own positions, featurizer, dims and `produced_by` stamp out of
  one shared forward. Decoding groups are exempt (their value is a continuation,
  not activations a cache can replay — the same reason §4 exempts them from
  elision) and so are grad-enabled groups (a training step's reads must stay
  attached to the graph it differentiates).
- `RunResult.forwards` reports what execution actually cost, so "the plan says
  N" and "the run paid M" are both observable rather than inferred.

Measured on 07 at tiny scale (2 layers x 2 positions, 4 points): 8 forwards
before, 5 after — exactly the plan's distinct-digest count. The saved tables are
byte-identical across the change (same MD5).

The memory trade is real and documented on `ForwardCache`: one shared pass holds
every tapped address at once where the per-point loop held one. It is still a
large net win — 32 passes elided at layers 0..31 cost ~16x the single
full-depth pass that replaces them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Test Results

2 702 tests  +6   2 675 ✅ +7   1m 44s ⏱️ +26s
    1 suites ±0      27 💤 ±0 
    1 files   ±0       0 ❌  - 1 

Results for commit 6eda004. ± Comparison against base commit b6fc9aa.

♻️ This comment has been updated with latest results.

The base's engine rename landed while this branch sat: `Backend` became
`Engine`, `causalab/neural/pytorch_hooks/` moved under `engines/`, and the
per-point execution loop was lifted out of the reference engine into the
engine-neutral `causalab/neural/shared/`. Git saw three modify/delete
conflicts because the two files this branch rewrote no longer exist by those
names, so the interning was re-applied onto the new structure rather than
merged textually.

Where each piece landed, and why:

- `RunResult.forwards` -> `protocol/engine.py` (was `protocol/backend.py`).
- `campaign_plans` / `_data_identity` / `_tap_union` -> `shared/execution.py`,
  not the pytorch_hooks engine: the campaign loop lives there now, and
  interning is a property of the point *set*, so it has to be derived where
  the set is known. `PytorchHooksBackend.campaign_plans` is therefore
  `shared.execution.campaign_plans`.
- `ForwardCache` / `Interning` and the `_group_digest` / `_interned` /
  `_publish` helpers -> `shared/executor_base.py`, keyed on the base's
  `TapKey` rather than the old `(module id, side)` pair. `TapKey` carries
  shape, tuple index, interface slot and expert, so two taps that share a
  module but mean different tensors no longer collide in the store — the old
  key would have.
- `ForwardCache.routing` is new: round 3 added an experts-interface routing
  table beside each capture (`idx_capture`), and a capture replayed without
  the dispatch indices it joins on is not the same value. The two are
  published and served together.
- `_forward_group` extraction -> `engines/pytorch_hooks/executor.py`, over the
  base's much larger group body (attention/experts/DeltaNet interface taps,
  the interior refusals). `_refuse_interior` now runs on this point's own
  sites *before* the cache is consulted, so a cache hit cannot smuggle past a
  refusal, and on the union sites as they are installed.
- Publishing filters unfilled placeholders: the base pre-seeds `capture[key]`
  with `torch.empty(0)` for dedup, and handing a later point an empty capture
  would be worse than letting it run the forward.

`execute_request` gained `intern_forwards`, opted into by the reference engine
only. The nnsight engine's trace executor does not consult the cache, so it
takes the handle and drops it and reports `forwards=0` — "not measured", not a
count nothing took.

Verified rather than assumed: `test_a_shared_forward_group_runs_once` still
fails on the merged tree when `_interned` is stubbed to return None ("8
forwards for a campaign whose plan has 5 distinct groups"), so the 8 -> 5
claim survives the rename intact.

Checks: `pytest -m "not gpu and not slow and not golden"` 2675 passed, 0
failed (the base's own suite is green now that #49 landed). pre-commit clean.
basedpyright: 7 errors across the touched modules against 9 on the base — all
pre-existing, none in the merged code. The golden tier is still a cluster job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@can-goodfire
can-goodfire merged commit 15372a8 into can/protocol-refactor Aug 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants