Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 11 additions & 19 deletions .agents/skills/ptq/references/slurm-setup-ptq.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,17 @@ pip install -U transformers

For unlisted models that need unreleased transformers (e.g., from git), see `references/unsupported-models.md` Step A.

**Prefer `PYTHONPATH`** to use the synced ModelOpt source instead of installing inside the container — this avoids risking dependency conflicts (e.g., `pip install -U nvidia-modelopt[hf]` can upgrade PyTorch and break other packages):
**Prefer `pip install -e ".[hf]" --no-build-isolation`** (run from the Model-Optimizer repo root) to make the synced ModelOpt source importable in the container — this matches how `examples/hf_ptq/slurm/multinode_fsdp2_ptq.slurm` sets up the job, and unlike `PYTHONPATH` it surfaces packaging/build issues instead of masking them. Avoid `pip install -U nvidia-modelopt[hf]` from PyPI, which can upgrade PyTorch and break other packages.

```bash
export PYTHONPATH=/path/to/Model-Optimizer:$PYTHONPATH
pip install -e ".[hf]" --no-build-isolation
```

If `PYTHONPATH` doesn't work due to missing compiled extensions, fall back to `pip install -e ".[hf]" --no-build-isolation` (run from the Model-Optimizer repo root).
If you specifically need to leave the container's installed packages untouched (e.g. to sidestep a dependency conflict), fall back to `PYTHONPATH` — but note it skips the editable install, so a missing compiled extension only surfaces at import time:

```bash
export PYTHONPATH=/path/to/Model-Optimizer:$PYTHONPATH
```

**Watch for pip dependency conflicts** — NGC containers set `PIP_CONSTRAINT` to pin versions, causing `ResolutionImpossible` errors. Unset it first so pip can resolve freely:

Expand All @@ -63,23 +67,11 @@ pip install -U transformers --no-deps

Estimate GPU count from model size and available GPU memory. `hf_ptq.py` uses `device_map="auto"` so it fills GPUs automatically — request only as many as needed.

For multi-node PTQ (200B+ params), use `examples/hf_ptq/multinode_ptq.py` with FSDP2 and accelerate:

```bash
accelerate launch \
--config_file examples/hf_ptq/fsdp2.yaml \
--num_machines $NUM_NODES \
--num_processes $((NUM_NODES * GPUS_PER_NODE)) \
--main_process_ip $MASTER_ADDR \
--main_process_port $MASTER_PORT \
--machine_rank $SLURM_PROCID \
examples/hf_ptq/multinode_ptq.py \
--pyt_ckpt_path <model> \
--qformat <format> \
--export_path <output>
```
For multi-node PTQ (200B+ params), use `hf_ptq.py --use_fsdp2`. For the launch commands (`sbatch`
and manual `torchrun`) and the `--recipe` format, see the *Multi-Node Post-Training Quantization with
FSDP2* section of `examples/hf_ptq/README.md`.

The `num_machines`, `num_processes`, `main_process_ip`, and `machine_rank` are overridden on the command line — no need to edit `fsdp2.yaml`. Only update `fsdp_transformer_layer_cls_to_wrap` in the YAML if the model uses a non-default decoder layer class.
Sizing guidance specific to this path: when the per-rank decoder shard approaches GPU capacity (200B+ at low rank count), either add more nodes (more ranks → smaller shard per rank) or add `--cpu_offload`. Layer detection is automatic; no YAML config needed.

Use the multi-node template from `skills/common/slurm-setup.md` section 4 as the job script wrapper.

Expand Down
81 changes: 52 additions & 29 deletions CHANGELOG.rst

Large diffs are not rendered by default.

34 changes: 19 additions & 15 deletions examples/hf_ptq/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,33 +471,37 @@ mtq.calibrate(model, algorithm="max", forward_loop=calibrate_loop)

## Multi-Node Post-Training Quantization with FSDP2

ModelOpt enables quantization of LLMs across multiple GPU nodes using various quantization formats. It leverages HuggingFace's Accelerate library and FSDP2 for distributed model sharding and calibration.
ModelOpt enables quantization of LLMs across multiple GPU nodes using FSDP2 for distributed model sharding and calibration, exposed via the `--use_fsdp2` flag on the standard `hf_ptq.py` entry point.

### Usage

For distributed execution across multiple nodes, use the `accelerate` library. A template configuration file (`fsdp2.yaml`) is provided and can be customized for user specific requirements.
#### Slurm (recommended)

On each node run the following command:
Slurm orchestrates launching the job on every node for you, so this is the easiest way to run a multi-node PTQ. A ready-to-run example that quantizes Nemotron-3-Super to NVFP4 is provided in [`slurm/multinode_fsdp2_ptq.slurm`](./slurm/multinode_fsdp2_ptq.slurm). Edit the `CONFIG` block (container image, model path, export path, recipe) and submit:

```bash
accelerate launch --config_file fsdp2.yaml \
--num_machines=<num_nodes> \
--machine_rank=<current_node_rank> \
--main_process_ip=<node0_ip_addr> \
--main_process_port=<port> \
--fsdp_transformer_layer_cls_to_wrap=<decoder_layer_name>
multinode_ptq.py \
sbatch --nodes=2 slurm/multinode_fsdp2_ptq.slurm
```

#### Manual (run on each node)

Without Slurm, start `torchrun` on every node yourself:

```bash
torchrun \
--nnodes=<num_nodes> --node_rank=<current_node_rank> \
--master_addr=<node0_ip_addr> --master_port=<port> \
--nproc_per_node=<num_gpus_per_node> \
hf_ptq.py \
--pyt_ckpt_path <path_to_model> \
--qformat <fp8/nvfp4/nvfp4_mlp_only/nvfp4_experts_only/nvfp4_omlp_only/nvfp4_awq/int8> \
--kv_cache_qformat <fp8/nvfp4/nvfp4_affine/none> \
--recipe general/ptq/nvfp4_default-kv_fp8_cast \
--batch_size <calib_batch_size> \
--calib_size <num_calib_samples> \
--dataset <dataset> \
--export_path <export_path> \
--trust_remote_code
--use_fsdp2
```

The exported checkpoint can be deployed using TensorRT-LLM/ vLLM/ SGLang. For more details refer to the [deployment section](#deployment) of this document.
See [Recipe-based Quantization](#recipe-based-quantization) for the recipe format and built-in recipe names. The exported checkpoint can be deployed using TensorRT-LLM/ vLLM/ SGLang. For more details refer to the [deployment section](#deployment) of this document.

> *Performance Note: FSDP2 is designed for training workloads and may result in longer calibration and export times. For faster calibration, maximize the batch size based on available GPU memory and choose the right number of GPUs to avoid unnecessary communication.*

Expand Down
72 changes: 72 additions & 0 deletions examples/hf_ptq/example_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import shutil
import warnings
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from typing import Any

Expand All @@ -48,11 +50,68 @@
except ImportError:
snapshot_download = None

from modelopt.torch.utils import distributed as dist_utils

logger = logging.getLogger(__name__)

SPECULATIVE_MODEL_LIST = ["Eagle", "Medusa"]


@dataclass
class DistributedState:
"""Example-local distributed state for model loading, dataloader sharding, and rank-0 output."""

rank: int
world_size: int
device: torch.device | str
is_main: bool


def setup_distributed_args(args):
"""Initialize and attach ``args.dist_state`` (single-process if FSDP2 off)."""
if getattr(args, "use_fsdp2", False):
# Raise the collective timeout above NCCL's 30-min default: rank 0's checkpoint write can
# exceed it, and PyTorch 2.8 has no per-call barrier() timeout (must be set at PG creation).
dist_utils.setup(timeout=timedelta(hours=2))
rank = dist_utils.rank()
args.dist_state = DistributedState(
rank=rank,
world_size=dist_utils.size(),
device=torch.device(f"cuda:{dist_utils.local_rank()}"),
is_main=rank == 0,
)
else:
args.dist_state = DistributedState(rank=0, world_size=1, device=args.device, is_main=True)


def cleanup_distributed(args):
"""Destroy the process group if ``--use_fsdp2`` set it up."""
if getattr(args, "use_fsdp2", False):
dist_utils.cleanup()


def validate_fsdp2_supported(args, config):
"""Raise ``NotImplementedError`` for model/CLI combos the FSDP2 path doesn't support yet."""
issues = []
if "vila" in args.pyt_ckpt_path.lower():
issues.append("VILA (custom builder + non-standard layer layout)")
if is_nemotron_vl(config) or _is_multimodal_config(config):
issues.append("multimodal / VL models (decoder layers not auto-detectable)")
if getattr(config, "quantization_config", None) is not None:
issues.append("pack-quantized / compressed-tensors checkpoints")
if getattr(args, "specdec_offline_dataset", None) is not None:
issues.append("speculative decoding (--specdec_offline_dataset)")
if getattr(args, "low_memory_mode", False):
issues.append("--low_memory_mode (redundant with FSDP2)")

if issues:
raise NotImplementedError(
"--use_fsdp2 does not support:\n - "
+ "\n - ".join(issues)
+ "\nRemove --use_fsdp2 or use a standard causal-LM checkpoint."
)


def run_nemotron_vl_preview(
full_model,
tokenizer,
Expand Down Expand Up @@ -372,6 +431,19 @@ def _apply_to_model_state_dict(
return out_state_dict


def mtp_layer_prefixes_from_checkpoint(model_path: str) -> list[str]:
"""MTP exclude-prefixes from a checkpoint's safetensors index (``[]`` if none); reads no tensors.

Local-index-only, matching :func:`load_mtp_weights`, so detection and re-attach stay in sync.
"""
index_file = Path(model_path) / "model.safetensors.index.json"
if not index_file.exists():
return []
weight_map = json.load(open(index_file))["weight_map"]
mtp_keys = [k for k, v in weight_map.items() if "mtp" in k or "mtp" in v]
return list(_keys_to_prefixes(mtp_keys))


def load_mtp_weights(
model: torch.nn.Module, model_path: str
) -> tuple[list[str], dict[str, torch.Tensor]]:
Expand Down
30 changes: 0 additions & 30 deletions examples/hf_ptq/fsdp2.yaml

This file was deleted.

Loading