interp-engine 1.3.4__tar.gz → 1.3.6__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.
Files changed (176) hide show
  1. {interp_engine-1.3.4 → interp_engine-1.3.6}/PKG-INFO +1 -1
  2. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/ARCHITECTURE_QUIRKS.md +49 -6
  3. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/arch.py +20 -5
  4. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/facts.py +103 -10
  5. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_backend.py +16 -2
  6. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/static.py +13 -3
  7. {interp_engine-1.3.4 → interp_engine-1.3.6}/pyproject.toml +1 -1
  8. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_static_set.py +27 -0
  9. {interp_engine-1.3.4 → interp_engine-1.3.6}/.gitignore +0 -0
  10. {interp_engine-1.3.4 → interp_engine-1.3.6}/LICENSE +0 -0
  11. {interp_engine-1.3.4 → interp_engine-1.3.6}/README.md +0 -0
  12. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/README.md +0 -0
  13. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/__init__.py +0 -0
  14. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/bench_spec.py +0 -0
  15. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/cells.py +0 -0
  16. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/probe.py +0 -0
  17. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/probe_lens_stream.py +0 -0
  18. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/publish.py +0 -0
  19. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/report_bench.py +0 -0
  20. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  21. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  22. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark-cudagraph.json +0 -0
  23. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark.json +0 -0
  24. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/deepseek-v4-flash-0731__vllm-static.json +0 -0
  25. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  26. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  27. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  28. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/gemma-2-2b__vllm-static.json +0 -0
  29. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  30. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  31. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  32. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/llama-3.1-8b__vllm-static.json +0 -0
  33. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  34. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3-4b__eager.json +0 -0
  35. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  36. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3-4b__vllm-static.json +0 -0
  37. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  38. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  39. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  40. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3.8-27b__vllm-static.json +0 -0
  41. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  42. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/results-latest.md +0 -0
  43. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/run_all.sh +0 -0
  44. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/run_bench.py +0 -0
  45. {interp_engine-1.3.4 → interp_engine-1.3.6}/benchmarks/workloads.py +0 -0
  46. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/AGENT_INTEGRATION.md +0 -0
  47. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/COMPATIBILITY.md +0 -0
  48. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
  49. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/GRADIENTS.md +0 -0
  50. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/INTERNALS.md +0 -0
  51. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/PERFORMANCE.md +0 -0
  52. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/PORTING.md +0 -0
  53. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/README.md +0 -0
  54. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/SUPPORTED_POINTS.md +0 -0
  55. {interp_engine-1.3.4 → interp_engine-1.3.6}/docs/USAGE.md +0 -0
  56. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/__init__.py +0 -0
  57. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/_loop.py +0 -0
  58. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/address.py +0 -0
  59. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/attn_config.py +0 -0
  60. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/attn_scores.py +0 -0
  61. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/autograd_support.py +0 -0
  62. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/capture.py +0 -0
  63. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/chat_compose.py +0 -0
  64. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/chat_conventions.py +0 -0
  65. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/chat_formatters.py +0 -0
  66. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/cuda_preflight.py +0 -0
  67. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/dispatch.py +0 -0
  68. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/hooks.py +0 -0
  69. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/lens.py +0 -0
  70. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/load.py +0 -0
  71. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/mappers.py +0 -0
  72. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/model.py +0 -0
  73. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/moe_routing.py +0 -0
  74. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/notebook_stdout.py +0 -0
  75. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/points.py +0 -0
  76. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/protocol.py +0 -0
  77. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/residual_basis.py +0 -0
  78. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/select.py +0 -0
  79. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/steer.py +0 -0
  80. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/steer_specs.py +0 -0
  81. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/sync.py +0 -0
  82. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/tokenize.py +0 -0
  83. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/__init__.py +0 -0
  84. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/_demux.py +0 -0
  85. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/_hooks.py +0 -0
  86. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/_payload.py +0 -0
  87. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/_tree.py +0 -0
  88. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/attn.py +0 -0
  89. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/capture.py +0 -0
  90. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/graphs.py +0 -0
  91. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  92. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  93. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/lens/readout.py +0 -0
  94. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  95. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/mhc.py +0 -0
  96. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/native.py +0 -0
  97. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/requests.py +0 -0
  98. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_capture/steering.py +0 -0
  99. {interp_engine-1.3.4 → interp_engine-1.3.6}/interp_engine/vllm_plugin.py +0 -0
  100. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/conftest.py +0 -0
  101. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/harness.py +0 -0
  102. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/model_expectations.yaml +0 -0
  103. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/synthetic_families.py +0 -0
  104. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_address.py +0 -0
  105. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_attn_config_tripwire.py +0 -0
  106. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_attn_probs_indexing.py +0 -0
  107. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_attn_scores.py +0 -0
  108. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_attn_z_gqa.py +0 -0
  109. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_autograd_support.py +0 -0
  110. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_bench_workloads.py +0 -0
  111. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_capability_refusals.py +0 -0
  112. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_capture_addressing.py +0 -0
  113. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_chat_compose.py +0 -0
  114. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_chat_formatters.py +0 -0
  115. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_chat_templates.py +0 -0
  116. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_core.py +0 -0
  117. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_cuda_preflight.py +0 -0
  118. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_doc_code_fences.py +0 -0
  119. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_eager_autograd.py +0 -0
  120. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_facts.py +0 -0
  121. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_family_points.py +0 -0
  122. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_gated_attn_out.py +0 -0
  123. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_head_contributions.py +0 -0
  124. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_hook_call_conventions.py +0 -0
  125. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_layer_kinds.py +0 -0
  126. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_load.py +0 -0
  127. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_logit_transform.py +0 -0
  128. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_mappers.py +0 -0
  129. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_mlp_internals.py +0 -0
  130. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_model_expectations.py +0 -0
  131. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_moe.py +0 -0
  132. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_multimodal_arch.py +0 -0
  133. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_new_models_gpu.py +0 -0
  134. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_no_chat_template.py +0 -0
  135. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_normalized_hook.py +0 -0
  136. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_notebook_stdout.py +0 -0
  137. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_packaging.py +0 -0
  138. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_parity_gpt2.py +0 -0
  139. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_per_layer_attn_dims.py +0 -0
  140. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_points_registry.py +0 -0
  141. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_protocol.py +0 -0
  142. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_published_benchmarks.py +0 -0
  143. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_qk_norm.py +0 -0
  144. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_qkv_layout.py +0 -0
  145. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_reasoning_spans.py +0 -0
  146. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_release.py +0 -0
  147. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_resid_mid.py +0 -0
  148. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_residual_basis.py +0 -0
  149. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_sandwich_norms.py +0 -0
  150. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_select.py +0 -0
  151. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_sliding_window_attn.py +0 -0
  152. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_small_models_gpu.py +0 -0
  153. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_static_dsv4_gpu.py +0 -0
  154. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_static_parity_gpu.py +0 -0
  155. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_static_warmup.py +0 -0
  156. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_steer_context.py +0 -0
  157. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_steer_math_parity.py +0 -0
  158. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_sync_loop.py +0 -0
  159. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_sync_parity.py +0 -0
  160. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_unified_free_functions.py +0 -0
  161. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_unresolved_families.py +0 -0
  162. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_capture_gpu.py +0 -0
  163. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_capture_scales.py +0 -0
  164. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_engine_loop.py +0 -0
  165. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_graph_path.py +0 -0
  166. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_graphs_on_gpu.py +0 -0
  167. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_hook_availability.py +0 -0
  168. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_hyper_connections.py +0 -0
  169. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_kv_isolation.py +0 -0
  170. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_new_points.py +0 -0
  171. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_only_families.py +0 -0
  172. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_plugin.py +0 -0
  173. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vllm_wire_grammar.py +0 -0
  174. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_vocabulary_boundary.py +0 -0
  175. {interp_engine-1.3.4 → interp_engine-1.3.6}/tests/test_worker_lens_capture_readout.py +0 -0
  176. {interp_engine-1.3.4 → interp_engine-1.3.6}/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.4
3
+ Version: 1.3.6
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
@@ -277,9 +277,26 @@ is therefore asked per side (`facts.has_qk_norm(attn, "q")`), because the pair-w
277
277
  "no" for such a layer and refusing the query norm on the key norm's absence declines a tensor the model
278
278
  plainly computes.
279
279
 
280
- Related config fields on this family that nothing reads yet: `attention_k_eq_v` (with
281
- `num_global_key_value_heads`) makes `v_proj` **None** on non-sliding layers and sets `value = key`;
282
- E2B and E4B both ship with it off.
280
+ - **`kv_heads_for_layer(layer)`** — the kv-head count moves with the head width, and in the opposite
281
+ direction: 4 against 16 on the 31B, 2 against 8 on the 26B, 1 against 8 on the 12B. transformers
282
+ 5.15 states this per layer (`per_layer_config`); below that the family spells it
283
+ `num_global_key_value_heads`, which `Gemma4TextAttention` applies to a layer only when
284
+ `attention_k_eq_v` is set **and** the layer is not sliding — so `ArchSpec` carries the flag beside
285
+ the count rather than reading the count on its own.
286
+
287
+ `attention_k_eq_v` is on for the 26B, 31B and 12B, and off for E2B and E4B. It is a second, separate
288
+ way for a layer to have no `v_proj`, and it does not overlap with KV sharing: those three SKUs set
289
+ `num_kv_shared_layers: 0`, so **every** one of their `full_attention` layers is built with
290
+ `v_proj = None` and takes the key projection's output as the value instead — `v_norm(k_proj(h))`
291
+ against a key of `rope(k_norm(k_proj(h)))`, so the two are the same projection read through
292
+ different norms, not the same tensor.
293
+
294
+ **What the engine does with that today is wrong in the quiet direction.** `_first_attr` treats a
295
+ present-but-`None` attribute as absent, so `ArchSpec.v_proj` returns `None` on those layers — which
296
+ is the signal it uses for "fused QKV, the caller must split" — on a family whose `fused_qkv` is
297
+ false. The layer needs a third answer: `value` is capturable, from `k_proj` and the `v_norm` after
298
+ it, and refusing or mis-addressing it is not the same as it not being there. Nothing exercises this
299
+ yet because the 31B has been swept without the value points.
283
300
 
284
301
  Two more ways the value side can differ from the query side, both of which produce a correctly shaped
285
302
  and completely wrong per-head split if you reshape by `head_dim`:
@@ -408,9 +425,20 @@ n_experts]` tensor that is plausible rather than obviously wrong.
408
425
  Mistral-4, dots1, GLM-4.5) or **every k-th layer sparse with a dense opt-out list** (`(layer + 1)
409
426
  % decoder_sparse_step == 0 and layer not in mlp_only_layers`; Qwen2/3-MoE, Qwen3-Next,
410
427
  Qwen3-VL/Omni). Expert counts have four live spellings (`num_local_experts`, `num_experts`,
411
- `n_routed_experts`, `moe_num_experts`), all read. A third idiom, an explicit `mlp_layer_types`
412
- pattern, wins over both — see the spelling warning in [block
428
+ `n_routed_experts`, `moe_num_experts`), all read, and the top-k has four of its own
429
+ (`num_experts_per_tok`, `experts_per_token`, `moe_topk`, `top_k_experts`). A third idiom, an
430
+ explicit `mlp_layer_types` pattern, wins over both — see the spelling warning in [block
413
431
  types](#block-types-classify-them-do-not-pattern-match-them).
432
+ - **Gemma-4 breaks the first sentence of this section.** Its sparse layers do not swap the MLP for an
433
+ expert bank: `Gemma4TextDecoderLayer` builds `self.mlp` on every layer and, where
434
+ `enable_moe_block` is set, hangs `self.router` and `self.experts` **beside** it on the block,
435
+ summing the two branches (`post_ffn_norm_1(mlp(x)) + post_ffn_norm_2(experts(x))`, both reading the
436
+ pre-feedforward residual). Three consequences, and `facts.dense_mlp_beside_experts` is the fact they
437
+ hang off. A sparse layer keeps a real neuron basis, so `mlp_pre`/`mlp_act` must stay served there —
438
+ which they are, because the LongCat guard makes the `mlp_pre` refusal require a router **on the MLP
439
+ module**, and Gemma-4's is a sibling. The parameter count of a sparse layer includes the dense MLP,
440
+ 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, which is the one that has no fix yet — see below.
414
442
 
415
443
  Which experts fired _is_ capturable, one level down: `router_logits` / `expert_weights` /
416
444
  `expert_indices` are three elements of the router module's own output tuple, resolved by
@@ -452,7 +480,22 @@ router_logits)`. `facts.ROUTER_OUTPUTS` holds the exceptions; `facts.assert_rout
452
480
 
453
481
  The MLP's neuron basis (`mlp_pre`, `mlp_pre_linear`, `mlp_act`) is refused on a sparse layer, since
454
482
  the projections live on the experts — often as one fused 3-D parameter per bank, with no per-expert
455
- module at all.
483
+ module at all. Gemma-4 is the exception noted above, and the refusal already lets it through for the
484
+ right reason rather than by luck: it is gated on the router being found *on the MLP module*, the same
485
+ guard that keeps LongCat's shortcut MoE from suppressing its two real feed-forwards.
486
+
487
+ **Open on Gemma-4's 26B**, both from the same fact — the routed branch is a sibling of `layer.mlp`
488
+ rather than a part of it:
489
+
490
+ - `mlp_out` taps `layer.mlp` and so returns the dense branch alone, missing the experts' contribution
491
+ entirely. Correctly shaped, wrong tensor, no tell. Either the point has to mean the summed
492
+ feed-forward here (which has no module boundary — the sum happens in the block's `forward`) or it
493
+ has to be refused with an explanation, and that choice has not been made.
494
+ - `arch.moe_router` looks for the router under `layer.mlp` and Gemma-4 hangs it on the block, so
495
+ `router_logits` / `expert_weights` / `expert_indices` do not resolve at all. When they do, note that
496
+ `Gemma4TextRouter` returns `(router_probabilities, top_k_weights, top_k_index)` — element 0 is
497
+ already softmaxed over all 128 experts, so it matches the default tuple *order* while not being
498
+ logits, and `assert_routing_shapes` cannot tell the difference because the width is right.
456
499
 
457
500
  In our shipping set only gpt-oss-20b is MoE (32 experts, top-4, no shared expert, every layer
458
501
  sparse). Its 3D batched expert weights matter only to code reading MLP weight matrices, and the
@@ -324,6 +324,11 @@ class ArchSpec:
324
324
  # elsewhere; use :meth:`head_dim_for_layer` / :meth:`kv_heads_for_layer`.
325
325
  per_layer_head_dim: tuple[int, ...] = ()
326
326
  per_layer_kv_heads: tuple[int, ...] = ()
327
+ # Gemma-4's ``num_global_key_value_heads`` and ``attention_k_eq_v``: the older spelling of the
328
+ # per-layer kv-head count, and the flag that decides whether it applies. See
329
+ # :func:`facts.kv_heads_for_layer`.
330
+ global_kv_heads: int | None = None
331
+ k_eq_v: bool = False
327
332
  # The width of one *value* head, where the family makes it differ from the q/k head (MiMo-V2,
328
333
  # DeepSeek). 0 means "same as the q/k head"; use :meth:`value_head_dim_for_layer`.
329
334
  v_head_dim: int = 0
@@ -914,12 +919,20 @@ class ArchSpec:
914
919
  def kv_heads_for_layer(self, layer: int) -> int:
915
920
  """How many key/value heads ``layer`` attends with.
916
921
 
917
- :attr:`n_kv_heads` for every family but Gemma-4-31B, whose full-attention layers carry 4 where
918
- its sliding ones carry 16. Prefer this anywhere ``k``, ``value`` or a k-side norm is reshaped
919
- per head: the model-wide number divides cleanly into the other layers' widths, so getting it
920
- wrong scrambles heads rather than raising. See :func:`facts.kv_heads_for_layer`.
922
+ :attr:`n_kv_heads` for every family but Gemma-4, whose full-attention layers carry 4 where its
923
+ sliding ones carry 16 on the 31B, and 2 against 8 on the 26B. Prefer this anywhere ``k``,
924
+ ``value`` or a k-side norm is reshaped per head: the model-wide number divides cleanly into
925
+ the other layers' widths, so getting it wrong scrambles heads rather than raising. See
926
+ :func:`facts.kv_heads_for_layer`.
921
927
  """
922
- return facts.kv_heads_for_layer(self.n_kv_heads, layer, self.per_layer_kv_heads)
928
+ return facts.kv_heads_for_layer(
929
+ self.n_kv_heads,
930
+ layer,
931
+ self.per_layer_kv_heads,
932
+ self.global_kv_heads,
933
+ self.quirks.hybrid_layer_types,
934
+ self.k_eq_v,
935
+ )
923
936
 
924
937
  def value_head_dim_for_layer(self, layer: int) -> int:
925
938
  """``layer``'s *value* head width -- what ``value`` and ``z`` are per head.
@@ -1160,6 +1173,8 @@ def resolve_arch(model: nn.Module, config: Any) -> ArchSpec:
1160
1173
  global_head_dim=model_facts.global_head_dim,
1161
1174
  per_layer_head_dim=model_facts.per_layer_head_dim,
1162
1175
  per_layer_kv_heads=model_facts.per_layer_kv_heads,
1176
+ global_kv_heads=model_facts.global_kv_heads,
1177
+ k_eq_v=model_facts.k_eq_v,
1163
1178
  v_head_dim=model_facts.v_head_dim,
1164
1179
  first_kv_shared_layer=model_facts.first_kv_shared_layer,
1165
1180
  layer_slots=layer_slots,
@@ -777,6 +777,35 @@ def mandatory_kv_cache_dtype(architectures: Sequence[str] | None) -> str | None:
777
777
  return None
778
778
 
779
779
 
780
+ # A multimodal model whose image tokens attend bidirectionally (Gemma 3, Gemma 4) has chunked
781
+ # multimodal input forced OFF by vLLM, and one whole item must then fit in a single batch or the
782
+ # engine refuses to start: "Chunked MM input disabled but max_tokens_per_mm_item (2496) is larger
783
+ # than max_num_batched_tokens (512)". vLLM raises its own floor for this, but only while defaulting
784
+ # `max_num_batched_tokens`, and clamps the result against `max_model_len` immediately after -- so a
785
+ # caller who sizes the window to its prompt, which capture does, defeats the fix and gets the raise.
786
+ #
787
+ # A floor rather than the number, because the number is not in the HF config: the per-item token
788
+ # count comes from vLLM's processing info for that family (2496 on Gemma 4, 256 on Gemma 3), and
789
+ # deriving it here would mean reimplementing each family's image-token math against a private API.
790
+ # Generous enough for every multimodal family served so far; if one ever needs more, vLLM's refusal
791
+ # names the value it wanted, which is the only honest way to revise this.
792
+ MM_MIN_BATCHED_TOKENS = 8192
793
+
794
+
795
+ def min_batched_tokens(config: Any) -> int | None:
796
+ """The ``max_num_batched_tokens`` this checkpoint cannot boot below, or ``None`` if unconstrained.
797
+
798
+ Companion to :func:`mandatory_kv_cache_dtype`, and here for the same reason: a boot requirement
799
+ every harness has to know is one the library should answer, not one each caller rediscovers from
800
+ a stack trace.
801
+
802
+ Keyed on the config being a multimodal wrapper rather than on the family, since the constraint
803
+ follows from having a non-text modality at all. Text-only checkpoints get ``None`` and keep
804
+ vLLM's own defaults, which is what makes this safe to ask about unconditionally.
805
+ """
806
+ return MM_MIN_BATCHED_TOKENS if config is not None and text_config(config) is not config else None
807
+
808
+
780
809
  # --- quantization and the backward pass ---------------------------------------
781
810
  #
782
811
  # Quantization is invisible to *capture*: hooks read activations, which transformers dequantizes to a
@@ -1072,7 +1101,15 @@ def logit_multiplier(cfg: Any) -> tuple[float | None, str]:
1072
1101
 
1073
1102
  # Four spellings of the routed-expert count, all in current use.
1074
1103
  _N_EXPERTS_FIELDS: tuple[str, ...] = ("num_local_experts", "num_experts", "n_routed_experts", "moe_num_experts")
1075
- _EXPERTS_PER_TOKEN_FIELDS: tuple[str, ...] = ("num_experts_per_tok", "experts_per_token", "moe_topk")
1104
+ # ``top_k_experts`` is Gemma-4's. Missing it did not merely lose a number: :func:`assert_routing_shapes`
1105
+ # guards its width check with ``and top_k``, so a zero here switched off the check that catches a
1106
+ # router tuple read in the wrong order -- on the newest MoE family, and silently.
1107
+ _EXPERTS_PER_TOKEN_FIELDS: tuple[str, ...] = (
1108
+ "num_experts_per_tok",
1109
+ "experts_per_token",
1110
+ "moe_topk",
1111
+ "top_k_experts",
1112
+ )
1076
1113
  _N_SHARED_EXPERTS_FIELDS: tuple[str, ...] = ("n_shared_experts", "num_shared_experts")
1077
1114
 
1078
1115
 
@@ -1228,6 +1265,27 @@ def block_types_name_the_feed_forward(cfg: Any) -> bool:
1228
1265
  return any(str(kind).lower() in SPARSE_MLP_LAYER_KINDS | DENSE_MLP_LAYER_KINDS for kind in kinds)
1229
1266
 
1230
1267
 
1268
+ def dense_mlp_beside_experts(cfg: Any) -> bool:
1269
+ """Whether a sparse layer *also* runs a dense MLP, whose output is added to the experts'.
1270
+
1271
+ Every other MoE family replaces the feed-forward with an expert bank, so a sparse layer has no
1272
+ dense MLP and no neuron basis. Gemma-4 does not: ``Gemma4TextDecoderLayer`` builds ``self.mlp`` on
1273
+ every layer, and where ``enable_moe_block`` is set it adds a routed branch *beside* it, combining
1274
+ them as ``hidden_states_1 + hidden_states_2`` -- two separately normed branches that both read the
1275
+ pre-feedforward residual.
1276
+
1277
+ Two consequences, and they pull in opposite directions, which is why this is its own fact. The
1278
+ parameter count of a sparse layer includes the dense MLP, so subtracting it (as the arithmetic
1279
+ does everywhere else) undercounts. And the dense neuron basis is real on a sparse layer here, so
1280
+ refusing ``mlp_act`` on one would be wrong.
1281
+
1282
+ Not the same thing as a shared expert: that lives inside the MoE block and is sized by
1283
+ ``moe_intermediate_size``, while this branch is the ordinary ``intermediate_size`` MLP that the
1284
+ checkpoint's dense siblings also carry.
1285
+ """
1286
+ return bool(config_attr(cfg, "enable_moe_block", False))
1287
+
1288
+
1231
1289
  def is_moe_layer(cfg: Any, layer: int) -> bool:
1232
1290
  """Whether ``layer``'s ``mlp`` is a sparse MoE block rather than a dense MLP.
1233
1291
 
@@ -1322,18 +1380,36 @@ def head_dim_for_layer(
1322
1380
  return head_dim if "sliding" in str(layer_types[layer]).lower() else global_head_dim
1323
1381
 
1324
1382
 
1325
- def kv_heads_for_layer(n_kv_heads: int, layer: int, per_layer: tuple[int, ...] = ()) -> int:
1383
+ def kv_heads_for_layer(
1384
+ n_kv_heads: int,
1385
+ layer: int,
1386
+ per_layer: tuple[int, ...] = (),
1387
+ global_kv_heads: int | None = None,
1388
+ layer_types: tuple[str, ...] | None = None,
1389
+ k_eq_v: bool = False,
1390
+ ) -> int:
1326
1391
  """How many key/value heads ``layer`` attends with.
1327
1392
 
1328
- One number for the whole model on every family but Gemma-4-31B, whose full-attention layers carry
1329
- 4 where its sliding ones carry 16 -- and whose config says so only through ``per_layer_config``,
1330
- so there is no older spelling to fall back to and ``per_layer`` empty means the model-wide value
1331
- is the answer. Getting it wrong is not a shape error (see :func:`effective_kv_heads`): the
1332
- reshape succeeds into a head count the layer does not have.
1393
+ One number for the whole model on every family but Gemma-4, whose full-attention layers carry 4
1394
+ where its sliding ones carry 16 on the 31B (2 against 8 on the 26B, 1 against 8 on the 12B).
1395
+ Getting it wrong is not a shape error (see :func:`effective_kv_heads`): the reshape succeeds into
1396
+ a head count the layer does not have.
1397
+
1398
+ Two spellings, as with :func:`head_dim_for_layer`. ``per_layer`` is the table a heterogeneous
1399
+ config states outright (transformers >= 5.15) and wins where it exists. ``global_kv_heads`` is the
1400
+ older ``num_global_key_value_heads``, and it applies only where the modeling code applies it:
1401
+ ``Gemma4TextAttention`` takes it when ``attention_k_eq_v and not is_sliding``, and the model-wide
1402
+ count otherwise. The ``k_eq_v`` gate is not decoration -- E2B and E4B set that flag false and
1403
+ carry ``num_global_key_value_heads: null``, so reading the field unconditionally would be wrong
1404
+ the moment a checkpoint states one without switching the flag on.
1333
1405
  """
1334
1406
  if per_layer and layer < len(per_layer):
1335
1407
  return per_layer[layer]
1336
- return n_kv_heads
1408
+ if not (k_eq_v and global_kv_heads):
1409
+ return n_kv_heads
1410
+ if not layer_types or layer >= len(layer_types):
1411
+ return n_kv_heads
1412
+ return n_kv_heads if "sliding" in str(layer_types[layer]).lower() else global_kv_heads
1337
1413
 
1338
1414
 
1339
1415
  # A scalar the attention module multiplies its value vectors by *after* the projection, so the tensor
@@ -1747,6 +1823,15 @@ class ModelFacts:
1747
1823
  # prefer these; see :func:`per_layer_ints`.
1748
1824
  per_layer_head_dim: tuple[int, ...] = ()
1749
1825
  per_layer_kv_heads: tuple[int, ...] = ()
1826
+ # Gemma-4's kv-head count for its ``full_attention`` layers, which it states as
1827
+ # ``num_global_key_value_heads`` and applies only when ``attention_k_eq_v`` is on. The older
1828
+ # spelling of what ``per_layer_kv_heads`` carries on transformers >= 5.15; ask
1829
+ # :meth:`kv_heads_for_layer`.
1830
+ global_kv_heads: int | None = None
1831
+ # Gemma-4's ``attention_k_eq_v``: on its ``full_attention`` layers the value tensor *is* the key
1832
+ # projection's output (differently normed and un-RoPE'd), and those layers are built with
1833
+ # ``v_proj = None``. True on 26B/31B/12B, false on E2B/E4B.
1834
+ k_eq_v: bool = False
1750
1835
  # The width of one value head, which differs from ``head_dim`` on MiMo-V2 and the DeepSeek MLA
1751
1836
  # families. See :func:`value_head_dim`; ``value`` and ``z`` are this wide per head, not ``head_dim``.
1752
1837
  v_head_dim: int = 0
@@ -1759,6 +1844,9 @@ class ModelFacts:
1759
1844
  n_experts: int = 0
1760
1845
  experts_per_token: int = 0
1761
1846
  n_shared_experts: int = 0
1847
+ # Gemma-4: a sparse layer keeps its dense MLP and adds the routed branch beside it, so the two
1848
+ # coexist rather than the experts replacing the MLP. See :func:`dense_mlp_beside_experts`.
1849
+ dense_mlp_beside_experts: bool = False
1762
1850
  # Layers whose ``mlp`` is a sparse block. Precomputed here because the branch needs config
1763
1851
  # fields that the vLLM client does not carry across the process boundary.
1764
1852
  moe_layers: tuple[int, ...] = ()
@@ -1842,8 +1930,10 @@ class ModelFacts:
1842
1930
  return head_dim_for_layer(self.head_dim, self.global_head_dim, self.layer_types, layer, self.per_layer_head_dim)
1843
1931
 
1844
1932
  def kv_heads_for_layer(self, layer: int) -> int:
1845
- """``layer``'s kv-head count. Prefer this to :attr:`n_kv_heads`, which is wrong on Gemma-4-31B."""
1846
- return kv_heads_for_layer(self.n_kv_heads, layer, self.per_layer_kv_heads)
1933
+ """``layer``'s kv-head count. Prefer this to :attr:`n_kv_heads`, which is wrong on Gemma-4."""
1934
+ return kv_heads_for_layer(
1935
+ self.n_kv_heads, layer, self.per_layer_kv_heads, self.global_kv_heads, self.layer_types, self.k_eq_v
1936
+ )
1847
1937
 
1848
1938
  def value_head_dim_for_layer(self, layer: int) -> int:
1849
1939
  """``layer``'s *value* head width, for reshaping ``value`` and ``z``. See :func:`value_head_dim`.
@@ -2198,11 +2288,14 @@ def resolve_facts(config: Any, *, n_layers_fallback: int | None = None) -> Model
2198
2288
  global_head_dim=_first_int(cfg, ("global_head_dim",)) or None,
2199
2289
  per_layer_head_dim=per_layer_ints(cfg, "head_dim", n_layers),
2200
2290
  per_layer_kv_heads=per_layer_ints(cfg, "num_key_value_heads", n_layers),
2291
+ global_kv_heads=_first_int(cfg, ("num_global_key_value_heads",)) or None,
2292
+ k_eq_v=bool(config_attr(cfg, "attention_k_eq_v", False)),
2201
2293
  v_head_dim=value_head_dim(cfg, head_dim),
2202
2294
  first_kv_shared_layer=first_kv_shared_layer(cfg, n_layers),
2203
2295
  n_experts=n_experts(cfg),
2204
2296
  experts_per_token=_first_int(cfg, _EXPERTS_PER_TOKEN_FIELDS) or 0,
2205
2297
  n_shared_experts=_first_int(cfg, _N_SHARED_EXPERTS_FIELDS) or 0,
2298
+ dense_mlp_beside_experts=dense_mlp_beside_experts(cfg),
2206
2299
  moe_layers=tuple(layer for layer in range(n_layers) if is_moe_layer(cfg, layer)),
2207
2300
  logit_multiplier=multiplier,
2208
2301
  logit_multiplier_source=multiplier_source,
@@ -315,6 +315,11 @@ def read_attn_dims(hf_model_id: str, trust_remote_code: bool = True) -> dict[str
315
315
  # Empty on a config that describes one shape for the whole model.
316
316
  "per_layer_head_dim": model_facts.per_layer_head_dim,
317
317
  "per_layer_kv_heads": model_facts.per_layer_kv_heads,
318
+ # The older spelling of that kv-head count (`num_global_key_value_heads`), and the flag
319
+ # Gemma-4 gates it on. Carried so a transformers below 5.15 -- which states no per-layer
320
+ # table -- still gets the wide layers' count right rather than the sliding one's.
321
+ "global_kv_heads": model_facts.global_kv_heads,
322
+ "k_eq_v": model_facts.k_eq_v,
318
323
  # From here on a layer reuses an earlier layer's keys/values and has no v_proj to hook, so
319
324
  # `value`/DFA is unavailable there (Gemma-4). None when every layer projects its own.
320
325
  "first_kv_shared_layer": model_facts.first_kv_shared_layer,
@@ -407,7 +412,12 @@ def kv_heads_for_layer(dims: dict[str, Any], layer: int) -> int:
407
412
  disagreement between the two is worth saying out loud.
408
413
  """
409
414
  return facts.kv_heads_for_layer(
410
- int(dims.get("n_kv_heads") or 0), layer, tuple(dims.get("per_layer_kv_heads") or ())
415
+ int(dims.get("n_kv_heads") or 0),
416
+ layer,
417
+ tuple(dims.get("per_layer_kv_heads") or ()),
418
+ dims.get("global_kv_heads"),
419
+ tuple(dims.get("layer_types") or ()),
420
+ bool(dims.get("k_eq_v")),
411
421
  )
412
422
 
413
423
 
@@ -551,7 +561,10 @@ def recompute_attn_from_payloads(payloads, layers, dims, tensor_parallel_size: i
551
561
  # one is not a claim about this layer -- it disagrees with Gemma-4's wide layers by design, and
552
562
  # on an MLA or tensor-parallel capture it describes something other than the width in hand --
553
563
  # so passing it as `expected` would turn a working recompute into a raise.
554
- stated_kv = kv_heads_for_layer(dims, int(layer)) if dims.get("per_layer_kv_heads") else None
564
+ states_per_layer_kv = bool(dims.get("per_layer_kv_heads")) or bool(
565
+ dims.get("k_eq_v") and dims.get("global_kv_heads")
566
+ )
567
+ stated_kv = kv_heads_for_layer(dims, int(layer)) if states_per_layer_kv else None
555
568
  n_kv_heads = _heads_in(k, head_dim, "k", int(layer), expected=stated_kv)
556
569
  scores = recompute_attn_scores(
557
570
  q,
@@ -1121,6 +1134,7 @@ class VLLMModel:
1121
1134
  ),
1122
1135
  n_layers=self.num_hidden_layers,
1123
1136
  tensor_parallel_size=self.tensor_parallel_size,
1137
+ min_n=facts.min_batched_tokens(cfg) or 0,
1124
1138
  )
1125
1139
  if fitted != max_n:
1126
1140
  logger.warning("lowering max_num_batched_tokens %s -> %s so static buffers fit", max_n, fitted)
@@ -446,9 +446,12 @@ def _config_weight_bytes(config: Any) -> int:
446
446
  routed_mlp = f.n_experts * 3 * f.d_model * moe_inter
447
447
  shared_mlp = f.n_shared_experts * 3 * f.d_model * moe_inter
448
448
  n_sparse = len(f.moe_layers) if f.n_experts else 0
449
+ # Gemma-4's sparse layers keep their dense MLP beside the experts, so it is paid on every layer.
450
+ # It stays at the model dtype either way -- it is not part of the quantized routed bank.
451
+ n_dense = f.n_layers if f.dense_mlp_beside_experts else f.n_layers - n_sparse
449
452
  embeddings = f.vocab_size * f.d_model * (1 if f.tied_embeddings else 2)
450
453
  stored = _storage_dtype_bytes(config)
451
- at_model_dtype = f.n_layers * attn + (f.n_layers - n_sparse) * dense_mlp + n_sparse * shared_mlp + embeddings
454
+ at_model_dtype = f.n_layers * attn + n_dense * dense_mlp + n_sparse * shared_mlp + embeddings
452
455
  return int(at_model_dtype * stored + n_sparse * routed_mlp * _expert_dtype_bytes(config, stored))
453
456
 
454
457
 
@@ -541,6 +544,7 @@ def fit_max_num_batched_tokens(
541
544
  kv_width: int,
542
545
  n_layers: int,
543
546
  tensor_parallel_size: int = 1,
547
+ min_n: int = 0,
544
548
  ) -> int:
545
549
  """Largest capture size at or below ``max_n`` whose static buffers fit, or raise.
546
550
 
@@ -558,14 +562,20 @@ def fit_max_num_batched_tokens(
558
562
  The 1024 floor is for ``"auto"`` / default ``max_n``: vLLM will not usefully serve with a
559
563
  tiny batch. A caller who already passed a smaller ``max_n`` (chunked-prefill tests) keeps it
560
564
  when it fits.
565
+
566
+ ``min_n`` raises that floor to a size the *engine* will not start below, which is a different
567
+ kind of limit and has to outrank the fit: shrinking under it trades an out-of-memory error for a
568
+ refusal to boot, and the refusal comes from vLLM's scheduler talking about multimodal items,
569
+ which reads as anything but a static-buffer problem. Above the floor it changes nothing --
570
+ ``fitted == asked`` stays the common case. See :func:`interp_engine.facts.min_batched_tokens`.
561
571
  """
562
572
  graph_fudge = 3 * 1024**3
563
573
  tp = max(int(tensor_parallel_size), 1)
564
574
  min_kv = max(int(n_layers), 1) * max(int(max_model_len), 1) * max(int(kv_width), 1) * 2
565
575
  budget = int(gpu_memory_utilization * device_memory) - int(weight_bytes) // tp - graph_fudge - min_kv
566
576
  asked = int(max_n)
567
- floor = min(1024, asked)
568
- candidates = [asked] + [s for s in _CAPTURE_SIZES if s < asked]
577
+ floor = max(min(1024, asked), int(min_n))
578
+ candidates = [max(asked, floor)] + [s for s in _CAPTURE_SIZES if s < asked]
569
579
  for n in candidates:
570
580
  if n < floor:
571
581
  continue
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interp-engine"
3
- version = "1.3.4"
3
+ version = "1.3.6"
4
4
  description = "A fast, standardized, and easy to use interpretability engine."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -221,6 +221,33 @@ def test_vram_check_lowers_max_n_instead_of_ooming():
221
221
  assert fitted >= 1024
222
222
 
223
223
 
224
+ def test_vram_check_will_not_shrink_below_an_engine_boot_floor():
225
+ """A multimodal prefix-LM will not start below one whole image, so shrinking under it is no fit.
226
+
227
+ Better to refuse here, naming the buffers, than to hand back a size vLLM rejects later with a
228
+ scheduler error about multimodal items that reads as anything but a static-buffer problem.
229
+ """
230
+
231
+ def fit(device_gib: int, **extra: int) -> int:
232
+ return fit_max_num_batched_tokens(
233
+ n_sites=80,
234
+ width=5120,
235
+ max_n=16384,
236
+ device_memory=device_gib * 1024**3,
237
+ gpu_memory_utilization=0.95,
238
+ weight_bytes=10 * 1024**3,
239
+ max_model_len=4096,
240
+ kv_width=kv_cache_width(d_model=5120),
241
+ n_layers=32,
242
+ **extra,
243
+ )
244
+
245
+ assert fit(20) == 4096 # left alone, this card shrinks under the floor
246
+ with pytest.raises(ValueError, match="do not fit even at max_num_batched_tokens=8192"):
247
+ fit(20, min_n=8192)
248
+ assert fit(24, min_n=8192) == 8192 # a floor it can meet changes nothing
249
+
250
+
224
251
  def test_vram_check_keeps_a_caller_max_n_below_the_1024_floor():
225
252
  """Chunked-prefill tests pass max_num_batched_tokens=32; skipping that candidate used to raise."""
226
253
  fitted = fit_max_num_batched_tokens(
File without changes
File without changes
File without changes