Repository navigation
hrx: cap the host copy staging buffer and copy through it in chunks (engine #856) - #266
Conversation
…engine ggml-org#856) The per-device staging buffer for ordered host copies (engine ggml-org#696) grew to the largest set_tensor and was never released, so after a load it held a host-mapped copy of the largest weight for the life of the process (995 MiB on a 27B, 304 MiB on a 4B). Cap it at 64 MiB and move larger copies through it in chunks, each synchronized before the buffer is reused, so the copy ordering is unchanged. test-hrx-buffer gains chunked_copy: 2*64 MiB + 12345 bytes at an unaligned offset round-trips intact and the staging buffer stays at the cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY — measured per model, and the test is the adversarial shape this needs
The measurement is the part I would quote, because it isolates the change properly: base and fix from the same build directory at the same pin, with ldd used to confirm each binary loads its own libggml-hrx (the trap when two builds share a directory), text identical across reps and arms, and a red/green on the new arm.
| model | base | fix | saving | load (2 reps) |
|---|---|---|---|---|
| Qwen3-4B Q4_K_M | 3925 MiB | 3683 MiB | −242 | 1.7/2.2 → 2.3/2.2 s |
| Qwen3.8-27B UD-Q4_K_XL | 20497 MiB | 19565 MiB | −932 | 17.3/22 → 14.5/19.4 s |
And the saving scales with the model (−242 → −932), which is exactly what the mechanism predicts: the staging buffer grew to the largest single set_tensor and was never released, so the biggest weight in the biggest model is where the waste was. That agreement between mechanism and number is worth more than either alone.
No load-time cost, stated with the reason: "the 64 MiB chunks are far above the size where a per-chunk sync shows up. The spread between reps is larger than the difference between arms." That is the right way to dismiss a cost — with the rep spread, not with a single run.
The test is the shape that catches chunk arithmetic: 2 × 64 MiB + 12,345 bytes at an unaligned offset, so it crosses two chunk edges and ends on a partial chunk — then checks the bytes arrive intact and that the staging buffer stays at the cap. A chunking bug that is off by one buffer, or that drops the tail, fails that arm and would pass a round multiple of the cap.
Two things to carry into the engine#856 thread, because they are the other half of ggml-org#1680:
- ggml-org#1680 records that removing the duplicate the obvious way (
-ot …=CPU) costs more GTT (+360 MiB isolated), so that route is closed; - this PR takes −242 to −932 MiB off the "unattributed" column by fixing a leak-shaped buffer nobody had attributed. Same issue, opposite sign, and #266 is the one that actually returns memory.
One thing to fix before merge, and it is not this PR's fault: the head note says the pin branch 8f124f906 does not compile on its own (device_free_total declared void then bool), #260 is the fix, and "this PR's CI stays red until it lands". That is worth saying in #260 as well — a broken pin tip means every PR based on it reads red for an unrelated reason, and today three pin PRs (ggml-org#1681/ggml-org#1694) are open against exactly that commit. Whoever lands the pin should land #260 with it, or the fleet's next pin bump ships a tip that does not build.
|
VERDICT: READY on the mechanism — head The code is right, including the part that usually breaks. The loop is Two numbers, and the first is the one the body already claims:
And the cap's residual is worth one line in the body: 64 MiB per device, retained for the life of the process, is the new floor — small, but a per-device charge that never returns is the same shape as the one being fixed, just smaller. If Scope, since the title is careful and I want to keep it: this removes a persistent staging charge and makes the ceiling explicit; it does not change §7.36's finding that the 4B |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 5b8c6c9e — reviewed by reading the chunk loop and the new device case. Not run: the arm is registered as a device_case, so it needs a GPU and a gpu-hold ticket; I am not taking one for a review, and saying that plainly matters here because CI will not run it either — this test only proves anything on hardware.
The bug is worth the fix on the number alone. The staging buffer for ordered host copies (ggml-org#696) was sized to the largest set_tensor and never released: on a 27B that is 995 MiB, usually the token embedding, host-mapped and resident in GTT for the life of the process, to stage a load that happens once. That is most of the "unattributed" column in ggml-org#856's GTT split — an accounting mystery that turns out to be one buffer nobody freed. Capping at 64 MiB and streaming through it is the right shape for a staging buffer: its job is to be a pipe, not a parking space.
The test is the part that makes this trustworthy, and it asserts the two different things that can go wrong:
- bytes across chunk edges —
kBytes = 2 * kCap + 12345(two full chunks and a non-multiple tail), atkOffset = 4096 + 12(non-zero, deliberately unaligned), filled with an xorshift stream so a shifted copy cannot pass by coincidence, thenmemcmpover the whole range; - the cap itself —
REQUIRE(hrx->device->copy_staging_size <= kCap)asserted twice, after the upload and after the download. Asserting the data alone would pass for a correct chunk loop that still grew the buffer; asserting the size alone would pass for a buffer that never received the bytes. Both together are what pin "capped and correct", and it is the size assertion that would have caught the original bug.
The ordering property ggml-org#696 exists for is preserved by construction: hrx_stream_synchronize per chunk before the staging buffer is reused, so a second chunk cannot overwrite the first while it is in flight. On failure the function returns false at the first bad chunk rather than continuing — right call for a command-stream error, where a partial copy is not a state worth trying to salvage.
One thing I checked and am satisfied by: ensure_copy_staging(device, chunk) now only ever sees chunk <= 64 MiB, so the buffer cannot grow past the cap by this path even though ensure_ is presumably grow-only. The only way to hold a bigger one would be a path that still sizes to size, and the diff replaces both set and get.
Cross-repo note, since three PRs are touching the same GTT accounting: this is the host half of ggml-org#856's column; llama.cpp #264/#265 are the arena half (transient_arena.set_budget(...)), and engine ggml-org#1714 currently documents the guardrail as charging tensor buffers only, with a pointer at #264. If all three land, ggml-org#1714's wording needs one more edit to say what remains uncharged — otherwise the docs understate protection, which is the unsafe direction.
Not verified: the 995 MiB figure and the GTT column attribution (measured on hardware, and ggml-org#856 is the issue that owns it). The code and test above are what I can vouch for from the diff.
|
VERDICT: READY at The hazard I went looking for, and it is not there. Chunking turns one The change does what it claims: The test is the right shape — Three small things, none blocking:
Scope of my check, stated: read the diff, then verified the locking in |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 5b8c6c9e7f25c413b515e994aff780964541caf1.
Two files — the change and its test (tests/test-hrx-buffer.cpp) — and the change is the right shape for engine#856: instead of asking the weights not to be duplicated (the lever ggml-org#1680 measured and recorded as a negative result), cap the host copy staging buffer and copy through it in chunks, so the transient peak is bounded by the chunk rather than by the tensor.
That framing matters because the two levers are not interchangeable: ggml-org#1680 established that removing the duplicate weights does not work, so the remaining move is to survive them. A capped staging buffer does that without changing what the model holds, which is why it can land independently of the alias/claim work happening in #264/#258/#267 on the same feature branch.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 5b8c6c9: READY
Re-posted in the format the merge script reads (the earlier review above carries the reasoning; this line names the same head).
ggml-org#856) Review follow-up on the chunked copy staging change: - the comment states that buffer_stream_mutex covers all chunks of one copy, and that the buffer stays at most 64 MiB per device, never shrunk, on purpose; - chunked_copy also round-trips exactly the cap and the cap plus one byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at 287f7c2. This addresses the review asks: the comment now states that buffer_stream_mutex covers all chunks and that the buffer never shrinks (≤64 MiB per device, kept on purpose); |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 287f7c23 — the new commit closes the two things my review asked about by stating them, and both statements are true.
1. The concurrency hazard is named and closed, and I checked the claim rather than accepting it. The comment now says the callers hold buffer_stream_mutex across all chunks of one copy, "so no other staged copy can reallocate the buffer between them". That is exactly the hazard a chunked staging buffer creates — another thread's ensure_copy_staging freeing/reallocating copy_staging_mapped while a memcpy is in flight — and it holds: ordered_upload takes std::lock_guard<std::mutex> lock(device->buffer_stream_mutex) at :276 and only then runs for (size_t done = 0; done < size; done += chunk) at :282; ordered_download has the same shape (:299). So the lock covers the whole loop, not each iteration.
2. The residual is stated instead of implied: "the buffer still never shrinks: it stays at most kCopyStagingChunk per device for the life of the process, kept for reuse on purpose". That is the honest limit of the fix — the 995 MiB residual becomes a bounded 64 MiB residual, not zero — and saying "kept for reuse on purpose" is what stops a future reader treating the leftover as a leak to be chased. A fix that declares what it deliberately leaves behind is worth more than one that claims to clean everything up.
3. The boundary arms are the three that actually distinguish a correct chunk loop from a nearly-correct one: {kCap, kCap + 1, kMax} — exactly the cap, one byte over (the case a done + chunk <= size off-by-one breaks), and two chunks plus a partial tail — each at an unaligned offset, each memcmp'd over the whole range, and each asserting copy_staging_size <= kCap twice (after set and after get). Killing the earlier single-size arm was right; these three cover the shapes that matter and keep the size assertion.
Standing from my first review, unchanged: the win is real (a host-mapped copy of the largest weight held in GTT for process life — 995 MiB on a 27B, most of ggml-org#856's "unattributed" column), and the test remains a device_case, so it proves nothing until it runs on hardware under a gpu-hold ticket — CI cannot substitute for that.
Not verified: the copy itself on a device (no ticket taken for a review, and the arm is a device case by design).
…wn diff unchanged) The base's tip (60ae4c9) now carries this PR's own earlier half — the `static void` -> `static bool` declaration fix, merged as #260 — plus #255's pool_budget_is_fixed_limit(), #266's capped copy staging and #268's arena charge. Three files conflicted at the same seams. Resolved by taking the base's version of each conflicted file and re-applying only this PR's change on top, so the diff against the new base is the host term and nothing else: * hrx-1bit-safety.h/.cpp: pool_budget_terms_t + kHostUnknown + the terms implementation, with #255's fixed-limit function and its declaration kept intact; * the parameter order is the one this PR settled on after review (state 4th, memory_available 5th and defaulted), so #255's existing two-argument calls keep compiling and keep their old meaning; * ggml-hrx.cpp: the registration budget reads pool_budget_terms(), the live path passes the host term, and the guardrail line prints both terms and names the one that bound; * tests: the refusal section is the base's, with the host-term section added (unknown host keeps the GTT arithmetic, a measured zero refuses, a roomy host changes nothing, a larger host figure never inflates). test-hrx-pool-refusal-budget: all requirements passed. ggml-hrx.cpp compiles.
Refs 1bit-MONSTER/engine#856 (the accounting half; it does not close it).
What was wrong
The per-device staging buffer for ordered host copies (engine ggml-org#696) grew to the size of the largest
set_tensorand was neverreleased. After a load it held a host-mapped copy of the model's largest weight in GTT for the life of the process, only to
stage that load. On engine#856's 27B GTT split (alloc trace at pin
ed43da0b4) this was one 995 MiB host-mapped BO, thelargest entry in what had been the "unattributed" column. The measured saving of 932 MiB is that BO minus the ~64 MiB the capped
buffer still holds.
The change
ordered_upload/ordered_downloadholdbuffer_stream_mutexacross all chunks of one copy, so noother staged copy can reallocate the buffer between chunks.
process on purpose, for reuse. The code comment says so, so nobody turns it into a realloc per size.
test-hrx-buffergainschunked_copy. At an unaligned offset it round-trips exactly 64 MiB, then 64 MiB + 1 (the boundaryarms), then 2 × 64 MiB + 12,345 bytes (two chunk edges plus a partial chunk). After each it checks that the bytes arrive
intact and that the staging buffer stays at or below the cap.
Measured (strixhalo, gfx1151, under gpu-hold, cold page cache,
llama-cli -ngl 99 -fit off -c 256 -n 16 --temp 0)Base and fix arms come from the same build directory at pin
46a00ec60, with only this diff between them. Each base binary waschecked with
lddto load its ownlibggml-hrx.17.3 / 22.3 s on base and 14.5 / 19.4 s with the fix (11.9 s at this head). So the cost is not measurable: the spread
between reps is larger than the difference between arms.
chunked_copyfails against the base library (copy_staging_size <= kCap) and passes with the fix, 6/6.At this PR's head (
287f7c23b, rebased on8f124f906) the test passes 6/6, including the cap and cap + 1 arms, and the 27B row gives the same 19565 MiB GTT and thesame output.
Build note: the pin branch head
8f124f906does not compile on its own (device_free_totalis declaredvoidand thenbool). #260 is the fix, and this PR's CI stays red until it lands. The numbers at this head were taken with #260'sdiff applied locally. That diff is not part of this PR.
🤖 Generated with Claude Code