Download large model shards as parallel HTTP Range requests#1960
Download large model shards as parallel HTTP Range requests#1960jjang-ai wants to merge 1 commit into
Conversation
A single-shard repo (one 4.6 GB model.safetensors) transferred over exactly
one connection, and per-connection throughput to the Hugging Face CDN caps
well below most links. maxConcurrentFiles only parallelizes across files, so
those repos got no benefit from it at all.
Large LFS objects now stream as concurrent Range requests into a pre-allocated
.part file. Measured end-to-end on a 4.6 GB shard: ~50s -> ~26s.
Three details drive the design:
- URLSession multiplexes concurrent HTTP/2 requests to one host onto a
single TCP connection, so sharing a session across chunks collapses them
back onto one connection. Each lane owns its own session; raising
httpMaximumConnectionsPerHost does not substitute for this.
- A session per chunk is just as bad -- every chunk then pays a fresh TLS
handshake, redirect, and TCP slow-start ramp, which measured 1.20x versus
1.9x for reused connections. So a fixed set of lanes drain a shared chunk
queue.
- Lanes are capped app-wide by LaneBudget. Without it, three concurrent
files would open 24 sockets and just add queueing delay on a thin link.
Also fixes two long-standing behaviors on this path:
- A failed retry restarted the file from byte zero, and resume data never
survived a relaunch. Durable chunks are now recorded in a .part.json
manifest, so an interrupted 4.6 GB transfer resumes instead of restarting.
- Integrity was checked by file size alone. Chunks are pinned to the
resolved commit SHA so a push mid-download cannot splice revisions, and
the assembled file is verified against the sha256 Hugging Face already
publishes in x-linked-etag.
Small files and any server that does not advertise Range fall back to the
existing single-task path unchanged.
Live transfer tests are gated behind RUN_LIVE_HF_DOWNLOAD=1: chunked range
semantics are exactly what a mock would get wrong.
|
1. HF token leaks to CDN hosts (blocker)
The fix should be small: only attach the token when the request host is 2. Chunking silently never activates for onboarding downloads Onboarding model downloads now go through the model download proxy (merged recently), The proxy's resolve response already returns Minor, non-blocking
|
|
Ran this live before reading anything else, because chunked range semantics are exactly the thing a desk review would get wrong. Both arms compiled verbatim from their branches into a minimal harness (
Integrity: A1, B1, and a resumed PR run all hash to Two caveats on the table before the questions. First, my link tops out around 8–19 MB/s — far below the per-connection cap your lane curve targets — so the throughput claim is simply not testable from here; 7.0 vs 8.8 MB/s is one run each on a noisy link, not a regression signal. Second, what the link's flakiness did surface is the thing the manifest buys: main hard-failed 2 of 3 full-file attempts (one sat at 0 bytes for 928 s before Questions, in the order I'd care about them:
The integrity core held up under everything I threw at it: short-206 refusal, 200-refusal, commit pinning, manifest invalidation on etag/commit change, sync-before-record, and a bit-exact file assembled across a process kill and a machine sleep. #1 and #2 are the only things I'd want resolved before merge. |
Problem
A single-shard repo — e.g.
mlx-community/Qwen3-8B-4bit, one 4.6 GBmodel.safetensors— downloads over exactly one connection. Per-connection throughput to the Hugging Face CDN caps well below most links, so no amount of waiting makes it faster.maxConcurrentFiles = 3only parallelizes across files, so single-shard repos get nothing from it.Two failure modes compound it, and these hit every repo:
resumeDataForAttempt = nilrestarts the file from byte zero. Resume data is in-memory only, so quitting the app throws away the partial file. A 4.6 GB download that dies at 90% starts over, four times, then fails.Change
Large LFS objects stream as concurrent HTTP Range requests into a pre-allocated
.partfile. Small files and any server that doesn't advertiseRangefall back to the existing single-task path unchanged.Three measured details drive the design:
URLSessionmultiplexes concurrent HTTP/2 requests to one host onto a single TCP connection. Sharing a session across chunks collapses them back onto one connection and wins nothing (81 vs 112 MB/s). Each lane owns its own session. RaisinghttpMaximumConnectionsPerHostdoes not substitute for this.LaneBudget. Without it, 3 concurrent files × 8 lanes = 24 sockets, which on a thin link just compete for the same bottleneck and add queueing delay. Reservation never blocks, so a small file can't starve behind a large one.Also folded in, because chunking makes them nearly free:
.part.jsonmanifest, so an interrupted transfer resumes instead of restarting — across a failed retry and across an app relaunch. A dead chunk now costs 32 MB, not 4.6 GB.x-repo-commit), so a push mid-download can't splice two revisions together — a corruption the size-only check would never catch. The assembled file is verified against the sha256 Hugging Face already publishes inx-linked-etag.Measurements
Lane count, end-to-end on the 4.6 GB shard:
Eight buys ~90% of the achievable gain at half the sockets of sixteen; past twelve is inside the noise. On the 4.6 GB shard that's ~50s → ~26s, sha256-verified.
Caveat, stated plainly: the single-connection cap is congestion-dependent, not a fixed ceiling — the legacy path measured anywhere from 34 to 97 MB/s across runs on the same machine. The direction of the win is consistent in every rep, but its magnitude varies with conditions. Users who are genuinely link-bound (rather than connection-bound) will see little throughput change; for them the win is the resume behavior.
All numbers are from one machine on one link.
Testing
swift test --filter ChunkedFileDownloaderTests— 14 tests. Unit coverage for commit pinning, the sha256 gate, chunkability, and the lane budget.RUN_LIVE_HF_DOWNLOAD=1, because chunked range semantics are exactly what a mock would get wrong. They assert that parallel chunks reassemble to Hugging Face's published sha256, that an interrupted transfer resumes from non-zero bytes, and that pause surfaces asPauseInforather than a failure — tearing down the lanes' sessions makes the in-flight chunk throwURLError.cancelled, which without classification would have marked the model.failed.ModelDownloadRetryPolicyTests,ModelDownloadServiceStorageTests,HuggingFaceDownloadPathTestspass unchanged.OsaurusCoresuite: 4,986 passed. The 5 failures (tool-exposure ×2, chat-session-store flush, gemma4 tokenizer, path resolver) reproduce on cleanmainat this same commit and are order-dependent / pre-existing; none touch the download path.Scope
PrivacyFilterModelDownloaderandImageModelDownloadServicehave their own downloaders and are untouched. The privacy-filter one is still strictly serial.Follow-up (not in this PR)
Authenticated requests 302 to
cas-bridge.xethub.hf.co(Xet) instead ofus.aws.cdn.hf.co(CloudFront), and measured ~half the single-connection throughput in 3/3 reps (37.9 vs 68.7 MB/s median). Users who add an HF token may be getting slower downloads on the current single-connection path. Chunking largely washes the difference out, so it's out of scope here, but it's worth re-testing from another network and filing separately.