Skip to content

Single gpu disk offload PTQ for DSR1/Ultra - #2008

Open
Fridah-nv wants to merge 19 commits into
fridah/omniml-4947-mem-monitorfrom
fridah/single-gpu-disk-offload-ptq
Open

Single gpu disk offload PTQ for DSR1/Ultra#2008
Fridah-nv wants to merge 19 commits into
fridah/omniml-4947-mem-monitorfrom
fridah/single-gpu-disk-offload-ptq

Conversation

@Fridah-nv

@Fridah-nv Fridah-nv commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Type of change: New feature, bug fix, new tests

Enables single-GPU PTQ for models too large to fit in VRAM (e.g. Nemotron-Ultra-550B at 1.1 TB BF16, DeepSeek-R1 at 642 GB BF16) by adding accelerate disk/CPU offload support to the HF PTQ example and fixing the export path to correctly handle offloaded models.

G1 Offload-aware unified HF export (modelopt/torch/export/unified_export_hf.py)

The existing _export_transformers_checkpoint removed accelerate hooks before materializing weights, silently writing meta tensors (empty weights) to the checkpoint. Fix:

  • _has_accelerate_offload(model)detects any disk/CPU-offload accelerate hook in the model tree.
  • _process_quantized_modules_offloaded(model, dtype) new export path for offloaded models: materializes one decoder layer at a time via enable_weight_access_and_writeback, dispatches export handlers inside the context window, and snapshots the layer state dict before hooks re-offload the weights. A second pass collects non-decoder modules that are also disk-offloaded (embed, norm, lm_head) to avoid meta tensors in the returned state dict. Hooks are removed only after the full state dict is assembled.
  • Meta-tensor guard in _export_quantized_weight raises RuntimeError on meta input instead of silently corrupting the checkpoint.

G2 Disk-offload CLI (examples/hf_ptq/hf_ptq.py, example_utils.py)

Three new arguments to hf_ptq.py:

  • --offload_folder PATH  enable accelerate disk offload; shards spill here.
  • --max_gpu_memory_gb N VRAM budget for the accelerate device map.
  • --max_cpu_memory_gb N  CPU RAM budget for the accelerate device map.

Validation: --offload_folder is incompatible with --low_memory_mode and --use_seq_device_map.

G3 Streaming shard writer for 80 GB CPU RAM (modelopt/torch/export/unified_export_hf.py)

The G1 path accumulated the entire quantized state dict in CPU RAM before writing
(~764 GiB for Ultra 550B), blocking the 80 GB target.

New streaming path writes shard files layer-by-layer. Peak memory = 1 decoder layer +
1 shard buffer instead of the full checkpoint:

Model Old peak CPU RAM New peak CPU RAM
Ultra NemotronH 550B ~764 GiB ~57 GB
DeepSeek-R1 ~630 GiB ~55 GB

Key pieces:

  • _StreamingShardWriter(export_dir, max_shard_size) buffers tensors up to max_shard_size bytes, flushes to numbered temp files (__shard_part_NNNNN.safetensors), renames to canonical shard names at finalize(), writes
    model.safetensors.index.json. Single-shard exports produce model.safetensors with no index file.
  • _postprocess_single_tensor(key, value, ...) per-tensor extraction of postprocess_state_dict logic (KV amax scale, skip/rename, squeeze) for streaming use.
  • _parse_shard_size(size) converts "10GB" / "500MB" strings to bytes.
  • _export_transformers_checkpoint_streaming(model, dtype, export_dir, max_shard_size) streams decoder layers via enable_weight_access_and_writeback, applies per-tensor postprocessing + name reversal + tied-alias filter, writes shard files directly. Non-decoder offloaded modules and GPU-resident tensors are handled in separate passes.
  • export_hf_checkpoint dispatches to the streaming path when _has_accelerate_offload(model) is true; hf_quant_config.json, quant-config name reversal, and config.json update are shared between paths.

export_hf_checkpoint accepts a new max_shard_size parameter (default "10GB") that controls the shard size for both paths.

Supporting changes

  • modelopt/torch/quantization/plugins/huggingface.py  get_nemotron_h_decoder_layers now checks both model.backbone.layers (remote-code variant) and model.model.layers (native HF variant), fixing layer discovery for NemotronH when loaded without trust_remote_code.
  • modelopt_recipes/general/ptq/nvfp4_experts_only-kv_fp8_layerwise_offload.yaml  new recipe combining NVFP4 W4A4 on MoE experts, FP8 KV cache, and layerwise calibration with calib_mutates_weights: false (required for disk-offload compatibility).
  • example_utils.py  _FP8BF16Fallback shim: dequantizes block-scaled FP8 expert weights to BF16 for calibration forward passes when the kernels package is unavailable (e.g. DSR1 on nodes without finegrained FP8 kernel support).

Usage

# Single-GPU PTQ for a model too large to fit in VRAM, using disk offload
python examples/hf_ptq/hf_ptq.py \
    --pyt_ckpt_path /path/to/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16 \
    --recipe general/ptq/nvfp4_experts_only-kv_fp8_layerwise_offload \
    --export_path /path/to/output \
    --offload_folder /path/to/offload \
    --max_gpu_memory_gb 170 \
    --max_cpu_memory_gb 500 \
    --trust_remote_code \
    --calib_size 8 --batch_size 1 --skip_generate

Testing

Unit tests (tests/unit/torch/export/test_offload_export.py, 7 tests, CPU-only):

  • _has_accelerate_offload detection (true/false/nested-module cases)
  • _export_quantized_weight meta-tensor guard (raises on meta, passes on real)
  • _process_quantized_modules_offloaded with disk-offloaded embed + GPU-resident decoder layer: verifies no meta tensor in returned state dict

GPU integration tests (tests/gpu/torch/export/test_offload_export.py, 2 tests):

  • Tiny 2-layer LLaMA with CPU offload: FP8 quantization + export, asserts no meta tensors and valid hf_quant_config.json
  • Same with layerwise FP8 (calib_mutates_weights=False): disk-offload path end-to-end

Validation runs (on GB200, single GPU):

  • Nemotron-Ultra-550B (1.1 TB BF16): 108 layers, 33.2 min wall time, 161.94 GB peak VRAM, 15 safetensors shards. Export previously crashed with AttributeError: NemotronHForCausalLM has no attribute 'backbone' â fixed by the non-decoder offloaded tensor materialization pass.
  • DeepSeek-R1 (642 GB BF16): validated on 4 GPUs with the layerwise offload recipe; single-GPU path blocked by kernels package dependency.

End-to-end validation

Two production-scale checkpoints were quantized end-to-end using the new disk-offload PTQ path on a single GB200 GPU (189 GiB VRAM).

DeepSeek-R1 (671B, MoE)

Checkpoint DeepseekV3ForCausalLM, 671B params, 61 decoder layers
Input size 642 GB BF16
Recipe nvfp4_experts_only-kv_fp8_layerwise_offload
--max_gpu_memory_gb 80
--max_cpu_memory_gb 80
--calib_size / --batch_size 8 / 1
--trust_remote_code no (built-in transformers)
Wall-clock 40 min 12 s (load ~14 min, calib ~12 min, export ~14 min)
Peak GPU memory 88.9 GB
Peak process RSS 376 GB
Output 40 shards x ~10 GB = 403 GB (~37% compression)
image

NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16 (550B, NemotronH MoE + Mamba)

Checkpoint NemotronHForCausalLM, ~550B params, 108 decoder layers
Input size ~1.1 TB BF16
Recipe nvfp4_experts_only-kv_fp8_layerwise_offload
--max_gpu_memory_gb / --max_cpu_memory_gb 170 / 500 and 80 / 80
--calib_size / --batch_size 8 / 1
--trust_remote_code yes (NemotronHForCausalLM)
170 GB GPU / 500 GB CPU 80 GB GPU / 80 GB CPU
Wall-clock 41 min 13 s 47 min 16 s
Peak GPU memory 165.8 GB 76.7 GB
Peak RSS (load) 789 GB transient 345 GB transient
Steady-state RSS ~454-496 GB ~50 GB
Output 34 shards x ~11 GB = 365 GB 34 shards x ~11 GB = 365 GB
image

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?: ✅
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: ✅
  • Did you write any new necessary tests?: ✅
  • Did you update Changelog?: ✅ / ❌ / N/A
  • Did you get Claude approval on this PR?: ✅ / ❌ / N/A

Additional Information

Fridah-nv and others added 3 commits July 22, 2026 23:04
Adds examples/hf_ptq/scripts/mem_monitor.py, a standalone cross-process
sidecar that samples device-level GPU (NVML, nvidia-smi fallback) and CPU
(psutil) memory + utilization into a CSV trace and peak/mean summary. It
binds to a workload via wrap mode (-- <cmd>, propagates the child exit code)
or standalone (--pid/--duration/signal), keeping profiling out of hf_ptq.py.

Opt-in from huggingface_example.sh via MEM_MONITOR=1 (default off, no
behavior change). Adds psutil + nvidia-ml-py deps and CPU-only unit tests.

Part of OMNIML-4947 (single-GPU layerwise PTQ memory validation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
- requirements.txt: drop nvidia-ml-py (already a core dep in pyproject.toml);
  keep psutil, which is not.
- _write_summary: create the summary parent dir before writing so a missing
  directory can't crash the run and swallow the child's exit code; gate
  peak_gpu{i} on .seen so a never-sampled GPU is omitted instead of reported 0.0.
- Guard psutil.Process against a stale/exited --pid (clean error + exit 1).
- Escalate child.terminate() to SIGKILL after a 10s timeout so an unresponsive
  child cannot hang the monitor.
- Docs: 'writes (overwriting any existing file)' instead of 'appends'; add the
  optional-dependency justification comment for the local pynvml import.
- Tests: default 30s subprocess timeout in the _run helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
Extend the sidecar to also sample GPU power draw (nvmlDeviceGetPowerUsage) and
temperature (nvmlDeviceGetTemperature), and CPU total + free memory
(psutil.virtual_memory().total/.available). New CSV columns gpu{i}_power_w,
gpu{i}_temp_c, sys_cpu_total_mb, sys_cpu_free_mb; summary gains peak power/temp,
total (once) and min free. The nvidia-smi fallback query is extended with
power.draw,temperature.gpu, and each GPU metric is now read in its own guarded
block so an unsupported one (MIG/vGPU) no longer drops the others.

Live-validated on GPU 2,3 (power ~26/31 W, temp ~40/39 C) and CPU-side locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (3)
  • main
  • release/.*
  • feature/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: da5e57c8-b99c-49be-bcb3-354d41a6bb8f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fridah/single-gpu-disk-offload-ptq

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

- --gpus now uses nargs=+ so both space-separated (--gpus 0 1 2 3) and CSV
  (--gpus 2,3) work; previously the natural space-separated form crashed
  argparse. Backward compatible with the CSV callers (huggingface_example.sh).
- Gate peak_sys_cpu_used_mb / mean_sys_cpu_util_pct on .seen, matching the
  other summary lines (per CodeRabbit).
- Document that sys_free is virtual_memory().available (reclaimable-inclusive),
  the right headroom measure, not raw .free (per Claude review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
@Fridah-nv
Fridah-nv force-pushed the fridah/single-gpu-disk-offload-ptq branch from 52fc552 to 827bbe7 Compare July 23, 2026 19:37
@Fridah-nv Fridah-nv changed the title Fridah/single gpu disk offload ptq Single gpu disk offload PTQ for DSR1/Ultra Jul 23, 2026
@Fridah-nv
Fridah-nv marked this pull request as ready for review July 23, 2026 20:11
@Fridah-nv
Fridah-nv requested review from a team as code owners July 23, 2026 20:11
@Fridah-nv
Fridah-nv requested review from meenchen and removed request for a team July 23, 2026 20:11
@Fridah-nv

Copy link
Copy Markdown
Contributor Author

/claude review

@Fridah-nv
Fridah-nv force-pushed the fridah/single-gpu-disk-offload-ptq branch from 827bbe7 to 8597e97 Compare July 23, 2026 20:31
@Fridah-nv
Fridah-nv force-pushed the fridah/single-gpu-disk-offload-ptq branch from 8597e97 to 87ccd1a Compare July 23, 2026 21:13
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 14.52145% with 259 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.13%. Comparing base (a5117fa) to head (e519853).
⚠️ Report is 2 commits behind head on fridah/omniml-4947-mem-monitor.

Files with missing lines Patch % Lines
modelopt/torch/export/unified_export_hf.py 11.11% 224 Missing ⚠️
modelopt/torch/export/quant_utils.py 35.71% 27 Missing ⚠️
modelopt/torch/quantization/plugins/huggingface.py 0.00% 6 Missing ⚠️
modelopt/torch/export/registry.py 33.33% 2 Missing ⚠️
Additional details and impacted files
@@                        Coverage Diff                         @@
##           fridah/omniml-4947-mem-monitor    #2008      +/-   ##
==================================================================
- Coverage                           66.22%   66.13%   -0.10%     
==================================================================
  Files                                 519      519              
  Lines                               60578    60831     +253     
==================================================================
+ Hits                                40120    40230     +110     
- Misses                              20458    20601     +143     
Flag Coverage Δ
examples 40.42% <14.52%> (+22.08%) ⬆️
gpu 32.71% <6.60%> (-18.26%) ⬇️
regression 15.10% <6.60%> (+0.04%) ⬆️

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.

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.

RB: Can you explain to me the overall flow of the changes in this file?

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.

🤖 Bot comment.

🐝 I’m tracing the file-level flow and its before/after behavior now.

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.

🤖 Bot comment.

The high-level change is that export_hf_checkpoint() now has two Transformers paths:

  • Normal model: keeps the existing flow: process all quantized modules → build/post-process one full state dict → call save_pretrained().
  • Accelerate CPU/disk-offloaded model: _has_accelerate_offload() detects the hooks and routes to _export_transformers_checkpoint_streaming().

The new offloaded flow is:

  1. Run the same model-level preparation as before (MoE preparation, resmooth/requantize, build quant config, and synchronize relevant amax values).
  2. Discover decoder layers, then materialize one layer at a time inside enable_weight_access_and_writeback(..., writeback=False). While the real weights are available, dispatch the existing export handlers to pack quantized weights and reconstruct fused MoE weights.
  3. Immediately post-process each tensor (drop quantizer internals, convert KV-cache amax to scales, squeeze scales, reverse Transformers conversion names, and skip tied aliases), move it to CPU, and feed it to _StreamingShardWriter. Exiting the context lets Accelerate offload that layer again.
  4. Repeat the same materialize-and-stream treatment for offloaded non-decoder modules such as embeddings, norms, and lm_head, then stream any remaining resident parameters/buffers.
  5. _StreamingShardWriter flushes at max_shard_size, renames temporary parts to canonical safetensor shard names, and writes the index. This bounds peak CPU memory to roughly one materialized layer plus one shard buffer instead of a full-model state dict.
  6. Call save_pretrained(state_dict={}) only to emit config/custom-code artifacts, then the outer function writes hf_quant_config.json and updates/sanitizes config.json as before.

Two safety pieces support this: Accelerate hooks are no longer removed before offloaded weights are materialized, and _export_quantized_weight() now fails loudly if a meta tensor reaches it. _process_quantized_modules_offloaded() is the analogous full-state-dict helper for direct lower-level calls, but the public top-level offload path uses streaming to avoid the full CPU-RAM accumulation.

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.

RB: This PR should treat offloading as the special case and consider not offloading as the general case. I feel that this PR does not respect this assumption. Example there are many places where regular path is route to if not _offloaded: ... (is it possible to have a cleaner seperation which does not need to move general model export code under if not _offloaded?)

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.

🤖 Bot comment.

🐝 I’m checking the branch structure and shared-vs-offload responsibilities now.

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.

🤖 Bot comment.

Yes—your read is correct. The regular path should remain the straight-line default, with one early special-case dispatch:

if _has_accelerate_offload(model):
    return _export_offloaded_transformers_checkpoint(...)

# existing regular export flow, unchanged
post_state_dict, hf_quant_config = _export_transformers_checkpoint(...)
...
model.save_pretrained(...)

The offload helper should own its unsupported-option warnings, layer materialization/streaming, inline name conversion, and save_pretrained(state_dict={}) artifact workaround. Only genuinely common finalization (quant config + sanitized config.json) needs a small shared helper.

There is also duplicated dispatch today: export_hf_checkpoint() routes offloaded models directly to _export_transformers_checkpoint_streaming(), while _export_transformers_checkpoint() independently detects _offloaded and branches to _process_quantized_modules_offloaded(). That lower-level branch is bypassed by the public path and makes ordinary callers offload-aware. Removing that branch from the general exporter and keeping it inside the dedicated offload path would restore the intended separation and eliminate the repeated if not _offloaded guards.

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.

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.

Addressed in f762313: the offloaded path now early-returns after its own block, and a _write_hf_export_config helper owns the shared hf_quant_config.json + config.json finalization. The regular export path is now the straight-line default with no guards; the offloaded block is a clean early exit at the top.

Comment thread modelopt/torch/export/unified_export_hf.py Outdated
Comment thread modelopt/torch/export/unified_export_hf.py
_sanitize_generation_config_for_save(model)
_patches = _patch_revert_weight_conversion()
try:
model.save_pretrained(str(export_dir), 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.

It seems this line may remove existing safetensors files because save_pretrained with an empty state_dict will think the already written safetensors files obsolete and so remove them. could you double check?

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.

This is correct. I forgot to push the local fix e4d69c7. It calls with explicit model.config.save_pretrained() + generation_config.save_pretrained() + shutil.copy2(*.py). This also avoids the MoE shared-tensor crash we hit on DSR1, where save_pretrained fired safetensors' shared-tensor validation on the live model parameters even with an empty state dict.

@Edwardf0t1 Edwardf0t1 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.

Inline notes on the five items I consider merge-blocking or worth resolving before merge. Overall the streaming-export architecture is sound and the Ultra-550B validation run (161 GB peak VRAM, 15 shards) is convincing evidence the happy path works — these are about CI, an aliasing edge case, and two scope questions.

Comment thread examples/hf_ptq/example_utils.py Outdated
self._buffer = {}
self._buffer_bytes = 0

def add(self, key: str, tensor: torch.Tensor) -> None:

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.

The streaming path drops the tied-weight safety net, so save_file can crash.

The batch path ends postprocess_state_dict with a data_ptr()-based dedup (quant_utils.py ~L1115-1135) that removes any aliased tensor. That's what guarantees safetensors.save_file never sees two keys sharing storage. The streaming path replaces it with name matching against _tied_weights_keys, gated on tie_word_embeddings=True.

_stream_tensor passes tensor.detach().contiguous().cpu() — for an already-contiguous CPU tensor all three are no-ops returning the same object, so aliasing survives into self._buffer and save_file raises RuntimeError: Some tensors share memory.

Concrete failure: a model with tie_word_embeddings=False in config but genuinely shared lm_head.weight / embed_tokens.weight storage, or any aliasing not literally listed in _tied_weights_keys (note transformers v5 changed _tied_weights_keys to a dict of patterns for several architectures, so exact-string matching can silently miss). Both keys land in the same shard buffer → crash after all prior shards are already written.

The comment above is right that data_ptr() is unreliable across the whole export — but it is reliable within a single shard buffer, because the writer holds a strong reference to every buffered tensor until _flush(). A per-buffer check restores parity without the cross-layer staleness problem:

def add(self, key: str, tensor: torch.Tensor) -> None:
    ptr = tensor.data_ptr()
    if ptr in self._buffer_ptrs:   # reset alongside self._buffer in _flush()
        return                      # (or .clone() if you'd rather keep both keys)
    self._buffer_ptrs[ptr] = key
    self._buffer[key] = tensor
    ...

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 adopted the suggested change. In the streaming export case, the tied word embedding case is handled by name filter in _stream_tensor, while the storage need to guard on alias because of these cases:
ExportContext.tied_cache / moe_tied_cache are live dicts, and _export_quantized_weight deliberately re-points weight / weight_scale / weight_scale_2 / input_scale at a previously-processed module sharing source memory — so the downstream data_ptr dedup can collapse them. This is postprocess_state_dict, batch-path only; streaming has no equivalent, so such aliases would reach save_file directly.

Let me know if this makes sense. I'm actually not famliar with the moe_tied_cache part.

Comment thread examples/hf_ptq/example_utils.py Outdated
Comment thread examples/hf_ptq/example_utils.py Outdated
f"Architecture {architecture} not found in transformers: {transformers.__version__}. "
"Falling back to AutoModelForCausalLM (or AutoModel for non-causal architectures)."
)
if not hasattr(transformers, architecture):

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.

Unexplained behavior change: dropping or "Deepseek" in architecture from this condition.

Previously any DeepSeek architecture took the AutoModelForCausalLM + assert trust_remote_code path even when transformers exposed the class. Now DeepseekV3ForCausalLM (present since transformers 4.53) falls through to getattr(transformers, architecture)._from_config, and further down model_kwargs2.pop("trust_remote_code", None) fires.

That's a real behavior change for the exact model family this PR targets, it isn't mentioned in the description, and there's no test covering it. It also appears to pull in the opposite direction from _install_transformers_compat_shims(), which exists specifically to make remote-code DSR1 load.

Could you explain the intent, or split this into its own PR so it can be evaluated independently?

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 clean this up as well but need a full run on DSR1 to verify

Comment thread modelopt/torch/export/unified_export_hf.py Outdated

@realAsma realAsma 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.

Looks great to me!!

Could you please benchmark DSR1/Ultra single GPU PTQ with max calibration (PTQ + export time, GPU memory, GPU utilization, CPU memory etc.) and add that information to the PR description?

Fridah-nv and others added 13 commits July 28, 2026 18:17
Move the cross-process PTQ memory/utilization sidecar out of
examples/hf_ptq/scripts/ into the repo-wide tools/ directory and rename
it resource_monitor.py to differentiate it from the in-process
modelopt.torch.utils.memory_monitor.GPUMemoryMonitor. Register the
tools/ test group in tests/conftest.py and update the huggingface_example.sh
wrapper path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
G1 — offload-aware unified HF export
- Add _has_accelerate_offload() to detect CPU/disk-offload hooks
- Add _process_quantized_modules_offloaded() that materializes decoder layers
  one at a time inside enable_weight_access_and_writeback, then collects
  the full state dict via a second loop for non-decoder offloaded modules
  (embed, norm, lm_head)
- Branch _export_transformers_checkpoint on the offload flag; remove hooks
  only after the offloaded state dict has been collected
- Add meta-tensor guard in _export_quantized_weight to catch accidental
  standard-path use on offloaded models

G2 — disk-offload CLI wiring in hf_ptq
- Add --offload_folder, --max_gpu_memory_gb, --max_cpu_memory_gb args
- Inject max_memory budget into load_model_from_config; skip seq_device_map
  when offload folder is set
- Add nvfp4_experts_only-kv_fp8_layerwise_offload.yaml recipe

Other
- Fix _FP8BF16Fallback shim: nested try avoids ambiguous except; remove
  how-comments from matmul; tighten outer except to Exception only
- Fix get_nemotron_h_decoder_layers to check both backbone.layers and
  model.layers

Tests
- 7 CPU-only unit tests (test_offload_export.py)
- 2 GPU integration tests (tests/gpu/torch/export/test_offload_export.py)

Signed-off-by: Fridah-nv <fridah@nvidia.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
embed_tokens, final norms, and lm_head are disk-offloaded alongside decoder
layers on single-GPU runs. model.state_dict() returns meta placeholders for
them; after revert_weight_conversion_quant_aware renames to hub-original
keys, transformers' save_pretrained looks up the tensors by hub name and
crashes (e.g. NemotronHForCausalLM has no attribute 'backbone').

Fix: add a second loop in _process_quantized_modules_offloaded that iterates
non-decoder modules, skips any without a live offload hook, checks for DIRECT
meta parameters/buffers (avoids re-collecting decoder children already
captured above), and materializes each via enable_weight_access_and_writeback.

Signed-off-by: Fridah-nv <fridah@nvidia.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
…ndler, memory

Four correctness / efficiency bugs in _process_quantized_modules_offloaded:

1. MoE reconstruction ordering: _reconstruct_fused_moe_linear was called after
   _process_quantized_modules_offloaded returned the state dict, so fused-MoE
   decoder layers shipped with per-expert 2D keys (or meta weights after context
   exit). Move it per-layer inside the materialization window before the state
   dict snapshot, mirroring the non-offloaded path.

2. Quantized non-decoder modules missed: lm_head (or any quantized module
   outside the decoder stack) never had _dispatch_export_handler called, so it
   exported raw unquantized weights. Add handler dispatch inside the non-decoder
   materialization context.

3. Decoder snapshots accumulating on GPU: tensor.detach() kept every layer's
   materialized weights on the GPU, defeating the layer-by-layer memory goal.
   Change to tensor.detach().cpu().

4. writeback=True on decoder context: on context exit the quantized weights were
   written back to the offload store (disk → CPU promotion per layer), wasting
   CPU memory. Changed to writeback=False since weights are captured in
   layer_tensors immediately after.

Also: apply gpu_mem_percentage (default 80 %) when --max_gpu_memory_gb is not
specified in disk-offload mode, matching the documented CLI default.

Restore test_non_decoder_offloaded_tensors_are_collected (lost during squash).

Signed-off-by: Fridah-nv <fridah@nvidia.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
Replace the accumulate-then-save pattern in the offloaded export path with a
stream-then-save pattern. Peak memory drops from ~764 GiB (Ultra 550B full
state dict in RAM) to 1 decoder layer + 1 shard buffer (~57 GB).

New pieces:
- `_postprocess_single_tensor` in quant_utils.py: per-tensor subset of
  postprocess_state_dict for use in the streaming loop
- `_StreamingShardWriter`: buffers tensors up to max_shard_size, flushes to
  temp part files, renames to canonical shard names at finalize()
- `_parse_shard_size`: converts "10GB"/"500MB" strings to bytes
- `_export_transformers_checkpoint_streaming`: streams decoder layers one at a
  time via enable_weight_access_and_writeback, applies per-tensor postprocessing
  and name reversal inline, handles tied-weight dedup from _tied_weights_keys
- `export_hf_checkpoint` dispatch: branches on _has_accelerate_offload to call
  the streaming path instead of _export_transformers_checkpoint for offloaded
  models; hf_quant_config.json and config.json update are shared between paths

Unit tests: 10 new tests covering _StreamingShardWriter (single-shard,
multi-shard, readback) and _postprocess_single_tensor (passthrough, filter,
rename, squeeze, real-quant drop, kv scale divide).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
…eview

- Extract _KV_CACHE_REPLACEMENTS / _QLORA_REPLACEMENTS / _BASE_SKIP_KEYS /
  _QLORA_SKIP_KEYS as module-level constants; eliminate the per-call rebuild
  in both _postprocess_single_tensor and postprocess_state_dict.
- Add _maybe_squeeze_scale helper; remove three identical inline squeeze
  expressions.
- Replace _StreamingShardWriter._part_bytes list (used only for sum()) with
  a scalar _total_bytes accumulator.
- Collapse the two GPU-resident named_parameters / named_buffers loops into
  a single itertools.chain loop.
- Move import contextlib to module level (was deferred inside function body).
- Initialize export_state_dict = None before the _offloaded branch to prevent
  a latent NameError on future edits.
- Narrow except (ImportError, Exception) to except ImportError in
  _parse_shard_size so parser errors propagate instead of silently falling
  through to the manual fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
- Gate tied-weight dedup on model.config.tie_word_embeddings to avoid
  incorrectly dropping lm_head.weight when embeddings are not tied
- Add _is_persistent_buffer helper; filter named_buffers() in the
  GPU-resident pass to match state_dict() semantics
- Add .contiguous() before .cpu() in _stream_tensor to handle
  non-contiguous views from accelerate writeback
- Copy trust_remote_code modeling files via model.save_pretrained(
  state_dict={}) with single-shard rename protection so custom model
  class files are not lost
- Refactor example_utils.py shims: module-level _FP8BF16Fallback class,
  _install_transformers_compat_shims() called lazily from get_model(),
  explicit UserWarning for lossy BF16 fallback path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
model.save_pretrained(state_dict={}) triggers safetensors' shared-tensor
validation on the live model parameters even when no weights are being
saved. DSR1 and other MoE models have expert weights that share storage
across layers, so this check always fails — crashing the export after
all shards are correctly written and leaving hf_quant_config.json unwritten.

Replace with targeted saves:
- model.config.save_pretrained() for config.json
- model.generation_config.save_pretrained() for generation_config.json
- shutil.copy2(*.py) for trust_remote_code custom modeling files

Also add missing contextlib and shutil stdlib imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
…, shard regression test

- Delete _process_quantized_modules_offloaded: dead from export_hf_checkpoint
  (public path dispatches to streaming writer; function accumulated full state dict
  in RAM, defeating offload)
- Replace if _offloaded: branch inside _export_transformers_checkpoint with
  NotImplementedError — streaming path owns that case via export_hf_checkpoint
- Add _write_hf_export_config helper (hf_quant_config.json + config.json patching)
- Refactor export_hf_checkpoint: early return after offloaded path eliminates
  three scattered if not _offloaded: guards; both paths share the helper
- Update meta-guard error message to point to export_hf_checkpoint
- Tests: remove test_non_decoder_offloaded_tensors_are_collected (tests deleted fn);
  add test_multi_shard_files_exist_after_finalize (regression: save_pretrained(state_dict={})
  triggered transformers cleanup loop that deleted model-NNNNN-of-NNNNN shards)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
Addresses PR review: recipe-doc parity, ruff, and mypy all fail on this branch.

- modelopt_recipes/ptq.md: add the nvfp4_experts_only-kv_fp8_layerwise_offload
  row and bump the summary count to 21, restoring the parity enforced by
  tests/unit/recipe/test_recipe_docs.py.
- Apply ruff format to example_utils.py and quant_utils.py.
- test_offload_export.py: wrap st.keys() in list() to clear SIM118. Ruff's own
  suggested fix (iterating the handle directly) would break the test --
  safetensors safe_open handles are not iterable.
- Fix 5 pre-existing mypy errors: duplicate raw_tied_keys annotation, missing
  None guard on the _postprocess_single_tensor result (its documented contract
  is to return (None, None) to skip), rebinding hf_quant_config to a different
  type in _write_hf_export_config, and two stale type: ignore comments.

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
…s opt-in

Addresses review feedback on the streaming export path and the hf_ptq shims.

_StreamingShardWriter.add now resolves shared storage before buffering.
safetensors.save_file rejects a dict holding two tensors that share memory, and
_stream_tensor's .detach().contiguous().cpu() is a no-op for an already-contiguous
CPU tensor, so the name-based _tied_weights_keys filter was the only guard -- it
misses ties transformers does not declare. data_ptr() is unreliable across the
export but reliable within one buffer, since buffered tensors stay alive until
flush. Exact (pointer, shape, dtype) matches are dropped as genuine ties; partial
matches are cloned so a distinct view is never silently lost.

_install_transformers_compat_shims is now opt-in via --allow_compat_shims. It was
running on every get_model() call, applying process-wide monkeypatches -- silently
disabling flash attention and substituting a lossy BF16 FP8 matmul -- for users who
never asked. Gating on trust_remote_code would be wrong: the FP8 shim patches the
native HF path, which is what DeepSeek-R1 now uses. The suppress(Exception) around
the FP8 block is narrowed so a missing _load_finegrained_fp8_kernel warns instead
of silently no-opping; on transformers 5.7 that symbol is absent, so the fallback
was never actually installed.

_FP8BF16Fallback.matmul now expands scales by block_size rather than
out_f // nb_out. The scale grid is ceil-divided, so the ratio mis-groups the last
block when a dimension is not a multiple of block_size (out_f=300, block=128 gave
100) and truncates outright when the ratio does not divide evenly. Verified against
a per-block reference for divisible and non-divisible shapes. Also drops the fp32
intermediate (~528 MB per call at DSR1's 7168x18432) by scaling in bf16.

Per review, hoist the offloaded refusal in _export_transformers_checkpoint to a
guard clause beside the detection, removing the else-after-raise and the
if not _offloaded wrapper around hook removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
…ote_code

The removal of the "Deepseek" clause from the architecture dispatch was an
undocumented drive-by in dd76c1b. It had two side effects beyond its intent:
--trust_remote_code was silently ignored for the model class, and DeepseekV2 and
DeepseekVL were swept onto the built-in path alongside DeepseekV3 despite neither
being validated here.

Restore the clause, gated on the flag. --trust_remote_code now selects the bundled
modeling code as before; without it the built-in class is used, which is what the
disk-offload and streaming-export paths are validated against and which previously
raised AssertionError. Non-DeepSeek architectures are untouched.

Also reword the compat-shim docstring: two of its three patches target bundled
modeling files, not the built-in path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
…ironment

These monkeypatched transformers process-wide to paper over environment problems,
which does not belong in an example -- especially a quantization one.

- _FP8BF16Fallback substituted a lossy BF16 dequant for the block-scaled FP8 matmul,
  degrading the very forward pass used for calibration amax collection. It also
  patched _load_finegrained_fp8_kernel, a private symbol that no longer exists in
  transformers 5.7 (now lazy_load_kernel), so within the supported range
  (>=4.56,<5.13) it silently no-ops. The fix is `pip install kernels`.
- The flash_attn shim forced availability checks to False when the package was
  installed but unimportable. Uninstalling or repairing the broken install reaches
  the same state without a global patch.
- The is_torch_fx_available shim covered old bundled modeling files on transformers
  5; not worth a process-wide patch on its own.

Also removes --allow_compat_shims, added in the previous commit to gate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
Fridah-nv and others added 2 commits July 29, 2026 01:00
The offloaded export path used data_ptr() as a stable tensor identity, but it
only identifies a tensor while that tensor is resident. Two failure modes, both
silent, both verified on Qwen3.6-35B-A3B with a 30/20 GB budget:

Recycled addresses. ExportContext.tied_cache / moe_tied_cache persisted for the
whole export while the streaming path materialises and frees one module at a
time, so the allocator handed a later expert the address of a freed earlier one.
The alias step then re-pointed its weight and scales at the wrong module. 1536
false hits, leaving 60% of expert weights (18440 tensors) as byte-identical
copies of unrelated experts, and short-circuiting half the export
(_export_quantized_weight ran 14592 times instead of 30720).

Null addresses. sync_tied_input_amax groups by weight data_ptr, and every meta
tensor reports 0, so all offloaded modules collapsed into two buckets whose
amaxes were max-merged model-wide. 16896 expert input_scale values inflated,
median 90% relative error, worst 12x.

Fixes: ExportContext.reset_tied_caches() drops dedup state at the end of each
materialization window, and sync_tied_input_amax skips modules whose weights are
not resident, warning only when the model declares ties it could not honour.

Verified against the batch export path, which is unaffected (it keeps every
weight resident): all 123846 shared tensors now match bitwise, versus 70580
differing before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
The streaming path warned that extra_state_dict "is not supported" and dropped
it. That silently lost the MTP weights: HF builds only num_hidden_layers
decoders, so multi-token-prediction tensors are orphaned and reach export solely
through extra_state_dict (hf_ptq.py passes load_mtp_weights' output there).

Exporting Qwen3.6-35B-A3B produced 19 fewer tensors than the batch path, all
mtp.* -- mtp.fc.weight, mtp.norm.weight, and the whole mtp.layers.0 block
including its fused experts. A checkpoint missing them cannot serve speculative
decoding.

Feed them to the shard writer after the resident-tensor pass. They are already
materialized and skip per-tensor postprocessing, matching the batch path which
merges them after postprocess_state_dict; only the hub-name reversal applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fridah-nv <201670829+Fridah-nv@users.noreply.github.com>
@Fridah-nv
Fridah-nv force-pushed the fridah/omniml-4947-mem-monitor branch from 6aad032 to 6538eb1 Compare July 29, 2026 04:34
@Fridah-nv
Fridah-nv requested a review from a team as a code owner July 29, 2026 04:34
@Fridah-nv
Fridah-nv requested review from kevalmorabia97 and removed request for a team July 29, 2026 04:34

@Edwardf0t1 Edwardf0t1 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.

Re-reviewed at e519853 — all five of my earlier findings are resolved, and I verified the first two by running the checks (ruff format/ruff check clean; recipe docs now 21/21 with no stale rows). One remaining test-coverage gap below.

)

# 2. All tensors in safetensors shards must be non-empty (no meta serialized as zeros)
safetensor_files = list(export_dir.glob("*.safetensors"))

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.

Missing: a streaming-vs-batch key-parity test.

The unit tests added for the alias guard and the data_ptr-under-offload cases are good, and this test is a solid guard against the original meta-tensor bug. But every assertion here iterates only the keys that were written — so it can confirm the written tensors are sane, and can't detect anything missing.

Concretely: if a refactor caused _export_transformers_checkpoint_streaming to skip an entire decoder layer (a seen_keys collision, a _tied_weights_keys pattern over-matching and dropping too much, a _dispatch_export_handler early-return, or the non-decoder pass missing an offloaded lm_head), this test still passes — the surviving shards are non-empty and non-meta.

That's the highest-risk failure mode for this PR, because the streaming path reimplements a lot of _export_transformers_checkpoint + postprocess_state_dict per-tensor rather than sharing it, and the two can drift silently.

The cheap version is to export the same tiny model twice — once offloaded, once not — and diff the key sets:

def test_streaming_export_matches_batch_export_keys(tmp_path):
    # non-offloaded reference
    ref_model = ...  # same tiny llama, plain .cuda(), same quant_cfg + forward_loop
    export_hf_checkpoint(ref_model, export_dir=str(ref_dir))

    # offloaded, exercises the streaming path
    off_model, *_ = _make_cpu_offloaded_model(...)
    export_hf_checkpoint(off_model, export_dir=str(off_dir))

    assert _all_keys(off_dir) == _all_keys(ref_dir)

where _all_keys unions safe_open(...).keys() across every shard. Comparing values too would be even better, but key parity alone catches the whole "silently dropped tensors" class and is what the current assertions can't see.

Two smaller gaps in the same spirit, if you want them in the same pass:

  • Multi-shard is never integration-tested. Tiny LLaMA fits in one shard, so model.safetensors.index.json generation inside the real export path is untested end-to-end — only _StreamingShardWriter is tested in isolation. Parametrizing this test with max_shard_size="1MB" would cover it.
  • _parse_shard_size has no test.

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.

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.

5 participants