Repository navigation
Conversation
Zamba2 hybrid layers now map mixer/norm into their wrapped Mamba-2 decoder, whose output is the layer's only residual write. RecurrentGemma blocks bridge their pre-norms, temporal block (as a passthrough mixer) and gated MLP; the embedding hook carries HF's bfloat16 normalizer scale and d_mlp is the real MLP width (intermediate_size // 2).
pablocs116
left a comment
There was a problem hiding this comment.
Thanks for this! As the author of the original RecurrentGemma adapter (#1483), this looks right to me. Splitting each layer into ln1/mixer/ln2/mlp is the right granularity now that the passthrough mixer handles the RG-LRU vs. local-attention difference, and the d_mlp fix is correct: RecurrentGemmaMlp sets intermediate_size = config.intermediate_size // 2, so the old value was 2x the real width.
Checked locally at 5feed31 (CPU, fp32):
- The PR's tests (
test_recurrent_gemma_adapter.py,test_zamba2_adapter.py,test_ssm_hybrid_resid_decomposition.py, integrationtest_zamba2_adapter.py): 150 passed, 16 skipped. - An independent script on a tiny random model (hidden_size 80, block types recurrent/recurrent/attention, 4 layers), against a deep-copied HF reference:
cfg.d_mlp == intermediate_size // 2 == mlp_block.gate_proj.out_features- logits match HF (max |diff| 7e-7), and
resid_postmatches HFhidden_statesper layer hook_embed == embed_tokens(x) * model.normalizerexactly (8.9375, the bf16 value, not sqrt(80))resid_pre + mixer_out == resid_midandresid_mid + mlp_out == resid_poston every layer, recurrent and attentiondecompose_residandget_full_resid_decompositionsum to the final residual- edits are in residual units: zeroing
hook_embedmatches zeroing HF's embedding, and zeroingblocks.1.hook_mlp_outmatches zeroing that layer's MLP - greedy
generatematches HF
One small, non-blocking note: the _boot helper in test_ssm_hybrid_resid_decomposition.py doesn't call adapter.prepare_model, so the new prepare_model (reading model.normalizer) isn't exercised by the tests. It's redundant with _embedding_normalizer unless the buffer and the config disagree, so this is just a coverage gap. I checked it separately by calling prepare_model the way boot_transformers does.
Also worth a line in the release notes: hook_embed and cfg.d_mlp change values for existing RecurrentGemma users (both for the better).
SBrouck
left a comment
There was a problem hiding this comment.
Thanks! @pablocs116 covered RecurrentGemma, so I focused on the Zamba2 side, which builds on the mixer slots from #1987.
The redirect renames norm / mixer in set_original_component. Your test starts with a Mamba layer, so I wanted to make sure a Mamba layer that comes after a hybrid one keeps .mamba. I checked locally at 5feed31 with an independent tiny Zamba2 that starts with a hybrid layer (hybrid, mamba, hybrid, mamba, hybrid, batch 2):
- Each block's
mixeris the right HF module (samein_projweights):mamba_decoder.mambaon hybrid layers,mambaon the others. resid_post - resid_pre == mixer.hook_outon every layer (1e-7 or less).decompose_resid()andget_full_resid_decomposition()(bothexpand_neurons) sum to the final residual (8e-8 or less), andssm_layers()returns all five layers.- Zeroing
blocks.2.mixer.hook_outgives exactly the same logits as zeroingmamba_decoder.mamba's output in an untouched HF copy. - Logits match HF exactly.
I couldn't download the 1.2B checkpoint here, so I haven't run the slow integration test_zamba2_adapter.py.
jlarson4
left a comment
There was a problem hiding this comment.
@flyingdoubleG This bug fix looks good! I was able to run all tests and can confirm everything turns up green on my end. Approved for inclusion in the next release
Description
Follow-up to #1986 / #1987. On Zamba2 and RecurrentGemma, the residual-decomposition APIs still raise the errors from #1986, because some blocks expose no sublayer at all:
decompose_resid()get_full_resid_decomposition(),stack_head_results()KeyError: 'blocks.1.hook_attn_out'KeyError: 'blocks.1.attn.hook_z'KeyError: 'blocks.0.hook_attn_out'KeyError: 'blocks.0.attn.hook_z'Both blocks declare no variant slot, so the cache falls back to treating them as attention blocks.
Zamba2.
Zamba2HybridLayerhas no top-level.mamba/.input_layernorm, so the optionalmixerandnormwere skipped on hybrid layers. The hybrid layer wraps a regularZamba2MambaDecoderLayeras.mamba_decoder, and the shared transformer's output (after the per-layer.linear) is only added to that layer's Mamba input:So the Mamba-2 output is the only residual write on every layer. A small
Zamba2BlockBridgepointsmixer/normatmamba_decoder.mamba/mamba_decoder.input_layernormwhen the HF layer has a.mamba_decoder. Hybrid layers then get the samemixerhooks as the Mamba layers (hook_mixer_out,mixer.in_proj, …), and the decompositions get a{l}_mixer_outrow for them. On a hybrid layernorm.hook_inis the residual plus the shared transformer's output.RecurrentGemma. The adapter wrapped each decoder layer whole, with only
hook_resid_pre/hook_resid_post. Every layer has the same layout, though:temporal_pre_norm→temporal_block→ residual add →channel_pre_norm→mlp_block→ residual add. Only the temporal block differs (RG-LRU or local attention). This PR bridges the two norms asln1/ln2, the temporal block as a passthroughmixer(a plainGeneralizedComponent;cfg.layers_block_typesays which kind it is), and the MLP as aGatedMLPBridge. The block getshook_resid_mid,hook_mixer_in/hook_mixer_outandhook_mlp_out. Decompositions label the temporal block{l}_attn_outon attention layers and{l}_mixer_outon recurrent ones, followed by{l}_mlp_out, andexpand_neurons=Trueworks.Two config fixes were needed for the sums to match:
model.normalizerafterembed_tokens, and that buffer is bfloat16 (sqrt(2560)is stored as 50.5).hook_embedwas the unscaled embedding, so it did not equalblocks.0.hook_resid_pre. The embedding bridge now getsoutput_contribution_scaleset to the normalizer, as on Granite. It is computed fromd_modeland read from the loaded model's buffer inprepare_model. This matches Gemma-2/3, whosehook_embedis already the scaled embedding.RecurrentGemmaMlpisintermediate_size // 2wide, butcfg.d_mlpwasintermediate_size. That gaveget_full_resid_decomposition(expand_neurons=True)twice as many labels as rows. The adapter now setsd_mlp = intermediate_size // 2.On the tiny random models of the new tests, the max abs error of each sum against the residual stream:
devdecompose_resid()KeyErrorget_full_resid_decomposition(expand_neurons=True)KeyErrordecompose_resid()KeyErrordecompose_resid(mlp_input=True)KeyErrorget_full_resid_decomposition(expand_neurons=True)KeyErrorLogits still match HF exactly on both (the bridge delegates the layer forwards), and greedy
generatematches HF token for token.User-visible changes:
.mixer/.normand their hooks, andcache.ssm_layers()includes them.ln1,mixer,ln2andmlp;hook_embedis the scaled embedding;cfg.d_mlpis the real MLP width.applicable_phasesis unchanged for both.RWKV-4 also raises
KeyErrorin these APIs. It is not changed here: HF halves the residual stream everyrescale_everylayers between blocks (soblocks.{l}.hook_resid_post != blocks.{l+1}.hook_resid_pre), and layer 0 appliespre_lnto the embedding, so it needs more than a slot mapping.Type of change
Checklist:
Tests:
tests/integration/model_bridge/test_ssm_hybrid_resid_decomposition.pygets tiny Zamba2 (mamba, hybrid, mamba, hybrid) and RecurrentGemma (recurrent, recurrent, attention, recurrent,hidden_size=80so the bfloat16 normalizer differs fromsqrt(80)) factories. Both run through the existing sum, partition, label, head and LN + projection checks. RecurrentGemma is added to themlp_input=Truecheck. There are also two direct tests: on Zamba2,resid_post - resid_pre == mixer.hook_outin every layer; on RecurrentGemma,hook_embed == blocks.0.hook_resid_preandresid_pre + hook_mixer_out == resid_mid.d_mlp.tests/integration/model_bridge/test_zamba2_adapter.pyasserted that hybrid layers have no mixer. Those two tests now check that.mixer/.normare the wrapped decoder's modules, and that the mixer output is the hybrid layer's residual write. I could not run that file locally because it downloads Zamba2-1.2B.On
dev, 37 of the new and updated tests intest_ssm_hybrid_resid_decomposition.pyandtest_recurrent_gemma_adapter.pyfail, and the Zamba2 unit test file cannot importZamba2BlockBridge. All 129 tests in these three files pass here.Local runs (Windows, CPU, torch 2.9.1,
--jaxtyping-packages=transformer_lens,beartype.beartype,PYTHONUTF8=1):test_ssm_hybrid_resid_decomposition.pyand the Zamba2 / RecurrentGemma unit tests: all pass.tests/unit -m "not slow": 8180 passed, 14 failed. The same 14 fail ondevin my environment (ten Gemma 3n adapter tests need the optionaltimm, threetest_sparse_probing.pytests check optimizer stop reasons, andtest_faithfulness_recovery_is_graded_not_binaryis [Bug Report] test_faithfulness_recovery_is_graded_not_binary passes or fails on the sign of float32 rounding #1993).test_ssm_effective_attention_dispatch.py: 8 Mamba / Granite / NemotronH SSD tests fail the same way ondevhere (nomixer.conv1d.hook_outin the cache), which I take to be my local transformers version.expand_neurons=Trueon its MoE, as before), Mamba and FalconMamba.black(the locked 23.12.1),isort --check-onlyandmypyon the changed files pass.This was prepared with AI assistance (Claude); I reviewed and tested the change.