Fix quantized MoE on the dense_matmul path - #4955
Conversation
`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.
There was a problem hiding this comment.
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.
| if self.quant_einsums is not None: | ||
| return self.quant_einsums[op_id](*args, mutable=["_overwrite_with_gradient"]) |
There was a problem hiding this comment.
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.
| 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
- Ensure appropriate checks or guards exist before accessing dictionary keys to handle invalid inputs or states safely.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Problem
RoutedMoE.get_einsumconstructs 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 thedense_matmulpath. Tiny Mixtral, one step, CPU:sparse_matmul=Truesparse_matmul=Falseint8CallCompactUnboundModuleErrorfp8AttributeError: 'Fp8Quantization' object has no attribute 'quant_dg'CallCompactUnboundModuleErrornanoo_fp8AttributeError: 'NANOOFp8Quantization' object has no attribute 'quant_dg'TypeError: Quantization.einsum() got an unexpected keyword argument 'mesh_axes'This PR fixes the
sparse_matmul=Falsecolumn. Thequant_dgfailures in the other column are a separate bug (get_quantization_dtypesreads 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.
__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.einsum_name, so no two share quantization state.NANOOFp8Quantizationadditionally lost itseinsumand its place in the isinstance check during the same migration. Both are restored, which also gives the now-unreferencedFp8Einsumclass inquantizations.pyits purpose back. Both are still present onrelease/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_fp8test_moe_fp8_token_dropping, which adds the dispatch and combine einsums viacapacity_factor > 0They 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.pylintreports no new findings andpyinkis clean.