Repository navigation
hrx: warn once when an unbounded run pins more host weight than the box had available (engine #669) - #193
Conversation
|
#193 ready for review at |
Dose test: three attempts, all silent - the path never executed, and here is what that saysThe claim I wanted to prove was "the line fires once when the resident set passes the threshold". I could not stage it on Dose build = this PR + threshold forced to
Worth knowing independently of this PR: my own 11-01 measurement put ~2030 MiB of host weights on a 4B at Not claimed: that the warning fires. Claimed: the parse is unit-tested (10 cases + live read), the changed TU compiles with |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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_WARNis in the shared success path ofacquire()but gated onlimit_ == 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/procread per weight; the latch
(oversize_warned_) makes it once per cache, andclear()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/proccannot 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.
|
Context: PR #193's body says
The memory lane (THRX-MEM — the residency semantics are its brief) re-ran that arm verbatim with a per-process sampler. Measured (2026-11-04 08:48 AST, one
|
| 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:
- +36 KiB is below the floor of having the runtime open. The
-dev nonecontrol costs 298 MiB in the same ticket
(and the standalonehrx_sizesprobe measured 294.9 MiB independently: AQL rings, CWSR save areas, scratch). Every
HRX0 arm must exceed its-dev nonesibling by at least that, so a delta smaller than the fixed cost is a sampling
artefact rather than a device fact. 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 fromHostWeightCache::acquire(), reached from
materialize_host_bindings(command-program-executor.cpp:581-582, acquire at:613). Independently, the
amdgpu_bo_createtracepoint shows the per-tensor device BOs at-ngl 0(306x2, 20x18, 14x90, 6x72 … = 3002 MiB) and
none of them in the-dev nonearm. 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.- 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_ctxper buffer with4*n_layer + 1buffers (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 0does 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
reachableacquire()(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.
Correction to what this PR claimed about reachability, with the dose test it should have hadThe body said the dose runs were silent because " With the right runtime, at the base pin
Re-run as a dose (scratch build, threshold
So the line does fire, at the crossing, once - and the two bounded arms stay silent, which is the control that says the |
…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.
1d405ca to
e9af7ad
Compare
|
Rebased onto Conflicts, and how they were resolved. One file conflicted: Re-checked on the new base:
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 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
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.
What
With
GGML_HRX_HOST_WEIGHT_LIMIT_MBunset (the shipped default) nothing bounds host-weight residency: each weight getsits 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.
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
MemAvailablefell ~107 GiB, everything backafter the kill. Nothing in the log said why, and structurally nothing could: the acquire-time refusal
(
host-memory.cpp:1881), the arena refusal and itsGGML_LOG_ERROR(:1963) andevict_for(:1889) are all insideif (limit_ != 0), and the default iskDefaultMiB = 0(:1460). So on a default run the only agent that knowsresidency 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()parsesMemAvailableout of a/proc/meminfobody;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 recordsMemAvailable; the first timeresident_bytes_passes it, oneGGML_LOG_WARNand a latch, so it never repeats.clear()resets the latch.limit_ != 0) are untouched: they already have their own refusals.Verification
Unit test, no device (
tests/test-hrx-host-memory-available.cpp, added to theif (TARGET ggml-hrx)group next totest-hrx-graph-program-budget): ten cases over the parser - realmeminfoshape withMemAvailableneither first norlast, variable whitespace, missing field, lookalike key
MemAvailableX:, junk, empty value, missing newline,2^64-1kB (overflow), negative value,
nullptr, empty string - plus the live read (must be a plausible number or 0).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'scompile_commands.jsonand run with-fsyntax-only: cleanexcept the pre-existing
freedset-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 0performs 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)) becauseIREE_HAL_AMDGPU_LIBHSA_PATHpointed at/opt/1bit/libhsainstead 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 thedevice materialises nothing, which is exactly the
+36 KiBGTT delta I read as "no device work". With the right runtime,-ngl 0materialises 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 printslimit=0.0 MiB, i.e. the pin's default really is unbounded - theheader's "default 2048" is the lie #191 fixes.
Re-run as a dose (scratch build, threshold forced to
available/1024, never pushed, onegpu-holdticket per arm):-dev HRX0 -ngl 0 -fit off)GGML_HRX_HOST_WEIGHT_LIMIT_MBVerbatim, and it fires once at the crossing rather than per weight:
Scope, unchanged: the shipped threshold is the box's real
MemAvailable(42-64 GiB on this box), so on this box the linecan 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.