Skip to content

Fix quantized MoE on the dense_matmul path - #4955

Open
gulsumgudukbay wants to merge 1 commit into
AI-Hypercomputer:mainfrom
ROCm:fix-moe-quantized-einsum-nnx-binding
Open

Fix quantized MoE on the dense_matmul path#4955
gulsumgudukbay wants to merge 1 commit into
AI-Hypercomputer:mainfrom
ROCm:fix-moe-quantized-einsum-nnx-binding

Conversation

@gulsumgudukbay

Copy link
Copy Markdown
Collaborator

Problem

RoutedMoE.get_einsum constructs a Linen einsum and calls it inline. That worked while the MoE layer was itself a Linen module, but since the move to NNX there is no Linen scope for it to bind to, so every quantization fails on the dense_matmul path. Tiny Mixtral, one step, CPU:

quantization sparse_matmul=True sparse_matmul=False
none passes passes
int8 passes CallCompactUnboundModuleError
fp8 AttributeError: 'Fp8Quantization' object has no attribute 'quant_dg' CallCompactUnboundModuleError
nanoo_fp8 AttributeError: 'NANOOFp8Quantization' object has no attribute 'quant_dg' TypeError: Quantization.einsum() got an unexpected keyword argument 'mesh_axes'

This PR fixes the sparse_matmul=False column. The quant_dg failures in the other column are a separate bug (get_quantization_dtypes reads an attribute the fp8 classes do not define) and are left alone here.

The dense path is what runs wherever the megablox and ragged kernels are unavailable, which is why this has gone unnoticed: TPU and NVIDIA runs take the sparse path.

Fix

Bridge the einsums into NNX rather than calling them unbound.

  • An fp8 einsum keeps its scaling factors and amax histories in Linen variables, so it is bridged when the parent module is built rather than on the first call. Creating that state during __call__ would grow the module graph inside the scanned layer loop, which NNX rejects, and would allocate it under a trace. The state has a fixed shape (scales (1,), amax histories (1024,)), so a canonical operand pair materializes it and the bridged einsum still accepts operands of any shape.
  • AQT is bridged on first use instead, since its state is shaped after the operands.
  • Each call site passes a stable einsum_name, so no two share quantization state.

NANOOFp8Quantization additionally lost its einsum and its place in the isinstance check during the same migration. Both are restored, which also gives the now-unreferenced Fp8Einsum class in quantizations.py its purpose back. Both are still present on release/v26.3.

Tests

Four tests in train_tests.py, all of which fail before this change:

  • test_moe_int8, test_moe_fp8, test_moe_nanoo_fp8
  • test_moe_fp8_token_dropping, which adds the dispatch and combine einsums via capacity_factor > 0

They deliberately carry no hardware marker. This is a binding bug that breaks identically on every backend, both fp8 flavors are emulated in XLA rather than needing hardware support, and the whole set runs on CPU in about 30 seconds.

Verification

Tiny Mixtral and tiny Gemma 4 26B, one step on CPU, all training after the change: int8 / fp8 / nanoo_fp8, with and without token dropping, scanned and unrolled layers, and under both the pure-NNX and the Linen decoder. pylint reports no new findings and pyink is clean.

`RoutedMoE.get_einsum` builds a Linen einsum and calls it inline, which only worked
while the MoE layer was itself a Linen module. Since the move to NNX there is no Linen
scope to bind to, so every quantization fails on the dense_matmul path. A tiny Mixtral,
one step on CPU:

  int8        CallCompactUnboundModuleError
  fp8         CallCompactUnboundModuleError
  nanoo_fp8   TypeError: Quantization.einsum() got an unexpected keyword argument 'mesh_axes'

Bridge the einsums into NNX instead. fp8 keeps its scaling factors and amax histories in
Linen variables, so those are created with the parent module rather than on the first
call: allocating them later would grow the module graph inside the scanned layer loop,
which NNX rejects, and puts the allocation under a trace. Their shape is fixed, so a
canonical operand pair materializes them and the einsum still takes operands of any shape.
AQT bridges on first use, since its state is shaped after the operands.

NANOO also lost its `einsum` and its place in the isinstance check in the same migration;
both are restored, which gives the orphaned `Fp8Einsum` class its purpose back.

The four new tests carry no hardware marker: this is a binding bug that shows up on every
backend, and both fp8 flavors are emulated in XLA, so they run on CPU in seconds. All four
fail before this change.

Still broken and left alone: fp8 on the sparse_matmul path, where `get_quantization_dtypes`
reads `self.quant.quant_dg`, which the fp8 classes do not have.

@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 support for quantized einsums in Mixture of Experts (MoE) layers, bridging Linen-based FP8 and NANOO FP8 einsums into NNX. It adds helper functions create_fp8_einsum and apply_einsum_in_nnx to manage quantization state, updates the MoE layer to utilize these quantized einsums during dense matrix multiplications, and adds integration tests. Feedback was provided to add a defensive check in get_einsum to prevent a cryptic KeyError if an unregistered einsum_name is accessed in self.quant_einsums.

Comment thread src/maxtext/layers/moe.py
Comment on lines +2724 to +2725
if self.quant_einsums is not None:
return self.quant_einsums[op_id](*args, mutable=["_overwrite_with_gradient"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If get_einsum is called with an unexpected or default einsum_name (which defaults to None, resulting in op_id = "einsum"), looking up op_id in self.quant_einsums will raise a cryptic KeyError since "einsum" is not registered in quant_einsums. Adding a defensive check with a clear error message will make debugging much easier if this method is called with an unregistered name.

Suggested change
if self.quant_einsums is not None:
return self.quant_einsums[op_id](*args, mutable=["_overwrite_with_gradient"])
if self.quant_einsums is not None:
if op_id not in self.quant_einsums:
raise ValueError(
f"Einsum name '{op_id}' is not registered in quant_einsums. "
f"Available names: {list(self.quant_einsums.keys())}"
)
return self.quant_einsums[op_id](*args, mutable=["_overwrite_with_gradient"])
References
  1. Ensure appropriate checks or guards exist before accessing dictionary keys to handle invalid inputs or states safely.

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.50000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/maxtext/layers/quantizations.py 83.33% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant