Claim the cross-point forward interning §3 promises: one forward per group digest - #54
Merged
can-goodfire merged 2 commits intoAug 31, 2026
Merged
Conversation
`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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
causalab/protocol/plan.pygives every forward group adigestthat is thecontent 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§3sells that as a headline:
and
tests/protocols/07_weekdays_locate_scan_im.json's owndescriptionspends 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.pyalreadypinned it (
test_locate_scan_shares_per_layer_harvests: all 64original/counterfactualgroups intern to one digest). The referencebackend did not.
PytorchHooksBackend.executewas a flatfor point_raw, coords, digest in zip(request.points, …)loop calling_execute_pointonce per point; it never calledplan_pointand never lookedat 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_modelisfunctools.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_hookonthe loaded model, so the number is what the model saw, not what the backend
claims:
sum(num_forwards))interned_groups)Saved tables are byte-identical across the change (same MD5 for
iia.jsonandlogit_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 carryingthe union of their taps. That count is what a campaign owes; the merged
group is also the one whose
stop_afteran eliding backend must read — theshared 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_afteritself soa 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.pyplans the whole campaign before runninganything (interning is a property of the point set), derives
digest → union-of-tap-sites, and threads oneForwardCachethrough everypoint'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_groupconsults the cache first. A group another pointalready 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,
dimsandproduced_bystamp out ofone shared forward. Per-point provenance in
outputs.pyis untouched.RunResult.forwardsreports what execution actually cost, so "the plansays N" and "the run paid M" are both observable.
Deliberately exempt
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.
attached to the graph the step differentiates, and their row slices are not
the campaign's. They get no
Interninghandle at all.The trade
Documented on
ForwardCache: one shared pass holds every tapped address atonce 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_once— fails before this change withAssertionError: 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 perpoint (a single-point request has nothing to intern against, so it is a
genuine before/after) and
assert_frame_equalon both metric tables.test_a_lone_point_still_runs_its_own_groups— interning must not drop agroup 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.pybeside the existinginterning 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, 1727passed 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.jsonvalidation on
12_probe_variable/generate_metrics, the missingpandasimport in
test_run_corpus.py, and 2 intest_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 2pre-existing
F821 pderrors intest_run_corpus.pyare on the base anduntouched).
basedpyright: 0 errors in all four changed modules.Not run locally: the 8
golden-tier tests. They are the GPU tier by theproject's own marker (
_device()skips without an accelerator), and on a Mactorch.backends.mps.is_available()is true so they attempt a 60 GiB MPSallocation — they already fail with
MPS backend out of memoryon the basebranch 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), notmain: the wholecausalab/protocol/package and the pytorch-hooks backend exist only on thatstack, and
can/protocol-refactoris its base (as #42/#48/#49/#51 also are).🤖 Generated with Claude Code