interp-engine 1.3.6__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {interp_engine-1.3.6 → interp_engine-1.4.0}/PKG-INFO +8 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/README.md +7 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/ARCHITECTURE_QUIRKS.md +71 -19
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/arch.py +79 -5
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/capture.py +8 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/facts.py +168 -3
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/hooks.py +29 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/model.py +53 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/steer.py +15 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_hooks.py +48 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_tree.py +112 -4
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/requests.py +44 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/pyproject.toml +1 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/synthetic_families.py +51 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_moe.py +145 -7
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_per_layer_attn_dims.py +145 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_new_points.py +79 -1
- {interp_engine-1.3.6 → interp_engine-1.4.0}/.gitignore +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/LICENSE +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/README.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/__init__.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/bench_spec.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/cells.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/probe.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/probe_lens_stream.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/publish.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/report_bench.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark-cudagraph.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm-static.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__eager.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__vllm-static.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__vllm-static.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__eager.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__vllm-static.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3-4b__vllm.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__vllm-static.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/results-latest.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/run_all.sh +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/run_bench.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/benchmarks/workloads.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/AGENT_INTEGRATION.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/COMPATIBILITY.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/GRADIENTS.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/INTERNALS.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/PERFORMANCE.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/PORTING.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/README.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/SUPPORTED_POINTS.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/docs/USAGE.md +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/__init__.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/_loop.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/address.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/attn_config.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/attn_scores.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/autograd_support.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/chat_compose.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/chat_conventions.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/chat_formatters.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/cuda_preflight.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/dispatch.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/lens.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/load.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/mappers.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/moe_routing.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/notebook_stdout.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/points.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/protocol.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/residual_basis.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/select.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/steer_specs.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/sync.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/tokenize.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_backend.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/__init__.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_demux.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/_payload.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/attn.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/capture.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/graphs.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/__init__.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/intervene.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/readout.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/lens/unembed.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/mhc.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/native.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/static.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_capture/steering.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/interp_engine/vllm_plugin.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/conftest.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/harness.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/model_expectations.yaml +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_address.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_config_tripwire.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_probs_indexing.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_scores.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_attn_z_gqa.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_autograd_support.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_bench_workloads.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_capability_refusals.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_capture_addressing.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_chat_compose.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_chat_formatters.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_chat_templates.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_core.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_cuda_preflight.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_doc_code_fences.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_eager_autograd.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_facts.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_family_points.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_gated_attn_out.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_head_contributions.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_hook_call_conventions.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_layer_kinds.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_load.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_logit_transform.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_mappers.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_mlp_internals.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_model_expectations.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_multimodal_arch.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_new_models_gpu.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_no_chat_template.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_normalized_hook.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_notebook_stdout.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_packaging.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_parity_gpt2.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_points_registry.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_protocol.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_published_benchmarks.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_qk_norm.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_qkv_layout.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_reasoning_spans.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_release.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_resid_mid.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_residual_basis.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sandwich_norms.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_select.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sliding_window_attn.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_small_models_gpu.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_dsv4_gpu.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_parity_gpu.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_set.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_static_warmup.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_steer_context.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_steer_math_parity.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sync_loop.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_sync_parity.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_unified_free_functions.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_unresolved_families.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_capture_gpu.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_capture_scales.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_engine_loop.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_graph_path.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_graphs_on_gpu.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_hook_availability.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_hyper_connections.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_kv_isolation.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_only_families.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_plugin.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vllm_wire_grammar.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_vocabulary_boundary.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_worker_lens_capture_readout.py +0 -0
- {interp_engine-1.3.6 → interp_engine-1.4.0}/tests/test_worker_lens_readout.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: interp-engine
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: A fast, standardized, and easy to use interpretability engine.
|
|
5
5
|
Project-URL: Homepage, https://github.com/decoderesearch/interp-engine
|
|
6
6
|
Project-URL: Repository, https://github.com/decoderesearch/interp-engine
|
|
@@ -139,6 +139,13 @@ We verify correctness in two main ways:
|
|
|
139
139
|
1. A test suite that checks results across several models - what each check is designed to catch is in [INTERNALS.md](docs/INTERNALS.md#correctness).
|
|
140
140
|
2. A full `validator` comparison engine that checks most hook points across 50+ models, at early, middle and late layers - fully reproducible, with detailed results saved in the git repo at [`validator/`](validator/).
|
|
141
141
|
|
|
142
|
+
## Known Issues
|
|
143
|
+
|
|
144
|
+
- **Gemma 4 requires transformers 5.14.1** because 5.15 moved `head_dim` into `per_layer_config` and vLLM's config read dies before a weight loads ([vllm#51744](https://github.com/vllm-project/vllm/issues/51744)).
|
|
145
|
+
- **Gemma 4 26B refuses `mlp_out`**, because `layer.mlp` there is one branch of a two-branch feed-forward; `mlp_out_post` is the residual contribution and is served ([ARCHITECTURE_QUIRKS.md](docs/ARCHITECTURE_QUIRKS.md#moe-tap-the-block-not-the-experts)).
|
|
146
|
+
- **DeepSeek-V2 on transformers older than 5.15.0** captures a wrong attention temperature — the engine warns at load, and upgrading is the fix ([COMPATIBILITY.md](docs/COMPATIBILITY.md)).
|
|
147
|
+
- **MXFP4 checkpoints (gpt-oss) need `interp-engine[quant]`**, which the `[vllm]` extra does not include; without it transformers dequantizes them to bf16 at roughly 3x the weights, which can turn a model that fits into one that does not.
|
|
148
|
+
|
|
142
149
|
## Why use an Interpretability Engine instead of building from scratch?
|
|
143
150
|
|
|
144
151
|
1. **Speed**: Get performance without sacrificing correctness.
|
|
@@ -108,6 +108,13 @@ We verify correctness in two main ways:
|
|
|
108
108
|
1. A test suite that checks results across several models - what each check is designed to catch is in [INTERNALS.md](docs/INTERNALS.md#correctness).
|
|
109
109
|
2. A full `validator` comparison engine that checks most hook points across 50+ models, at early, middle and late layers - fully reproducible, with detailed results saved in the git repo at [`validator/`](validator/).
|
|
110
110
|
|
|
111
|
+
## Known Issues
|
|
112
|
+
|
|
113
|
+
- **Gemma 4 requires transformers 5.14.1** because 5.15 moved `head_dim` into `per_layer_config` and vLLM's config read dies before a weight loads ([vllm#51744](https://github.com/vllm-project/vllm/issues/51744)).
|
|
114
|
+
- **Gemma 4 26B refuses `mlp_out`**, because `layer.mlp` there is one branch of a two-branch feed-forward; `mlp_out_post` is the residual contribution and is served ([ARCHITECTURE_QUIRKS.md](docs/ARCHITECTURE_QUIRKS.md#moe-tap-the-block-not-the-experts)).
|
|
115
|
+
- **DeepSeek-V2 on transformers older than 5.15.0** captures a wrong attention temperature — the engine warns at load, and upgrading is the fix ([COMPATIBILITY.md](docs/COMPATIBILITY.md)).
|
|
116
|
+
- **MXFP4 checkpoints (gpt-oss) need `interp-engine[quant]`**, which the `[vllm]` extra does not include; without it transformers dequantizes them to bf16 at roughly 3x the weights, which can turn a model that fits into one that does not.
|
|
117
|
+
|
|
111
118
|
## Why use an Interpretability Engine instead of building from scratch?
|
|
112
119
|
|
|
113
120
|
1. **Speed**: Get performance without sacrificing correctness.
|
|
@@ -291,12 +291,49 @@ way for a layer to have no `v_proj`, and it does not overlap with KV sharing: th
|
|
|
291
291
|
against a key of `rope(k_norm(k_proj(h)))`, so the two are the same projection read through
|
|
292
292
|
different norms, not the same tensor.
|
|
293
293
|
|
|
294
|
-
**
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
294
|
+
**The third answer that layer needed is the norm, and it applies to the whole family.**
|
|
295
|
+
`ArchSpec.v_proj` still returns `None` there, which is the signal it uses for "fused QKV, the caller
|
|
296
|
+
must split", on a family whose `fused_qkv` is false — so `value` no longer asks it first.
|
|
297
|
+
`ArchSpec.value_module` does, and it returns the `v_norm` whenever the family has one
|
|
298
|
+
(`facts.ATTN_VALUE_NORM_ATTRS`). Two things that buys, and the first would apply even without
|
|
299
|
+
`attention_k_eq_v`:
|
|
300
|
+
|
|
301
|
+
- `Gemma4TextAttention.forward` runs `value_states = self.v_norm(value_states)` on **every** layer
|
|
302
|
+
that projects its own KV, so the projection's output is a norm short of the tensor the attention
|
|
303
|
+
pattern multiplies. `value` was off by that norm on the sliding layers too, which do have a
|
|
304
|
+
`v_proj`.
|
|
305
|
+
- vLLM has no value projection to compare against at all — its `Gemma4Attention` splits one fused
|
|
306
|
+
`qkv_proj`, with the K weights loaded into the V slot on the `k_eq_v` layers — but it runs the same
|
|
307
|
+
`v_norm` on the V slice. So this is the only boundary at which the two engines hold the same tensor,
|
|
308
|
+
which is what makes the point checkable rather than merely servable. `_tree._value_module` is the
|
|
309
|
+
vLLM side, and it prefers the norm in the same order for the same reason.
|
|
310
|
+
|
|
311
|
+
**Reading a norm costs a rank, and the point does not pay it.** A norm over `head_dim` can only be
|
|
312
|
+
given the per-head view, so both engines hand `v_norm` a `(…, n_kv_heads, head_dim)` tensor where a
|
|
313
|
+
`v_proj` would have produced the flat one — and `value` would then be 4-D on this family and 3-D on the
|
|
314
|
+
next, against a `Width.HEADS` declaration and `run_with_cache`'s `[batch, seq, width]`. Flattened back
|
|
315
|
+
on both sides (`hooks.flat_per_head`, `vllm_capture._hooks.flat_value`), including on the way *into* a
|
|
316
|
+
steer, so a vector measured on a capture of `value` is the shape the write expects; the module gets its
|
|
317
|
+
own rank back before the attention reshapes it. Eagerly the head count is checked rather than assumed,
|
|
318
|
+
because the other 4-D layout in circulation is `[batch, heads, pos, head_dim]` — transformers norms
|
|
319
|
+
after the head transpose on ten families — and flattening that one puts the head count where every
|
|
320
|
+
reader expects the sequence.
|
|
321
|
+
|
|
322
|
+
**Off Gemma-4, `value` on vLLM is one third of a packed matrix.** Every family with a fused vLLM
|
|
323
|
+
implementation goes through `QKVParallelLinear`, whose output is `[q | k | v]` on the last axis, so an
|
|
324
|
+
output hook there returns all three under the value's name at three times the width.
|
|
325
|
+
`_tree.value_span` measures the q and k widths off the projection's own rank-local geometry
|
|
326
|
+
(`num_heads`, `num_kv_heads`, `head_size`, each divided by the TP size in its `__init__`) and
|
|
327
|
+
`_hooks.value_columns` applies it on both install paths, refusing a geometry it can read only in part —
|
|
328
|
+
every wrong offset into a packed matrix yields a right-shaped tensor of another projection's heads. On
|
|
329
|
+
the per-request path the narrowing happens *before* steering, so a steer on `value` leaves the same
|
|
330
|
+
matrix's q and k alone.
|
|
331
|
+
|
|
332
|
+
The `k_eq_v` layers stay the interesting case for a *reader*: `value` there is `v_norm(k_proj(h))`
|
|
333
|
+
where the key is `rope(k_norm(k_proj(h)))`, so the two points come off one projection through
|
|
334
|
+
different norms and only one of them has RoPE applied. `ArchSpec.is_k_eq_v_layer` answers which layers
|
|
335
|
+
those are, gated on `not sliding` the way the modeling code gates it — the flag is model-wide and the
|
|
336
|
+
structure is not.
|
|
300
337
|
|
|
301
338
|
Two more ways the value side can differ from the query side, both of which produce a correctly shaped
|
|
302
339
|
and completely wrong per-head split if you reshape by `head_dim`:
|
|
@@ -438,7 +475,7 @@ n_experts]` tensor that is plausible rather than obviously wrong.
|
|
|
438
475
|
which they are, because the LongCat guard makes the `mlp_pre` refusal require a router **on the MLP
|
|
439
476
|
module**, and Gemma-4's is a sibling. The parameter count of a sparse layer includes the dense MLP,
|
|
440
477
|
so the usual `(n_layers - n_sparse) * dense_mlp` undercounts (the 26B by ~0.5B). And `mlp_out` is
|
|
441
|
-
**not** the whole feed-forward here,
|
|
478
|
+
**not** the whole feed-forward here, so it is refused — see below.
|
|
442
479
|
|
|
443
480
|
Which experts fired _is_ capturable, one level down: `router_logits` / `expert_weights` /
|
|
444
481
|
`expert_indices` are three elements of the router module's own output tuple, resolved by
|
|
@@ -484,18 +521,33 @@ module at all. Gemma-4 is the exception noted above, and the refusal already let
|
|
|
484
521
|
right reason rather than by luck: it is gated on the router being found *on the MLP module*, the same
|
|
485
522
|
guard that keeps LongCat's shortcut MoE from suppressing its two real feed-forwards.
|
|
486
523
|
|
|
487
|
-
**
|
|
488
|
-
rather than a part of it:
|
|
489
|
-
|
|
490
|
-
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
`
|
|
496
|
-
`
|
|
497
|
-
|
|
498
|
-
|
|
524
|
+
**Three things follow on Gemma-4's 26B from that one fact** — the routed branch is a sibling of
|
|
525
|
+
`layer.mlp` rather than a part of it, so the *block* owns both halves of the feed-forward:
|
|
526
|
+
|
|
527
|
+
- **`mlp_out` is refused**, on both backends. It taps `layer.mlp`, which here returns the dense branch
|
|
528
|
+
alone: correctly shaped `[tokens, d_model]`, at the right positions, missing the experts entirely,
|
|
529
|
+
and with nothing about it to notice — both engines build the same tree, so a cross-engine sweep
|
|
530
|
+
agrees on the same half rather than catching it. The summed tensor has no module boundary to serve
|
|
531
|
+
instead (the sum is a local of the block's `forward`), and there is already a point that means it:
|
|
532
|
+
`mlp_out_post` is the `post_feedforward_layernorm`'s output, downstream of the sum, and the one for
|
|
533
|
+
which `resid_post == resid_mid + mlp_out_post` holds. `ArchSpec.mlp_is_half_the_feed_forward` is the
|
|
534
|
+
question both refusals ask. Nothing else moves: `mlp_in`, `mlp_pre`, `mlp_pre_linear` and `mlp_act`
|
|
535
|
+
are the dense branch's own internals and mean what they mean everywhere else.
|
|
536
|
+
- **The router is looked for on the block as well as on the MLP** (`facts.moe_router_owner`). Asking
|
|
537
|
+
only `layer.mlp` left all three routing points unresolvable on the 26B. Presence alone is not enough
|
|
538
|
+
now that a decoder layer is searched: `facts.moe_router_attr` requires a callable submodule, because
|
|
539
|
+
a block can hold a plain flag under one of these names and the caller is about to install a forward
|
|
540
|
+
hook on whatever comes back.
|
|
541
|
+
- **`router_logits` is read one module deeper than the tuple.** `Gemma4TextRouter.forward` returns
|
|
542
|
+
`(router_probabilities, top_k_weights, top_k_index)`: element 0 matches the default tuple *order*
|
|
543
|
+
while being a softmax over all 128 experts, and is exactly as wide as the logits over the same bank,
|
|
544
|
+
so neither `ROUTER_OUTPUTS` nor a width check can see it — and the block discards it. So the point
|
|
545
|
+
reads the router's own `proj` output, which is what that softmax consumed and what vLLM's
|
|
546
|
+
`Gemma4Router.forward` returns outright, leaving the two engines comparable
|
|
547
|
+
(`facts.ROUTER_LOGITS_SUBMODULE`, plus a `ROUTER_OUTPUTS` row naming slot 0 for what it is).
|
|
548
|
+
`assert_routing_shapes` also rejects a `router_logits` that is non-negative everywhere and sums to 1
|
|
549
|
+
per token, which is the family-agnostic version of the same catch. Elements 1 and 2 are the weights
|
|
550
|
+
and indices the block really routes with, and are read where every other family's are.
|
|
499
551
|
|
|
500
552
|
In our shipping set only gpt-oss-20b is MoE (32 experts, top-4, no shared expert, every layer
|
|
501
553
|
sparse). Its 3D batched expert weights matter only to code reading MLP weight matrices, and the
|
|
@@ -86,6 +86,12 @@ class Quirks:
|
|
|
86
86
|
# no branch on this -- ``mlp_in``/``mlp_out`` tap ``layer.mlp``, which is the whole block
|
|
87
87
|
# including any shared expert -- so it is reported for callers, not consumed here.
|
|
88
88
|
moe_layers: tuple[int, ...] = ()
|
|
89
|
+
# Whether a sparse layer's routed experts are added *beside* the dense MLP instead of replacing
|
|
90
|
+
# it (Gemma-4, and so far only Gemma-4). That inverts the sentence above: ``layer.mlp`` is then
|
|
91
|
+
# one of two branches the block's own forward sums, so tapping it returns half a feed-forward.
|
|
92
|
+
# Consumed by capture, which refuses ``mlp_out`` on such a layer and points at ``mlp_out_post``.
|
|
93
|
+
# See :func:`facts.dense_mlp_beside_experts`.
|
|
94
|
+
dense_mlp_beside_experts: bool = False
|
|
89
95
|
# Each sublayer's output is normalized before being added to the residual (Gemma-2/3/4
|
|
90
96
|
# sandwich norms, OLMo-2/3 post-norms), so the *residual contribution* is a different tensor
|
|
91
97
|
# from the raw submodule output. Enables the ``attn_out_post`` / ``mlp_out_post`` points.
|
|
@@ -556,6 +562,10 @@ class ArchSpec:
|
|
|
556
562
|
# did it while `mlp_in`/`mlp_out` at the same position happily resolved to that same dense
|
|
557
563
|
# module, so the two halves of the MLP vocabulary contradicted each other about one block.
|
|
558
564
|
mlp = self.mlp_projection_holder(layer)
|
|
565
|
+
# The MLP's *own* router, deliberately, not `facts.moe_router_owner`: a router beside the MLP
|
|
566
|
+
# rather than inside it means the dense MLP is still there and still has a neuron basis to
|
|
567
|
+
# capture (Gemma-4 -- see `mlp_is_half_the_feed_forward`), so widening this to the block would
|
|
568
|
+
# refuse three points that exist. `moe_router` widens; this one must not.
|
|
559
569
|
if self.is_moe_layer(layer) and facts.moe_router_attr(mlp) is not None:
|
|
560
570
|
raise ValueError(
|
|
561
571
|
f"Layer {layer} of {self.architecture} is a sparse MoE block, so it has no single "
|
|
@@ -615,6 +625,17 @@ class ArchSpec:
|
|
|
615
625
|
"""Whether ``layer``'s MLP is a sparse mixture-of-experts block rather than a dense one."""
|
|
616
626
|
return layer in self.quirks.moe_layers
|
|
617
627
|
|
|
628
|
+
def mlp_is_half_the_feed_forward(self, layer: int) -> bool:
|
|
629
|
+
"""Whether ``layer.mlp`` is one of two feed-forward branches the block's forward sums.
|
|
630
|
+
|
|
631
|
+
True only where a sparse layer keeps its dense MLP *beside* the experts (Gemma-4): both
|
|
632
|
+
branches read the pre-feedforward residual through norms of their own, and the block adds
|
|
633
|
+
them. So ``layer.mlp`` is a complete module producing a complete ``d_model`` tensor that is
|
|
634
|
+
nonetheless not the layer's feed-forward -- which is why this is asked rather than inferred
|
|
635
|
+
from ``is_moe_layer``, true on families where the MLP *is* the whole block.
|
|
636
|
+
"""
|
|
637
|
+
return self.is_moe_layer(layer) and self.quirks.dense_mlp_beside_experts
|
|
638
|
+
|
|
618
639
|
def moe_router(self, layer: int) -> nn.Module:
|
|
619
640
|
"""The sparse block's router, whose output is the whole routing decision.
|
|
620
641
|
|
|
@@ -634,12 +655,16 @@ class ArchSpec:
|
|
|
634
655
|
"the neuron basis of a dense MLP."
|
|
635
656
|
)
|
|
636
657
|
mlp = self.mlp_module(layer)
|
|
637
|
-
|
|
638
|
-
|
|
658
|
+
# Beside the MLP as well as inside it: Gemma-4 hangs the router on the block, and asking only
|
|
659
|
+
# the MLP reported "no router submodule found" on a checkpoint whose router is one attribute
|
|
660
|
+
# away. See :func:`facts.moe_router_owner`.
|
|
661
|
+
owner = facts.moe_router_owner(self.block(layer), mlp)
|
|
662
|
+
if owner is None:
|
|
639
663
|
raise AttributeError(
|
|
640
|
-
f"No router submodule found on layer {layer}'s {type(mlp).__name__} "
|
|
641
|
-
f"({self.architecture}); tried {facts.MOE_ROUTER_ATTRS}"
|
|
664
|
+
f"No router submodule found on layer {layer}'s {type(mlp).__name__} or on the block "
|
|
665
|
+
f"itself ({self.architecture}); tried {facts.MOE_ROUTER_ATTRS}"
|
|
642
666
|
)
|
|
667
|
+
holder, attr = owner
|
|
643
668
|
if "forward" in vars(mlp):
|
|
644
669
|
# A quantizer or kernel loader has replaced the block's forward on the instance, and the
|
|
645
670
|
# replacements route *inline*: transformers' MXFP4 path for gpt-oss calls
|
|
@@ -676,7 +701,7 @@ class ArchSpec:
|
|
|
676
701
|
"the fused path (for gpt-oss: quantization_config=Mxfp4Config(dequantize=True)) to get the "
|
|
677
702
|
"eager router, and with it the weights and indices, back."
|
|
678
703
|
)
|
|
679
|
-
return getattr(
|
|
704
|
+
return getattr(holder, attr)
|
|
680
705
|
|
|
681
706
|
def inline_routing_logits(self, layer: int) -> tuple[nn.Module, str] | None:
|
|
682
707
|
"""Address of ``router_logits`` on a block that routes inline, or None if it calls its router.
|
|
@@ -696,6 +721,12 @@ class ArchSpec:
|
|
|
696
721
|
|
|
697
722
|
Raises on a KV-shared layer, where the value tensor is genuinely produced by a different
|
|
698
723
|
layer rather than merely being hard to find.
|
|
724
|
+
|
|
725
|
+
``None`` also -- and this is why callers must ask :meth:`value_module` *first* rather than
|
|
726
|
+
reading None as "split a fused QKV" -- on a layer that has no value projection because it uses
|
|
727
|
+
the key's (:meth:`is_k_eq_v_layer`). That is a third case this return value cannot distinguish,
|
|
728
|
+
on a family whose :attr:`Quirks.fused_qkv` is false, so a caller reaching here with None on
|
|
729
|
+
Gemma-4 would go looking for a fused projection that does not exist.
|
|
699
730
|
"""
|
|
700
731
|
if self.is_kv_shared_layer(layer):
|
|
701
732
|
raise ValueError(self._kv_shared_refusal(layer, "value", "value projection", "DFA reads it too."))
|
|
@@ -953,6 +984,48 @@ class ArchSpec:
|
|
|
953
984
|
:func:`facts.value_scale`."""
|
|
954
985
|
return facts.value_scale(self.attn_module(layer))
|
|
955
986
|
|
|
987
|
+
def is_k_eq_v_layer(self, layer: int) -> bool:
|
|
988
|
+
"""Whether ``layer`` takes its *key* projection's output as the value and has no ``v_proj``.
|
|
989
|
+
|
|
990
|
+
Gemma-4's ``attention_k_eq_v``, and only on the layers the modeling code applies it to: the
|
|
991
|
+
flag is model-wide but ``Gemma4TextAttention`` gates it on ``not is_sliding``, so a sliding
|
|
992
|
+
layer of the same checkpoint projects its value normally. Reading the flag alone would
|
|
993
|
+
describe every layer of the 12B, 26B and 31B as having no value projection when half of them
|
|
994
|
+
do -- and the sliding layers' ``num_key_value_heads`` differs from the global ones', so the
|
|
995
|
+
two kinds are not interchangeable in any case (:meth:`kv_heads_for_layer`).
|
|
996
|
+
"""
|
|
997
|
+
if not self.k_eq_v:
|
|
998
|
+
return False
|
|
999
|
+
kinds = self.quirks.hybrid_layer_types
|
|
1000
|
+
if not kinds or layer >= len(kinds):
|
|
1001
|
+
# The flag with no layer table is a homogeneous trunk: nothing marks a layer sliding, so
|
|
1002
|
+
# the alternative attention applies throughout.
|
|
1003
|
+
return True
|
|
1004
|
+
return "sliding" not in str(kinds[layer]).lower()
|
|
1005
|
+
|
|
1006
|
+
def value_module(self, layer: int) -> nn.Module | None:
|
|
1007
|
+
"""The module whose output is the value attention consumes, when that is not the projection.
|
|
1008
|
+
|
|
1009
|
+
The norm the family runs between projection and attention -- Gemma-4's ``v_norm`` -- or
|
|
1010
|
+
``None`` on every family where ``value`` is the projection's own output.
|
|
1011
|
+
|
|
1012
|
+
Preferred over :meth:`v_proj` wherever it exists, on *all* of that family's layers rather
|
|
1013
|
+
than only the ones missing a projection. Two reasons, and the first would apply even if the
|
|
1014
|
+
second never came up: the normed tensor is the one the attention pattern multiplies, so it is
|
|
1015
|
+
what ``value`` names and what DFA needs; and vLLM has no separate value projection to compare
|
|
1016
|
+
against (its ``Gemma4Attention`` splits a fused QKV) but runs the same ``v_norm``, so this is
|
|
1017
|
+
the only boundary at which the two engines can be checked against each other at all.
|
|
1018
|
+
|
|
1019
|
+
Raises on a KV-shared layer, where the tensor is genuinely another layer's -- the same
|
|
1020
|
+
refusal :meth:`v_proj` makes, and for the same reason: those layers are built with neither a
|
|
1021
|
+
projection nor a norm.
|
|
1022
|
+
"""
|
|
1023
|
+
if self.is_kv_shared_layer(layer):
|
|
1024
|
+
raise ValueError(self._kv_shared_refusal(layer, "value", "value norm", "DFA reads it too."))
|
|
1025
|
+
attn = self.attn_module(layer)
|
|
1026
|
+
attr = facts.value_norm_attr(attn)
|
|
1027
|
+
return getattr(attn, attr) if attr is not None else None
|
|
1028
|
+
|
|
956
1029
|
def is_kv_shared_layer(self, layer: int) -> bool:
|
|
957
1030
|
"""Whether ``layer`` reuses an earlier layer's keys/values and has no k/v projection."""
|
|
958
1031
|
return self.first_kv_shared_layer is not None and layer >= self.first_kv_shared_layer
|
|
@@ -1127,6 +1200,7 @@ def resolve_arch(model: nn.Module, config: Any) -> ArchSpec:
|
|
|
1127
1200
|
qkv_layout=facts.eager_qkv_layout(architecture, config),
|
|
1128
1201
|
gated_attn_out=gated_attn_out,
|
|
1129
1202
|
moe_layers=model_facts.moe_layers,
|
|
1203
|
+
dense_mlp_beside_experts=model_facts.dense_mlp_beside_experts,
|
|
1130
1204
|
sandwich_norms=post_mlp_norm_attr is not None,
|
|
1131
1205
|
tied_embeddings=tied,
|
|
1132
1206
|
attn_sinks=bool(hints.get("attn_sinks", False)),
|
|
@@ -27,7 +27,7 @@ from interp_engine.address import Address, to_address
|
|
|
27
27
|
from interp_engine.attn_scores import capture_attn_scores
|
|
28
28
|
from interp_engine.dispatch import TokensLike, as_batched_tokens, as_token_ids, refuse
|
|
29
29
|
from interp_engine.facts import text_config
|
|
30
|
-
from interp_engine.hooks import HookManager
|
|
30
|
+
from interp_engine.hooks import HookManager, flat_per_head
|
|
31
31
|
from interp_engine.model import EagerModel
|
|
32
32
|
from interp_engine.points import token_flattened
|
|
33
33
|
from interp_engine.protocol import InterpModel
|
|
@@ -386,6 +386,13 @@ def _run_with_cache_eager(
|
|
|
386
386
|
heads = model.arch.kv_heads_for_layer(key.layer or 0) if key.name.startswith("k_") else model.n_heads
|
|
387
387
|
cache.tensors[key] = _to_token_major(cache.tensors[key], seq, heads)
|
|
388
388
|
|
|
389
|
+
for key in {k for k in cache.tensors if k.name == "value"}:
|
|
390
|
+
# Flat, however the family produced it: a value norm sees the per-head view and a value
|
|
391
|
+
# projection does not, and `value` is one point. See `hooks.flat_per_head`.
|
|
392
|
+
layer = key.layer or 0
|
|
393
|
+
flat, _ = flat_per_head(cache.tensors[key], heads=model.arch.kv_heads_for_layer(layer))
|
|
394
|
+
cache.tensors[key] = flat
|
|
395
|
+
|
|
389
396
|
if wants_attn:
|
|
390
397
|
attentions = output.attentions # one [batch, n_heads, q, k] per softmax-attention layer
|
|
391
398
|
for address in addresses:
|
|
@@ -1134,9 +1134,46 @@ def moe_router_attr(mlp: Any) -> str | None:
|
|
|
1134
1134
|
"""Attribute name of the sparse block's router, or ``None`` if this MLP has none.
|
|
1135
1135
|
|
|
1136
1136
|
``None`` on every dense MLP, including the dense prefix layers of a hybrid MoE model, so a
|
|
1137
|
-
caller must not read it as "this checkpoint is not MoE".
|
|
1137
|
+
caller must not read it as "this checkpoint is not MoE". Also ``None`` on a Gemma-4 sparse layer,
|
|
1138
|
+
whose router is a sibling of the MLP rather than a child of it -- ask :func:`moe_router_owner`,
|
|
1139
|
+
which looks in both places, unless you specifically mean the MLP's own.
|
|
1138
1140
|
"""
|
|
1139
|
-
return next((name for name in MOE_ROUTER_ATTRS if
|
|
1141
|
+
return next((name for name in MOE_ROUTER_ATTRS if _is_module(getattr(mlp, name, None))), None)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _is_module(candidate: Any) -> bool:
|
|
1145
|
+
"""Whether this attribute is a callable submodule rather than a flag or a tensor.
|
|
1146
|
+
|
|
1147
|
+
Duck-typed because this module holds no torch dependency (see the header). Presence alone is not
|
|
1148
|
+
enough once the *block* is searched for a router as well as the MLP: a decoder layer can hold a
|
|
1149
|
+
plain attribute under one of these names, and ``hasattr`` would hand a bool back to a caller
|
|
1150
|
+
about to install a forward hook on it.
|
|
1151
|
+
"""
|
|
1152
|
+
return callable(candidate) and hasattr(candidate, "forward")
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def moe_router_owner(layer: Any, mlp: Any) -> tuple[Any, str] | None:
|
|
1156
|
+
"""The module that holds the sparse block's router, and the attribute name -- or ``None``.
|
|
1157
|
+
|
|
1158
|
+
The MLP first, because that is where every other MoE family puts it: the router is a child of
|
|
1159
|
+
the ``MixtralSparseMoeBlock`` / ``Qwen3MoeSparseMoeBlock`` / ``GptOssMLP`` that consumes it, so
|
|
1160
|
+
on those the block is never searched and nothing about them changes.
|
|
1161
|
+
|
|
1162
|
+
Gemma-4 is the family that needs the second look. Its router is a sibling of ``layer.mlp``
|
|
1163
|
+
(``layer.router``, beside ``layer.experts``), for the same reason its MLP is only half the
|
|
1164
|
+
feed-forward: the routed branch is assembled by the *block's* forward, so the block owns both
|
|
1165
|
+
halves and neither is inside the other. Asking only the MLP left all three routing points
|
|
1166
|
+
unresolvable on the 26B, reported as "no router submodule found" -- a lookup failure that reads
|
|
1167
|
+
like a checkpoint without a router rather than a router one level up.
|
|
1168
|
+
|
|
1169
|
+
``None`` on a dense layer either way, which a caller must not read as "this checkpoint is not
|
|
1170
|
+
MoE": the dense prefix of a hybrid trunk answers ``None`` too.
|
|
1171
|
+
"""
|
|
1172
|
+
if (attr := moe_router_attr(mlp)) is not None:
|
|
1173
|
+
return mlp, attr
|
|
1174
|
+
if layer is not None and (attr := moe_router_attr(layer)) is not None:
|
|
1175
|
+
return layer, attr
|
|
1176
|
+
return None
|
|
1140
1177
|
|
|
1141
1178
|
|
|
1142
1179
|
#: Sparse-block ``forward`` replacements that route *inline* -- computing the logits with the router's
|
|
@@ -1188,16 +1225,59 @@ def routing_convention(architecture: str) -> str | None:
|
|
|
1188
1225
|
#: outside: element 0 is ``[tokens, k]`` where the default reading expects ``[tokens, n_experts]``,
|
|
1189
1226
|
#: which is a plausible tensor under either name, and it took a cross-engine width mismatch to catch.
|
|
1190
1227
|
#: :func:`assert_routing_shapes` is the check that makes the next one of these loud instead.
|
|
1228
|
+
#: A name for an element that is not one of the three points -- it is a real tensor a router returns
|
|
1229
|
+
#: which no canonical point means, and it exists so that a layout can say "the logits are *not* here"
|
|
1230
|
+
#: rather than leaving the slot labelled with the point that would then be read out of it.
|
|
1231
|
+
_NOT_A_POINT_PROBS = "router_probabilities"
|
|
1191
1232
|
_DEFAULT_ROUTER_OUTPUT: tuple[str, ...] = ("router_logits", "expert_weights", "expert_indices")
|
|
1192
1233
|
ROUTER_OUTPUTS: dict[str, tuple[str, ...]] = {
|
|
1193
1234
|
"GraniteMoeForCausalLM": ("expert_indices", "expert_weights", "router_logits"),
|
|
1194
1235
|
"GraniteMoeSharedForCausalLM": ("expert_indices", "expert_weights", "router_logits"),
|
|
1195
1236
|
"GraniteMoeHybridForCausalLM": ("expert_indices", "expert_weights", "router_logits"),
|
|
1237
|
+
# Gemma-4 returns the three tensors in the default *order* while element 0 is not the default
|
|
1238
|
+
# tensor: `Gemma4TextRouter.forward` softmaxes over all 128 experts and returns those
|
|
1239
|
+
# probabilities there. The width check cannot catch it -- probabilities over the bank are exactly
|
|
1240
|
+
# as wide as logits over the bank -- and neither can a caller downstream, since both are
|
|
1241
|
+
# per-token float rows whose entries are plausible either way. The block itself discards element
|
|
1242
|
+
# 0, so it is not even a tensor the model uses. Its logits are one module deeper
|
|
1243
|
+
# (:data:`ROUTER_LOGITS_SUBMODULE`), which is where `router_logits` resolves on this family.
|
|
1244
|
+
"Gemma4ForConditionalGeneration": (_NOT_A_POINT_PROBS, "expert_weights", "expert_indices"),
|
|
1245
|
+
"Gemma4UnifiedForConditionalGeneration": (_NOT_A_POINT_PROBS, "expert_weights", "expert_indices"),
|
|
1246
|
+
"Gemma4ForCausalLM": (_NOT_A_POINT_PROBS, "expert_weights", "expert_indices"),
|
|
1196
1247
|
}
|
|
1197
1248
|
|
|
1198
1249
|
|
|
1250
|
+
#: Where a family's *pre-softmax* logits are, for the families whose router module does not return
|
|
1251
|
+
#: them: the attribute, on the router, of the projection that produces them.
|
|
1252
|
+
#:
|
|
1253
|
+
#: Gemma-4 is the family (see :data:`ROUTER_OUTPUTS`). ``Gemma4TextRouter`` norms, scales and then
|
|
1254
|
+
#: projects, and it is that ``proj`` whose output the softmax consumes -- so this is a *read* of the
|
|
1255
|
+
#: tensor the routing decision was made from, not a recomputation of it, and it is bit-identical to
|
|
1256
|
+
#: what vLLM's own ``Gemma4Router.forward`` returns, which makes the two engines comparable at this
|
|
1257
|
+
#: point rather than only within one.
|
|
1258
|
+
#:
|
|
1259
|
+
#: Keyed by architecture and deliberately small, like every other table here. A family whose router
|
|
1260
|
+
#: returns its logits needs no entry, and guessing an entry for one would address a point at a
|
|
1261
|
+
#: submodule whose output has never been checked against what the family routes on.
|
|
1262
|
+
ROUTER_LOGITS_SUBMODULE: dict[str, str] = {
|
|
1263
|
+
"Gemma4ForConditionalGeneration": "proj",
|
|
1264
|
+
"Gemma4UnifiedForConditionalGeneration": "proj",
|
|
1265
|
+
"Gemma4ForCausalLM": "proj",
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def router_logits_submodule(architecture: str) -> str | None:
|
|
1270
|
+
"""The router submodule holding this family's pre-softmax logits, or None if the router returns them."""
|
|
1271
|
+
return ROUTER_LOGITS_SUBMODULE.get(architecture)
|
|
1272
|
+
|
|
1273
|
+
|
|
1199
1274
|
def router_output_index(architecture: str, point: str) -> int:
|
|
1200
|
-
"""Which element of this family's router output tuple carries ``point``.
|
|
1275
|
+
"""Which element of this family's router output tuple carries ``point``.
|
|
1276
|
+
|
|
1277
|
+
Raises where the tuple carries no such tensor, which is a real answer and not a gap in the table:
|
|
1278
|
+
a family can return probabilities where the default returns logits, and the caller then has to go
|
|
1279
|
+
somewhere else for them (:func:`router_logits_submodule`) rather than read the slot.
|
|
1280
|
+
"""
|
|
1201
1281
|
layout = ROUTER_OUTPUTS.get(architecture, _DEFAULT_ROUTER_OUTPUT)
|
|
1202
1282
|
if point not in layout:
|
|
1203
1283
|
raise ValueError(f"{architecture}'s router output carries {layout}, not {point!r}")
|
|
@@ -1221,6 +1301,16 @@ def assert_routing_shapes(point: str, tensor: Any, *, architecture: str, n_exper
|
|
|
1221
1301
|
f"Its router's output tuple is not {_DEFAULT_ROUTER_OUTPUT}; register the real order in "
|
|
1222
1302
|
"`facts.ROUTER_OUTPUTS`."
|
|
1223
1303
|
)
|
|
1304
|
+
if point == "router_logits" and _looks_like_a_distribution(tensor):
|
|
1305
|
+
raise ValueError(
|
|
1306
|
+
f"{architecture}'s captured 'router_logits' is non-negative everywhere and sums to 1 per "
|
|
1307
|
+
"token, so it is a softmax over the expert bank rather than the logits that went into one. "
|
|
1308
|
+
"A family can return the probabilities where the default tuple returns logits (Gemma-4 "
|
|
1309
|
+
"does, and the width check above cannot see it, because a distribution over the bank is "
|
|
1310
|
+
"exactly as wide as the logits over it). Point 'router_logits' at the router's own "
|
|
1311
|
+
"projection with `facts.ROUTER_LOGITS_SUBMODULE` and mark the slot in "
|
|
1312
|
+
"`facts.ROUTER_OUTPUTS`."
|
|
1313
|
+
)
|
|
1224
1314
|
if point == "expert_indices" and tensor.dtype.is_floating_point:
|
|
1225
1315
|
raise ValueError(
|
|
1226
1316
|
f"{architecture}'s captured 'expert_indices' is {tensor.dtype}, and a selection is integers. "
|
|
@@ -1234,6 +1324,28 @@ def assert_routing_shapes(point: str, tensor: Any, *, architecture: str, n_exper
|
|
|
1234
1324
|
)
|
|
1235
1325
|
|
|
1236
1326
|
|
|
1327
|
+
def _looks_like_a_distribution(tensor: Any) -> bool:
|
|
1328
|
+
"""Whether every row of ``tensor``'s last axis is non-negative and sums to one.
|
|
1329
|
+
|
|
1330
|
+
The tell that separates a softmax's output from its input, and the only property that does: the
|
|
1331
|
+
two are the same shape, the same dtype and the same width, and both are per-token float rows whose
|
|
1332
|
+
entries look like scores. Both halves are needed -- logits happen to sum near 1 sometimes, and a
|
|
1333
|
+
non-negative row need not be normalized -- and together they are a thing real logits over 128
|
|
1334
|
+
experts do not do.
|
|
1335
|
+
|
|
1336
|
+
Written with no torch dependency (see the header) and defensively: anything that does not answer
|
|
1337
|
+
these questions is not a distribution as far as this is concerned, because the caller's job is a
|
|
1338
|
+
shape assertion and it must not be the thing that raises.
|
|
1339
|
+
"""
|
|
1340
|
+
try:
|
|
1341
|
+
if tensor.dtype.is_floating_point is False:
|
|
1342
|
+
return False
|
|
1343
|
+
as_float = tensor.detach().float()
|
|
1344
|
+
return bool((as_float >= 0).all()) and bool(((as_float.sum(-1) - 1.0).abs() < 1e-3).all())
|
|
1345
|
+
except (AttributeError, RuntimeError, TypeError):
|
|
1346
|
+
return False
|
|
1347
|
+
|
|
1348
|
+
|
|
1237
1349
|
def inline_routing_logits_index(mlp: Any) -> int | None:
|
|
1238
1350
|
"""Where this block's own output holds the router logits, when its forward routes inline.
|
|
1239
1351
|
|
|
@@ -1286,6 +1398,24 @@ def dense_mlp_beside_experts(cfg: Any) -> bool:
|
|
|
1286
1398
|
return bool(config_attr(cfg, "enable_moe_block", False))
|
|
1287
1399
|
|
|
1288
1400
|
|
|
1401
|
+
#: The attribute both engines' Gemma-4 decoder layers set from ``enable_moe_block``, marking a block
|
|
1402
|
+
#: that hangs the routed branch beside its dense MLP. A *layer* flag rather than a config read, which
|
|
1403
|
+
#: is what the vLLM worker needs: it holds a block with no layer index to ask
|
|
1404
|
+
#: :func:`is_moe_layer` about, and the two trees set this from the same config field, so asking the
|
|
1405
|
+
#: block cannot disagree with what the eager backend concluded from the config.
|
|
1406
|
+
DENSE_MLP_BESIDE_EXPERTS_FLAG = "enable_moe_block"
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def experts_beside_this_layers_mlp(layer: Any) -> bool:
|
|
1410
|
+
"""Whether *this* block sums a routed branch with its dense MLP's output.
|
|
1411
|
+
|
|
1412
|
+
False on a Gemma-4 layer that is dense, so this is per block rather than per checkpoint -- and
|
|
1413
|
+
false everywhere outside the family, where a sparse layer has no dense MLP to sum with. See
|
|
1414
|
+
:func:`dense_mlp_beside_experts` for what the arrangement is and why it needs saying.
|
|
1415
|
+
"""
|
|
1416
|
+
return bool(getattr(layer, DENSE_MLP_BESIDE_EXPERTS_FLAG, False))
|
|
1417
|
+
|
|
1418
|
+
|
|
1289
1419
|
def is_moe_layer(cfg: Any, layer: int) -> bool:
|
|
1290
1420
|
"""Whether ``layer``'s ``mlp`` is a sparse MoE block rather than a dense MLP.
|
|
1291
1421
|
|
|
@@ -1432,6 +1562,41 @@ def value_scale(attn_module: Any) -> float:
|
|
|
1432
1562
|
return 1.0
|
|
1433
1563
|
|
|
1434
1564
|
|
|
1565
|
+
#: A norm applied to the value vectors *between* the projection and attention, so the tensor
|
|
1566
|
+
#: attention consumes is this module's output rather than the projection's. Gemma-4 is the family:
|
|
1567
|
+
#: ``Gemma4TextAttention.forward`` runs ``value_states = self.v_norm(value_states)`` on every layer
|
|
1568
|
+
#: that projects its own KV, and vLLM's ``Gemma4Attention`` runs the same line on the V slice of its
|
|
1569
|
+
#: fused QKV -- so the two engines agree here in a way they cannot at the projection, which vLLM does
|
|
1570
|
+
#: not have separately.
|
|
1571
|
+
#:
|
|
1572
|
+
#: The same idea as :data:`ATTN_VALUE_SCALE_ATTRS`, one step further: that corrects a scalar the
|
|
1573
|
+
#: forward applies after the projection, and this names a module that does. Kept as a module rather
|
|
1574
|
+
#: than folded into the scale because an RMS norm is per token, not a constant, so no factor
|
|
1575
|
+
#: reproduces it.
|
|
1576
|
+
#:
|
|
1577
|
+
#: Exact names, and only the ones verified against a family's forward. A ``v_norm`` that a forward
|
|
1578
|
+
#: does *not* apply to the value would make this the wrong tensor -- silently, since it is the right
|
|
1579
|
+
#: shape -- which is the same trap ``use_qk_norm=True`` beside an ``nn.Identity`` sets.
|
|
1580
|
+
ATTN_VALUE_NORM_ATTRS: tuple[str, ...] = ("v_norm",)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
def value_norm_attr(attn_module: Any) -> str | None:
|
|
1584
|
+
"""Attribute name of the norm this layer's value passes through, or ``None`` if there is none.
|
|
1585
|
+
|
|
1586
|
+
``None`` on every family but Gemma-4, where ``value`` is the projection's own output and nothing
|
|
1587
|
+
about the resolution changes.
|
|
1588
|
+
|
|
1589
|
+
Two things this buys on Gemma-4, and the second is why it is a module question rather than a
|
|
1590
|
+
correction applied afterwards. The value it names is the one attention multiplies the pattern by,
|
|
1591
|
+
where ``v_proj``'s output is a norm short of it -- true on *every* layer of the family, including
|
|
1592
|
+
the sliding ones that do have a ``v_proj``. And on a ``attention_k_eq_v`` layer there is no
|
|
1593
|
+
``v_proj`` at all: the forward passes the *key* projection's output to ``v_norm``, so this module
|
|
1594
|
+
is the only boundary the value crosses, and asking for the projection there is a question with no
|
|
1595
|
+
answer (see :meth:`interp_engine.arch.ArchSpec.is_k_eq_v_layer`).
|
|
1596
|
+
"""
|
|
1597
|
+
return next((name for name in ATTN_VALUE_NORM_ATTRS if _is_module(getattr(attn_module, name, None))), None)
|
|
1598
|
+
|
|
1599
|
+
|
|
1435
1600
|
def value_head_dim(cfg: Any, head_dim: int) -> int:
|
|
1436
1601
|
"""The width of one *value* head, which is not always the query/key head's width.
|
|
1437
1602
|
|
|
@@ -46,6 +46,35 @@ def extract_hidden(output: object, index: int = 0) -> torch.Tensor:
|
|
|
46
46
|
return output # type: ignore[return-value]
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def flat_per_head(tensor: torch.Tensor, *, heads: int) -> tuple[torch.Tensor, torch.Size | None]:
|
|
50
|
+
"""A capture that arrived with a head axis, as the flat ``[batch, pos, heads * head_dim]`` a point
|
|
51
|
+
declares -- with the shape to restore, for a hook that has to write back.
|
|
52
|
+
|
|
53
|
+
`value` needs this and `z` does not, for a reason that is about modules rather than about heads.
|
|
54
|
+
The module `value` resolves to is a *norm* wherever the family has one, and a norm over `head_dim`
|
|
55
|
+
has to be given the per-head view: Gemma-4 runs `v_norm` on `v_proj(h).view(..., n_kv, head_dim)`
|
|
56
|
+
and vLLM runs the same norm on `v.unflatten(-1, (n_kv, head_dim))`. So the point arrives per head
|
|
57
|
+
on one family and flat on the next, while `Width.HEADS` and `run_with_cache` both promise one
|
|
58
|
+
rank -- and TransformerLens' `hook_v` is the per-head spelling of this point, which is a
|
|
59
|
+
distinction the mapping table would lose if ours moved family to family.
|
|
60
|
+
|
|
61
|
+
The head count is checked rather than assumed, because the other 4-D layout a norm can produce is
|
|
62
|
+
`[batch, heads, pos, head_dim]` (transformers norms after the head transpose on ten families), and
|
|
63
|
+
flattening *that* yields a tensor whose second axis is the head count. Every reader would take it
|
|
64
|
+
for the sequence, `[batch, seq, width]` would still describe it, and nothing downstream could see
|
|
65
|
+
it was wrong.
|
|
66
|
+
"""
|
|
67
|
+
if tensor.ndim != 4:
|
|
68
|
+
return tensor, None
|
|
69
|
+
if tensor.shape[-2] != heads:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Expected a per-head capture with {heads} heads on its second-to-last axis, got "
|
|
72
|
+
f"{tuple(tensor.shape)}. A head-major layout ([batch, heads, pos, head_dim]) is the "
|
|
73
|
+
"likely reason, and flattening it would put the head count where the sequence belongs."
|
|
74
|
+
)
|
|
75
|
+
return tensor.flatten(-2, -1), tensor.shape[-2:]
|
|
76
|
+
|
|
77
|
+
|
|
49
78
|
def parse_point(point: str) -> tuple[str, int]:
|
|
50
79
|
"""Split a hook side into ``(side, tuple index)``: ``"output"`` -> ``("output", 0)``,
|
|
51
80
|
``"output:2"`` -> ``("output", 2)``.
|