Skip to content

[https://nvbugs/6272397][fix] Prevent host OOM during checkpoint prefetch - #17430

Draft
moraxu wants to merge 1 commit into
NVIDIA:mainfrom
moraxu:dev-mguzek-nvbug-6272397-deepseek-OOM
Draft

[https://nvbugs/6272397][fix] Prevent host OOM during checkpoint prefetch#17430
moraxu wants to merge 1 commit into
NVIDIA:mainfrom
moraxu:dev-mguzek-nvbug-6272397-deepseek-OOM

Conversation

@moraxu

@moraxu moraxu commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Dev Engineer Review

  • Replaced whole-file prefetch reads with reusable 64 MiB chunked reads.
  • Limits per-thread host memory while preserving page-cache warming.
  • Preserves missing-file tolerance.
  • Adds rate-limited progress heartbeats every 60 seconds.
  • The callback typing and optional parameter preserve existing callers.
  • Tests cover chunk bounds, missing files, and progress reporting.
  • No configuration or test-list changes were identified.

QA Engineer Review

  • Added test coverage for:
    • Bounded chunked prefetch reads.
    • Missing-file handling.
    • Per-chunk progress heartbeat logging.
  • No corresponding tests/integration/test_lists/ coverage was reported.
  • Verdict: needs follow-up.

Description

What the problem actually was

HfWeightLoader._prefetch_one_file warmed the OS page cache by calling f.read() — materializing each ~4 GB safetensors shard as a whole Python bytes object, purely to throw it away. With 16 prefetch threads per rank × 8 ranks, that's 128 concurrent multi-GB buffers. On fast storage they live for milliseconds. On slow storage (the H20-3e CI runners: zero of 128 files finished in ~30 minutes), all 128 buffers fill up simultaneously — up to ~0.5 TB of unreclaimable anonymous memory on top of the page cache the prefetch is deliberately creating. The prefetch gate (641GB < 0.9 × available) budgets nothing for this, and reads host-wide /proc/meminfo besides. The kernel OOM killer then takes out a rank mid-prefetch; MPI aborts; the launcher hangs silently; the test harness's 1800 s stall detector SIGKILLs it — and the log's last line is Prefetching model-00113-of-000163.safetensors to memory..., which everyone read as "mysterious OOM during a 641 GB prefetch that should have fit."

The fix

Two changes in weight_loader.py, deliberately behavior-neutral otherwise:

  1. Prefetch now reads each file in 64 MB chunks through one reusable buffer — identical page-cache warming, constant per-thread footprint, eliminating the ~0.5 TB transient spike entirely.
  2. A rate-limited progress heartbeat (Prefetch progress: X.XXGB / Y.YYGB (local rank), at most once per 60 s) so a slow-but-healthy prefetch emits output instead of the fatal silence — any future storage slowness produces a diagnosable log instead of a stall-kill mislabeled as OOM.

Known limitation: the heartbeat is progress-gated. A hard-hung mount (no read completing at all) still produces silence and will still be stall-killed — arguably the correct outcome; a timer-thread heartbeat was deliberately left out of scope.

Why this doesn't reproduce on typical dev nodes

To reproduce it, the bug needs two environmental ingredients that a docker memory cap alone can't supply. The killer is the transient read buffers, and buffers only accumulate when storage is slow — a typical dev node prefetches at ~2.7 GB/s, completing files in seconds, so at most a few GB of buffers ever coexist; the affected CI runners read ~10× slower, letting all 128 buffers grow toward full size together. And a memory cap mostly caps page cache, which the kernel happily reclaims — clean page cache cannot OOM a cgroup — so capped repro runs sail through while pointing at the "641 GB > limit" red herring. The bug was never "the checkpoint doesn't fit"; it was "slow storage turns the prefetch's own scratch buffers into half a terabyte." This also makes the failure version-independent: the prefetch code is identical from 1.3.0rc17 through rc23, matching the CI history.

Test Coverage

Unit tests (new)

tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py:

  • test_prefetch_one_file_reads_full_file_in_bounded_chunks — full file consumed strictly chunk by chunk, including a trailing partial chunk.
  • test_prefetch_one_file_missing_file_is_noop — missing files silently skipped, as before.
  • test_prefetch_files_emits_progress_heartbeat — heartbeat fires per chunk when the interval is forced to zero.

Testing scenario (before/after efficacy demo)

Same 8×H200 node, same container with a 400 GiB memory cap (--memory=400g --memory-swap=400g), same test: perf/test_perf.py::test_perf[deepseek_r1_0528_fp8-bench-pytorch-float8-input_output_len:1000,1000-reqs:20000-ep:8-tp:8-gpus:8] (one of the failing CI cases; ~641 GB checkpoint).

  • Before (pre-fix allocation pattern, recreated via a temporary env-gated throttle/pinning patch — not part of this PR): dies in ~3 minutes. Container memory climbs at ~2.8 GiB/s to 394 GiB of the 400 GiB cap; the kernel memcg OOM killer kills a rank holding 50.5 GiB of anonymous prefetch buffers (dmesg: CONSTRAINT_MEMCG ... Killed process (python3) anon-rss:52992132kB); MPI aborts; the harness stall detector SIGKILLs the hung launcher ~30 minutes later — reproducing the exact CI signature (died with <Signals.SIGKILL: 9> during prefetch) end to end, including the misleading log shape.
  • After (this fix, same cap): the full test runs to PASSED, cold-cache 641 GB prefetch included, with heartbeat lines pacing the prefetch and container memory staying two orders of magnitude below the cap.

Validation on the affected CI hardware

The failing cases live in the QA weekly lane (tests/integration/test_lists/qa/llm_perf_core.yml, H20-3e pool), which is not reachable from PR CI. A one-off run of the patched build on an H20-3e runner has been requested in NVBug 6272397; the heartbeat additionally guarantees that any residual failure there produces an attributable log instead of a silent SIGKILL.

PR Checklist

Please review the following before submitting your PR:

  • PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.

  • PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.

  • Test cases are provided for new code paths (see test instructions)

  • If PR introduces API changes, an appropriate PR label is added - either api-compatible or api-breaking. For api-breaking, include BREAKING in the PR title.

  • Any new dependencies have been scanned for license and vulnerabilities

  • CODEOWNERS updated if ownership changes

  • Documentation updated as needed

  • Update tava architecture diagram if there is a significant design change in PR.

  • The reviewers assigned automatically/manually are appropriate for the PR.

  • Please check this after reviewing the above items as appropriate for this PR.

GitHub Bot Help

To see a list of available CI bot commands, please comment /bot help.

…etch

Checkpoint prefetch warmed the OS page cache by reading each safetensors
shard with a whole-file f.read(), pinning the entire file in anonymous
memory per in-flight file. With up to 16 prefetch threads per local rank
(128 concurrent multi-GB reads on an 8-GPU node), slow shared storage
lets those transient buffers accumulate into hundreds of GB on top of
the page cache itself, exhausting host memory: the kernel OOM killer
then takes down the whole job mid-prefetch, e.g. during the ~641 GB
DeepSeek-R1 FP8 prefetch on H20-3e perf CI runners.

Read in fixed-size 64 MB chunks into one bounded buffer per in-flight
file instead. This warms the page cache identically while keeping the
per-thread footprint constant. Also emit a rate-limited progress
heartbeat so a slow-but-healthy prefetch produces observable output
instead of tens of minutes of log silence (which output-stall watchdogs
punish with SIGKILL, masking the real failure mode).

Signed-off-by: Michal Guzek <mguzek@nvidia.com>
@moraxu
moraxu requested a review from a team as a code owner August 7, 2026 20:43
@moraxu
moraxu requested a review from Wanli-Jiang August 7, 2026 20:43
@moraxu

moraxu commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

/bot run

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Checkpoint prefetching now reads files in bounded 64 MiB chunks. Worker threads report byte progress through a synchronized callback, which emits periodic heartbeat logs. Tests cover chunk boundaries, missing files, and progress logging.

Changes

Prefetch progress

Layer / File(s) Summary
Chunked file prefetch
tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py, tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py
_prefetch_one_file reads reusable 64 MiB chunks, reports bytes through an optional callback, and continues to skip missing files. Tests cover full chunk reads, trailing partial chunks, and missing files.
Worker progress reporting
tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py, tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py
Prefetch orchestration aggregates local file sizes, tracks worker progress safely, logs periodic heartbeat updates, and passes the callback to each worker. Tests validate per-chunk heartbeat logging.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: bowenfu, wanli-jiang

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the fix for host out-of-memory failures during checkpoint prefetch and follows the repository's ticket and type format.
Description check ✅ Passed The description explains the problem, solution, tests, validation results, known limitation, and checklist status in the required sections.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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: 2

🤖 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 `@tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py`:
- Around line 356-358: Keep the read-loop callback in weight_loader.py lines
356-358 limited to byte accounting via report_progress. Update the worker-future
handling at lines 382-404 to emit rate-limited heartbeat logs while futures
remain pending, including when a blocking read produces no completed bytes. Add
a blocking-read test in
tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py lines 327-347
that verifies a heartbeat occurs before read completion and subsequent
heartbeats are rate-limited.

In `@tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py`:
- Around line 327-347: Extend test_prefetch_files_emits_progress_heartbeat with
a controlled blocking read that takes longer than _PREFETCH_LOG_INTERVAL_SEC,
using a synchronization mechanism to pause and resume the read. Assert that
“Prefetch progress” is logged before the blocked read completes, and verify the
number or timing of logs remains rate-limited rather than emitting continuously.
Keep the existing chunk-based heartbeat assertions intact.
🪄 Autofix

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: 889d2b4a-28ca-457c-8f77-6849b01521ff

📥 Commits

Reviewing files that changed from the base of the PR and between 2c96f94 and 16e4ce0.

📒 Files selected for processing (2)
  • tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py
  • tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py

Comment on lines +356 to +358
while num_read := f.readinto(buffer):
if report_progress is not None:
report_progress(num_read)

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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Decouple heartbeat scheduling from completed reads.

A heartbeat check occurs only after readinto() returns. A slow 64 MiB read can block beyond 60 seconds, so the process can remain silent during active prefetch.

  • tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py#L356-L358: keep this callback for byte accounting only.
  • tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py#L382-L404: schedule rate-limited logs while worker futures are pending, including when no read completes.
  • tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py#L327-L347: add a blocking-read test that verifies a heartbeat before read completion and verifies rate limiting.

As per path instructions, test coverage must validate changed test behavior.

📍 Affects 2 files
  • tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py#L356-L358 (this comment)
  • tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py#L382-L404
  • tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py#L327-L347
🤖 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 `@tensorrt_llm/_torch/models/checkpoints/hf/weight_loader.py` around lines 356
- 358, Keep the read-loop callback in weight_loader.py lines 356-358 limited to
byte accounting via report_progress. Update the worker-future handling at lines
382-404 to emit rate-limited heartbeat logs while futures remain pending,
including when a blocking read produces no completed bytes. Add a blocking-read
test in tests/unittest/_torch/models/checkpoints/hf/test_weight_loader.py lines
327-347 that verifies a heartbeat occurs before read completion and subsequent
heartbeats are rate-limited.

Source: Path instructions

Comment on lines +327 to +347
def test_prefetch_files_emits_progress_heartbeat(tmp_path, monkeypatch):
# The heartbeat is what keeps a slow prefetch observable (and alive under
# output-stall watchdogs); with the log interval forced to zero it must
# fire for every chunk.
from tensorrt_llm._torch.models.checkpoints.hf import weight_loader as wl

monkeypatch.setattr(wl, "_PREFETCH_CHUNK_SIZE_BYTES", 1024)
monkeypatch.setattr(wl, "_PREFETCH_LOG_INTERVAL_SEC", 0.0)
files = []
for i in range(3):
file = tmp_path / f"model-0000{i}-of-00003.safetensors"
file.write_bytes(os.urandom(4 * 1024))
files.append(str(file))

with mock.patch.object(wl.logger, "info") as info:
HfWeightLoader().prefetch_files(files)

progress_logs = [call for call in info.call_args_list if "Prefetch progress" in str(call)]
# Every chunk logs when the interval is zero: 3 files x 4 KB at a 1 KB
# chunk size means at least 12 heartbeats (short reads only add more).
assert len(progress_logs) >= 12

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Test a read that exceeds the heartbeat interval.

Setting _PREFETCH_LOG_INTERVAL_SEC to zero only tests completed-chunk callbacks. It cannot detect that a blocked readinto() call prevents heartbeat evaluation.

Add a controlled blocking-read test. Assert that progress logging continues before the read completes and remains rate-limited.

As per path instructions, test coverage must validate changed test behavior.

🤖 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/unittest/_torch/models/checkpoints/hf/test_weight_loader.py` around
lines 327 - 347, Extend test_prefetch_files_emits_progress_heartbeat with a
controlled blocking read that takes longer than _PREFETCH_LOG_INTERVAL_SEC,
using a synchronization mechanism to pause and resume the read. Assert that
“Prefetch progress” is logged before the blocked read completes, and verify the
number or timing of logs remains rate-limited rather than emitting continuously.
Keep the existing chunk-based heartbeat assertions intact.

Source: Path instructions

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64699 [ run ] triggered by Bot. Commit: 16e4ce0 Link to invocation

@moraxu
moraxu marked this pull request as draft August 7, 2026 21:17
@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64699 [ run ] completed with state SUCCESS. Commit: 16e4ce0
/LLM/main/L0_MergeRequest_PR pipeline #52554 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

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

The diagnosis is right and the fix is minimal and behavior-preserving — main comment is on the mechanism (inline), and mostly asks you to record why readinto was chosen over mmap + MADV_POPULATE_READ in the PR description.

Two smaller points:

  • The heartbeat text ends with (local rank). logger already prefixes [RANK n], so the suffix reads as a stray token rather than telling you the totals are this rank's share of the checkpoint. Suggest Prefetch progress: X / Y GB (this rank's share) or dropping it.
  • The known limitation (a fully hung mount still logs nothing, because the heartbeat is progress-gated) is worth a one-line comment in prefetch_files next to report_progress, not just in the PR description — the next person to debug a silent stall will read the code, not the PR.

NVBug tag is fine; no docs/changelog owed for an internal log line.

# those buffers accumulate into hundreds of GB across the local
# ranks, which can OOM the host. Chunked reads warm the OS page
# cache identically with a constant per-thread footprint.
buffer = memoryview(bytearray(_PREFETCH_CHUNK_SIZE_BYTES))

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.

Chunking fixes the OOM, but it still copies every byte out of the page cache into an anonymous buffer only to discard it — ~640GB of pointless memcpy per node, on top of the read itself.

The alternative for a pure "get these pages into the page cache" warm-up is mmap() the file read-only, madvise(MADV_POPULATE_READ) over it, then munmap(): the kernel populates the page cache directly, with no user-space copy and no anonymous buffer at all. The repo already has the pieces — tensorrt_llm/runtime/kv_cache_manager_v2/_utils.py:564 does the ctypes libc.madvise call for MADV_POPULATE_WRITE including the EINVAL/ENOSYS fallback for kernels older than 5.14 (MADV_POPULATE_READ has the same 5.14 requirement), and tensorrt_llm/_torch/mmap_utils.py wraps madvise_range. The chunked readinto loop written here would be the fallback path, so it'd be additive rather than a rewrite, and progress reporting works the same way if you madvise in chunks.

That said, mmap on network filesystems is a known sore spot, and checkpoints here are typically on Lustre — if mmap/madvise was already ruled out for that reason (or for any other), that's a fine answer; please state it in the PR description so the choice is on record and nobody re-litigates it later. If it wasn't considered, it's worth a look given the memcpy volume.

Either way the description should say which it is.

files.append(str(file))

with mock.patch.object(wl.logger, "info") as info:
HfWeightLoader().prefetch_files(files)

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.

prefetch_files shards its input with file_names[local_mpi_rank()::local_mpi_size()], so this test's >= 12 heartbeat count silently depends on the process not being launched under MPI — under local_mpi_size() > 1 this rank only reads 1 of the 3 files and the assertion fails.

Monkeypatch wl.local_mpi_rank -> 0 and wl.local_mpi_size -> 1 so the test asserts on a fixed file set regardless of how it's launched.

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.

3 participants