Skip to content

Warm the page cache before sharded loading (opt-in via HF_SHARD_PREFETCH) - #48227

Open
qgallouedec wants to merge 13 commits into
mainfrom
load-shard-prefetch
Open

Warm the page cache before sharded loading (opt-in via HF_SHARD_PREFETCH)#48227
qgallouedec wants to merge 13 commits into
mainfrom
load-shard-prefetch

Conversation

@qgallouedec

@qgallouedec qgallouedec commented Aug 23, 2026

Copy link
Copy Markdown
Member

CPU CI GPU run-slow

Adds an opt-in page-cache prefetch to sharded model loading: with HF_SHARD_PREFETCH=<threads>, the local ranks split the checkpoint's shard list between them and stream it into the page cache with large sequential reads before loading begins. The load itself then runs at memory speed.

Why

The loader's per-tensor read pattern (mmap + slicing) reads a network filesystem far below what the hardware sustains. Measured on Lustre (H100 nodes, fadvise-evicted so genuinely cold):

  • raw sequential reads: 2.0 GiB/s for 1 stream, 8.5 GiB/s for 32 streams per node
  • the loader, cold: 0.26–0.7 GiB/s effective
  • the loader, warm page cache: 10–20 GiB/s

So the entire gap is read scheduling, not conversion compute.

Measured

Load Baseline (cold) With HF_SHARD_PREFETCH=4
GLM-4.5-Air, 206 GiB, 1 node / 8 ranks 60 s 23 s + 10 s load = 33 s
GLM-4.5-Air, 206 GiB, 8 nodes / 64 ranks 791 s 152 s + 32 s = 184 s
GLM-4.6, 665 GiB, 8 nodes / 64 ranks 1042–3110 s 465 s + 63 s = 528 s
image

Multi-node totals are bounded by the filesystem's aggregate bandwidth shared across nodes (~10 GiB/s measured here): every node needs the full checkpoint cached, because with distributed_config every rank slices tensors from every shard. That is also why the env is opt-in: it requires node RAM ≥ checkpoint size.

With the cache warm, the fixed 4-thread IO pool becomes the next bottleneck; raising it (already possible) halves the load again (Air: 39 s → 19 s across 8 ranks, skew < 3 s), and the usual raise-workers OOM hazard is gone because post-prefetch reads are cheap.

A note on the environments: the multi-node rows above come from remote nodes with ~15 ms of extra latency to the filesystem, which amplifies the loader's many-small-reads pattern; the single-node rows are low-latency nodes. Prefetch helps in both because large sequential reads are latency-tolerant.

Minimal repro

Runs on one node with 2+ GPUs and any sharded checkpoint (below: Qwen3-30B-A3B, 57 GiB, measured on 8×H100 with a Lustre-backed cache, 23 s cold baseline vs 13 s with prefetch):

# torchrun --nproc_per_node 2 repro.py Qwen/Qwen3-30B-A3B
# run once as-is, once with HF_SHARD_PREFETCH=4
import glob, os, sys, time
from datetime import timedelta
import torch
from huggingface_hub import snapshot_download
from transformers import AutoModelForCausalLM
from transformers.distributed import DistributedConfig

model_id = sys.argv[1]
torch.distributed.init_process_group(backend="nccl", timeout=timedelta(hours=1))
torch.cuda.set_device(int(os.environ["LOCAL_RANK"]))

if int(os.environ["RANK"]) == 0:  # evict the checkpoint from the page cache: genuinely cold, no root needed
    for f in glob.glob(os.path.join(snapshot_download(model_id, allow_patterns=["*.safetensors"]), "*.safetensors")):
        fd = os.open(f, os.O_RDONLY)
        os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
        os.close(fd)
torch.distributed.barrier()

t0 = time.time()
AutoModelForCausalLM.from_pretrained(
    model_id, dtype=torch.bfloat16,
    distributed_config=DistributedConfig(tp_size=int(os.environ["WORLD_SIZE"]), enable_expert_parallel=True),
)
torch.distributed.barrier()
if int(os.environ["RANK"]) == 0:
    print(f"loaded in {time.time() - t0:.0f}s (HF_SHARD_PREFETCH={os.environ.get('HF_SHARD_PREFETCH', 'unset')})")

Usage:

HF_SHARD_PREFETCH=4 torchrun --nproc_per_node 8 train.py  # any from_pretrained with checkpoint files

Found while fine-tuning 100B–753B MoEs with FSDP2 × EP (#48204).

Part of the loading-performance work tracked in #48239.

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

@ArthurZucker ArthurZucker 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 go!

Comment thread src/transformers/modeling_utils.py Outdated
# Model's definition arriving here is final (TP hooks added, quantized layers replaces)
expected_keys = list(model.state_dict().keys()) if expected_keys is None else expected_keys

if checkpoint_files and (prefetch_threads := int(os.environ.get("HF_SHARD_PREFETCH", "0"))):

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.

very happy to have it, but in distributed/utils.py, isolated, imported.
ALSO we should default to something that makes sense if its always faster!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ArthurZucker happy to, but I'd rather base the default on more than our cluster.
What I measured: 2.6x cold on a shared FS, no measurable cost warm (+0.8 s on 93 s), but only 1.08x on node-local NVMe.
So it's a big win when the fs is remote and cold, neutral otherwise, and I haven't measured single-GPU or small-model loads.

Opened #48512 to track flipping it once we have numbers from other setups.

@Cyrilvallez

Cyrilvallez commented Sep 1, 2026

Copy link
Copy Markdown
Member

Hey @qgallouedec! Could you describe extremely precisely how you benchmarked this please? Because with our usuals cluster setups, what you will actually measure is usually simply whether or not weka already warmed up the files, not trying to prefetch them manually (the PR). If the files are warmed up by weka, everything is super fast. But the first run will be cold, and things will be slow.
My point is, if you simply ran the same script with/without the new env variable etc, the first run is always expected to be slower as weka is loading up the checkpoint in memory. Benchmarking this is extremely tricky/borderline impossible, or you need to make extra extra sure that weka offloaded all your checkpoint between each run

@qgallouedec

qgallouedec commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Note

Edit: replaced the reproducer with a corrected version. --scratch must point at the shared filesystem you actually load checkpoints from: on a node-local NVMe the whole question is moot (reads are already fast, measured speedup ~1.0x), so the original snippet could look like it disproves the PR.
Also re-ran the corrected script exactly as written on the shared filesystem, as an independent confirmation of the table below: 378.8 s -> 128.9 s (2.94x on a single pair), warm overhead within noise. Table numbers unchanged.

@Cyrilvallez Fair, the first numbers didn't control for that.

Redone with a protocol that removes the warm/cold confound: every run reads a checkpoint copy that has never been read on that node before, so neither the page cache nor the filesystem client can have it warm, and the two arms are interleaved so any drift in the storage backend hits both equally.

(I also checked your weka-side concern directly, since a freshly written copy could plausibly still be hot in the backend: from a compute node, sequential read throughput on a fresh copy vs a file written 12 days ago and untouched since was 0.21 vs 0.18 GiB/s. So fresh writes get no meaningful server-side cache advantage here and both arms genuinely start cold.)

Results on Qwen3-30B-A3B (57 GB, 8 ranks EP on one node), from_pretrained wall time:

arm runs (s) mean
cold, no prefetch 335.7, 353.3, 419.2 369.4
cold, prefetch=4 145.2, 136.6, 149.8 143.9
warm, no prefetch 93.2
warm, prefetch=4 94.0 overhead +0.8 s

The mechanism is only the read pattern: the per-tensor loading pass reads this filesystem at ~0.2 GiB/s single-stream, while a few large sequential readers sustain several times that. The prefetch doesn't beat a warm cache, it makes the cold path read at streaming speed instead of slice-at-a-time.

Self-contained reproducer (makes its own copies, interleaves the arms, prints the table):

bench_prefetch_repro.py
"""Self-contained cold-cache benchmark for the shard prefetch (transformers #48227).

Every run reads a checkpoint copy that has never been read on this node, so neither the page
cache nor the filesystem client can have it warm; the two arms are interleaved so any drift in
the storage backend hits both equally.

    # one node, 8 GPUs, ~6 x checkpoint size of free space
    python bench_prefetch_repro.py --model Qwen/Qwen3-30B-A3B --scratch /shared/fs/bench --pairs 3

`--scratch` must live on the filesystem you actually load checkpoints from (the shared/network one).
Pointed at a node-local NVMe the whole question is moot: reads are already fast, and the measured
speedup drops to ~1.0x.

It prints one line per run and a summary table at the end.
"""

import argparse
import json
import os
import shutil
import statistics
import subprocess
import sys
import tempfile
import time

WORKER = r"""
import os, sys, time
os.environ["HF_SHARD_PREFETCH"] = sys.argv[2]
import torch
from transformers import AutoModelForCausalLM
from transformers.distributed import DistributedConfig

world = int(os.environ["WORLD_SIZE"])
t0 = time.time()
model = AutoModelForCausalLM.from_pretrained(
    sys.argv[1],
    dtype=torch.bfloat16,
    distributed_config=DistributedConfig(tp_size=world, fsdp_size=1, enable_expert_parallel=True),
)
torch.distributed.barrier()
if int(os.environ["RANK"]) == 0:
    print(f"LOAD_SECONDS {time.time() - t0:.1f}", flush=True)
"""


def run_once(worker_path, model_dir, prefetch, nproc, port):
    cmd = [
        "torchrun", "--nproc_per_node", str(nproc), "--master_port", str(port),
        worker_path, model_dir, str(prefetch),
    ]
    out = subprocess.run(cmd, capture_output=True, text=True)
    for line in out.stdout.splitlines():
        if line.startswith("LOAD_SECONDS"):
            return float(line.split()[1])
    sys.exit(f"run failed:\n{out.stdout[-2000:]}\n{out.stderr[-2000:]}")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--model", default="Qwen/Qwen3-30B-A3B")
    ap.add_argument(
        "--scratch", required=True, help="on the shared filesystem under test; room for 2*pairs copies"
    )
    ap.add_argument("--pairs", type=int, default=3)
    ap.add_argument("--prefetch-threads", type=int, default=4)
    ap.add_argument("--nproc", type=int, default=8)
    args = ap.parse_args()

    from huggingface_hub import snapshot_download

    source = snapshot_download(args.model)
    os.makedirs(args.scratch, exist_ok=True)

    # One never-read copy per run: the cold state is a property of the copy, not of an eviction trick.
    copies = []
    for i in range(2 * args.pairs):
        dst = os.path.join(args.scratch, f"copy_{i}")
        if not os.path.exists(dst):
            print(f"copying {i + 1}/{2 * args.pairs} ...", flush=True)
            shutil.copytree(source, dst, symlinks=False)
        copies.append(dst)

    with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
        f.write(WORKER)
        worker_path = f.name

    results = {0: [], args.prefetch_threads: []}
    for i, copy in enumerate(copies):
        prefetch = 0 if i % 2 == 0 else args.prefetch_threads  # interleaved arms
        seconds = run_once(worker_path, copy, prefetch, args.nproc, 29500 + i)
        results[prefetch].append(seconds)
        print(f"### cold run {i + 1}: prefetch={prefetch} load={seconds:.1f}s", flush=True)

    # Same copy twice more: the cache is warm now, so this is the cost of prefetching needlessly.
    warm = copies[-1]
    warm_off = run_once(worker_path, warm, 0, args.nproc, 29600)
    warm_on = run_once(worker_path, warm, args.prefetch_threads, args.nproc, 29601)
    os.unlink(worker_path)

    off, on = results[0], results[args.prefetch_threads]
    print("\n### RESULTS (load wall time, seconds)")
    print(f"cold, no prefetch : {[round(x, 1) for x in off]}  mean {statistics.mean(off):.1f}")
    print(f"cold, prefetch={args.prefetch_threads}  : {[round(x, 1) for x in on]}  mean {statistics.mean(on):.1f}")
    print(f"speedup           : {statistics.mean(off) / statistics.mean(on):.2f}x")
    print(f"warm, no prefetch : {warm_off:.1f}")
    print(f"warm, prefetch={args.prefetch_threads}  : {warm_on:.1f}  (overhead {warm_on - warm_off:+.1f}s)")
    print(json.dumps({"cold_off": off, "cold_on": on, "warm_off": warm_off, "warm_on": warm_on}))


if __name__ == "__main__":
    main()
python bench_prefetch_repro.py --model Qwen/Qwen3-30B-A3B --scratch /shared/fs/bench --pairs 3

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 34159898754:1
Result: success | Jobs: 16 | Tests: 185,934 | Failures: 1 | Duration: 16h 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.

4 participants