Repository navigation
Conversation
1bd419b to
a40304d
Compare
3d01ae8 to
f2f8fab
Compare
f2f8fab to
d9b0ba0
Compare
de0cd58 to
c35c1d1
Compare
c35c1d1 to
be7b41c
Compare
b17b511 to
926785c
Compare
|
/review |
|
Do we have the flash infer with nvfp4 in the ci container? |
| weights = _value_pass_weights.get(experts) | ||
| if weights is None: | ||
| if _optimizer_step_hook is None: | ||
| _optimizer_step_hook = register_optimizer_step_post_hook( |
There was a problem hiding this comment.
I think this cache can go stale in the main RL flow. NeMo-RL's use_reference_model swaps the reference weights in with param.copy_() (no optimizer step, no load_state_dict), so the reference logprob pass would reuse the policy's cached kernel weights and the KL would be computed against the policy. Nothing errors out.
Could we key the cache on the source params' _version (the underlying data tensors for MXFP8) instead of relying only on the optimizer/load hooks? That would also catch in-place copies, EMA swaps, etc.
There was a problem hiding this comment.
Good catch, fixed. The cache is now keyed on the address and _version of every source tensor: the FP8 data and scales for TE MXFP8 parameters, and the parameter itself otherwise. That catches in-place copies and EMA swaps.
| return output, None | ||
|
|
||
|
|
||
| def _build_value_pass_weights(experts: InferenceGroupedMLP): |
There was a problem hiding this comment.
For MXFP8 this is basically a second copy of every local expert weight (~330 MB/layer/rank on Nano), which gets big fast on larger MoEs where expert weights dominate memory. Also since it lives in a module-level dict rather than params/buffers, it stays on the GPU when the trainer offloads the model to CPU before colocated generation, unless someone calls release_inference_value_pass_weights, and nothing calls it today.
Could we at least drop it automatically on offload? Longer term it'd be nice to write FlashInfer's layout directly from the main-param update so there's only one copy.
There was a problem hiding this comment.
Agreed that it's a second copy of the local expert weights in FlashInfer's layout: about 330 MB per layer per rank for MXFP8 at Nano-v3 shapes, and about 180 MB for NVFP4, which is smaller than TE NVFP4's own quantized copy. It's built on the first value pass after a weight change and reused until the next optimizer step, state_dict() call, checkpoint load or in-place write.
Megatron has no single offload point it could hook without guessing, so the cache is dropped explicitly: release_inference_value_pass_weights(model) frees it, and the next value pass rebuilds it from the parameters. For colocated generation, the trainer should call it before offloading. In NeMo-RL that's offload_before_refit / offload_after_refit in megatron_policy_worker.py. I'll send that as a NeMo-RL follow-up rather than reaching into the trainer from Megatron.
Writing FlashInfer's layout straight from the main-parameter update, so there's only one copy, is the right long-term fix. It needs a change in the optimizer's parameter-update path, so I'd like to keep it out of this PR.
| min_rows = 0 | ||
| if layer.experts._nvfp4_recipe is None: | ||
| # Above 128 routes per local expert the kernel's tile is its largest, 256. | ||
| min_rows = 128 * layer.num_local_experts // layer.config.moe_router_topk + 1 |
There was a problem hiding this comment.
This hard-codes FlashInfer's internal tile selection (>128 routes per local expert -> 256 tile). If a FlashInfer release changes that heuristic, parity breaks silently. Could we pin the FlashInfer version for this feature, or check which tile actually got picked at runtime?
There was a problem hiding this comment.
I removed the version check rather than pinning, because the floor can't silently break parity. In batch-invariant mode, the kernel's output rows don't depend on the tile it picks; the token-count parity tests check that. The floor only avoids the kernel's "No valid config" error on very small launches, so if FlashInfer changes its heuristic, the worst case is a loud failure, not a numeric difference.
|
did you consider scoping the swap to just the experts instead of the whole MoE layer? Under The combine order is the part I'm less sure about. Is that the main reason you went through generation's dispatcher? Curious if there's something else I'm missing. |
Add moe_match_generation_forward: the training forward of an inference-optimized MoE layer runs the FlashInfer routed MXFP8 or per-token NVFP4 kernel that generation runs, through generation's dispatcher, so a training log-prob equals its generation log-prob bitwise. Under selective 'moe' recompute the TE recompute pass supplies the backward, which ignores the forward's quantization as every quantized TE recipe does. The FlashInfer kernels fuse the activation and FC2-input quantization into the FC1 epilogue, apply routing probabilities after FC2 and accumulate in their own order, so TE training cannot be aligned to them by hand. Torch and vLLM need no value pass: under batch_invariant_mode they already match TE training. - TransformerConfig: moe_match_generation_forward. Requires inference_optimized with the flashinfer backend, MXFP8 or inference_moe_nvfp4_recipe, batch_invariant_mode and selective moe recompute; rejects local CUDA graphs, the overlapped 1F1B schedule, router settings that change expert selection and expert tensor parallelism. - MoELayer: the value pass routes through generation's router path, so the kernel sums each token's routes in generation's top-k order, and uses generation's dispatcher. On NVLS it reuses generation's symmetric heap, or without generation in the process allocates it as a generation context with the default max_tokens would; passes larger than the heap run in heap-sized launches. A heap built for another EP size is rejected. - InferenceGroupedMLP: generation and the value pass share _flashinfer_routed_forward, generation's unchanged kernel calls. The value pass rebuilds the kernel weights from the parameters on every forward, so it never reads weights from before an optimizer step. - Refit: per-token NVFP4 expert weights are requantized after a refit instead of raising NotImplementedError. Tests: config validation and route order (CPU); bitwise train-gen parity on the NVLS setup for MXFP8, NVFP4 squared ReLU and SwiGLU; parity after a weight update, across token counts and with separately allocated heaps; heap chunking; forward/backward mismatch no larger than TE's MXFP8 and per-token NVFP4 recipes; bit-exact determinism replay of the value pass with its recompute backward. Signed-off-by: YigongQin <yigongq@nvidia.com>
- Refresh NVLS dispatch metadata on every value-pass chunk. - Do not apply generation's padding mask to training rows. - Require MoE in the effective recompute modules. - Recompute for grad-enabled eval forwards. - Double-quote new literals. Signed-off-by: YigongQin <yigongq@nvidia.com>
…rows - Rebuild the FlashInfer kernel weights on the first microbatch of each forward-backward call, as TE refreshes its quantized weights. - Launch MXFP8 and NVFP4 on only the rows that hold tokens; MXFP8 keeps the rows for the kernel's largest token tile. - Test routed MXFP8 batch invariance across launch row counts. Signed-off-by: YigongQin <yigongq@nvidia.com>
- Run the expert linears' pre-forward hooks before reading their weights, so overlapped DDP parameter all-gathers complete first. - set_is_first_microbatch drops only the value pass's cached weights instead of re-arming is_first_microbatch on every module. Signed-off-by: YigongQin <yigongq@nvidia.com>
MegatronModule.release_match_generation_weights() drops the cached kernel weights; set_is_first_microbatch calls it. A caller such as an RL trainer can call it after a forward-backward call so the weights are not held through the optimizer step or colocated generation. Signed-off-by: YigongQin <yigongq@nvidia.com>
Rename moe_match_generation_forward to moe_training_matches_inference and move the value pass into moe/training_matches_inference.py. Grad mode picks the generation forward (checkpoint's no-grad first call, or an eval forward) or the training forward (recompute), so MoELayer keeps no state and custom_forward has no branches. Generation's _flashinfer_forward is unchanged. Drop tests that duplicate the new test file's coverage, and add the field to the hybrid MoE config snapshot. Signed-off-by: YigongQin <yigongq@nvidia.com>
…xperts and module The value pass calls the FlashInfer kernel from training_matches_inference.py instead of through a flag and branch in InferenceGroupedMLP.forward. Its kernel weight cache moves there too, keyed weakly by experts module, and is dropped by an optimizer step post-hook and a load_state_dict post-hook registered from that file, so MegatronModule.set_is_first_microbatch is unchanged from main. The release API becomes training_matches_inference.release_inference_value_pass_weights(model). Signed-off-by: YigongQin <yigongq@nvidia.com>
Signed-off-by: YigongQin <yigongq@nvidia.com>
…ugh training's all-to-all dispatcher The value pass no longer borrows generation's NVLS dispatcher or buffers. Training's all-to-all dispatcher sends each route as its own row, FlashInfer runs every row as a top-1 token with probability 1.0, and the batch-invariant unpermute, in a new BF16_RANK_PARTIALS mode, adds each EP rank's routes in generation's top-k order as FlashInfer's finalize does before adding the ranks as generation's combine does. - Require moe_token_dispatcher_type='alltoall' for the flag. - Drop the per-layer .item() sync, the chunked launches and the FlashInfer version guard on the MXFP8 tile floor. - Drop the cached kernel weights on state_dict(), since TE writes into MXFP8 parameters bypass the version counter. Signed-off-by: YigongQin <yigongq@nvidia.com>
830d368 to
6e77cf3
Compare
Done. The value pass now runs training's router module and all-to-all dispatcher, with no generation dispatcher, NVLS buffers or per-layer sync. Only the per-rank combine order needed matching: FlashInfer sums a token's routes on each rank in top-k order (fp32 FMA, then BF16). A new mode of the batch-invariant unpermute kernel does the same; with the mode off, the existing path is unchanged. FlashInfer runs each dispatched row as a top-1 token with probability 1.0. |
|
/ok to test 6e77cf3 |
What does this PR do?
This PR adds one opt-in knob,
moe_training_matches_inference, meant to be enabled only by RL training. It is off by default, and generation's forward is unchanged with it on or off.With the knob on, the training forward of an inference-optimized MoE layer runs the same FlashInfer routed kernel that generation runs. A token's training log-prob then equals its generation log-prob bitwise, for FlashInfer routed MXFP8 and per-token NVFP4 MoE. The backward is unchanged: it comes from the Transformer Engine (TE) recompute pass. The requirement is that this adds no forward–backward inconsistency beyond what the default TE recipe already has; see Core metric.
Numbers below are for one MoE layer with Nano-v3 shapes, GB300 × 4, EP 4, eager; see Performance. The value pass runs through training's own router module and all-to-all dispatcher, so training needs none of generation's buffers.
Training keeps one derived copy of the expert weights for FlashInfer: BF16 plus NVFP4 for per-token NVFP4, and MCore MXFP8 plus FlashInfer's routed layout for MXFP8.
Per-token NVFP4: faster and smaller, no trade-off
Against TE's per-token NVFP4 recipe, the same quantization scheme:
Checked by bitwise parity with generation (including after a refit), kernel batch invariance, bit-exact replay under contention, and the core metric; see What is tested.
MXFP8: faster, with extra memory during each call
Against TE MXFP8:
Checked by the same parity and core-metric tests; the parity tests also cover a token's output across launch sizes.
The drawback is memory held between optimizer steps:
fp8_paramweights already are its kernel's input.training_matches_inference.release_inference_value_pass_weights(model)frees it, for example before colocated generation.inference_grouped_gemm_backend: torch), which already matches TE training bitwise underbatch_invariant_mode, without this flag or the cache.Usage
On top of generation's FlashInfer settings (
inference_optimized,batch_invariant_mode, and per-token NVFP4 or MXFP8 withfp8_param), the RL trainer adds:The value pass also runs under
model.eval(), so the RL log-prob forward takes it too.Training-side memory
state_dict()call, checkpoint load, in-place write to the parameters, ortraining_matches_inference.release_inference_value_pass_weights(model). The log-prob pass and the training microbatches of one step share a single build. Against TE without recompute, the MoE activations recompute frees outweigh the MXFP8 cache from about 4096 tokens per rank per microbatch.Background
Under
batch_invariant_mode, the torch and vLLM inference MoE kernels already match TE training bitwise. FlashInfer, the production path for MXFP8 rollouts, cannot: #7302 reports the MXFP8 mismatch, and per-token NVFP4 from #8262 is batch invariant but does not match training either. FlashInfer fuses the activation and the FC2-input quantization into the FC1 epilogue, applies routing probabilities after FC2, and accumulates in its own order, so the training path cannot be aligned to it by hand. In RL, the gap shows up as a bias in the importance ratio.FlashInfer owns only the per-rank expert compute and the sum of each token's routes on a rank (its finalize); the router, dispatch and cross-rank combine are Megatron's. Under
batch_invariant_mode, training's router already picks generation's experts and training's all-to-all combine already adds the EP ranks as generation's does. The training forward therefore keeps training's router and dispatcher, and reproduces only FlashInfer's expert compute and its per-rank sum.Scope
The forward and the backward come from different libraries, so the gradient is not the derivative of the forward. That only matters for recipes whose backward was the exact derivative of their forward to begin with, and those are out of scope:
For the in-scope recipes, the backward stays exactly as it was. The forward moves from one quantized implementation to another that differs only in quantization details, accumulation order and where the routing probabilities are applied. FlashInfer's routed MoE has no backward, so an all-FlashInfer path is not an option.
Core metric: no extra forward–backward inconsistency
Claim. Using generation's forward does not make the gradient fit the loss worse than the default TE recipe already does.
Quantization has zero derivative almost everywhere, so a quantized backward computes the gradient of the BF16 layer, not of its quantized forward:
backward_override='high_precision')The inconsistency is the gap between the quantized forward and the BF16 layer. Within each recipe the backward is identical, so the metric is each forward's relative distance from BF16 on the MoE output, with the same weights and tokens:
Pass condition, for every recipe, seed and input type:
Both forwards use the same quantization scheme, so either can land slightly closer to BF16; 1% is small next to the roughly 20% both are from BF16.
How it is measured:
TestForwardBackwardMismatchintests/unit_tests/inference/test_moe_training_matches_inference.py.NVFP4BlockScaling(row_scaled_activation=True, disable_2d_quantization=True, disable_rht=True, disable_stochastic_rounding=True, backward_override='high_precision'), the same scheme as FlashInfer's, so the comparison measures the kernel rather than the scheme.Results (EP = 4, GB200). Each cell is the range over seeds 11–13 (lower is better). The last number column is the largest amount by which this PR exceeds TE on any seed; negative means this PR is closer to BF16 on every seed.
The test also prints the worst single token's distance, which is not part of the pass condition:
Limits. This compares one layer's forward, not a training curve. A finite-difference gradient check does not work here, because the loss of a quantized forward is piecewise constant in the weights.
Performance
One MoE layer with Nano-v3 shapes (hidden 2688, 128 experts, top-6, squared ReLU, shared expert), GB300 × 4, EP 4, batch-invariant mode, eager, mean of 20 iterations. The baseline is TE in the same quantization: TE MXFP8, or TE's per-token NVFP4 recipe (FlashInfer's scheme, run outside batch-invariant mode). Peak is the extra memory allocated during the pass, on top of resident memory; the cached kernel weights are resident, so they appear under static memory, not peak. All numbers come from one run.
Train (forward + backward). The flag requires
moerecompute, so the baseline is TE + recompute. The tables time microbatches after the first, where neither side converts weights.Weight rebuild. The first value pass after each weight change also rebuilds the kernel weights: about 36 ms per MoE layer for MXFP8 (stack and FlashInfer shuffle of the TE MXFP8 parameters) and about 18 ms for NVFP4 (requantization from BF16), independent of the token count. The cost is shared across the step's microbatches and its log-prob pass. The rebuilt weights are allocated inside that first forward, so its saved memory includes them (about 326 MiB MXFP8, 200 MiB NVFP4).
Log-prob (
eval()forward underno_grad). Nothing is saved for backward and nothing is recomputed, so the baseline is plain TE.Script:
bench_match_generation_forward.py(not part of this PR).--weight-cache cached(the default) gives the tables above;--weight-cache rebuildresets the cache before every pass, which gives the rebuild cost.What is changed
TransformerConfig.moe_training_matches_inference(default off). Construction fails unless:transformer_impl='inference_optimized'andinference_grouped_gemm_backend='flashinfer'. Torch and vLLM need no value pass.inference_moe_nvfp4_recipeis set. FlashInfer BF16 is not batch invariant.batch_invariant_modeis on,moe_token_dispatcher_type='alltoall', and selectivemoerecompute is enabled.MoELayer.forwardhands off totraining_matches_inference_forwardwhen the flag is on and inference is not active;custom_forwardis unchanged. Everything else is in the newmoe/training_matches_inference.py, and the layer keeps no state:precision_aware_checkpoint, whose first call runs without grad and whose recompute runs with it. The grad mode picks the pass: no grad runs the value pass, grad runs the trainingcustom_forward(the recompute, which supplies the gradient). Ano_gradeval forward, the RL log-prob pass, runs the value pass directly.InferenceMode.active(), for that call only), which returns each token's top-k routes in generation's order. Training's routing map does not keep that order, and FlashInfer sums a token's routes in it. This path also leaves the router's expert-bias token counts to the recompute.BF16_RANK_PARTIALSmode of the batch-invariant unpermute kernel (moe/batch_invariant.py,unpermute_with_bf16_rank_partials). For each EP rank it adds the token's routes on that rank in top-k order as FP32 fused multiply-adds of probability and row, from zero, and quantizes the sum to BF16, as FlashInfer's finalize does. It then adds the ranks as the existing batch-invariant combine does, matching generation's ordered or multimem combine. The existing training path compiles the kernel with the mode off, unchanged.training_matches_inference.py, on kernel weights built from the training parameters (MXFP8: the Major-K shuffle from TE's MXFP8 parameters; NVFP4: the requantization from BF16).InferenceGroupedMLPkeeps no value-pass state and itsforwardhas no value-pass branch; its only change is allowing the TE MXFP8 recompute with the flag on. Generation's_flashinfer_forwardis unchanged.training_matches_inference.py, keyed weakly by experts module, with the address and version of every source tensor, so an in-place write to the parameters rebuilds them. TE writes into MXFP8 parameters bypass the version counter, so a process-wide optimizer step post-hook (torch.optim.optimizer.register_optimizer_step_post_hook) andstate_dictandload_state_dictpost-hooks on the experts, all registered there, also drop them. A reference-model swap that copies intostate_dict()tensors, as NeMo-RL's does, is covered.training_matches_inference.release_inference_value_pass_weights(model)drops them on request, for example before colocated generation. The training pipeline (MegatronModule, the schedules, the optimizer) is unchanged.refresh_cache()requantizes the NVFP4 copy after a refit, replacingmain'sNotImplementedError.fp4with inference-optimized MoE. The MXFP8 recompute runs TE MXFP8.What is tested
The tests are in
tests/unit_tests/inference/test_moe_training_matches_inference.py. GPU tests use EP = world size with the all-to-all dispatcher, themxfp8,nvfp4-squared_reluandnvfp4-swiglurecipes, batch-invariant mode, and top-k 8:eval().state_dict()tensors as NeMo-RL's reference swap does, or the release function, and reused otherwise. Training on 512 and 2048 tokens equals generation decoded 8 tokens at a time.tests/unit_tests/determinism/kernels/test_moe_kernels.py::test_inference_kernel_value_pass_replays[nvfp4-*]: the value pass, recompute and backward replay bit-exactly under contention (EP=1, all-to-all dispatcher).tests/unit_tests/determinism/kernels/test_moe_kernels.py::test_mxfp8_training_stack_replays: the MXFP8 training-stack quantization replays bit-exactly.tests/unit_tests/inference/test_flashinfer_nvfp4.py: a refit requantizes the NVFP4 kernel weights, and refit accepts built NVFP4 expert weights.uv run python -m torch.distributed.run --nproc-per-node 4 -m pytest -q -s \ tests/unit_tests/inference/test_moe_training_matches_inference.py uv run python -m torch.distributed.run --nproc-per-node 4 -m pytest -q -s \ tests/unit_tests/determinism/kernels/test_moe_kernels.py -k "value_pass or mxfp8_training_stack" uv run python -m torch.distributed.run --nproc-per-node 4 -m pytest -q -s \ tests/unit_tests/transformer/moe/test_moe_batch_invariant_unpermute.py uv run python -m torch.distributed.run --nproc-per-node 4 -m pytest -q -s \ tests/unit_tests/inference/test_flashinfer_nvfp4.py -k refitResult (GB300 × 4):
test_moe_training_matches_inference.py52 passed; the determinism, batch-invariant unpermute and refit tests pass.Limitations
Contribution process
Pre-checks
jit_fuser/torch.compile, CUDA extension, TE or external-library dispatch, or a scatter/index accumulation), I have added or updated its bit-exact determinism test and registered it intests/unit_tests/determinism/kernels/manifest.py(guide)