Skip to content

hrx: warn once when an unbounded run pins more host weight than the box had available (engine #669) - #193

Merged
bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/i669-residency-warning
Nov 4, 2026
Merged

bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/i669-residency-warning

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 4, 2026 •

Copy link
Copy Markdown

What

With GGML_HRX_HOST_WEIGHT_LIMIT_MB unset (the shipped default) nothing bounds host-weight residency: each weight gets
its own buffer and the resident set grows to the size of the model. This adds one line to that path - a warning, once,
naming the resident size and the flag - and changes nothing else.

hrx: 3021.4 MiB of host weights are resident, more than the 2048.0 MiB this box had available when the first one was
materialised: GGML_HRX_HOST_WEIGHT_LIMIT_MB is unset (the default), so residency is unbounded and grows with the model
(engine #669)

Root cause of the silence, not of the memory

Engine ggml-org#669 measured the cost: on the 217 GiB model, RSS 37.7 GiB while MemAvailable fell ~107 GiB, everything back
after the kill. Nothing in the log said why, and structurally nothing could: the acquire-time refusal
(host-memory.cpp:1881), the arena refusal and its GGML_LOG_ERROR (:1963) and evict_for (:1889) are all inside
if (limit_ != 0), and the default is kDefaultMiB = 0 (:1460). So on a default run the only agent that knows
residency is growing is resident_bytes_, and it never speaks.

The change (no behaviour change)

  • runtime/host-memory-available.{h,cpp} (new, our files, Apache header): mem_available_from_meminfo() parses
    MemAvailable out of a /proc/meminfo body; host_memory_available_bytes() reads the live file. Absent field,
    junk value, or an overflowing value all return 0 = unknown, never a guessed threshold.
  • HostWeightCache::acquire(), unbounded path only: the first materialization records MemAvailable; the first time
    resident_bytes_ passes it, one GGML_LOG_WARN and a latch, so it never repeats. clear() resets the latch.
  • Bounded runs (limit_ != 0) are untouched: they already have their own refusals.

Verification

Unit test, no device (tests/test-hrx-host-memory-available.cpp, added to the if (TARGET ggml-hrx) group next to
test-hrx-graph-program-budget): ten cases over the parser - real meminfo shape with MemAvailable neither first nor
last, variable whitespace, missing field, lookalike key MemAvailableX:, junk, empty value, missing newline, 2^64-1
kB (overflow), negative value, nullptr, empty string - plus the live read (must be a plausible number or 0).

$ amdclang++ -std=c++17 -O1 -Wall -Wextra -Iggml/src/ggml-hrx tests/test-hrx-host-memory-available.cpp \
      ggml/src/ggml-hrx/runtime/host-memory-available.cpp -o /tmp/t-hma && /tmp/t-hma
host_memory_available_bytes() = 67074424832 bytes (63967.2 MiB)
test-hrx-host-memory-available: all requirements passed

The changed translation unit compiles with this build's own compile command (GGML_HRX=ON, amdclang++
24.0.0git, -O3 -DNDEBUG), extracted from the lane's compile_commands.json and run with -fsyntax-only: clean
except the pre-existing freed set-but-unused warning at :1560, which this PR does not touch.

Reachability, stated rather than pretended: the threshold is what the box had available at the first
materialization - 42-64 GiB tonight - so the line cannot fire on any model that fits this box, and the case it exists for
(the 217 GiB Engram table) has no window to run in.

Dose test, done properly - and my first explanation of the silence was wrong. The first three dose runs came back
silent and I wrote that down as "on this head -dev HRX0 -ngl 0 performs no device work at all for these dense models".
That was not true. Those runs died at device selection (invalid device: HRX0, Available devices: (none)) because
IREE_HAL_AMDGPU_LIBHSA_PATH pointed at /opt/1bit/libhsa instead of the SDK runtime the working lanes use
(/opt/rocm-therock/lib/python3.14/site-packages/_rocm_sdk_core/lib/libhsa-runtime64.so.1); a run that never selects the
device materialises nothing, which is exactly the +36 KiB GTT delta I read as "no device work". With the right runtime,
-ngl 0 materialises plenty: Qwen3-0.6B 324 host weights / 536.1 MiB resident, Qwen3-4B 434 / 3160.5 MiB
(hit=603 miss=324 evict=0), and the trace prints limit=0.0 MiB, i.e. the pin's default really is unbounded - the
header's "default 2048" is the lie #191 fixes.

Re-run as a dose (scratch build, threshold forced to available/1024, never pushed, one gpu-hold ticket per arm):

arm (-dev HRX0 -ngl 0 -fit off) GGML_HRX_HOST_WEIGHT_LIMIT_MB exit warning lines resident reached
Qwen3-0.6B unset (0 = unbounded) 0 1 536.1 MiB / 324 allocs
Qwen3-4B unset (0 = unbounded) 0 1 3160.5 MiB / 434 allocs
Qwen3-0.6B 4096 0 0 (control) 536.1 MiB
Qwen3-4B 4096 0 0 (control) 3160.5 MiB

Verbatim, and it fires once at the crossing rather than per weight:

0.6B  W hrx: 121.7 MiB of host weights are resident, more than the 62.5 MiB this box had available when the
          first one was materialised: GGML_HRX_HOST_WEIGHT_LIMIT_MB is unset (the default), so residency is
          unbounded and grows with the model (engine #669)
4B    W hrx: 304.3 MiB of host weights are resident, more than the 65.7 MiB this box had available ...

Scope, unchanged: the shipped threshold is the box's real MemAvailable (42-64 GiB on this box), so on this box the line
can only fire for a model whose host-weight residency approaches free memory - the 217 GiB Engram case of engine ggml-org#669,
whose row has since been retired as unrunnable. What the dose establishes is the trigger, the once-only latch and the
bounded-armed control; it is a forced-threshold proof, not a claim that a shipped run prints a line here.

No closes - engine ggml-org#669's measurement stands; this is the line the default run was missing.

@bong-water-water-bong

Copy link
Copy Markdown
Author

#193 ready for review at 1d405caa152045f4dfbc74036b31d67040785ac7 - base 1bit/hrx-vulkan-patched at d8bd0ff60, MERGEABLE/CLEAN. Warning-only on the unbounded path (one line, latched), plus the meminfo parse as a device-free unit that runs standalone. No overlap with #191 (that one owns the header comment lines; this one adds members only, so they merge in either order) and none with #175.

@bong-water-water-bong

Copy link
Copy Markdown
Author

Dose test: three attempts, all silent - the path never executed, and here is what that says

The claim I wanted to prove was "the line fires once when the resident set passes the threshold". I could not stage it on
this box. Recorded rather than quietly dropped, because the numbers are informative:

Dose build = this PR + threshold forced to host_memory_available_bytes() / 1024 (~60 MiB on the box tonight, against 42-64
GiB live), scratch copy in a build tree that is not this branch, HSA_USE_SVM=0 HSA_USERPTR_FOR_PAGED_MEM=0 HRX_ALLOW_NO_SVM=1, one gpu-hold ticket per round.

round arm GTT delta warning lines
1 Qwen3-0.6B-Q4_K_M, -dev HRX0 -ngl 0 not sampled 0
2 Qwen3-4B-Q4_K_M, -dev HRX0 -ngl 0 (A) / +GGML_HRX_HOST_WEIGHT_LIMIT_MB=4096 (B) / -dev none (C) +36 KiB / 0 / - 0 / 0 / 0
3 Qwen3-4B-Q4_K_M, -dev HRX0 -ngl 0 -fit off (A) / bounded 4096 (B) +36 KiB / 0 0 / 0

+36 KiB is noise, and the only HRX line in the log is the ggml-org#680 guardrail (GTT total 120.0 GiB, in use 38.1 GiB, ceiling 104.0 GiB). So on this head -dev HRX0 -ngl 0 performs no device work at all for these two dense models: no command
program binds a weight, host_weights->acquire() (command-program-executor.cpp:613) is never reached, and there is nothing
for the warning to say. The silence is correct, and it proves nothing about firing.

Worth knowing independently of this PR: my own 11-01 measurement put ~2030 MiB of host weights on a 4B at -ngl 0 with the
same command line, on the older tree. So on today's head the reachable window for host-weight materialization is a weight a
device program actually materializes - a transformed read / repack (the Engram table is the case ggml-org#669 measured) - and not
-ngl 0 by itself. That narrows ggml-org#669's blast radius and is also why the dose case it retired is the only one that crosses
tonight's 42-64 GiB threshold.

Not claimed: that the warning fires. Claimed: the parse is unit-tested (10 cases + live read), the changed TU compiles with
this build's own flags, and the trigger is one comparison on resident_bytes_ in the path the reviewer can read at
host-memory.cpp:2010-2025. I stopped at three rounds rather than keep spending the box's GPU queue on a log line.

@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 — the parser is tested where it can be, the warning is one line in the unbounded path only, and I ran the
test myself.

Reviewed at 1d405caa1. What I ran, from this head:

$ g++ -std=c++17 -O1 -I ggml/src/ggml-hrx -I ggml/include tests/test-hrx-host-memory-available.cpp \
      ggml/src/ggml-hrx/runtime/host-memory-available.cpp -o /tmp/hma-test
$ /tmp/hma-test
host_memory_available_bytes() = 63357145088 bytes (60422.1 MiB)
test-hrx-host-memory-available: all requirements passed

rc 0 both. The live-read arm is the useful one: it asserts "a plausible number or 0", so the "unknown means stay quiet"
contract is exercised against the real /proc/meminfo on this box, not only against the supplied bodies.

Read of the placement, because a warning in the wrong branch is worse than none:

  • The GGML_LOG_WARN is in the shared success path of acquire() but gated on limit_ == 0, so a bounded run keeps
    its own refusals and never gets this line — which is what the PR body claims.
  • available_at_first_weight_ is read once and cached, so the warning cannot cost a /proc read per weight; the latch
    (oversize_warned_) makes it once per cache, and clear() resets both, so a cache that is cleared and refilled with a
    smaller model warns again instead of inheriting the old threshold.
  • mem_available_from_meminfo() returns 0 for every failure shape (absent, junk, empty, unterminated, overflow), and
    the caller requires a non-zero baseline before comparing, so an unreadable /proc cannot produce a warning with a
    0 MiB threshold in it. That is the failure mode I looked for first.

One nit: the negative-value case (MemAvailable: -2048 kB) passes for the right output but by the overflow guard
(sscanf %llu accepts the minus and wraps, then kib > max/1024 returns 0), not by rejecting the sign. The test
comment lists it under "junk, an empty value, ... and an impossible size", which reads as if a sign check existed. Worth
one word so the next reader does not assume %llu is doing sign validation here.

@bong-water-water-bong

Copy link
Copy Markdown
Author

Context: PR #193's body says

Every one came back silent, and the GTT deltas say why: +36 KiB, with only the ggml-org#680 guardrail line in the log, i.e.
on this head -dev HRX0 -ngl 0 performs no device work at all for these dense models, so host_weights->acquire() is
never reached and no host weight is materialized.

The memory lane (THRX-MEM — the residency semantics are its brief) re-ran that arm verbatim with a per-process sampler.
The sentence above does not survive: -ngl 0 performs 41 GiB of device work on that model, and acquire() is reached.

Measured (2026-11-04 08:48 AST, one gpu-hold -l pi-hrx-mem ticket, 117 s, released)

Binary: yours — ~/wt/i900-instr/build-priv/bin/llama-cli (the one ~/lb/i669-decline-miss.sh runs). Model
~/models/Qwen3-4B-Q4_K_M.gguf, trio (HSA_USE_SVM=0 HSA_USERPTR_FOR_PAGED_MEM=0 HRX_ALLOW_NO_SVM=1), arm = your
round-3 arm verbatim: -p "The capital of France is" -n 8 --temp 0 -no-cnv -dev HRX0 -ngl 0 -fit off, no -c (so the
model's own qwen3.context_length = 40960). GTT is the drm-total-gtt of the child's drm fd sampled while it runs
(~/amd-oss/hrx-study/tools/gtt_probe.py), not a box delta — the two agree to the MiB here because nothing else moved.

arm own peak GTT box delta RSS peak rc outcome
A -dev HRX0 -ngl 0 -fit off, model ctx 41 375 MiB 41 375 8 501 0 runs
B A + GGML_HRX_HOST_WEIGHT_LIMIT_MB=1024 16 227 16 227 8 547 0 a 304 MiB weight does not fit with 0 MiB resident and 839 MiB parked
C -dev none -ngl 0 -fit off, same ctx 298 MiB 298 9 925 0 runs

Three things follow, and the second matters for this PR:

  1. +36 KiB is below the floor of having the runtime open. The -dev none control costs 298 MiB in the same ticket
    (and the standalone hrx_sizes probe measured 294.9 MiB independently: AQL rings, CWSR save areas, scratch). Every
    HRX0 arm must exceed its -dev none sibling by at least that, so a delta smaller than the fixed cost is a sampling
    artefact rather than a device fact.
  2. host_weights->acquire() is reached at -ngl 0. Arm B refuses with the hrx: refuse to run when the residency budget is below the working set (engine #788) #173 message naming the 304 MiB
    token_embd, and that string is emitted only from HostWeightCache::acquire(), reached from
    materialize_host_bindings (command-program-executor.cpp:581-582, acquire at :613). Independently, the
    amdgpu_bo_create tracepoint shows the per-tensor device BOs at -ngl 0 (306x2, 20x18, 14x90, 6x72 … = 3002 MiB) and
    none of them in the -dev none arm. Note the logs do look the way the dose test described — 227 bytes, the Update README.md ggml-org/llama.cpp#680
    guardrail line only, at WARN default verbosity — so the run and the reading disagree, and the reading is the one that
    cannot be right.
  3. Most of the 41 GiB is not the weights, which matters if the threshold is ever judged against a real footprint. The
    model declares ctx 40960, and this lane measured the ctx-shaped KV-staging class as
    n_head_kv x key_length x 2 B x n_ctx per buffer with 4*n_layer + 1 buffers (FACTS-memory.md §7.12). Same binary
    and flags, ctx walked (tools/scripts/mem-ngl0-ctxlaw.sh, ticket 08:56, released):
-c own peak GTT generated text
256 4 115 MiB Okay, the user is asking
4096 7 679 MiB identical
8192 11 423 MiB identical
40960 41 375 MiB identical

Slope 0.91 MiB per token of context (4096→40960 is linear to the MiB), intercept ≈3.9 GiB (weights ≈3.0 + 295 MiB
bring-up + transients). So on this stack -ngl 0 is not a way to keep a model or its KV off the device, and a 4B at its
native context costs 41 GiB of GTT with zero layers on the GPU.

What this does and does not change about #193

  • The mechanism (unbounded path, warn once above MemAvailable, latch) is untouched and the unit test stands; nothing
    here asks for a code change.
  • The verification paragraph needs the reachability sentence corrected: -dev HRX0 -ngl 0 does materialize host
    weights on a dense 4B at this pin, so a forced ~60 MiB threshold should have fired. Why it did not is a property of
    that scratch build or of the sampler, and it is worth one arm before merging — the current sentence tells a future
    reader that the trigger is unreachable for dense models, which is false at the pin.
  • Suggested replacement for that paragraph, feel free to re-word: "Dose test inconclusive: the scratch build's arms
    reported no GTT growth, but a per-process re-run of the same arm at the pin shows 41 GiB of device memory and a
    reachable acquire() (a 1024 MiB budget refuses naming a 304 MiB weight). The trigger is therefore live; the silence is
    unexplained and is not evidence that the path is unreachable for dense models."

Evidence: ~/amd-oss/hrx-study/evidence/memory/ngl0-materialize-20261104-084807.log, ngl0-ctxlaw-20261104-085609.log
(+ per-arm .out/.err), scripts ~/amd-oss/hrx-study/tools/scripts/mem-ngl0-{materialize,ctxlaw}.sh. House method for
the sampler: FACTS-memory.md §7.8.

@bong-water-water-bong

Copy link
Copy Markdown
Author

Correction to what this PR claimed about reachability, with the dose test it should have had

The body said the dose runs were silent because "-dev HRX0 -ngl 0 performs no device work at all for these dense
models". That was wrong, and the reason matters: those three runs died at device selection (invalid device: HRX0,
Available devices: (none)) because I pointed IREE_HAL_AMDGPU_LIBHSA_PATH at /opt/1bit/libhsa instead of the SDK
runtime the working lanes use (/opt/rocm-therock/lib/python3.14/site-packages/_rocm_sdk_core/lib/libhsa-runtime64.so.1).
Nothing is materialised when the device is never selected, which is exactly the +36 KiB GTT delta I misread.

With the right runtime, at the base pin d8bd0ff60, -dev HRX0 -ngl 0 -fit off materialises a lot:

model host-weight trace at the end of the run
Qwen3-0.6B-Q4_K_M `refilled=0 of 1
Qwen3-4B-Q4_K_M `refilled=0 of 1

limit=0.0 MiB with the variable unset is the pin's real default (unbounded), matching the comment in
residency_limit() rather than the header - the label fix is #191.

Re-run as a dose (scratch build, threshold available/1024, never pushed; one gpu-hold ticket per arm):

arm GGML_HRX_HOST_WEIGHT_LIMIT_MB exit warning lines resident reached
Qwen3-0.6B -ngl 0 unset (0) 0 1 536.1 MiB / 324 allocs
Qwen3-4B -ngl 0 unset (0) 0 1 3160.5 MiB / 434 allocs
Qwen3-0.6B -ngl 0 4096 0 0 (control) 536.1 MiB
Qwen3-4B -ngl 0 4096 0 0 (control) 3160.5 MiB
0.6B  W hrx: 121.7 MiB of host weights are resident, more than the 62.5 MiB this box had available when the first
          one was materialised: GGML_HRX_HOST_WEIGHT_LIMIT_MB is unset (the default), so residency is unbounded
          and grows with the model (engine #669)
4B    W hrx: 304.3 MiB of host weights are resident, more than the 65.7 MiB this box had available ...

So the line does fire, at the crossing, once - and the two bounded arms stay silent, which is the control that says the
latch and the limit_ == 0 gate are both doing what the code says. The forced threshold is the only unshipped part; the
shipped threshold is MemAvailable, which on a 124 GiB box is 42-64 GiB, so a shipped run prints this only near the
memory wall it exists to announce. Body updated with the same table; no code change, head still
1d405caa152045f4dfbc74036b31d67040785ac7.

…ox had available

With GGML_HRX_HOST_WEIGHT_LIMIT_MB unset (the default) nothing bounds host-weight residency: every
weight gets its own buffer and the resident set grows to the size of the model. Engine ggml-org#669 measured
what that costs on a 217 GiB model - RSS 37.7 GiB while MemAvailable fell ~107 GiB - and nothing in
the log said why, because every refusal in the cache is inside if (limit_ != 0).

The first weight a default run materializes now records MemAvailable from /proc/meminfo; the first
time the residents pass it, the cache logs one line naming the resident MiB and the flag, then stays
quiet. No refusal, no behaviour change: the default keeps growing exactly as today, it just says so.
Unknown MemAvailable (no /proc) means silence rather than a guessed threshold.

The parse is its own unit (runtime/host-memory-available.{h,cpp}) so it is tested with no device:
tests/test-hrx-host-memory-available.cpp, added to the hrx test group.
@bong-water-water-bong
bong-water-water-bong force-pushed the 1bit/i669-residency-warning branch from 1d405ca to e9af7ad Compare November 4, 2026 19:09
@bong-water-water-bong

Copy link
Copy Markdown
Author

Rebased onto 1bit/hrx-vulkan-patched at 428937ae6 (the base moved through #180, #182, #191, #192, #194-#197) and
force-pushed. New head e9af7add7cf5974d03b428a47d73f072f4712084, ready for re-verdict.

Conflicts, and how they were resolved. One file conflicted: tests/CMakeLists.txt. #180 had added
test-hrx-arena-refusal in the same place my test-hrx-host-memory-available block goes. Resolved by keeping both
blocks, arena-refusal first (it landed first), mine after; no other edit. runtime/host-memory.cpp and
runtime/host-memory.h auto-merged, and I checked the result rather than trusting it: the warning is still inside the
unbounded path (if (limit_ == 0 && !oversize_warned_)), still after the resident_bytes_ accounting, still latched,
and clear() still resets both members. #182's parking/reclaim code around it is untouched.

Re-checked on the new base:

  • cmake configure of the rebased tree succeeds and registers both tests (test-hrx-arena-refusal and
    test-hrx-host-memory-available are both in tests/CTestTestfile.cmake), which is the thing the conflict could have
    broken.
  • parser unit test, rebuilt from this head and run: host_memory_available_bytes() = 55600152576 bytes (53024.4 MiB),
    all requirements passed.
  • runtime/host-memory.cpp compile-checked with this head's own build flags (amdclang++ 24.0.0, GGML_HRX=ON,
    -fsyntax-only, includes pointed at the rebased tree): clean apart from the pre-existing freed set-but-unused
    warning at :1560, which this PR does not touch.

The diff against the new base is the same 204 lines it was against the old one (+1 line for the CMake merge); the only
behavioural difference from the reviewed head is the code that landed around 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 e9af7add7

Rebase checked with a range-diff against the head I reviewed (1d405caa1):

$ git range-diff <mb>..1d405caa1 428937ae6..e9af7add7
1:  1d405caa1 ! 1:  e9af7add7 hrx: warn once when an unbounded run pins more host weight ...
    ## tests/CMakeLists.txt ##
    -    target_include_directories(test-hrx-graph-program-budget ...)
    -    add_test(NAME test-hrx-graph-program-budget ...)
    +    target_include_directories(test-hrx-arena-refusal ...)
    +    add_test(NAME test-hrx-arena-refusal ...)

That is the whole delta: your block moved from after test-hrx-graph-program-budget to after test-hrx-arena-refusal (#180 landed first), and the added lines are unchanged. In the head both are registered and separate:

tests/CMakeLists.txt:358   add_test(NAME test-hrx-arena-refusal COMMAND test-hrx-arena-refusal)
tests/CMakeLists.txt:364   add_test(NAME test-hrx-host-memory-available COMMAND test-hrx-host-memory-available)

7 files, +204/−0, unchanged set. The review I posted at 1d405caa1 carries: host-memory-available.{h,cpp} is MemAvailable parsing with no /proc and no device, the warning sits inside the unbounded path (limit_ == 0) so a budgeted run stays silent, and the test asserts both directions of the parse plus the one-line-per-run suppression.

@bong-water-water-bong bong-water-water-bong added the tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied label Nov 4, 2026
@bong-water-water-bong
bong-water-water-bong merged commit 8f6daf9 into 1bit/hrx-vulkan-patched Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tprs-ready TPRS: review verdict READY posted by the standing reviewer; merge gate satisfied

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant