perf(qwen3.5): reuse Qwen3-Next's nested block scan - #5009
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements hierarchical nested scans for Qwen3-Next and Qwen3.5 hybrid decoders, enabling per-layer rematerialization to optimize memory usage. It introduces scannable blocks that structure inhomogeneous attention cycles and updates checkpoint conversion utilities to support multi-axis stacked tensors with arbitrary nesting depths. Comprehensive unit tests are added to verify decoder parity and state restoration. The review feedback suggests updating the type annotations for mt_key from str to str | tuple[str, ...] in stacked_axes and _build_multi_axis_stacked_tensor to accurately reflect that composite keys can be represented as tuples.
| return depth | ||
|
|
||
|
|
||
| def stacked_axes(mt_key: str, config, depth: int) -> tuple: |
There was a problem hiding this comment.
The mt_key parameter is annotated as str, but the function explicitly handles tuple inputs (lines 69-70). To ensure type safety and prevent static analysis warnings, the type annotation should be updated to str | tuple[str, ...].
| def stacked_axes(mt_key: str, config, depth: int) -> tuple: | |
| def stacked_axes(mt_key: str | tuple[str, ...], config, depth: int) -> tuple: |
| hook_fns: Any, | ||
| target_leaf: Any, | ||
| config, | ||
| mt_key: str = "", |
| hook_fns: Any, | ||
| target_shape: tuple, | ||
| config, | ||
| mt_key: str = "", |
4c37a30 to
ec280c7
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
What
Extends the Qwen3-Next scanned-block work from #4964 to Qwen3.5.
Qwen3.5 repeats the exact same hybrid attention period as Qwen3-Next —
inhomogeneous_layer_cycle_interval = 4, i.e. three GatedDeltaNet layers followed by one full-attention layer — so rather than porting the logic, this makes Qwen3.5 reuse it.Qwen3NextScannableBlock's layer construction moves into an overridable_make_decoder_layer()hook, andQwen3_5ScannableBlockbecomes a subclass that only swaps inQwen3_5DecoderLayer. The ~50-line hand-rolled Python-loop block it replaces is deleted, so Qwen3.5 picks up the inner scan over the homogeneous linear-attention layers, the trip-count-onelax.scanscheduling barrier around the full-attention layer, and per-sub-layer remat, for free.Changes
models/qwen3.py— extract layer construction fromQwen3NextScannableBlock.__init__into_make_decoder_layer(). No behavior change for Qwen3-Next.models/qwen3_5.py—Qwen3_5ScannableBlocksubclassesQwen3NextScannableBlockand overrides the hook.Qwen3_5DecoderLayergains an explicitis_full_attention_layerkwarg, because inside a scan a sub-layer's position in the cycle is not recoverable fromlayer_idx; it still falls back to the(layer_idx + 1) % cycle == 0derivation when unscanned. The MoE load-balance loss is now assigned rather thansow-n —sowappends to a tuple, so the layer'sIntermediatestructure grows on every call, which alax.scanbody cannot express.layers/decoders.py,layers/nnx_decoders.py— routeQWEN3_5through the shared hybrid path._init_scanned_qwen3_next/_apply_qwen3_next_scanned_blocksare renamed to..._qwen3_hybrid_...and pick the block class bydecoder_block, including forlayers_remainder.checkpoint_conversion/utils/param_mapping.py— remap Qwen3.5's scanned checkpoint mapping from the old per-cycle-positionlayers-layer_{i}prefixes to thelocal_layers/global_layertree, mirroring what perf(qwen3-next): nested scan over hybrid attention with per-layer remat #4964 did for Qwen3-Next. Local layers nest[block][local], the global layer is flat[block].checkpoint_conversion/utils/tensor_handling.py—stacked_axes()was guarded onisinstance(mt_key, str), so Qwen3.5's composite(wi_0, wi_1)key (fed by one fused HFgate_up_projtensor) fell through to the leading-axis MoE layout instead of the nested-scan layout and would have silently transposed those weights. Tuple keys now resolve via their first component.Results
AOT
temp_size_in_bytesper device, using the config fromtrain_compile_test.py::test_qwen3_5(per_device_batch_size=1,max_target_length=1024,sparse_matmul,megablox,attention=flash,use_tokamax_splash):argument_size_in_bytesis unchanged (18.61 GB at 397b/v5p-512); generated code size drops 320 MB → 163 MB.Real training on a v7-8 (qwen3.5-35b-a3b, synthetic data,
ici_fsdp_parallelism=8,per_device_batch_size=1,max_target_length=1024, 6 steps) converges on both sides with matching loss curves (step 5: 7.056 before, 7.072 after — RNG-fork differences only). Memory is a wash at that scale, 40.6 → 41.1 GB estimated temp, which is the same regime as the v5p-8 row above.Caveat: the win depends on
weight_dtypeIsolating that single variable at 397b / v5p-512:
weight_dtypefloat32(MaxText default)bfloat16This is not introduced here — it is a property of #4964 itself. Measured against the pre-#4964 commit, qwen3-next-80b-a3b at v5p-512 goes 21.44 → 10.68 GB with fp32 weights (the reported win, reproduced) but 20.15 → 53.46 GB with bf16 weights. The mechanism is consistent with the design: with fp32 storage the per-layer bf16 weight cast is a temporary, and block-level remat kept all four sub-layers' casts live at once, which is exactly what per-sub-layer remat fixes; with bf16 storage there is no cast to save and only the nested scan's overhead remains. Left as-is here to keep the port faithful to Qwen3-Next — worth tracking separately.
Known gap
integration/vllm/weight_converter.py:696still assumes the oldlayers/layer_{slot}tree and will not read scanned Qwen3.5 checkpoints. #4964 did not touch this file either, so Qwen3-Next has the identical gap; this PR matches that rather than fixing it.Test plan
tests/unit/nnx_decoders_test.py -k Qwen3— 21 passed. The three Qwen3-Next test classes are parameterized by class attributes so Qwen3.5 subclasses reuse them, giving Qwen3.5 the same coverage: nested-scan output matches a sequential unroll numerically, and the Linen and NNX parameter trees match, both for a whole number of periods and with a remainder.tests/unit/param_mapping_test.py— 28 passed, including two new cases covering thelocal_layers/global_layermapping shape and the composite-key axis fix.