perf(refit): batch MXFP8 expert prequantization - #3804
Conversation
Reduce MXFP8 and ModelOpt refit overhead while preserving transport and checkpoint-engine lifecycle correctness. Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
Signed-off-by: sna <sna@nvidia.com>
…optimization-squashed # Conflicts: # nemo_rl/algorithms/grpo.py
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
…timization-squashed Signed-off-by: seonjinn <sna@nvidia.com>
…optimization-squashed Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
…optimization-squashed Signed-off-by: seonjinn <sna@nvidia.com>
…optimization-squashed Signed-off-by: seonjinn <sna@nvidia.com> # Conflicts: # nemo_rl/models/generation/vllm/quantization/fp8.py # tests/unit/models/generation/test_vllm_fp8_quantization.py
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
…optimization-squashed
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
seonjinn
left a comment
There was a problem hiding this comment.
Self-review at head 1d1970194
The GitHub diff shows 78 files because the branch stacks on #3294 (88721ced8 is an ancestor of this head); the actual review surface is 7 commits / 4 files (+574/-2): the batched expert prequantization in fp8_train_utils.py, its wiring in megatron_policy_worker.py, and two test files. Everything below is scoped to that delta - #3294's findings (including the blocking prepare_refit_info signature drift and the per-batch scale-check straddle) apply to the base layer and are already posted there.
Process: three domain reviewers (numerical correctness, stream/memory lifetime, tests), then an adversarial verification pass that re-read every citation and fetched Megatron-Bridge at the pinned submodule commit: 8 confirmed, 5 downgraded, 2 refuted, 1 new. No Critical or High correctness bug survived verification - the core batching math, grouping, fallback routing, wire pairing, and all four stream-sync fix commits check out.
Gates for undrafting
- Perf + peak-memory rerun at current head. The body's prototype table predates the four fix commits and is already labeled as pending rerun - just noting the memory side should be measured too: with interleaved per-expert export order,
pendingextends the lifetime of up to ~46 projection tensors (~540 MiB for 235B-shaped experts) plus one reused ~192 MiB stacking scratch per(device, dtype, stream)key. Much of that is memory Megatron-Bridge's grouped export already materializes, so the true delta needs atorch.cuda.max_memory_allocated()before/after on a real MoE refit rather than arithmetic. - One worker-composition test (inline below): the only worker-level test stubs out the real iterator, so an export-naming drift would silently disable batching (correctness preserved via per-tensor fallback, but the PR's entire perf value gone with no failing test).
Verified clean (adversarial pass, own citations)
- Batched vs per-tensor parity: MXFP8 quantization is row-independent, so the flatten-stack-quantize-unflatten path is mathematically identical to per-tensor; the CPU parity test does a genuine bitwise
torch.equalagainst the real per-tensor quantizer (not a tautology) and covers both the multi-block and squeezed single-block scale shapes. E8M0 zero-scale clamp and thesqueeze(dim=-1)behavior are inherited verbatim through the sharedquantize_fn, not reimplemented. - Stream handshake complete at every producer-consumer edge (pending-input to stack, stack to quantize output, per-tensor fallback), each with a CUDA-gated regression test targeting exactly that edge. The load-bearing assumption - that
export_hf_weightsproduces on the ambient stream - was verified against Megatron-Bridge at submodule pin8c46dc425: zero stream primitives inmodel_bridge.py/param_mapping.py, synchronous collectives,cpu=False. Holds today; worth a comment for future Bridge bumps (inline). - Grouping/fallback: layer+projection-scoped grouping with flush-on-prefix-change; shape/dtype/device/layout mismatch, lone remainders, duplicate expert ids, fused
w13-style names, andshared_expertsall route to the per-tensor or passthrough path correctly. Chunk arithmetic has no off-by-one;max_experts_per_batch<=0raises,==1degenerates to exact old-path behavior. - The scratch-release fix (
34da20e46) is the right call: the buffer now lives only for the export pass, andrecord_streambookkeeping makes an explicit sync unnecessary.
Refuted by the adversarial pass (so nobody re-flags them)
- "Shape-mismatch fallback never actually triggered in tests" - false; the
(3,64)parametrized case drives a real mismatch through the fallback with value assertions. - "The grad-enabled fixture wouldn't catch a removed
no_gradguard" - false; thetorch.stack(..., out=)in the test path is real production code and would raiseRuntimeErrorwithout the guard, which is exactly what commit09591f0b2fixed.
Notes
- Race-window tests use fixed
torch.cuda._sleep(5_000_000)cycles; the failure mode on faster future GPUs is a spurious pass (missed detection), not flakiness - fine for CI, worth a comment. - Test niceties, all Low: a zeroed row through the CPU parity test (clamp through the batched reshape), a dtype-mismatch fallback case, a 16/17-expert default-boundary case.
| list(worker._maybe_prequantize_param(name, tensor)) | ||
|
|
||
|
|
||
| def test_iter_params_batches_expert_prequantization(monkeypatch): |
There was a problem hiding this comment.
[Medium] The only worker-level test stubs out the real iterator, so batching can be silently disabled with no failing test.
This test monkeypatches fp8_train_utils.iter_mxfp8_prequantized_params with a stub (effective because the call site does a deferred from-import on every call, megatron_policy_worker.py:2256-2258), and nothing else composes the worker with the real iterator. The direct unit tests do exercise _EXPERT_WEIGHT_PATTERN against realistic HF export names, so the regex isn't untested - but if Megatron-Bridge's export naming ever drifts, every selected tensor falls back to per-tensor quantize_one: correctness preserved, the PR's entire perf win silently gone, and no test turns red.
Suggested: one composition test that runs the real iter_mxfp8_prequantized_params behind a fake export_hf_weights yielding export-shaped expert names and asserts batched (stacked) quantize calls actually occur.
| current_prefix = prefix | ||
| group_key = (prefix, projection) | ||
| group = pending.setdefault(group_key, []) | ||
| source_stream = ( |
There was a problem hiding this comment.
[Low] Two ambient-stream assumptions worth a comment - both hold today, neither is checked.
torch.cuda.current_stream()sampled at consumption time is assumed to be the streamexport_hf_weightsproduced on. Verified true at the pinned Megatron-Bridge commit8c46dc425(no stream primitives anywhere inmodel_bridge.py/param_mapping.py, synchronous collectives, ambient-stream export loop) - but a future Bridge bump that introduces a side/copy stream would turn everywait_streamin this file into a silent race (intermittent bad weights, not a crash).- The scratch cache key uses the raw
cudaStream_tpointer (int(stack_stream.cuda_stream)below). If atorch.cuda.Streamwere created and destroyed within one export pass, CUDA can reissue the pointer and two different streams would alias one scratch entry. No current call site does this, but the failure would be silent corruption rather than an error.
A short comment documenting both ("producer must be the ambient stream; streams must outlive the export pass") makes the contract auditable at the next submodule bump.
| return x_q, x_scales | ||
|
|
||
|
|
||
| def iter_mxfp8_prequantized_params( |
There was a problem hiding this comment.
[Low] scratch_cache is per-export-pass by design in production - say so, or the next reviewer re-flags it.
The only production call site (megatron_policy_worker.py:2260-2263) passes no scratch_cache, so cross-refit reuse never happens - and that's intentional: 34da20e46 deliberately removed the worker-level cache so the ~192 MiB stacking buffer doesn't stay resident through training. What remains meaningful is in-pass reuse (one buffer shared across all layers/projections of a single export). The parameter is now test-injection-only API, and test_batched_expert_prequantization_preserves_wire_entries_and_reuses_scratch still asserts cross-call reuse as if it were a production behavior. A one-line docstring note (or reframing that assertion as a library-capability check) closes the trap. Do not wire a persistent cache back in - that would undo the memory-lifetime fix.
| (name + "_scale_from_checkpoint", scale[index]), | ||
| ) | ||
| ) | ||
| yield from yield_on_current_stream(entries, producer_stream) |
There was a problem hiding this comment.
[Low, latent] Yielded per-expert tensors are views pinning the whole <=16-expert batch.
value[index] / scale[index] are views into the single stacked quantize output, so any downstream consumer that retains one expert's tensor keeps the entire batch's value and scale allocations alive. The current receiver copies into vLLM params promptly, so nothing leaks today - but a persistent-IPC or caching consumer would silently hold up to 16x the expected memory per retained entry. A one-line comment is enough; .contiguous()/clone per entry would defeat the batching win and is not recommended.
| ) | ||
| else: | ||
| for name, tensor in base_iter: | ||
| yield from self._maybe_prequantize_param(name, tensor) |
There was a problem hiding this comment.
[Info] _maybe_prequantize_param's quantization branch is now dead in production.
This else branch only runs when self._refit_prequant_names is empty, and _maybe_prequantize_param passes through whenever the name isn't in that set - always true here. All real quantization now flows through iter_mxfp8_prequantized_params; the old helper's quantize branch is reachable only from its direct unit tests. Worth a comment (or folding the helper away) so a future reader doesn't assume it's still on the hot path.
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Adapt PR to main's refit and quantization API changes: - migrate all recipes and tests from quantization_ignored_layer_kws to quantization_ignore_patterns (including PR-added B200 n8g recipes) - route refit buffer sizing through CollectiveSenderSpec; user-provided refit_buffer_size_gb overrides the sender spec buffer size - drop http_weight_synchronizer changes (file deleted on main) - restrict cached loader routes to the non-layerwise fast path; deferred layerwise weight_loader calls make the route cache unsound - enable refit_prequantize/refit_cache_loader_routes in main's new deepseek-v3 and nemotron3-super mxfp8 recipes - allow B200-only mxfp8 recipes outside the GB200 performance suite Signed-off-by: seonjinn <sna@nvidia.com>
|
/ok to test cd825d2 |
Signed-off-by: seonjinn <sna@nvidia.com>
|
/ok to test 6fbbb27 |
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
|
/ok to test 5ccba2f |
Signed-off-by: seonjinn <sna@nvidia.com>
Signed-off-by: seonjinn <sna@nvidia.com>
|
/ok to test e83281e |
Three L0 failures on this head were fixture drift, not production bugs: - experts_cls stub now exposes is_monolithic() - model_runner stubs now carry vllm_config.additional_config - offload_after_refit worker stub now sets finalize_async_save and the colocated-generation attributes, and asserts the move_params kwarg Signed-off-by: seonjinn <sna@nvidia.com>
|
/ok to test 1c9e4b5 |
Summary
Batch matching MoE expert weights before trainer-side MXFP8 prequantization. The implementation quantizes at most 16 experts per call, reuses stream-local stacking buffers within each export pass, and releases them before training resumes. Stream handoffs synchronize pending inputs, batched outputs, and per-tensor fallbacks before packing. The path keeps the existing weight and scale entries consumed by vLLM.
Non-expert weights and expert groups that cannot be batched keep the existing per-tensor path.
Depends on #3294, which adds trainer-side prequantized refit. Receiver-side MXFP8 layout conversion remains in #3478.
Why
The current prequantized path launches one quantization operation per expert tensor. Large MoE models repeat that operation hundreds of times per refit. Batching tensors with the same layer, projection, shape, dtype, and device reduces launch and synchronization overhead without changing the refit wire format.
Prior prototype results
Qwen3-30B-A3B, GB200, synchronous colocated GRPO, CUDA Graph enabled, 20 steps:
The 20-step prototype run completed with unchanged generation KL error (
0.00398). The current head adds stricter export-order, scale-shape, memory-lifetime, and CUDA-stream handling. Its end-to-end performance rerun is still pending.Validation
1d1970194b868553d997a8d234976e3caff1b337passed 17 MXFP8/layout tests and 1 Megatron worker integration test on GB200Current-head Qwen3-30B-A3B performance revalidation and Qwen3-235B end-to-end memory and performance validation are still pending, so this PR remains a draft.