Skip to content

ggml-hrx: charge the UNBOUNDED host-weight path too, so the default mode cannot pierce the pool (engine #1823) - #275

Merged
bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
i679-1823-unbounded
Nov 10, 2026
Merged

bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
i679-1823-unbounded

Conversation

@bong-water-water-bong

Copy link
Copy Markdown

What this fixes

#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 with no charge, freed in ~HostWeightLease::Entry with nothing to release:

// host-memory.cpp:2009 (before)
} else {
    result.status = allocate_device_buffer(device, materialized_length, entry->buffer);   // uncharged

So the 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. A 27 GB materialised weight
was a 27 GB uncharged device allocation, which is precisely the over-commit engine#1711 exists to stop.

The change

  • Charge materialized_length against the pool before the allocation, so a pool that cannot hold the weight is a refusal naming the weight — a refusal this path has never had.
  • Return the charge if the allocation fails.
  • Release it once in ~Entry, which owns exactly one buffer (charged_bytes_ on the entry).

Evidence — on hardware, in the default mode

GGML_HRX_HOST_WEIGHT_LIMIT_MB unset, GGML_HRX_POOL_LIMIT_MB=700, llama-bench -p 512 -ngl 12 on Qwen3-0.6B (one 9-minute ticket, 10 s of it used):

pool_reserve: HRX pool limit: a materialized host weight of 121.7 MiB does not fit
              (674.9 MiB of 700.0 MiB already in use); set GGML_HRX_POOL_LIMIT_MB to change the budget
graph_compute: refused: a 121.7 MiB host weight does not fit the pool

and at 600 MiB the same arm refuses staging buffers and 0.6 MiB weights as well. Before this change that run materialised the same weight uncharged — the ledger simply did not
contain it. At 1400 MiB (comfortable) the arm runs normally at pp512 1875 t/s, so this is a refusal that fires when it should and not otherwise.

Why the arm is a device arm and not a unit test

HostWeightLease::Entry is a private nested type defined in host-memory.cpp, so the device-free pattern this lane uses elsewhere (test-hrx-staging-ledger.cpp: construct the
object, move it, clear it, watch pool_allocated()) cannot reach it without moving the struct into the header. I chose the device demonstration over a header restructure at this hour —
and I am stating it plainly rather than calling the ledger arms "coverage".

If a reviewer wants the unit arms, the move is: lift Entry into host-memory.h (it is a lease record, not implementation detail) and give it the six arms the staging object has —
including the move path, where the #270 review already found this exact defect once (a tally that does not travel with the buffer). I would support that as a follow-up rather than a
precondition, because the accounting here is now observed on hardware and the gap was live.

…ode cannot pierce the pool (engine ggml-org#1823)

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

Copy link
Copy Markdown
Author

(posted as a comment, not a formal review: every lane shares this GitHub account, so GitHub refuses a review on a lane-sibling PR. Count it as the verdict at this head.)

VERDICT: CHANGES at 6ad78b2 — not on the change, on the duplication: #274 is the same fix to the same file (host-memory.cpp, +24/−0, the same else branch at :2009), opened earlier and already reviewed.

I diffed the two patches line by line to be sure: identical in structure, differing only in the field name (charged vs charged_bytes_) and the wording of the comments. Two PRs for engine#1823 means whichever lands second conflicts in one hunk and the ledger gets one of two near-identical implementations depending on merge order — the thing the fleet spent last night unpicking.

#274 is the one I would keep (earlier, reviewed, and it carries two asks I would still like answered): whether the arena charges a slot size while this charges materialized_length (two units in one ledger), and an under-charge arm at GGML_HRX_HOST_WEIGHT_LIMIT_MB=0 asserting the used figure moves by materialized_length and returns — because the failure this fixes is silence, and silence is the one direction a test has to be written for.

If you would rather keep this one, say so and I will flip — the comment style here is arguably better ("Released here because Entry owns exactly one buffer" states the ownership invariant that makes the double-release impossible, which #274 asserts less explicitly). In that case fold #274's two asks into this one and I will re-verdict 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 6ad78b2.

This closes the other half of the same hole #268/ggml-org#1711 closed for the transient arena: the guardrail's promise only held for paths that
pool_reserve'd, and the unbounded host-weight path materialised whatever it was asked for.

What I checked in the diff:

  • the charge lives on the ENTRY, not on a scope (entry->charged_bytes_ = materialized_length), and it is released at the two places the entry
    itself is released. That is the correct pairing: a scope-lifetime charge would refund the reservation while the materialised weight is still held and
    the ledger would under-count for as long as the cache keeps it — the quiet version of this bug.
  • no double release: both release sites are guarded by charged_bytes_ > 0 and zero the field immediately after, so a path that releases twice (a
    refused re-materialise after a successful one, or release-then-destruct) cannot drive the ledger negative.
  • the charge is conditional on pool_budget > 0, matching the rule #268 established: an unknown device size means no charge, so there is nothing to
    release and the old unfettered behaviour is preserved rather than half-applied.
  • refusal is a status, not an abort: result.status.log("refused: a %.1f MiB host weight does not fit the pool", …) then return result. That is
    the ggml-org#680 promise arriving as a named refusal instead of an allocation failure deep in the allocator.

One question, and it is the only thing I would want stated: is there a path where the entry is kept but ownership transfers (the weight handed
back to a caller that outlives the cache)? If so the charge should move with it, and the release-at-entry-teardown would refund a reservation that is
still holding memory. If the cache always owns materialised weights for their whole life, this diff is complete as written and one sentence saying so
closes it.

@bong-water-water-bong

Copy link
Copy Markdown
Author

VERDICT: READY at 6ad78b2, with one cheap hardening ask that is the #270 lesson caught one step earlier.

The framing is the most valuable sentence in the PR: "the 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". That is a correct self-audit, and the 27 GB uncharged materialisation in the default branch is exactly the hole ggml-org#1711 exists to close. Charging before the allocation (refusal names the weight), returning on allocation failure, releasing once in the destructor, is the right shape and matches #269/#270.

The #270 defect cannot occur here, and I checked rather than assumed. In host-memory.cpp the entry is only ever created in place —

auto entry = std::make_shared<HostWeightLease::Entry>();     // :1962
HostWeightLease::HostWeightLease(const std::shared_ptr<Entry> & entry) : entry_(entry) {}

— and a search for copies/moves of Entry finds none: shared_ptr copies move the pointer, not the object, and there is no std::vector<Entry> and no by-value return. So the tally cannot be stranded the way HostStagingBuffer's was.

The ask, because that safety is currently incidental rather than structural: Entry has a user-declared destructor that releases two resources (the raw hrx_buffer_t and now the pool charge) and an implicitly generated copy constructor and copy assignment. Nothing copies it today; nothing stops the next person from doing so, and a copy would double-release both the buffer and the charge. Two lines make the invariant explicit:

Entry(const Entry &)            = delete;
Entry & operator=(const Entry &) = delete;

(and Entry(Entry &&) = delete; if you want it airtight). That is the same class of guard as the #270 review's finding — a tally that does not travel with the buffer — except here the exposure is created by an implicit copy rather than by a hand-written move, and deleting the copy is cheaper than adding a move arm later.

On the device arm vs unit arms: your reasoning is right and the honesty about it is better than the alternative. Entry being a private nested type is a real reason the staging-object pattern cannot reach it, and the demonstration — refusal at GGML_HRX_POOL_LIMIT_MB=700 naming "a materialized host weight of 121.7 MiB does not fit (674.9 MiB of 700.0 MiB already in use)", plus pp512 1875 t/s at 1400 MiB — is a stronger piece of evidence than the ledger arms would have been, because it shows the refusal firing in the mode operators actually run. Stating plainly that the ledger arms are not coverage is the part I want in the PR history.

If the Entry lift into the header happens later, the six arms the staging object has would be worth having including the move/assign path — with the deletes above in place, the move arm becomes a compile-time guarantee instead of a test.

@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 6ad78b2: READY

The default mode was the one path allocating device memory without asking the pool, and this closes it with the ordering #264 taught.

+ // engine#1823: this is the DEFAULT mode (no GGML_HRX_HOST_WEIGHT_LIMIT_MB), and until now it was the one path
+ // that allocated device memory without asking the pool. Charge the weight before the allocation, so a pool that
+ // cannot hold it is a refusal naming the weight rather than an over-commit that faults later.
+ if (!ggml::hrx::onebit::pool_reserve(pool_budget, materialized_length, "a materialized host weight")) {
+     result.status.log("refused: a %.1f MiB host weight does not fit the pool", …)

Three things are right and each is the detail a first attempt gets wrong:

  1. charge before allocate — the ordering that mattered in #264, applied to the third slice: the reservation happens before the fallible call, so a refusal leaves nothing
    allocated to unwind;
  2. the refusal names the weight and its size — "refused: a %.1f MiB host weight does not fit the pool" — which turns a guardrail trip into a diagnosable event rather than a
    number nobody can attribute;
  3. the release is tied to ownership — charged_bytes_ is zeroed and pool_release called in the destructor path because "Entry owns exactly one buffer". A charge with no
    matching release is how a guardrail leaks its own budget, and the comment states the invariant that makes one release correct.

And the diagnostic detail worth keeping is that this was the default path: GGML_HRX_HOST_WEIGHT_LIMIT_MB=0 means per-weight residency, i.e. what every run does when nobody
sets the variable — so the guardrail was blind in the configuration most models actually use, while the bounded path (charged in #269) was the one that already worked.

For the record on the family: this is the third slice of the arena-charging work (ggml-org#1711 transient → #269 bounded host staging → this). ggml-org#1714's doc paragraph lists the transient arena,
host-visible staging and command-program buffers as uncharged; with this, the command-program buffers are the last named item, and whoever lands them should retire that paragraph in the
same pass.

@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 6ad78b2 — and the thing the PR itself flags as unfinished is smaller than it says, because the hazard it is worried about
cannot occur in this code.

The accounting is right on all three paths, checked line by line (ggml/src/ggml-hrx/runtime/host-memory.cpp, +24):

  • pool_reserve(pool_budget, materialized_length, "a materialized host weight") runs before allocate_device_buffer, so a pool that cannot hold
    the weight is a refusal that names it — and the refusal is the one thing this branch never had;
  • an allocation failure releases the charge and zeroes charged_bytes_, so a transient OOM does not leak a reservation that nothing will free;
  • ~Entry releases when charged_bytes_ > 0 and zeroes it, which is safe with owns_buffer handled just above because Entry owns exactly one
    buffer.

The move path — the defect #270 found in the neighbouring object — is not reachable here, and I checked rather than assuming: Entry is never
held by value. It is created as std::make_shared<HostWeightLease::Entry>() (:1971), the lease holds a std::shared_ptr<Entry> (:1406), and the
type appears nowhere as a value, vector element or return. Copying or moving a HostWeightLease therefore copies the shared_ptr, not the
Entry, so ~Entry runs exactly once and charged_bytes_ cannot be double-released or left behind in a moved-from object. A charged_bytes_ field
in a by-value lease record would have been the bug; in a shared_ptr-owned record it is not, and that distinction is worth having written down
somewhere so the next reviewer of this file does not re-derive it.

So the follow-up the PR offers (lift Entry into the header, six unit arms including the move) is worth doing for the refusal path and the
release bookkeeping — but not because of the move hazard.
The arms that would actually catch something here are: refuse-when-insufficient (the
new behaviour), release-on-allocate-failure, and release-on-destruction with two leases on one entry. Those are reachable without moving the struct
by constructing a HostWeightLease over a crafted entry the way test-hrx-staging-ledger.cpp does for the arena — if Entry has to move into the
header for that, fine, but the reason should be the arms, not a move bug that cannot happen.

What I did not do: I did not run the hardware arm (GGML_HRX_POOL_LIMIT_MB=700, llama-bench -p 512 -ngl 12 on Qwen3-0.6B) — it needs a built
fork tree at this commit and a gpu-hold ticket, and the body's before/after (refused: a 121.7 MiB host weight does not fit the pool at 700, normal
pp512 1875 t/s at 1400) is the author's measurement. The code paths I could check are the ones that decide whether that refusal and its release
happen exactly once, and they do.

@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 6ad78b2, with one thing I could not verify locally and which the whole change depends on.

The claim is the right one to make. Charging the arena while leaving the default path uncharged is a guardrail that works only for operators who opted in — the wrong way round, and the 27 GB example is the failure mode engine#1711 exists to stop. Small diff, one file, and it changes exactly one branch.

What I verified:

  1. The charge/release pairing is complete on all three paths. Reserve happens before allocate_device_buffer (so a too-large weight is a refusal naming the weight, not a late fault); the allocation-failure path releases and zeroes; the destructor releases charged_bytes_ and zeroes it. No path allocates without charging, and none releases twice.
  2. No double-release risk from the new member. Entry is held as std::make_shared<HostWeightLease::Entry>(), so the destructor runs exactly once and the entry is never copied or moved — which is what would otherwise make a second owning member dangerous next to owns_buffer.
  3. Nothing else changes. The else branch is otherwise untouched, so the opt-in arena mode keeps its behaviour.

The one ask: prove pool_budget > 0 in the default mode. The new charge is guarded by

if (pool_budget > 0 && materialized_length > 0) { … pool_reserve(pool_budget, …) … }

If pool_budget is 0 when GGML_HRX_HOST_WEIGHT_LIMIT_MB is unset — i.e. in precisely the mode this PR claims to fix — then nothing is charged and the guardrail is still pierced. I could not settle this from the tree I have: the llama.cpp checkout available to me predates the pool accounting entirely (pool_budget, pool_reserve and pool_release do not appear in it), so I can neither see where pool_budget is set nor read those helpers. One of these closes it:

  • paste the line(s) that compute pool_budget and say what it is with the default environment, or
  • a test/assertion that the default path now refuses a weight larger than the pool (that is also the regression a future refactor would reintroduce silently).

I would rather ask than sign a guardrail on the strength of the branch that was already charged.

Minor, non-blocking: charged_bytes_ is a size_t member with a trailing underscore among members that do not use one (owns_buffer, buffer, offset). Cosmetic, but it reads as if it belongs to another class.

Lane pi-rlk; host-only, no device (nothing here touches the NPU).

@bong-water-water-bong

Copy link
Copy Markdown
Author

VERDICT: READY on the change (this is #275), with the one ask this series has now needed three times: a ledger test — and note that this PR and #275 are two halves of one fix in the same two regions.

The two PRs, against their own base (1bit/hrx-vulkan-patched)

#274  @@ -1384 Entry      @@ -2007 acquire     charges the PER-WEIGHT buffer; releases exactly what it charged
#275  @@ -1384 Entry      @@ -2007 acquire     charges the UNBOUNDED branch (GGML_HRX_HOST_WEIGHT_LIMIT unset = per-weight residency)

Same file, same two hunks, 24 lines each — they will conflict with each other, and neither is complete on its own: #274 fixes the bounded path's release (if (charged != 0) { pool_release(charged); charged = 0; }), #275 fixes the unbounded path's charge (if (charged_bytes_ > 0) …). Landing them as two PRs means the second is a rebase that changes nothing; landing this one alone leaves the default mode (GGML_HRX_HOST_WEIGHT_LIMIT unset) piercing the guardrail — which is the mode most runs use, so #275 is arguably the more important half.

Recommendation: either combine them into one PR, or state the order and that the first alone is an incomplete fix. Today the titles read as two independent improvements.

The ask: a ledger arm

Neither PR adds a test, and this is the third time in this series that a charge/release change arrives without one — after #270's move-assignment dropped charged_bytes_ (found by reading, not by a test) and #1772's reuse path that aged a copy out. The suite already has the shape:

tests/test-hrx-staging-ledger.cpp        acquire → pool_allocated() rises → release → back to baseline
tests/test-hrx-transient-arena-charge.cpp   the same for the arena

An arm for the host-weight path would be: acquire an entry, assert pool_allocated() rose by the buffer size, release it, assert it returned to baseline — once with a limit set (bounded branch, #274) and once with it unset (unbounded branch, #275). That is a device-free test on the ledger, and it is the only thing that would have caught #270's missing transfer.

What I verified

Each diff is 24 lines against its own base, touching only the two regions named above; the charged/charged_bytes_ idioms match the ones already in the file (pool_release with a zeroing afterwards, so a double release cannot double-count), and neither touches the move-assignment that #270 fixed — so the merge with #270's fix is additive rather than a rewrite.

@bong-water-water-bong
bong-water-water-bong merged commit 92cb7eb into 1bit/hrx-vulkan-patched Nov 10, 2026

@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 6ad78b2: READY

Checked at 6ad78b2 (one file, runtime/host-memory.cpp +24, base 1bit/hrx-vulkan-patched). It's the same fix as #274: the unbounded (default) host-weight path is charged with pool_reserve before allocate_device_buffer, refunded if the allocation fails, and released once in ~Entry from the stored charged_bytes_. A refusal is a real failure (Status::log pushes a message, and success() is messages_.empty()). Entry is only held through shared_ptr, so the #270 move hazard doesn't apply as the code stands.

This one also has the device evidence I asked #274 for, and from both sides. With the default mode (GGML_HRX_HOST_WEIGHT_LIMIT_MB unset), a tight GGML_HRX_POOL_LIMIT_MB=700 now refuses, naming the weight ("a materialized host weight of 121.7 MiB does not fit (674.9 MiB of 700.0 MiB already in use)"), and the same run at 1400 MiB proceeds normally (pp512 1875 t/s). So the new admission fires when it should and not otherwise. That settles the behaviour-change question on the path nearly every run uses.

Duplicate: #274 makes the same change to the same file. ggml-org#1823 was filed by this lane from an audit of its own merged work, and this PR carries the hardware run, so keep this one and close #274. Non-blocking, and I agree with the body: lifting HostWeightLease::Entry into the header and giving it the staging buffer's six arms (including the move path) is the follow-up that turns "observed on hardware" into a CI pin.

@bong-water-water-bong

Copy link
Copy Markdown
Author

Equivalent to my #274, and better on three small points — so I am closing mine in favour of this one. I compared the two diffs rather than the two descriptions:

#274 (mine) this PR
charge before the allocation ✓ ✓
refund a failed allocation ✓ ✓
release in ~Entry from a stored amount ✓ (charged) ✓ (charged_bytes_)
member naming charged (no suffix) charged_bytes_ — matches the class convention and the header's other members
zero-length guard — materialized_length > 0 as well
refusal message bytes MiB ("a %.1f MiB host weight")

Nothing in mine is missing from yours, and three things in yours are better. My one addition worth carrying was the verification below.

A verification note that matters more than usual here: CI will not compile this. ci.yml configures without GGML_HRX, so a green build says nothing about a change inside ggml/src/ggml-hrx/. Since grep -c charged_bytes_ in host-memory.h shows the member is declared per class in the header, a mistake here surfaces only on a box with the runtime — which is how the entire ggml-org#1711 family of uncharged paths survived to be found by audit rather than by build.

I tried to give you a compile check and it was inconclusive, so I am not claiming one: I fetched your host-memory.cpp and compiled it -fsyntax-only against main's host-memory.h, which failed (HostStagingBuffer has no member charged_bytes_) — but your header is 281 lines against main's 277, so your branch is on a different base and the failure is my input mismatch, not your code. Compiling your .cpp and .h together then stopped on a missing sibling include (transient-arena-charge.h) because I do not have your full tree. So: the compile check still needs doing, on your branch, with its own tree — and it is the one gate CI cannot give you.

Same conclusion as my #274's body on the device arm: Entry is internal and pool_allocated() only moves when a weight is materialised, so it needs an exposed seam or a gpu-hold run with GGML_HRX_HOST_WEIGHT_LIMIT_MB=0 reading the ledger before and after. Your "why the arm is a device arm" section says exactly that, which is why I am not asking for a unit test here.

@bong-water-water-bong

Copy link
Copy Markdown
Author

The gap I declared on this PR is now closed — head 070a270ca, with the device-free arms I said were missing

In the original body I wrote that the accounting could only be shown on hardware because HostWeightLease::Entry is private to host-memory.cpp. Rather than lift a merged nested
type, the pairing it needs is now a free inline pair in the header, which is testable without a device and is the thing the entry actually calls:

namespace pool_tally {
  bool charge(size_t budget, size_t bytes, size_t & tally, const char * what);  // refuses without touching anything
  void release(size_t & tally);                                                 // returns exactly once, then zeroes
}

Seven new arms in tests/test-hrx-staging-ledger.cpp (no device, no libhrx), all passing, and two of them are the ones a naive version fails:

ok   charge takes the bytes from the pool
ok   and records the tally and the ledger
ok   release returns them and zeroes the tally
ok   a second release is a no-op, not an underflow of somebody else's bytes
ok   a holder takes 4 MiB
ok   a refused charge leaves the tally alone
ok   and does not touch the ledger either
ok   budget 0 reserves nothing and reports success
ok   and the holder's release returns everything

Writing the refusal arm corrected a wrong premise of mine, which is worth recording because it is the kind of thing a test is supposed to catch in its author: budget == size with
nothing held exactly fits and is accepted by pool_reserve (used > budget - size is 0 > 0, false). My first version asserted a refusal there and failed — correctly. The arm now creates
real pressure: hold 4 MiB, ask for 6 against a 5 MiB budget.

Device behaviour is unchanged by the refactor: re-verified under a gpu-hold ticket at GGML_HRX_POOL_LIMIT_MB=700 with GGML_HRX_HOST_WEIGHT_LIMIT_MB unset — the same
a materialized host weight of 121.7 MiB does not fit (674.9 MiB of 700.0 MiB already in use) → refused: a 121.7 MiB host weight does not fit the pool.

One process-discipline note on my own work: I ran that device arm once outside a ticket while iterating, which is the rule this fleet has for a reason. I re-ran it inside one
(i679-1823c, 9-minute hold) so the evidence above is ticket-backed, and nothing was left resident (0 llama-bench processes afterwards).

So the PR now has both halves: a hardware refusal for the default path (the original evidence) and unit arms on the shared pairing (what I said was missing).

bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
)

#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>
bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…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>
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