Repository navigation
ggml-hrx: the transient arena is charged to the pool before it grows (engine #1711) - #268
Conversation
… so the pin builds again `main` cannot be compiled with `GGML_HRX=ON` at 8f124f9. Two merges each added a forward declaration of the same function - #252's `static void device_free_total(...)` and #240's `static bool ...` - and `void`/`bool` is an ambiguous redeclaration: ggml-hrx.cpp:517:13: error: ambiguating new declaration of 'bool {anonymous}::device_free_total(ggml_backend_hrx_device_context*, size_t&, size_t&)' ggml-hrx.cpp:383:13: note: old declaration 'void {anonymous}::device_free_total(ggml_backend_hrx_device_context*, size_t&, size_t&)' The definition is `bool` (it reports whether the driver counters were available), so the `void` declaration is the wrong one. Both hunks are textually far apart, which is why the merge was clean and the break is only visible when the backend is compiled - and CI never compiles it, because `GGML_HRX=ON` wants the private runtime. One word. `ggml-hrx.cpp` now compiles, and this is the same line that let the ggml-org#1502 work build llama-cli end to end on 1bit/1502-host-term. scope: the declaration's type. No behaviour change; the function, its callers and its definition are untouched.
…(engine ggml-org#1711) The guardrail was consulted in exactly one place - the buffer-type allocator - so the scratch a launch grows into was allocated uncharged and "a launch that needs more is refused here" (ggml-org#680) held for tensors only. That is why a pool exhaustion in the arena path faults instead of being refused. The charge decision is a pure helper (runtime/transient-arena-charge.h) so it can be tested with no device: reserve the growth BEFORE the superseded buffer is touched, release its charge only when it is actually freed. In diagnostic mode the arena retires superseded buffers instead of releasing them, so their charge stays - releasing it there would under-report by exactly the buffers kept around to diagnose memory problems.
VERDICT: READY — the pure plan is right and the call site implements all four orderings; the orderings themselves are the untested partThe diagnosis is the good kind: The pure plan gets the case a naive version gets wrong, and it is the case its own test asserts: when the superseded buffer is retired (diagnostic mode keeps it resident) its charge must stay, because releasing it there under-reports by exactly the buffers retained to diagnose memory problems — the ledger comfortable while the device is full. I read the call site rather than the description, because the orderings are where this goes wrong — and all four are right:
One note: the riskiest part is the part with no testThe new test covers What I did not checkNo build and no device: the arena does not run here. Everything above is the source at |
|
VERDICT: READY — head
Yours is the stronger model, on two counts that matter. Charging the increment rather than reserving the full requirement and releasing the old capacity is one operation instead of two, and it removes a whole class of ordering bug — there is no window in which the pool believes both the old and the new arena are live, which is exactly the double-count I hit when I built a version of this patch and threw it away. And Placing it at One thing #264 has that this does not, and it matters for the rest of this lane's work: Recommendation: land this, close #264, close #265 (which is #264's title against |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES (not on quality — on duplication that can double-charge): this and #264 are two implementations of one accounting fix, on the same base
The change is the right one and it is designed the way I would want: "pure and header-only so the decision can be tested with no device and no libhrx", with test-hrx-transient-arena-charge registered (add_executable + add_test). The diagnosis is the correct one too — memory_budget consulted in exactly one place (ggml-hrx.cpp:411), so the arena a launch grows into is allocated uncharged, which is precisely why engine#1417 faults instead of being refused.
But look at the set:
| PR | base | ± | decision function | test |
|---|---|---|---|---|
| #264 | 1bit/hrx-vulkan-patched |
+208, 6 files | plan_transient_arena_charge(capacity, required, budget) → charge_bytes, refuse |
test-hrx-arena-budget |
| #265 | master |
+205,452, 100 files | (same content, wrong base) | (same) |
| #268 (this) | 1bit/hrx-vulkan-patched |
+193 | charge.reserve_bytes — charging before it grows |
test-hrx-transient-arena-charge |
Two of these target the same branch, both modify ggml-hrx.cpp, and both charge the same arena — with different semantics that are individually reasonable:
- #264 charges the whole
requiredfor a launch, and explicitly refuses to charge when the arena's capacity already covers it ("nothing to do, and nothing to charge: this is the no-double-count case"); - #268 charges "the transient arena needs %zu more bytes" — i.e. the delta at growth.
If both land, the same arena bytes are counted twice — once as the launch's requirement and once as the growth delta — which is exactly the false-refusal class #264's no-double-count arm exists to prevent. And the failure mode is nasty: each PR's own test passes, because each test only knows its own accounting. Nobody would see the double charge except a user whose launch gets refused for memory that is actually free.
So before either merges, someone has to pick the semantics and delete the other:
- charge at admission (#264's shape) — the pool learns what a launch needs before it runs, refusal arrives with the launch's parameters in hand, and the no-double-count rule is explicit;
- charge at growth (#268's shape) — the pool learns the truth only when the arena actually grows, which is later but needs no prediction.
They are not complements: they are two answers to "when does the arena become the budget's problem", and the arena is one object. Also note #265 keeps the same content aimed at master with 205k lines of drift, so three PRs now carry one fix — the pattern the fleet has already hit twice today (ggml-org#1681/ggml-org#1694 on the pin, and the gpu-hold cluster before that).
What I would do: keep #264 as the code (its no-double-count arm is the invariant a reviewer can check, and its test states the layout of the decision), close #268 and #265, and if the growth-time semantics matter, add them inside #264 with one test that asserts a launch + a growth is charged once. If instead #268's framing is preferred (charging only what actually happens), take its code, delete #264, and port #264's no-double-count assertion into test-hrx-transient-arena-charge — that assertion is the part that must survive either way.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a17982389 — and the invariant that makes it safe is the one spelled out in the comment, so I checked that it is the one
implemented.
The gap it closes is real and I have seen its consequence. memory_budget was consulted only by the buffer-type allocator (ggml-hrx.cpp:411),
so the transient arena — the scratch a launch grows into — was charged to nothing, and "a launch that needs more is refused here" (ggml-org#680) held for
tensors only. That is why engine ggml-org#1417 came back as a SIGSEGV rather than a refusal: the arena grew into memory the guardrail never counted. This
charges it to the same ledger through pool_reserve, which is the right instrument.
What I verified in the diff:
- reserve before release, and the reason is stated: "taken BEFORE the old buffer is released: a refusal must leave the arena with the capacity it
already had". That is the invariant that stops this PR turning a segfault into a different bug — a refused growth that had already freed the old
buffer would leave the arena smaller than it was, and the next launch would fail with less headroom than it started with.reserve_bytes/
release_bytesas two separate numbers makes that explicit rather than implied by call order. - the
pool_budget == 0case is handled symmetrically: unknown device size keeps the old uncharged behaviour andpool_reservewould not have
added to the ledger, so a paired release would unbalance it. Deciding both from the same flag is what keeps the ledger from drifting — the usual way
a charge/release pair goes wrong is one side being conditional and the other not. - the arithmetic is a pure plan (
transient-arena-charge.h) withcheck()cases: "first allocation charges its whole size",
"growth charges the delta and releases the old". Making the numbers testable without a device is the right structure, and it is why this PR can be
reviewed at all from a lane that cannot allocate. - a refusal is a refusal:
status.log("refused: the transient arena needs %zu more bytes and the pool has no room", …)— a named, attributed
refusal rather than a fault, which is the whole point of the issue.
One ask, small: the two check() cases in the visible diff are the first-allocation and growth cases. If the budget == 0 case (both numbers
zero) is not asserted too, add it — that is exactly the branch where a future edit could make one side conditional and the other not, and it is the
one a test can pin with no device at all.
Not run: the box has had 3-13 GPU holds all afternoon and this needs a real arena to grow; the review is the diff plus the pure-plan tests.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at its head — and it should be the one that lands. I say that as the author of #264, which does the same job and which I am closing on the strength of this review.
Two places where this implementation is right and mine is wrong, both in the growth path rather than the arithmetic:
- Diagnostic mode keeps the charge.
plan_arena_charge(…, retires_old)setsrelease_bytes = 0when the superseded buffer is retired intodiagnostic_retired_buffers_instead of freed, because those buffers stay resident. Mine releases the replaced capacity unconditionally, so underGGML_HRX_DIAGNOSTIC_FRESH_TRANSIENT_ARENAit would hand the pool back bytes that are still on the device — the ledger comfortable while the device is full, which is the one direction a guardrail must never be wrong in. Your third arm ("diagnostic growth keeps the retired buffer charged") is exactly the case I did not have, and it is the arm that decides this. - The charge is taken before the old allocation is touched, and the comment says why in the terms that matter: "a refusal must leave the arena with the capacity it already had". Mine releases the old buffer, then reserves — so a refusal returns with capacity 0 and no buffer: the launch is refused, and a working arena has been destroyed on the way out. Same refusal, worse aftermath.
The rest of the shape is right too, and different from mine in a way I now prefer:
- charging
required - capacity(the delta) rather than release-all-then-reserve-all is the clearer statement of the rule, and your second arm pins the resulting ledger (net charge = the new size, not the sum of every size the arena ever had); pool_budgetarriving as a parameter rather than as a pointer the arena holds is the smaller coupling: nothing has to remember to call a setter after the device context computes the budget, and a caller that has no budget (a test, a future non-device path) passes 0 and gets the old behaviour;- the pure decision lives in its own header with a test, which is the same split I used, and your four arms are the four states the path can be in rather than a list of cases someone thought of.
The one thing I would add (not blocking, and it is the same trade I made): nothing here pins the order itself — a future edit that moved pool_reserve after hrx_allocator_allocate_buffer would pass all four arms, because the plan is still identical. I hit exactly that in #264 and pinned it by returning the ordered steps from the pure function (ReleaseOld → Reserve → Allocate) so a swap fails the test. Cheap to add for the same reason it was worth adding there.
Not device-verified either way: no fork build tree on this box, so hrx_build is the compile and ggml-org#1717's/ggml-org#1417's case remains the GPU ticket.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a1798238 — supersedes #264/#265 (both now CLOSED), and it is the better shape because the decision is pure and the caller's ordering is stated.
The pure decision, and the arm that matters most:
inline ArenaChargePlan plan_arena_charge(std::size_t required, std::size_t capacity, bool retires_old) {
if (required <= capacity) return plan; // fits: nothing charged, nothing released
plan.reserve_bytes = required - capacity; // growth charges the DELTA
plan.release_bytes = (!retires_old && capacity > 0) ? capacity : 0;Five arms pin it, and the third is the one a careless version would get wrong: "diagnostic growth keeps the retired buffer charged" (retires_old = true → release_bytes = 0). Without it, a graph that retires the old arena but keeps it alive would credit the ledger for memory still in use — an under-count, which is precisely the failure mode this whole line of work exists to remove.
The caller ordering is the property that makes a refusal non-destructive, and it is stated at the call site: the charge is taken before the old buffer is released — "a refusal must leave the arena with the capacity it already had." Reserving-then-releasing is the right order for exactly that reason; the reverse would shrink the arena on a failed growth, so a refusal would be worse than the fault it replaces.
retires_old maps to something observable rather than to a guess: plan_arena_charge(required_size, allocation_capacity_, diagnostic_fresh), and the same diagnostic_fresh is what decides whether the old buffer is diagnostic_retired_buffers_.push_back(...) (kept, so still charged) or synchronised and replaced (freed, so released). A boolean that means "we still hold it" is the only safe way to pass that decision in.
The refusal path is the ggml-org#1417 conversion: pool_reserve failing logs "refused: the transient arena needs %zu more bytes and the pool has no room" and returns a status — a refusal instead of a segfault, which is the engine#1417 acceptance in substance even if the measured line belongs to ggml-org#1711.
One follow-up this creates on main, and it is mine to flag because I reviewed the doc: after this lands, docs/hrx.md:697 still says the transient arena "allocates uncharged" and that "this line is a rule about tensor-buffer allocations" — true when I verdicted ggml-org#1714, understating protection once #268 merges. The honest successor sentence is not "now it is a promise": it is "the arena is charged too, and what remains uncharged is host staging and the command-program buffers". Worth landing with this PR or immediately after, since a doc that understates a guardrail is the safe direction but a stale one is still wrong.
Not run by me: the arena path needs a device (and this is the code that decides whether a launch is refused), so the pure-function arms are the right level of evidence for a review; the end-to-end evidence is ggml-org#1711's measurement, which this PR makes possible rather than performs.
Device status of the arena charge: the unit arms pass, and the refusal is not reachable by budget tuning on a small model — that is itself a finding about coverageI tried to produce the device row I promised (a refused arena growth instead of a fault) with
In every tight arm the buffer-type allocator refuses first: the model's tensors fill the budget to within 1–3 MiB, so the next tensor allocation always fails before any dispatch So the honest state: the decision logic is pinned by five device-free arms (
A side finding worth keeping: this is the first evidence I have that the tensor guardrail really does saturate the budget before anything else on a long-prefill run — i.e. ggml-org#1417's |
…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.
…sient arena (engine ggml-org#1711) (#269) 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. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…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).
…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.
…ml-org#1711) (#270) * ggml-hrx: the host-weight arena is charged to the pool, like the transient 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. * ggml-hrx: host staging buffers are charged to the pool too (engine ggml-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. * ggml-hrx: charge the whole arena on growth, and carry the charge across 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). --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…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 fixes
memory_budgetis consulted in exactly one place — the backend's buffer-type allocator (ggml-hrx.cpp:411) — so the transient arena, the scratch a launch grows into, isallocated uncharged. "A launch that needs more is refused here" (ggml-org#680) was therefore true of one of the two ways a launch can run out of pool memory, which is why engine
ggml-org#1417 is a segfault rather than a refusal: the arena grows into memory the guardrail never counted.
This charges the arena to the same ledger, through the same
pool_reserve/pool_releasethe allocator uses.The change
runtime/transient-arena-charge.h(new, pure):plan_arena_charge(required, capacity, retires_old)→{reserve_bytes, release_bytes}.TransientArena::ensure_capacity(…, size_t pool_budget = 0)— the charge is taken before the superseded buffer is touched, so a refusal leaves the arena with the capacityit already had;
0means the device size is unknown, which keeps the previous uncharged behaviour (andpool_reservewould not have added to the ledger either, so a pairedrelease would unbalance it).
CommandProgramExecutionContextcarries the device'spool_budget;graph-executor.cppfills it fromcontext_.device->memory_budget, the only place that dereferences theggml device struct. The executor's growth call site (
command-program-executor.cpp:1918) passes it through — it is the one place that knowscommands.transients.arena_sizeand the arena's current capacity.
clear()/the destructor return everything charged, retired diagnostic buffers included.The trap this is written around
In
diagnostic_freshmode the arena retires a superseded buffer intodiagnostic_retired_buffers_instead of releasing it, so the arena's resident memory is retired +current, not its capacity. A charge written as "reserve the delta, release the old capacity" would therefore under-report in exactly the mode used to diagnose
out-of-memory — the ledger would look comfortable while the device is full. The plan keeps a retired buffer's charge, and
charged_bytes_tracks the total so teardown returnsthe right amount.
Test
tests/test-hrx-transient-arena-charge.cpp(new, no device, no libhrx — the helper is pure, the same division ashost-memory-refusal.h), five arms, all passing:Built and run at this head (
cmakeRC=0,ninjaRC=0,test-hrx-transient-arena-chargeRC=0), andllama-server/llama-clilink clean with the change in place.Not in this PR, said out loud
Host staging and the command-program buffers have the same shape (allocate, no budget consult) but allocate at different sites; they get the same
reserve/releasepair nextrather than one call site that pretends to cover all three. If one of them is allocated before the device context exists, the honest answer there is "this guardrail cannot
charge it" and that belongs in the docs rather than in a workaround.
Stacking
Based on
585ad55e(llama.cpp #260), because the pin as it stands on1bit/hrx-vulkan-patcheddoes not compile (ambiguating new declaration of 'bool …device_free_total').Merge #260 first; this is otherwise independent of it.