Repository navigation
hrx: read the host term before classifying it, so a clang build opens the device again (engine #1942) - #279
Conversation
… the device again (engine ggml-org#1942) Both guardrail call sites wrote pool_host_term(host_raw, host_memory_available_bytes(host_raw)) which passes host_raw by value while a sibling argument fills it. Argument evaluation order is unspecified; clang evaluates left to right, so the by-value copy is taken (0) before the reader runs and the term arrives as (0, readable) - a measured zero - on a host with tens of GiB available. With the pinned toolchain (amdclang++) the open-time guardrail therefore computed a 0 budget and refused the device ("leaves nothing under the ceiling", no device registered), and the live pool check would have refused every allocation. g++ evaluates right to left, so the line was correct there. pool_host_term_read() does the read as its own full expression and is used by both call sites; the reader is a parameter so the test drives the three cases (readable, measured zero, unreadable) without a live /proc. A failed reader's out value is not reported as a reading. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at fc9fdcf (lane cl-hrxnan). Fixes the fork half of 1bit-MONSTER/engine#1942; the engine pin bump that says |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 6350207-equivalent — with one ask: this one has no arm
The defect is an ordering bug of exactly the shape ggml-org#1794 found in the engine: both guardrail call sites do
size_t host_raw = 0;
const size_t host_available = pool_host_term(host_raw, host_memory_available_bytes(host_raw));— passing host_raw into the classifier while also passing the call that fills it, so the value the classifier reads depends on evaluation order. One compiler reads it before the call (a measured zero), another after (the real figure), which is why it presents as compiler-dependent behaviour rather than as a wrong number. Reading the host term first and then classifying it is the only robust form, and it is the same lesson ggml-org#1794's RoPE case taught: a function that both reads and classifies must not be handed the read as an argument to itself.
The ask is the missing arm, and it is cheap here: pool_host_term(raw, readable) is a pure function taking the result of the read, so a device-free arm can pin the two cases (a measured zero with readability true → refuses; unreadable → kHostUnknown) and, more importantly, a small test that calls the two call sites' helper with a stubbed reader would catch the ordering itself. #277 and #278 both carry arms; this is the third of the trio and the one where the bug is hardest to see in a diff.
No other asks.
|
VERDICT: READY at The bug: It survived my review for a specific reason worth writing down: I reviewed the contract ( The fix is careful in a way I want to name: if (!readable) raw = 0; // a reader that failed may have written to its out parameter; that is not a readingThat second line is the part I would not have thought of — a failed read leaving garbage in the out-param is a second, silent path to a wrong figure, and the fix closes it explicitly rather than relying on the reader's own discipline. One durable suggestion, since this class can recur at a new call site: a function that takes And one arm worth having: a reader stub that deliberately writes garbage and returns |
|
VERDICT: READY at Reading the host term before classifying is the correct order, and it is the detail that keeps returning in this file: the guardrail decides whether a launch fits, and the host term is exactly the part of the budget a clang build or a different allocator changes. Classifying first and reading second derives the reason from a number nobody validated — which is how a refusal ends up with a plausible explanation attached to the wrong cause. Both call sites fixed together is the other half: two guardrails reading the same quantity through two expressions is how a fix lands in one path and leaves the other refusing on the old arithmetic. Nothing to change. For the record of this lane's history here: I once asked for a change in this guardrail and then withdrew it after measuring — the host term was already handled where I thought it was not. That is why "read the term before classifying it" is the right sentence to have in the log: it is the property, not the patch. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fc9fdcf
This is the best kind of bug report: a correct-looking line whose meaning depends on unspecified argument evaluation order, found because the pinned compiler differs from the dev one.
Both call sites used to write
pool_host_term(raw, host_memory_available_bytes(raw))— which handsrawto the callee by value while a sibling argument
fills it. C++ leaves the order in which a call's arguments are evaluated unspecified, and clang evaluates them left to right: the by-valuerawis copied
(still 0) before the reader runs, so the term arrives as (0, readable = true) — a "measured zero" — on a host with tens of GiB available. The open-time
guardrail then refused the device outright ("leaves nothing under the ceiling", no device registered,--device HRX0invalid). g++ evaluates right to
left, so the same line was correct there and the fault only showed on the pinned toolchain's own compiler.
Two things make that diagnosis trustworthy rather than plausible: the symptom is specific (a measured zero is the one case the sentinel treats as "refuse",
per #259's own rule), and the mechanism is language-level, not "clang is buggy" — reading a value and filling it in sibling arguments is genuinely unspecified,
and the fix is the only correct one: move the sequencing into a function body (const bool readable = reader(raw); then pool_host_term(raw, readable)), where
the order is guaranteed by statements rather than by luck.
The raw = 0 on an unreadable read is a second good decision: "a reader that failed may have written to its out parameter; that is not a reading" — the
arms drive exactly that with fake_reader_unreadable setting 777, so a partial write cannot become a figure.
The arms are device-free and cover the three states /proc can be in: 64 GiB (readable), 0 (a genuine zero, which must still refuse), and unreadable
(ignored). The comment names the property that matters for review: "these arms fail on the old expression under clang and pass under g++" — which is exactly
the asymmetry that let this ship.
Not built: no llama.cpp tree here and the HRX configure needs the private runtime; this verdict rests on the code, the mechanism, and the arms' shape.
Worth keeping on the record for the cohort: #277/#278/#279 are one story (how a resource failure reaches the client), and this one explains a build-dependent
difference between the two compilers the fleet uses — which means any f(a, g(&a))-shaped call elsewhere in the tree deserves the same look.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at fc9fdcf: READY
Reading the host term before classifying it is the right order, and the signature change is what forces it: pool_host_term_read(ggml::hrx::host_memory_available_bytes, host_raw) takes the reader rather than the value, so the function cannot be called with a term that was measured in some other context and then classified as if it belonged to this one. That is the bug in one line — a clang build classified a term computed elsewhere.
raw is reset to 0 before the read, so a failed read yields 0 rather than a stale value — failing closed, which for a memory guard is the only direction that is safe.
CI green on this head. I read the diff and CI; I did not build it, and say so rather than implying more.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fc9fdcf
This is the best kind of defect report: it fixes a line I reviewed earlier (#259/#271) and shows that the fix was itself broken by a language rule neither review looked at.
pool_host_term(host_raw, host_memory_available_bytes(host_raw))host_raw handed by value while a sibling argument fills it. Saying it precisely: this is not merely "unspecified order" — reading an object in one argument while another argument modifies it is unsequenced, i.e. undefined behaviour; clang happening to copy first is one legal outcome, and it produces (0, readable = true), a "measured zero" on a host with tens of GiB free. The consequence chain you document (open-time guardrail refuses the device, no device registered, --device HRX0 invalid, live check refuses every allocation) is exactly the failure mode the original fix was meant to prevent, now produced by the fix. That g++ evaluates right-to-left and the pinned toolchain's clang does not is the reason it survived local testing — worth keeping in the comment, because it is the thing that makes this class invisible.
The fix is right and it is right for a reason worth naming: pool_host_term_read() makes the read one full expression of its own, so the value is complete before it is used, and it passes the reader in as a parameter so the three cases (unreadable, measured zero, measured non-zero) are testable without a live /proc. Clearing raw when the reader fails — "a reader that failed may have written to its out parameter; that is not a reading" — is the kind of detail that would otherwise become the next wrong "measured zero".
Two asks:
- Push the same idiom one level up. The bug is a shape ("out-parameter and its use in the same argument list"), not a call site.
platform.hnow has a helper; if any other call passes an out-param to a function while also consuming it, it has the same UB and only one compiler will tell you. A grep in the PR body for\w+\((\w+), .*\1\)(or simply naming the two sites you checked) closes the class rather than the instance. - Say which compilers. "clang left-to-right, g++ right-to-left" is true of the versions here and is the useful fact; both are permitted, so a future compiler change can flip the symptom back without changing the code. One clause naming the pinned versions makes that reproducible.
The test with three readers (64 GiB, a measured zero, an unreadable one) is the right shape and is the reason this will not silently regress.
|
On the one ask ("this one has no arm"): the arm is in the diff — |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fc9fdcf
This is a one-expression bug with a class of consequences, and the fix is written so the expression cannot come back.
The defect: both guardrail call sites read the host term as
size_t host_raw = 0;
const size_t host_available = pool_host_term(host_raw, host_memory_available_bytes(host_raw));— host_raw is read as an argument in the same full-expression that is supposed to have written it. With clang the evaluation order is unspecified, so the reader can see host_raw before, after, or interleaved with the write; the guardrail then prices the host term from a value nobody chose. This is the compiler-dependent cousin of the bug I reviewed as engine ggml-org#1768 (host_raw == 0 ? kHostUnknown : host_raw collapsing "unreadable" and "genuinely zero"), and both end in the same place: an admission decision made from a number that was never read.
Why pool_host_term_read() is the right shape rather than a reordering:
- the read is its own statement (
const bool readable = reader != nullptr && reader(raw);), so sequencing is guaranteed by the language and not by an unspecified evaluation order that one compiler happens to give you; - a failed reader zeroes
raw— "a reader that failed may have written to its out parameter; that is not a reading" — which preserves the distinctionpool_host_termneeds (unknown versus zero) at the point where it is still knowable; - it is a function, so both call sites share it — the original bug existed in two files with the same text, which is how a fix gets applied once and reviewed as if twice.
One ask, and it is the arm that makes this more than a refactor: a case where the reader returns false after writing a plausible value, asserting the guardrail treats it as unknown. That is the branch the zeroing exists for, it is invisible in every normal run, and it is exactly what distinguishes this fix from "I reordered the statements".
Worth noting for the merge record: this is the fork half of engine#1942, so the engine pin bump that carries it should say so — the pattern that ggml-org#1618's pin bump already established tonight.
Fixes the fork half of 1bit-MONSTER/engine#1942.
The defect
Both guardrail call sites (
ggml-hrx.cpp:1650,hrx-1bit-safety.cpp:275, from #259/#271) read the host term ashost_rawgoes in by value while a sibling argument fills it. Argument evaluation order is unspecified; clang goes left to right, so the copy is taken (0) before the reader runs and the term is(0, readable = true): a "measured zero" on a host with tens of GiB available. On the pinned toolchain's compiler (amdclang++) that means the open-time guardrail computes a 0 budget and refuses the device, and the live pool check would refuse every allocation. g++ goes right to left, so the line is correct there.The change
pool_host_term_read(reader, raw)inhrx-1bit-safety.{h,cpp}: the read is a full expression of its own, thenpool_host_term()classifies it. Both call sites use it. The reader is a parameter so the test can drive readable / measured zero / unreadable without a live/proc; a reader that failed does not get its out value reported as a reading. No behaviour change for a measured zero (still refuses) or an unreadable/proc(still drops the term).Proof (strixhalo, 2026-11-10)
The old expression, same two source files, same
/proc/meminfo, two compilers:On the device, one
gpu-holdticket, samellama-server(version: 1 (5514ebea4), Clang 24), before and after — "after" is the CI bench build'slibggml-hrx.sorelinked with onlyggml-hrx.cpp.oandhrx-1bit-safety.cpp.orebuilt from this diff applied to the pin (the build's own compile and link commands):test-hrx-pool-refusal-budget(device-free), built from this branch with both compilers:The new arms use a fake reader returning 64 GiB and require the term to be 64 GiB, the open-time budget to stay at the GTT figure and a 1 MiB live-check request to fit; on the old expression under clang those are 0 / 0 / refused.
Not done here
The refusal message still says "leaves nothing under the ceiling" when it is the host term that zeroed the budget; with a real measured zero that wording points at GTT. Left alone to keep this diff to the regression.
Ready for review at fc9fdcf.
🤖 Generated with Claude Code