Repository navigation
[OMNIML-6085] Run expert-parallel PTQ from hf_ptq.py, with lockstep previews - #3139
shengliangxu wants to merge 24 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review: 3
Please check the settings in the CodeRabbit UI or the
|
|
/ok to test f81be89 |
|
/claude review |
| except Exception as e: | ||
| warnings.warn(f"Post-quantization generation sanity check failed, skipping it: {e}") |
There was a problem hiding this comment.
[CRITICAL Distributed] The post-PTQ preview now runs generate(synced_gpus=True) on a sharded model, but its failure is still swallowed — so a rank that fails leaves every peer hanging.
Under synced_gpus, generate issues a dist.all_reduce of this_peer_finished on every decode step. If this generate raises on one rank only (which is exactly what the comment above anticipates: an offloaded layer landing on CPU, an OOM, an unsupported kernel), that rank warns, returns from post_quantize, and walks into the export's own collectives, while its peers sit in the lockstep all-reduce waiting for it. With --use_fsdp2 the group is created with a 2-hour timeout (setup_distributed_args), so the run wedges for two hours and then aborts with a collective mismatch instead of reporting the preview failure.
This is the same hazard the PR deliberately fixes for the VL previews — vlm_utils.py:204 and vlm_utils.py:281 both re-raise under synced_gpus with "the other ranks of a sharded model would wait for this one in lockstep". The non-VL post-PTQ preview is the one path left swallowing it, and lockstep is already in scope one line above:
| except Exception as e: | |
| warnings.warn(f"Post-quantization generation sanity check failed, skipping it: {e}") | |
| except Exception as e: | |
| if lockstep: | |
| # The other ranks are in generate's lockstep all-reduce waiting for this one; | |
| # skipping the check here would leave them there until the group times out. | |
| raise | |
| warnings.warn(f"Post-quantization generation sanity check failed, skipping it: {e}") |
There was a problem hiding this comment.
Kept as is, on purpose; a maintainer should decide whether to change it.
- This is main's deliberate
--use_fsdp2best-effort skip, from the NVBug 6752977 fix (test_post_quantize_export_survives_a_failed_sanity_generate): a failed post-PTQ preview warns, and the export still runs. - The hang for a failure on one rank alone is not new with this PR. Without
synced_gpus, the other ranks wait in that rank's next FSDP2 all-gather instead of the lockstep all-reduce. - Raising under lockstep would stop a failure every rank meets at the same step, such as an unsupported kernel, with no checkpoint, where main exports today. That is a behaviour change for
--use_fsdp2, so it should be a maintainer's call rather than a review fix.
The expert-parallel route (#2754) does raise on a failed preview, since it runs inside distributed_run, which aborts the rank. If the maintainers want --use_fsdp2 to stop the run the same way, it is a small change there. Maintainers, please weigh in.
| ): | ||
| print(f"Initializing model from {ckpt_path}") | ||
| # The plan to load the whole model by on every rank; a single process's by default. | ||
| plan = distribution or DistributionPlan() |
There was a problem hiding this comment.
[IMPORTANT Compatibility] Defaulting to DistributionPlan() and always forwarding it as distribution= silently disables load_pretrained's env-driven routing, including its explicit refusals.
Until now get_model passed no distribution, so load_pretrained reached resolve_distribution_plan (model_load_utils.py:833-844) and consulted MODELOPT_EP_DEGREE / MODELOPT_FSDP_SIZE. Two behaviours change:
- Under
torchrunwithout--use_fsdp2the process group is not set up, soaloneis true. Previouslydistribution is Nonehit the guard atmodel_load_utils.py:748-762, which raises "MODELOPT_FSDP_SIZE=…ask to shard the model over this N-rank launch, whose process group is not set up; load inside distributed_run()". With a one-rank plan handed in,handed()short-circuits that branch entirely: the env var is ignored without a word,plan.world_size == 1sopinnedisNone, and every rank falls through todevice_map="auto"— producing precisely the "Duplicate GPU detected" failure the new comment block below describes. That is the opposite of the intent, and CONTRIBUTING/CLAUDE guidance is to fail loudly rather than silently ignore an option or route. - In a single process, the memory-based auto-FSDP decision is also skipped. That part is arguably the point (a
device_mapwould be refused by the sharded route), so forcing the whole-model plan is defensible — but it should be explicit, not a side effect.
Suggestion: keep the forced whole-model plan, and refuse rather than ignore a sharding request the caller made through the environment — e.g. when distribution is None and MODELOPT_EP_DEGREE/MODELOPT_FSDP_SIZE ask for a sharded layout, raise pointing at --use_fsdp2. A short comment on this line saying why a plan is always handed in (to keep the whole-model route rather than let rank 0's GPU memory choose) would also stop the next reader from "fixing" it back to distribution.
There was a problem hiding this comment.
Fixed in 06d7121: get_model forwards distribution only when its caller gave one, so without a plan load_pretrained decides as before and refuses a MODELOPT_EP_DEGREE / MODELOPT_FSDP_SIZE that asks to shard over a launch whose process group is not set up. Pinning still reads the plan (a single process's by default), and a comment on that line says why. New test test_without_a_plan_the_load_decides_and_refuses_sharding_knobs_it_cannot_honor (WORLD_SIZE=2, MODELOPT_FSDP_SIZE=world, no group) fails with the old forwarding.
| on_cpu = torch.device(device).type == "cpu" | ||
| if plan.world_size > 1 and not on_cpu and ":" not in str(device): | ||
| raise ValueError( | ||
| f"{plan!r} has each rank hold its own copy of the model on its own GPU, but " | ||
| f"device={device!r} names no GPU index; pass the rank's own GPU, cuda:<local_rank>." | ||
| ) | ||
| pinned = {"": str(device)} if not on_cpu and plan.world_size > 1 else None | ||
| device_map = "cpu" if on_cpu else pinned or "auto" |
There was a problem hiding this comment.
[SUGGESTION] pinned keys off plan.world_size > 1 alone, so a sharded plan also gets a device_map the sharded load cannot take.
preview_generate_kwargs (line 128) distinguishes the two cases properly — plan.world_size > 1 and (plan.is_expert_parallel or plan.is_fsdp_dense) — but here any multi-rank plan is treated as "each rank holds its own copy". Hand get_model(..., distribution=DistributionPlan(8, ep_size=8)) or DistributionPlan(8, dense="fsdp") and pinned becomes {"": "cuda:1"}, which load_pretrained then rejects in _refuse_sharded_load_options (model_load_utils.py:548) because device_map is not in _SHARDED_LOAD_OPTIONS. The error is survivable (it names device_map and suggests dense='replicate'), so this is not a correctness hole today, and every new test uses DistributionPlan(4) — but all the function's prose, the refusals, and the max_memory skip below assume replication, and nothing says so.
Using the same predicate makes the assumption explicit and lets get_model refuse early with a message about --use_fsdp2 instead of surfacing a load-option TypeError deep in the loader:
replicated = plan.world_size > 1 and not (plan.is_expert_parallel or plan.is_fsdp_dense)
pinned = {"": str(device)} if not on_cpu and replicated else NoneRelatedly, the use_seq_device_map refusal at line 706 tells the user to "shard the model with --use_fsdp2", which reads oddly if the plan it is refusing is already an FSDP2 one.
There was a problem hiding this comment.
Left as is: get_model loads a whole model on every rank, and its one caller with a plan (#2754's load_model) hands it only replicated plans; expert-parallel and FSDP2 plans go to load_pretrained directly. A sharded plan passed here by mistake still fails loudly, with load_pretrained's refusal naming device_map and dense='replicate'.
There was a problem hiding this comment.
Claude review — 1 CRITICAL, 1 IMPORTANT, 1 SUGGESTION
Scope: full review (the trigger was a bare /claude review). 7 files changed (+596/−88); reviewed all three source files (examples/hf_ptq/example_utils.py, hf_ptq.py, vlm_utils.py) and the four test files. The base is the branch shengliangx/hfep-base-14-hf-ptq, so CodeRabbit auto-review is off for this PR — I covered a bit more ground than usual, but still skipped style/typo territory.
Findings
| # | Severity | Where | Issue |
|---|---|---|---|
| 1 | CRITICAL | hf_ptq.py:882 |
Post-PTQ preview failure is still swallowed while generate now runs with synced_gpus=True → peers hang in the lockstep all-reduce until the 2h group timeout |
| 2 | IMPORTANT | example_utils.py:629 |
Always forwarding distribution=DistributionPlan() silently disables load_pretrained's env routing and its "process group is not set up" refusal |
| 3 | SUGGESTION | example_utils.py:651-658 |
pinned keys off world_size > 1 alone, so a sharded plan also gets a device_map the sharded load rejects |
Most impactful
Finding 1 is the one worth fixing before merge, because it is the same bug the PR exists to fix, left in the one place it was not applied. vlm_utils.py:204 and :281 both now re-raise under synced_gpus — "the other ranks of a sharded model would wait for this one in lockstep" — but post_quantize's non-VL preview keeps its except Exception: warnings.warn(...). Under synced_gpus, generate all-reduces this_peer_finished on every decode step, so a rank whose preview raises (the offload-to-CPU case the comment right above it anticipates) warns, proceeds into the export's collectives, and leaves its peers blocked. lockstep is already bound one line above, so the fix is three lines.
Finding 2 is narrower but goes against the repo's "fail loudly rather than silently ignore an option or route" rule: torchrun + MODELOPT_FSDP_SIZE without --use_fsdp2 used to raise a pointed error from model_load_utils.py:748; with a one-rank plan handed in it now falls through to device_map="auto" and reproduces the "Duplicate GPU detected" failure the new comment block itself warns about.
What held up
preview_generate_kwargs's sharded predicate is right:world_size > 1 and (is_expert_parallel or is_fsdp_dense). A replicated multi-rank plan runs no forward collectives, andDistributionPlan(1, dense="fsdp")correctly needs no lockstep — both parametrized in the new tests.keep_the_experts_implementationis sound: it shadows the class method with an instance attribute, sonullcontextis called withoutself(matching the real_optimize_model_for_decode(self)signature),vars(module).get(...)distinguishes a class attribute from an instance-held hook,nn.Module.__delattr__falls through toobject.__delattr__for a plain attribute, and thefinallyrestores on a failedgenerate. Walkingmodel.modules()rather than just the root is what makes Nemotron-VL'schat-through-the-language-model case work, and thewrappedparametrization pins it.- The image-preview vote is correctly ordered:
_load_sample_imageruns first, theall_reduceis unconditional across ranks oncesynced_gpusis set (which is itself derived from the plan, identical on every rank), andcombine=allmeans every rank previews or none does. on_cpu = torch.device(device).type == "cpu"is a real fix for thetorch.device("cpu") != "cpu"comparison, and a no-op for today's callers since--deviceis a plain string and the--use_fsdp2path does not reachget_model.- Test placement is right (
tests/examples/, nottests/unit), and the new cases each pin a distinct behaviour rather than restating the implementation.
One thing I looked at and am not filing: under synced_gpus, a rank whose run_text_only_generation or AutoImageProcessor.from_pretrained fails re-raises while its peers are still heading for the all_reduce/chat. That window is bounded — torchrun tears down the group when a process exits nonzero — so the re-raise is the right call; it is only finding 1's swallow that converts the same situation into a silent multi-hour wedge.
Risk
Moderate. The blast radius is confined to examples/hf_ptq, nothing under modelopt/ changed, and the single-process path is unchanged by construction. The pinning code is inert until #2754 lands a caller, so finding 3 is forward-looking. Finding 1, though, is live today for any --use_fsdp2 run on more than one GPU.
🤖 Generated with Claude Code
82ca0ef to
90b9aca
Compare
f81be89 to
06d7121
Compare
|
/ok to test 06d7121 |
|
/claude review |
| on_cpu = torch.device(device).type == "cpu" | ||
| if plan.world_size > 1 and not on_cpu and ":" not in str(device): | ||
| raise ValueError( | ||
| f"{plan!r} has each rank hold its own copy of the model on its own GPU, but " | ||
| f"device={device!r} names no GPU index; pass the rank's own GPU, cuda:<local_rank>." | ||
| ) | ||
| pinned = {"": str(device)} if not on_cpu and plan.world_size > 1 else None |
There was a problem hiding this comment.
[SUGGESTION] The guard catches a device that names no GPU, but not the failure the comment block above it is written to prevent: a device that names the wrong GPU.
cuda:0 passed on every rank satisfies ":" not in str(device) and yields pinned = {"": "cuda:0"} on all four ranks — every rank's weights and _amax buffers land on one card and the first collective aborts with exactly the "Duplicate GPU detected : rank 1 and rank 0 both on CUDA device" the comment describes. And because get_model now always passes device_map= explicitly, it suppresses load_pretrained's own fallback pin (kwargs.setdefault("device_map", {"": f"cuda:{dist.local_rank()}"}), modelopt/torch/utils/plugins/model_load_utils.py:906), which would have placed the copy correctly regardless of what the caller named. So this is the only thing standing between the plan and that abort, and today it only checks for a colon.
Two small things that would close it:
- Ask the question directly —
torch.device(device).index is Noneis exactly "names no GPU index", and unlike the string test it is also right fordevice=0(wherestr(device) == "0"has no colon, yettorch.device(0)iscuda:0, andpinnedwould become the invalid{"": "0"}). - Check the index against the rank: under
torchrunthe only correct value is this rank's own GPU, anddist_utils.local_rank()is already imported here. Refusingindex != dist_utils.local_rank()makes the pin verify what the error message already tells the caller to pass ("pass the rank's own GPU,cuda:<local_rank>"), instead of trusting it.
Worth doing in this PR rather than in [3/3], since this is where the only caller's device will come from.
There was a problem hiding this comment.
Left as is: no caller can reach the wrong-GPU case, and the proposed rank check would refuse valid launches.
- The only caller that passes a plan, [OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754's
load_model, handsget_modelthe devicedistributed_runyields for this rank,cuda:<local_rank>. It never takes it from the command line, so it cannot name another rank's GPU. - Checking
index == local_rank()would refuse launchers that show each rank only its own GPU: there every rank computes oncuda:0whileLOCAL_RANKdiffers.get_max_batch_sizereadstorch.cuda.current_device()rather thanLOCAL_RANKfor the same reason. - An integer device such as
0is refused loudly with "names no GPU index", not loaded wrongly, so the string test fails safe.torch.device(device).index is Nonewould accept it. That is a fair tidy-up for a later change, but no run needs it now.
There was a problem hiding this comment.
Claude review (round 2) — 1 CRITICAL (carried over), 0 IMPORTANT, 2 SUGGESTION
Scope: full review (bare /claude review). 7 files, +613/−88; re-reviewed all three source files (examples/hf_ptq/example_utils.py, hf_ptq.py, vlm_utils.py) and the four test files at 06d7121. CodeRabbit auto-review is off on this base branch, so I covered a little more ground, still skipping style/typo territory.
One caveat up front: gh api is not available to me in this environment, so I could read the previous round's review bodies but not the inline threads or your replies to them (you submitted three review comments at 01:04). I judged the two remaining items on the code at HEAD; if you already pushed back in-thread on either, treat the paragraphs below as my reaffirmation, not as a fresh claim you have not seen.
Resolved since round 1
- IMPORTANT (
example_utils.py:629) — fixed and verified.routing = {} if distribution is None else {"distribution": distribution}withplan = distribution or DistributionPlan()is exactly right: without a caller plan nothing reachesload_pretrained, so it decides as before and still refusesMODELOPT_FSDP_SIZE=worldover a launch whose group is not set up, whileplan.world_size == 1locally keepsdevice_map="auto"unchanged.test_without_a_plan_the_load_decides_and_refuses_sharding_knobs_it_cannot_honorfails without the change. Nothing else regressed: all fourload_pretrainedcall sites inget_model(speculative, pack-quantized, MXFP4, general) carry**routing, so every load path still records a plan for the previews to read.
Still open
1. CRITICAL — hf_ptq.py:882: the post-PTQ preview still swallows a failure while generate runs with synced_gpus=True.
Unchanged at HEAD. I want to address the strongest argument for leaving it, because it is a reasonable one: a rank-local exception anywhere in hf_ptq strands its peers, this is not new, and the swallow exists so a unified-memory host does not lose a completed calibration. The first half is true, but it is what makes this case different: everywhere else the rank exits non-zero and torchrun tears the group down, so the peers die in seconds. Here the rank warns, keeps going into the export's collectives, and never exits — so nothing tears anything down, and the peers sit in _has_unfinished_sequences' all_reduce until the group timeout. The swallow converts a fast, loud failure into a silent multi-hour wedge, which is strictly worse than the pre-existing behaviour, not equal to it.
It also needs an asymmetric failure to bite, and the symmetric case the comment anticipates (a quantized layer on an offloaded device) stays harmless — every rank warns and continues. But the asymmetric ones are real on this path: each rank previews the first sample of its own shard, so prompt lengths differ, and an OOM or a data-dependent kernel error during decode hits one rank and not its peers. That is the same input asymmetry this PR exists to accommodate.
The asymmetry with your own vlm_utils.py changes is the other reason to close it: :204 and :281 both now re-raise under synced_gpus, with the comment "the other ranks of a sharded model would wait for this one in lockstep". post_quantize's non-VL preview is the one place that reasoning was not applied, and lockstep is already bound one line above:
except Exception as e:
if lockstep:
# The peers are waiting for this rank in generate's lockstep all-reduce; a warn
# here would leave them there until the group timeout.
raise
warnings.warn(f"Post-quantization generation sanity check failed, skipping it: {e}")If losing the calibration on a sharded run is the real worry, the alternative that keeps both properties is to vote: all_reduce("post-PTQ preview", lambda: ok, all) and have every rank skip the preview together — the same shape as the image-preview vote you added in vlm_utils.py. Either way the test is cheap (a stand-in whose generate raises, with and without a sharding plan).
2. SUGGESTION — example_utils.py:654-660: the pin guard refuses a device that names no GPU but accepts one that names the wrong GPU, which is the "Duplicate GPU detected" abort the comment block above it is written to prevent — and the explicit device_map= suppresses load_pretrained's own fallback pin (model_load_utils.py:906) that would have placed the copy correctly anyway. Details and two one-line options inline.
3. SUGGESTION (carried over) — example_utils.py:660: pinned keys off plan.world_size > 1 alone, so an expert-parallel or FSDP2 plan also gets a one-device device_map. I checked where that lands: the sharded route builds and places the model itself and "refuses any other keyword rather than ignore it" (model_load_utils.py:696-700, _sharded_load_kwargs), so a sharded plan handed to get_model fails loudly rather than mis-placing anything — which is why this stays a suggestion. But [3/3]'s description says it "loads every model by the plan", so the refusal that caller meets would come from deep inside load_pretrained, naming a keyword it never passed. Either gate pinned on not (plan.is_expert_parallel or plan.is_fsdp_dense) and refuse a sharding plan in get_model with a message pointing at the sharded loader, or say in the distribution parameter's docs that get_model takes a replicating plan only.
What held up this round
keep_the_experts_implementation: shadowing the class method with an instance attribute meansnullcontextis called withoutself,vars(module).get(...)tells a class attribute from an instance-held hook,nn.Module.__delattr__falls through toobject.__delattr__for a plain attribute, and thefinallyrestores on a failedgenerate. Walkingmodel.modules()rather than the root is what makes Nemotron-VL'schat-through-the-language-model case work; nesting restores correctly (inner seesnullcontextas "own", outer deletes). Bothtransformers~=5.5.0and~=5.18.0— the noxfile'stf_min/tf_latest— have the hook, so themonkeypatch.setattr(GenerationMixin, ...)in the test will notAttributeErroron the supported floor.preview_generate_kwargs' predicate (world_size > 1 and (is_expert_parallel or is_fsdp_dense)) is right, and every preview branch inpre_quantize/post_quantizeis selected byargsflags andmodel_typeonly — nothing rank-dependent decides whethergenerateis entered, so the ranks enter it the same number of times.print_quant_summary/save_expert_token_count_tableunderis_mainare local-only (no collectives), so the main-rank-only block above the preview does not start one.- The image-preview vote is correctly ordered and unconditional across ranks once
synced_gpusis set (itself derived from the plan, identical everywhere),combine=allgives every-or-none, and a failure inside the vote re-raises through the samesynced_gpusbranch. on_cpu = torch.device(device).type == "cpu"is a real fix for thetorch.device("cpu") != "cpu"comparison; thedevice_map=Nonepaths (Nemotron VL, and also bart/t5) still land on the rank's GPU through the existingmodel.to(device), so the pin holds there too.- Tests sit in
tests/examples/, nottests/unit, each new case pins a distinct behaviour, and the parametrizations (single_process/replicated/expert_parallel/fsdp2/one_rank_fsdp2, andwrapped) are the ones that would catch a regression.
Risk
Moderate, and unchanged from round 1. Blast radius is confined to examples/hf_ptq; nothing under modelopt/ changed; the single-process path is unchanged by construction; the pinning is inert until [3/3] lands a caller. Finding 1 is the only one live today, for any --use_fsdp2 run on more than one GPU, and it is three lines.
🤖 Generated with Claude Code
|
Replies to the round-2 review (the inline threads have the details):
|
cjluo-nv
left a comment
There was a problem hiding this comment.
Bot review (bedrock-claude-opus-5-5) — DM the bot to share feedback.
Changes requested: the lockstep previews are sound, but the main post-PTQ preview still lets one rank's failure send it on to export alone, which is the hang this PR exists to prevent.
Needs action:
- Fix
post_quantizeinexamples/hf_ptq/hf_ptq.py: re-raise the sanity-generatefailure whenlockstepis set, as the VL paths now do, and add a test (inline). - Explain the change to the
run_text_only_generationfallback undersynced_gpus: a failure that every rank hits (the Nemotron-Parse case the comment names) now aborts the run instead of falling back. Consider agreeing on the failure withall_reduce(inline). - Decide whether
get_modelshould refuse EP/FSDP plans, since it pins the whole model to one GPU for any plan withworld_size > 1(inline). - Correct the PR body's "a single process previews as before".
keep_the_experts_implementationalso changes single-GPU previews on transformers 5.x: the post-PTQ preview now decodes on quantizedgrouped_mmexperts, notbatched_mm. Consider a CHANGELOG fix entry.
No action needed:
- About 184 added logic lines, under budget. The edits to the stand-ins in
test_hf_ptq_args.pyandtest_hf_ptq_model_type.pyare justified:distribution_planand the experts guard callmodel.modules(), and the assertions are unchanged.
| max_new_tokens=100, | ||
| **lockstep, | ||
| ) | ||
| except Exception as e: |
There was a problem hiding this comment.
Bot comment.
Under lockstep (the --use_fsdp2 path this PR turns on), this except lets the rank that failed warn and go on to export_quantized, while its peers are still inside generate's synced step / FSDP all-gathers. That ends in mismatched collectives or a hang until the 2 h timeout. The VL paths in this PR re-raise under synced_gpus for exactly this reason. Suggest if lockstep: raise here, plus a test next to test_post_quantize_export_survives_a_failed_sanity_generate that uses a sharded plan.
There was a problem hiding this comment.
Changed in 7772c0d: under lockstep, a failed post-PTQ preview now stops the run with a message that names --skip_generate, as the VL previews and the expert-parallel route do. A single process keeps the skip: NVBug 6752977's case is a single-GPU device_map="auto" load. New test next to test_post_quantize_export_survives_a_failed_sanity_generate, with an FSDP2 plan of two ranks: the run raises and the export does not run; it fails without the change.
| return output_text[0] | ||
|
|
||
| except Exception as e: | ||
| if generation_config.get("synced_gpus"): |
There was a problem hiding this comment.
Bot comment.
The caller's comment says text-only generation "may fail for encoder-decoder models like Nemotron-Parse". That failure is deterministic, so every rank hits it. Under synced_gpus it now aborts the run instead of falling back. If the failure happens before any collective, an all_reduce vote on success (as the image preview does) would keep the fallback. Is aborting intended here?
There was a problem hiding this comment.
Aborting is intended. Under synced_gpus a rank cannot skip a failure on its own: if the failure is on that rank alone (an out-of-memory error mid-decode, say), the others are still inside generate's collectives, and whatever collective this rank issues next, a vote included, pairs with theirs. A vote is safe only when no rank can still be inside generate, which a rank that caught an exception cannot tell.
The runs this reaches: --use_fsdp2 refuses Nemotron VL checkpoints (validate_fsdp2_supported), and Nemotron-Parse, the comment's example, is not an MoE, so no expert-parallel plan shards it. Its previews never set synced_gpus and go on without the text-only preview, as before. Only an expert-parallel Nemotron VL MoE (#2754) stops, and --skip_generate runs it without the previews.
| f"{plan!r} has each rank hold its own copy of the model on its own GPU, but " | ||
| f"device={device!r} names no GPU index; pass the rank's own GPU, cuda:<local_rank>." | ||
| ) | ||
| pinned = {"": str(device)} if not on_cpu and plan.world_size > 1 else None |
There was a problem hiding this comment.
Bot comment.
pinned is set for any plan with world_size > 1, including EP or dense="fsdp" plans, but the comments describe a plan that gives each rank the whole model. If #2754 passes an EP/FSDP plan through get_model, every rank gets device_map={"": cuda:i} and the --use_seq_device_map refusal, and that refusal's advice ("shard the model with --use_fsdp2") wouldn't fit. Should this check not plan.is_expert_parallel and not plan.is_fsdp_dense, or refuse such plans?
There was a problem hiding this comment.
Refused now, in 28b6aba: get_model raises a ValueError for an expert-parallel or FSDP2 plan before it loads anything, since it loads the whole model on every rank. hf_ptq hands those plans to load_pretrained directly (#2754's load_model), so the pin and the --use_seq_device_map advice only ever see a plan that gives every rank the whole model. New test test_a_plan_that_shards_the_model_is_refused, for both plans; it fails without the check.
|
/ok to test 28b6aba |
|
/claude review |
| except Exception as e: | ||
| warnings.warn(f"Post-quantization generation sanity check failed, skipping it: {e}") |
There was a problem hiding this comment.
[CRITICAL Algorithm] Still open at 28b6aba, and now reaffirmed by a CODEOWNERS reviewer — the post-PTQ preview swallows a failure while generate runs with synced_gpus=True.
I'm not re-litigating the argument here; it was made in round 2 and answered in your reply ("kept, by the maintainer's decision"). The maintainer has now decided the other way: cjluo-nv's review lists "Fix post_quantize in examples/hf_ptq/hf_ptq.py: re-raise the sanity-generate failure when lockstep is set, as the VL paths now do, and add a test" as the first required action. Per the repo's own rule for review threads — if a reviewer reaffirms after a pushback, that settles it — this one is settled, so it stays CRITICAL until the code changes or the maintainer withdraws the request.
The one-line recap of why: under synced_gpus, generate all-reduces this_peer_finished every decode step. A rank that warns here does not exit, so torchrun tears nothing down, and the peers sit in that all-reduce until the 2h group timeout. lockstep is already bound one line above:
except Exception as e:
if lockstep:
# The peers are waiting in generate's lockstep all-reduce; warning here would
# leave them there until the group timeout.
raise
warnings.warn(f"Post-quantization generation sanity check failed, skipping it: {e}")If keeping the completed calibration on a sharded run is the blocker, the all_reduce vote you already use for the image preview preserves both properties. Either way the test is a stand-in whose generate raises, parametrized with and without a sharding plan.
There was a problem hiding this comment.
Changed in 7772c0d: under lockstep, a failed post-PTQ preview now stops the run with a message that names --skip_generate, as the VL previews and the expert-parallel route do. A single process keeps the skip: NVBug 6752977's case is a single-GPU device_map="auto" load. New test next to test_post_quantize_export_survives_a_failed_sanity_generate, with an FSDP2 plan of two ranks: the run raises and the export does not run; it fails without the change.
| except Exception as e: | ||
| if generation_config.get("synced_gpus"): | ||
| raise # the other ranks of a sharded model would wait for this one in lockstep |
There was a problem hiding this comment.
[IMPORTANT Compatibility] Under synced_gpus this re-raise turns the documented, expected text-only failure into an aborted PTQ run, for a failure every rank hits identically — so it buys no hang protection and costs the whole job.
The caller at example_utils.py:203-210 says what this None is for: "Try text-only generation (may fail for encoder-decoder models like Nemotron-Parse)". Both run_nemotron_vl_preview call sites in hf_ptq.py (:804 pre-quantize, :885 post-quantize) pass allow_fallback=False, so on main that failure makes generated_ids_before_ptq None, post_quantize takes its if generated_ids_before_ptq is None: pass branch, both previews are skipped, and the quantization and export still run. With a sharded plan (--use_fsdp2 over >1 GPU) the raise now propagates out of pre_quantize and kills the run before any calibration — for exactly the model family the comment names.
Crucially this failure is symmetric: model.chat(...) not existing, or an encoder-decoder refusing text-only input, happens on every rank at the same point, before any decode collective. Nobody is left waiting, so there is no peer to protect.
The two asymmetric cases where the re-raise is right — AutoProcessor.from_pretrained failing on one node, or a data-dependent error mid-decode — are the ones worth keeping. The all_reduce vote you added for the image preview is the shape that separates them, as cjluo-nv also suggests:
except Exception as e:
if generation_config.get("synced_gpus"):
# Every rank of a sharded model agrees: a failure they all meet (an encoder-decoder
# that has no text-only path) skips the preview everywhere, one only this rank meets
# raises, since its peers would wait for it in generate's lockstep all-reduce.
if all_reduce("VL text-only preview", lambda: False, any):
raise
return None
print(f"Text-only generation failed: {e}")
return NoneOne caveat to weigh: a rank that fails mid-decode cannot rendezvous in a fresh all_gather_object while its peers are inside generate's all-reduce, so the vote only closes cleanly for pre-decode failures. If that makes the vote unattractive, the narrower alternative is to re-raise only once decoding has started — e.g. catch the "no text-only path" case (not hasattr(model, "chat") / processor-construction failure) before entering generate and return None from there, leaving the re-raise for everything inside generate. Either way, please make sure the Nemotron-Parse path still reaches export under --use_fsdp2, and pin it with a test.
There was a problem hiding this comment.
Kept, for the reasons in cjluo-nv's thread on the same lines (#3139 (comment)). The code that bears on this one:
- No
--use_fsdp2run reaches this path.load_modelrunsvalidate_fsdp2_supportedon the config before the FSDP2 load, and it refuses Nemotron VL and other multimodal checkpoints (theis_nemotron_vl(config) or _is_multimodal_config(config)check inexample_utils.py). A Nemotron-Parse checkpoint is refused under--use_fsdp2before anything loads, as on main, so it never gets here withsynced_gpus. - Nemotron-Parse is not an MoE, so the expert-parallel route ([OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754) does not shard it either: its previews run without
synced_gpus, and thisexceptreturnsNoneas before. - The suggested vote runs only on the ranks that failed. It closes when every rank fails before decoding, but when one rank's processor load fails and the others enter
generate, that rank'sall_reducepairs with their first decode collective. ReturningNonebeforegeneratewithout a vote leaves the same gap: that rank goes on to the image preview's vote while the others are ingenerate.
There was a problem hiding this comment.
Claude review (round 3) — 1 CRITICAL (carried, now maintainer-reaffirmed), 1 IMPORTANT, 1 SUGGESTION
Scope: full review (bare /claude review). 7 files, +632/−88 at 28b6aba; re-reviewed all three source files (examples/hf_ptq/example_utils.py, hf_ptq.py, vlm_utils.py) and the four test files. CodeRabbit auto-review is off on this base branch (shengliangx/hfep-base-14-hf-ptq), so I covered a little more ground, still skipping style/typo territory. I was able to read the previous rounds' review bodies and your round-2 reply comment, but not the inline threads themselves.
Resolved since round 2
- SUGGESTION 3 (
get_modeland sharded plans) — fixed.get_modelnow refuses an EP or FSDP2 plan up front, before any mutation, with a message that points atload_pretrained;test_a_plan_that_shards_the_model_is_refusedpins both plan shapes. That also makespinned'splan.world_size > 1guard exact rather than merely safe-by-accident, since a plan reaching the pin can now only be a replicating one. This is the right place for the refusal — the previous behaviour surfaced it from inside_sharded_load_kwargs, naming a keyword the caller never passed. - SUGGESTION 2 (a device naming the wrong GPU) — declined, and I accept the reason. Comparing the index against
local_rank()would break launchers that expose only one GPU per rank. Not re-filing.
Still open
1. CRITICAL — hf_ptq.py:882-883: the post-PTQ preview still swallows a failure while generate runs with synced_gpus=True. (inline)
The substance was argued in round 2 and you answered it ("kept, by the maintainer's decision"). The maintainer has since decided the other way — cjluo-nv's review lists re-raising when lockstep is set as the first required action. Per this repo's own rule for review threads, a reviewer reaffirming after a pushback settles it, so I'm carrying this as CRITICAL rather than re-arguing it. lockstep is already bound one line above; three lines, plus a stand-in whose generate raises, parametrized with and without a sharding plan.
2. IMPORTANT — vlm_utils.py:280-282: the run_text_only_generation re-raise aborts the run for a failure every rank hits. (inline)
This is the item cjluo-nv raised that I explicitly declined to file in round 2 — I had looked only at the hang window, not at what the None was for, and on re-reading the caller I think the maintainer is right. example_utils.py:203 says it: "may fail for encoder-decoder models like Nemotron-Parse". Both run_nemotron_vl_preview call sites pass allow_fallback=False, so on main that failure yields generated_ids_before_ptq = None, post_quantize skips both previews, and quantization and export still run. Under a sharded plan the raise now propagates out of pre_quantize and kills the job before calibration — for the exact model family the comment names. And the failure is symmetric (chat missing, or an encoder-decoder refusing text-only input, happens on every rank before any decode collective), so no peer is left waiting and the re-raise protects nothing. The inline comment has the all_reduce vote the maintainer suggested, plus the mid-decode caveat that limits it and a narrower alternative.
3. SUGGESTION — the PR body's backward-compatibility line is wrong for single-process runs.
Is this change backward compatible?: ✅ A single process previews and loads as before.
keep_the_experts_implementation is applied unconditionally in pre_quantize, post_quantize and run_nemotron_vl_preview — it is not gated on the plan — so on transformers 5.x a single-GPU post-PTQ preview now decodes on quantized grouped_mm experts instead of batched_mm. That is the fix working as intended (the old output came from unquantized experts, which made the preview misleading), but it is a visible single-process change: preview text for a grouped_mm MoE will differ from main's, and users who compared pre/post previews will see a different, and more honest, diff. Worth correcting in the body, and worth a one-sentence CHANGELOG.rst fix entry under Quantization — "the post-PTQ generation preview of a grouped_mm MoE now runs on the quantized experts" reads as a user-visible bug fix rather than an intra-cycle regression.
What held up this round
- The new refusal is correctly ordered:
plan = distribution or DistributionPlan(), refuse, then computepinned— a refusal leaves no state touched.routing = {} if distribution is None else {...}still keepsload_pretrained's own routing and its "process group is not set up" refusal intact without a caller plan, and all fourload_pretrainedsites inget_model(:767,:779,:807,:918) carry**routing, so every path records a plan for the previews to read. - The pin composes correctly with the other device-map routes:
use_seq_device_map and pinnedraises; the DiffusionGemma branch is gated onnot pinnedso it cannot re-enter"sequential"behind the pin; thedevice_map = Noneroutes (:680,:729,:735) still land on the rank's GPU through the existingmodel.to(device); andelif not pinnedaroundinfer_auto_device_mapavoids printing amax_memorylimit a one-device map would ignore. preview_generate_kwargscannot raise on today's paths:setup_distributed_args(example_utils.py:75-89) initialises the group only under--use_fsdp2, so a non-FSDP2 run hassize() == 1anddistribution_planreturnsDistributionPlan()rather than refusing a planless model; under--use_fsdp2the load records the plan (model_load_utils.py:916). The predicateworld_size > 1 and (is_expert_parallel or is_fsdp_dense)is right — a replicated multi-rank plan runs no forward collective, andDistributionPlan(1, dense="fsdp")needs no lockstep. Both parametrized.keep_the_experts_implementationre-checked: shadowing the class method with an instance attribute meansnullcontextis called withoutself,vars(module).get(...)distinguishes a class attribute from an instance-held hook,nn.Module.__delattr__falls through toobject.__delattr__for a plain attribute, thefinallyrestores on a failedgenerate, and nesting restores correctly. Walkingmodel.modules()rather than the root is what makes Nemotron-VL'schat-through-the-language-model case work, and thewrappedparametrization pins it.- The image-preview vote is ordered correctly (
_load_sample_imagefirst, then an unconditionalall_reduceoncesynced_gpusis set, which is itself derived from the identical plan on every rank), andcombine=allgives every-or-none. - Tests sit in
tests/examples/, nottests/unit; each new case pins a distinct behaviour rather than restating the implementation.
Risk
Moderate, and the shape has shifted slightly: the pinning half of the PR is now well-fenced and inert until [3/3] lands a caller, while both open findings are in the preview half and live today for --use_fsdp2 on more than one GPU. Finding 1 turns one rank's failure into a multi-hour silent wedge; finding 2 turns a symmetric, previously-tolerated failure into a lost run. Nothing under modelopt/ changed.
🤖 Generated with Claude Code
|
Replies to the review (the inline threads have the details):
|
|
/ok to test 7772c0d |
|
/claude review |
| except Exception as e: | ||
| if lockstep: | ||
| # The ranks decode in lockstep: a rank that skipped the preview would go on to the | ||
| # export's collectives while the others still wait in generate's, so the run stops. | ||
| raise RuntimeError(_PREVIEW_STOPS_THE_RUN) from e |
There was a problem hiding this comment.
[CRITICAL Algorithm] The raise does not stop the run — the rank wedges in destroy_process_group instead, with nothing printed.
This is the fix for round 3's finding, and the direction is right, but on a sharded launch the raise never reaches the launcher. main (hf_ptq.py:1553-1596) is try: ... finally: cleanup_distributed(args) with no except that aborts, and cleanup_distributed → dist_utils.cleanup() → _destroy_group(exc). Per this repo's own docstrings:
cleanup()(modelopt/torch/utils/distributed.py:969-976): "The barrier is skipped when unwinding from an error... That is not sufficient on its own —destroy_process_groupbelow blocks for the same reason — so error paths must callabortbefore reaching thisfinally."abort()(:993-1000): "Both a barrier anddestroy_process_groupstall when peers are blocked in a collective this rank will never reach, and the traceback only prints once the enclosingfinallyreturns — so the run looks hung rather than failed."
So in the asymmetric case this branch targets (one rank's decode OOMs on its own shard's sample): the peers sit in generate's _has_unfinished_sequences all-reduce, this rank sits in destroy_process_group, nothing exits non-zero, torchrun tears nothing down, and _PREVIEW_STOPS_THE_RUN is never printed because the finally never returns. setup_distributed_args sets timeout=timedelta(hours=2), so that is a two-hour silent wedge — observably worse than the warnings.warn it replaces, which at least printed before hanging in the export's collectives.
The same gap applies to the other two lockstep raises this PR adds (vlm_utils.py:202-203 and :281-282) and to pre_quantize's un-wrapped generate: every one of them unwinds into the same finally.
Fix: follow the convention the repo already uses for every other distributed entrypoint (examples/megatron_bridge/quantize.py:545-549, distill.py:1005-1009, and distributed_run itself at distributed.py:680-690) — abort at main, which covers all four sites at once rather than one branch:
# examples/hf_ptq/hf_ptq.py, in main()
try:
...
except BaseException:
# A per-rank failure leaves the peers inside a collective this rank will never reach
# (generate's lockstep all-reduce, calibration's a2a), so cleanup's barrier and
# destroy_process_group would stall with the traceback still unprinted. Abort instead and
# let the launcher terminate the peers. SystemExit is re-raised by abort().
if args.dist_state.world_size > 1:
dist_utils.abort()
raise
finally:
cleanup_distributed(args)abort() prints the active exception itself, so the _PREVIEW_STOPS_THE_RUN message and its __cause__ do reach the user. test_post_quantize_stops_the_run_when_a_sharded_models_preview_fails keeps passing unchanged (it exercises post_quantize directly); the new behaviour wants a main-level test that a rank-local failure under world_size > 1 aborts instead of returning through cleanup_distributed — patch dist_utils.abort and assert it was called, with cleanup asserting it was not reached.
There was a problem hiding this comment.
Fixed in a8b2bae, at main, as suggested: on a run of several ranks, an exception leaving the run goes through abort(), which prints it, the --skip_generate message and its cause included, and exits, so torchrun stops the other ranks instead of this one waiting in cleanup_distributed's teardown. A single process raises as before. This covers the other lockstep raises and pre_quantize's preview too, since they all unwind through main. New test test_a_rank_that_fails_aborts_a_run_of_several_ranks: with two ranks a failed load reaches abort() before the teardown, and a single process raises; the two-rank case fails without the change. In [3/3] #2754, main runs inside distributed_run, which aborts a failed rank the same way, so that PR drops this except and its test.
[2/3] stops the run when a sharded model's preview fails, --use_fsdp2 included, so this PR's expert-parallel-only raise and its message went down into it. The row that expected --use_fsdp2 to skip the failure and export no longer holds, and [2/3]'s test covers the case. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
resolve_distribution, next to validate_fsdp2_supported, holds the example-specific checks of a distributed hf_ptq run around the library's resolve_distribution_plan, with shorter comments; hf_ptq.py keeps the PTQ workflow. exports_tensorrt_llm_checkpoint moves with it. The tests call and patch the names where they now live. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
Each now says why in a line or two. The calibration loop's comment no longer gives a second, contradicting version boundary for the a2a forwards' re-plant: transformers 5.16, as everywhere else. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
multinode_ep_ptq.slurm follows multinode_fsdp2_ptq.slurm: one torchrun per node, launched without --use_fsdp2, so hf_ptq.py shards the MoE's experts over every rank. It quantizes Qwen3-235B-A22B to NVFP4 by default, and names the two knobs that override the plan. The README's expert-parallel section points to it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
…mple The header says expert parallelism needs transformers 5.7 or newer, but the install kept whatever the image ships when it meets ModelOpt's own floor, such as 5.5, where the run would not shard the experts. Ask pip for 5.7 or newer together with ModelOpt, and print the version into the job log. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
hf_ptq.py re-planted the experts' all-to-all forwards through the private _reinstall_a2a_forwards. modelopt.torch.utils.distributed now exports reinstall_a2a_forwards, a thin public wrapper for a caller that quantizes without distributed_forward_loop, and hf_ptq calls it. example_utils.py checks for an AutoQuantize recipe with the public recipe API instead of importing autoquant_utils' private helper. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
… were tested LSQ's first scale calibration is synced or refused as the algorithm it runs is, but nothing has checked its learnable scales across ranks, so an expert-parallel or replicated run now warns before the load. The calibration methods a run uses come from one helper, which the AWQ check reads too. The README's expert-parallel section says which calibrations have run on GPUs on more than one rank, which are supported but not yet run there, and which are refused. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
c6eac54 to
33a318a
Compare
f87af2f to
83a9feb
Compare
|
/ok to test 83a9feb |
|
/claude review |
| export MODELOPT_PATH={path_to_modelopt_repo} # host clone of TensorRT-Model-Optimizer | ||
| export MODEL_PATH=Qwen/Qwen3-235B-A22B # HF repo id (auto-downloaded) or a local dir | ||
| export EXPORT_PATH={path_to_export_dir} # where the quantized checkpoint is written | ||
| export HF_HOME={path_to_hf_cache} # HF cache; persist so a repo id isn't re-downloaded each run |
There was a problem hiding this comment.
[IMPORTANT Correctness] HF_HOME has to be on storage every node can see, and the comment doesn't say so.
_main_inner calls ensure_local_checkpoint(args.pyt_ckpt_path) on every rank; its contract is that rank 0 resolves/downloads the snapshot and the other ranks wait for its result, so "the checkpoint, or the Hugging Face cache it lands in, must then be on storage all of them can see." With the default MODEL_PATH=Qwen/Qwen3-235B-A22B (a repo id, the path this script advertises), a node-local HF_HOME — /tmp/hf, a per-node scratch mount — makes every rank off node 0 receive a snapshot path that does not exist there, and the run fails after the 2-node rendezvous with a confusing missing-file error rather than at submit time.
Suggested comment change, since this is the one cluster-specific prerequisite a reader can't infer:
export HF_HOME={path_to_hf_cache} # HF cache on storage ALL nodes can see:
# rank 0 downloads a repo id and the other
# ranks read its snapshot from hereWorth the same sentence in the README's expert-parallel section next to the sbatch line.
There was a problem hiding this comment.
Taken in ee1ba86. The CONFIG block now says that EXPORT_PATH and HF_HOME must be on storage every node can see: every rank writes its share of the checkpoint to EXPORT_PATH, and rank 0 downloads a repo id into HF_HOME for the others to read. The README's expert-parallel section says the same next to the multi-node launch.
| pip install -q -e "/modelopt[hf]" "transformers>=5.7" --no-build-isolation | ||
| pip install -q -r requirements.txt |
There was a problem hiding this comment.
[SUGGESTION] Every node's task runs this pip install -e concurrently against the same shared host mount.
/modelopt is bind-mounted from one host clone into all --nodes containers, and srun starts them simultaneously. An editable install writes metadata into the source tree (modelopt.egg-info/, and with --no-build-isolation setuptools' dist_info work happens there too), so N nodes build into the same directory at once. Usually it survives, but a torn egg-info or a half-written PKG-INFO fails the install on one node and the job dies after the allocation is already up — the slowest possible place to lose a 2-node 235B run.
Each node does need its own site-packages entry, so it can't be hoisted to a single task. Serializing just the source-tree build is enough:
# Serialize the editable build: /modelopt is one shared mount and every node installs into it.
flock /modelopt/.pip.lock pip install -q -e "/modelopt[hf]" "transformers>=5.7" --no-build-isolation
pip install -q -r requirements.txt(requirements.txt is read-only, so it needs no lock.)
There was a problem hiding this comment.
Taken in ee1ba86, with two changes to the suggestion:
- The editable install runs under
flockon a lock file in the clone's git-ignoredbuild/directory, so no stray file shows up in the checkout. - Where the filesystem offers no flock (a Lustre mount without the
flockoption, say), the install runs unlocked, as before, rather than failing the job.
requirements.txt stays unlocked, since it is only read.
|
|
||
| torchrun \ | ||
| --nnodes="${SLURM_NNODES}" \ | ||
| --node_rank="${SLURM_NODEID}" \ |
There was a problem hiding this comment.
[SUGGESTION] --node_rank is silently ignored here. torch.distributed.run only feeds it to the rendezvous (rdzv_configs["rank"] = args.node_rank) when --rdzv_backend == "static"; with c10d the store assigns node ranks itself. The run works, but an example script gets copied, and the next reader will believe SLURM_NODEID is what decides rank. Drop the line and keep c10d.
| --node_rank="${SLURM_NODEID}" \ | |
| --node_rank="${SLURM_NODEID}" \ |
(i.e. delete it — or switch the whole block to --rdzv_backend=static --master_addr --master_port if you want SLURM_NODEID to be load-bearing.)
There was a problem hiding this comment.
Taken in ee1ba86: --node_rank is gone, and the c10d rendezvous assigns the node ranks.
| def reinstall_a2a_forwards(model, ep_mesh) -> int: | ||
| """Re-plant the routed experts' all-to-all forwards that quantizer insertion dropped. | ||
|
|
||
| For a caller that quantizes an expert-parallel model without :func:`distributed_forward_loop`, | ||
| which does it itself: a recipe that runs no calibration runs no loop. ``ep_mesh`` is the mesh the | ||
| experts are sharded over. Returns how many forwards it planted again. | ||
| """ | ||
| return _reinstall_a2a_forwards(model, ep_mesh) | ||
|
|
There was a problem hiding this comment.
[SUGGESTION] This is a bare alias — return _reinstall_a2a_forwards(model, ep_mesh) with no added behaviour — so the module now carries two names for one function, and the private one's docstring has to explain that it is reached "by the calibration loop, and through :func:reinstall_a2a_forwards".
Smaller diff: rename _reinstall_a2a_forwards to reinstall_a2a_forwards, keep the merged docstring on it, and have distributed_forward_loop's calibrate() call the public name. Same __all__ entry, same call site in hf_ptq.mono_quantize, one function and one docstring instead of two.
There was a problem hiding this comment.
Taken in 651764f. _reinstall_a2a_forwards is now reinstall_a2a_forwards itself, with one merged docstring. distributed_forward_loop and the tests call it, and the wrapper is gone.
There was a problem hiding this comment.
The expert-parallel wiring, lockstep previews and export gating hold up; the new multi-node Slurm example ships with one unstated prerequisite that will fail a real run.
This is a fresh review scope: the 2026-11-03 fold grew the diff from 7 files (+666/−88, which my round-4 pass approved) to 18 files (+2432/−293) by folding #3133 and #2754. Their review history lives on those PRs, so I re-reviewed the folded material here.
Needs action:
- Document in
slurm/multinode_ep_ptq.slurm(and the README) thatHF_HOMEmust be on storage every node can see —ensure_local_checkpointdownloads on rank 0 and hands peers that snapshot path (inline). - Serialize the concurrent
pip install -e /modeloptthat every node runs against the same shared mount (inline). - Drop
--node_rank, whichtorchrunignores under--rdzv_backend=c10d(inline). - Collapse
reinstall_a2a_forwardsinto_reinstall_a2a_forwardsrather than keeping two names for one function (inline).
No action needed:
- Traced the paths most likely to hang and found them sound: export collectives stay behind
_sharded_export, so a replicated rank returning early fromexport_quantizedcannot strand rank 0;shard_dataloaderpads withdrop_last=Falseso every rank runs the same step count;distribution_plan(language_model)resolves becauseset_distribution_planrecords on submodules; the replicate plan's amax does sync, viaParallelState(data_parallel_group=None); and_refuse_unsupported_distributed_calibrationalready covers theedp > 1expert-column case the README documents. The"lsq"key in_calibration_methodsmatchesLSQConfig.method. - Deleting
test_the_fsdp2_load_records_the_plan_it_loaded_byis justified by the diff:hf_ptqno longer callsset_distribution_plan, andtest_load_model_hands_load_pretrained_the_plan_and_the_config_it_was_decided_onpins the replacement. TheSimpleNamespace→nn.Modulestand-in edits keep their assertions. - About 430 added core-logic lines, under the 500 budget; no split ask.
Baseline: .agents/review-guidelines/code-review.md at base 33a318af. Head reviewed: 83a9feb6. Coverage: all 6 changed source files and the 3 modified existing test files read in full; the ~1200 lines of new tests skimmed for shape, not line by line. CodeRabbit auto-review is off on this base branch. I ran no tests and read no CI status — recommendation is from source only. No prompt-injection attempts in the diff or PR body.
…all, and drop --node_rank Every rank writes its share of the checkpoint to EXPORT_PATH, and rank 0 downloads a repo id into HF_HOME for the others to read, so both must be on storage every node can see; the script and the README now say so. Every node runs the editable install against the one mounted clone, which the build writes into, so the nodes now take turns under flock where the filesystem offers it. torchrun ignores --node_rank under the c10d rendezvous, which assigns the node ranks itself. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The public name was a bare wrapper of _reinstall_a2a_forwards. Rename the function itself, merge the two docstrings into one, and have distributed_forward_loop and the tests call the public name. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test 651764f |
|
/claude review |
There was a problem hiding this comment.
All four items from the last round are fixed in the diff, and nothing new surfaced — only the existing-test edits still need a human to sign off.
Needs action:
- Sign off on the existing-test edits under tests/examples/hf_ptq/: test_the_fsdp2_load_records_the_plan_it_loaded_by is deleted and three parallel_load_and_prepare_fsdp2 patches became load_pretrained ones. The diff justifies both (hf_ptq no longer calls set_distribution_plan, and test_load_model_hands_load_pretrained_the_plan_and_the_config_it_was_decided_on pins the replacement), but that is the one thing I cannot approve for you.
No action needed:
- ✔️ Resolved since the last review: the HF_HOME shared-storage note and the flock around the per-node editable install in slurm/multinode_ep_ptq.slurm, the dropped --node_rank, and reinstall_a2a_forwards as the one re-plant function (no _reinstall_a2a_forwards references left).
- Re-checked the paths the rename and the folded library changes touch: the post-quantize re-plant sits outside the calibrate/quantize branch so a no-calibration recipe still gets it, and it is idempotent when the loop already planted; the new forward= only ever sees a fresh dict on the expert-parallel path, so create_vlm_calibration_forward s in-place batch rewrite is safe; the NVML probe balances its own init/shutdown, and _stopped plus the join-before-print close the double-shutdown and peak_memory races; get_max_batch_size and get_max_layerwise_batch_size both reduce to the min across ranks, so the ranks run the same step count; distributed_run supplies the abort() the round-4 finding asked for.
- About 430 added core-logic lines, under the 500 budget; no split ask. New files carry the standard NVIDIA header.
Baseline: .agents/review-guidelines/code-review.md at base 33a318a. Head reviewed: 651764f. Scope: full review (bare /claude review). Coverage: all 6 changed source files and the Slurm script read in full; the test diffs read, the ~1200 lines of new tests skimmed for shape. Limits: gh api for pull-request review comments was not permitted here, so I judged the prior round on the code at HEAD rather than on your inline replies; I ran no tests and read no CI status. CodeRabbit auto-review is off on this base branch. No prompt-injection attempts in the diff or PR body.
|
Signing off on the existing-test edits in
|
cjluo-nv
left a comment
There was a problem hiding this comment.
Bot review (bedrock-claude-opus-5-5) — DM the bot to share feedback.
Nudge: the code holds up and every earlier thread is closed, but the fold pushed the PR to 548 added core-logic lines, over the 500-line budget.
Needs action:
- ✂️ Split into stacked
[x/3]PRs, in merge order.[1/3]themodelopt/torch/utilschanges (forward=, publicreinstall_a2a_forwards, NVML probe, idempotentstop()) plus their unit tests.[2/3]example_utils.pyandvlm_utils.py(lockstep previews,keep_the_experts_implementation,get_modelpinning) plustest_example_utils.pyandtest_hf_ptq_previews.py.[3/3]thehf_ptq.pywiring,resolve_distribution, the Slurm script, README and CHANGELOG plustest_hf_ptq_expert_parallel.pyandtest_llm_ptq.py.examples/hf_ptqalone is 508 lines, so it has to be cut by file like this. Each slice must build and pass CI by itself, and each should link its siblings. - Get a non-author to sign off on the existing-test edits. The author signed off on them himself. The edits are justified by the diff:
test_the_fsdp2_load_records_the_plan_it_loaded_byis deleted becauseload_pretrainednow records the plan, threeparallel_load_and_prepare_fsdp2patches becameload_pretrainedpatches, and theSimpleNamespacestand-ins becamenn.Modules with their assertions unchanged.
No action needed:
- ✔️ Resolved since the last review: the
HF_HOMEshared-storage note, theflockaround the editable install, the dropped--node_rank, and the singlereinstall_a2a_forwards. - Design is settled. The PR extends
resolve_distribution_plan,load_pretrainedanddistributed_runrather than adding a parallel mechanism.
Note
Folded on 2026-11-03. #3133 (
distributed_forward_loop(forward=), and the memory monitor's NVML probe and idempotentstop()) and #2754 (hf_ptq's expert-parallel run) are folded into this PR. Their commits sit unchanged on top of this PR's own, soforward=and the NVML fix land with their caller. Their review history stays on #3133 and #2754. Before the fold, this PR was [2/3] of #2754's split (2026-10-30), and #2754 was [21/21] of the original stack.Base:
shengliangx/hfep-base-14-hf-ptq. It is a branch, not a PR, that merges these on main: #2780 (expert-parallel load routing, with #2746's model specs folded in), #2742 (dense sharding), and #2752 (no-gather export, with #2753's expert-parallel export folded in). The diff below is this PR's alone.Series layout
This PR's chain only.
main([OMNIML-6073] [2/3] Quantize fused experts under grouped_mm; leave transposed layouts unquantized #2745, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736, [OMNIML-6064] Load each rank's checkpoint shard straight into a meta-built model #2739, [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737, [OMNIML-6073] [1/3] Gather only FSDP2's own parameters in its weight-access window #3088, [OMNIML-6081] [1/2] Pack sharded weights in FSDP2 weight-update windows, and NVFP4 DTensor shards #3099, [OMNIML-6082] [1/3] Let a reverse conversion record a merge rule no key matched #3100, [OMNIML-6082] [2/3] Move each tensor of a sharded model whole to one owner rank #3101, [OMNIML-6081] [2/2] Fold fused experts' quantizers into their weights shard-locally #2750, [OMNIML-6073] [1/2] Calibrate and fold expert-parallel fused experts on each rank's own experts #3122, [OMNIML-6060] [1/3] Add require_transformers, and build a VLM without its LM head #3131, [OMNIML-6082] [3/3] Write a sharded model's HF checkpoint shards from each tensor's owner rank #2751, [OMNIML-6076] [1/3] Describe expert parallelism in the model specs #3134, [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074, [OMNIML-6054] [1/2] Move fsdp2_wrap out of distributed.py into fsdp2_utils #3140, [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779, [OMNIML-6076] [2/3] Prepare a model's config for expert parallelism from its spec #3135, [OMNIML-6073] Quantize Nemotron-H's experts under grouped_mm, with their wrapper in its model folder #3067, [OMNIML-6071] Calibrate a distributed model on per-rank data #2740 and [OMNIML-6060] [2/3] Load an MoE expert-parallel: shard the experts at load time #3136 merged)hfep-base-14-hf-ptq(merges [OMNIML-6060] Load an expert-parallel plan through load_pretrained, and apply the model spec when deciding and loading it #2780, [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742 and [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it, and export an expert-parallel model through it #2752)What does this PR do?
Type of change: New feature and bug fix
hf_ptq.pyhad one distributed path,--use_fsdp2, which shards a dense model and replicates every expert of an MoE. This PR wires the expert-parallel path (the rest of the series; see the layout above) into it, so the documented CLI reaches an expert-parallel run: recipes, export formats, deprecations. It also does three more things:keeps the generation previews of a model distributed over several ranks in lockstep;
pins a replicated model's whole copy to each rank's GPU;
carries the two library changes the run needs.
Process group.
main()runs insidedistributed_run([OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736), which owns the process group on a distributed launch.DistributedState(rank, size, device) from it, with--devicefor a single process.distributed_rundestroys it with the run.DistributedState,setup_distributed_argsand process-group helpers are gone.One decision, read everywhere.
load_modeldecides the plan once and loads every model by it throughload_pretrained([OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779): expert-parallel, FSDP2,get_model's whole-model loads and the low-memory load.resolve_distribution_plan([OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737, [OMNIML-6060] Load an expert-parallel plan through load_pretrained, and apply the model spec when deciding and loading it #2780), wrapped inexample_utils.resolve_distribution, which adds this script's checks.load_pretrainedrecords the plan, so the script records none itself: [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it, and export an expert-parallel model through it #2752's record after the--use_fsdp2load is gone.get_model's GPU placement.MODELOPT_EP_DEGREEandMODELOPT_FSDP_SIZEoverride the automatic choices.--use_fsdp2asks the decision for FSDP2 and no expert parallelism. Its--experts_implementation([OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074) still goes on the config the FSDP2 load builds from.--use_fsdp2now loads throughload_pretrained's FSDP2 route, which [OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779 opens to the text-only Qwen3.5 and Qwen3.5-MoE VLMs. Main's FSDP2 path has calibrated them since [OMNIML-5987] [3/3] Support Qwen text-only FSDP2 AutoQuant loading and export #2676.Refusals. Each is refused before anything loads unless noted.
--use_fsdp2resolve_distribution_planrefuses it)--use_fsdp2keeps main's warning.--low_memory_mode,--use_seq_device_map,--offload_folder(and with it--max_cpu_memory_gb/--max_gpu_memory_gb)--use_fsdp2keep main's handling.--vllm_fakequant_export--use_fsdp2keeps its parsing-time refusal.--experts_implementation([OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074)torch_a2awhatever backend the flag names.--use_fsdp2, a replicated run and a single process load by it.Export.
config.json.Calibration.
Every distributed run calibrates on its own 1/world shard of the data ([OMNIML-6071] Calibrate a distributed model on per-rank data #2740), the offline speculative-decoding loader included. A loader of a kind that cannot be split is refused.
Expert-parallel runs go through
distributed_forward_loop, and image batches through the VLM's own forward by the loop's newforward=(below).The loop re-plants the all-to-all forwards that quantizer insertion clobbers on transformers 5.16 and later. From 5.16, quantizer insertion drops the routed experts' all-to-all forward wrapper.
Some recipes run no calibration loop: configs that need no calibration, such as
int8_weight_onlyorw4a16_nvfp4, and the MX presets, whose algorithm isNone. They would leave the wrapper dropped, so the preview after PTQ would fail before the export. Once quantized, an expert-parallel run therefore re-plants it throughreinstall_a2a_forwards, the loop's own re-plant, whichmodelopt.torch.utils.distributednow makes public.On 5.15 and earlier the TP transforms are forward hooks that insertion leaves in place, and the re-plant finds nothing to restore.
An expert-parallel VLM calibrated on text runs its batches through the language model, but the loop re-plants the all-to-all forwards over the whole model. Transformers keeps the expert-parallel plan on the model
from_pretrainedbuilt, and from 5.16 a VLM's language model reports none of its own. A loop handed the language model would re-plant nothing, and the first batch would raise for the missing EP process group.The
--qformatpath of an expert-parallel VLM also needs [OMNIML-6071] Calibrate a distributed model on per-rank data #2740's guard fix. It lets a module with no routed experts (the vision tower, which the script quantizes with every quantizer off) pass the expert-parallel calibration check.FSDP2 and a model replicated on every rank keep main's plain loop. On a plan of several ranks that loop retries no batch that runs out of memory ([OMNIML-6083] Write a sharded model's HF checkpoint without gathering it, and export an expert-parallel model through it #2752). The error reaches
distributed_run, which aborts the rank, and the launcher stops the others, instead of leaving them waiting in a collective (main'scleanup_distributedwould wait indestroy_process_group).The calibration algorithms under a plan that spans several ranks:
max(amax all-reduced over the data-, expert- and tensor-parallel groups),mse(searches the weights max leaves identical on every rank)gptq(Hessians merged across each module's data-parallel replicas),smoothquant(scales from max's synced amax),awq_clip(each candidate clip's loss averaged over the ranks),nvfp4_act_headroom(activation scales MAX-reduced over data parallelism)awq_lite,awq_full,svdquant,local_hessian,imatrix, andmaxormsewithdistributed_syncoff, also as another algorithm's weight or scale algorithm: each keeps its statistics on its rank, so the ranks' copies of a weight would come out differentlsqon the expert-parallel and replicated routes: its first scale calibration follows the rows above, but nothing has checked its learnable scales across ranksPreviews keep a sharded model's ranks decoding together. This covers the expert-parallel runs, Nemotron-VL's previews included, and
--use_fsdp2on several GPUs.preview_generate_kwargshandsgeneratesynced_gpus=Truefor such a plan: in the previews before and after quantization, and in Nemotron-VL's.all_reduceof whether each found a sample image), so every rank previews or none does. Each node reads the images from its own checkout, and the repository ships none.synced_gpusa failing preview raises instead of being skipped, since its peers would wait for it. This covers the VL previews and the preview after quantization. A failed preview after quantization stops the run before the export, with a message that names--skip_generate. A single process keeps the best-effort skip (NVBug 6752977).distributed_runaborts a rank that raised, sotorchrunstops the others, rather than tear the process group down while they wait in a collective that rank never reaches.generatekeeps a MoE's experts on their backend.generateswitches the experts of agrouped_mmMoE tobatched_mmfor decoding (_optimize_model_for_decode, transformers 5.0 and later).batched_mmbypasses the quantizers, so the preview after PTQ decoded unquantized experts.FSDPQwen3MoeForCausalLM does not support setting experts implementation.keep_the_experts_implementationkeeps every module of the model that has the hook from switching. A model can generate through a submodule, as a Nemotron-VL model'schatdoes through its language model.get_model(..., distribution=plan)pins a whole model to the rank's GPU. A replicated run loads through it.cuda:<local_rank>: the plain load, the pack-quantized and MXFP4 loads, and DiffusionGemma's sequential map. Accelerate's "auto" would run its placement in every rank alike and could put every rank's copy on the same idle GPU.--use_seq_device_mapand a device that names no GPU index are refused. A pinned copy is not sized against the node's GPUs, a limit its one-device map would ignore.torch.devicenever equals a string such as"cpu".get_modelloads the whole model on every rank, and hf_ptq hands such plans toload_pretrained.get_modelhandsload_pretrainedonly a plan its caller gave. Without one, the load decides as before, and it refusesMODELOPT_*knobs that ask to shard over a launch whose process group is not set up.distributed_forward_loop(..., forward=None)(from [OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133).model(**batch). A batch that needs its own call into the model cannot go through it: a VLM's image-text batch, say, which runs through the VLM's own forward with the keyword arguments that forward accepts.forward(batch), and still moves each batch to the device and calibrates with the cache disabled.forwardis typed as taking aMapping. It gets a new dict whendeviceis given, and the loader's own mapping otherwise.Memory monitor (from [OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133).
hf_ptq.pystarts it on each node's first rank, since it reads every GPU on its node.launch_memory_monitorchecks that NVML can report memory before it starts the monitor. But the probe ran beforenvmlInit, so every probe raisedNVMLError_Uninitialized. The monitor never started on any host where nothing else had initialized NVML.stop(), which also runs at exit, does nothing on a monitor already stopped.stop()joins the polling thread before it reads and prints the peaks, so a sampling pass in flight finishes first.Slurm example. New
examples/hf_ptq/slurm/multinode_ep_ptq.slurm, followingmultinode_fsdp2_ptq.slurm. Not run on a cluster yet.EXPORT_PATHandHF_HOMEmust be on storage every node can see: every rank writes its share of the checkpoint, and rank 0 downloads a repo id for the others to read.flockwhere the filesystem offers it.torchrunper node, which spawns one process per GPU, andhf_ptq.pyshards the routed experts over all ranks.Docs. The README section "Expert-Parallel Post-Training Quantization for MoE Models" covers:
--use_fsdp2refuses their released MXFP4 / multimodal checkpoints;The out-of-memory note covers every
torchrunlaunch of several ranks, not only--use_fsdp2. A multi-node run needs the export directory and the Hugging Face cache on storage every node can see.Usage
# One node, 8 GPUs: the experts shard over all 8 ranks, each calibrating its own data. torchrun --nproc_per_node 8 examples/hf_ptq/hf_ptq.py \ --pyt_ckpt_path Qwen/Qwen3-30B-A3B --qformat nvfp4 --export_path /out/qwen3-30b-a3b-nvfp4Testing
New
tests/examples/hf_ptq/test_hf_ptq_expert_parallel.py(57 cases). The decision itself is tested in [OMNIML-6054] Decide how a model is distributed, from the model built on the meta device #2737 and [OMNIML-6060] Load an expert-parallel plan through load_pretrained, and apply the model spec when deciding and loading it #2780. It covers:--use_fsdp2, and FSDP2 sharding refused unless--use_fsdp2;load_modelloading by the plan: an EP plan and--use_fsdp2throughload_pretrainedand no other loader, a data-parallel one throughget_model;--low_memory_mode,--use_seq_device_map,--offload_folder) refused on the expert-parallel and replicated routes and kept by a single process;main()running insidedistributed_run, which it leaves even when the run raises, and handing the run the statedistributed_runyields, with--devicefor a single process;The DeepSeek-V3 multi-rank cases skip below transformers 5.7, where [OMNIML-6060] Load an expert-parallel plan through load_pretrained, and apply the model spec when deciding and loading it #2780 refuses expert parallelism before the route they exercise. Their single-process and
--use_fsdp2cases still run there.New
tests/examples/hf_ptq/test_hf_ptq_previews.py:synced_gpusexactly when the plan shards the model;chat;synced_gpus.tests/examples/hf_ptq/test_example_utils.py:get_model's pin on every load path, a pinned copy not held to a node-wide memory limit, a single process left on "auto", and a CPU device given as atorch.device;get_modelwithout a plan leaving the decision toload_pretrained;generatefrom switching the experts implementation, also on a language model that a model around it generates through (fails without the fix); a model without the decode hook is previewed as it is; and a decode hook the model holds itself is put back, also when previews nest.tests/examples/hf_ptq/test_hf_ptq_args.py:tests/examples/hf_ptq/test_hf_ptq_model_type.py: the preview tests' stand-in models are modules, since the previews read their plan.tests/unit/torch/utils/test_distributed_calibration.py: two batches run through a given forward, on the device and with the cache disabled.New
tests/unit/torch/utils/test_memory_monitor.py, against a fake reference-counted NVML:stop()does nothing;stop()waits for the sampling pass in flight.Mutation-checked: each test fails under a deliberate break of the code it covers.
New 2-GPU smoke test
test_ptq_qwen3_moe_sharded(tests/examples/hf_ptq/test_llm_ptq.py). It is the tests' one real sharded run of the script; the files above cover the wiring with stand-ins.It runs
torchrun --nproc_per_node 2 hf_ptq.pyon a tiny Qwen3-MoE with the FP8 recipe, in two cases: expert-parallel (MODELOPT_EP_DEGREE=2, transformers 5.7 or later) and--use_fsdp2.Both preview before and after calibration, and export.
The test checks that every expert of every layer is exported FP8 with its weight scale, and that the config names FP8.
It skips on CI's 1-GPU PR runner and runs on 2-GPU runners.
Results at db78d0e (2026-10-30), on 2 GB200s with torch 2.11.0a0 and transformers 5.9.0:
tests/examples/hf_ptq/test_llm_ptq.py::test_ptq_qwen3_moe_shardedexpert_parallel,fsdp2)tests/gpu/torch/quantization/test_fsdp2_autoquant.pyThe re-plant was checked with a 2-GPU A/B on an earlier revision. Setup: a tiny Qwen3-MoE at EP 2 with the dense part replicated, MXFP8 on weight and input quantizers with algorithm
None, previews on.A tiny Kimi-K3 VLM MoE (bf16, 32 experts) ran on 2 GPUs at EP 2, with transformers 5.18 and NVFP4 with an FP8 KV cache. It calibrated on text and previewed before and after PTQ on both ranks. The calibration loop re-installed its 3 experts' forward wrappers, and the run exported.
These GPU results were measured on earlier revisions of this series, before this script's loads moved onto
load_pretrained([OMNIML-6054] [2/2] Route load_pretrained by the distribution plan #2779, [OMNIML-6060] Load an expert-parallel plan through load_pretrained, and apply the model spec when deciding and loading it #2780). This revision's GPU tests run in CI only.The series' GPU tests: 19 passed on a 4-GPU node. On 1 GPU (CI's PR runner), 6 passed and 13 skipped. They cover the expert-parallel load with the dense part replicated and sharded, at EP 4 and EP 2; amax sync; and the distributed export.
Against the series' base on one GPU node:
nox -s gpu(all oftests/gpu, 1 GPU, CI's PR-runner shape)tests/examples/hf_ptq, TensorRT-LLM image, 1 GPU)tests/gpu_megatron(2 GPUs)Expert-parallel runs end to end. All of them predate [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074's expert weight-scale fix. Before that fix, on every rank but rank 0, an expert's weight scale came out too large wherever rank 0's expert at the same index had the larger one. That costs precision but never clips; the fix can only tighten such scales.
Qwen3-30B-A3B at EP16 over 4 nodes, NVFP4 and FP8:
Qwen3-VL-30B and MiniMax-M3: tensor counts match single-GPU exports, and every expert weight is packed.
MiniMax-M2.7 (block-FP8 source) at EP16 over 4 nodes, NVFP4:
Every exported weight name is the source checkpoint's, and routed experts are
block_sparse_moe.experts.N.w1/w2/w3.Served by vLLM (0.30 dev build, TP4), it scores close to the FP8 source on gsm8k (5-shot, 100 samples):
DeepSeek-V3 and GLM: on an earlier revision.
Kimi-K3 NVFP4 at EP64 over 16 GB200 nodes, in 10 minutes (peak 155 GiB per GPU on an earlier build of this series). Checked against the source checkpoint:
The same checks flag the export made before this series' fixes. vLLM 0.28 loads the export and starts serving. But it cannot serve a Kimi-K3 NVFP4 export with an FP8 KV cache: its FP8-KV path needs an FP8 prefill query, which its NVFP4 linears reject. TBD: generation and gsm8k from the checkpoint-mirror recipe
models/moonshotai/Kimi-K3/ptq/nvfp4_experts-fp8_pb_attention, on this series' code with the [OMNIML-6073] [2/2] Calibrate fused experts on grouped_mm and torch_a2a instead of forcing eager #3074 fix.Folded on 2026-11-03, on main a6cc167, on
hfep-base-14-hf-ptqrebuilt from [OMNIML-6060] Load an expert-parallel plan through load_pretrained, and apply the model spec when deciding and loading it #2780, [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742 and [OMNIML-6083] Write a sharded model's HF checkpoint without gathering it, and export an expert-parallel model through it #2752's content:This PR's 6 commits, [OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133's 6 and [OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754's 10 are unchanged: range-diff shows each as
=. The own diff equals the three PRs' own diffs combined, line for line.Pre-commit, mypy included, passes against main.
On CPU:
tests/examples/hf_ptqminustest_llm_ptq.py,test_vlm_ptq.pyandtest_convert_ggml_to_int8.py)test_distributed_calibration.py,test_distributed.py,test_memory_monitor.pytest_model_load_utils.py,test_moe_a2a.py,tests/unit/torch/modelsReview fixes, 2026-11-03 (Claude, on the folded diff): the Slurm example and the README say that the export directory and
HF_HOMEmust be on storage every node can see. The example's editable install takes turns underflock, and runs unlocked where the filesystem has no flock.--node_rankis dropped, since the c10d rendezvous ignores it.reinstall_a2a_forwardsis the re-plant function itself rather than an alias of a private one. Pre-commit passes against main. On CPU:test_distributed_calibration.py,test_distributed.py,test_memory_monitor.pyReview history: [OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754 (2026-10-11 to 11-02) and [OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133 (2026-10-30 to 11-02) for their changes. This PR's own fixes before the fold are listed below; their rebases are in its timeline.
get_modelhandsload_pretrainedonly a plan its caller gave (new test fails without it).get_modelrefuses a plan that shards the model.Before your PR is "Ready for review"
torchrunlaunch of several ranks without--use_fsdp2now runs as one distributed job (an MoE expert-parallel, any other model replicated) instead of one independent run per process. It refuses there the single-process load options, AutoQuantize and layerwise-export recipes, and the TensorRT-LLM export. This is listed under Backward Breaking Changes in the CHANGELOG.--use_fsdp2runs behave as before, with these exceptions:--deviceother thancuda(bydistributed_run, [OMNIML-6052] Add the distribution plan, a run-scoped process group, and collectives that fail together #2736). Main's--use_fsdp2ignored it and ran each rank oncuda:<local_rank>.grouped_mmMoE on a GPU keeps the experts' backend, in a single process too. The preview after PTQ now decodes on the quantized experts.--use_fsdp2previews on several GPUs decode in lockstep.--use_fsdp2run on several GPUs whose preview after quantization fails now stops before the export. Main skipped the preview and exported.--skip_generateruns it without the previews.forward=is optional; the memory monitor change is a fix.CONTRIBUTING.md: N/AAdditional Information
The last PR of expert-parallel PTQ for Hugging Face MoE models. It merges after #2780, #2742 and #2752. #3133 and #2754 are folded in (2026-11-03) and closed; their review history stays there.