Repository navigation
perf(qwen3-next): nested scan over hybrid attention with per-layer remat - #4964
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for Qwen3-Next scanned decoder blocks using hierarchical nested scans, separating linear-attention layers from the global full-attention layer to optimize rematerialization. It also refactors NNX scanning to avoid stacking read-only parameters. The review feedback correctly identifies that when kv_caches is provided, the implementation falls back to the default path and causes double rematerialization. To resolve this, the reviewer suggests updating _apply_qwen3_next_scanned_blocks to accept and pass kv_caches directly to _apply_layers_sequentially while keeping block-level rematerialization disabled.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
Pushed 0857e11 addressing the review — the KV-cache path now keeps /gemini review |
There was a problem hiding this comment.
Code Review
This pull request adds support for Qwen3-Next scanned decoder blocks in MaxText, implementing hierarchical nested scans (local linear-attention layers and a global full-attention layer) and adding corresponding unit tests. The feedback suggests grouping the jax.experimental.xla_metadata import with other third-party imports in qwen3.py to comply with PEP 8, and optimizing the external KV cache path in qwen3.py to avoid processing read-only parameters, mirroring the optimization made in nnx_scan.py.
9c3bae5 to
2968036
Compare
Qwen3-Next's scannable block instantiated its four heterogeneous sub-layers flat and ran them in a Python loop, so the whole block was rematerialized as a unit and all four sub-layers' activations were live together. Restructure it along the same lines as Gemma4: stack the three linear-attention (GatedDeltaNet) layers and run them through nnx_scan.apply_scanned_layers, and run the single full-attention layer inside a trip-count-one jax.lax.scan that acts as an XLA scheduling barrier. Each sub-layer is now rematerialized on its own, so the outer apply skips block-level remat. Also stop apply_scanned_layers from returning parameters out of its scan body: lax.scan stacks every carry output, so returning full state made XLA materialize a second copy of the stacked layer weights on every call. Add full_attention_layer_offset to place the full-attention layer within each cycle; it defaults to -1 (last in the cycle), reproducing the previous (layer_idx + 1) % cycle == 0 schedule.
Asserts the block splits a cycle into a stacked local scan plus one global layer, that the local params really are stacked along param_scan_axis, that the nested scans reproduce a sequential unroll of the same weights, and that a block whose full-attention layer is not last is rejected.
The scanned-block apply was guarded on `kv_caches is None`, so an inference call fell through to the generic branch and rematerialized the whole block on top of the block's own per-layer remat. Route Qwen3-Next through `_apply_qwen3_next_scanned_blocks` unconditionally and regroup the flat per-layer cache list into per-block tuples before the scan, writing it back afterwards -- the `prepare_kv_caches_for_scan` / `update_kv_caches_after_scan` pair Gemma4 already uses. The scan runs over blocks, so the flat list would otherwise hand block i only `kv_caches[i]`. Training (kv_caches=None) is unaffected: the grouping helpers pass None through, so that path is byte-identical to before. Move the scannable-block tests out of their own file into tests/unit/nnx_decoders_test.py alongside TestGemma4ScannableBlock, and add coverage for both halves of the KV path.
…path Parameters are read-only in the forward pass, so stacking them back into self.local_layers allocated a second copy of every layer weight. Collect only the non-Param state, which also removes the param_scan_axis round-trip: unlike Params, non-Param state is stacked on axis 0 by nnx_scan.create_scanned_layers. Same reasoning as the scan-body change in nnx_scan.apply_scanned_layers, applied to the static-unroll path. Also move the xla_metadata import into the jax group (PEP 8).
`apply_scanned_layers` dropped every `nnx.Param` from the scan output to avoid materializing a second copy of the stacked layer weights. That also discarded parameters created *while tracing the body*, most notably the `nnx.LoRAParam` adapters Qwix materializes, which are an `nnx.Param` subclass -- LoRA setup then failed with "LoRA module path matched target modules, but nnx.LoRAParam is still missing". Only drop the params that were fed in as scan inputs; anything else the body produces still leaves the scan.
… layout
The scannable-block rewrite replaced the per-cycle-position `layer_{i}`
params with a nested layout -- `layers-local_layers-*` (an inner scan over
the linear-attention layers, nested in the block scan) and
`layers-global_layer-*` (the block scan only). The HF param mapping still
described the old layout, so conversion produced keys the model does not
have. Rewrite the scanned branch of the qwen3-next mapping (and its hooks)
for the new layout.
Routed-expert weights are expert-stacked *inside* the nested scan, giving
`[expert][block][local]` -- a third stacked axis, which the conversion
helpers did not support. Generalize `_build_multi_axis_stacked_tensor` and
the inverse in `process_maxtext_param` from two axes to N, with the axis
placement factored into a shared `stacked_axes` helper, and broaden the
nested-scan detection from `scanned_blocks-local_layers` (gemma4's module
name) to `-local_layers` so qwen3-next's `layers-local_layers` matches too.
Also fix the Linen `_apply_qwen3_next_scanned_blocks`: its broadcast-arg
spec had been copied from gemma4 and no longer matched
`Qwen3NextScannableBlock.__call__`, and it named the scanned module
`scanned_blocks` where the pure-NNX decoder uses `layers`. Both decoder
paths now emit byte-identical parameter names and shapes, so one mapping
serves both.
… test Address the follow-ups from the scanned Qwen3-Next block PR that fit in it: - The pure-NNX decoder built `num_decoder_layers // cycle` blocks and stopped there, so a layer count that is not a multiple of the cycle silently dropped its trailing layers. They now go into a `layers_remainder` block that is built and applied like Gemma4's. - The Linen decoder spelled its remainder out as individual `Qwen3NextDecoderLayerToLinen` layers, which named parameters differently from the NNX side. It now uses a `Qwen3NextScannableBlockToLinen` named `layers_remainder` too, so one checkpoint mapping still serves both decoders. `TestQwen3NextDecoderParity` checks the two parameter trees match, with and without a remainder. - Gemma4 and Qwen3-Next now share `_apply_remainder_block` instead of keeping two copies of the same split/checkpoint/merge dance. - Deleted the unreachable third copy of `_build_multi_axis_stacked_tensor` and friends in checkpoint_conversion/utils/utils.py; nothing calls that module's `_get_hf_loading_function`. Decode coverage: `deepseek_decode_consistency_test.py` gains three Qwen3-Next cases (unscanned, scanned, scanned with a remainder). Both attention kinds in the hybrid stack carry state across a decode step, so a block that mis-threads its sub-layers' state diverges from the teacher-forced rollout.
The flag was added alongside the scanned block, but the block rejects every value it can take other than the default: the local layers are scanned before the global one, so the full-attention layer has to be last in the cycle. Any other offset raised, or -- on the unscanned path, which does not go through the block -- silently built a different architecture from the scanned path. Stock Qwen3-Next puts full attention last, no config set the flag, and nothing tested a non-default value working. Go back to deriving the position from the cycle, and keep the block's validation, which still earns its place: a block starting off a cycle boundary straddles two periods and would reorder the model.
ceedbea to
4e94068
Compare
The tests added for the nested-scan block dominated the unit-test suite runtime. Their cost is Python-side op dispatch, not tensor size, so it comes down to how many sub-layers run outside a scan and how many blocks are built. - build the scannable block once per class instead of once per test; - jit the sequential-unroll reference, which was dispatching four sub-layers op by op (25s -> 2.6s); - fold the stacked-params and no-remainder-block assertions into the structural tests that already build the same object; - drop the block-level external-kv test: the decoder-level one covers the same path end to end, including the local...global ordering; - run the kv-cache regrouping test with a cycle of 2 and 4 layers. That path is a static unroll, so its cost is per sub-layer, and two blocks is the minimum that can regroup wrongly. Qwen3-Next tests go from 121s to 55s; nothing they asserted is lost.
…yout
`MuonDimensionTest::test_model_integration_qwen3-next-80b-a3b` landed on main
after this branch was cut, so it only ran once the two were merged, and it
failed on both `tpu-unit (2)` and `tpu-pathways-unit (2)`.
Its hardcoded tree still describes the flat `decoder.layers.layer_{0..3}`
block. This branch splits the cycle into a stacked `local_layers` scan plus a
single `global_layer`, so the key names no longer match.
Only the names change. `transform_logic` maps a parameter's path to its
dimension numbers and never looks at shapes, and the two scan axes are
inserted at `param_scan_axis` and `param_scan_axis + 1`, so they land between
axis 0 and the trailing negative axes -- for example
`local_layers/attention/in_proj_qkvz/kernel` is
`(embed_attn, layers, local_layers, gdn_head)`, where `mdn((0,), (-1,))` still
reduces over `embed_attn` and outputs over `gdn_head`. Muon treats the two
extra axes as batch axes and vmaps over them. main's own nested-scan
precedent agrees: `_DEEPSEEK4_MLP_SCANNED` is defined as `_DEEPSEEK4_MLP`.
The other 13 parameterized models are unaffected; `MuonDimensionTest` passes
14/14 on a v5p-8.
Description
Qwen3-Next has a hybrid decoder stack: each period of
inhomogeneous_layer_cycle_interval(4) layers is three linear-attention (GatedDeltaNet) layers followed by one full-attention layer.Qwen3NextScannableBlockinstantiated those four heterogeneous sub-layers flat (layer_0…layer_3) and ran them in a Python loop, so the whole block was rematerialized as a single unit and all four sub-layers' activations were live at once.This PR restructures the block along the same lines as
Gemma4ScannableBlock:nnx_scan.apply_scanned_layers;jax.lax.scan, which acts as an XLA scheduling barrier (skip-simplify-while-loops_trip-count-one);apply_internal_remat=True), so the outer apply passesskip_block_remat=Trueand nothing is rematerialized twice.A second, independent fix in
nnx_scan.apply_scanned_layers: the scan body returnednnx.state(current_layer)— the full state, parameters included.jax.lax.scanstacks every per-iteration output, so returning parameters made XLA materialize a second copy of the stacked layer weights on every call. The body now returns only the state that was not fed in as a scan input.Dropping every
nnx.Paramwould be too blunt. Qwix materializes LoRA adapters while tracing the scan body, andnnx.LoRAParamis annnx.Paramsubclass, so they would go out with the carried weights and LoRA setup would fail withLoRA module path matched target modules, but nnx.LoRAParam is still missing. Diffing against the parameter paths that went in keeps the memory saving and still lets anything the body creates out.External (vLLM) KV caches arrive as a flat list with one entry per decoder layer, but the NNX scan runs over blocks, so
_apply_qwen3_next_scanned_blocksregroups them into per-block tuples and writes them back afterwards — the sameprepare_kv_caches_for_scan/update_kv_caches_after_scanpair Gemma4 uses. That keepsskip_block_remat=Trueon the KV-cache path instead of falling back to the generic, block-rematerialized branch.Qwen3NextScannableBlocknow also threads one cache per sub-layer and returns the updated ones; previously the block handed the same cache object to all four sub-layers and dropped the updates. The LinenDecoderalready did the equivalent regrouping and is unchanged in this respect.Layer counts that are not a multiple of the cycle are handled on both decoders. The pure-NNX decoder built
num_decoder_layers // cycleblocks and stopped there, silently dropping the trailing layers; the LinenDecoderdid keep them, but spelled them out as individualQwen3NextDecoderLayerToLinenlayers, which named their parameters differently from anything the NNX side produces. Both now put the leftovers into one shortQwen3NextScannableBlocknamedlayers_remainder, so no layer is dropped and a single checkpoint mapping still covers both trees. Gemma4 and Qwen3-Next share the code that applies it (_apply_remainder_block) rather than carrying two copies of the same split /jax.checkpoint/ merge dance.Checkpoint conversion
The new layout needs matching conversion support, so that HF Qwen3-Next checkpoints still load into MaxText and still save back out. This follows #4530, which did the same for Gemma4.
The scanned branch of
QWEN3_NEXT_MAXTEXT_TO_HF_PARAM_MAPPING, and its hook map, now describe the nested layout instead of the oldlayer_{0..3}keys. Writingpsaforparam_scan_axis, a parameter lands in one of four shapes:global_layer-*[block]psalocal_layers-*[block][local]psa, psa + 1global_layer-…-routed_experts-w*[expert][block]0, 1local_layers-…-routed_experts-w*[expert][block][local]0, psa, psa + 1Only the last row needed new machinery. Routed experts are expert-stacked inside the nested local scan, so they take a third stacked axis, and the conversion helpers only handled two.
_build_multi_axis_stacked_tensor(to_maxtext) and its inverse inprocess_maxtext_param(to_huggingface) are generalized from two axes to N, and the axis placement moves into a sharedstacked_axeshelper intensor_handling.py, next tonesting_depthandslice_shape. A third copy of the same helper lived inutils/utils.py, unreachable — nothing calls that module's_get_hf_loading_function— so it is deleted here rather than updated. Nested-scan detection widens fromscanned_blocks-local_layers, which is Gemma4's module name, to-local_layers, so Qwen3-Next'slayers-local_layersmatches as well;mt_keyis threaded through theload_dynamiclazy path so it makes the same choice.The Linen
Decoderneeded a fix first._apply_qwen3_next_scanned_blocksstill had a broadcast-arg spec copied from Gemma4, which no longer matchedQwen3NextScannableBlock.__call__and passed 12 positional args to a 10-argument signature, and it named the scanned modulescanned_blockswhere the pure-NNX decoder useslayers. With both corrected, and with the remainder unified as described above, the two decoders emit byte-identical parameter names and shapes, so a single mapping serves both.Results
Activation memory drops by roughly 45–60%. Every row below is the same command on both sides, changing only the checkout.
AOT compile, real
qwen3-next-80b-a3b,compile_topology=v5p-256,per_device_batch_size=1,max_target_length=2048,remat_policy=full,attention=flash,dtype=bfloat16:CompiledMemoryStatstemp_size_in_bytesargument_size_in_bytesgenerated_code_size_in_bytesReal training runs on a v5p-8 (4 chips,
ici_fsdp_parallelism=4), scaled-down qwen3-next,max_target_length=4096:per_device_batch_size=2per_device_batch_size=12argument_sizeis byte-identical on both sides in all three experiments, confirming the two trees are the same architecture with the same parameter count — the saving is purely activations, which is why the gap widens with batch size.Step time is unchanged: 1.544 s (main) vs 1.538 s (this PR) at batch 2, and 8.181 s vs 8.130 s at batch 12.
Shortcomings and follow-ups
layer_0…layer_3to a stackedlocal_layersplus aglobal_layer. HF checkpoints round-trip through the updated conversion, but a MaxText checkpoint saved from the old tree has to be re-converted.inhomogeneous_layer_cycle_intervalrather than exposed as a config flag.first_num_dense_layersis 0 for stock Qwen3-Next, so a scanned dense prefix is left as a follow-up._apply_remainder_block), but factoring out a shared hybrid-block base, and unifyingnnx_scan.apply_scanned_layerswithNNXDecoder._apply_layers_sequentially(two appliers with two different state write-back conventions), is worth doing separately.Tests
Block and decoder structure
Ten new tests in
tests/unit/nnx_decoders_test.py, next to the analogousTestGemma4ScannableBlock.TestQwen3NextScannableBlockasserts that the block splits a cycle into a stacked local scan plus one global layer, that the local params really are stacked alongparam_scan_axis, that the nested scans reproduce a plain sequential unroll of the same weights tortol=atol=1e-5, that the external-KV path matches the scanned path and returns one cache per sub-layer inlocal…globalorder, and that a block starting off a cycle boundary -- which would straddle two periods and put the full-attention layer third of four -- is rejected rather than silently reordered.TestNNXDecoderQwen3Nextfeeds the decoder a flat per-layer cache list with a distinct sentinel per layer and checks each one comes back on its own layer — this fails on the pre-fix code, where block i receivedkv_caches[i]instead of its four-cache group. It also covers the remainder: with 6 layers and a cycle of 4,layers_remainderhas to hold the last two, and perturbing only that block's weights has to move the output, which it cannot do if the block is never applied. On the pre-fix code those two layers do not exist at all. With 8 layers there is no remainder block.TestQwen3NextDecoderParitybuilds the parameter tree fromget_maxtext_model_infoon each decoder and asserts the two are equal, with and without a remainder. One mapping serves both decoders only for as long as that holds, and nothing was checking it.Checkpoint conversion
tests/unit/param_mapping_test.pygains three round-trip tests, modelled on #4530'stest_gemma4_local_layers_stack_unstack_roundtrip: stacking HF weights into the MaxText layout and un-stacking them back has to be the identity. They cover the two-axis[block][local]case, the three-axis[expert][block][local]case, and the global layer's plain leading-axis MoE case.test_qwen3_next_mapping_scannedis rewritten for the new layout.The mapping is also checked against the model itself. For a scanned
qwen3-next-80b-a3b, its key set matchesget_maxtext_model_info's parameter tree exactly, with nothing missing and nothing extra, and every key's nesting depth matches the real tensor shape at the axesstacked_axespicks — on both decoders, which now produce identical trees.Decode
Golden-logit tests only cover the forward pass, so nothing here caught a broken prefill → autoregressive handoff, which is exactly the kind of bug a restructured block can introduce: the forward pass can be bit-correct while the block mis-threads its sub-layers' carried state between decode steps.
tests/unit/deepseek_decode_consistency_test.pyis the existing harness for that, added by the DeepSeek "decode ran the prefill attention path under NNX" fix; its module docstring notes the property holds for any model, so new families go in the parameter list. It decodes a tiny model two ways and requires the token ids to be equal:prefillon the padded prompt,init_decode_state,insertat slot 0, thengenerateper step, greedy sampling;MODEL_MODE_TRAINforward pass recomputed over the whole growing prefix each step, takingargmaxat the last real position.The second is the definition of what the first is supposed to be replaying, so any divergence is a decode bug rather than a tolerance question. Both sides load the same freshly initialized
nnx.Paramstate, withmatmul_precision=highest,dtype=float32andattention=dot_productso the comparison is exact rather than approximate. Prompt is 5 tokens, 5 tokens are generated.Three Qwen3-Next cases are added, all on a scaled-down
qwen3-next-80b-a3b(base_emb_dim=64,head_dim=32, 4 experts,gdn_chunk_size=4, vocab 64):qwen3_next_unscannedqwen3_next_scannedqwen3_next_scanned_remainderlayers_remainderblockBoth attention kinds in the hybrid stack carry state across a decode step — the GatedDeltaNet recurrent and conv states, and the full-attention KV cache — and the scanned path threads them through two nested
lax.scans plus, in the remainder case, a separate short block outside the scan. A block that regroups caches wrongly, hands one sub-layer another's state, or drops the write-back shows up here as a diverging token. The remainder case additionally fails outright on the pre-fix code, since layers 4 and 5 were not built.Commands
End-to-end on a v5p-8, 8 steps of synthetic data with fixed seeds. Losses track main to within 0.002 at every step despite the two trees drawing init RNG differently:
Reproduce (drop
override_model_configand the size overrides to run the real 80B):AOT numbers come from
Memory analysis: {compiled.memory_analysis()}intrain_compile.py:Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.