Skip to content

hf: a large file downloads over up to 16 ranges at once, each resumed where it stopped - #1037

Merged
juliankang4 merged 1 commit into
midagedev:mainfrom
juliankang4:dgx-spark-q38-usab-parallel-download
Oct 31, 2026
Merged

juliankang4 merged 1 commit into
midagedev:mainfrom
juliankang4:dgx-spark-q38-usab-parallel-download

Conversation

@juliankang4

Copy link
Copy Markdown
Collaborator

--hf가 큰 모델 파일을 최대 16개 range로 동시에 받고, range마다 끊긴 곳에서 이어 받으며, sha256은 받는 동안 읽어 끝난 뒤 파일을 다시 읽지 않게 합니다.

Part of #565.

What and why

--hf downloaded each file over one connection and read the whole file again for the sha256 check after the last byte. A multi-GB GGUF used one connection's speed, and a cut download lost its place in the file.

Now a file of 64 MiB or more is cut into up to 16 ranges, each fetched by its own curl with --continue-at. A connection that finishes its range takes the back half of the widest remaining range, so no connection idles while others work. A saved record beside the part file keeps each range's position; a restarted download resumes every range where it stopped, and the record is discarded and the file fetched from the start when it does not match the listing or the part file. A cut connection, a 429 or a 5xx asks again for that range from its received byte, waiting the hub's Retry-After (at most 300 s) or, without one, 1 s after a try that got bytes and 2, 4, 8, 16 s after tries that got none; the fifth try in a row with no bytes fails. Any other failure (404, 403, a disk error) stops every range. The sha256 is computed from the part file in order while the ranges arrive, so the check ends with the last byte. A part file with no record is still read as the front of the file, so a part left by an earlier version resumes.

  • crates/hf/src/fetch/ranges.rs (new): the range split, the per-range resume and retry, the record file and the in-order hash.
  • crates/hf/src/fetch.rs: the one-connection download is replaced by the ranges; curl assembly, the header file name and the hasher (Sha256, GitSha1) are each one shared function. The record and the temporary file for marker writes end in .lock.lock, a name lock_file already keeps out of any listed file name, so a listed file can never be read as another file's record.
  • docs/serving.md: the --hf description matches the new behavior.

Release notes: Changed: --hf downloads a large file over up to 16 connections, resumes each where it stopped and checks the sha256 while downloading, so a large GGUF downloads in about half the time.

Code shape

  • No second copy. Download, retry and hash logic has one owner, crates/hf/src/fetch/ranges.rs; the hash code that digest used for whole files is the one the streaming hash uses.
  • Model code holds only architecture facts. The change is in the shared hf crate; no model file gained a branch.
  • Dead code is gone. The old one-connection download and its whole-file re-read are removed.
  • No silent failure. A record that does not match, a failed retry budget and a failed disk write each end in a named error or a restart from byte 0.
  • Comments state what is true now. The comments describe the code as it is.
  • No relaxed gate. No band, tolerance or lint level moved.
  • cargo fmt and cargo clippy are clean on what I touched; the lint count did not go up.

Duplication found or removed (path:line): none. The duplicate scan reported no candidates.

What I ran

Base 6a9680ae320f0cfdb7a262376b825958a2225122, head b1e7fd9283fb6c227adca52216cbc2c908e3ace1.

The host gate for the hf crate (just gate-hf):

test fetch::ranges::tests::a_large_file_is_fetched_over_ranges_at_once ... ok
test fetch::ranges::tests::each_range_resumes_where_it_stopped_and_another_files_ranges_are_stale ... ok
test fetch::ranges::tests::a_busy_or_cut_range_is_asked_again_from_its_byte_after_retry_after ... ok
test fetch::ranges::tests::an_idle_worker_takes_an_open_range_then_the_back_half_of_the_widest ... ok
test fetch::tests::listed_files_named_like_another_files_records_keep_their_bytes ... ok
test result: ok. 159 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 3.06s
gate-hf rc=0

The five tests cover: four ranges start at once; each range resumes from its saved position and a record of another file is discarded; a 503 with Retry-After: 2 and a body cut in half are asked again from the received byte; an idle connection takes the back half of the widest range; and listed files named like each other's records keep their own bytes.

Download time, base against head, on an RTX 5090 machine, with the real bloomery-serve built from each commit (--features glm5next,clef,vision,mimo2,solar2,minimaxm3,flint,gemma-embedding2,hf,checkpoint, sm_120a). The base binary is the head tree with crates/hf/src/fetch.rs restored to the base. Each run fetches ggml-org/lev-GGUF lev-Q8_0.gguf (4,482,405,312 B) into a new cache, alternating base and head three times each (base, head, head, base, base, head):

BLOOMERY_CACHE=<new cache> bloomery-serve --hf ggml-org/lev-GGUF:Q8_0 --host 127.0.0.1 --port 18761
check base0_fetched_whole: PASS lev-Q8_0.gguf 4482405312 B: hf file to hf done 41.1 s (109.1 MB/s)
check head0_fetched_whole: PASS lev-Q8_0.gguf 4482405312 B: hf file to hf done 22.0 s (203.5 MB/s)
check head1_fetched_whole: PASS lev-Q8_0.gguf 4482405312 B: hf file to hf done 20.3 s (220.5 MB/s)
check base1_fetched_whole: PASS lev-Q8_0.gguf 4482405312 B: hf file to hf done 35.7 s (125.7 MB/s)
check base2_fetched_whole: PASS lev-Q8_0.gguf 4482405312 B: hf file to hf done 36.1 s (124.3 MB/s)
check head2_fetched_whole: PASS lev-Q8_0.gguf 4482405312 B: hf file to hf done 19.5 s (229.6 MB/s)
check one_digest: PASS fb8779adec0185b423c768afce7fc07f49f7446aec5bbeca74da2a29d086ae7f
base: 41.1 35.7 36.1 s; min 35.7 max 41.1 median 36.1
head: 22.0 20.3 19.5 s; min 19.5 max 22.0 median 20.3
ranges overlap: False
Metric Base Head Difference Ranges overlap
Download time, hf file to hf done (median of 3) 36.1 s 20.3 s -44 % no (base 35.7 to 41.1 s, head 19.5 to 22.0 s)

The time runs from the hf file line to the hf done line and includes the sha256 check. Both arms produce the same digest.

Resume after a kill, same machine and binary: the head server was killed at its first progress line and started again with the same command; it asked only for what the saved ranges lacked, answered /health, and answered a /v1/systemone request from docs/serving.md:

resume hf file file=lev-Q8_0.gguf bytes=4482405312 state=resume from=937381888
resume hf done file=lev-Q8_0.gguf fetched_bytes=3545023424 check=sha256 digest=fb8779adec0185b423c768afce7fc07f49f7446aec5bbeca74da2a29d086ae7f
resume: /health 200 after 37.0 s
check resume_keeps_what_the_cut_held: PASS resume from=937381888 fetched=3545023424
check resume_same_digest: PASS fb8779adec0185b423c768afce7fc07f49f7446aec5bbeca74da2a29d086ae7f
check no_part_left: PASS
check seat_answers: PASS
RESULT PASS

The resumed download fetched 3,545,023,424 B, which is the file size minus the 937,381,888 B already held, and ended on the same digest. No part or record file was left.

AI help

AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.

… where it stopped

A file was one curl transfer with --continue-at -, so a large shard came
at one connection's rate, and its sha256 was read from disk again after
the last byte landed.

A fetch now cuts the file into ranges of at least 64 MiB, at most 16, and
fills <file>.part with one curl transfer per range, each writing at its
own offset; a transfer whose range is full takes the second half of the
range with the most bytes left. <file>.part.ranges names what each range
holds under the listing's digest and size, so an interrupted fetch resumes
every range where it stopped; a sidecar of another digest or size, or one
that does not tile the file, is stale. A part with no sidecar is still the
file's first bytes.

A transfer cut by the network, stalled for 60 s, or answered 429 or 5xx is
asked again from the byte it reached, after the response's Retry-After
seconds (at most 300) or a 1, 2, 4, 8, 16 s backoff, until five attempts
in a row bring no byte.

The digest is hashed on the calling thread as the bytes from the file's
start complete, from the page cache the transfers wrote, so no second
read of the file follows the download.
@github-actions github-actions Bot added documentation Improvements or additions to documentation model The CPU engine and model files: crates/model, models, hf, gguf, tokenizer, jinja labels Oct 31, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: CLEAN: No blocking defect found in the changed code.

Non-blocking

⚠ Refresh the verification evidence: crates/hf/src/fetch/ranges.rs:393. The supplied runs name base 6a9680a and head b1e7fd9; this review covers 5658c58 and 192d54b.
Attach just gate-hf output for the reviewed head and FAIL-first evidence for the new contracts. Add the nvidia-smi card and driver line to the download-speed evidence required by CONTRIBUTING.md.

Checked

Duplicates: curl setup and digest state have shared owners in fetch.rs; range downloads use them.

Complexity: changed paths handle loading; no decode, prefill or kernel path changes.

Silent failure: invalid range records trigger refetch; transfer and storage failures reach the caller; cleanup failures do not publish unchecked bytes.

Tests: static mutation checks for all eight new and three adjusted tests. In ranges.rs: retain the old end at 247 → scheduler tiling assertion fails; force one worker at 420 → parallel fetch fails; omit the initial record at 427 → kill test fails; remove the failure guard at 575 → peer-position assertion fails; omit resume at 548 → resume test fails; keep the idle counter after progress at 621 → retry assertion fails. In fetch.rs: restore suffix temporaries at 1001 → collision assertion fails; use the full basename at 958 → long-name fetch fails; allow .lock at 1011 → reserved-name assertion fails; publish the marker before the rename at 545 → publication-order assertion fails; overwrite in place at 1002 → old-inode assertion fails. Tests were not executed.

Gates: no existing band, tolerance, lint level or test contract removed or relaxed.

Escape catalog: C1–C4 do not match. Bounds, concurrent splits, ordered hashing, empty files, stale records and disk-error publication paths checked.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

Filed outside this PR: #1038

@juliankang4

Copy link
Copy Markdown
Collaborator Author

The review is done and the contribution check passed, so I'm merging this.

@juliankang4
juliankang4 merged commit 0586b4e into midagedev:main Oct 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation model The CPU engine and model files: crates/model, models, hf, gguf, tokenizer, jinja

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant