fix(inference): materialise checkpoint tensors in host memory before H2D copy (17.7x faster load on GB300) - #150
Conversation
024240c to
ef73e55
Compare
…H2D copy _DiffusersHuggingFaceStorageReader inherits _process_read_request, which copies each tensor from mmap-backed safetensors storage directly into a CUDA target. The host-to-device path then handles a memory-mapped page fault per tensor. On Grace (GB300) this dominates checkpoint load time with zero disk I/O -- the data is already in page cache. Cosmos3-Super reasoner load: 1184s -> 67s (17.7x). Cosmos3-Nano: 285s -> 8s (35.6x). Full 14-command sweep: 4h49m -> 2h51m. Materialising each tensor into anonymous host memory before the copy fixes it. Pre-faulting in place is not sufficient (mmap source stays at 9.00 ms/tensor after faults are pre-paid, vs 1.61 ms/tensor from heap), and pinning is unnecessary (heap pageable 1.61 vs heap pinned 1.69 ms/tensor). Output is byte-identical on GB300 and x86 A100, single-process and 8-rank. Known trade-off: on 8x A100 where the mmap path is not slow, the added copy costs ~13% on Cosmos3-Super (431s -> 489s, reproducible). Happy to make it conditional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The staging copy removes a slow file-backed mmap H2D path on Grace, but where
that path is already fast it is pure overhead. On 8x A100 (Cosmos3-Super, 8
torchrun ranks) applying it unconditionally measured ~13.2% slower (431/433s
stock vs 489/489s), because concurrent ranks copying at once contend for host
memory bandwidth. Single-process x86 is unaffected either way.
Default on for aarch64/arm64, off elsewhere, overridable in either direction
with COSMOS_MATERIALIZE_CHECKPOINT=1/0. The architecture is only a proxy for
"is the mmap H2D path slow here", so it is used as a default rather than as a
hard condition; the override keeps the heuristic correctable without a code
change. The staging logic moves to a _MmapSafeReadMixin so the plain HF reader
is covered as well as the diffusers reader.
Verification, both architectures, same build:
gate resolution (COSMOS_MATERIALIZE_CHECKPOINT unset / =1 / =0)
x86_64 A100 off / on / off both readers
aarch64 GB300 on / on / off both readers
x86_64 -- 8x A100, Cosmos3-Super reasoner, 8 ranks, alternating runs
stock A 434s
gated, default off 429s
gated, forced on 490s
gated, default off 430s
stock B 431s
all five md5 9c81833184f7a7067c7f59b3326a91b2
aarch64 -- 1x GB300, Cosmos3-Super reasoner, single process
gated, default on 124s wall, 83s load window
gated, forced off 1252s wall, 1211s load window
both md5 d520e852a059ed52c6d42953f787e70a, equal to that node's
stock baseline
Byte-identical output within each node across every arm; hashes compared
within a node, not across. Both trees reverted clean at 5e67049 afterwards.
Timings come from the diffusers reader path; the plain HF reader shares the
mixin and therefore the mechanism, but was not separately benchmarked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ef73e55 to
3d8c469
Compare
| "", | ||
| ) | ||
| else: | ||
| cls._materialize_cache = platform.machine().lower() in ("aarch64", "arm64") |
There was a problem hiding this comment.
Should be all default off? Since not necessarily would benefit in all aarch64/arm64 case? Still think this is highly related with the storage system.
There was a problem hiding this comment.
Fair challenge — and the published table genuinely could not answer it, since the two nodes differ
in architecture, filesystem and host-GPU interconnect at once.
I ran the controlled version: one node, one shard, filesystem as the only variable, then the same
sweep on x86 for a 2×2. Full numbers in
#150 (comment).
Short version: on aarch64 the penalty holds on every filesystem — 9.03×–13.42× — including
tmpfs, which has no block-device fault handler behind it, and overlay, which is what the x86 node
uses. It never collapses. On x86 it is absent everywhere (0.21×–0.28×; mmap is ~4× faster).
So architecture flips the sign of the effect and the filesystem only scales its magnitude — a
1.49× spread across five filesystems against a ~40× gap between architectures. Storage is
second-order, not causal.
On "not necessarily would benefit in all aarch64/arm64 case" — you're right, and the sweep does not
fix that. It cannot separate architecture from host-GPU interconnect (C2C vs PCIe), because both
boxes differ in both. aarch64 is a better-supported proxy now, but still a proxy. That is why it
is a default with an env override rather than a hard condition, and I'd argue for keeping that
shape precisely because of the uncertainty you're pointing at.
| import os | ||
| import platform | ||
|
|
||
| override = os.environ.get("COSMOS_MATERIALIZE_CHECKPOINT") |
There was a problem hiding this comment.
Could have default 0 in the env.get?
There was a problem hiding this comment.
That would make it opt-in everywhere — with a "0" default, override is never None, so the
platform.machine() branch below becomes unreachable. Worth being explicit that the change deletes
the arch heuristic rather than just adjusting it.
I'd argue against, but only on the strength of the new measurements
(#150 (comment)): on aarch64 the mmap
path is 9–13× slower than staging on every filesystem tested, and a Cosmos3-Super reasoner load
goes 1211 s → 83 s. Default-off means every Grace user pays a ~20-minute load with nothing
indicating a fix exists — the failure mode is silent, and the people most affected are least likely
to know the env var is there.
Default-on for aarch64 with COSMOS_MATERIALIZE_CHECKPOINT=0 as the escape hatch keeps the
regression risk bounded (x86 is unaffected: the gate resolves off, verified at 434/433 s against
434/431 s stock) while making the fix reachable without configuration.
That said — this is a one-line change and I'm happy to take it if you'd rather ship opt-in. The
measurements make it reversible in either direction, so it's a policy call, not a technical one.
Your call and I'll follow it.
Filesystem sweep: the gate condition, measured@lfengad — your objection that this is "highly related with the storage system" was fair, and the So I ran the controlled version: one node, one shard, filesystem as the only variable — then Q1 — filesystem, or architecture?The discriminating quantity is the penalty ratio: mmap source vs heap source measured on the GB300 — aarch64, 5 filesystems
Medians alone would overstate what 5 repeats support, so the envelope column takes the per-repeat A100 — x86_64, 3 filesystems (every writable path on that pod is overlay, so the layered
The penalty never collapses on aarch64 — not on tmpfs, which has no block-device fault handler Architecture flips the sign of the effect; the filesystem only scales its magnitude — 1.49× What this does not settle, and I would rather say so than let the result be read as more than Q2 — is pre-faulting in place sufficient?No. On aarch64, after every fault is pre-paid, the mmap source still costs 3.0–4.0 ms/tensor Q3 —
|
Summary
Checkpoint loading copies each tensor from mmap-backed safetensors storage directly into a CUDA target, so the host-to-device path handles a memory-mapped page fault per tensor. On Grace (GB300) this dominates wall-clock: a Cosmos3-Super reasoner load takes 1,184 s with zero disk I/O — the data is already in page cache and ~1.3 of 72 cores are busy.
Materialising each tensor into ordinary (anonymous) host memory before the copy fixes it:
† Re-measured on a later boot of the same node as 1,211 s → 83 s (14.6×). The effect reproduces in shape but the exact ratio moves ~20% between boots — read the Super figure as order 15×, not a constant. See "Run-to-run spread" below.
Output is byte-identical on both architectures and all models tested. Decode times are unchanged, confirming only the load path was affected.
The mechanism is one
clone(); the rest is the gate helper and comments. The copy is applied conditionally, for the reason set out under "Conditional application" below.Root cause
torch.distributed.checkpoint.hf_storage.HuggingFaceStorageReader._process_read_request, reached fromcosmos_framework/inference/model.py::from_pretrained_dcp:The model is moved to GPU before weights are loaded, so each tensor is an individual H2D copy whose source is a memory-mapped file.
Evidence:
py-spy: every sample lands in_process_read_request— one hot frame, not diffuse cost/proc/<pid>/io:read_bytes: 0whilercharclimbs past 2.59 GB — served entirely from page cache, no disk I/OWhy anonymous host memory, not merely pre-faulting
Micro-benchmark, 4.61 GiB Cosmos3-Super shard, 48 tensors, page-fault cost measured separately and pre-paid, each regime run both first and last to control for ordering:
Two results drive the design:
Alternatives measured and rejected
thread_count=16on the readerload_file()per shardThe pinned-vs-heap pair on Super is the cleanest evidence, since the two differ in exactly one respect and heap wins 67 s to 111 s.
Conditional application — implemented
The first revision of this PR applied the copy unconditionally, and disclosed a cost on x86. On configurations where the mmap path is not slow the copy is pure overhead — measured on 8× A100 (Cosmos3-Super, 8 torchrun ranks), alternating runs: stock 431/433 s vs unconditional 489/489 s, ~13.2% slower. The cause is straightforward: on x86 the per-tensor mmap H2D is already fast, so the copy buys nothing, and with 8 ranks copying concurrently they contend for host memory bandwidth. Single-process Edge and Nano on that node were unaffected.
Rather than leave the policy open, the copy is now conditional:
Why arch is the default rather than the condition. The real condition is "does this platform have a slow file-backed mmap H2D path".
aarch64is a proxy for that, and a proxy hardcoded with no escape hatch is an unfalsifiable claim in code — wrong for any future x86 with a slow path, or any ARM without one. Making it a default keeps the heuristic correctable without a code change.Verified on both architectures
Gate resolution, same build on both nodes:
platform.machine()=1=0x86_64aarch64x86 — 8× A100, Cosmos3-Super reasoner, 8 ranks, alternating runs, same harness that produced the original 431/433 vs 489/489 measurement:
The regression is gone at the default, and the forced arm reproduces it — which also confirms the gate is live code rather than something that never fires.
aarch64 — 1× GB300, Cosmos3-Super reasoner, single process:
14.6× on the load window, with the default firing automatically and no configuration required.
All outputs byte-identical within each node: A100
9c81833184f7…across all five runs, GB300d520e852…across both, each matching that node's own stock baseline.Measurement scope: all timings above come from the diffusers reader path (
_DiffusersHuggingFaceStorageReader), which is what the Cosmos3 checkpoints load through. The plain HF reader inherits the same mixin and therefore the same behaviour by construction, but was not separately benchmarked. Both readers were verified to resolve the gate identically (unset/=1/=0→ off/on/off on x86_64).On
.contiguous().clone(): a bare.clone()is very likely equivalent here and would avoid a redundant second copy when a slice is non-contiguous. We propose the.contiguous()form only because it is what every number above was measured against; switching would mean quoting timings for code we did not run. Happy to change it.Run-to-run spread, disclosed: the headline 1,184 s → 67 s (17.7×) and this re-verification's 1,211 s → 83 s (14.6×) are the same measurement taken on different boots of the same node. The effect reproduces in shape and order of magnitude, but the exact ratio moves by roughly 20% between boots — the headline figure should be read as "order 15×", not as a precise constant.
Alternatives considered for the condition itself
world_size == 1Happy to drop the env var, rename it, invert the default, or move to a probe — whichever is preferred. The measurements above should make any of those a small edit rather than a re-investigation.
Correctness
Byte-identical output against stock, every configuration tested:
560ce35de53c6c4a6435db615d861d40d520e852a059ed52c6d42953f787e70a560ce35de53c6c4a6435db615d861d409c81833184f7a7067c7f59b3326a91b2Full-sweep comparison on GB300: 62 of 63 output files bit-identical. The single exception (
transfer_multi_control) is under investigation and is not attributable to this change — all six transfer specs come from one command and therefore one model load, and the other five are bit-identical; had the patch altered any weight, all six would have moved. It has since differed a third time under a second, independent patch sharing no code with this one, which points to nondeterminism inherent to that spec rather than to either change.Peak host memory checked under the heaviest configuration available (Cosmos3-Super, 30 shards, 8 ranks): 73 GB of 1771 GB, no pressure.
Upstream note
The same pattern exists in
torch/distributed/checkpoint/hf_storage.py::_process_read_requestand affects any project loading a DCP/HF checkpoint onto GPU. This PR fixes it within cosmos-framework's subclass so the benefit does not depend on a torch release; a corresponding upstream report is worth filing separately.Environment
Benchmarks pinned to cosmos-framework
5e67049(v1.2.2) and cosmos0299468, identical checkpoint SHAs on both nodes. All comparison results were produced on unmodified code; the patch was measured separately and reverted.Rebase note. This branch has since been rebased onto
mainatee58e41to satisfy the up-to-date-branch requirement. The rebase was clean and the three intervening commits (#148, #153, #159) touch no file this PR modifies. The one nearby change ismodel/generator/utils/safetensors_loader.py(#148), which is the VLM safetensors loader — a different code path from the DCPHuggingFaceStorageReaderpatched here, and its changes concern shard/replica semantics, not the H2D copy.The x86 gate A/B was nonetheless re-run in full on the rebased branch, since the 13.2% regression and its removal are the load-bearing claims for the gate. Arms selected by
git checkoutof the real commits (stockee58e41, patched3d8c469), same 8-rank harness as before:9c81833184f79c81833184f79c81833184f7Unchanged from the pre-rebase measurement: the default is indistinguishable from stock, the forced arm reproduces the regression, and all five runs are byte-identical to the stock baseline. The GB300 numbers were not re-run post-rebase (separate node); the patched code path is untouched by the rebase, but say the word if you want them repeated.
Storage, RAM speed, thread configuration, aarch64 vectorisation and the bulk safetensors path were each ruled out by measurement before arriving at this diagnosis.
Thanks to @NVIDIA research (Liang Feng) for reviewing an earlier version of this analysis and correcting the mechanism — an initial benchmark had measured a cold page cache and misattributed the cost to pageable-vs-pinned transfers.