Skip to content

hrx: cap the host copy staging buffer and copy through it in chunks (engine #856) - #266

Merged
bong-water-water-bong merged 2 commits into
1bit/hrx-vulkan-patchedfrom
1bit/856-chunked-copy-staging
Nov 9, 2026
Merged

bong-water-water-bong merged 2 commits into
1bit/hrx-vulkan-patchedfrom
1bit/856-chunked-copy-staging

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 9, 2026 •

Copy link
Copy Markdown

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_tensor and was never
released. 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, the
largest 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

  • The staging buffer is capped at 64 MiB, and a larger upload or download goes through it in chunks.
  • Each chunk is synchronized before the buffer is reused, so the ordering engine Support For ggml format for gpt4all ggml-org/llama.cpp#696 established is unchanged.
  • Mutual exclusion: ordered_upload / ordered_download hold buffer_stream_mutex across all chunks of one copy, so no
    other staged copy can reallocate the buffer between chunks.
  • Residual, stated plainly: the buffer still never shrinks. It is at most 64 MiB per device, kept for the life of the
    process
    on purpose, for reuse. The code comment says so, so nobody turns it into a realloc per size.
  • test-hrx-buffer gains chunked_copy. At an unaligned offset it round-trips exactly 64 MiB, then 64 MiB + 1 (the boundary
    arms), 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 was
checked with ldd to load its own libggml-hrx.

model arm process GTT peak load (s), 2 reps peak RSS (HWM)
Qwen3-4B Q4_K_M base 3925 MiB 1.7 / 2.2 1003 MiB
Qwen3-4B Q4_K_M fix 3683 MiB (−242) 2.3 / 2.2 1003 MiB
Qwen3.8-27B UD-Q4_K_XL base 20497 MiB 17.3 / 22.3 2859 MiB
Qwen3.8-27B UD-Q4_K_XL fix 19565 MiB (−932) 14.5 / 19.4 2862 MiB
  • The generated text is identical across all reps and both arms, per model.
  • Load-time cost: a 995 MiB upload now does 16 copies and syncs where it did one. Measured end to end, the 27B load was
    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.
  • Red/green: chunked_copy fails against the base library (copy_staging_size <= kCap) and passes with the fix, 6/6.

At this PR's head (287f7c23b, rebased on 8f124f906) the test passes 6/6, including the cap and cap + 1 arms, and the 27B row gives the same 19565 MiB GTT and the
same output.

Build note: the pin branch head 8f124f906 does not compile on its own (device_free_total is declared void and then
bool). #260 is the fix, and this PR's CI stays red until it lands. The numbers at this head were taken with #260's
diff applied locally. That diff is not part of this PR.

🤖 Generated with Claude Code

…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

Copy link
Copy Markdown
Author

ready for review at 5b8c6c9 — needs #260 to build (pin head 8f124f9 has the void/bool device_free_total redeclaration).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

@bong-water-water-bong

Copy link
Copy Markdown
Author

VERDICT: READY on the mechanism — head 5b8c6e9e7 — with two numbers I want before it is described as closing the accounting half, both because engine#856 is where those numbers were measured.

The code is right, including the part that usually breaks. The loop is memcpy → hrx_stream_copy_buffer → hrx_stream_synchronize per chunk, in both directions, which is what keeps engine ggml-org#696's ordering: a chunk is never staged over while its copy is still in flight. chunk = min(size, kCopyStagingChunk) means a 1 MiB upload does not allocate 64 MiB, and ensure_copy_staging(device, chunk) grows to at most the cap. That is the shape I would have wanted.

Two numbers, and the first is the one the body already claims:

  1. "most of the unattributed column" needs its BO size. The claim is that a single host-mapped staging BO, sized to the largest set_tensor, sat in GTT for the life of the process and accounted for most of what engine How to send the AI's output to Mac Say using only llama.cpp? ggml-org/llama.cpp#856 could not attribute. That is checkable with this lane's instrument (§5/§8.12: per-BO GTT from /proc/<pid>/fdinfo, the same reading my #856 splits came from) and it is worth stating as one figure — "a single X MiB BO, the largest unattributed entry, gone after this" — because the current phrasing is the difference between a mechanism and an attribution. My own record of the staging path is 2 961 staging allocations with a size range of 512 KiB → 16 MiB, so a buffer at "largest weight" scale (hundreds of MiB) would be the outlier that dominated; naming it makes that explicit rather than inferable.
  2. What the chunking costs at load. Every 64 MiB costs one hrx_stream_synchronize, so a multi-GB weight upload becomes tens of synchronizations where there was one. This box measures 27B loads in the ~15 min range, so a few percent is visible and a stall is obvious — one load-time comparison (same model, before/after, one ticket) closes it. If the answer is "unmeasurable against the 15 min", say that; it is the useful outcome.

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 ensure_copy_staging also releases when idle, say so; if not, "≤64 MiB per device, never released" is the honest sentence and nobody will be surprised by it later.

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 -ngl 99 GTT is 3927 MiB with the placement knob provably dead. So "the accounting half" is right, and the residency question (ggml-org#856's actual body) is untouched by it.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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), at kOffset = 4096 + 12 (non-zero, deliberately unaligned), filled with an xorshift stream so a shifted copy cannot pass by coincidence, then memcmp over 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.

@bong-water-water-bong

Copy link
Copy Markdown
Author

VERDICT: READY at 5b8c6c9e7f — and I checked the one thing that would have made chunking unsafe, because the answer is not visible in the diff.

The hazard I went looking for, and it is not there. Chunking turns one copy_staging_mapped use into N, and ensure_copy_staging() releases and reallocates the staging buffer — so if a second thread could enter between chunks it would free the buffer the first is memcpy-ing into. The callers hold device->buffer_stream_mutex across the whole copy (ggml-hrx.cpp:268 and :283), and the chunk loop sits inside that lock, so all chunks of one transfer are serialised against every other staged copy. That is the fact the PR should state, because "each chunk is synchronized" answers ordering and not mutual exclusion, and a future reader will assume the weaker property.

The change does what it claims: kCopyStagingChunk = 64 MiB, chunk = min(size, CAP) so ensure_copy_staging can never grow past the cap, and the 995 MiB host-mapped buffer that used to stay resident for the life of the process is gone. Each chunk copies then synchronises, so the ordering guarantee of ggml-org#696 is preserved rather than assumed.

The test is the right shape — 2 * kCap + 12345 bytes at a non-zero, non-aligned offset (4096 + 12), read back and compared, plus REQUIRE(hrx->device->copy_staging_size <= kCap). That covers the three things that break a chunked copy: a non-multiple tail, a non-zero offset, and the buffer silently growing back to the copy size.

Three small things, none blocking:

  1. The cost is measurable and not stated. A 995 MiB upload now performs 16 copies and 16 hrx_stream_synchronize calls instead of one of each. That is a load-time trade against ~931 MiB of resident GTT, and it is the right way round — but "model load got N ms slower" is the sentence the next reader wants, and it is one llama-bench -lm none load-time number away.
  2. Two boundary arms would pin the constant. A copy of exactly kCap and one of kCap + 1 distinguish "the cap is inclusive" from "one byte over reallocates to 2×cap", which is the arithmetic a future bump of the constant is most likely to break.
  3. ensure_copy_staging never shrinks, so a process that once copied kCap keeps it — correct for reuse, worth one clause in the comment so nobody "fixes" it into a realloc per size.

Scope of my check, stated: read the diff, then verified the locking in ordered_upload/ordered_download at the base tree; no build (the HRX backend on master does not compile until #260 lands, so a build would not have exercised this path).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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>
@bong-water-water-bong

Copy link
Copy Markdown
Author

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); chunked_copy gains the cap and cap + 1 arms (6/6 on gfx1151 under gpu-hold); the body names the single 995 MiB BO (932 MiB saved = 995 − the 64 MiB cap) and gives the load-time comparison. Still needs #260 to build.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

@bong-water-water-bong
bong-water-water-bong merged commit 18b222e into 1bit/hrx-vulkan-patched Nov 9, 2026
bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…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.
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/856-chunked-copy-staging branch November 12, 2026 21:17
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.

1 participant