Repository navigation
[OMNIML-6085] [2/3] Keep hf_ptq's previews in lockstep on a sharded model, and pin whole-model loads by plan - #3139
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.
There was a problem hiding this comment.
Claude review (round 4) — 1 CRITICAL, 0 IMPORTANT, 0 SUGGESTION
Scope: full review (bare /claude review). 7 files, +666/−88 at 7772c0d; 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 could read the previous rounds' review bodies and your reply comments, but not the inline threads themselves.
Resolved since round 3
- IMPORTANT (
vlm_utils.py:281, therun_text_only_generationre-raise) — closed by your explanation, and I accept it. A rank cannot tell whether its peers are still insidegenerate's collectives, so theall_reducevote cjluo-nv suggested cannot cover the mid-decode case; and the one model family the comment names (Nemotron-VL / Nemotron-Parse) is refused under--use_fsdp2byvalidate_fsdp2_supported(example_utils.py:106-107), so the aborting path is not reachable until [3/3]'s EP plans. Not re-filing. - SUGGESTION (the PR body's backward-compatibility line) — fixed. The body now states the single-process change: on transformers 5.0+ the post-PTQ preview of a
grouped_mmMoE decodes on the quantized experts. Your reason for noCHANGELOG.rstentry (the preview is a printed sanity check; the exported checkpoint is unchanged either way) is consistent with the repo's "critical or known bugs" rule. Settled.
Still open — one new finding
CRITICAL — hf_ptq.py:886-890: the new raise does not stop the run; the rank wedges in destroy_process_group with the message unprinted. (inline)
The direction of 7772c0d is right — swallowing under lockstep was the round-1/2/3 CRITICAL, and cjluo-nv asked for the raise. But raising is only half of it on this entrypoint. main is try: ... finally: cleanup_distributed(args) with no except that aborts (:1553-1596), and the library you added in [1/3] says in three places that this is not enough:
cleanup(): "…error paths must callabortbefore reaching thisfinally."
abort(): "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 exists for — one rank's decode fails on its own shard's sample — the peers stay in generate's all-reduce, this rank stays in destroy_process_group, no process exits non-zero, torchrun tears nothing down, and _PREVIEW_STOPS_THE_RUN is never printed. With setup_distributed_args' timeout=timedelta(hours=2) that is a two-hour wedge with no output, where main's warnings.warn at least printed before hanging in the export. This is the same conclusion you reached yourself in the round-2 reply ("a rank that raised would also wait, in cleanup_distributed's destroy_process_group") — the raise landed, the abort it depends on did not.
It is not specific to this branch: the two vlm_utils.py lockstep raises (:202, :281) and pre_quantize's un-wrapped generate all unwind into the same finally. So the fix belongs at main, matching what every other distributed entrypoint in this repo already does (examples/megatron_bridge/quantize.py:545-549, distill.py:1005-1009, distributed_run at distributed.py:680-690): except BaseException: if args.dist_state.world_size > 1: dist_utils.abort(); raise. Four lines, one place, and it makes all four raise sites behave as their comments claim. The inline comment has the snippet and a test shape.
What held up this round
- The lockstep gate itself is right:
preview_generate_kwargs' predicate (world_size > 1 and (is_expert_parallel or is_fsdp_dense)) excludes a replicated multi-rank plan (no forward collective) andDistributionPlan(1, dense="fsdp"), both parametrized.lockstepis computed once, before thetry, so the branch cannot disagree with whatgeneratewas handed. post_quantize's single-process path keeps the NVBug 6752977 best-effort skip verbatim, andtest_post_quantize_export_survives_a_failed_sanity_generatestill pins it; the newtest_post_quantize_stops_the_run_when_a_sharded_models_preview_failsasserts the__cause__andexport_calls == [], so it fails without the change.get_model's refusal of a sharding plan is correctly ordered —plan = distribution or DistributionPlan(), refuse, then computepinned— so a refusal leaves no state touched, and it makespinned'sworld_size > 1guard exact rather than safe-by-accident.routing = {} if distribution is None else {...}keepsload_pretrained's own env routing and its "process group is not set up" refusal intact without a caller plan, and all fourload_pretrainedsites carry**routing.- The pin composes with the other device-map routes:
use_seq_device_map and pinnedraises; the DiffusionGemma branch is gated onnot pinned;device_map=Noneroutes still land on the rank's GPU throughmodel.to(device);elif not pinnedaroundinfer_auto_device_mapavoids printing amax_memorylimit a one-device map would ignore (andmodel_kwargs["max_memory"]is only set inside that branch, so the pinned load passes none). keep_the_experts_implementationre-checked once more: 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__, thefinallyrestores on a failedgenerate, and nesting restores correctly. Walkingmodel.modules()is what makes Nemotron-VL'schat-through-the-language-model case work; thewrappedparametrization pins it and fails without the fix.- The image-preview vote is ordered correctly (
_load_sample_imagefirst, then an unconditionalall_reduceoncesynced_gpusis set — itself derived from the identical plan on every rank),combine=allgives every-or-none, and theall_reducecannot hit its planless-launch refusal sincesynced_gpusimplies a set-up group. - Tests sit in
tests/examples/, nottests/unit; the stand-in changes intest_hf_ptq_args.py/test_hf_ptq_model_type.pyare the minimum themodel.modules()walk requires, with assertions unchanged.
Risk
Moderate. The pinning half is well-fenced and inert until [3/3] lands a caller. The preview half is live today for --use_fsdp2 on more than one GPU, and the one open finding is that its failure handling still ends in a silent multi-hour hang rather than a fast, loud one — the same class of bug the PR exists to close, now one layer further out. Nothing under modelopt/ changed.
🤖 Generated with Claude Code
|
/ok to test a8b2bae |
|
/claude review |
Claude review (round 5) — 0 CRITICAL, 0 IMPORTANT, 0 SUGGESTIONScope: full review (bare Resolved since round 4CRITICAL ( except BaseException:
if args.dist_state.world_size > 1:
abort()
raiseWhat I checked, rather than taking it on the comment:
What held up this round
One note, not a finding
RiskLow, and down from the previous rounds. The blast radius is confined to 🤖 Generated with Claude Code |
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.
Every earlier concern, the round-4 abort included, is now fixed in the diff or settled by a reply I checked. A human still needs to sign off on the edits to existing tests.
Needs action:
- Sign off on the existing-test edits in
tests/examples/hf_ptq/test_hf_ptq_args.pyandtest_hf_ptq_model_type.py. TheSimpleNamespacestand-ins becamenn.Modules so the previews can read the plan; the assertions are unchanged.
No action needed:
- ✔️ Resolved since the last review:
- the post-PTQ preview no longer swallows a failure under lockstep;
mainnow callsabort()before teardown on a run of several ranks;get_modelrefuses EP/FSDP plans;get_modelforwards a plan only when the caller gave one.
Therun_text_only_generationabort and the wrong-GPU guard were settled by the author's replies.
- About 206 added source lines, under the budget. The new test file carries the standard NVIDIA header.
a8b2bae to
ab91b2a
Compare
90b9aca to
a6ec21a
Compare
|
/ok to test ab91b2a |
|
/ok to test 7f987fb |
ab91b2a to
7f987fb
Compare
a6ec21a to
5ef889a
Compare
…ed experts Each rank previews the first sample of its own calibration shard, so the ranks finish decoding at different steps. A model its plan shards runs a collective in every forward (FSDP2's all-gathers, the routed experts' all-to-all), and a rank that stopped decoding left the others blocked in it. The previews now generate with synced_gpus when the recorded plan shards the model (preview_generate_kwargs), Nemotron-VL's text and image previews too. The ranks decide together whether to run the image preview, since each node reads the sample images from its own checkout, and a preview that fails under synced_gpus raises instead of leaving its rank behind. While a preview decodes, generate is kept from switching a MoE's experts to batched_mm (keep_the_experts_implementation), on every module that has the decode hook: batched_mm bypasses the quantizers, and transformers refuses the switch on an FSDP2-wrapped model. A hook a module holds itself is put back. Tests: the previews' kwargs by plan, Nemotron-VL's included, the image-preview vote, the decode hook kept and put back, nested previews. The previews read the model's plan, so the stand-in models of the existing preview tests are modules now. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
get_model takes the distribution plan it loads the model by. When the plan gives each of several ranks the whole model, every load path pins the rank's copy to the device it was handed, cuda:<local_rank>, the pack-quantized, MXFP4 and DiffusionGemma loads included. Left to accelerate's "auto", every rank resolves the same placement, and all their copies land on one GPU. A device that names no GPU index, and --use_seq_device_map, which spreads one model over every GPU, are refused there. A pinned copy is not sized against the node's GPUs, a limit its one-device map would ignore. The device is compared by its type, as a run's state carries a torch.device, which never equals a string such as "cpu". A single process keeps its placement. hf_ptq passes the plan in the next PR of this split. Tests: the pin on every load path, by a string and by a torch.device, a CPU device, and both refusals. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
get_model forwarded a single process's plan when its caller gave none, so load_pretrained no longer decided the layout itself: a MODELOPT_EP_DEGREE or MODELOPT_FSDP_SIZE asking to shard over a launch whose process group is not set up was ignored instead of refused, and every rank fell back to "auto" placement. get_model now forwards only a plan it was given, and pins by plan as before. The new test fails without it. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
get_model pins a whole copy of the model to each rank's GPU for a plan of several ranks. An expert-parallel or FSDP2 plan shards the model instead, and hf_ptq hands those to load_pretrained directly; one passed to get_model would get a pinned whole-model load, and advice that does not fit. Refuse it up front. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
The ranks of a sharded model decode the preview in lockstep (synced_gpus). A rank that warned and skipped a failed preview would go on to the export's collectives while the others still wait in generate's. Raise instead, with a message that names --skip_generate. A single process keeps the best-effort skip, as in NVBug 6752977's case. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
… group down A rank that fails alone, such as in a sharded model's lockstep preview, leaves the others in a collective it never reaches. main() then went through cleanup_distributed, whose barrier and destroy_process_group wait on those ranks, with the traceback still unprinted: the run hung until the collective timeout instead of failing. A run of several ranks now exits through abort(), which prints the error and exits, so the launcher stops the others; a single process raises as before. Signed-off-by: Shengliang Xu <shengliangx@nvidia.com>
|
/ok to test 739b185 |
7f987fb to
739b185
Compare
5ef889a to
72898fc
Compare
Note
[2/3] of #2754's split (2026-10-30), cut to the 500-line budget: [1/3] #3133 library pieces → [2/3] this PR, hf_ptq's previews and whole-model placement → [3/3] #2754, hf_ptq's expert-parallel run. They merge in that order.
Base:
shengliangx/hfep-base-14-hf-ptq, a branch (not a PR) that merges #2746, #2742, #2753 and #3133. This PR needs only #2779 (load_pretrained(distribution=...)) and #2752 (the plan--use_fsdp2records), both below it; the diff below is this PR's alone.What does this PR do?
Type of change: Bug fix and new feature
hf_ptq's generation previews, and
get_model's placement of a whole model, for a model distributed over several ranks. Both read the model's distribution plan. On this base--use_fsdp2already records its plan (#2752), so its runs get the preview changes now.get_model's pinning has its first caller in #2754, which loads every model by the plan.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: the VL previews, and the preview after quantization, which then stops the run before the export with a message that names--skip_generate. A single process keeps the best-effort skip (NVBug 6752977).mainaborts 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. Transformers also refuses the switch for an FSDP2-wrapped model: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.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([OMNIML-6085] [3/3] Run expert-parallel PTQ from hf_ptq.py #2754).Usage
Testing
test_hf_ptq_expert_parallel.py, where each was mutation-checked:tests/examples/hf_ptq/test_example_utils.py: the pin on every load path, a pinned copy not held to a node-wide memory limit, a single process left on "auto", a CPU device given as atorch.device, and the refusals of the sequential map and of a device without a GPU index.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.pyalso covers the experts guard:generatefrom switching the experts implementation, also on a language model that a model around it generates through (fails without the fix);tests/examples/hf_ptq/test_example_utils.pyalso checks thatget_modelwithout a plan leaves the decision toload_pretrained, which refuses a sharding knob over a launch without a process group.tests/examples/hf_ptq/test_hf_ptq_model_type.py: the preview tests' stand-in models are modules, since the previews read their plan.test_hf_ptq_args.py: the stand-in whosegeneratefails carries a plan.hfep-base-14-hf-ptq(every parent rebased onto main 99c3834): no conflicts in this PR's commits. Review fix (Claude):get_modelhandsload_pretrainedonly a plan its caller gave, so without one the load decides as before and refusesMODELOPT_*knobs that ask to shard over a launch whose process group is not set up; the new test fails without it. Pre-commit passes against main; the hf_ptq example tests above pass on CPU (transformers 5.18: 184 passed; 5.5.4: 184 passed).get_modelrefuses a plan that shards the model, which hf_ptq loads throughload_pretrainedinstead; the new test fails without the check. This description no longer says that a single process previews as before (see the compatibility note). Pre-commit passes against main;test_example_utils.py,test_hf_ptq_previews.py,test_hf_ptq_model_type.pyandtest_hf_ptq_args.pypass on CPU (transformers 5.18 and 5.5.4: 178 passed each).--skip_generate; a single process keeps the skip, and NVBug 6752977's test is unchanged. The new test next to it, with an FSDP2 plan of two ranks, fails without the change. Pre-commit passes against main; the same four test files pass on CPU (transformers 5.18 and 5.5.4: 179 passed each).mainexits a rank that raised throughabort(), which prints the error and exits, instead of its teardown, whose barrier anddestroy_process_groupwould wait on ranks blocked in a collective it never reaches; so every lockstep raise stops the run. The new test fails without the change. Pre-commit passes against main; the four test files pass on CPU (transformers 5.18 and 5.5.4: 181 passed each).hfep-base-14-hf-ptqrebuilt from the rebased [OMNIML-6076] [3/3] Apply the model spec when deciding and loading expert-parallel #2746, [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742, [OMNIML-6084] Export an expert-parallel model through the no-gather writer #2753 and [OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133: no conflicts, own diff unchanged. Pre-commit passes against main; the four test files pass on CPU (transformers 5.18 and 5.5.4: 185 passed each).hfep-base-14-hf-ptqrebuilt from the rebased [OMNIML-6076] [3/3] Apply the model spec when deciding and loading expert-parallel #2746, [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742, [OMNIML-6084] Export an expert-parallel model through the no-gather writer #2753 and [OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133: no conflicts, own diff unchanged. Pre-commit passes against main; the four test files pass on CPU (transformers 5.18 and 5.5.4: 185 passed each).hfep-base-14-hf-ptqrebuilt from the rebased [OMNIML-6076] [3/3] Apply the model spec when deciding and loading expert-parallel #2746, [OMNIML-6061] Load the dense part sharded, and size the load warmup to each rank #2742, [OMNIML-6084] Export an expert-parallel model through the no-gather writer #2753 and [OMNIML-6085] [1/3] Add forward= to distributed_forward_loop, and fix the GPU memory monitor's NVML probe #3133: no conflicts, own diff unchanged. Pre-commit passes against main; the four test files pass on CPU (transformers 5.18 and 5.5.4: 185 passed each).Before your PR is "Ready for review"
grouped_mmMoE on a GPU now keeps the experts' backend, in a single process too: the preview after PTQ decodes on the quantized experts, wheregenerateused to switch them tobatched_mm, which bypasses the quantizers.--use_fsdp2previews on several GPUs also decode in lockstep now. One failure path changes: a--use_fsdp2run on several GPUs whose preview after quantization fails now stops before the export, where main skipped the preview and exported;--skip_generateruns it without the previews.CONTRIBUTING.md: N/AAdditional Information
Split out of #2754 on review (meenchen's bot, "split this PR into stacked [x/3] PRs"). Merge order: #3133 [1/3], then this PR, then #2754 [3/3].