Repository navigation
ggml-hrx: the host-weight arena is charged to the pool, like the transient arena (engine #1711) - #269
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.
Exercise arm for the host-weight charge:
|
The arm ran; what it shows, and what it does not
The prefill drop is the knob, not this PR — the knob did something much bigger than I expected: A 27.5 GiB weight left the device, so the path that ran is not the path the previous number measured. That is the knob's intended behaviour and it makes the prefill figures So the wiring is still not exercised, and I am not going to claim it is. What the run does establish:
The arm that would settle it is a limit above the largest weight, so the cache actually materialises and grows: Where that leaves this PR: the decision is pinned by 8 pure arms; the wiring compiles, links and does not perturb a real 18.8k run; the positive exercise of the growth path |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 203aac2cfb02b91a4993c6b1644486daf83a56ff — The host-weight arena is now charged to the pool (+?), i.e. the same discipline #264 applied to the transient arena extended to the host side — a budget that only covers one of the two arenas is not a budget, it just moves the allocation that breaks.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at +56/−5 head — the third arena charged, and the ledger bookkeeping is paired at every site, which is the only thing that can go wrong here.
The framing is exactly right and worth keeping: GGML_HRX_HOST_WEIGHT_LIMIT_MB limits how much host-weight 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. A limit is not an account." That sentence is the whole PR, and it is the same defect as #268's transient arena one layer over: a budget that bounds behaviour but is invisible to the ledger.
It reuses #268's decision function rather than growing a second one: plan_arena_charge(target, previous_capacity, false) — the false is retires_old, i.e. growing past the cap releases the superseded capacity, which is right for a regrow that frees the old arena. One pure rule, three callers, is the outcome I hoped for when I reviewed #268.
The four ledger sites are all paired, and I checked them because a double-release under-counts, which is the unsafe direction:
charged_bytes_ += reserveafter a successfulpool_reserve;- rollback after a later failure:
pool_release(charge.reserve_bytes); charged_bytes_ -= min(reserve, charged_bytes_)— releases exactly what it took, with an underflow guard; - grow:
pool_release(charge.release_bytes)alongside the matching decrement; - teardown:
if (charged_bytes_ > 0) { pool_release(charged_bytes_); charged_bytes_ = 0; }— guarded, so no spurious release and no leak.
No site releases without a preceding charge, and the destructor releases only the remainder rather than a remembered total — which is what keeps a rollback from being released twice.
A refusal, not a fault, on the failure path: pool_reserve failing logs "growing the host weight arena to %.1f MiB needs %zu more bytes and the pool has no room" and returns false. So the guardrail converts this arena's over-commit from an opaque failure into a named refusal, which is the ggml-org#1417 acceptance in the second of its two places.
Test covers the three decisions (first growth charges the whole; a regrow to the same budget charges nothing; growing past the cap releases the superseded capacity) — arms on the pure function, which is where they can run without a device.
Not run: no device ticket taken, and this path decides admission, so hardware is where the end-to-end proof lives (ggml-org#1711's measurement). With #268 merged and this on top, the remaining uncharged allocations are host staging and the command-program buffers — the doc follow-up I flagged on docs/hrx.md:697 gets closer to honest with each of these.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 203aac2: READY
This is the second slice and it does the thing I asked for in ggml-org#1711's verdict: it reuses the planner instead of writing a second one.
+ #include "transient-arena-charge.h" // engine#1711: the same charge decision as the transient arena
+ const ArenaChargePlan charge = plan_arena_charge(target, previous_capacity, false);
+ bool charge_taken = false;
+ context.host_weights->acquire(context.device, context.stream, *context.host_transfers, source, context.pool_budget);"the same decision the transient arena uses" is the property that keeps the three chargeable things from drifting — the whole point of plan_transient_arena_charge() returning a struct
was that the arithmetic lives in one place, and this is the first test of whether that held. It did.
And the failure it fixes is stated the way it should be: "Without this, a large residency is invisible to pool_reserve and the guardrail over-commits by however much the arena
holds." That is a description of a specific wrong number, not a general worry — a residency the guardrail cannot see is exactly how ggml-org#1417's class survives a budget that "should" have
refused it.
charge_taken is the #264 lesson applied: the charge is taken before the growth and released if the allocation fails, so a refusal leaves the arena at its previous capacity rather
than destroying it. That was the difference between #264 and #268, and this PR has it by construction because it reuses the same plan.
One thing to keep in the doc: this is the second of three. ggml-org#1714's paragraph lists the transient arena, host-visible staging and command-program buffers as uncharged; after this, two
of the three are charged and only the command-program buffers remain. Whoever lands this should edit that list in the same pass — not because the list is wrong today, but because a
paragraph that stays accurate only until the next PR is the one a reader trusts at the wrong moment.
|
VERDICT: READY — head 1. It reuses 2. The budget it charges against is current, not a snapshot — I checked, because that was the open question in #264/#268.
So this composes with #256 rather than fighting it: the arena is charged against the same figure admission uses, whatever that figure is at the moment of growth. One robustness note, and it is about the assignment I just relied on. That initialisation is positional, so a new member inserted before const CommandProgramExecutionContext execution_context = {
.device = context_.device->device,
…
.pool_budget = context_.device->memory_budget,
};Same semantics, and it makes "which member is the budget" a name rather than a position — worth doing the next time anyone touches that struct, since three charges now depend on it. Scope, stated because the body is careful: this makes host-weight residency visible to the ledger; it does not bound it. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 203aac2cfb02b91a4993c6b1644486daf83a56ff
Base is 1bit/hrx-vulkan-patched, not master.
The right fix for the same hole #265 closed one layer over, and it reuses the decision instead of re-deriving it. plan_arena_charge() is already shared — host-memory.cpp:1748 and transient-arena.cpp:95 call the same header-only function (transient-arena-charge.h:41) — so the ledger cannot answer "how much do I charge for a grow" differently for the two arenas. That is the property I would have asked for: two arenas making the same accounting decision in two places is how a ledger starts disagreeing with itself.
The bug is stated in the terms that matter: "a large residency is invisible to pool_reserve and the guardrail over-commits by however much the arena holds, which is what GGML_HRX_HOST_WEIGHT_LIMIT_MB limits but never accounts." Limiting and accounting are different jobs and the fix names the distinction — the limit caps one arena; only the ledger makes the total visible.
The three behaviours the test pins are exactly the three that can go wrong, and the first is the one a naive fix gets wrong:
first growth charges the whole arena (reserve 4096 MiB, release 0)
a regrow to the same budget charges nothing (reserve 0, release 0)
growing past the budget releases the superseded arena's charge in the same step
The middle line is the important one: a cache that keeps the same arena across many weights is the common case, so charging on every grow would double-count it continuously. retires_old = false is the correct parameter for this arena (it grows straight to its budget), and having it as a parameter of the shared planner rather than a copy is what makes that checkable.
One detail worth confirming in review, since it is the classic ledger leak: the release paths. The code tracks charge_taken and releases charge.reserve_bytes on the failure path, releases release_bytes for the superseded arena, and pool_release(charged_bytes_) on clear — with charged_bytes_ kept separately from arena_capacity_, exactly so "clear returns what was charged, including the case where a grow released a superseded arena". That comment is the one that shows the author thought about the double-free side of the ledger rather than only the double-charge side.
Not built: no llama.cpp build tree here; the diff is 4 files and the reasoning above is from the code. The test is device-free by construction (it drives the planner), so it can run on any box — that is the right level for an accounting change.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 203aac2: READY
The order is right this time. In HostWeightCache::arena_reserve the pool charge (pool_reserve(pool_budget, charge.reserve_bytes, …)) is taken before allocate_device_buffer, so a refusal leaves the existing arena intact and reaches no allocator. The charge is given back if the allocation fails, the old capacity's charge is released when the grown arena replaces it, and clear() returns the tally. That is the admission-before-allocation order I asked for on #264. It reuses #268's pure plan_arena_charge(target, previous, retires_old=false), which is right because this arena releases the superseded buffer rather than retiring it.
Checked at this head: tests/test-hrx-transient-arena-charge.cpp built standalone gives "all arms passed", including the two new host-weight arms ("a regrow to the same budget charges nothing", "growing past the budget swaps charge for charge"). host-memory.cpp and command-program-executor.cpp compile with -fsyntax-only -Werror and the private hrx-system includes (rc 0). git merge-tree onto the pin tip is clean.
Non-blocking: charged_bytes_ += charge.reserve_bytes runs outside mutex_, while the decrements on release and clear() run inside it. arena_reserve already drops the lock between reading previous_capacity and swapping in the grown arena, so callers must serialise growth anyway, and this adds no new hazard. Still, taking the lock for the increment would keep charged_bytes_ consistent with its readers by construction.
A question about double counting, from following the two terms throughI verified this in the engine pin before asking (the pool test in The charge enters the ledger. The same bytes are already in the host term. Since #256 the budget is So for one X of host-weight arena growth the guardrail sees a reduction of 2X: X through the ledger (the reservation) and X through Two readings, and I would rather you pick:
I have no measurement of a refused load attributable to this; the arithmetic is what I am going on, plus the fact that the two sources are a ledger and |
Positive device proof — the growth path does refuse, and it does not need the 32 GiB routeI had said this PR's wiring was "reviewed, not yet exercised" and that reaching the growth path needed a residency limit above the model's largest weight (a ~32 GiB What this establishes, in order:
And it produces a second, independent refusal the code already knew how to make (the arena-full-at-limit path, engine ggml-org#900), which is a good sign rather than a problem: the two guards fire in the Connected to engine ggml-org#1618 (a failed host-memory device buffer allocation surfacing as Cost: one |
…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).
…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>
…ode cannot pierce the pool (engine ggml-org#1823) (#275) #269 charged the host-weight *arena*, which is the mode an operator opts into with GGML_HRX_HOST_WEIGHT_LIMIT_MB. The default is that variable UNSET (0 = off = per-weight residency), and that branch allocated each materialised weight its own device buffer at host-memory.cpp:2009 with no charge, freed in ~HostWeightLease::Entry with nothing to release. So the earlier claim that all three allocators were charged held for the opt-in mode and not for the mode everybody runs - the wrong way round for a guardrail. The weight is now charged against the pool before the allocation (a pool that cannot hold it is a refusal naming the weight, a refusal this path has never had), returned if the allocation fails, and released once in ~Entry, which owns exactly one buffer. Observed on hardware with the default mode (no GGML_HRX_HOST_WEIGHT_LIMIT_MB) at GGML_HRX_POOL_LIMIT_MB=700: 'a materialized host weight of 121.7 MiB does not fit (674.9 MiB of 700.0 MiB already in use)' then 'refused: a 121.7 MiB host weight does not fit the pool'. Before this change that run materialised it uncharged. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
) #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>
What this adds
Second slice of engine#1711, on top of the merged #268 (the transient arena).
GGML_HRX_HOST_WEIGHT_LIMIT_MBlimits how much host-weight residency the cache keeps, butnothing charged it to the pool ledger — so a large host-weight arena was invisible to
pool_reserveand the guardrail over-committed by however much the cache held. A limit is notan account.
The change
HostWeightCache::arena_reserve/acquiretake the device'spool_budget; the twoacquirecall sites incommand-program-executor.cpppasscontext.pool_budget, which thecontext has carried since ggml-hrx: the transient arena is charged to the pool before it grows (engine #1711) #268 — so no new plumbing was needed beyond the parameter.
plan_arena_charge(target, previous_capacity, /*retires_old=*/false), because this arena releases thesuperseded buffer (
hrx_buffer_release(arena_)) rather than retiring it — the two paths differ only in that argument, which is exactly why the decision is a helper.superseded arena, and returned in full by
clear()via acharged_bytes_tally.Test
Three arms added to
tests/test-hrx-transient-arena-charge.cppfor the shape this path has, all passing (no device, no libhrx — the helper is pure):The middle arm is the one that matters in practice: this cache keeps one arena across many weights, so a version that charged on every regrow would double-count the steady state
— the common case, not the edge case.
Built at this head:
cmakeRC=0,ninja llama-serverRC=0, test RC=0 (8 arms).Still not charged, and said out loud
Host staging buffers (
host-memory.cpp:2104, mapped host-visible) remain uncharged by this PR. They are the third and last allocator of this shape; they will need the samepair at their own growth point, and if their lifetime is shorter than a dispatch the accounting needs a release on unbind rather than on growth. I would rather do that as its own
slice than fold it in here untested.
On the evidence for this one
The unit arms pin the decision; what they cannot show is the wiring under load. For #268 the equivalent check was an 18.8k-token run at the pin (356.7 tok/s prefill, no refusal,
no double-count symptom) — the same run would exercise this path and I have not re-taken it at this head, so treat the wiring here as reviewed-not-yet-exercised until it runs.