Skip to content

Prefetch only the byte spans each rank will read - #48604

Open
qgallouedec wants to merge 26 commits into
load-shard-prefetchfrom
prefetch-rank-spans
Open

Prefetch only the byte spans each rank will read#48604
qgallouedec wants to merge 26 commits into
load-shard-prefetchfrom
prefetch-rank-spans

Conversation

@qgallouedec

@qgallouedec qgallouedec commented Sep 7, 2026

Copy link
Copy Markdown
Member

CPU CI GPU run-slow

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_shards reads the safetensors headers and the DTensor placements and warms only those ranges. Reading less costs seeks, so _SEEK_COST_BYTES prices a seek in bytes and whole files win whenever they should.

image

Paired cold runs, same nodes, differing only in whether the meta state dict is passed:

820B-A42B MoE, 1.5 TB, 8 nodes bytes/rank from_pretrained
whole shards 155-221 GiB 2455.7 s
rank spans 28.7 GiB 478.7 s

Node 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 jobs becomes exactly whole[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.

torchrun --nproc_per_node 4 repro_spans.py
import os, shutil, torch, torch.distributed as dist
from safetensors.torch import save_file
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.tensor import Shard, distribute_tensor
from transformers.distributed.utils import _SEEK_COST_BYTES, _rank_byte_spans

EXPERTS, HIDDEN, LAYERS, DIRECTORY = 32, 2048, 2, "spans_repro_tmp"

def report(path, state_dict, rank, world, local_world, label):
    own, common = _rank_byte_spans([path], state_dict)
    jobs = own + common[rank % local_world :: local_world]
    needed = sum(end - start for _, start, end in jobs)
    cost = needed + len(jobs) * _SEEK_COST_BYTES
    whole = os.path.getsize(path) / local_world
    if rank == 0:
        print(f"  {label:11s} {world // local_world} node(s): warms {min(needed, whole) / 2**20:7.1f} MiB"
              f"  (spans {needed / 2**20:7.1f} MiB in {len(jobs):3d}, cost {cost / 2**20:8.1f} MiB"
              f"  vs whole files {whole / 2**20:7.1f} MiB) -> {'SPANS' if jobs and cost < whole else 'whole files'}", flush=True)

dist.init_process_group("gloo")
rank, world = dist.get_rank(), dist.get_world_size()
mesh = init_device_mesh("cpu", (world,))
packed, per_expert = f"{DIRECTORY}/packed.safetensors", f"{DIRECTORY}/per_expert.safetensors"
if rank == 0:
    os.makedirs(DIRECTORY, exist_ok=True)
    save_file({f"model.layers.{i}.mlp.experts.gate_up_proj":
               torch.zeros(EXPERTS, HIDDEN, HIDDEN, dtype=torch.bfloat16) for i in range(LAYERS)}, packed)
    save_file({f"model.layers.{i}.mlp.experts.{e}.gate_up_proj":
               torch.zeros(HIDDEN, HIDDEN, dtype=torch.bfloat16) for i in range(LAYERS) for e in range(EXPERTS)}, per_expert)
dist.barrier()

state_dict = {f"model.layers.{i}.mlp.experts.gate_up_proj":
              distribute_tensor(torch.zeros(EXPERTS, HIDDEN, HIDDEN, dtype=torch.bfloat16), mesh, [Shard(0)])
              for i in range(LAYERS)}
if rank == 0:
    print(f"\n{world} ranks, {os.path.getsize(packed) / 2**20:.0f} MiB checkpoint\n")
for local_world in [w for w in (world, world // 2, world // 4) if w >= 1]:
    report(packed, state_dict, rank, world, local_world, "packed")
    report(per_expert, state_dict, rank, world, local_world, "per-expert")
dist.barrier()
if rank == 0:
    shutil.rmtree(DIRECTORY, ignore_errors=True)
dist.destroy_process_group()
4 ranks, 512 MiB checkpoint

  packed      1 node(s): warms   128.0 MiB  (spans   128.0 MiB in   2, cost  152.0 MiB  vs whole files 128.0 MiB) -> whole files
  per-expert  1 node(s): warms   128.0 MiB  (spans   512.0 MiB in   1, cost  524.0 MiB  vs whole files 128.0 MiB) -> whole files
  packed      2 node(s): warms   128.0 MiB  (spans   128.0 MiB in   2, cost  152.0 MiB  vs whole files 256.0 MiB) -> SPANS
  per-expert  2 node(s): warms   256.0 MiB  (spans   512.0 MiB in   1, cost  524.0 MiB  vs whole files 256.0 MiB) -> whole files
  packed      4 node(s): warms   128.0 MiB  (spans   128.0 MiB in   2, cost  152.0 MiB  vs whole files 512.0 MiB) -> SPANS
  per-expert  4 node(s): warms   512.0 MiB  (spans   512.0 MiB in   1, cost  524.0 MiB  vs whole files 512.0 MiB) -> whole files

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.

qgallouedec and others added 14 commits August 22, 2026 20:36
…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.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

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 VI-Arthur 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.

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?

@VI-Arthur

Copy link
Copy Markdown
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?

@VI-Arthur

Copy link
Copy Markdown
Collaborator

qgallouedec and others added 8 commits September 8, 2026 15:00
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
@github-actions

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 34273963871:1
Result: success | Jobs: 16 | Tests: 186,596 | Failures: 0 | Duration: 15h 31m

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