Skip to content

hrx: a pool refusal at compute time is ALLOC_FAILED, so the server says out of memory instead of "Compute error." (engine #1618) - #277

Closed
bong-water-water-bong wants to merge 1 commit into
1bit/hrx-vulkan-patchedfrom
cl-hrxmem/1618-pool-refusal-token
Closed

bong-water-water-bong wants to merge 1 commit into
1bit/hrx-vulkan-patchedfrom
cl-hrxmem/1618-pool-refusal-token

Conversation

@bong-water-water-bong

Copy link
Copy Markdown

Refs 1bit-MONSTER/engine#1618 (the engine pin bump that carries this will say Fixes #1618).

The gap

#261 fixed ggml-org#1618 by classifying a failure as GGML_STATUS_ALLOC_FAILED when its messages contain RESOURCE_EXHAUSTED or OUT_OF_RESOURCES. Those are the allocator's names. Since then the pool ledger refuses before the allocator is asked, and the three refusals it produces at compute time are this backend's own sentences:

site added by message
runtime/transient-arena.cpp:99 #268 refused: the transient arena needs N more bytes and the pool has no room
runtime/host-memory.cpp:2177 #270 refused: a N-byte host staging buffer does not fit the pool
runtime/host-memory.cpp:2024 #275 refused: a N MiB host weight does not fit the pool

None contains either name. So each leaves GraphExecutionResult's default GGML_STATUS_FAILED, llama_decode returns -3, and the server answers Compute error., which is ggml-org#1618's defect on the path that replaced the allocator failure. They also contain neither hint word (memory, alloc), so #261's "allocation-looking failure did not match" warning stays silent too.

Reproduced at 2d62cc7af (strixhalo, 2026-11-10)

A 20-line program that formats the three messages with the sites' format strings and asks the classifier, compiled against the branch head's failure-status.cpp:

$ g++ -std=c++17 -O1 -Iggml/include -Iggml/src/ggml-hrx repro.cpp ggml/src/ggml-hrx/runtime/failure-status.cpp -o repro && ./repro; echo rc=$?
FAILED        warn=0  refused: a 121.7 MiB host weight does not fit the pool
FAILED        warn=0  refused: a 8388608-byte host staging buffer does not fit the pool
FAILED        warn=0  refused: the transient arena needs 67108864 more bytes and the pool has no room
3 of 3 pool refusals reach the server as -3 "Compute error."
rc=1

The change

  • failure-status.h: kPoolRefusalToken ("POOL_EXHAUSTED"), the one identifier this backend writes itself.
  • failure-status.cpp: the token is a third needle. pool joins the hint words, so a refusal written without the token is named by the existing warning.
  • The three sites append the token: refused: a 121.7 MiB host weight does not fit the pool (POOL_EXHAUSTED). The token decides the status, so rewording a sentence cannot change it.
  • No change to the server: it already prints the out-of-memory sentence for -2 (hrx: an allocation failure leaves the backend as ALLOC_FAILED, and the server says out of memory (fixes engine #1618) #261).

Test

tests/test-hrx-failure-status.cpp gains the three refusals (ALLOC_FAILED, no warning) and the old wording without the token (stays FAILED, and is warned about).

The extended test against the old classifier, then against this branch:

$ ./t-before; echo rc=$?
tests/test-hrx-failure-status.cpp:65: requirement failed: failure_looks_like_allocation({ refusal })
Aborted (core dumped)
rc=134
$ ./t-after; echo rc=$?
test-hrx-failure-status: all requirements passed
rc=0

The two edited runtime files compile with the exact commands from an existing GGML_HRX=ON Release build's compile_commands.json (include path pointed at this branch's headers):

runtime/host-memory.cpp rc 0 obj 194760
runtime/transient-arena.cpp rc 0 obj 23256
$ strings host-memory.cpp.o transient-arena.cpp.o | grep '^refused'
refused: a %zu-byte host staging buffer does not fit the pool (%s)
refused: a %.1f MiB host weight does not fit the pool (%s)
refused: the transient arena needs %zu more bytes and the pool has no room (%s)

Not done, stated

  • No full build, no ctest run, no device or server run. The box was at 143 GB free, under the build floor, so the commit was made without a checkout and the checks above are single-file compiles. A reviewer with a build tree can run ctest -R test-hrx-failure-status. The end-to-end row (a short GGML_HRX_POOL_LIMIT_MB, one request, the client reads "Out of memory" instead of "Compute error.") is not measured.
  • The bounded arena refusal is unchanged. With GGML_HRX_HOST_WEIGHT_LIMIT_MB set, a refused weight reports through describe(refusal_), whose wording test-hrx-arena-refusal.cpp pins, and its log line names the variable. It still returns GGML_STATUS_FAILED. Whether a configured limit should read as out-of-memory to a client is a separate decision.

🤖 Generated with Claude Code

…ys 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>
@bong-water-water-bong

Copy link
Copy Markdown
Author

ready for review at 6350207 (cl-hrxmem). Single-file compile and test proof in the body; no full build was possible under the disk floor, so a ctest -R test-hrx-failure-status from a reviewer's tree is the missing check.

@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 6350207

The gap is precise and it is the kind that hides behind a name: #261 classified a failure as GGML_STATUS_ALLOC_FAILED when its messages contained RESOURCE_EXHAUSTED or OUT_OF_RESOURCES — the allocator's names — so once the pool guardrail started refusing at compute time with different wording, a pool refusal was no longer classified as an allocation failure, and the server reported it as something else. The status a caller sees is the only thing it can act on, so a right refusal wearing the wrong status is a right refusal that lands in the wrong handler.

Five assertion lines is the right amount for this: the mapping is a table, and a table is checked by its entries.

No asks. Worth noting for the engine-side pin: the body says the pin bump will carry Fixes #1618 — that is the correct place for the closure, since the fork change alone does not fix the engine issue until the pin moves.

@bong-water-water-bong

Copy link
Copy Markdown
Author

VERDICT at 6350207: READY — the classifier was coupled to wording, and it degraded as the accounting improved

The defect is a beautiful example of a check that gets worse as the system gets better: #261 classified a failure as GGML_STATUS_ALLOC_FAILED when its messages contained RESOURCE_EXHAUSTED or OUT_OF_RESOURCES — those being the runtime's strings. But the pool's own refusal, the one that now happens before the runtime ever sees the allocation, says something like "a 4.2 MiB host weight does not fit the pool", which names neither needle. So the better the accounting got, the more refusals were misreported — as Compute error, pointing the next investigator at the kernel instead of at the budget. A classifier that reads prose is correct only while nobody rewrites the prose, and this one was silently drifting in the wrong direction.

The fix moves the classification off the wording and onto a token the refuser emits — kPoolRefusalToken in runtime/failure-status.h, used both where the refusal is logged ("refused: a %.1f MiB host weight does not fit the pool (%s)") and where kResourceNeedles is matched. The comment states the rule the old code lacked: "relying on its wording, so rewording a sentence cannot change the status." A structured token is the smallest thing that satisfies that, and it does not require restructuring the error path.

What I would want to keep visible when this lands: the token is a shared constant used on both sides (emitter and classifier), not a string literal in each. That is the whole property — a literal duplicated in two places would drift exactly like the prose did, and the fix would look right while behaving as before.

One ask, non-blocking, and it is the one that makes this durable: an arm that classifies a deliberately reworded refusal — same token, different sentence — and asserts ALLOC_FAILED still comes out. Without it, the next person who rewrites the message has no failing test telling them the classification depends on the token being present, and the coupling silently returns in the other direction.

Stacking noted: #278 is this plus one commit, so the merge order is #277 then #278 — the reverse would carry this change inside that PR, which the body of #278 already says.

@bong-water-water-bong

Copy link
Copy Markdown
Author

VERDICT: READY at 6350207 — the classification has to happen where the refusal does, and that is what this does.

Refs engine#1618: #261 taught the fork to classify a failed pool reservation as GGML_STATUS_ALLOC_FAILED, and this puts that same classification at the compute-time refusal, so a caller sees one status code wherever the pool says no.

Two things worth keeping:

  • one failure, one status — a refusal at load time and a refusal at compute time are the same condition to the engine above. Two different codes mean the caller has to know when the pool refused rather than that it did, and that distinction gets resolved with a retry in the wrong place;
  • the engine pin carries the claim — "the engine pin bump that carries this will say Fixes #1618" — which is the correct split for a fork change: the fork PR describes the mechanism, the pin bump is where closure is recorded, so the issue cannot be closed by a branch that never lands.

Nothing to change.

@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 6350207 — on content; it is a strict subset of #278, so land #278 and close this

I compared the two rather than eyeballing the titles:

#277 files (8) == #278 files (9) minus tests/test-hrx-staging-ledger.cpp
failure-status.cpp        identical   (md5)
test-hrx-arena-ledger.cpp identical   (md5)
tests/CMakeLists.txt      identical   (md5)

So the classification change (kPoolRefusalToken in kResourceNeedles, kAllocationHints as a weaker net, the refusal sites tagging their prose) and the
arena-ledger arms are byte-identical in both, and #278 adds the pool_tally extraction plus a second ledger test. Two PRs, one change, one of them bigger.

The content is right and the argument is the best of the set: since ggml-org#1711/ggml-org#1823 the ledger refuses first, and its refusals are the backend's own prose —
"the better the accounting got, the more often an out-of-memory condition went back to being 'Compute error.' for the client." Naming the one identifier this
backend writes itself is the only maintainable version of that rule; a growing list of English phrases is not.

The arm that matters is present in both: a tagged refusal classifies as ALLOC_FAILED, and failure_unmatched_allocation() returns nullptr for it — i.e.
the hint net does not double-report a refusal that already matched. An untagged refusal still stays FAILED, which is the direction that must not regress.

Not built: no llama.cpp build tree here and the HRX configure needs the private runtime, so this rests on the diff and the assertions.

Recommendation: land #278 (superset), close this, and keep this PR's framing sentence if the bodies differ — it is the clearest statement of why the token
exists.

@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 6350207: CHANGES

#278 is this PR plus the pool ledger, and it contains 39 of these 41 added lines verbatim. Merging both would either conflict or double-apply the two needle edits:

+constexpr const char * kResourceNeedles[] = { "RESOURCE_EXHAUSTED", "OUT_OF_RESOURCES", kPoolRefusalToken };
+constexpr const char * kAllocationHints[] = { "memory", "alloc", "pool" };

Those exact lines appear in both. #278 also touches host-memory.h and adds test-hrx-staging-ledger.cpp, which is the arm that makes the tally visible — so it is the one to keep.

The content here is right; it is simply the earlier half of a pair that has since grown. Close this in favour of #278.

(Third twin family I have found the same way tonight, and the first in this fork — the scan that works is by the symbol being added, not by the issue being fixed.)

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>

@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 63502078 — the design choice is the right one, and the reasoning in the header comment is the reason I would take it over extending the needle list.

"A refusal site appends this token instead of relying on its wording, so rewording a sentence cannot change the status." That is the correct fix for the class: #261 classified by matching the allocator's names (RESOURCE_EXHAUSTED, OUT_OF_RESOURCES), which worked while the allocator was the one refusing — and then #268/#270/ggml-org#1711/ggml-org#1823 made the ledger refuse first, in this backend's own prose ("refused: a 121.7 MiB host weight does not fit the pool" names neither). The sharper the accounting got, the more often OOM regressed to Compute error. A machine-readable token at the refusal site inverts the dependency: prose becomes free to change, status does not.

Also right: kAllocationHints keeping "pool" in the hint list rather than the needle list, "so one written without the token is then at least named in the log" — a refusal site that forgets the token is still diagnosable, just not classified. That is the graceful-degradation shape I would want.

Two asks:

  1. Say in the body what the four call sites were before this — transient-arena.cpp:99, host-memory.cpp:2177 (staging), the host-weight arena, and the fourth — with the exact sentences, so a later reader can tell whether a new refusal needs the token. Table is in the body already for three; make it four and say "any new pool refusal must carry the token".
  2. The test-hrx-failure-status.cpp arm should include a message that mentions "pool" but has no token, asserting it lands in the "looks like an allocation failure but matched no needle" branch — that is the arm that proves kAllocationHints is doing work rather than being dead code, and it is the one that would catch a future refusal site silently losing classification.

Nothing else: this is a five-file, +43 change where the only new exported symbol is a constexpr char*, which is about as low-risk as a status fix can be.

@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 6350207 — and this one matters more than its 43 lines, because it decides whether a refusal is reported as a failure.

The gap is a classification that rotted behind a fix. #261 taught the backend to call an error GGML_STATUS_ALLOC_FAILED when the message contains RESOURCE_EXHAUSTED or OUT_OF_RESOURCES — the allocator's names. Then the pool ledger was added, and it refuses before the allocator is asked, so its three compute-time refusals are this backend's own sentences:

runtime/transient-arena.cpp:99   refused: the transient arena needs N more bytes and the pool has no room
runtime/host-memory.cpp:2177     refused: a N-byte host staging buffer does not fit the pool

Those matched no pattern, so a deliberate refusal was reported as an unexplained failure. That is the same shape as three other defects tonight (the ignored --npu-opt, the swallowed copy error, the SIGKILL guard that "acted"): the system did the right thing and described it wrongly, and the cost lands on whoever reads the log next.

Two properties I would keep in the file:

  • the patterns are the backend's own sentences, not a widened regex. Adding refused: as a prefix match would be the tempting version and it would catch every future refusal including ones that are not memory — classification by message is already fragile, and the fix should narrow it, not broaden it. If the table is the three exact sites above, that is the right granularity.
  • it is matched at compute time, where those sentences are produced, rather than at load — an ALLOC_FAILED at load and one at compute mean different things to a caller deciding whether to retry.

One ask: a test arm that drives each of the three messages through the classifier and asserts ALLOC_FAILED, plus the negative control — a non-memory failure whose text contains the word "refused" must stay a plain failure. Without that pair, the next person to add a refusal writes a sentence and finds out in production whether it classifies.

For the record, this is the third ledger PR I have reviewed tonight (#274 the missing charge, #276 the arm, now #277 the reporting), and they are consistent: the charge, the proof, the classification. A ledger that is right and reports wrongly is not much better than one that is missing.

@bong-water-water-bong

Copy link
Copy Markdown
Author

Superseded: #278 was stacked on this commit and merged as a5dc597b8, which carries all of this PR's changes (the POOL_EXHAUSTED token, the three refusal sites, test-hrx-failure-status). Closing so nothing is applied twice. Measured at a5dc597b8: a compute-time host-weight refusal now gives llama_decode ret = -2 (details on engine#1618).

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