fix(nnx): keep only newly created params in _apply_layers_sequentially - #5130
Open
hodaaaaaaaaaa wants to merge 1 commit into
Open
fix(nnx): keep only newly created params in _apply_layers_sequentially#5130hodaaaaaaaaaa wants to merge 1 commit into
hodaaaaaaaaaa wants to merge 1 commit into
Conversation
Under `dynamic_graph_init` the scan body returned the full `nnx.Param` state. `jax.lax.scan` stacks every output, so each Qwix LoRA injection materialized a fresh copy of the entire decoder layer stack -- 1194 MB per adapter against a 6.1 MB adapter on gemma2-2b, with the base no longer shared between adapters. The branch is needed (new LoRA params are born inside the body and this is how they escape), it just carried the base out with them. Apply the same fix 5e3a353 made to `nnx_scan.apply_scanned_layers`: record the paths fed in as scan inputs and return only paths not in that set. The base is read back off the pre-scan `layers` module, which preserves array identity and its original param_scan_axis layout. Per-adapter cost drops to 6.1 MB with all 24 base arrays shared, and adapter outputs are bitwise identical to before. Fixes AI-Hypercomputer#5129
hodaaaaaaaaaa
requested review from
A9isha,
NuojCheng,
RissyRan,
SurbhiJainUSC,
abhinavclemson,
aireenmei,
bvandermoon,
darisoy,
dipannita08,
gagika,
gobbleturk,
hengtaoguo,
huytransformer,
igorts-git,
jiangjy1982,
khatwanimohit,
parambole,
richjames0,
shralex,
shuningjin,
vipannalla and
xibinliu
as code owners
September 3, 2026 18:41
There was a problem hiding this comment.
Code Review
This pull request modifies _apply_layers_sequentially in nnx_decoders.py to prevent carried-in parameters from being re-emitted as scan outputs during dynamic graph initialization, while ensuring parameters created inside the scan body (such as LoRA adapters) are correctly returned. It also adds unit tests to verify this behavior. The reviewer identified a critical issue where this change exposes a bug in _apply_interleaved_scanned_layers, as the returned chunk_stack is not written back to self, which would cause newly created parameters inside interleaved layers to be lost.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Under
dynamic_graph_init, the scan body inNNXDecoder._apply_layers_sequentiallyreturns the fullnnx.Paramstate.jax.lax.scanstacks every output, so each Qwix LoRA injection materializes a fresh copy of the whole layer stack: ongemma2-2b, 1194 MB per adapter, for adapter weights of 6.1 MB. Nothing errors, but the base is no longer shared between adapters, so multi-adapter serving on one worker is impossible with scan on.The branch is necessary — new LoRA params are created inside the scan body and this is how they get out. It just carries the base out with them.
5e3a353("fix(nnx): keep params created inside the layer scan") fixed the same problem innnx_scan.apply_scanned_layersby returning only params created inside the body. That applier has two callers (Gemma 4, Qwen3-Next); everything else scanned,gemma2included, goes through_apply_layers_sequentially, which never got the fix because it doesn't crash.This PR transcribes that fix: record the paths fed in as scan inputs, return only paths not in that set, and read the base back off the pre-scan
layersmodule withnnx.state(layers, nnx.Param). That call preserves array identity, so the base is genuinely shared rather than just smaller, and it returns at its originalparam_scan_axislayout.One semantic change: under
dynamic_graph_init, in-body mutations of carried params are no longer returned. The non-dynamic branch already discards them andapply_scanned_layersassumes the same — the applier already treats scanned params as read-only inside the body.The two appliers are slated to be unified (see the note at
nnx_decoders.py:1024); fixing this first means the merge won't have to reconcile two different behaviors.FIXES: #5129
Tests
New regression test —
TestApplyLayersSequentiallyDynamicGraphInitintests/unit/nnx_decoders_test.py, following the existingTestApplyLayersSequentiallyMetadataAxisNameharness. It drives_apply_layers_sequentiallydirectly with annnx.vmap-stacked dummy layer that creates annnx.LoRAParamwhile tracing, and asserts that the new param escapes the scan and that the base array is the same object afterwards, atparam_scan_axis0 and 1. The identity assertion fails onmainat both axes and passes with this change.Existing suites, on CPU:
XLA_FLAGS=--xla_force_host_platform_device_count=8 JAX_PLATFORMS=cpu \ pytest tests/unit/nnx_decoders_test.py tests/unit/nnx_decoder_test.py \ tests/unit/nnx_scan_test.py tests/unit/lora_utils_nnx_test.py \ tests/unit/nnx_wrappers_test.py # 75 passed, 2 skippedEnd-to-end check — build
gemma2-2bwithbase_num_decoder_layers=2,scan_layers=True, anfsdp=4 × tensor=2mesh over 8 forced host devices, then calllora_utils.apply_lora_to_model(model, mesh, config)twice and diffjax.live_arrays()around each call:nnx.jitforwardAdapter outputs are bitwise identical before and after (
np.array_equal, compared across processes): the change affects memory, not numerics. The two arrays already shared before the fix aretoken_embedder/embeddinganddecoder/decoder_norm/scale, the only Gemma 2 weights outside the layer stack.All of the above runs on plain CPU; no TPU required.
Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.