Skip to content

FSDP2 calibration with hf_ptq.py [1/2] - #1563

Merged
sugunav14 merged 34 commits into
mainfrom
svelury/fsdp2-refactor
Jul 26, 2026
Merged

FSDP2 calibration with hf_ptq.py [1/2]#1563
sugunav14 merged 34 commits into
mainfrom
svelury/fsdp2-refactor

Conversation

@sugunav14

@sugunav14 sugunav14 commented May 28, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: New feature (+ refactor/removal of the legacy multi-node path)

Consolidates multi-node FSDP2 post-training quantization into the standard hf_ptq.py entry point behind a --use_fsdp2 flag, and removes the separate
Accelerate-based multinode_ptq.py script and fsdp2.yaml config. FSDP2 PTQ is now launched with torchrun and supports single-node multi-GPU and multi-node out
of the box.

Highlights:

  • --use_fsdp2 / --cpu_offload on hf_ptq.py — calibration runs under PyTorch FSDP2; decoder layers are sharded (root stays replicated), with optional CPU
    offload of decoder shards between forwards.
  • New modelopt/torch/utils/model_load_utils.py — parallel, round-robin safetensors loading: each rank reads only the decoder layers it owns from disk, then
    broadcasts them so every rank can shard its slice. Non-decoder weights (embed/lm_head/norm) are read on rank 0 and broadcast.
  • New FSDP2 helpers in modelopt/torch/utils/distributed.py — fsdp2_wrap, shard_dataloader, fsdp_aware_forward_loop, broadcast_state_dict.
  • Distributed export (unified_export_hf.py) — replaces the Accelerate get_state_dict gather with get_model_state_dict(full_state_dict, cpu_offload,
    broadcast_from_rank0); rank 0 writes files, other ranks sync on a barrier.
  • core_utils.py — relaxes the stale "root must be an FSDPModule" assert (only decoder layers are wrapped), and adds a CPU↔GPU mirror so weight access/writeback
    works for CPU-offloaded shards.
  • Docs — rewritten examples/llm_ptq/README.md FSDP2 section and the SLURM PTQ reference, both using torchrun --use_fsdp2.

v1 scope: standard causal-LM checkpoints only. --use_fsdp2 raises NotImplementedError for VILA, multimodal/VL, pack-quantized/compressed-tensors, speculative decoding, MTP, auto-quantize, and sparsity.

Design Notes

  1. Why a custom parallel-safetensors loader instead of HF device_map / accelerate

AutoModelForCausalLM.from_pretrained(device_map="auto" | "cpu") and accelerate.load_checkpoint_in_model both load the full checkpoint on every rank from disk (per-rank CPU peak ≈ full model size). For 70B that's ~140 GiB/rank; for 200B+ it OOMs the node before sharding can run. parallel_load_and_prepare_fsdp2 round-robins decoder layers across ranks so each rank reads only ~model_size / world_size from disk, then per-layer broadcasts to peers. Per-rank CPU peak is bounded by the largest single layer + transient broadcast, not the full model. This is what makes 200B+ FSDP2 PTQ feasible on commodity nodes; HF/accelerate's existing entry points don't expose this composition.

  1. Why a custom FSDP2 stack instead of keeping multinode_ptq.py + accelerate launch

The deleted multinode_ptq.py ran on accelerate launch --config_file fsdp2.yaml and duplicated hf_ptq.py's load → calibrate → export path. Two consequences:

  • Two divergent scripts for the same operation: CLI surface, calibration loop, and export rewrites had to land twice. They had already drifted.
  • Users had to know which script applied at which scale.

The new code path unifies under hf_ptq.py --use_fsdp2. Going direct to FSDP2 primitives (fully_shard, CPUOffloadPolicy, MixedPrecisionPolicy) instead of
routing through accelerate's wrappers buys:

  • Direct control over mp_policy and offload_policy (accelerate's plugin layer hides them).
  • The parallel-read loader above (incompatible with accelerate's per-rank full load).
  • No YAML config file in the example dir.

The "custom stack" is intentionally thin: fsdp2_wrap is a 5-line wrapper over fully_shard; the rest is pure torch.distributed. We're not reimplementing FSDP2 —just composing the public PyTorch surface directly rather than via accelerate's adapter.

  1. fsdp_aware_forward_loop ↔ transformers_trainer.py:_quantize_model duplication
    Both implement the same trick: mtq.quantize unwraps the FSDP module before calling the user's forward_loop, and forwarding through the unwrapped module bypasses FSDP2's pre/post-forward hooks (no all-gather → broken calibration). Both capture the outer wrapped model and forward through it instead. This PR extracts the pattern into fsdp_aware_forward_loop (in distributed.py) as the canonical helper. The QLoRA path (transformers_trainer.py:_quantize_model) keeps its inlined version this PR because the QLoRA forward loop has training-specific quirks (batch shape, loss accumulation, gradient flow) that need a careful pass to share the helper cleanly. The TODO in the helper's docstring marks the consolidation target.

Usage

 # Single node, multiple GPUs
  torchrun --standalone --nproc_per_node=<num_gpus> hf_ptq.py \
      --pyt_ckpt_path <model> \
      --qformat nvfp4 \
      --export_path <out> \
      --use_fsdp2

  # Multi-node (run on each node)
  torchrun \
      --nnodes=<N> --node_rank=<rank> \
      --master_addr=<node0_ip> --master_port=<port> \
      --nproc_per_node=<gpus_per_node> \
      hf_ptq.py \
      --pyt_ckpt_path <model> --qformat nvfp4 \
      --export_path <out> --use_fsdp2 --cpu_offload   # --cpu_offload for very large models

Testing

  • tests/gpu/torch/quantization/test_fsdp2.py: test_writeback_root_unwrapped (assert relaxation, root unwrapped) and test_writeback_cpu_offload (CPU↔GPU mirror
    round-trip under CPUOffloadPolicy).
  • tests/unit/torch/utils/test_model_load_utils.py: pure-function tests for weight_map_for (sharded / single-file / missing) and read_safetensors_subset.
  • Manual end-to-end PTQ runs on single- and multi-node torchrun (FP8 / NVFP4 / NVFP4 layerwise), with and without --cpu_offload.

Before your PR is "Ready for review"

Make sure you read and follow Contributor guidelines and your commits are signed (git commit -s -S).

Make sure you read and follow the Security Best Practices (e.g. avoiding hardcoded trust_remote_code=True, torch.load(..., weights_only=False), pickle, etc.).

  • Is this change backward compatible?: ❌ hf_ptq.py is fully backward compatible (FSDP2 is opt-in via a new flag), but the legacy examples/llm_ptq/multinode_ptq.py script and fsdp2.yaml are removed. Users of the old multi-node entry point must migrate to hf_ptq.py --use_fsdp2
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: N/A
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ❌
  • Did you get Claude approval on this PR?: ✅ / ❌ / N/A

Additional Information

Summary by CodeRabbit

  • New Features

    • FSDP2-enabled PTQ: torchrun single-/multi-node execution, CPU offload, and NVFP4 layerwise calibration; distributed model loading and export support.
  • Documentation

    • Rewritten PTQ guide and SLURM notes with explicit torchrun/FSDP2 instructions.
  • Removed

    • Legacy Accelerate-based multinode PTQ workflow and YAML config.
  • Tests

    • Added FSDP2-focused tests for quantization writeback and safetensors distributed loading.

@sugunav14
sugunav14 requested review from a team as code owners May 28, 2026 23:22
@sugunav14
sugunav14 requested a review from realAsma May 28, 2026 23:22
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds FSDP2-aware distributed loading, sharding, and checkpoint export paths into hf_ptq; implements parallel safetensors subset loading, fsdp2 wrap/sharding utilities, DTensor gather/writeback bookkeeping changes, rank-aware export synchronization, README and SLURM docs updates, and regression/unit tests.

Changes

FSDP2 PTQ Consolidation

Layer / File(s) Summary
Model load and distributed helpers
modelopt/torch/utils/model_load_utils.py, modelopt/torch/utils/distributed.py, tests/unit/torch/utils/test_model_load_utils.py, tests/gpu/torch/utils/test_model_load_utils.py
Implements safetensors subset reader, weight-map resolution, meta-model materialization, non-DTensor promotion, parallel_load_and_prepare_fsdp2, fsdp2_wrap, shard_dataloader, fsdp_aware_forward_loop, broadcast_state_dict, and unit+GPU tests for weight-map, subset reading, dataloader sharding, forward-loop behavior, and end-to-end parallel load/export.
hf_ptq integration & example utils
examples/llm_ptq/hf_ptq.py, examples/llm_ptq/example_utils.py, examples/llm_ptq/README.md, .claude/skills/ptq/references/slurm-setup-ptq.md
Adds --use_fsdp2/--cpu_offload CLI flags and validation; setup_distributed_args/cleanup_distributed; validate_fsdp2_supported and load_and_prepare_fsdp2_model wrappers; shards calibration dataloader per rank; selects fsdp_aware_forward_loop for calibration; gates disk writes to rank 0; rewrites README and SLURM guidance to torchrun-based FSDP2 usage and documents nvfp4_max_layerwise.
Distributed HF checkpoint export
modelopt/torch/export/unified_export_hf.py
Use torch.distributed.checkpoint.state_dict.get_model_state_dict with StateDictOptions(full_state_dict=True, cpu_offload=True, broadcast_from_rank0=True) to gather full state for FSDP modules; only rank 0 writes files; other ranks return early and a final torch.distributed.barrier() synchronizes all ranks.
Quantization writeback adjustments
modelopt/torch/quantization/utils/core_utils.py, tests/gpu/torch/quantization/test_fsdp2.py
Remove strict root-FSDP assertion; when gathering DTensor params materialize local tensors, create optional CPU mirrors and GPU working copies, extend originals bookkeeping, and restore CPU mirrors during writeback; add tests verifying writeback behavior for root-unwrapped and CPU-offload scenarios.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • meenchen
  • kinjalpatel27
  • realAsma
  • shengliangxu
  • jingyu-ml
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed All SECURITY.md rules verified: torch.load properly justified for internal buffers, trust_remote_code defaults False, no eval/exec risks, no nosec bypasses, no problematic new dependencies.
Title check ✅ Passed The title clearly describes the main change: adding FSDP2 calibration support in hf_ptq.py.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch svelury/fsdp2-refactor

Comment @coderabbitai help to get the list of available commands.

@sugunav14
sugunav14 marked this pull request as draft May 28, 2026 23:23
@copy-pr-bot

copy-pr-bot Bot commented May 28, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/llm_ptq/example_utils.py`:
- Around line 68-78: The else branch for when getattr(args, "use_fsdp2", False)
is false is overwriting args.device with None which breaks non-FSDP device
handling (e.g., --device cpu); change the else branch in that block so it sets
args.rank = 0, args.world_size = 1 and args.is_main = True but does NOT modify
args.device (leave existing args.device intact or only set it if undefined), so
downstream calls like get_model() receive the requested device; keep reference
to getattr(args, "use_fsdp2", False), args.device, args.rank, args.world_size,
args.is_main and get_model() to locate the code to change.
- Around line 183-201: Modify create_fsdp2_calibration_loop to accept an is_main
(default False) parameter and thread it into the inner calibrate closure; inside
calibrate only wrap the dataloader with tqdm(desc="Calibrating") when is_main is
True and otherwise iterate the dataloader without tqdm to avoid per-rank
progress bars, and use print_rank_0 to emit any high-level start/finish messages
on rank 0 if needed; update references to the calibrate closure accordingly so
callers can pass is_main=True on rank 0.
- Around line 115-180: The FSDP2 load path in load_and_prepare_fsdp2_model
ignores the --attn_implementation setting, so pass the attn implementation
through to HuggingFace calls and the caller: include args.attn_implementation
(when args is not None) in AutoConfig.from_pretrained,
AutoModelForCausalLM.from_pretrained and AutoModelForCausalLM.from_config by
forwarding it as the attention backend argument expected by HF (e.g.,
attn_implementation or equivalent kwarg), and ensure examples/llm_ptq/hf_ptq.py
passes args.attn_implementation into load_and_prepare_fsdp2_model when invoking
it; update references in this function (hf_config creation, from_pretrained
call, from_config call) and the call site to propagate the flag unchanged.

In `@examples/llm_ptq/hf_ptq.py`:
- Around line 1430-1440: Add a fail-fast validation after argument parsing to
reject the incompatible combination of --use_fsdp2 and auto-quantize: detect
when args.use_fsdp2 is true and the auto-quantize flag/setting (the CLI flag
that triggers mtq.auto_quantize(), e.g., args.auto_quantize_bits or equivalent)
is present/non-zero, log/raise a clear error and exit non-zero before calling
fsdp2_shard() or mtq.auto_quantize(); reference the parser option "--use_fsdp2",
the fsdp2_shard() call and mtq.auto_quantize() so the check runs immediately
after parsing and prevents entering the frozen-parameter path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 250df2b3-92b6-427a-ad39-e2fb1869f878

📥 Commits

Reviewing files that changed from the base of the PR and between a2c496a and 46cb80e.

📒 Files selected for processing (7)
  • examples/llm_ptq/README.md
  • examples/llm_ptq/example_utils.py
  • examples/llm_ptq/fsdp2.yaml
  • examples/llm_ptq/hf_ptq.py
  • examples/llm_ptq/multinode_ptq.py
  • modelopt/torch/quantization/utils/core_utils.py
  • modelopt/torch/utils/distributed.py
💤 Files with no reviewable changes (2)
  • examples/llm_ptq/fsdp2.yaml
  • examples/llm_ptq/multinode_ptq.py

Comment thread examples/hf_ptq/example_utils.py Outdated
Comment thread examples/llm_ptq/example_utils.py Outdated
Comment thread examples/llm_ptq/example_utils.py Outdated
Comment thread examples/hf_ptq/hf_ptq.py
@codecov

codecov Bot commented May 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.98039% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.90%. Comparing base (d143276) to head (e041fbb).

Files with missing lines Patch % Lines
modelopt/torch/utils/plugins/model_load_utils.py 90.10% 18 Missing ⚠️
modelopt/torch/quantization/utils/core_utils.py 89.47% 2 Missing ⚠️
modelopt/torch/utils/distributed.py 94.44% 2 Missing ⚠️
modelopt/torch/export/unified_export_hf.py 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1563      +/-   ##
==========================================
+ Coverage   75.79%   77.90%   +2.11%     
==========================================
  Files         518      519       +1     
  Lines       58658    58896     +238     
==========================================
+ Hits        44459    45885    +1426     
+ Misses      14199    13011    -1188     
Flag Coverage Δ
examples 43.19% <21.56%> (-0.29%) ⬇️
gpu 59.12% <87.45%> (+8.37%) ⬆️
regression 15.06% <3.92%> (+0.02%) ⬆️
unit 54.88% <36.47%> (-0.08%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sugunav14
sugunav14 marked this pull request as ready for review May 29, 2026 16:47
@sugunav14
sugunav14 requested a review from meenchen May 29, 2026 17:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
examples/llm_ptq/example_utils.py (1)

97-112: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject encoder-decoder configs before entering the FSDP2 loader.

load_and_prepare_fsdp2_model() always uses AutoModelForCausalLM, so families like T5/BART/Whisper currently slip through validation and fail later during model construction instead of with the intended early NotImplementedError.

Suggested fix
     if getattr(config, "quantization_config", None) is not None:
         issues.append("pack-quantized / compressed-tensors checkpoints")
+    if getattr(config, "is_encoder_decoder", False):
+        issues.append("encoder-decoder models (FSDP2 v1 only supports causal LMs)")
     if getattr(args, "specdec_offline_dataset", None) is not None:
         issues.append("speculative decoding (--specdec_offline_dataset)")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/llm_ptq/example_utils.py` around lines 97 - 112, The validation
block that collects issues must also reject encoder-decoder configs so
load_and_prepare_fsdp2_model() doesn't call AutoModelForCausalLM on incompatible
families; detect encoder-decoder models by checking config.is_encoder_decoder
(or config.model_type in known encoder-decoder types) and append a descriptive
message to issues (e.g., "encoder-decoder models (T5/BART/Whisper)"), keeping
the existing checks (vila, is_nemotron_vl, _is_multimodal_config,
quantization_config, specdec_offline_dataset, low_memory_mode) and raising the
same NotImplementedError if issues is non-empty before any use of
AutoModelForCausalLM or model construction.
♻️ Duplicate comments (4)
examples/llm_ptq/hf_ptq.py (1)

1552-1558: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject --use_fsdp2 together with --auto_quantize_bits.

load_and_prepare_fsdp2_model() goes through fsdp2_shard(), which freezes every parameter before auto_quantize() runs. The help text already says this combo is unsupported, so error out here instead of failing deep in the quantization flow.

Suggested fix
     if args.use_fsdp2 and os.environ.get("RANK") is None:
         parser.error("--use_fsdp2 requires launching with torchrun")
+    if args.use_fsdp2 and args.auto_quantize_bits is not None:
+        parser.error("--use_fsdp2 is not supported with --auto_quantize_bits")
     if args.cpu_offload and not args.use_fsdp2:
         parser.error("--cpu_offload requires --use_fsdp2")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/llm_ptq/hf_ptq.py` around lines 1552 - 1558, The CLI currently
allows combining --use_fsdp2 and --auto_quantize_bits which later breaks because
load_and_prepare_fsdp2_model -> fsdp2_shard freezes parameters before
auto_quantize runs; add an explicit check after parsing args that if
args.use_fsdp2 and args.auto_quantize_bits are both truthy, call parser.error
with a clear message rejecting this combination (mentioning --use_fsdp2 and
--auto_quantize_bits) so the program exits early instead of failing inside
load_and_prepare_fsdp2_model / fsdp2_shard / auto_quantize.
modelopt/torch/utils/distributed.py (1)

439-458: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hide the calibration progress bar on non-main ranks.

tqdm will render once per rank here, so multi-node calibration still spams overlapping progress bars. Thread an is_main flag into this helper and disable the bar elsewhere.

Suggested fix
-def fsdp_aware_forward_loop(wrapped_model, dataloader, device=None):
+def fsdp_aware_forward_loop(wrapped_model, dataloader, device=None, is_main: bool = True):
@@
-        for batch in tqdm(dataloader, desc="Calibrating"):
+        for batch in tqdm(dataloader, desc="Calibrating", disable=not is_main):

As per coding guidelines **/*.py: Use print_rank_0 or warn_rank_0 when possible to avoid noisy logs and guard shared side effects against race conditions between ranks in distributed processing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modelopt/torch/utils/distributed.py` around lines 439 - 458, The calibration
progress bar in fsdp_aware_forward_loop's inner function calibrate renders on
every rank; add an is_main (or rank0) boolean parameter to
fsdp_aware_forward_loop and thread it into calibrate, then pass disable=not
is_main (or only instantiate tqdm when is_main) so tqdm only shows on the main
rank; additionally wrap any shared-side-effect logs with
print_rank_0/warn_rank_0 and ensure any state changes that could race between
ranks are guarded by the same is_main check.
examples/llm_ptq/example_utils.py (2)

74-78: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't overwrite the requested device in the non-FSDP path.

Setting args.device = None makes --device cpu a no-op and can later feed None into get_model().

Suggested fix
     else:
         args.rank = 0
         args.world_size = 1
-        args.device = None
         args.is_main = True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/llm_ptq/example_utils.py` around lines 74 - 78, The code sets
args.device = None in the non-FSDP branch which overwrites any user-requested
device and can pass None into get_model(); change this to preserve the provided
device (do not assign None) — either remove the args.device = None assignment or
set args.device = args.device if already defined, ensuring args.device retains
"--device" input before calling get_model() and other functions that expect a
valid device.

115-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve --attn_implementation on the FSDP2 load path.

Under --use_fsdp2, this helper ignores the CLI flag entirely, so config/model loading diverges from get_model() and can pick a different attention backend or memory profile.

Suggested fix
 def load_and_prepare_fsdp2_model(
     ckpt_path: str,
     device: torch.device,
     rank: int,
     args=None,
     trust_remote_code: bool = False,
+    attn_implementation: str | None = None,
     mp_policy=None,
     cpu_offload: bool = False,
 ):
@@
-    hf_config = AutoConfig.from_pretrained(ckpt_path, trust_remote_code=trust_remote_code)
+    config_kwargs = {"trust_remote_code": trust_remote_code}
+    if attn_implementation is not None:
+        config_kwargs["attn_implementation"] = attn_implementation
+    hf_config = AutoConfig.from_pretrained(ckpt_path, **config_kwargs)
@@
         src_model = AutoModelForCausalLM.from_pretrained(
             ckpt_path,
             torch_dtype="auto",
             trust_remote_code=trust_remote_code,
+            attn_implementation=attn_implementation,
             low_cpu_mem_usage=True,
         )
@@
         model = AutoModelForCausalLM.from_config(
-            hf_config, torch_dtype=dtype, trust_remote_code=trust_remote_code
+            hf_config,
+            torch_dtype=dtype,
+            trust_remote_code=trust_remote_code,
+            attn_implementation=attn_implementation,
         )

Please also pass args.attn_implementation from examples/llm_ptq/hf_ptq.py.

Also applies to: 146-168

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/llm_ptq/example_utils.py` around lines 115 - 123, The FSDP2 load
path in load_and_prepare_fsdp2_model currently ignores the CLI flag for
attention backend; update the function to accept and propagate
args.attn_implementation (or a dedicated attn_implementation param) when
building the model/config so the same attention implementation used by
get_model() is preserved, and ensure callers (examples/llm_ptq/hf_ptq.py) pass
args.attn_implementation into load_and_prepare_fsdp2_model; also mirror this
change in the related FSDP2 code paths around the block at 146-168 so the
attention backend and memory profile remain consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@examples/llm_ptq/example_utils.py`:
- Around line 97-112: The validation block that collects issues must also reject
encoder-decoder configs so load_and_prepare_fsdp2_model() doesn't call
AutoModelForCausalLM on incompatible families; detect encoder-decoder models by
checking config.is_encoder_decoder (or config.model_type in known
encoder-decoder types) and append a descriptive message to issues (e.g.,
"encoder-decoder models (T5/BART/Whisper)"), keeping the existing checks (vila,
is_nemotron_vl, _is_multimodal_config, quantization_config,
specdec_offline_dataset, low_memory_mode) and raising the same
NotImplementedError if issues is non-empty before any use of
AutoModelForCausalLM or model construction.

---

Duplicate comments:
In `@examples/llm_ptq/example_utils.py`:
- Around line 74-78: The code sets args.device = None in the non-FSDP branch
which overwrites any user-requested device and can pass None into get_model();
change this to preserve the provided device (do not assign None) — either remove
the args.device = None assignment or set args.device = args.device if already
defined, ensuring args.device retains "--device" input before calling
get_model() and other functions that expect a valid device.
- Around line 115-123: The FSDP2 load path in load_and_prepare_fsdp2_model
currently ignores the CLI flag for attention backend; update the function to
accept and propagate args.attn_implementation (or a dedicated
attn_implementation param) when building the model/config so the same attention
implementation used by get_model() is preserved, and ensure callers
(examples/llm_ptq/hf_ptq.py) pass args.attn_implementation into
load_and_prepare_fsdp2_model; also mirror this change in the related FSDP2 code
paths around the block at 146-168 so the attention backend and memory profile
remain consistent.

In `@examples/llm_ptq/hf_ptq.py`:
- Around line 1552-1558: The CLI currently allows combining --use_fsdp2 and
--auto_quantize_bits which later breaks because load_and_prepare_fsdp2_model ->
fsdp2_shard freezes parameters before auto_quantize runs; add an explicit check
after parsing args that if args.use_fsdp2 and args.auto_quantize_bits are both
truthy, call parser.error with a clear message rejecting this combination
(mentioning --use_fsdp2 and --auto_quantize_bits) so the program exits early
instead of failing inside load_and_prepare_fsdp2_model / fsdp2_shard /
auto_quantize.

In `@modelopt/torch/utils/distributed.py`:
- Around line 439-458: The calibration progress bar in fsdp_aware_forward_loop's
inner function calibrate renders on every rank; add an is_main (or rank0)
boolean parameter to fsdp_aware_forward_loop and thread it into calibrate, then
pass disable=not is_main (or only instantiate tqdm when is_main) so tqdm only
shows on the main rank; additionally wrap any shared-side-effect logs with
print_rank_0/warn_rank_0 and ensure any state changes that could race between
ranks are guarded by the same is_main check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 0c53a76c-6ab3-44c4-92db-4acf83822755

📥 Commits

Reviewing files that changed from the base of the PR and between 46cb80e and 705316d.

📒 Files selected for processing (3)
  • examples/llm_ptq/example_utils.py
  • examples/llm_ptq/hf_ptq.py
  • modelopt/torch/utils/distributed.py

@sugunav14
sugunav14 requested a review from shengliangxu May 29, 2026 20:07
@sugunav14
sugunav14 marked this pull request as draft May 29, 2026 20:39

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot review — DM the bot to share feedback.

Substantial new FSDP2 subsystem (~860 LOC across examples/llm_ptq + modelopt/torch/utils/distributed.py) but the PR description is the unfilled template — no problem statement, no testing notes, no Changelog mention, the backward-compat / tests / Claude-approval boxes are unchecked. The deterministic complexity gate fired (1318 lines / 5 dirs / >300-line single non-test file), so design review is required, and the design question is unaddressed in the PR body.

Design protocol — alternatives that are not justified against:

  1. examples/llm_ptq/multinode_ptq.py (deleted in this PR) already did multi-node PTQ via HuggingFace Accelerate's FSDP2 integration. The PR rips that out and replaces it with a hand-rolled fsdp2_wrap / fsdp2_shard / _load_via_parallel_read / broadcast_state_dict stack. The PR body never explains why accelerate's FSDP2 path was insufficient and a parallel custom system is the right answer.
  2. modelopt/torch/quantization/plugins/transformers_trainer.py (_quantize_model, _patch_accelerate_for_fsdp2_fix) already runs FSDP2 calibration via accelerate and has the same "use the wrapped model, not the unwrapped arg" forward-loop pattern. The new fsdp_aware_forward_loop carries a TODO: ... consolidate it onto this helper, i.e. the author knows the duplication exists. Introducing a second FSDP2 calibration entry point before consolidating is exactly the "two systems for one job" anti-pattern.
  3. transformers.AutoModelForCausalLM.from_pretrained already supports sharded safetensors loading via device_map/accelerate. The custom _load_via_parallel_read (per-layer broadcast + FSDP2 set_model_state_dict) is a sizable parallel I/O subsystem that needs justification — at minimum, numbers showing the rank-0-load fallback is unworkable for the target models.

Please address these in the PR body (or extend the existing accelerate path) before this lands.

Concrete issues found:

  • examples/llm_ptq/hf_ptq.py:948 and :1005: preview-generation max_new_tokens=10010 is changed unconditionally, not just on the FSDP2 path. This silently affects every existing user of hf_ptq.py (shorter pre/post-PTQ qualitative previews). If it's an FSDP2-perf workaround, gate it on args.use_fsdp2; otherwise call it out as an intentional global change.
  • examples/llm_ptq/hf_ptq.py:_export_fsdp2_hf_checkpoint: ~30 lines that re-implement export_hf_checkpoint in modelopt/torch/export/unified_export_hf.py (write hf_quant_config.json, save_pretrained with state_dict, patch quantization_config into config.json). The only real differences are rank-0 gating and _original_architectures. Worth extending export_hf_checkpoint to take a "rank0-only" flag and an architectures override rather than forking a second copy.
  • modelopt/torch/export/unified_export_hf.py:_export_transformers_checkpoint: get_model_state_dict(model, options=StateDictOptions(full_state_dict=True, cpu_offload=True)) materializes the full unsharded state dict on every rank (cpu_offload moves it to host, but each rank still gets a full copy). PR comment claims "Rank 0 holds the full model in CPU briefly … other ranks pay ~0 CPU" — that's not what full_state_dict=True gives you. Either pass broadcast_from_rank0-style options to make non-rank-0 cheap, or fix the comment.
  • modelopt/torch/utils/distributed.py:shard_dataloader: silently drops worker_init_fn, persistent_workers, prefetch_factor, timeout, multiprocessing_context, and generator when reconstructing the DataLoader. Probably fine for the current calibration use case but a footgun if reused; either copy them through or document the limitation.
  • modelopt/torch/utils/distributed.py:fsdp2_wrap uses LayerActivationCollector.get_decoder_layers, which returns nn.ModuleList | None; if not layers treats both None and an empty ModuleList as the same error, but the messages differ for the two paths and the empty case is currently impossible to hit. Minor — consider is None.

Test coverage:
The only new test (tests/gpu/torch/quantization/test_fsdp2.py:test_writeback_root_unwrapped) is a regression guard for the loosened fsdp2_weight_access_and_writeback_context assert — it does not cover any of the new public API: load_fsdp2_causal_lm, fsdp2_shard, _load_via_parallel_read, broadcast_state_dict, shard_dataloader, fsdp_aware_forward_loop, _export_fsdp2_hf_checkpoint, validate_fsdp2_supported. For ~500 LOC of new framework code in modelopt/torch/utils/distributed.py plus an entirely new CLI flag, that's not enough. Mandatory tests box is unchecked in the PR body too. At minimum: a 2-rank GPU test that loads a tiny causal-LM via load_fsdp2_causal_lm (parallel-read + fallback), runs a calibration forward via fsdp_aware_forward_loop, and exports to HF with rank-0-only writes — checking that the resulting config.json has the original (non-FSDP-prefixed) architectures.

Comment thread examples/llm_ptq/hf_ptq.py Outdated
Comment thread examples/llm_ptq/hf_ptq.py Outdated
Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread modelopt/torch/utils/distributed.py Outdated
loader.dataset,
batch_size=loader.batch_size,
sampler=sampler,
collate_fn=loader.collate_fn,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

TODO acknowledges that transformers_trainer.py:_quantize_model already inlines this exact "call the wrapped model, ignore the unwrapped arg" pattern. Introducing a second copy of the helper before consolidating is the wrong order — please either land the consolidation in this PR or open a follow-up issue and link it from the TODO so this doesn't quietly become permanent.

return calibrate


def broadcast_state_dict(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

shard_dataloader only forwards batch_size, collate_fn, num_workers, pin_memory. It silently drops worker_init_fn, persistent_workers, prefetch_factor, timeout, multiprocessing_context, and generator from the input loader. For calibration today this is fine, but the helper is a public-looking entry in __all__ — please either propagate those attributes (getattr(loader, ..., default) is enough) or note the limitation in the docstring.



def test_writeback_root_unwrapped(dist_workers):
dist_workers.run(_test_writeback_root_unwrapped)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

This is the only new test, and it covers the loosened fsdp2_weight_access_and_writeback_context precondition — none of the new public API (load_fsdp2_causal_lm, fsdp2_shard, _load_via_parallel_read, broadcast_state_dict, shard_dataloader, fsdp_aware_forward_loop, _export_fsdp2_hf_checkpoint, validate_fsdp2_supported) has direct test coverage. Please add at least a 2-rank GPU end-to-end test (tiny causal-LM → load_fsdp2_causal_lm → fsdp_aware_forward_loop calibration → rank-0 export) and verify the exported config.json keeps the pre-FSDP architectures list.

@sugunav14
sugunav14 marked this pull request as ready for review June 4, 2026 18:10
@sugunav14
sugunav14 requested a review from a team as a code owner June 4, 2026 18:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
examples/llm_ptq/hf_ptq.py (1)

1490-1497: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject --use_fsdp2 --sparsity_fmt sparsegpt with the other FSDP2 guardrails.

The new help text says FSDP2 v1 does not support sparsity, but the parser still accepts that combination and main() will route it into sparsity_main() after the FSDP2 load path. Please fail fast here next to the existing --use_fsdp2 validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/llm_ptq/hf_ptq.py` around lines 1490 - 1497, Add a fast-fail check
next to the existing FSDP2 validation to reject using FSDP2 together with the
sparsity format that triggers sparsity processing: if args.use_fsdp2 and
args.sparsity_fmt == "sparsegpt" then call parser.error(...) with a clear
message that FSDP2 v1 does not support sparsity/sparsegpt; this prevents main()
from routing into sparsity_main() when --use_fsdp2 is set. Use the existing
symbols args.use_fsdp2, args.sparsity_fmt, parser.error and reference
sparsity_main() in the message if helpful.
🧹 Nitpick comments (1)
tests/gpu/torch/quantization/test_fsdp2.py (1)

266-313: ⚡ Quick win

Add a regression for the CPU-offload writeback path too.

This new test covers the root-unwrapped case, but the production change also added the cpu_local/gpu_working mirror branch in fsdp2_weight_access_and_writeback_context(). Because this fixture keeps the gathered weight on CUDA the whole time, that sync-back path is still untested. A small distributed case with FSDP CPU offload would lock down the new behavior much better.

As per coding guidelines, "Write focused unit tests during development; before staging or committing, curate production tests by removing redundant lower-level tests and keeping only tests that document expected behavior, protect against regressions, or flag backward-incompatible changes."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/gpu/torch/quantization/test_fsdp2.py` around lines 266 - 313, Add a
distributed regression that exercises the CPU-offload writeback mirror branch by
creating a variant of test_writeback_root_unwrapped which enables FSDP CPU
offload (so the gathered weight moves to CPU and then back to GPU_working) and
calls the same enable_weight_access_and_writeback(layer[0], model) context;
specifically, duplicate or parametrize _test_writeback_root_unwrapped to
configure FSDP with cpu_offload=True (or the project’s equivalent flag) and
ensure you invoke
fsdp2_weight_access_and_writeback_context()/enable_weight_access_and_writeback
to validate both the cpu_local/gpu_working sync-back path and that shards are
restored and modified values are written back. Ensure the new test runs under
dist_workers like test_writeback_root_unwrapped and asserts DTensor sharding
before/after and correctness of the mutated weight.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/llm_ptq/example_utils.py`:
- Around line 78-96: The _checkpoint_has_mtp_weights helper currently only
inspects local files or already-cached indexes and thus misses remote HF repo
indexes on a cold cache; change _checkpoint_has_mtp_weights to first attempt to
materialize/download the index (use try_to_load_from_cache(model_path,
"model.safetensors.index.json") when callable and, if that returns None, trigger
a download/materialization of the remote "model.safetensors.index.json" into a
local temp/file cache) before inspecting it, then run the existing
json.load/weight_map check; ensure validate_fsdp2_supported calls this updated
_checkpoint_has_mtp_weights after the checkpoint path has been materialized
locally so MTP checkpoints are detected reliably.

In `@modelopt/torch/export/unified_export_hf.py`:
- Around line 1223-1227: The distributed barrier currently only runs on non-zero
ranks before returning, which can hang if rank 0 raises an exception later; make
the barrier exception-safe by ensuring every rank executes
torch.distributed.barrier() inside a finally block. Concretely, wrap the logic
that calls save_pretrained() and the subsequent config rewrite (the block
guarded by torch.distributed.is_available()/is_initialized() and
torch.distributed.get_rank()) in a try/finally so that
torch.distributed.barrier() is always called by all ranks in the finally, and
only after that should non-zero ranks return; i.e., reference and protect the
calls to save_pretrained() and the config rewrite and ensure
torch.distributed.barrier() is invoked in the finally on both failure and
success.

In `@modelopt/torch/quantization/utils/core_utils.py`:
- Around line 500-513: The temporary local nn.Parameter assignments (created via
local_replicated, working, cpu_mirror and set into module with
_set_parameter(module, name, nn.Parameter(working))) must be restored even if
the context body raises; wrap the code that sets originals[name] and calls
_set_parameter in a try/finally and move the writeback/teardown loop into the
finally so the original parameter, collected shard, original_placements,
original_device_mesh, and any cpu_mirror syncing are always restored/synced;
apply the same try/finally pattern to the similar block around the lines noted
(the block at 523-527) so both success and exception paths perform cleanup.

In `@modelopt/torch/utils/model_load_utils.py`:
- Around line 124-126: The code currently sets model.config.use_cache = False
mutating the live HF config; instead capture the original value (e.g.,
orig_use_cache = model.config.use_cache) before changing it, set use_cache to
False only for the local calibration/export operation (around the calls that
require it), and restore model.config.use_cache = orig_use_cache before
returning or before any save_pretrained/export path; alternatively move the
temporary disable into the specific calibration/export functions so model and
its config are never left mutated (refer to the model.eval() /
model.config.use_cache lines to locate the change).

---

Outside diff comments:
In `@examples/llm_ptq/hf_ptq.py`:
- Around line 1490-1497: Add a fast-fail check next to the existing FSDP2
validation to reject using FSDP2 together with the sparsity format that triggers
sparsity processing: if args.use_fsdp2 and args.sparsity_fmt == "sparsegpt" then
call parser.error(...) with a clear message that FSDP2 v1 does not support
sparsity/sparsegpt; this prevents main() from routing into sparsity_main() when
--use_fsdp2 is set. Use the existing symbols args.use_fsdp2, args.sparsity_fmt,
parser.error and reference sparsity_main() in the message if helpful.

---

Nitpick comments:
In `@tests/gpu/torch/quantization/test_fsdp2.py`:
- Around line 266-313: Add a distributed regression that exercises the
CPU-offload writeback mirror branch by creating a variant of
test_writeback_root_unwrapped which enables FSDP CPU offload (so the gathered
weight moves to CPU and then back to GPU_working) and calls the same
enable_weight_access_and_writeback(layer[0], model) context; specifically,
duplicate or parametrize _test_writeback_root_unwrapped to configure FSDP with
cpu_offload=True (or the project’s equivalent flag) and ensure you invoke
fsdp2_weight_access_and_writeback_context()/enable_weight_access_and_writeback
to validate both the cpu_local/gpu_working sync-back path and that shards are
restored and modified values are written back. Ensure the new test runs under
dist_workers like test_writeback_root_unwrapped and asserts DTensor sharding
before/after and correctness of the mutated weight.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 6556fc96-fe9a-4dd9-b207-1dc70a4eccc3

📥 Commits

Reviewing files that changed from the base of the PR and between 705316d and f1b57cd.

📒 Files selected for processing (7)
  • examples/llm_ptq/example_utils.py
  • examples/llm_ptq/hf_ptq.py
  • modelopt/torch/export/unified_export_hf.py
  • modelopt/torch/quantization/utils/core_utils.py
  • modelopt/torch/utils/distributed.py
  • modelopt/torch/utils/model_load_utils.py
  • tests/gpu/torch/quantization/test_fsdp2.py

Comment thread examples/llm_ptq/example_utils.py Outdated
Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread modelopt/torch/quantization/utils/core_utils.py Outdated
Comment thread modelopt/torch/utils/model_load_utils.py Outdated

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot review — DM the bot to share feedback.

Several critical review points from prior rounds addressed, but a few are still open and the design-protocol questions raised on the prior round are still not addressed in the PR body. Recommending human sign-off.

Addressed since prior review:

  • 💬 args.device = None clobber in non-FSDP path — fixed (setup_distributed_args no longer overwrites args.device in the else branch).
  • 💬 tqdm progress bar on every rank — fixed via disable=not is_master() in fsdp_aware_forward_loop.
  • 💬 --attn_implementation ignored on FSDP2 load path — now threaded through load_and_prepare_fsdp2_modelparallel_load_and_prepare_fsdp2build_meta_causal_lm, and hf_ptq.py passes args.attn_implementation.
  • 💬 --use_fsdp2 + --auto_quantize_bits — guarded by an assert at the top of auto_quantize() (and the help text already declared it unsupported).
  • 💬 _export_fsdp2_hf_checkpoint duplicating export_hf_checkpoint — refactored away; the FSDP2 path now goes through export_hf_checkpoint itself, which gained rank-0 gating + barrier and uses get_model_state_dict(..., broadcast_from_rank0=True) so non-rank-0 no longer pays full host state dict.
  • 💬 shard_dataloader dropping DataLoader kwargs — now forwards worker_init_fn, persistent_workers, prefetch_factor, timeout, multiprocessing_context, generator, pin_memory_device.
  • 💬 max_new_tokens=100→10 global preview change — appears reverted; current hf_ptq.py uses max_new_tokens=100 for both pre- and post-PTQ previews.

Still open / worth a human's eyes before approving:

  • 💬 Design-protocol questions raised in the prior review (PR body justification for the custom parallel-safetensors loader vs. HF device_map/accelerate, custom FSDP2 stack vs. the deleted accelerate-based multinode_ptq.py, and the fsdp_aware_forward_looptransformers_trainer.py:_quantize_model duplication that still carries a TODO: ... consolidate it onto this helper) — the PR body has not been updated to address them. The code still ships a second FSDP2 calibration helper alongside the existing one in transformers_trainer.py.
  • Test coverage of the new public surface is still thin: tests/gpu/torch/quantization/test_fsdp2.py adds test_writeback_root_unwrapped and test_writeback_cpu_offload (good — they cover the loosened assert and the new CPU↔GPU mirror) and tests/unit/torch/utils/test_model_load_utils.py covers weight_map_for / read_safetensors_subset, but parallel_load_and_prepare_fsdp2, fsdp2_wrap, shard_dataloader, fsdp_aware_forward_loop, broadcast_state_dict, the FSDP2 export path in export_hf_checkpoint, and validate_fsdp2_supported have no direct tests. A 2-rank GPU end-to-end (tiny causal-LM → load → calibrate → rank-0 export, asserting the exported config.json keeps the original architectures) is the obvious gap.
  • --use_fsdp2 --sparsity_fmt sparsegpt is still accepted by parse_args; main() then routes into sparsity_main() after FSDP2 load. The help text says sparsity is unsupported — please fail-fast in parse_args next to the other --use_fsdp2 guards.
  • examples/llm_ptq/example_utils.py:_checkpoint_has_mtp_weights still only inspects the local checkpoint dir + an already-cached model.safetensors.index.json (try_to_load_from_cache returns None on a cold cache). For a remote HF repo ID with no cache hit, validate_fsdp2_supported will silently accept an MTP checkpoint and the FSDP2 loader will drop the MTP weights at export. Either materialize the index first or move the MTP check after the snapshot download already done inside parallel_load_and_prepare_fsdp2.
  • modelopt/torch/utils/model_load_utils.py:build_meta_causal_lm mutates model.config.use_cache = False and never restores it; the same model is later passed to save_pretrained() so the exported checkpoint inherits use_cache=False. Capture/restore around the calibration scope or move the disable into the calibration call site.
  • modelopt/torch/quantization/utils/core_utils.py:fsdp2_weight_access_and_writeback_context does not wrap the yield in try/finally. If the body raises (calibration error, OOM mid-context, …) the temporary local nn.Parameters set via _set_parameter are never restored and the new CPU mirror never syncs back, leaving the module in a broken state.
  • modelopt/torch/export/unified_export_hf.py:export_hf_checkpoint puts the trailing torch.distributed.barrier() outside the try/except; if rank 0 raises during save_pretrained/config rewrite it re-raises before reaching the barrier, and non-zero ranks have already passed theirs — fine in this direction, but if rank 0 raises before the early non-zero-rank barrier (e.g. inside _export_transformers_checkpoint's rank-0-only work, since broadcast_from_rank0=True makes rank 0 do extra work after the gather), non-zero ranks wait out NCCL timeout. Putting both barriers in a finally would close this. The in-code comment acknowledges the gap, but it's still worth a second pair of eyes given how easy the fix is.
  • fsdp_aware_forward_loop still carries TODO: consolidate transformers_trainer.py:_quantize_model onto this helper — file a follow-up issue and reference it from the TODO so the duplication doesn't quietly become permanent.

@sugunav14 sugunav14 self-assigned this Jun 4, 2026

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot review — DM the bot to share feedback.

Major prior-round issues are addressed in the current diff; flagging for human sign-off because this is still a 1500-line architectural change introducing a parallel FSDP2 stack alongside the existing one, and a few lower-priority items remain.

Resolved since prior review:

  • 💬 Author addressed: args.device = None clobber removed; tqdm rank-gated via disable=not is_master(); attn_implementation threaded through load_and_prepare_fsdp2_modelparallel_load_and_prepare_fsdp2build_meta_causal_lm.
  • 💬 --use_fsdp2 + --auto_quantize_bits and --use_fsdp2 + --sparsity_fmt sparsegpt now fail fast (assert in auto_quantize, parser.error in parse_args).
  • 💬 _export_fsdp2_hf_checkpoint duplication eliminated — FSDP2 path now goes through export_hf_checkpoint, which gained rank-0 gating + try/finally barrier and uses get_model_state_dict(..., broadcast_from_rank0=True) so non-rank-0 ranks don't materialize a full host state dict.
  • 💬 shard_dataloader now forwards worker_init_fn, persistent_workers, prefetch_factor, timeout, multiprocessing_context, generator, pin_memory_device.
  • 💬 core_utils.py writeback wrapped in try/finally so the temporary local params and CPU mirror are always restored on exception.
  • 💬 _checkpoint_has_mtp_weights now uses hf_hub_download to resolve the index for remote HF repo IDs on a cold cache.
  • 💬 PR body now has a "Design Notes" section that explicitly addresses the three previously-raised design-protocol questions (custom parallel safetensors loader vs HF/accelerate, custom FSDP2 stack vs accelerate-based multinode_ptq.py, and the fsdp_aware_forward_loop ↔ transformers_trainer.py:_quantize_model duplication).
  • 💬 Test coverage broadened — tests/gpu/torch/utils/test_model_load_utils.py adds a 2-rank E2E test (test_parallel_load_and_export) that loads a tiny LlamaForCausalLM via parallel_load_and_prepare_fsdp2, runs a forward, exports via export_hf_checkpoint, and asserts the saved config.json keeps architectures == ["LlamaForCausalLM"]. Also covers broadcast_state_dict, shard_dataloader, fsdp_aware_forward_loop, plus weight_map_for/read_safetensors_subset unit tests.

Still warrants a human's eyes before approval:

  • Architectural surface: this lands a second FSDP2 calibration path (fsdp_aware_forward_loop) alongside the existing one in modelopt/torch/quantization/plugins/transformers_trainer.py:_quantize_model. The PR body acknowledges the duplication and notes a TODO to consolidate, but the TODO in modelopt/torch/utils/distributed.py:fsdp_aware_forward_loop is not linked to a tracked follow-up issue, so this risks becoming permanent. Recommend filing a follow-up and referencing it from the TODO before merge.
  • parallel_load_and_prepare_fsdp2 only verifies that the FSDP2 export path preserves architectures; it does not exercise CPU-offload end-to-end (the new --cpu_offload codepath has unit-level coverage only via test_writeback_cpu_offload on a synthetic 32-dim model). A 2-rank parallel_load_and_prepare_fsdp2(..., cpu_offload=True) smoke test would lock down the _promote_non_dtensor_to_gpu + per-layer CPU shard plumbing.
  • parse_args accepts but does not validate --use_fsdp2 against --vllm_fakequant_export or --cast_mxfp4_to_nvfp4; both will route into export_quantized paths that don't go through the rank-0-gated FSDP2 export. v1 scope should either claim or reject these explicitly.
  • unified_export_hf.py comment correctly notes that if rank 0 raises before the collective gather inside _export_transformers_checkpoint, the other ranks hang on NCCL timeout — out of scope per the comment, but worth a human eyeballing whether that gap is acceptable for v1.

@cjluo-nv cjluo-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot review — DM the bot to share feedback.

Most prior-round critical issues are addressed in the current diff; flagging for human sign-off given this is a 1500-line architectural change introducing a parallel FSDP2 stack alongside the existing one.

Resolved since prior reviews:

  • 💬 args.device = None clobber removed; tqdm rank-gated via disable=not is_master(); attn_implementation threaded through load_and_prepare_fsdp2_modelparallel_load_and_prepare_fsdp2build_meta_causal_lm.
  • 💬 --use_fsdp2 + --auto_quantize_bits (assert in auto_quantize) and --use_fsdp2 + --sparsity_fmt sparsegpt (parser.error in parse_args) now fail fast.
  • 💬 _export_fsdp2_hf_checkpoint duplication eliminated — FSDP2 path goes through export_hf_checkpoint which gained rank-0 gating + try/finally barrier and uses get_model_state_dict(..., broadcast_from_rank0=True).
  • 💬 shard_dataloader now forwards worker_init_fn, persistent_workers, prefetch_factor, timeout, multiprocessing_context, generator, pin_memory_device.
  • 💬 core_utils.py writeback wrapped in try/finally so temporary local params and CPU mirror are always restored on exception.
  • 💬 _checkpoint_has_mtp_weights uses hf_hub_download to resolve the index for remote HF repo IDs on a cold cache.
  • 💬 model_load_utils.py no longer mutates model.config.use_cache.
  • 💬 PR body has a "Design Notes" section explicitly addressing the three previously-raised design-protocol questions (custom parallel safetensors loader vs HF/accelerate, custom FSDP2 stack vs accelerate-based multinode_ptq.py, and the fsdp_aware_forward_loop ↔ transformers_trainer.py:_quantize_model duplication).
  • 💬 Test coverage broadened — tests/gpu/torch/utils/test_model_load_utils.py adds a 2-rank E2E test_parallel_load_and_export (load → forward → export → assert architectures preserved) plus tests for broadcast_state_dict, shard_dataloader, fsdp_aware_forward_loop, weight_map_for, read_safetensors_subset. Plus test_writeback_cpu_offload covers the new CPU↔GPU mirror.

Still warrants a human's eyes:

  • Architectural surface — this lands a second FSDP2 calibration helper (fsdp_aware_forward_loop) alongside the existing inlined one in modelopt/torch/quantization/plugins/transformers_trainer.py:_quantize_model. The PR body acknowledges the duplication and the helper has a TODO: ... consolidate it onto this helper, but the TODO is not linked to a tracked follow-up issue; recommend filing one and referencing it from the TODO before merge so the second copy doesn't quietly become permanent.
  • v1 scope guards in parse_args reject --use_seq_device_map, --auto_quantize_bits, and --sparsity_fmt sparsegpt but do not validate --use_fsdp2 against --vllm_fakequant_export or --cast_mxfp4_to_nvfp4; both will route into export_quantized paths that don't go through the rank-0-gated FSDP2 export. v1 should either claim or reject these explicitly.
  • The 2-rank E2E test exercises the standard FSDP2 path end-to-end, but parallel_load_and_prepare_fsdp2(..., cpu_offload=True) is only covered at unit level via test_writeback_cpu_offload on a synthetic 32-dim model. A 2-rank smoke test with cpu_offload=True would lock down the _promote_non_dtensor_to_gpu + per-layer CPU shard plumbing.
  • unified_export_hf.py comment correctly notes that if rank 0 raises before the collective gather inside _export_transformers_checkpoint, the other ranks hang on NCCL timeout — out of scope per the comment, but worth a human eyeballing whether that gap is acceptable for v1.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/gpu/torch/utils/test_model_load_utils.py (1)

123-127: 💤 Low value

Test leaves temporary directories behind.

The test creates checkpoint and export directories in tempfile.gettempdir() but never cleans them up. Consider using tempfile.TemporaryDirectory() with proper synchronization, or add cleanup in a finally block.

+    import shutil
+
     ckpt_dir = os.path.join(tempfile.gettempdir(), f"_test_parallel_load_{os.getpid()}")
+    export_dir = os.path.join(tempfile.gettempdir(), f"_test_parallel_export_{os.getpid()}")
     if rank == 0:
         os.makedirs(ckpt_dir, exist_ok=True)
         _build_tiny_llama_checkpoint(ckpt_dir)
     dist.barrier()
     ...
-    export_dir = os.path.join(tempfile.gettempdir(), f"_test_parallel_export_{os.getpid()}")
-    if rank == 0:
-        os.makedirs(export_dir, exist_ok=True)
-    dist.barrier()
+    try:
+        if rank == 0:
+            os.makedirs(export_dir, exist_ok=True)
+        dist.barrier()
         ...
+    finally:
+        dist.barrier()
+        if rank == 0:
+            shutil.rmtree(ckpt_dir, ignore_errors=True)
+            shutil.rmtree(export_dir, ignore_errors=True)

Also applies to: 145-148

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/gpu/torch/utils/test_model_load_utils.py` around lines 123 - 127, The
test creates ckpt_dir (and the export dir around lines 145-148) under
tempfile.gettempdir() and never removes them; wrap the setup and teardown in a
try/finally so the directories are removed, or use tempfile.TemporaryDirectory()
on rank==0 and ensure other ranks wait on dist.barrier() to use the path, then
have rank==0 clean up (shutil.rmtree or let TemporaryDirectory go out of scope)
after a final dist.barrier(); update the block that calls
_build_tiny_llama_checkpoint and the export-dir creation to perform creation on
rank==0 and deletion in the finally (or via TemporaryDirectory) with proper
synchronization.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/gpu/torch/utils/test_model_load_utils.py`:
- Around line 123-127: The test creates ckpt_dir (and the export dir around
lines 145-148) under tempfile.gettempdir() and never removes them; wrap the
setup and teardown in a try/finally so the directories are removed, or use
tempfile.TemporaryDirectory() on rank==0 and ensure other ranks wait on
dist.barrier() to use the path, then have rank==0 clean up (shutil.rmtree or let
TemporaryDirectory go out of scope) after a final dist.barrier(); update the
block that calls _build_tiny_llama_checkpoint and the export-dir creation to
perform creation on rank==0 and deletion in the finally (or via
TemporaryDirectory) with proper synchronization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 8b1c5e94-fe55-4e7c-9dfc-be7f8cef41cd

📥 Commits

Reviewing files that changed from the base of the PR and between 7bb3039 and a2c005b.

📒 Files selected for processing (6)
  • examples/llm_ptq/example_utils.py
  • examples/llm_ptq/hf_ptq.py
  • modelopt/torch/export/unified_export_hf.py
  • modelopt/torch/quantization/utils/core_utils.py
  • modelopt/torch/utils/model_load_utils.py
  • tests/gpu/torch/utils/test_model_load_utils.py
💤 Files with no reviewable changes (1)
  • modelopt/torch/utils/model_load_utils.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • modelopt/torch/export/unified_export_hf.py
  • examples/llm_ptq/example_utils.py

Comment on lines +1224 to +1227
# Under torch.distributed: only rank 0 writes; everyone syncs at the finally barrier.
# If rank 0 raises BEFORE the collective gather inside _export_transformers_checkpoint
# (e.g. rank-divergent preprocessing), other ranks hang on that collective until NCCL
# timeout — closing that case would need a broadcast-status pattern; out of scope.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sugunav14 do you know how complex it is to write independently from each process? Currently we are bound by the sequential single process disk write speed.

Can we have seperate process write seperate safe-tensor shards?

If this is too much for this PR; can you add a TODO here that we should make the export disk write as well parallel in future?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to look into how much effort it would take to add that support. Maybe we can push it to a follow up PR since this PR is already significantly large to review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure; This seems like a require item to me. Current design will cause large models to go OOM. See https://github.com/NVIDIA/Model-Optimizer/pull/1563/changes#r3365625990. Can we have a TODO note atleast?

Comment thread modelopt/torch/utils/plugins/model_load_utils.py Outdated
Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread modelopt/torch/utils/distributed.py Outdated
sugunav14 and others added 25 commits July 25, 2026 18:17
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
…r param

Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Replace the hand-rolled _conversion_rules (regex renames + a
MergeModulelist/Concatenate whitelist that drifts as HF adds ops) with
thin adapters that drive transformers' own conversion APIs:

- _conversion_plan splits HF's conversion mapping instead of translating
  it into our own rule language
- _resolve_target uses rename_source_key for target-name resolution
  (bucketing) before any tensor is read
- _convert_keys drives each WeightConverter's ops via op.convert, so any
  conversion op works; only a many-to-one shape guard remains

Guarded behind the conversion engine's presence: pre-v5 transformers
keeps legacy _checkpoint_conversion_mapping renames with no fusion.

Unit test updated to validate the op-driven path against a real tiny
Qwen3-MoE (multi-source gate_up + single-source down_proj fusion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
Co-authored-by: realAsma <86726418+realAsma@users.noreply.github.com>
Signed-off-by: sugunav14 <178320438+sugunav14@users.noreply.github.com>
@sugunav14
sugunav14 force-pushed the svelury/fsdp2-refactor branch from 8311d61 to 0db57e5 Compare July 25, 2026 18:19
get_max_batch_size now calls the module (model(...)) so FSDP2 hooks fire,
not model.forward. Route the Mock's __call__ via side_effect so the OOM-retry
regression test records the probed batch sizes again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Suguna Velury <178320438+sugunav14@users.noreply.github.com>
@sugunav14
sugunav14 merged commit 33d05b0 into main Jul 26, 2026
54 checks passed
@sugunav14
sugunav14 deleted the svelury/fsdp2-refactor branch July 26, 2026 01:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants