Skip to content

Add disable_mmap kwarg to from_pretrained with hf-mount auto-detection - #45547

Merged
ArthurZucker merged 3 commits into
huggingface:mainfrom
rtrompier:disable-mmap-with-hf-mount-autodetect
Apr 22, 2026
Merged

ArthurZucker merged 3 commits into
huggingface:mainfrom
rtrompier:disable-mmap-with-hf-mount-autodetect

Conversation

@rtrompier

Copy link
Copy Markdown
Contributor

What

Adds a new disable_mmap kwarg to PreTrainedModel.from_pretrained (and plumbs it through load_state_dict / LoadStateDictConfig / _load_pretrained_model). When enabled, safetensors checkpoints are read fully into memory and parsed via safetensors.torch.load(bytes) instead of being memory-mapped with safe_open.

Why

  • Loading a safetensors model from a FUSE filesystem can deadlock the Python process when mmap + kernel readahead + the parallel state-dict loader trigger many concurrent page-faults. We observed this in production on HF Spaces/Endpoints, which expose Hub repos via a FUSE mount named hf-mount: captured kernel stacks showed Python hanging on page-faults with a saturated FUSE queue.
  • Bypassing mmap (reading the file straight into RAM) side-steps the deadlock with a modest RAM cost.
  • diffusers already ships the exact same kwarg (disable_mmap in diffusers/models/model_loading_utils.py); adding it to transformers makes the two libraries consistent and unblocks users who currently hit TypeError: ...__init__() got an unexpected keyword argument 'disable_mmap' when they try to pass it through.

How

  • New _is_on_hf_mount(path) helper parses /proc/mounts on Linux and returns True if path resolves under a mountpoint whose device string is hf-mount (returns False on non-Linux).
  • load_state_dict(...) gains a disable_mmap: bool | None = None kwarg. When None, it auto-detects via _is_on_hf_mount. When truthy (and map_location != "meta"), the safetensors file is loaded with safetensors.torch.load(open(path, "rb").read()).
  • LoadStateDictConfig gets a matching disable_mmap field; from_pretrained forwards its new kwarg into it; the multi-shard safetensors branch in _load_pretrained_model mirrors the same no-mmap path per file.
  • Auto-detection is a no-op on every platform except Linux with an hf-mount FUSE mount, so there's no behavior change for existing users.

Tests

Adds DisableMmapLoadingTest in tests/utils/test_modeling_utils.py covering:

  • _is_on_hf_mount matches when /proc/mounts lists hf-mount for the path.
  • _is_on_hf_mount does not match for a regular filesystem.
  • _is_on_hf_mount short-circuits to False on non-Linux platforms.
  • load_state_dict(..., disable_mmap=True) returns tensors equal to the mmap path.

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

sounds great ty for adding

Comment thread src/transformers/modeling_utils.py Resolved
Comment thread tests/utils/test_modeling_utils.py Resolved
@ArthurZucker

Copy link
Copy Markdown
Collaborator

@bot /style

@github-actions

github-actions Bot commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Style fix bot fixed some files and pushed the changes.

@rtrompier
rtrompier marked this pull request as ready for review April 21, 2026 09:43
@ArthurZucker
ArthurZucker merged commit 8fb7c7e into huggingface:main Apr 22, 2026
27 of 29 checks passed
XciD added a commit to huggingface/hf-mount that referenced this pull request Nov 13, 2026
…ans from window plans (#246)

Fixes #234. Supersedes #241 and #254.

## Summary

Parallel page faults on one memory-mapped file ran one at a time, at
about 3 MB/s. transformers, torch and safetensors load checkpoints this
way. The per-handle `PrefetchState` mutex was held across network I/O,
and interleaved streams replaced each other in its single buffer, so
almost every fault became a new 256 KiB range download of about 50 ms.
This is the stall that made transformers turn mmap off on hf-mount
(huggingface/transformers#45547).

Many cold readers at once also wedged the mount (#234). Each read stream
held xet download buffer for data that only a FUSE worker could consume,
and the workers were blocked on streams that could not get buffer for
their first bytes. Reads timed out at offset 0 and ended in EIO.

This PR replaces `PrefetchState` with a `RemoteReader` per lazy handle
(`src/virtual_fs/remote_reader.rs`):

- **Blocks, fetched concurrently.** The file is cached in 256 KiB
blocks. Missing blocks are fetched by background tasks, each streaming
one bounded range. No lock is held across network I/O, and a read of a
block in flight waits for it instead of fetching it again.
- **Read-ahead per stream.** Up to 32 sequential streams are tracked per
handle. Each keeps a window of blocks requested ahead of its reads: 2
MiB at first (64 MiB for a read from the start of the file), growing
fourfold then twofold up to 512 MiB, and topped up in fetches that
download in parallel. A fetch close to the reader is short (1 MiB, then
as long as its distance to the reader) so that the first blocks arrive
early; fetches far ahead are 32 MiB, and no fetch crosses a multiple of
32 MiB, so that every pass over a file asks for the same ranges and a
re-read finds them in the chunk cache. A stream's read-ahead stops where
another active stream ahead of it started, also inside its last block,
so parallel copies of one tensor do not fetch each other's ranges. A
stream that starts up to 16 MiB past the furthest block read (the next
tensor, or the next record header of a torch zip) reads ahead at once;
further reads (the slices of a tensor-parallel rank) wait for a second
read, as with the old prefetch code.
- **Fetching goes on past a slow fetch, within what the link carries.**
A xet range delivers its data only when the whole range has arrived, so
fetches finish out of order. At most half of the read-ahead budget is in
flight, and each completed fetch tops its stream's window up again while
the stream reads. While a read waits for a slow fetch, the next ones
keep downloading instead of the network idling. xet downloads the terms
of the fetches in the order they were asked for, so on a slow link a
deep pipeline would make the last fetches wait past the fetch timeout: a
reader caps the read-ahead in flight that no read waits for yet (64 MiB
at first, plus the bytes of each read-ahead fetch that arrives within a
quarter of the fetch timeout, halved by each slower or failed one,
between 8 MiB and half its limit). On a fast link the cap soon reaches
half the limit.
- **Bounded memory.** A handle holds at most 512 MiB of pending or
unread blocks. Read-ahead is reserved in a budget per mount before its
fetches start (1 GiB, `--read-ahead-mb`), and each handle may hold an
even share of it. A handle that holds read-ahead or that the budget
turns away counts in the split; one whose only fetches are blocks its
reads wait for does not. A handle above its share gives fetched
read-ahead back at its next read (first what no stream heads to, then
the blocks furthest ahead). Blocks already read (for re-reads and for
streams that meet in a block; the page cache keeps the rest) are capped
at 8 MiB per handle and 256 MiB across the handles of a mount, keeping
the last block of each live stream. A handle that has no read waiting
and read no new block for 10 s gives back its read-ahead beyond its
share; after 120 s it gives back all of it and the blocks it read, and
its next read starts small windows again. So a reader that reads in
bursts keeps what its next burst needs, and a file kept open does not
keep a share. Every 256 reads, a handle also drops the unread blocks of
streams that stopped. Blocks are copied out of xet terms, so that a
cached block does not keep a whole 64 MiB term alive.
- **Fetches do not wait for readers.** A fetch copies its data into
blocks as it arrives, whether a reader waits for it or not, so no xet
download buffer stays held until a FUSE worker consumes it. This breaks
the cycle of #234.
- **FUSE reads answer from a task when they wait.** A read of data
already fetched is still served on the worker thread. A read that waits
for the network finishes in a task, so it does not hold one of the
`--max-threads` workers while other faults queue behind it.
- **xet settings from #241.** The download buffer limit goes from 256
MiB to 1 GiB, as one fast reader alone keeps up to 256 MiB in flight,
and the cap of the adaptive download concurrency goes from 64 to 124,
unless `HF_XET_FIXED_DOWNLOAD_CONCURRENCY` is set. The per-stream
download buffers of #241 are not needed: fetches are bounded and drained
as they arrive.

Retries (3 attempts in a row that deliver nothing, resuming at the first
missing block), the per-chunk stall timeout (`--read-fetch-timeout-ms`),
EOF handling, and `--direct-io` (a re-read fetches the bytes again, even
inside a block read in part) keep their behavior, with one change to the
timeout: a fetch that gets no chunk within it waits on while other
fetches of the mount receive chunks. The link serves fetches in the
order they were asked for, so on a slow link the first read of a cold
file can wait behind the read-ahead of other files: it waits in line
rather than stalls. Each stream notes the bytes the mount's other
streams still wait for when it opens, and a fetch waits as long as the
mount still receives them, and at least four timeouts. On a 3 MB/s link,
8 cold files of 256 MiB read at once finish at the speed of the link,
where a fixed allowance of four timeouts made them all end in EIO. With
nothing received on the mount, one timeout still fails the attempt. A
read that waits for several fetches fails as soon as one of them gives
up; a read whose failed fetch it only joined tries once more with
fetches of its own.

New unit tests cover parallel reads, sharing a block in flight,
interleaved streams fetching each byte once, read-ahead up to the end of
the file, forward scans, refill while a read waits, the budgets of a
handle and of a mount (with a handle the budget turns away), the
read-ahead of an idle handle (and of one whose read waits for a slow
fetch), blocks a read waits for staying at hand until it ends, the block
of a cancelled read, a read that ends after its failed block was fetched
again, a fetch task that ends early, reads of 8 and 40 blocks whose
fetches fail and stall at once, fetches spawned on a runtime that shuts
down, the eviction queue and partial re-reads with `--direct-io`,
cancellation on close, streams that meet inside a block, sparse forward
reads, bursts with pauses and a read after a long pause, refill once a
stream stops, readers with only demand fetches, the read-ahead of a
stopped stream, blocks already read split across a mount and kept at the
end of a live stream, attempts that deliver blocks, a read that joined a
failed fetch, fetch runs within cells, blocks reads wait for and the
cap, and a slow link (a mock link that downloads terms in the order they
were asked for, as xet does), for one file and for cold readers of
several files (3 files at 3 MB/s, 16 at 12 MB/s, 8 of 256 MiB at 3 MB/s,
24 at 5 MB/s, 3 at 1 MB/s), a stalled fetch that waits in line while the
mount receives data, but not forever, and a fetch behind more bytes than
four timeouts carry. `test_fuse_parallel_mmap_read` has 16 threads copy
one memory-mapped 48 MiB file through a mount with 4 worker threads and
checks the content. `test_fuse_parallel_cold_reads`, from #241, has 24
cold readers of 64 MiB files go through a mount with 4 worker threads
and a 5 s fetch timeout, and fails on any read error or fetch timeout.

## Benchmarks

m6i.2xlarge in us-east-1, `--no-disk-cache`, page cache dropped and a
fresh mount for every run.
`Qwen/Qwen2.5-1.5B-Instruct/model.safetensors` (3.09 GB) unless noted.
Throughput to CAS changed a lot during the runs (a plain sequential read
of the same file went from about 260 to 830 MB/s from one run to the
next), so the sequential rows interleave the binaries and give medians.
Only the last table includes the xet settings commit.

| Workload | main | This PR |
|---|---|---|
| safetensors, 4 threads, `natural` key order (transformers-like) | 2.7
MB/s (77 of 338 tensors in 411 s) | 195 to 224 MB/s |
| safetensors, 1 thread, file order (torch copies each tensor with 8
threads) | 5.9 MB/s (161 of 338 tensors in 302 s) | 301 to 364 MB/s |
| `torch.load(mmap=True)` then copy, 4 threads, `EleutherAI/pythia-1.4b`
(2.93 GB) | 2.0 MB/s (47 of 364 tensors in 358 s) | 68 to 82 MB/s (36 to
43 s) |
| 8 threads copying random 4 MiB ranges of one mapping | 1.8 MB/s, p50
17.9 s per copy | 157 to 210 MB/s, p50 152 to 195 ms |
| Sequential `read()`, 54 MB file (median of 3) | 110 MB/s | 166 MB/s |
| Sequential `read()`, 91 MB file (median of 3) | 149 MB/s | 275 MB/s |
| Sequential `read()`, 268 MB file (median of 3) | 285 MB/s | 290 MB/s |
| Sequential `read()`, 3.09 GB file (median of 4) | 590 MB/s | 946 MB/s
|
| Sequential mmap scan, 3.09 GB file (median of 4) | 575 MB/s | 893 MB/s
|
| Random 4 KiB page faults, p50 / p99 (3 rounds) | 62 to 63 ms / 183 to
227 ms | 63 to 67 ms / 195 to 281 ms |

The runs on main stopped at their deadline, hence the partial tensor
counts. The small files are
`google/electra-small-discriminator/pytorch_model.bin`,
`sentence-transformers/all-MiniLM-L6-v2/model.safetensors` and
`distilbert/distilbert-base-uncased/model.safetensors`.

Peak RSS of the daemon during a sequential read of the 3.09 GB file: 875
to 959 MiB with the first commits, about 720 to 830 MiB after the review
fixes (below), against 698 to 747 MiB on main (read-ahead budget of 512
MiB per handle).

Bytes read through a mount were compared with a copy downloaded directly
from the Hub, for 16 threads reading contiguous ranges of one mapping,
16 threads reading random ranges, and a safetensors load tensor by
tensor: all identical.

### Many cold readers (#234)

Shards of `Qwen/Qwen2.5-72B-Instruct` read at once from a cold mount,
each from its start, with `--max-threads 4 --read-fetch-timeout-ms 5000`
like `test_fuse_parallel_cold_reads`:

| Load | main | This PR |
|---|---|---|
| 24 files, first 64 MiB of each | 177 s, EIO on 19 files | 2.6 s (619
MB/s), no errors |
| 37 files, first 256 MiB of each | 301 s, EIO on 32 files | 15 s (661
MB/s), no errors |

In the second run with this PR, one fetch got no data for 5 s and its
retry succeeded.

### xet settings

3 rounds with the default thread count and fetch timeout, peak RSS of
the daemon in parentheses:

| Workload | Without the settings | With the settings |
|---|---|---|
| 37 cold readers, first 256 MiB of each | 978 to 1106 MB/s (1156 to
1185 MiB) | 1097 to 1216 MB/s (1172 to 1258 MiB) |
| Sequential `read()` of the 3.09 GB file | 577 to 845 MB/s (771 to 907
MiB) | 669 to 976 MB/s (832 to 895 MiB) |

For one reader of a small file, the settings make no measurable
difference. Whole-file `read()` as in the CI benchmark, medians of 5
rounds:

| File | main | This PR without the settings | This PR |
|---|---|---|---|
| `google/electra-small-discriminator/pytorch_model.bin` (54 MB) | 112
MB/s | 192 MB/s | 182 MB/s |
| `distilbert/distilbert-base-uncased/model.safetensors` (268 MB) | 308
MB/s | 350 MB/s | 353 MB/s |
| `openai-community/gpt2/model.safetensors` (548 MB) | 368 MB/s | 548
MB/s | 515 MB/s |

The CI benchmark varies more than that between runs of the same code:
three runs of its 500 MB FUSE read gave 893, 524 and 1000 MB/s.

### Review fixes

The mount budget reserved before fetches start, idle handles, and the
bookkeeping cleanups (4552bf4 to 32f3b26), against the code before them
with the same xet settings. 3 rounds, peak RSS of the daemon in
parentheses:

| Workload | Before | After |
|---|---|---|
| 37 cold readers, first 256 MiB of each | 829 to 1173 MB/s (1190 to
1246 MiB) | 1148 to 1236 MB/s (1095 to 1118 MiB) |
| 24 cold readers of 64 MiB, `--max-threads 4 --read-fetch-timeout-ms
5000` | 761 to 953 MB/s | 675 to 845 MB/s |
| Sequential `read()` of the 3.09 GB file | 794 to 889 MB/s (756 to 784
MiB) | 702 to 864 MB/s (741 to 831 MiB) |
| safetensors, 4 threads, `natural` key order (1 round) | 210 MB/s (822
MiB) | 234 MB/s (905 MiB) |

The cleanup that followed (eb85607) reads at the same speed as 32f3b26,
within the noise: over 3 rounds, 628 to 779 against 707 to 789 MB/s with
37 readers, 574 to 698 against 648 to 754 MB/s for one reader, and 189
to 225 against 189 to 219 MB/s for safetensors with 4 threads.

No fetch timed out. The same binary varies by up to 30% between rounds
here (the code before gave 599 to 965 MB/s in the 24-reader case over
two runs), so only the 37-reader case moved beyond the noise. When many
readers start at once, the first ones give back the end of their window
as the others arrive and fetch it again later.

### Second review round

PR before the fixes of the second review (506487e) against this PR,
m6i.2xlarge, 5 rounds interleaved, medians:

| Workload | Before | After |
|---|---|---|
| safetensors, 4 threads, `natural` key order | 254 MB/s | 230 MB/s |
| Sequential `read()`, 548 MB / 3.09 GB | 546 / 966 MB/s | 572 / 959
MB/s |
| fio sequential, 548 MB, FUSE / NFS | 555 / 527 MB/s | 591 / 518 MB/s |
| 37 cold readers, first 256 MiB of each | 1283 MB/s | 1250 MB/s |
| Headers of 37 shards | 3.3 s | 3.2 s |

The safetensors load is about 9% slower in every comparison of the day;
variants that restore the 64 MiB forward-scan gap, drop the cap of
read-ahead in flight or drop the cell alignment did not bring it back,
and CDN stalls during the runs (30 s without data on some fetches, for
both binaries) blurred the smaller differences.

With all traffic into the instance shaped to 3 MB/s (`tc`), reading 400
MiB of the 3.09 GB file: main read about 3 MiB in 15 minutes (its 128
MiB prefetch windows got no data within 30 s, EIO every 3 minutes); the
PR before these fixes had 143 fetch timeouts and an EIO at 165 MiB; this
PR reads it in 136 s (3.08 MB/s, the link rate), with no timeout, no
error and 275 MiB of peak RSS.


## Window plans (`--window-plans`, off by default)

Depends on huggingface/xet-core#1064 (`Client::get_chunk_layout`):
xet-core is pinned to that branch (`1d4b8487`, on xet-core main after
v1.8.1) for now, to repin on xet-core main once it merges.

xet asks CAS for the plan of each read-ahead fetch in three pieces (8,
16 and 8 MiB), so the remote reader above makes about 96 reconstruction
requests per GiB read, and a memory-mapped load with 16 threads sends
200 to 250 per second, above the rate of the CAS WAF limit (13,000
requests per minute per IP).

With `--window-plans on`, hf-mount asks for the plan and the chunk
layout (`GET /v2/chunk-layout`, huggingface-internal/xetcas#1414) of an
aligned 256 MiB window, and answers every plan request inside it
locally. The first and last terms are trimmed on chunk boundaries with
the uncompressed sizes. Each term is fetched as a sub-range of the
window's signed ranges, located with the serialized sizes. The result is
the plan CAS would return for the range, so the downloads do not change.

- Windows are asked in the background, so no read waits for one. A
reader that starts at the beginning of the file, or comes from the
previous window, asks its window at once with the next 4 (1 GiB ahead):
a window takes about 0.3 s plus 1 s per GiB to arrive, and a fast reader
goes through a window in less than that.
- Elsewhere, a window is asked on the second plan request that falls in
it, so a lone random read never pulls one.
- Reads through the mount give the file size: no window is asked past
the end of a file, and files under 256 MiB keep exact plans, since they
are read before a window arrives.
- Plans stay exact when CAS does not serve chunk layouts (a 404 turns
the feature off for the mount), for direct S3 URLs (they sign the exact
range), and for a window that failed (asked again after a minute).
- At most 128 windows are kept, by last use, and a window is replaced
after 50 minutes from its plan request, before its signed URLs expire. A
window in flight keeps its slot until its fetch ends, so at most 128
window fetches run at once, and none of them twice. A window used in the
last 30 seconds is not dropped for another one: with more large files
read at once than the cache holds (37 files at 32 MB/s), a request
elsewhere keeps its exact plan, instead of dropping windows in use that
are then fetched again (9,216 window fetches for 592 windows before, 394
now).
- `--window-plans check` derives the plans, compares them with the plans
CAS returns (terms, offset, fetched chunks, and byte ranges against the
layout), logs any difference, and uses the CAS plans. This is the first
rollout step. Production CAS now serves `/v2/chunk-layout`: a `check`
mount there read a 3.1 GB file and 16 threads of random mmap reads with
282 derived plans, all equal to the CAS plans.

The option is off by default.

RFC: https://app.notion.com/p/3f01384ebcac81d384e0cca29335116d

xet-core#1064 also builds reqwest with gzip, so every CAS request asks
for compressed responses. CAS already compresses its read responses: a
256 MiB chunk layout goes from 54 to 25 KB and a 1 GiB plan from 180 to
26 KB, so windows arrive 70 to 350 ms sooner from Paris.

### Window plan results

Cold mounts with `--no-disk-cache` against production CAS, variants
interleaved, medians of 3 rounds. 1,482 mounts in all, with no data
difference between variants, and no plan difference in `check` mode
(5,416 derived plans compared on the final build).

Paris client (88 ms to CAS), no windows / with windows:

| Load | Time | CAS requests |
|---|---|---|
| 3.1 GB file, sequential | 3.9 / 3.2 s | 281 / 59 |
| safetensors mmap, 16 threads | 6.8 / 3.7 s | 1,367 / 116 |
| 2,000 random 4 KiB reads | 16.3 / 2.1 s | 1,854 / 113 |
| 8 shards of 3.9 GB in parallel | 20.0 / 15.4 s | 2,677 / 348 |
| 19.9 GB file, sequential | 23.0 / 15.4 s | 1,779 / 212 |

The windows were faster in 40 of 42 paired rounds in Paris and 30 of 42
in us-east-1 (2 ms to CAS, a noisier network), where random reads are
2.6 times faster and CAS requests 3 to 35 times fewer. Window sizes from
128 MiB to 1 GiB with 1 to 4 windows ahead, xet-core reconstruction and
concurrency settings, fetch size, read-ahead and buffer sizes were also
compared: the current hf-mount settings stay the best.

### Window plan notes for review

- The window plan is asked through the raw CAS client, not through the
plan cache, so window plans do not fill the exact-plan cache.
- The chunk layout is asked through the xet-core client, so it shares
the mount's CAS auth, retries and user agent. A 404 means the CAS does
not serve the endpoint.
- Peak RSS rises by 250 to 750 MB on fast reads, from the extra bytes in
flight; the windows themselves weigh a few MB.
- Tests build plans the way CAS builds them for a deduplicated file and
check that the derived plans are equal for about a hundred ranges, that
`check` reports a wrong byte range, the fetch, 404 and direct S3 paths,
the windows asked ahead, that small files and files of unknown size keep
exact plans, and that a CAS that never answers chunk layouts gets at
most 128 window fetches, none of them twice.

## Notes

- A sequential read makes about three CAS reconstruction queries per 32
MiB fetch (304 for the 3.09 GB file): xet splits each fetch in 8 and 16
MiB reconstruction blocks
(`HF_XET_RECONSTRUCTION_MIN_RECONSTRUCTION_FETCH_SIZE`, unchanged) that
it downloads in parallel and delivers in turn. The single stream of main
made a few queries for the whole file. With one 32 MiB block per fetch,
`torch.load(mmap=True)` opened faster (12 to 14 s instead of 23 to 30
s), but small files read 40% slower. Fetches now fall on the same 32 MiB
cells on every pass, so the plan cache serves a re-read within the hour;
a first read can only make fewer queries once `CachedXetClient` can
derive tight range plans from a full plan, which needs chunk sizes in
the terms of xet-core (`derive_range_response`).
- #228 moves every FUSE operation to a task. This PR only moves the
reads that wait for the network: a read of data already fetched is
answered on the worker thread, without a task handoff for each 128 KiB
request. Both change the same `read` handler.
- transformers still reads safetensors fully into memory on hf-mount
(`_is_on_hf_mount`). Its first forward pass over mmap-backed weights
touches them one parameter at a time, in module order, not file order,
so each parameter costs at least one round trip: about 55 s for this
model, against about 8 s for the full read. Dropping that workaround
needs more than this PR (for example, filling the page cache ahead of
the reads).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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