Prefetch only the byte spans each rank will read - #48604
Open
qgallouedec wants to merge 26 commits into
Open
Conversation
…TCH) The loader's per-tensor read pattern pulls a network filesystem at well under 1 GiB/s while large sequential reads sustain many times that (measured: 0.26-0.7 GiB/s vs 8.5 GiB/s on Lustre). With HF_SHARD_PREFETCH=<threads>, the local ranks split the shard list and stream it into the page cache before loading; the load then runs at memory speed. Measured on GLM-4.5-Air (206 GiB, cold, 8 GPUs): 60 s baseline vs 23 s prefetch + 10 s load.
prefetch_checkpoint_shards warms whole shards on every node, so an 8-node job reads the checkpoint 8 times over. Given the model's meta state dict it can instead compute, from the safetensors headers and the DTensor placements, the byte ranges this rank actually slices, and warm only those. Only dim-0 sharding keeps a rank's share contiguous on disk, so that is the case that gets sliced; everything else is read whole, which is a superset of what the rank needs. Spans that come out identical on every rank are shared out between the local ranks. Reading less costs seeks, and a checkpoint that stores experts one tensor at a time leaves thousands of small spans where the seeks cost more than the bytes saved. _SEEK_COST_BYTES prices a seek in bytes so the two plans can be compared directly, and the whole-file plan wins whenever it should. On a cross-region Lustre mount a seek measured at 12 MiB (50 ms, 0.24 GiB/s per stream); on a local NVMe it is far less, which only makes the fallback more eager. Measured on a 1.5 TB MoE checkpoint over 8 nodes / 64 GPUs, cold page cache, paired runs differing only in this change: from_pretrained 2455.7s -> 478.7s (5.1x), peak memory unchanged. A 60 GiB checkpoint that stores experts per-tensor takes the whole-file path and is unchanged.
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
… files Two gaps in the first commit. A checkpoint that stores one tensor per expert names them so the packed parameter never matches, so every expert fell through to 'read whole' and the plan was rejected. But a rank owns a contiguous run of experts and consecutive experts sit next to each other on disk, so keeping the owned ones and dropping the rest leaves a few long runs, not thousands of fragments. On Qwen3-30B-A3B at 16 ranks a rank wants 3 to 23 spans of a 3.72 GiB shard, median 9 to 72 MiB. This is most MoE checkpoints today: 9 of the 13 recent families surveyed store experts this way. Knowing which experts the rank owns is enough, and every expert parameter is sharded identically, so one of them answers for all and no name mapping is needed. Second, the whole-file plan was priced as the files this rank happened to be dealt. With fewer shards than local ranks most ranks are dealt none, so that came to zero bytes and the span plan could never win. Price it as the node's read divided by its ranks instead.
Two changes that only pay off together with the per-rank spans.
Warming a range by reading it copies every byte through userspace and
blocks. For the short ranges a rank's own shard produces, asking the kernel
to read ahead instead is nearly free. It is not free for a whole shard:
that queues far more readahead than the kernel will honour, and the
outstanding requests then compete with the per-tensor reads that follow, so
bulk warming keeps reading.
Measured on Qwen3-30B-A3B, 8 nodes / 64 ranks, tp=8 fsdp=8, cold page
cache, paired arms differing only in these lines:
fadvise, whole shards 204.0 s
no prefetch (main) 141.2 s
read, rank spans 101.3 s
read, whole shards 75.9 s
fadvise, rank spans 50.9 s (repeat 50.7 s)
So the range choice and the warming call are not independent: kernel
readahead over whole shards is worse than not prefetching at all, and over
per-rank spans it is 2.8x better than main and 1.5x better than warming
whole shards by reading.
Merging also now bridges a gap when the gap costs less than the seek it
saves, which cuts a rank's span count roughly in half on a checkpoint that
stores one tensor per expert.
The swap to named_parameters was made while chasing a device-mesh crash that turned out to have a different cause. It is not equivalent: its keys do not match the checkpoint names on every model, and where they do not, nothing resolves and the whole checkpoint is warmed instead of this rank's spans.
VI-Arthur
reviewed
Sep 8, 2026
VI-Arthur
left a comment
Collaborator
There was a problem hiding this comment.
let' s talk a bit about this together, this seems like a non typical case where you already need the checkoints to be saved in a certain format no?
Collaborator
|
This looks like a case of read then shard (GPU0 reads file0 layers 0-10, GPU1 file 1 layers 10-20, etc) -> you split like Pipeline, so you saturate coms, fewer reads no? |
Collaborator
Kernel readahead is advice. Past a few GiB it is dropped, the pages are not there when the loader asks, and the load pays for the miss instead. Gating on the size of each range got this wrong: an 820B checkpoint's spans average 43 MiB, so every one of them qualified, while the rank was queueing 29.7 GiB in total. Prefetch looked good at 272 s against 447 s for reading, and the load behind it went from 32 s to 1600 s. Gate on the total instead. Measured: 1.3 and 4.4 GiB of readahead land, 8 and 29.7 GiB do not.
Covers the merge policy, the two MoE checkpoint layouts, and the dim-0 slice, on CPU with two gloo ranks. Four mutations of the span code each fail at least one test: dropping the gap bridge, keeping every expert, never slicing dim 0, and losing the header offset.
The spans plan and the whole-file plan divide a node's checkpoint up differently, so when some local ranks took one and some the other, the files nobody was dealt stayed cold and the loader paid for them: GLM-4.5-Air on one node loaded in 41.7 s against 29.9 s for whole files, with ranks reporting 103 and 150 spans next to ranks reporting 6. Agree on the plan across ranks, and lower the readahead ceiling to 5 GiB, since a 7.5 GiB hint does not land either.
A bare barrier() leaves NCCL guessing the device from the global rank, which it warns can hang when the rank to GPU mapping is heterogeneous. _distributed_barrier() passes device_ids and already returns early when distributed is not initialized, so the guard goes with it.
# Conflicts: # src/transformers/distributed/utils.py
…etch-rank-spans # Conflicts: # src/transformers/distributed/utils.py # src/transformers/modeling_utils.py
Contributor
CI recapDashboard: View test results in Grafana |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #48227, which warms whole shards on every node and so carries the caveat "requires node RAM >= checkpoint size". It doesn't have to: every rank slices its own shard out of those files, so a node only needs the spans its ranks read.
Given the model's meta state dict,
prefetch_checkpoint_shardsreads the safetensors headers and the DTensor placements and warms only those ranges. Reading less costs seeks, so_SEEK_COST_BYTESprices a seek in bytes and whole files win whenever they should.Paired cold runs, same nodes, differing only in whether the meta state dict is passed:
from_pretrainedNode RAM now holds 229 GiB instead of 1.5 TB. Peak GPU memory 66.2 GB and median step 12.8 s in both arms.
The 12 MiB constant is measured, not chosen (right panel): reading a fixed 12.5% of a shard at varying span size, cache evicted per point. Below ~1.8 MiB spans, reading 12.5% is slower than reading all of it, which puts a seek at ~50 ms, or 12 MiB of sequential read. This is a cross-region mount; local NVMe is well below that, where the only effect is that whole-file warming is chosen more readily.
Checkpoints storing one tensor per expert (Qwen3-30B-A3B) make thousands of tiny spans, the cost test rejects them, and
jobsbecomes exactlywhole[local_rank::local_world]— the pre-PR path, byte for byte.Repro
CPU only, no download, seconds. Prints what one node warms for both layouts as the node count grows.
One node saves nothing, because whole-file warming already splits shards across a node's ranks. The saving is
local_world / world, so it grows with the node count, and only for packed layouts.