Skip to content

Add general scan support for M3 - #5875

Open
hengtaoguo wants to merge 1 commit into
mainfrom
hengtaoguo-m3scan
Open

hengtaoguo wants to merge 1 commit into
mainfrom
hengtaoguo-m3scan

Conversation

@hengtaoguo

@hengtaoguo hengtaoguo commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Add post-construction scanning for m3 Qwen3 through scan_layers=true in m3.train.loop. The transform stacks independent layer weights and executes them with jax.lax.scan, preserving existing unscanned parameter paths.

New models opt into m3 layer scanning by exposing decoder.layer_names as an ordered sequence of structurally identical layers or model-specific blocks, keeping scan.py free of model-name branches.

Fix: Adding a small patch to reduce Python overhead in the training loop (original #5900, before vs after: Cache the model/optimizer binding and avoid extracting model state for profiling.

Tests

New scan_tests.py guards:

  • Scanned outputs match unscanned outputs at parameter scan axes 0 and 1.
  • Every parameter gradient matches after aligning the stacked layout.
  • Disabling scanning preserves individual layer entries.
  • Scalar and vector parameters stack and execute correctly under JIT.
  • Short sharding specs receive the scan dimension at the correct position.
  • Unsupported decoders raise before the layer stack is rewritten.
python -m maxtext.m3.train.loop src/maxtext/configs/base.yml model_name=qwen3-0.6b use_m3_model=true run_name=qwen3_profile_scanned scan_layers=true per_device_batch_size=3 max_target_length=2048 steps=8 profiler=xplane skip_first_n_steps_for_profiler=3 profiler_steps=3 base_output_directory=/home/hengtaoguo_google_com/projects/profiles enable_checkpointing=false 

Profiling comparison:

  • Scanned xprof: 226.75ms
  • Unscanned xprof: 361.53ms
  • Unscanned on main xprof: 361.16ms
Scanned ckpt logits check
python -m tests.utils.forward_pass_logit_checker src/maxtext/configs/base.yml run_name=ht_test_m3 use_m3_model=true model_name=qwen3-0.6b tokenizer_path=Qwen/Qwen3-0.6B load_parameters_path=/dev/shm/checkpoints/qwen3-0.6b/0/items scan_layers=true dtype=float32 weight_dtype=float32 activations_in_float32=true matmul_precision=highest float32_logits=true float32_qk_product=true attention=dot_product per_device_batch_size=1 max_target_length=32 skip_jax_distributed_system=true ici_tensor_parallelism=4 ici_fsdp_parallelism=1 --run_hf_model=true --hf_model_path=Qwen/Qwen3-0.6B --max_kl_div=0.1 hf_access_token=xxx
--- Prompt: I love to ---
/home/hengtaoguo_google_com/projects/maxtext/tests/utils/forward_pass_logit_checker.py:413: DeprecationWarning: `with mesh:` context manager has been deprecated. Please use `with jax.set_mesh(mesh):` instead.
  with mesh, nn_partitioning.axis_rules(config.logical_axis_rules):
INFO:absl:
--- MaxText model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 1486       | play                 | 14.5847    |
| 8180       | eat                  | 14.2710    |
| 4296       | cook                 | 14.1049    |
| 3270       | write                | 13.6537    |
| 5821       | travel               | 13.4449    |
| 1281       | make                 | 13.4420    |
| 1349       | read                 | 13.4115    |
| 728        | go                   | 13.4032    |
| 1490       | see                  | 13.3301    |
| 653        | do                   | 13.2475    |

INFO:absl:
--- HF model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 1486       | play                 | 14.6170    |
| 8180       | eat                  | 14.2900    |
| 4296       | cook                 | 14.1443    |
| 3270       | write                | 13.6548    |
| 5821       | travel               | 13.4635    |
| 1281       | make                 | 13.4502    |
| 1349       | read                 | 13.4458    |
| 728        | go                   | 13.4271    |
| 1490       | see                  | 13.3409    |
| 653        | do                   | 13.2776    |

INFO:absl:
--- Similarity Metrics of Top Tokens ---
INFO:absl:| Metric                         | Value               |
|--------------------------------|----------------------|
| overlap_count                  | 10/10     |
| jaccard_similarity             | 1.0     |
| rank_agreement_percentage      | 100.0     |

INFO:absl:
Average KL divergence per token (D_KL(P_golden || Q_model)): 7.81e-05
INFO:absl:Per-token KL Divergences: 
['8.69e-05', '6.20e-05', '8.54e-05']
INFO:absl:
Max KL divergence for a single token in the set: 8.69e-05
INFO:absl:
--- Prompt: Today is a ---
INFO:absl:
--- MaxText model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 7014       | Monday               | 16.4315    |
| 7589       | Tuesday              | 16.3095    |
| 7920       | Wednesday            | 16.2941    |
| 6602       | Friday               | 15.9657    |
| 7794       | Thursday             | 15.9154    |
| 7728       | Saturday             | 15.3790    |
| 1899       | day                  | 15.2705    |
| 7270       | Sunday               | 15.0673    |
| 3281       | special              | 14.6526    |
| 2400       | date                 | 14.4855    |

INFO:absl:
--- HF model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 7014       | Monday               | 16.3987    |
| 7589       | Tuesday              | 16.2641    |
| 7920       | Wednesday            | 16.2587    |
| 6602       | Friday               | 15.9392    |
| 7794       | Thursday             | 15.8802    |
| 7728       | Saturday             | 15.3365    |
| 1899       | day                  | 15.2569    |
| 7270       | Sunday               | 15.0272    |
| 3281       | special              | 14.6245    |
| 2400       | date                 | 14.4796    |

INFO:absl:
--- Similarity Metrics of Top Tokens ---
INFO:absl:| Metric                         | Value               |
|--------------------------------|----------------------|
| overlap_count                  | 10/10     |
| jaccard_similarity             | 1.0     |
| rank_agreement_percentage      | 100.0     |

INFO:absl:
Average KL divergence per token (D_KL(P_golden || Q_model)): 1.82e-04
INFO:absl:Per-token KL Divergences: 
['3.18e-04', '1.39e-04', '8.76e-05']
INFO:absl:
Max KL divergence for a single token in the set: 3.18e-04
INFO:absl:
--- Prompt: What is the ---
INFO:absl:
--- MaxText model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 7428       | purpose              | 17.1214    |
| 14806      | formula              | 16.3799    |
| 6672       | difference           | 16.0150    |
| 3491       | problem              | 15.8804    |
| 25361      | significance         | 15.7821    |
| 897        | value                | 15.7411    |
| 18927      | probability          | 15.5221    |
| 7286       | concept              | 15.2908    |
| 3476       | role                 | 15.2718    |
| 5025       | relationship         | 15.2641    |

INFO:absl:
--- HF model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 7428       | purpose              | 17.1047    |
| 14806      | formula              | 16.3597    |
| 6672       | difference           | 15.9854    |
| 3491       | problem              | 15.8565    |
| 25361      | significance         | 15.7555    |
| 897        | value                | 15.7190    |
| 18927      | probability          | 15.4849    |
| 7286       | concept              | 15.2706    |
| 3476       | role                 | 15.2603    |
| 5025       | relationship         | 15.2455    |

INFO:absl:
--- Similarity Metrics of Top Tokens ---
INFO:absl:| Metric                         | Value               |
|--------------------------------|----------------------|
| overlap_count                  | 10/10     |
| jaccard_similarity             | 1.0     |
| rank_agreement_percentage      | 100.0     |

INFO:absl:
Average KL divergence per token (D_KL(P_golden || Q_model)): 6.06e-05
INFO:absl:Per-token KL Divergences: 
['1.20e-04', '9.25e-06', '5.31e-05']
INFO:absl:
Max KL divergence for a single token in the set: 1.20e-04
INFO:absl:
--- Prompt: The city of Lyon sits at the confluence of the Rhone and Saone rivers in eastern... ---
INFO:absl:
--- MaxText model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 432        | it                   | 19.3386    |
| 279        | the                  | 18.4658    |
| 55201      | Lyon                 | 17.1852    |
| 1181       | its                  | 16.3319    |
| 323        | and                  | 15.4647    |
| 553        | by                   | 14.5548    |
| 419        | this                 | 14.3522    |
| 1212       | under                | 13.9366    |
| 448        | with                 | 13.8906    |
| 892        | which                | 13.8728    |

INFO:absl:
--- HF model top 10 tokens ---
INFO:absl:| Token ID   | Token                | Score      |
|------------|----------------------|------------|
| 432        | it                   | 19.3157    |
| 279        | the                  | 18.4528    |
| 55201      | Lyon                 | 17.2411    |
| 1181       | its                  | 16.3461    |
| 323        | and                  | 15.4643    |
| 553        | by                   | 14.5391    |
| 419        | this                 | 14.3296    |
| 1212       | under                | 13.9320    |
| 448        | with                 | 13.8879    |
| 892        | which                | 13.8335    |

INFO:absl:
--- Similarity Metrics of Top Tokens ---
INFO:absl:| Metric                         | Value               |
|--------------------------------|----------------------|
| overlap_count                  | 10/10     |
| jaccard_similarity             | 1.0     |
| rank_agreement_percentage      | 100.0     |

INFO:absl:
Average KL divergence per token (D_KL(P_golden || Q_model)): 1.20e-04
INFO:absl:Per-token KL Divergences: 
['1.08e-04', '5.06e-05', '1.86e-04', '6.16e-05', '8.84e-05', '5.76e-06', '9.75e-05', '2.23e-07', '3.49e-06', '5.67e-05', '1.19e-04', '2.25e-04', '1.05e-04', '2.44e-05', '5.10e-04', '7.49e-05', '2.99e-05', '1.11e-04', '3.75e-04', '3.21e-06', '1.22e-04', '1.14e-04', '1.73e-05', '4.02e-04', '2.76e-04', '1.23e-04', '1.93e-05', '1.56e-04', '4.32e-05', '9.51e-05', '2.54e-05', '2.17e-04']
INFO:absl:
Max KL divergence for a single token in the set: 5.10e-04

Checklist

Before submitting this PR, please make sure (put X in square brackets):

  • I have performed a self-review of my code. For an optional AI review, add the gemini-review label.
  • I have necessary comments in my code, particularly in hard-to-understand areas.
  • I have run end-to-end tests tests and provided workload links above if applicable.
  • I have made or will make corresponding changes to the doc if needed, including adding new documentation pages to the relevant Table of Contents (toctree directive) as explained in our documentation.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces post-construction scanning of repeated decoder layers or blocks for Qwen3 models, implementing the ScannedLayers module and integration utilities in scan.py along with corresponding unit tests. The review feedback highlights several compatibility issues with standard Flax NNX APIs in the new scan.py file, specifically pointing out that nnx.Variable lacks get_metadata() and get_value() methods, and that nnx.graphdef is not a valid function.

Comment thread src/maxtext/m3/infra/scan.py
Comment thread src/maxtext/m3/infra/scan.py
Comment thread src/maxtext/m3/infra/scan.py
Comment thread src/maxtext/m3/infra/scan.py
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 18 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/maxtext/m3/infra/scan.py 80.85% 9 Missing and 9 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Hi @hengtaoguo, I've received your request, and I'm working on it now! You can track my progress in the logs for more details.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Review Summary

Verdict: Core scan transformation implemented and verified for dense weights, but contains scalar parameter stacking/transposition bugs and an unedited boilerplate description.

Top issues:

  1. ⚠️ [Description] The PR description contains only unedited template boilerplate with unchecked boxes and no problem statement, architecture context, or reproduction instructions. → Replace the boilerplate with the problem, design of the M3 post-construction scan transform, limitations (no remat yet, DeepStack incompatible), and reproduction steps.
  2. 🔴 [Bug] _stack_states and ScannedLayers.__call__ assume all parameters have rank > param_scan_axis, which will crash on 0D (scalar) parameters with AxisError when param_scan_axis=1. → Guard parameter stacking and axis transpositions with ndim > param_scan_axis, defaulting to axis 0 (see inline comments on src/maxtext/m3/infra/scan.py).
  3. [Bug] _remove_axis_from_spec only deletes if len(spec) > axis, which fails to invert _insert_axis_in_spec when the original spec had length <= axis, leaving mismatched PartitionSpec ranks. → Delete index min(axis, len(spec) - 1) so the inserted axis is consistently removed (see inline comment on src/maxtext/m3/infra/scan.py).
  4. [Perf] Mapping sharding metadata updates 3 times inside the inner scan body adds tracing overhead while serving no functional purpose, as JAX/XLA ignores variable metadata during forward execution. → Remove _map_sharding_metadata calls from the inner scan body (see inline comment on src/maxtext/m3/infra/scan.py).
  5. [Tests] Core configuration variants like param_scan_axis=0 and sharded execution with an active Mesh are untested in tests/unit/m3/scan_test.py. → Parameterize _config and add tests validating param_scan_axis=0 and sharding specs under a mesh (see inline comment on tests/unit/m3/scan_test.py).
Inferred spec & validation
  • Sources: PR title; code before and after; m3/README.md and legacy layers/nnx_scan.py.
  • Problem: M3 lacked layer scanning support for decoder stacks, forcing full unrolling during compilation and execution.
Spec item Implementation Validating test
R1 (inferred): Post-construction scan transform (ScannedLayers, maybe_apply_scan) stacks layers and runs them via jax.lax.scan. ✅ tests/unit/m3/scan_test.py::test_qwen3_scanned_matches_unscanned ✅
R2 (inferred): Scanned model preserves forward logits and backward gradients compared to unscanned model. ✅ tests/unit/m3/scan_test.py::test_qwen3_scanned_matches_unscanned, test_qwen3_scanned_gradients_match ✅
R3 (inferred): Parameter stacking and scanning supports arbitrary param_scan_axis (including 0 and 1) and handles arbitrary parameter ranks. ⚠️ partial (scalar parameters crash when param_scan_axis=1) tests/unit/m3/scan_test.py ❌ none (only default param_scan_axis=1 tested on dense 2D/1D weights)
R4 (inferred): Guard unsupported models (e.g. Qwen3VLDecoder with DeepStack injections) by rejecting scanning before mutating model. ✅ tests/unit/m3/scan_test.py::test_scan_rejects_unsupported_model_before_mutation ✅
R5 (inferred): Integrate scan into M3 training loop (train/loop.py) under scan_layers=true. ✅ tests/unit/m3/train_loop_test.py::test_train_loop_losses_match_with_and_without_scan ✅
C1 (inferred): scan_layers=false remains an exact no-op with unchanged model structure. ✅ preserved tests/unit/m3/scan_test.py::test_qwen3_scan_is_noop_when_disabled ✅
C2 (inferred): Maintain transparent parameter paths for checkpointing, optimizer, and weight inspectability. ✅ preserved tests/unit/m3/scan_test.py::test_qwen3_scanned_gradients_match ✅

Comment thread src/maxtext/m3/infra/scan.py
Comment thread src/maxtext/m3/infra/scan.py
Comment thread src/maxtext/m3/infra/scan.py Outdated
Comment thread src/maxtext/m3/infra/scan.py
Comment thread tests/unit/m3/scan_test.py Outdated
Comment thread src/maxtext/m3/infra/scan.py Outdated
@hengtaoguo
hengtaoguo force-pushed the hengtaoguo-m3scan branch 2 times, most recently from d160cdd to 2019109 Compare October 21, 2026 20:48
Comment thread src/maxtext/m3/models/qwen3/modeling_qwen3.py Outdated

@bvandermoon bvandermoon left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Just two callouts:

  1. (Also discussed offline) Is there a way we can enforce model files follow the patterns for scan to get picked up properly?
  2. Does merged code from @fenghuizhang's GLM-5.3-Flash or @RissyRan's Qwen3.8-Flash-Next need updates with this?

@RissyRan RissyRan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding general scan support for m3! I left comments on two blocking issues (GLM-5.3-Flash failing under the default scan_layers=true, and loading m3 checkpoints saved before this PR) and six correctness issues in scan.py. These come from reading the code at 784b2587; I haven't run these cases, so please let me know if I've misread anything.

"""
if not config.scan_layers:
return model
decoder = model.decoder

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: model.decoder is read unconditionally, so any m3 model without a decoder attribute now fails whenever scan_layers=true, which is the base.yml default. Glm5Model (registered as glm5.3-flash, with use_m3_model: true in its yml) has embed_tokens / prefix_layers / super_block / suffix_layers and no decoder. As a result, create_model, from_pretrained and the m3 loop all raise AttributeError: 'Glm5Model' object has no attribute 'decoder'. Setting scan_layers=false avoids it, but it also disables GLM's own scan (glm5_scan reads config.scan_layers).

The same default also turns the layer_names is None case (e.g. Qwen3-VL) into a hard ValueError for runs that previously ignored scan_layers.

Suggestion: treat a missing decoder / layer_names, or a model that scans internally, as a no-op rather than an error (e.g. decoder = getattr(model, "decoder", None) and return early). Models would then opt in, instead of every m3 model going through this path.


return m3_models.create_model(config, mesh, rngs=rngs, quant_mode_str=quant_mode_str)
model = m3_models.create_model(config, mesh, rngs=rngs, quant_mode_str=quant_mode_str)
model = m3_scan.maybe_apply_scan(model, config, mesh=mesh)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (checkpoint compatibility): with this change, m3 models start honoring scan_layers (default true). However, m3 checkpoints saved before this PR wrote custom_metadata["scan_layers"] = config.scan_layers (checkpointing.py:1613), i.e. True, while their trees hold unscanned decoder/layers_N.

In from_pretrained, verify_and_sync_scan_layers then either:

  • keeps True (default), so the model is built with a stacked decoder/layers and restore fails on the tree mismatch; or
  • raises "Configuration mismatch" when the user passes scan_layers=false explicitly.

So those checkpoints can't be loaded with any setting. Could we add a migration path, e.g. an m3-specific metadata key, or accepting unscanned trees for checkpoints that predate this change?


def stack(*variables):
first = variables[0]
if not _is_variable(first):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stack returns layer 0's value for any non-Variable leaf. NNX graph flattening keeps raw jax.Array / np.ndarray attributes as bare state leaves, so a unit that stores a constant buffer (e.g. self.inv_freq = jnp.arange(64)) ends up with a single unstacked copy. That leaf then lands in rest, and lax.scan slices its leading axis as if it were the layer axis: a length mismatch if dim 0 != L, or silently one row per layer if it happens to equal L. Could we stack these leaves as well, or reject them explicitly?

layer = nnx.merge(self._layer_graphdef, layer_params, layer_rest)
out = layer(carry, *args, **kwargs)
# Params come back unchanged; only emit the (possibly mutated) non-Param state.
_, _, new_rest = nnx.split(layer, nnx.Param, ...)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only non-Param state is emitted from the body, so any nnx.Param created during the call is silently dropped. With the documented order construct → scan → quantize, Qwix LoRA / quantized layers create their params lazily inside this call (LoRA adapters are Param subclasses), so they would be discarded on every step and never trained.

Conversely, a new non-Param variable created in the call (sow, quantization stats) is stacked and added by nnx.update. That changes the model graphdef after the first step, which makes nnx.cached_partial raise "graph structure ... was mutated".

maxtext.layers.nnx_scan.apply_scanned_layers already handles this case. Could we reuse it, or mirror its handling here?

_stack_states([nnx.state(l) for l in layers], param_scan_axis, mesh),
)
# Adopt the stacked layer's attributes so parameter paths match a single layer's.
for name, value in vars(stacked).items():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying attributes with plain setattr skips names starting with _ and loses explicit nnx.data / nnx.static status:

  • A unit with a private submodule or Variable (e.g. self._norm = nnx.RMSNorm(...)) loses it from ScannedLayers' state, and nnx.merge(self._layer_graphdef, ...) in the body then fails on the missing leaves.
  • A unit holding nnx.data([...]) can hit flax's "Found data on value ... assigned to static attribute" error here.

Could we copy every attribute, including _-prefixed ones, and preserve its data/static status?


def _insert_axis_in_spec(spec: Any, axis: int) -> Any:
"""Inserts a replicated (None) entry for the new layer axis into a sharding spec."""
if isinstance(spec, (P, tuple)):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_insert_axis_in_spec and _remove_axis_from_spec only handle P and tuple. If a Variable's out_sharding is a NamedSharding or a list, it passes through unchanged. The stacked rank-(d+1) array then keeps a rank-d spec, and _stack_states also skips the device_put (it checks isinstance(spec, P)). Downstream sharded creation (get_nnx_var_named_sharding_with_scan_axis / create_nnx_sharded_model) would then fail on the rank mismatch. nnx.spmd.add_axis / remove_axis may already cover these cases.


stacked = nnx.merge(
graphdef,
_stack_states([nnx.state(l) for l in layers], param_scan_axis, mesh),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stacking nnx.state(l) per layer turns a Variable or submodule that is shared across layers, or shared with a module outside the stack, into L independent copies. Weight tying is then silently broken: gradients no longer accumulate into the shared weight, the outside copy stops seeing in-layer updates, and the parameter count grows. Could ScannedLayers.__init__ detect shared references across layers (e.g. by object identity) and raise?

f"{type(decoder).__name__} does not expose scan units through "
"`layer_names`; m3 layer scanning is currently unsupported for this model."
)
if len(layer_names) < 2:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With scan_layers=true, a decoder with fewer than two layers is left unscanned (decoder/layers_0), while the checkpoint metadata still records scan_layers=True. The legacy path produces a stacked layers tree with L=1 in that case, so a 1-layer scanned checkpoint can't move between the two paths. lax.scan handles length 1 fine, so the < 2 guard (here and in ScannedLayers.__init__) could be dropped.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants