Studio: price a partial GGUF by what is left to fetch - #8989
Conversation
The size beside a partial variant was the variant total, so continuing a sharded download that was already 40 GB in still read "56 GB" and looked like the whole model coming down again. That is the reading behind the reports of the hub re-downloading models people already have. The variants endpoint now reports download_remaining_bytes for a partial: the plan total minus the bytes on disk a transfer can actually reuse. Reuse is per file, so a finished shard counts and an unresumable partial does not, and the card shows "16 GB left" instead of "56 GB". A one-file quant therefore still reads back its full size, because that is what continuing it costs: huggingface_hub 1.18+ refetches an interrupted file from zero, and a single-file quant has no other file to keep.
for more information, see https://pre-commit.ci
The local and offline listings return before the hub-plan path, so their partial rows carried no remaining figure and fell back to the full total. The on-device card asks for exactly those (preferLocalCache), which is where a partial is most likely to be looked at. The worker writes its manifest before fetching anything, so those rows can be priced from the file list that produced them. Capped at the row's own total, since a manifest counts companions the row's size may not. The on-device card also merges local rows over remote ones, so it now carries the remaining figure through instead of dropping it.
A local listing sizes a variant by summing the shards it can see, so an early interruption makes that total smaller than the transfer: three 2 GB shards with one cached advertises 2 GB, and capping the remainder by it reported "2 GB left" when 4 GB had to be fetched. Under-reporting is the one direction this figure must never be wrong in. The cap was there to stop a companion-inclusive remainder reading larger than the row's own size, but no surface shows the two together: a partial row's size chip IS the remainder. So the cap is dropped and the manifest total stands.
for more information, see https://pre-commit.ci
2c7cb96 to
9cdc090
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9cdc090121
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| except Exception as e: | ||
| logger.warning(f"Remaining-bytes lookup failed for {repo_id}: {e}") | ||
| return None | ||
| return max(0, requirement.download_size_bytes - have) |
There was a problem hiding this comment.
Deduplicate blob sizes before computing the remainder
When two expected target filenames reference the same content hash, requirement.download_size_bytes counts that blob once per filename while existing_blob_bytes() counts it only once per hash. Such aliases therefore report one blob still left even when the shared blob is already cached, and report twice the actual transfer when it is absent. The download worker's _preflight_disk_space() explicitly deduplicates expected sizes by hash for this case; compute the total here the same way before subtracting have.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The asymmetry is real (download_size_bytes sums per file, existing_blob_bytes credits per hash) but the premise is not reachable. I probed 750 real GGUF repos with model_info(files_metadata=True): zero plans contain a duplicate hash. 136 of 750 do ship duplicate LFS oids, but every one is cross-variant, so they land in different plans; the single same-variant case (unsloth/QwQ-32B-GGUF) is already dropped by _one_shard_family, and a dflash- file cannot self-pair because _DRAFTER_KINDS excludes it from main. Not fixing an unreachable case. Separately, the remainder cannot go negative (max(0, ...)) or exceed the planned total.
…across case-variant repo dirs
for more information, see https://pre-commit.ci
Review summaryVerdict: useful, merge it - with the Before / afterBefore, a partial row printed the variant's full size beside its Resume button: Real issue, and correctly scoped. The reuse model it encodes - whole files are kept, a nonce-suffixed Does it break anythingNo. The new field is additive and optional, and no decision path consumes the remainder: fit classification still reads Old frontend against new backend: there is no zod anywhere in the frontend and Two bugs found and fixed (315e77b)Both live in 1. A sparse partial was credited by its logical size. 2. One blob was counted once per repo directory. Both fixes are strictly more conservative, so the other two consumers only get safer, and On the dedup itemI did not take the staged Simulation151 assertions across 6 offline groups plus 7 network groups that download real GGUF shards and SIGKILL them mid-flight - real cache trees on disk, not mocks. 147 pass; the 4 failures produced the two bugs above. Cross-platform: the accounting reads Two things for the author
Smaller: a live download's chip does not count down (it flips CIThe single red check is an unrelated flake: |
UI evidenceTwo isolated Studio installs, each built from its own checkout with
Same scene on both sides: purge What moved:
What did not move, and is the control that makes the pair readable: every other row in the same list is byte for byte the same ( Not covered by this pair:
Useful: the number on the resume button now says what the resume costs, and the pair shows it changing only for the partial row. |
The live overlay carried only the expected size, so a row that says N left kept whatever the one-time variant fetch had measured, or the full total for a download started after it. A download 90 percent through read as though nothing had moved. The running job already carries its own progress, so derive the remainder from it and keep the fetched figure for every other row.
snapshot_progress nets completed_baseline_bytes out of expected_bytes and downloaded_bytes alike, so the job's two counters are consistent with each other and not with the catalog totals. Subtracting the job's transfer from the larger of the two scopes added that baseline straight back: 1 GB reused and 1 GB fetched of a 5 GB plan read 4 GB left rather than 3 GB. The catalog total still drives the size the row reports.
A row can name a snapshot in a previous, legacy or default HF cache root, and the request already scopes partial detection and the manifest read to it, but the blob scan always read the active root. Wrong in both directions: shards already in the pinned root earned no credit, and a copy of the same blob in the active root earned credit a resume into the pinned root cannot use, which reported less left than there is. The docstring called counting the active root only the safe direction to be wrong in, and it is for the first half, but not for the second. The fixture stub also had to start honouring an explicit root; ignoring it would have answered the active root for a pinned row and hidden the behaviour the new tests are about.
for more information, see https://pre-commit.ci
|
@codex review |
|
@codex review |
An XET run that falls back to HTTP re-claims in the same generation with a completed_baseline_bytes recomputed from disk, so the baseline now covers every blob the XET attempt finalized and the new run reports completed_bytes 0 against a shrunken total. resolveProgressUpdate holds the previous reading through that zero, so the card kept the dead run's finalized bytes while its total moved to the retry's scope, and taking the max of the two counters subtracted 3 GB from a 0.5 GB remainder: the row read 0 B left with the transfer barely started. snapshot_progress builds downloaded_bytes as completed plus in-flight and nets the same baseline out of both, so a single reading never has completed above downloaded and the max could only ever fire on a held figure.
resolveProgressUpdate holds the previous downloadedBytes through a poll that reported zero, and the GGUF row forwarded that held figure as the current transfer. An XET run that falls back to HTTP re-claims in the same generation with completed_baseline_bytes recomputed from the finalized blobs on disk, and snapshot_progress nets that baseline out of downloaded_bytes, completed_bytes and the total alike, so the retry's first reading is a legitimate 0 against a shrunken total. Subtracting the held 3 GB from the 0.5 GB that remained read "0 B left" until the retry moved its first byte. Carry the distinction out of resolveProgressUpdate as measuredTransfer and skip the progress-derived remainder on a held reading, falling back to the backend's own download_remaining_bytes the way the terminal row already does.
measuredTransfer marks a reading the poll did not measure, so the remainder is not derived from a stale downloadedBytes. It was not persisted, so a reload restored the stale bytes beside the new run's shrunken expectedBytes with the flag reading undefined, which the selector treats as measured. That is the '0 B left' the guard exists to stop, and on app start it lasts until the first poll returns rather than a single tick. Persist and restore the flag. Absent stays absent, so a record written before this field still means never polled rather than held.
A restored fallback job is adopted by the next running-status probe, which seeds the persisted byte counters but rebuilt the job without the marker. That restored it as undefined, which reads as measured, so the remainder was derived from the dead run's bytes again and the row read 0 B left. The marker is a statement about those counters, so it travels with them: seededMeasuredTransfer joins carriesOverSeed in adopt-rules, gated on the same condition. Not carrying the seed zeroes the counters, so there is no held figure left and undefined stays the honest answer.
blob_bytes_present read a missing st_blocks and a present zero as the same thing, so a partial set to its final length before its first chunk landed fell through to st_size and was credited whole -- the row read 0 B left on a download that had transferred nothing. Confirm the emptiness with SEEK_DATA rather than inferring it, so a mount that never populates st_blocks keeps the size fallback.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 01bc342eb8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Undefined is a job that has not polled yet, which has no held | ||
| // figure to forward; only an explicit false is a held reading. | ||
| measuredTransfer: job.measuredTransfer !== false, |
There was a problem hiding this comment.
Handle legacy persisted counters as unmeasured
When an active download is restored from persistence written by the previous frontend, measuredTransfer is always undefined even if that job had already polled; treating every undefined value as measured therefore misclassifies a held nonzero counter. In particular, a reload or upgrade during an XET-to-HTTP reclaim can restore the old run's downloaded bytes beside the retry's smaller total and derive 0 B left, recreating the issue this marker is intended to prevent until a positive poll arrives. Treat an absent marker conservatively when a legacy record already carries nonzero counters, or migrate/version the persisted state.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Right, and this overturns a call I made two rounds ago. I wrote that comment as "absent stays absent, so a record written before this field still means never polled", on the reasoning that an older job has no held figure to distrust. That reasoning was wrong: an older job can absolutely be holding one, it just has no way to say so.
Fixed in e95d89a, taking the migrate/version route rather than the blanket one. The two remedies are not equivalent here. Reading every absent marker beside nonzero counters as held would also catch current records, where an absence is a real statement ("this job has not polled yet"), and would suppress the tightened remainder for a poll on jobs that never had a problem. So PERSIST_VERSION goes to 2 and migrate passes a legacy flag down to sanitizePersistedJob; only a record restored from below version 2 with downloadedBytes > 0 is read as held. Anything written from this build onward keeps absent meaning never polled.
Downgrade is unaffected: an older build reading a version-2 record runs its own migrate and ignores the field it does not know about.
Tests in studio/frontend/tests/download-legacy-measured-migration.test.ts: a legacy record carrying counters restores as held, a legacy record with nothing counted stays unknown. Mutation-checked in three directions, each of which fails a test: dropping the new branch, forcing the version gate false, and reverting PERSIST_VERSION to 1. Worth noting that a fourth mutation (passing legacy = true unconditionally) fails nothing, because migrate only runs on a version mismatch in the first place, so there is no current-version path through it to catch.
tests/download-transport-persistence.test.ts was already writing version 1, so it now exercises the migration incidentally and still restores transports intact. 77 tests pass across the nine download suites, typecheck clean.
|
@codex review |
|
Codex Review: Didn't find any major issues. Breezy! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
A record written before measuredTransfer existed cannot say whether its byte counters were measured, so an absent marker there is not the never-polled it means in a current record. An upgrade landing mid-reclaim restored the dead run's held bytes with the guard reading measured, which is the 0 B left the marker exists to stop. Version the persisted state so the migration can tell the two absences apart, and read only the pre-marker one conservatively.
|
@codex review |
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ed0b21985
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Preserve the backend-adjusted total in the live remainder
When the backend subtracts a reusable companion baseline, live.expectedBytes is not guaranteed to retain that adjusted total: the effect in gguf-download-card.tsx lines 694-705 raises the job back to the catalog's full download_size_bytes after every poll. A 5 GB plan with a reused 1 GB companion and 1 GB transferred therefore reaches this calculation as 5 GB - 1 GB and displays 4 GB left rather than the actual 3 GB. Fresh evidence beyond the earlier zero-progress case is that this happens after a positive measured poll, because the existing effect repeatedly overwrites the backend-owned total before this subtraction.
Useful? React with 👍 / 👎.


Follow-up to #8927, and to the reports that the Model hub re-downloads models people already have.
What people are seeing
Two different things get reported as one bug, and only one of them is real.
A model that finished downloading is never fetched again.
huggingface_hubchecks the blob path before it makes any request and symlinks the cached blob into the snapshot when the etag matches, and nothing in the hub download path passesforce_download.An interrupted file is a different story. Since 1.18 the writer is a process-unique
<etag>.<uuid>.incompleteopened"wb"and unlinked in afinally(huggingface/huggingface_hub#4228), so there is no ranged resume and the partial is not even kept. We pinhuggingface-hub>=1.23.0on Python 3.10+, so on a current install nothing resumes inside a file. Reuse is whole-file only:snapshot_downloadskips shards that are already materialized.So the honest cost of continuing a partial is "every file that did not finish", and the card was not showing that. It printed the variant total, which reads as "all of this is about to download again" no matter how much was already there.
The change
The variants endpoint now reports
download_remaining_byteson a partial variant: the plan total minusexisting_blob_bytes, which already applies exactly the right rule (finalized blobs count, an unresumable partial does not, a blob a live peer holds the lock on does).The card shows that number with a "left" suffix on partial rows only:
The second row is not a bug. A single-file quant has no other file to keep, so continuing it really does transfer all 18 GB, and the number now says so instead of leaving people to find out at the end. An unmeasured partial falls back to the total rather than guessing lower.
Cost is bounded: the scan runs only for variants already known to be partial, which is normally at most one per repo.
Tests
studio/backend/tests/test_partial_remaining_bytes.py, new: a finished shard is subtracted, an unresumable partial is not, a one-file quant prices whole, an unresolvable plan reports null rather than guessing.studio/frontend/tests/gguf-variant-transfer-size.test.ts, new: covers the label including the "left" suffix and the fallback.npm run typecheck,npm run build, the new suites andtest_gguf_variant_rows.pypass. Pre-existing on a clean checkout, not from this branch:tests/delete-chat-files-preference.test.ts(importschat/utils/pasted-text, absent from the tree),test_the_sweep_will_not_cross_a_case_variant_directory(needs a case-sensitive filesystem), and a handful oftest_gguf_variant_rows.pycases that only fail when that module shares a process with other suites.I have not driven this in a live UI: reproducing it means interrupting a real multi-shard download.
Not in this PR
Independent of #8982, which fixes the tooltip and the button label on the same rows. Either can land first.
The deeper fix is resume itself. We could range-fetch into a stable
.incompleteunder our own control instead of relying on the hub's writer, which would turn that 18 GB back into 1 GB. That is a real change to the download path and wants its own discussion, so I have kept it out of here.