Repository navigation
ggml-hrx: charge the UNBOUNDED host-weight path too, so the default mode cannot pierce the pool (engine #1823) - #275
Conversation
…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.
|
(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 I diffed the two patches line by line to be sure: identical in structure, differing only in the field name ( #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 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
left a comment
There was a problem hiding this comment.
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_ > 0and 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", …)thenreturn 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.
|
VERDICT: READY at 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 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 The ask, because that safety is currently incidental rather than structural: Entry(const Entry &) = delete;
Entry & operator=(const Entry &) = delete;(and On the device arm vs unit arms: your reasoning is right and the honesty about it is better than the alternative. If the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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:
- 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; - 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; - the release is tied to ownership —
charged_bytes_is zeroed andpool_releasecalled in the destructor path because "Entryowns 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
left a comment
There was a problem hiding this comment.
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 beforeallocate_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; ~Entryreleases whencharged_bytes_ > 0and zeroes it, which is safe withowns_bufferhandled just above becauseEntryowns 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
left a comment
There was a problem hiding this comment.
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:
- 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 releasescharged_bytes_and zeroes it. No path allocates without charging, and none releases twice. - No double-release risk from the new member.
Entryis held asstd::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 toowns_buffer. - Nothing else changes. The
elsebranch 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_budgetand 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).
|
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 (
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.
|
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:
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. I tried to give you a compile check and it was inconclusive, so I am not claiming one: I fetched your Same conclusion as my #274's body on the device arm: |
The gap I declared on this PR is now closed — head
|
) #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 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::Entrywith nothing to release: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
materialized_lengthagainst 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.~Entry, which owns exactly one buffer (charged_bytes_on the entry).Evidence — on hardware, in the default mode
GGML_HRX_HOST_WEIGHT_LIMIT_MBunset,GGML_HRX_POOL_LIMIT_MB=700,llama-bench -p 512 -ngl 12on Qwen3-0.6B (one 9-minute ticket, 10 s of it used):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::Entryis a private nested type defined inhost-memory.cpp, so the device-free pattern this lane uses elsewhere (test-hrx-staging-ledger.cpp: construct theobject, 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
Entryintohost-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.