Repository navigation
ggml-hrx: host staging buffers are charged to the pool too (engine #1711) - #270
Conversation
…sient arena (engine ggml-org#1711) Second slice of engine#1711. GGML_HRX_HOST_WEIGHT_LIMIT_MB limits how much residency the cache keeps, but nothing charged it to the pool ledger, so a large host-weight arena was invisible to pool_reserve and the guardrail over-committed by however much the cache held. The growth uses the same pure decision as the transient arena (plan_arena_charge, retires_old = false: the superseded arena is released, so its charge goes with it). The budget reaches it through CommandProgramExecutionContext, which already carries it since #268; the two acquire() call sites in the executor pass it down. Three arms added to tests/test-hrx-transient-arena-charge.cpp for the shape this path actually has: first growth charges the whole arena, a regrow to the same budget charges nothing (the common case - one arena serves many weights), and a growth past the budget swaps charge for charge.
…ml-org#1711) Third and last allocator of this shape. A host staging buffer is device-visible memory like the arenas, but nothing charged it, so a large materialisation was invisible to pool_reserve. Unlike the two arenas there is no delta arithmetic: the object owns exactly one buffer, so the charge is taken on allocate (before it, so a refusal leaves no buffer) and given back in clear(). The contract that matters is that clear() returns the bytes EXACTLY once -- clear() is reached by the destructor, by move-assignment and by the next allocate, so a tally that survived a release would hand the same bytes back two or three times. tests/test-hrx-staging-ledger.cpp pins that on the real object with no device (6 arms), including the two that a no-op version fails: clear() on an empty buffer leaves the ledger alone, and a second clear() does not release again.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 34470c19 — and it settles the question I raised on #264/#268, so I need to correct my own advice there
First, the correction: I told #264/#268 to keep #264's code and close #268. That is now wrong, and this PR is why. #270 includes transient-arena-charge.h — the header #268 introduces — and charges the host staging buffers through the same pool_reserve/pool_release pair. A second PR building on one of two competing implementations is how a tie gets settled, and it went the other way from my recommendation. What matters from #264 survives as an invariant, not as a file name: the no-double-count rule must be asserted in whatever shape ships.
What this PR adds is the third allocation that could over-commit the pool, and the reasoning is the same one that made the first two necessary: "residency is invisible to pool_reserve and the guardrail over-commits by however much the arena holds". Plus a comment at :11 and one at :66 that names the subtle case — "returns exactly what was charged, including the case where a grow released a superseded arena" — which is the accounting question a reservation system actually has to answer.
The reserve/release pairing is the thing to check, and it looks right on inspection: the reserve failure returns false before any release, the single-shot path releases charge.reserve_bytes after use, the grow path releases charge.release_bytes (the superseded arena), and the two destructor/exit paths release charged_bytes_. That is one release per reserve on every path shown. What I cannot check without running it is whether every early return between a successful reserve and its release exists — a missing release is a budget leak, and a leak does not fail loudly: it makes a later launch get refused for memory that is actually free, which is the same false refusal the double-count would have produced, arriving by a slower route.
So one ask: an arm that asserts the budget returns to its starting value after a full allocate/use/free cycle, and after a failed allocate. That is cheap (the test file is already there — static int failures appears in the diff) and it is the only assertion that catches a leak, because every other check passes while the pool slowly narrows.
Two smaller notes: pool_reserve's refusal names the purpose ("the host-weight arena", "a host staging buffer"), which is what makes a refusal actionable rather than "out of memory"; and with #259 (host term in the live pool check) also open, this series is now three PRs adding terms to one budget — the release-arm is what keeps that from becoming a slow narrowing nobody can see.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 34470c191e742b62e214423dadd68951a7ac2c21 — Host staging buffers charged to the pool too (command-program-executor.cpp +5, host-memory.cpp) — the complement to #269's host-weight arena. Between them, the budget now covers both host allocations, which is the state where a pooled check can actually be trusted.
|
VERDICT: hold — do not merge as it stands: the branch is behind the pin, and merging it would revert two merged PRs. Checked in the objects, not inferred from the file list: Those "-" lines are my merged work, not this PR's own. Nothing wrong with the change itself, and the reasoning is the strongest of the three slices: "unlike the arenas there is no delta arithmetic — To get it mergeable, in this order: git merge origin/1bit/hrx-vulkan-patched # no force-push; expect conflicts where #256/#261 touched ggml-hrx.cpp
# keep BOTH sides in every conflict — the pin's terms/failure-status AND this PR's staging charge
# then build + run test-hrx-staging-ledger AND test-hrx-pool-refusal-budget AND test-hrx-failure-statusThe three tests matter here specifically: the staging charge interacts with Two notes for that re-verdict: release symmetry (a staging buffer freed must give the bytes back — the arenas needed explicit |
|
VERDICT: READY at Ran the new test at this head (checked out and the arena charge test still passes at the same head ( Why this is the last one matters, and it belongs in the body. engine#1711 enumerated three uncharged allocators by The design choice is right and worth stating more loudly: no delta arithmetic here, because Two small notes:
Scope of my check, stated: checked out the PR head, built and ran |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE. Charging the third allocator is right, and the two details that make a ledger safe are both in place:
- The charge is stored, not recomputed.
clear()returnscharged_bytes_— "clear() returns exactly what was charged" is asserted directly, so a later edit that refunds a size computed from the current buffer (which can be zeroed or resized) fails a test rather than drifting the budget. - The charge is taken before the allocation, so a refusal leaves no buffer to refund — which is the ordering that makes the double-entry problem not exist rather than exist-and-be-compensated.
One ask: the three paths you name as the risk are not all in the test. The body is explicit that clear() is reached by the destructor, by move-assignment, and by the next allocate — and the six arms cover explicit clear(), a second clear(), an accepted request, the carried bytes, and the exact refund. That proves the tally resets and that a second explicit call is inert. It does not exercise the other two entry points, and move-assignment is the classic double-refund site: an implementation that refunds the old charge and then takes the new one can also refund the old twice, or refund on a move-from that no longer owns it. Two arms close it:
- destructor: charge, let the object go out of scope, assert
pool_allocated()is back to base; - move-assignment: charge A, move-assign B over it, assert the ledger carries exactly B's bytes — not A+B (no refund) and not base (refunded twice or lost the charge).
Both are device-free and fit the harness exactly as it stands, since the ledger is all that is under test.
Also right: pool_allocated() rather than pool_reserve_bytes() is called out in the test with the reason — the 16 GiB headroom reserve is a different number, and conflating them would make the assertions pass for the wrong reason. And the "smaller than the other two, and that is the point" framing is the useful version of a series: #268 (transient arena, merged), #269 (host-weight arena, open), #270 (this) — three allocators, one shape, and the one that carries no delta arithmetic is the one where the risk is a duplicated refund rather than a mis-signed one.
VERDICT: CHANGES — the move-assignment never transfers
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 34470c1: CHANGES REQUESTED — two arithmetic defects, both provable from the diff
I wrote the two-gate analysis this PR is implementing (engine#1711), so I read it against that: pool_reserve charges a
ledger and separately runs the live gate. Both findings below are about the arena's arithmetic and the move path, and both
are the failure mode I flagged as worse than no charge at all — a ledger that disagrees with the bytes actually held.
1. A re-grow double-subtracts: the ledger ends at new − old, not new
plan_arena_charge() returns a delta and the retired capacity:
plan.reserve_bytes = required - capacity; // the growth
plan.release_bytes = (!retires_old && capacity > 0) ? capacity : 0; // the arena being freedand arena_reserve() applies both:
charged_bytes_ += charge.reserve_bytes; // ledger += (C2 - C1)
...
hrx_buffer_release(arena_);
if (charge.release_bytes > 0) {
pool_release(charge.release_bytes); // ledger -= C1
charged_bytes_ -= charge.release_bytes;
}Walk it: first grow from 0 → C1 charges C1 - 0 = C1 ✔. Second grow C1 → C2 charges C2 - C1 and then releases C1,
leaving the ledger at C2 - C1 — short by the old capacity, and short again on every subsequent grow. That is
under-counting, so the guardrail over-admits by exactly the arena it is supposed to be accounting for: the outcome the PR
exists to prevent, now with the extra property that it looks fine until the pool is small.
The two numbers are each right for a different lifecycle, so pick one:
- replace:
pool_reserve(target)before the allocation,pool_release(previous_capacity)once the old buffer is gone —
the ledger then always holds exactly the live arena; - transfer:
pool_reserve(target - previous_capacity)and release nothing on the swap, because the old capacity's
charge moves to the new allocation rather than ending.
Either way charged_bytes_ should end equal to target, which is the invariant clear() relies on and the one a test can
assert in one line.
2. The move-assignment drops charged_bytes_, in the path the test's own header calls out
HostStagingBuffer & HostStagingBuffer::operator=(HostStagingBuffer && other) noexcept {
if (this == &other) { return *this; }
clear();
buffer = other.buffer; host_data = other.host_data; source_host_buffer = std::move(other.source_host_buffer);
value = other.value; length = other.length; upload = other.upload; download = other.download;
other.buffer = nullptr; other.host_data = nullptr; other.value = -1; other.length = 0;
...Every field is moved except charged_bytes_: the destination ends with 0 while holding the buffer (its clear() then
returns nothing — the charge stays in the ledger), and the source keeps the count while owning nothing (its destructor
releases bytes it no longer holds). HostStagingBuffer(HostStagingBuffer&&) delegates here, so both move paths do it.
That is precisely the hazard tests/test-hrx-staging-ledger.cpp describes in its own header — "clear() is reached by the
destructor, by move-assignment and by the next allocate, so a tally that survived a release would hand the same bytes back
twice" — and the test does not have that arm: it covers clear() on an empty buffer and clear() on a held one, not a
move. Two lines close both:
charged_bytes_ = other.charged_bytes_;
other.charged_bytes_ = 0;plus an arm that moves a held buffer and asserts pool_allocated() is unchanged between the move and the two destructions
(that is the property, not the field).
3. Nit: pool_budget > 0 skips the live gate too
pool_reserve(0, size, …) runs the live check and returns before touching the ledger (budget == 0 returns after the live
gate, by design). Gating on pool_budget > 0 means that when the budget is unknown — the case where the operator never set
GGML_HRX_POOL_LIMIT_MB — staging allocations skip the live check as well, though the live check is the one thing that
still says no on such a box. If dropping the charge for an unknown budget is the intent, call pool_reserve(0, size, …) and
keep the gate.
What is right here, and I want it on the record
- The per-object tally is the correct design and much easier to keep honest than a running total: one buffer per object,
charge on allocate, return inclear(), and every path (destructor, move, next allocate) goes throughclear(). Findings
1 and 2 are the two places the implementation does not yet match that design; neither is a reason to change it. previous_capacityfolded in properly (a shrink cannot charge), the reserve happens beforeallocate_device_buffer
with a matching release on refusal, and the refusal logs what was asked for. That ordering is right and is the thing #268
got argued about.charged_bytes_separate fromarena_capacity_, with the comment saying why (a grow released a superseded arena), is the
right call for the eviction logic that readsarena_capacity_.- The test's shape — drive the real ledger through
pool_allocated()rather than re-deriving the arithmetic — is why findings
1 and 2 are one-line assertions instead of an argument.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Adding the arithmetic as numbers, since the head has not moved and this is the finding most likely to be argued about.
Your bookkeeping transcribed — charge plan.reserve_bytes (target - previous), then release plan.release_bytes
(previous when it is freed) — over three grows of the host-weight arena:
grow delta release ledger the arena holds
0->128 128 0 128 128
128->320 192 128 192 320
320->768 448 320 320 768
final ledger 320, arena holds 768, undercount 448
Every grow after the first loses the previous capacity, so the ledger settles at last - previous instead of last: here
320 for 768 MiB held, and the undercount grows with the arena. With GGML_HRX_POOL_LIMIT_MB set, that is the guardrail
admitting 448 MiB more than it has accounted for — the over-commit the charge was added to stop, now with a running total that
never converges.
The two numbers are each right for a different lifecycle; pick one and the invariant charged_bytes_ == arena_capacity_ falls
out (and is a one-line test):
// replace: the ledger always holds exactly the live arena
plan.reserve_bytes = required; // not required - capacity
plan.release_bytes = capacity; // unchanged
// transfer: the old capacity's charge moves to the new allocation
plan.reserve_bytes = required - capacity; // unchanged
plan.release_bytes = 0; // release nothing on the swapEither way the first grow is unaffected (capacity == 0), which is why any single-grow test passes and this needs three.
The move-assignment finding stands as written: operator= copies every field but charged_bytes_ and does not zero the
source, so the destination holds the buffer with no charge and the source holds a charge with no buffer.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 34470c1: CHANGES
The charge order in allocate_host_staging_buffer is right: pool_reserve before allocate_device_buffer, pool_release if the allocation fails, and the charge held on the object as charged_bytes_, given back by clear(). (The first commit here, 203aac2, is #269, already merged, and its content is identical, so it merges clean.)
Blocking: the move operations don't carry charged_bytes_, and the real code path moves every staging buffer right after charging it. HostStagingBuffer::operator=(HostStagingBuffer&&) copies buffer, host_data, source_host_buffer, value, length, upload and download, and resets them on other, but leaves charged_bytes_ on the moved-from object (the move constructor delegates to it). In command-program-executor.cpp (~:759-781):
HostStagingBuffer staging;
allocate_host_staging_buffer(context.device, binding.length, staging, …); // charge taken, held on the local
…
prepared.host_staging.push_back(std::move(staging)); // buffer moves; charged_bytes_ stays on the localAt the end of that loop iteration the local is destroyed, and ~HostStagingBuffer() → clear() → pool_release(charged_bytes_) gives the whole charge back while the buffer lives on, uncharged, in prepared.host_staging. So on the path that matters the charge lasts a few lines, and the ledger never holds staging memory while it's in use, which is ggml-org#1711's point. A std::vector reallocation moves elements the same way.
tests/test-hrx-staging-ledger.cpp's header names move-assignment as one of the three ways clear() is reached, but no arm asserts that a moved-to buffer keeps the charge.
Fix: in the move assignment, charged_bytes_ = other.charged_bytes_; other.charged_bytes_ = 0; alongside the other fields, after the clear() of this. Add an arm: allocate with a budget, std::move into a second object, destroy the first, and assert the pool still holds size. Then destroy the second and assert it holds 0.
|
VERDICT: CHANGES REQUESTED at What is right
The defect:
|
|
VERDICT: CHANGES — the charge is taken and then dropped, on the actual dispatch path, one line later. The design is right; the move-assignment is missing the one field that carries the money. What I traced// command-program-executor.cpp:781
prepared.host_staging.push_back(std::move(staging));
HostStagingBuffer & HostStagingBuffer::operator=(HostStagingBuffer && other) noexcept {
…
buffer = other.buffer; host_data = other.host_data; value = other.value;
length = other.length; upload = other.upload; download = other.download;
other.buffer = nullptr; other.host_data = nullptr; other.value = -1;
other.length = 0; other.upload = false; other.download = false;
return *this; // ← charged_bytes_ appears ZERO times in this function
}So after the move: the vector's element has if (charged_bytes_ > 0) { pool_release(charged_bytes_); charged_bytes_ = 0; } // :2125-2127The bytes are given back while the buffer they were charged for is still live and owned by the vector. That is the opposite of this PR's purpose: the materialisation is still invisible to It is not just the
|
|
VERDICT: CHANGES at The design is right and the parts I checked are right: The defect: HostStagingBuffer & HostStagingBuffer::operator=(HostStagingBuffer && other) noexcept {
...
clear();
buffer = other.buffer; host_data = other.host_data; source_host_buffer = std::move(other.source_host_buffer);
value = other.value; length = other.length; upload = other.upload; download = other.download;
other.buffer = nullptr; ... other.length = 0; other.upload = false; other.download = false;
return *this; // charged_bytes_ is neither copied to *this nor zeroed on other
}
prepared.host_staging.push_back(std::move(staging));into a
Net: the ledger under-counts exactly the staging bytes this PR exists to charge, in the permissive direction — the over-commit is back, now with a green test saying it is charged. That is worse than the original state, because the number is no longer merely missing, it is wrong. The fix, two lines in charged_bytes_ = other.charged_bytes_;
...
other.charged_bytes_ = 0;And the arm that would have caught it — the one your header comment names as a path // a moved-from buffer must not release the charge the receiver now owns
HostStagingBuffer src, dst;
check(allocate_host_staging_buffer(device, 8u << 20, src, budget).success(), ...); // or the ledger-only form you use
const size_t charged = pool_allocated();
dst = std::move(src);
check(pool_allocated() == charged, "move-assignment carries the charge, it does not release it", pool_allocated(), charged);
{ HostStagingBuffer gone = std::move(src); } // a moved-from object destructs
check(pool_allocated() == charged, "a moved-from buffer's destructor releases nothing", pool_allocated(), charged);
dst.clear();
check(pool_allocated() == charged, "the receiver's clear() releases it exactly once", ...);If the ledger-only test cannot allocate a real buffer, drive it through the member directly the way your Everything else in the PR I have no complaint about, and the two facts above are the reason: this is the third allocator and the only one whose object can be moved, so it needs the move arm and the other two do not. |
|
VERDICT: CHANGES Head |
…ss a move (both #270 review points) Review point 1: the plan was a delta (required - capacity) paired with releasing the superseded capacity, which subtracts the old capacity twice - the ledger settles at required - capacity instead of required, under-counting by exactly the arena it accounts for, so the guardrail over-admits. It is now REPLACE: charge the whole new allocation, release the superseded one. Under the replace model a retired (diagnostic) buffer correctly leaves the ledger at old + new, which is what the device actually holds in that mode. Two arms walk a five-step growth sequence and assert the invariant directly: after every growth the ledger equals the live capacity, and for the retiring model old + new. This also corrects the two already-merged call sites that use the same helper (#268's transient arena and #269's host-weight arena). Review point 2: HostStagingBuffer's move-assignment copied every field except charged_bytes_, so the destination held a buffer it was not charged for while the source kept a tally for a buffer it no longer owned. The tally now travels with the buffer and the source's is zeroed; six staging arms pin the move path (no ledger change, destination charged, source clean, one release on clear).
Both points fixed and pushed — new head
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 8895c3b — both review points are addressed with arms, and the second one is a bug my own #268 review should have caught.
1. The move carries the charge. charged_bytes_ = other.charged_bytes_; other.charged_bytes_ = 0;, with four arms that are exactly the right four: a move does not touch the ledger, the destination carries the charge, the source keeps no tally for a buffer it no longer owns, and — the one that matters for safety — clearing the moved-from source does not release again. Without that last arm a move-then-clear would release a charge it no longer holds, which under-counts the pool: the unsafe direction.
2. The growth charge was double-subtracting, and the fix is the right model. From the diff:
The delta form —
reserve(required - capacity)together withrelease(capacity)— subtracts the old capacity TWICE, so the ledger settles atrequired - capacityinstead ofrequired, under-counting by exactly the arena it is meant to account for; the guardrail then over-admits.
I checked the arithmetic and it is right: net = +reserve − release = (required − capacity) − capacity on a ledger that already held capacity, i.e. required − capacity. The fix — charge the whole new allocation (reserve_bytes = required) and give back the superseded one only when it is actually freed — makes the invariant "after a growth the ledger equals the arena's live capacity", which is what clear() releases. And the retired-buffer case is restated correctly: a retired buffer stays resident, so its charge stays, and the ledger ends at old + new — exactly the memory held in that mode.
This one is on me. I reviewed #268 and called the pure decision sound, quoting its arms ("growth charges the delta and releases the old", "diagnostic growth keeps the retired buffer charged") as if they pinned the behaviour. They pin the fields, not the invariant: each field was individually reasonable, and the net effect was a ledger that under-counted by one arena. A test asserting reserve == delta && release == capacity passes for a rule whose sum is wrong. The lesson, written down for the FACTS/STATE record: for a charge/release pair, assert the net (ledger after == live capacity), not the two numbers — the pair can each be "correct-looking" and still cancel wrongly.
Scope note: the body/commit also states the first commit here (203aac2) is #269's content and identical, so the merge is clean — consistent with what I saw when reviewing #269.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 8895c3be — reviewed by reading the final functions and the arms, not by building: this box cannot build GGML_HRX=ON without third_party/hrx-system and disk is under the 175 G floor for a new tree, so the merge gate must be a real GGML_HRX=ON build + ctest. What I can check, I checked against the file at this head, not the diff.
1. allocate_host_staging_buffer is correct on all three of its exits. clear() first (so the previous charge is already returned), reserve, then allocate: on success the length and the tally are set together, on allocation failure the charge is released. That ordering is what makes "a refusal leaves the previous state intact" true rather than aspirational — a refusal happens before any buffer exists, so there is nothing to unwind.
2. clear() releases exactly once and zeroes. if (charged_bytes_ > 0) { release; charged_bytes_ = 0; } — so destructor, move-assignment and the next allocate all converge on the same non-idempotent-safe shape. The arm that proves the double-clear case (a second clear() does not release it again) is in the test and is the arm I would have asked for.
3. The move arm is the one that mattered, and the comment says why. Dropping either half "leaves a ledger that disagrees with the memory in both objects at once" — source releasing bytes it does not hold, destination holding bytes it is not charged for. charged_bytes_ = other.charged_bytes_; other.charged_bytes_ = 0; fixes both directions, and the test asserts the ledger is untouched by the move and that clearing the moved-from source is a no-op.
4. The plan_arena_charge fix is the most valuable line in the PR and it is buried in a comment: reserve(required - capacity) together with release(capacity) subtracts the old capacity twice, settling the ledger at required - capacity instead of required. That is a bug in merged code (#268), not just in this slice, and the replace form is right: reserve(required) + release(capacity) nets to the live capacity. The extended arena test now has the arms for it — "growth charges the WHOLE new arena and releases the old", "diagnostic growth keeps the retired buffer charged (ledger ends at old+new)", and the host-weight case where the same arena is reused across many weights and a regrow to the same budget must charge nothing.
5. The tests use pool_allocated() rather than pool_reserve_bytes() with the reason in the comment (the latter is the 16 GiB headroom reserve) — that distinction is exactly the kind of thing that makes a ledger test quietly assert on the wrong counter, and it is stated in one line instead of left to the reader.
Three notes, none blocking:
- The
staging.clear()-then-pool_reservewindow. Between the release and the new reserve the ledger is momentarily lower than the memory held, so a guardrail check on another thread in that window would admit slightly more than the budget. Single-stream dispatch makes this theoretical today; if staging can ever be allocated from two threads, the reserve should come first and the old charge be returned after the new one is secured. pool_budget == 0means "not charged", silently. The test says so ("pool_reserve() returns true WITHOUT touching the ledger when budget == 0"), and every caller incommand-program-executor.cppnow passescontext.pool_budget, so it is right today — but a future caller that forgets the argument gets an uncharged buffer with no diagnostic. OneGGML_LOG_DEBUGin thepool_budget == 0branch while this lands would make the omission visible.- The PR body should lead with the
plan_arena_chargedouble-subtraction. As written it reads as the third slice of a series; the fact that it also corrects a ledger bug in merged code is the reason to look at it carefully, and the person reading the queue will not find that in the diff.
The "no delta arithmetic, and that is the point" framing is right: this is the allocator of the three that has one buffer, so it should be the shortest, and being shortest is what makes the exactly-once property checkable by eye.
…irective: merge main in) One conflict, in tests/test-hrx-transient-arena-charge.cpp: main carries the delta-shaped expectations from #268's original arms, this branch changes the helper to the replace model (charge the whole new arena, release the superseded one), so HEAD's expectations are the correct ones and main's are dropped. The merged helper is this branch's (plan.reserve_bytes = required), which is what the arms assert. Both test binaries pass at the merge head.
Merged the pin in (owner directive) — head is now
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 9478595: READY
Checked at 9478595: 8895c3b, the fix for both review points, plus the merge of 1bit/hrx-vulkan-patched. The head merges cleanly into the base, and there are no conflict markers.
- The move now carries the charge (my point from 34470c1).
HostStagingBuffer::operator=(&&)nowclear()sthisfirst (so the destination's earlier charge is released), then copiescharged_bytes_and zeroes the source's, and the self-move guard is still there. That is the fix I asked for. Thepush_back(std::move(staging))incommand-program-executor.cppnow leaves the charge on the element inprepared.host_staging, and the local's destructor releases nothing. The new arm 3 intest-hrx-staging-ledger.cppcovers exactly that: the ledger doesn't change on the move, the destination is charged, the source is clean, and the bytes are released exactly once. - Arena growth now charges the whole new arena.
plan_arena_chargereservesrequiredand releasescapacitywhen the old buffer is freed, so the ledger settles atrequired, notrequired - capacity. Arena retired in diagnostic mode: the ledger stays at old + new, which is what the device holds.test-hrx-transient-arena-charge.cpp, built with ASan/UBSan, reports "all arms passed". Negative control: withreserve_bytesput back torequired - capacity, it fails 4 arms, for example "growth charges the WHOLE new arena and releases the old: reserve 8388608 want 16777216". Both callers (transient-arena.cpp:95,host-memory.cpp:1748) give back exactlyreserve_byteson a failed allocation, andrelease_bytesonly afterhrx_buffer_release. - Compiles:
runtime/host-memory.cpp,runtime/transient-arena.cppandtests/test-hrx-staging-ledger.cpppass-fsyntax-only -Werrorwith the HRX target's flags, against hrx-system at the engine's pin7f4236924.
Not run here: test-hrx-staging-ledger itself. It links the real operator= through libggml with HRX on, and the box is at 171G free, under the 175G line for a new HRX build. So the move arm is verified by reading the code and compiling it, not by running it. Whoever builds with GGML_HRX=ON next can close that with ctest -R test-hrx-staging-ledger.
Non-blocking, so it's on record: on the transient arena, "replace" overstates the peak by capacity while it grows. It reserves required while the old buffer is still charged, but then frees the old buffer before allocating the new one, so the device never holds both. Close to the budget edge, a growth that would fit can be refused. That is the safe direction (a clear refusal, never an over-admit). The host-weight arena allocates grown before freeing the old one, so there the charge is exactly the peak. If edge refusals ever show up on the transient path, the fix is to reserve required - capacity and release nothing when the old buffer is freed.
) #269 charged the arena and #275 charged the unbounded per-weight path, but nothing asserted either against the LEDGER: the staging buffer has had that arm since #270 and the arena never did, so both charges could have been wrong in the one direction that does not fail loudly. An under-charge leaves the ledger saying less memory is held than is, the guardrail admits a launch it should refuse, and the result lands in the allocator as "llama_model_load: error loading model" -- the shape of engine#1502 and ggml-org#1417. No device: the charge is SIMULATED the way tests/test-hrx-staging-ledger.cpp simulates the staging buffer's -- reserve against the pool, record the tally on the object, then exercise the paths that must return it -- because what is under test is conservation in the ledger, not the allocation. That needs one seam: HostWeightCache::charged_bytes_ is private, so it gets a named read/write pair rather than the struct-on-a-platter the staging object gets by being a struct. Sixteen assertions in four groups: an empty clear() touches nothing (an unconditional release would underflow every later figure); a held tally is returned EXACTLY once (and the tally zeroed, so a second clear() is a no-op); a growth the budget refuses leaves the ledger untouched and a clear() after it returns nothing it did not take; and two caches are independent, so clearing one does not release the other's bytes. Both directions bite, checked by mutation rather than argued: * release removed from clear() -> 5 failures (the ledger keeps bytes nobody holds: an over-charge, which refuses launches that would fit); * clear() releases twice -> 1 failure (the ledger drops below what is held: the under-charge the issue is about, and the direction that fails silently). Deliberately NOT asserted here: the size of a growth increment (target - previous_capacity). That number comes out of plan_arena_charge(), and test-hrx-transient-arena-charge.cpp already pins that arithmetic; restating it here would be a second statement of one rule, which is the defect class this arm exists to reduce. This arm asserts conservation; the plan arm asserts the size. Built and run: `cmake -DGGML_HRX=ON -Dhrx_DIR=… -Dloomc_DIR=… -DGGML_HRX_KERNEL_CORPUS_SOURCE_FORMAT=text` (text corpus because a ledger test needs no kernel corpus), `ninja test-hrx-arena-ledger`, `all host-arena ledger checks passed`. Co-authored-by: bong-water-water-bong <bong@users.noreply.github.com>
…evice-free arms (carries 070a270; stacked on #277) (#278) * hrx: a pool refusal at compute time is ALLOC_FAILED, so the server says out of memory (engine ggml-org#1618) #261 made a resource failure return GGML_STATUS_ALLOC_FAILED by matching the allocator's two status names, RESOURCE_EXHAUSTED and OUT_OF_RESOURCES. Since then the pool ledger refuses before the allocator is asked (#268 the transient arena, #270 a host staging buffer, #275 a materialized host weight), and those three refusals are this backend's own sentences. None of them contains either name, so each returned GGML_STATUS_FAILED and reached a llama-server client as "Compute error." again, with no warning in the log. The three sites now append kPoolRefusalToken ("POOL_EXHAUSTED"), which is a third needle of the classifier. The token decides, not the wording. "pool" is added to the allocation hints, so a refusal written without the token is named by the existing warning instead of passing silently. tests/test-hrx-failure-status.cpp: the three refusals classify as ALLOC_FAILED with no warning; the old wording without the token stays FAILED and is warned about. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * ggml-hrx: a shared pool_tally pair, so the ggml-org#1823 charge has device-free arms Carried from 070a270 on the i679-1823-unbounded branch, which was pushed as #275 merged at the commit before it and so never landed. Rebased onto the branch head; the one conflict was the refusal line, which keeps the token from the commit below. The charge/release pairing HostWeightLease::Entry needs is a free inline pair in host-memory.h: charge(budget, bytes, tally, what) refuses without touching anything, release(tally) returns exactly once and zeroes the tally. Entry calls them, so the default-mode charge is testable with no device. tests/test-hrx-staging-ledger.cpp gains the pair's arms. One addition to the carried test: a bystander holds 1 MiB through the block. Without it the ledger is at zero when the second release runs, pool_release clamps there, and the "second release is a no-op" arm passes for a release() that never zeroes its tally. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
What this adds
Third and last allocator of the shape ggml-org#1711 is about, after #268 (transient arena, merged) and #269 (host-weight arena, open). A host staging buffer is device-visible memory
too, and nothing charged it — so a large materialisation was invisible to
pool_reserveand the guardrail over-committed by however much staging a dispatch needed.The change is smaller than the other two, and that is the point
Unlike the arenas there is no delta arithmetic:
HostStagingBufferowns exactly one buffer, so the charge is taken on allocate (before the allocation, so a refusal leaves no buffer)and given back in
clear(). What matters is thatclear()returns the bytes exactly once — it is reached by the destructor, by move-assignment, and by the nextallocate, so a tally that survived a release would hand the same bytes back two or three times and quietly inflate the budget for everything afterwards.
The test targets that contract on the real object
tests/test-hrx-staging-ledger.cpp(new, no device, no libhrx — the ledger is the only thing under test), 6 arms, all passing:The first two and the last two are the ones a no-op or a half-done version fails: a default-constructed buffer is already cleared, and
clear()runs again from the destructor. Theyalso pin the real object rather than a helper — this path has no pure-decision function to extract, so testing the helper would have been theatre.
Two things the arms found while being written, worth recording because they would have produced a green-but-meaningless test:
onebit::pool_reserve_bytes()is not the ledger — it is the 16 GiB headroom reserve. The ledger getter ispool_allocated(). An arm built on the wrong one asserts nothing.pool_reserve()withbudget == 0returns true without touching the ledger, so a test that passes 0 for "no budget" observes no charge and would read as "clear() released nothing"— i.e. it would pass against a completely broken implementation.
Built
cmakeRC=0,ninja llama-serverRC=0,test-hrx-staging-ledgerRC=0 (6/6), and the existingtest-hrx-transient-arena-chargestill 8/8.Status of ggml-org#1711 after this
All three allocators of the guardrail-piercing shape are charged: transient arena (#268, merged), host-weight arena (#269, open), host staging (this PR). What remains on the issue is
evidence, not code: the positive exercise of the growth paths under load needs a configuration that actually materialises host memory (a residency limit above the model's largest
weight, on a box with the GTT to spare) — the recipe is on #269 and it is not a call to take unilaterally on a shared APU.