Repository navigation
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: |
| tensor_getter_fn: Callable[[str], np.ndarray], | ||
| hook_fns: Any, | ||
| target_leaf: Any, | ||
| config, | ||
| mt_key: str = "", |
| @@ -344,66 +344,59 @@ def get_maxtext_model_info(config): | |||
|
|
|||
|
|
|||
| def _build_multi_axis_stacked_tensor( | |||
| hf_source_keys: List[List[str]], | |||
| hf_source_keys: List[Any], | |||
| tensor_getter_fn: Callable[[str], np.ndarray], | |||
| 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! |
ec280c7 to
8f902ef
Compare
7519d40 to
682b3d7
Compare
Qwen3.5's decoder layer and scannable block were near-duplicates of
Qwen3-Next's. Collapse them onto the Qwen3-Next implementations, which
already nest a scan over the cycle's local layers inside a trip-count-one
scan around the full-attention layer, so Qwen3.5 picks up per-sub-layer
remat instead of block-level remat.
Sharing is via three overridable factory hooks on Qwen3NextDecoderLayer
(`_make_full_attention`, `_make_linear_attention`, `_make_mlp`). Qwen3.5
overrides only those, keeping the submodule attribute names -- and
therefore the checkpoint parameter paths -- identical, so the whole
forward pass, both norms and the residual structure are inherited rather
than copied. qwen3_5.py drops from 266 to 127 lines.
Two things had to be repaired to keep functionality identical:
Router replay. `_apply_layers_sequentially` filters call kwargs against
the callee signature, and Qwen3NextScannableBlock did not accept
`forced_routed_experts`, so scanned Qwen3.5 lost it. Thread it through
`nnx_scan.apply_scanned_layers` (new optional per-layer `xs`), the block
(scan xs for the local layers, closure for the global one, per-layer
slices on the external-KV unroll) and the hybrid scan entry point, which
also slices the scan remainder. Combining forced routing with
externally-managed KV caches now raises rather than silently dropping
one of them.
vLLM weight sync. The RL rollout converter and the direct-sync unroll
both assumed one module per cycle slot (`layers.layer_{slot}`) with a
single stacked axis. The nested block stores `local_layers` with the
cycle slot on `param_scan_axis + 1` and `global_layer` with the block
axis alone, so both now read that layout, selecting the slot once per
plan group. Trainer states in the old layout still convert. This also
fixes Qwen3-Next, which produced the nested layout already and was
therefore never syncable.
AOT compile, per_device_batch_size=1, max_target_length=1024, flash
attention with tokamax splash, temp_size_in_bytes vs main:
qwen3.5-35b-a3b v5p-128 16.16 GB -> 11.03 GB (-31.7%)
qwen3.5-35b-a3b v5p-512 11.99 GB -> 6.74 GB (-43.8%)
qwen3.5-397b-a17b v5p-512 75.62 GB -> 46.01 GB (-39.2%)
The routed-MoE kernel pspecs drop the fsdp axis so XLA emits the FSDP all-gather at the sharding constraint in sparse_matmul. Under a scanned decoder the kernels reaching that constraint are a per-layer dynamic-slice of a stacked param, and with nothing in between XLA rewrites all_gather(dynamic_slice(w)) into dynamic_slice(all_gather(w)). That gather is loop invariant, so it is hoisted out of the scan and every layer's gathered expert kernel is live at once -- num_layers * num_experts * embed * moe_mlp * dtype bytes of extra peak memory. The jnp.asarray(kernel, dtype) cast in __call__ normally separates the slice from the collective and blocks the rewrite. It is an identity when weight_dtype == dtype, which is why the blow-up only shows up there: qwen3-next-80b-a3b at tpu7x-128 compiles to 14.20 GB of temporaries with weight_dtype=float32 but 65.59 GB with weight_dtype=bfloat16, on code that is byte-identical to main's. Stand an optimization_barrier in for the cast in exactly that case, matching the barrier moe_fsdp_use_two_stage_all_gather already uses a few lines above. AOT temp_size_in_bytes per device: model topology before after qwen3-next-80b-a3b tpu7x-128 65.59 GB 12.11 GB qwen3.5-35b-a3b tpu7x-128 30.23 GB 6.94 GB qwen3.5-35b-a3b tpu7x-16 35.71 GB 17.04 GB qwen3-30b-a3b tpu7x-16 13.03 GB 12.42 GB Gating on the identity keeps every weight_dtype=float32 build byte-identical (qwen3.5-35b-a3b 25132249440 at tpu7x-16 and 8510193056 at tpu7x-128, qwen3-next-80b-a3b 14204451200 at tpu7x-128, before and after). Applying the barrier unconditionally costs 3-8% on those.
pylint C3001 unnecessary-lambda-assignment on the no-router-replay branch of Qwen3NextScannableBlock._scan_local_layers. It was the only finding failing the Code Quality Check / Pre-commit Linters job. Mirrors the def already used in the sibling branch; behaviour is unchanged.
Cut the explanatory comments and docstring prose added by this branch down to the few that record something the code cannot show. 180 lines removed, 27 added; no code changes (AST identical, docstrings excluded).
682b3d7 to
2892a98
Compare
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.The two architectures now share the decoder layer as well as the block.
Qwen3_5DecoderLayersubclassesQwen3NextDecoderLayerand overrides only three factory hooks —_make_full_attention,_make_linear_attention,_make_mlp— so the forward pass, both LayerNorms, the residual structure, theis_full_attention_layerderivation and the MoE load-balance handling are inherited rather than duplicated. The hooks keep the submodule attribute names (input_layernorm,attention,post_attention_layernorm,mlp), so the checkpoint parameter paths are identical either way.Qwen3_5ScannableBlocklikewise subclassesQwen3NextScannableBlockand overrides only_make_decoder_layer.models/qwen3_5.pygoes from 266 to 127 lines. 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 sub-block construction fromQwen3NextDecoderLayer.__init__into_make_full_attention()/_make_linear_attention()/_make_mlp(), and layer construction fromQwen3NextScannableBlock.__init__into_make_decoder_layer(). These are the only points a sibling architecture has to diverge at. Also threadsforced_routed_expertsthrough the layer and the block (see below). No behavior change for Qwen3-Next.models/qwen3_5.py—Qwen3_5DecoderLayerandQwen3_5ScannableBlockbecome thin subclasses that override the hooks.Qwen3NextDecoderLayergains 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 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.forced_routed_experts_per_layer()is factored out ofreshape_forced_routed_experts_for_scan()so the hybrid path can slice routing per sub-layer.layers/nnx_scan.py—apply_scanned_layers()gains an optionalxspytree of per-layer scan inputs. Callers that pass noxskeep the two-argumentapply_fnsignature; Gemma4, the only other caller, is unchanged.layers/moe.py— hold the FSDP expert-kernel all-gather inside the decoder loop whenweight_dtype == dtype. Not part of the port; it is a pre-existing bug in the scanned MoE path that this PR would otherwise widen to Qwen3.5. See Theweight_dtype=bfloat16blow-up below.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.integration/vllm/weight_converter.py,integration/vllm/maxtext_vllm_rollout.py— teach the RL rollout weight sync to read the nested tree (see below).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,remat_policy=full— thebase.ymldefault — on both sides). Both sides freshly compiled againstmain@457040f7:Exact bytes: 35b/tpu7x-16
30359507040 → 25132249440; 35b/tpu7x-843612869024 → 44113347456; 397b/v5p-51275622737696 → 46011566848. The 397b/v5p-128 OOM row is the original pre-rebase measurement and was not re-run.The saving is a roughly constant 5–7 GB, not a constant percentage. Sweeping device count on identical silicon (qwen3.5-35b-a3b, tpu7x-N):
What shrinks with device count is the baseline, not the saving. The FSDP-sharded share of the working set falls as 1/N, while what this PR removes does not shard at all:
sparse_matmuldeliberately stripsfsdpfrom the expert-kernel pspecs, so the gathered per-device MoE kernels are full size on every chip, and block-level remat keeps all four sub-layers' worth of them co-live. Per-sub-layer remat keeps one. The same absolute saving is therefore 17% of a 30 GB budget and 45% of a 16 GB one.The cross-generation control agrees: v5p-128 and tpu7x-64 are both 64 JAX devices (v5p slice names count TensorCores) and start from quite different baselines — 16.16 GB and 19.81 GB — yet save 5.129 GB and 5.105 GB, within 0.5% of each other. v5p-512 (256 devices) saves 5.25 GB, 11.99 → 6.74 GB.
argument_size_in_bytesis unchanged in both directions (26.01 GB at 35b/tpu7x-16, 18.61 GB at 397b/v5p-512). Generated code size roughly halves on every row, including the ones where temp memory does not move — that is the nested scan replacing an unrolled cycle, and it is topology-independent: 35b/tpu7x-8 205 → 99 MB, 35b/tpu7x-16 210 → 99 MB, 397b/v5p-512 320 → 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 there is a wash, 40.6 → 41.1 GB estimated temp, consistent with the tpu7x-8 row.The layer-sharing refactor is free. Recompiling qwen3.5-35b-a3b at tpu7x-16 after collapsing
Qwen3_5DecoderLayerontoQwen3NextDecoderLayerand threading router replay givestemp_size_in_bytes=25132249440,generated_code_size_in_bytes=99265536— byte-identical to the build before the refactor.Test plan
All CPU-only, run individually against this branch:
tests/unit/nnx_decoders_test.py-k Qwen3: 17 passed, up from 7 onmain)tests/unit/moe_test.pytests/unit/router_replay_test.pytests/unit/nnx_scan_test.pytests/unit/param_mapping_test.pytests/post_training/unit/weight_converter_test.pynnx_decoders_test.pyare parameterized by class attributes so the Qwen3.5 subclasses reuse them: 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. New: a block given forced routing that does not cover every sub-layer must raise.moe_test.pyaddstest_sparse_matmul_pins_expert_kernel_all_gather_when_the_upcast_is_an_identity, parameterized over(bf16, bf16),(f32, f32)and(f32, bf16). It asserts the barrier is applied exactly when the upcast is an identity, and — by object identity, since the stand-in kernels share a shape — that the sharding constraint consumes the barrier's outputs rather than the raw kernels, so a barrier whose results are dropped does not pass. Both matching-dtype cases fail without themoe.pychange.param_mapping_test.pycovers thelocal_layers/global_layermapping shape and the composite-key axis fix.router_replay_test.pyadds a case for the scan remainder block, where only the trailing layer's routing differs between two runs, so the losses can diverge only if the remainder replays its own slice. Reverting theqwen3.py/nnx_scan.py/nnx_decoders.pychanges failstest_loss_fn_with_forced_routed_experts_scanned_qwen3_5andtest_per_layer_routing_is_not_broadcast_scanned.weight_converter_test.pyadds a nested-layout source tree and asserts it converts byte-identically to the per-slot layout, plus a truncated cycle-slot axis being rejected. Both fail if theweight_converter.pychange is reverted.tests/post_training/unit/vllm_rollout_unroll_test.pyadds a nestedlocal_layers+global_layerunroll case and a missing-slot-axis rejection. This file needsvllminstalled to import the module under test, so it was verified in CI-equivalent form by executing the unroll helpers in isolation: the nested layout produces a denselayers_{0..7}with every slot on its own layer, and the legacy per-slot and homogeneous paths are unchanged. The same check fails againstmain.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.