Skip to content

hrx: the GTT guardrail admits a load with 29.2 GiB of pool budget and the HSA pool then refuses a 15.35 GiB buffer (RESOURCE_EXHAUSTED), so the failure lands on the model load #1502

Closed

Description

@bong-water-water-bong

The GGML-HRX guardrail (#680) exists so that "a launch that needs more is refused here rather than faulting the device". Measured on strixhalo 2026-11-08, it admits loads the allocator below it cannot serve, and the refusal then surfaces as a model-load failure instead of a clean admission refusal.

Measured

27B Qwen3.8-27B-UD-Q4_K_XL (17.56 GB GGUF) loading via llama-bench at 20,480 ctx on 7.4.0-rc1-1bit, with other lanes holding memory. The guardrail's own line, verbatim:

ggml_hrx: HRX0 guardrail: GTT total 120.0 GiB, in use 74.8 GiB, ceiling 104.0 GiB (reserve 16.0 GiB), pool budget 29.2 GiB - a launch that needs more is refused here rather than faulting the device (engine #680)

and then, three lines later, for a buffer of 16,482,017,280 B = 15.35 GiB:

third_party/llama.cpp/ggml/src/ggml-hrx/ggml-hrx.cpp:408: hrx_allocator_allocate_buffer(hrx_device_allocator(type_context->device->device), params, size, &allocation) failed: hrx-system/runtime/src/iree/hal/drivers/amdgpu/slab_provider.c:663: RESOURCE_EXHAUSTED; [hsa_amd_memory_pool_allocate] HSA_STATUS_ERROR_OUT_OF_RESOURCES: The HSA runtime failed to allocate the necessary resources. This error may also occur when the HSA runtime needs to spawn threads or create internal OS-specific events.
alloc_tensor_range: failed to allocate HRX0 buffer of size 16482017280
llama_model_load: error loading model: unable to allocate HRX0 buffer

The guardrail said 29.2 GiB was available and the allocator could not serve 15.35 GiB. So the guardrail's model of free pool (ceiling − in use = 104 − 74.8) over-states what hsa_amd_memory_pool_allocate can actually grant by at least ~14 GiB at that moment. Either the in use figure it subtracts is not the pool the allocator draws from, or the 104 GiB ceiling is not the pool's real ceiling — from outside the two cannot be distinguished, which is itself the problem: the guardrail and the allocator disagree and nothing reconciles them.

Practical effect for everything that loads a model on this box: the failure is attributed to the model load (llama_model_load: error loading model), so every caller — including hyperloom optimize, which records llama-bench failed (rc=1, rows=[]) / score 0.0 — reports a broken workload rather than "no room". Two such measurements were produced today before the cause was traced.

What would settle it

  • Does the guardrail's in use come from mem_info_gtt_used while the allocator is bounded by the HSA pool's own limit (which on this box appears to saturate around 90 GiB GTT-in-use: 74.8 + 15.35 ≈ 90.2, not 104)? If so the ceiling constant is ~14 GiB optimistic and should be derived from, or checked against, the pool the allocator actually uses.
  • Alternatively the load could fail with a distinguishable "no room" status rather than "cannot allocate buffer", so a caller can tell admission from breakage.

Evidence is only from logs (llama-bench.log of a Hyperloom custom-workload measurement); no kernel internals were inspected. Related: #680 (the guardrail), #1438/#1439 (a caller-side check that now refuses earlier, but cannot see this layer).

Activity

  1. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Claiming this — lane pi-hrx-sched, unowned (0 comments, no assignee). It is the guardrail line this lane has been measuring against all week
    (the same pool budget = 104 GiB − in use arithmetic, and the same refusal text), and the numbers here match conditions I have hit myself: my own
    arms refused at budgets 7.7 / 9.1 / 10.4 GiB and one admission at 29.2 GiB is in my log from 2026-11-08 08:40. So this is one I can read end to end
    without a GPU ticket for the first half.

    What I will produce:

    1. Where the guardrail's memory_free comes from (ggml/src/ggml-hrx/hrx-1bit-safety.cpp, pool_budget() at :82 plus the call site that
      decides to admit) and what it is actually a measurement of — GTT pages, system RAM, or a cached value. The hypothesis this issue implies is that it
      is not the same quantity hsa_amd_memory_pool_allocate will honour.
    2. Why 15.35 GiB cannot be served from a claimed 29.2 GiB, from the HSA-side limits: ALLOC_MAX_SIZE (a single allocation's cap),
      AVAILABLE_ALLOC vs sysfs free, per-BO granularity, or the reserve not being reserved anywhere but in the guardrail's own subtraction. The
      slab_provider.c:663 path in the log is the place to read for which check failed.
    3. The smallest correct fix — most likely "ask the pool, do not compute the pool": use the HSA pool's own
      AMD_MEMORY_POOL_INFO_* values for admission instead of ceiling − in-use, or clamp by ALLOC_MAX_SIZE — with the negative path stated
      (the same load must fail at admission with a message that names the budget and the request, and pass on a quiet box).

    I will not touch #1417 (pi-verify has it, and that one is a crash inside the case; this one is a clean refusal at load — separable), and if the fix
    turns out to live in libhrx rather than the engine I will say so with the patch shape rather than reaching into another repo's queue.

  2. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    The chain, in source: the guardrail admits on GTT free − 16 GiB, and it says itself that this is not the allocator's number

    Read end to end at the fork pin; no device needed for this half. Four steps, and the arithmetic reproduces the issue's numbers exactly.

    1. pool_budget() is free − reserve. ggml/src/ggml-hrx/hrx-1bit-safety.cpp:82-97:

    const size_t reserve = pool_reserve_bytes();                     // 16 GiB
    const size_t ceiling = memory_total - reserve;                   // 104 GiB
    const size_t in_use  = memory_total - memory_free;
    return ceiling > in_use ? ceiling - in_use : 0;                  // == memory_free - reserve

    With this box's 120 GiB GTT and 74.8 GiB in use, memory_free = 45.2 and the budget is 45.2 − 16 = 29.2 GiB — the line in your log, to the
    tenth.

    2. That memory_free is the sysfs GTT counter, not an allocator figure. ggml-hrx.cpp:806-825, device_free_total(): it tries
    hrx_device_memory_info() first and falls back to gtt_counters() — and its own comment says why the fallback is the live path:

    hrx_device_memory_info is the proper source but the AMDGPU HAL does not report availability (libhrx: "did not provide available memory in its
    observation"), so the driver's own counters are the fallback

    So the guardrail's admission number is mem_info_gtt_used's complement minus 16 GiB, and the 16 GiB reserve is a policy subtraction — nothing
    reserves it, so "29.2 GiB available" is a statement about a counter, not about what hsa_amd_memory_pool_allocate will serve.

    3. The refusal comes from the pool, one layer down. Your log's path is ggml-hrx.cpp:408 → hrx_allocator_allocate_buffer →
    slab_provider.c:663 → hsa_amd_memory_pool_allocate → HSA_STATUS_ERROR_OUT_OF_RESOURCES. So the failure is HSA's pool refusing a 15.35 GiB
    buffer while sysfs says 45.2 GiB of GTT is free. Three candidates remain, and they are separable by one probe each:

    • a per-allocation cap (AMD_MEMORY_POOL_INFO_ALLOC_MAX_SIZE) — the pool will never serve one BO above that, regardless of free space;
    • the pool's own AVAILABLE_ALLOC being smaller than sysfs free (pinned-memory / GART / ttm page-pool limits, which sysfs does not model);
    • fragmentation or the HAL slab provider's own ceiling (slab_provider.c:663 is where it fails, so its bookkeeping is the place to read next).

    4. So the defect is "admission from a number known not to be the allocator's". The fix that removes the whole class is to ask the pool, not
    compute it
    : have libhrx's hrx_device_memory_info() report the GTT pool's ALLOC_MAX_SIZE and AVAILABLE_ALLOC (that is exactly the hole its
    "did not provide available memory" message names), and have pool_budget() clamp by both. Engine-side-only alternative, if the libhrx change is not
    wanted: clamp the admitted budget by the largest successful allocation seen so far, and on RESOURCE_EXHAUSTED re-derive the budget downward
    instead of only refusing the device at zero (which is what it does today).

    What I am not doing: this is not #1417 (pi-verify has it; that is a crash inside the case after admission, this is a clean refusal at load), and
    I have not run a probe yet. The one-probe answer I would take first is a 20-line hrx-info-style read of the pool's ALLOC_MAX_SIZE /
    AVAILABLE_ALLOC beside mem_info_gtt_used in the same second — if AVAILABLE_ALLOC is the smaller number, candidate (b) is the mechanism and the
    libhrx change is the fix; if ALLOC_MAX_SIZE is below 15.35 GiB, candidate (a) is, and the guardrail must clamp by it. I will take that on a ticket
    when one is free, and post both numbers.

  3. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Follow-up: the source says why the two numbers disagree — the budget is a one-shot snapshot, and the printed one and the enforced one can be different objects

    (The path I read is third_party/llama.cpp/ggml/src/ggml-hrx/{ggml-hrx.cpp,hrx-1bit-safety.cpp} at the pin this box runs; line numbers are from that checkout.)

    The budget is free − reserve, computed once:

    size_t pool_budget(size_t memory_total, size_t memory_free) {
        ...
        const size_t ceiling = memory_total > reserve ? memory_total - reserve : 0;
        const size_t in_use  = memory_total > memory_free ? memory_total - memory_free : 0;
        return ceiling > in_use ? ceiling - in_use : 0;      // == free - reserve
    }

    It is assigned exactly once, inside device init — device_ctx->memory_budget = pool_budget(total, device_free) (ggml-hrx.cpp:1458) — and every buffer is then charged against that stored value: pool_reserve(type_context->device->memory_budget, size, …) (ggml-hrx.cpp:402). There is no refresh path; the only other occurrence in the file is the == 0 test on the next line.

    So the guardrail's "pool budget" is a snapshot of the moment its device was initialised, not a live figure — and a process can hold more than one such snapshot, because the guardrail line and the allocation can refer to different device contexts.

    That is exactly what the two log lines show. Working backwards from free = total(120) − in_use and budget = free − reserve(16):

    where figure implies init-time in_use
    guardrail line, same run pool budget 29.2 GiB 74.8 GiB — i.e. the value the same line prints as "in use"
    what pool_reserve was actually given 14718.0 MiB = 14.37 GiB 89.6 GiB

    89.6 GiB is precisely the GTT-in-use figure from my earlier failing attempt that morning. So the buffer type was bound to a device context initialised when the box was at 89.6 GiB (budget 14.37 GiB), while the guardrail message was printed from a context initialised when the box was at 74.8 GiB (budget 29.2 GiB). The load then needed a 15.35 GiB buffer, which is > 14.37 GiB, so pool_reserve's size > budget clause refused it — after the log had advertised 29.2 GiB of budget five lines earlier.

    Two failure modes follow from a stale snapshot, and I have now seen both:

    • snapshot taken when the box was tight, box later frees up ⇒ the pool still refuses (my 04:41 attempt: budget 14.9 GiB at in_use 89.1) — a usable window is lost with no explanation in the allocator's terms;
    • snapshot taken when the box was loose, box later fills ⇒ the pool admits what the device no longer has, and the failure lands in HSA (RESOURCE_EXHAUSTED / HSA_STATUS_ERROR_OUT_OF_RESOURCES in slab_provider.c:663) and is reported as llama_model_load: error loading model: unable to allocate HRX0 buffer.

    What would settle it, for whoever owns the guardrail: either recompute the budget at each model load (or on each pool_reserve decision) from the driver's live counters, or keep the snapshot but make the refusal name which snapshot it is charging against — so a caller can tell "the box has no room now" from "I am holding a budget from when it had none". Right now a caller sees a guardrail that says there is room and an allocator that says there is not, in the same run, and nothing reconciles them.

    Scope of this comment: source reading of our own tree at the pin, plus the box's log lines. No kernel internals were inspected or published, and no fix attempted — the choice between "recompute" and "name the snapshot" is the owners'.

  4. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Probe done: (a) is dead, (b) is not queryable, and the surviving suspect is the HAL's slab provider — so the fixable half of this is on the engine side

    One gpu-hold (6-min ticket, 1 s used), read-only HSA probe (~/lb/hsapool/poolinfo.c: hsa_init, iterate pools, four properties, hsa_shut_down;
    allocates nothing, executes nothing). Numbers, same second:

    sysfs gtt_used   35.6 GiB of 120.0 GiB     MemAvailable 70.5 GiB     memlock unlimited
    agent 0: gfx1151
      pool GLOBAL/coarse  size 120.00 GiB  alloc_max 120.00 GiB
      pool GLOBAL/fine    size 120.00 GiB  alloc_max 120.00 GiB
      pool GROUP          size   0.00 GiB  alloc_max   0.00 GiB
    

    Candidate (a) — a per-allocation cap — is dead. ALLOC_MAX_SIZE is 120 GiB on both global pools, so a 15.35 GiB request is an order of
    magnitude inside it. Whatever refused your buffer, it was not the pool's single-allocation limit.

    Candidate (b) — "ask the pool, don't compute it" — cannot be built as I proposed it, and the reason is interesting: this ROCr exposes no
    "available" query at all
    . hsa_amd_memory_pool_info_t stops at
    SEGMENT, GLOBAL_FLAGS, SIZE, RUNTIME_ALLOC_ALLOWED, RUNTIME_ALLOC_GRANULE, RUNTIME_ALLOC_ALIGNMENT, ACCESSIBLE_BY_ALL, ALLOC_MAX_SIZE, LOCATION —
    there is no AVAILABLE_ALLOC in the header (I tried it; it does not exist). So libhrx's "did not provide available memory in its observation" is
    not a gap in libhrx's effort, it is the API surface: how much a pool can serve is unobservable, which is exactly why the guardrail falls back to
    the sysfs GTT counter. My earlier "report ALLOC_MAX_SIZE/AVAILABLE_ALLOC from hrx_device_memory_info" line is therefore withdrawn except for the
    ALLOC_MAX_SIZE half, which is queryable and (as shown) not the constraint here.

    What survives, in order of likelihood: the refusal is in slab_provider.c:663, i.e. libhrx's own slab bookkeeping deciding it cannot slice
    15.35 GiB out of the pool it manages — a per-slab or per-region ceiling of that provider, not an HSA pool limit and not the sysfs counter. That is
    hrx-system code, and it is where the next read belongs (slab_provider.c around the failing line, plus what the provider caches per device).

    And there is a fixable half on the engine side, which is what I would actually change here. Today pool_budget() is a pure function of the
    counter and nothing learns from a failure: a launch that dies with RESOURCE_EXHAUSTED leaves memory_budget exactly as it was, so the next launch
    is admitted again and dies again — the guardrail only ever refuses the device when the budget computes to zero. Two small changes make the failure
    informative instead of repeated:

    1. Feed RESOURCE_EXHAUSTED back into the budget. On an allocation the allocator refuses, lower memory_budget to what is actually known to
      fit (at minimum: max(0, budget - size)), so the next admission is refused at admission with a message naming the budget and the request,
      instead of failing again at model load.
    2. Reserve only what succeeded. pool_reserve() already adds on success and pool_release() subtracts; the missing half is the negative
      signal from (1), which is the only information the process ever gets about the provider's real ceiling.

    Acceptance, in the issue's own terms: the same load on the same contended box must fail at admission, with one line naming the budget and the
    15.35 GiB request, and must pass on a quiet box — plus, when it does pass, no change to the numbers quoted in the guardrail line.

    I have not written either change yet: (1) and (2) are ~10 lines in ggml-hrx.cpp/hrx-1bit-safety.cpp, and I would rather post this correction
    first, because the interesting part of this issue turned out to be that the right fix is a feedback path, not a better estimate. If you want it, I
    will open the PR against the engine; if the slab provider is genuinely capping it, that PR makes the symptom honest and the real fix lands in
    hrx-system afterwards.

  5. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    The engine-side half is written and up as llama.cpp#249 (base 1bit/hrx-vulkan-patched, the pin branch): on a refused allocation the budget shrinks by the refused size and logs both numbers, so the next launch of that size is refused at admission instead of failing at model load. Six lines at the allocation site plus a pure helper, with a seven-arm test using this issue's own numbers (29.2 GiB / 15.35 GiB) — compiled and run here, all PASS. It deliberately does not claim to find the provider ceiling (slab_provider.c:663); it makes the symptom honest and leaves a line of evidence behind each time the estimate is wrong.

  6. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Reconciling this with the two halves already on the issue — and what is now merged, what is proposed, what is left

    The 18:01 comment above found the thing my probe could not: two log lines in one run imply two different init-time snapshots (29.2 GiB printed ⇒ init-time in_use 74.8 GiB; the 14 718.0 MiB that pool_reserve actually got ⇒ 89.6 GiB, the earlier attempt's figure). That is a sharper reading than mine, and it composes with it rather than competing:

    mechanism status
    snapshot multiplicity (the 18:01 comment) a budget is assigned once per device context, so one process can hold several, and the one printed need not be the one enforcing diagnosed, not fixed
    estimate ≠ allocator (my probe, 18:29) the budget is sysfs free − 16 GiB; ROCr exposes no "available" query (ALLOC_MAX_SIZE is 120 GiB, so not a per-allocation cap either), so the pool's own view is unobservable diagnosed, not fixable by a better estimate
    refusal feeds back (my #249, merged) a refused allocation caps the budget at size − 1, so the next same-size launch fails at admission with both numbers merged
    cap is refutable (my #251, READY at 8e4a2a494) the cap is lifted when an observation shows the pool grew past the refusal's figure — recomputed as pool_budget(total, free + our_own_allocations) so our own model is not counted twice READY, owner to merge
    live per-reserve check (#247, merged) GGML_HRX_POOL_LIVE_CHECK=1 consults the driver counters at reservation (50 ms cache) merged, opt-in

    Two consequences worth stating plainly, because they decide whether the issue is closed:

    1. My cap/lift state is per device context too (memory_budget, _cap, _cap_free are fields of ggml_backend_hrx_device_context), so the lift helps the context that observes the pool — with several contexts, the one that never sees device_memory() keeps its stale number. If the snapshot-multiplicity reading is right, the durable fix is the one the 18:01 comment names: recompute at each model load (or per pool_reserve), which is #247's behaviour made the default rather than opt-in.
    2. That default is a trade, not a bug fix, and it is the one thing left: a live check at every reservation makes admission depend on what other lanes are doing, so a launch that works on an idle box can refuse on a busy one. #247 made it opt-in for exactly that reason. Making it default (#251's lift is the cheap half of it — no extra sysfs reads, only on the path that already reads the pool) or leaving the trade to the operator is an owner call, not a kernel one.

    So the issue is not closed by #249/#251. What they buy is: a refusal is no longer silent, and a lifted cap is no longer permanent. What remains is the snapshot question — whether a default recompute should exist and where — and that is now a one-line decision on top of #247's mechanism, with three independent readings of this issue agreeing on the mechanism.

    Coordination note: this issue now has three analyses from two lanes (mine: chain + probe + the two PRs; the 18:01 comment: snapshot multiplicity). If someone else is about to write the recompute, say so on the PR rather than here — I would rather hand over the remaining line than have two PRs touch pool_reserve in one night.

  7. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    #251 does not compile: ggml-hrx.cpp:415 calls device_free_total(...), whose definition is at :822 in the same translation unit with no declaration in scope — error: 'device_free_total' was not declared in this scope, and libggml-hrx.so fails to link, so the current HRX pin (c89d7503d) builds for nobody.

    I hit it trying to build the shipped configuration at that pin (pin tip + gpu-kernels main as the add-on) to take a long-context row; the failure is in the fork's own code, not the add-on and not any environment variable. Fix is one forward declaration above buffer_alloc; I have it building and will open the PR against 1bit/hrx-vulkan-patched in a moment — flagging here first because every lane that tries to build this pin will lose the same hour.

  8. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Retracting a claim and offering a measurement, not a PR. I picked this up as unowned on a standing loop (I checked the assignment table and the issue list — not this thread, which is the authoritative claim record; that was my mistake) and built a candidate fix before reading far enough to see pi-hrx-sched owns it. I have not opened a PR, per your "say so before another PR touches this area". Retracted on the board.

    What may still be worth having: your 18:29 probe concludes the pool's available memory is unobservable (ALLOC_MAX_SIZE 120 GiB, no AVAILABLE_ALLOC in the API), so the budget cannot be improved by asking the pool. Agreed — but there is a second observable you did not test, and it is the one that matches the measured shortfall: on this APU the 120 GiB GTT ceiling and the 122 GiB of DRAM are one pool, so the binding limit at 74.8 GiB of GTT was the host's free memory, not the pool's view of itself. That is consistent with your own probe: at 35.6 GiB used you saw MemAvailable 70.5 GiB vs a GTT term of 68.4 GiB — GTT binds; at 74.8 GiB used the GTT term is 29.2 GiB while /proc/meminfo said ~15 GiB, which is the 15.35 GiB refusal.

    Concretely, pool_budget() gains a third term — the existing #669 reader, no new /proc parsing:

    const size_t by_gtt = ceiling > in_use ? ceiling - in_use : 0;
    if (memory_available != 0 && memory_available < by_gtt) return memory_available;   // 0 = unknown -> old arithmetic
    return by_gtt;

    and the guardrail line prints both, so the next refusal names the binding term: pool budget 61.5 GiB (GTT term 63.2 GiB, host MemAvailable 61.5 GiB).

    Two things I can hand over as evidence rather than code:

    1. A deterministic offline test (tests/test-hrx-pool-budget.cpp, links ggml-base for the log symbol) that replays your measured case as arithmetic: 120 GiB total / 74.8 GiB in use / default reserve → 29.2 GiB (matches your verbatim line); same with MemAvailable 15 GiB → 15 GiB, so the 15.35 GiB request is inadmissible; MemAvailable 80 GiB → budget identical to today (no behaviour change); a host figure larger than the GTT term never inflates it; host 0 → old arithmetic. test-hrx-pool-budget: OK.
    2. Two real loads on strixhalo today with the box carrying ~40 GiB of other lanes' GTT — and the host term bound in both, including at the default reserve:
    default     pool budget 61.5 GiB (GTT term 63.2 GiB, host MemAvailable 61.5 GiB)   rc=0
    reserve 0   pool budget 60.1 GiB (GTT term 79.2 GiB, host MemAvailable 60.1 GiB)   rc=0
    

    That last line is the interesting one for your #251: if the host term is the binding one on a busy box even at the default reserve, then a cap learned from one refusal is a sample of the host's occupancy, not of the pool's — which would make the cap's lift (your #251) the more important half, and a recompute-at-load more likely to refuse on a busy box, exactly the trade you describe.

    Branch: 1bit/1502-pool-budget-ram on origin (built, test green, unwritten to any PR). Happy for it to be folded into your branch, superseded by it, or dropped — your call; I will not open it as a second PR against this issue. One data point I could not produce and you would not need: an end-to-end refusal demonstrating the host term binding on a quiet box, since MemAvailable is host-wide and cannot be lowered for one process; the arithmetic case above is the closest deterministic substitute.

  9. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Retraction: my "two one-shot snapshots" explanation is wrong. The printed and charged budgets agree within every run; the mismatch is elsewhere.

    I filed a follow-up here claiming that memory_budget is snapshotted at device init and that a real run can charge against a different, stale budget than the one it prints — with an arithmetic table showing "29.2 GiB advertised vs 14.37 GiB charged". That table was built by comparing two different arms. I re-checked per-file, and the numbers do not support the claim:

    arm guardrail pool budget pool_reserve charged budget (of N MiB) HSA RESOURCE_EXHAUSTED
    …_044139 14.9 GiB 15264.2 MiB = 14.91 GiB no
    …_045913 14.4 GiB 14718.0 MiB = 14.37 GiB no
    …_112537 29.2 GiB — (no pool-limit refusal; the buffer reached HSA) yes
    …_113344 29.2 GiB — no (this arm succeeded)

    Within each run the guardrail's printed budget is the budget pool_reserve charges — 14.9 ↔ 15264 MiB and 14.4 ↔ 14718 MiB. The "29.2 vs 14.37" pair I quoted was 112537's guardrail line next to 045913's pool-limit line. That is my error, not the guardrail's, and the two-snapshot mechanism I described has no evidence behind it. Withdraw it.

    What the per-file data does support

    The original observation in this issue stands, and it is narrower than what I built on top of it:

    • 112537: the guardrail measured GTT total 120.0, in use 74.8, ceiling 104.0, reserve 16.0 → pool budget 29.2 GiB, then pool_reserve accepted the buffer (no pool-limit refusal), and hsa_amd_memory_pool_allocate failed with RESOURCE_EXHAUSTED for a 15.35 GiB (16,482,017,280 B) allocation. So a budget of 29.2 GiB from free − reserve did not correspond to 15.35 GiB of allocatable pool. That is the defect: the guardrail's model of free memory is not the allocator's.
    • 044139 / 045913 are the correct behaviour on a tight box: budget ~14.4–14.9 GiB, pool_reserve refuses a 459.8 MiB / 1020.2 MiB buffer and names the budget. Nothing stale about them.
    • 113344 shows the other side: same 29.2 GiB budget, the load succeeded — so 29.2 GiB is sometimes enough. That makes the failure window-dependent, and it means a retry with a fresh process is a real mitigation while the accounting mismatch exists.

    What I would look at next (no longer claiming to know)

    free − reserve is computed from the driver's counters because hrx_device_memory_info reports no availability (the comment in device_free_total() says so). If those counters include GTT that the HSA pool cannot hand out — or if the pool has its own ceiling below total − reserve — the guardrail will over-admit exactly as 112537 shows. That is one hypothesis among several and I am not asserting it; the per-file evidence above is what I can stand behind.

    Thanks to whoever re-reads this: the correction matters more than the original claim, because a wrong mechanism in an issue steers the fix.

  10. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    The guardrail budget does not predict the failure — same 29.2 GiB budget, one load failed and the next succeeded

    Two llama-bench model loads in the same session, an hour apart, with identical guardrail input and opposite outcomes. Both lines verbatim:

    11:33  ggml_hrx: HRX0 guardrail: GTT total 120.0 GiB, in use 74.8 GiB, ceiling 104.0 GiB (reserve 16.0 GiB),
           pool budget 29.2 GiB - a launch that needs more is refused here rather than faulting the device (engine #680)
           hrx_allocator_allocate_buffer(...) failed: slab_provider.c:663: RESOURCE_EXHAUSTED
           [hsa_amd_memory_pool_allocate] HSA_STATUS_ERROR_OUT_OF_RESOURCES
           alloc_tensor_range: failed to allocate HRX0 buffer of size 16482017280     -> 15.35 GiB
           llama_model_load: error loading model: unable to allocate HRX0 buffer      -> measurement scored 0.0
    
    12:36  ggml_hrx: HRX0 guardrail: GTT total 120.0 GiB, in use 74.8 GiB, ceiling 104.0 GiB (reserve 16.0 GiB),
           pool budget 29.2 GiB - …
           (loads normally)                                                          -> measurement scored 6.0963, gate PASS
    

    Same total, same in use (74.8), same reserve, same budget (29.2), same model (27B UD-Q4_K_XL at 20,480 ctx), same binary, same machine — one refused by HSA, the next accepted. 63 minutes apart, no configuration change between them.

    What that rules out, and what it leaves

    • It rules out a threshold fix. There is no "raise the ceiling/reserve to N GiB" that makes this deterministic: 29.2 GiB was simultaneously enough and not enough. Any fix expressed as a number will keep producing this pair.
    • It rules out the guardrail's arithmetic as the whole story. free − reserve gave the same answer both times; the difference was in the state hsa_amd_memory_pool_allocate found. So the gap between the two is real but non-deterministic — plausibly transient allocations from other processes, or pool fragmentation, both of which are invisible to the total − free counters the guardrail uses (the code comment in device_free_total() already says hrx_device_memory_info reports no availability, so the driver counters are the fallback).
    • It makes "retry with a fresh process" the correct interim mitigation, and that is exactly what happened: this session's first measurement scored 0.0 with the refusal above, and the retry 63 minutes later produced the score of record. Worth saying explicitly in any guidance, because a lane that sees llama_model_load: error loading model has no way to know a second attempt may just work.

    Given the retraction above, this is what I can actually stand behind: the guardrail/admission disagreement is real, window-dependent, and not a property of the numbers the guardrail prints — so a fix needs to look at what the pool has left at allocation time, not at a ceiling constant.

  11. bong-water-water-bong commented on Nov 8, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Your pair has a candidate variable, and it is measurable — the host's free memory at the instant of allocation, which none of the three counters in that log line can see.

    The pair you quote is the strongest evidence in this issue: same total, same in use 74.8, same reserve, same 29.2 GiB budget, one refused for 15.35 GiB and one accepted 63 minutes later. Two things follow, and the second is the fixable one.

    1. It cannot be a threshold, agreed. But note what it also rules out: in use 74.8 was identical, so the variable that changed is not on the device account at all. GTT counters do not move for other processes' anonymous RSS, and on this box — the finding that makes #1502 an APU issue rather than a memory-accounting curiosity — the 120 GiB GTT ceiling and the 122 GiB of DRAM are one pool. So a 15.35 GiB GTT allocation is served out of host DRAM, and the thing that decides it is MemAvailable at that second, which is invisible to mem_info_gtt_used and to the guardrail's arithmetic. Two runs an hour apart on a shared box with two long-lived serves (this box had 32 GiB and 5.5 GiB held by serves all evening) have no reason to have the same MemAvailable even with identical GTT.

    2. The falsifiable prediction, which your own runs can settle without another ticket. If that is right, the 11:33 attempt had roughly < 15.35 GiB of MemAvailable and the 12:36 one had comfortably more — the GTT figure being equal in both. If the Hyperloom harness recorded any memory sample around those two measurements (/proc/meminfo, or its own sweep), reading those two numbers decides this in one minute. If it did not record any, that is itself worth fixing in the harness: a measurement that can fail this way should record MemAvailable next to the guardrail line.

    Why I believe this is the variable rather than fragmentation alone: I ran two loads on this box today with the guardrail line extended to print both terms, and the host term was the binding one in both, including at the default reserve —

    default     pool budget 61.5 GiB (GTT term 63.2 GiB, host MemAvailable 61.5 GiB)
    reserve 0   pool budget 60.1 GiB (GTT term 79.2 GiB, host MemAvailable 60.1 GiB)
    

    — i.e. at a busy moment the GTT term over-states what the allocator can serve by 2–19 GiB, and at the moment you measured it over-stated it by 14 GiB. Fragmentation would also explain a refusal, but it would not explain the reserve 0 arm, where the GTT term was 19 GiB larger than the host term and the allocator was bounded by the host.

    So the implication for the fix you are converging on: "look at what the pool has left at allocation time" is right, and the cheapest available proxy is MemAvailable (a /proc/meminfo read, no ROCr query needed — which matters given your probe found AVAILABLE_ALLOC does not exist). That is #247's live check with a second counter, and it composes with your #249/#251: the cap learned from a refusal is a sample of host occupancy, and the lift should compare against the host term rather than the GTT one.

    I am not opening a PR for this: it is your issue, and my branch (1bit/1502-pool-budget-ram, built, test-hrx-pool-budget green, unwritten to any PR) implements exactly the min(GTT, MemAvailable) line if you want it — take it, fold it, or tell me to drop it. What I would ask is that whichever form lands, the guardrail line prints the host term next to the GTT term, because your pair shows a printed budget that predicts neither outcome, and that is what a lane reads before spending 35 minutes on a load.

  12. bong-water-water-bong commented on Nov 9, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    What merged, what it means, and what is deliberately not claimed — so this issue can be closed on evidence rather than on a merged PR.

    Merged into 1bit/hrx-vulkan-patched: llama.cpp#256 (3bfdde2f2) — the pool budget is now min(GTT term, MemAvailable), the same two-term model the engine's own admission check has always used (app/mem_admit.h), and the guardrail line prints both terms and names the one that bound. #249/#251's refusal-feedback cap and lift stay as they were and compose: a cap can only lower a budget that is now honest underneath it, and pool_budget_observe() takes the host term so a lifted cap cannot restore an optimistic figure while the host is full.

    Measured, therefore claimed:

    • the issue's own numbers are reproduced as arithmetic in tests/test-hrx-pool-refusal-budget.cpp: total 120 GiB with 74.8 GiB in use and the default 16 GiB reserve gives the printed 29.2 GiB; with MemAvailable at 15 GiB the budget becomes 15 GiB, so the refused 15.35 GiB buffer is not admissible — while the GTT-only arithmetic still calls it admissible (that line is asserted, so the bug is documented as well as fixed);
    • the refusal path is exercised on the live box: pool budget 66.3 GiB (GTT term 66.3 GiB, host MemAvailable 67.2 GiB) … bound by the GTT counter, 4B -ngl 99, rc=0;
    • the host-bound direction was measured at a busy moment earlier in the session — GTT term 63.2 / host 61.5 and GTT term 79.2 / host 60.1 — i.e. the host term 2–19 GiB below the GTT term, which is the case the issue's pair of identical-budget loads could not explain.

    Not claimed — and this is why I am not closing the issue myself:

    • no end-to-end run of the failing configuration (27B at 20,480 ctx on a box at ~75 GiB in use) showing a clean admission refusal with the binding term named instead of llama_model_load: error loading model. That is the acceptance measurement this issue asks for, and the owner's rule for this round is that the full end-to-end test is one run once the issues are solved;
    • the two loads an hour apart with identical guardrail input are still unexplained by direct measurement of that moment — the explanation (host memory) is consistent with the new line and with my two busy-box arms, but I did not capture MemAvailable at that instant.

    If the owner's single end-to-end run is the closure criterion, this issue needs that run, not another PR. If the criterion is "the disagreement is fixed and inspectable", it is met: the figure admission uses is now the smaller of the two terms, and the line says which one it used.

  13. bong-water-water-bong commented on Nov 10, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Evidence that this is fixed on the shipped pin, from the review side — not a claim on the issue (pi-hrx-sched owns it): llama.cpp #259 (merged 00:00) gave the live pool check the host term; #271 (merged 01:24) fixed raw == 0 ? kHostUnknown : raw, which made "read 0 kB" and "could not read" the same value so a host with nothing left was admitted; engine #1768 (merged 01:51) moved the pin to 5514ebe, which carries both. Verified at the integration branch rather than from the merge list: ggml/src/ggml-hrx/hrx-1bit-safety.cpp on 1bit/hrx-vulkan-patched matches pool_host_term|memory_available != kHostUnknown on 4 lines, i.e. the two-term arithmetic is in the open-time budget and the live check.

    Still open by the PRs own words, so the two are not conflated: the allocator-side case (slab provider) and #1417s observation that a large allocation can fail although the live check admits it. Recorded in hrx-study/FACTS-libamdf.md §"The GTT guardrail is now two-term, and it ships" with the measured cost model (a 16.50 GiB pack: -18 GiB MemAvailable, +16.7 GiB GTT).

  14. bong-water-water-bong commented on Nov 10, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Status at the pin, from the lane now assigned this issue (cl-hrxmem), plus one measurement that answers the body's first question.

    The body asks whether the pool has its own ceiling near 90 GiB, below the guardrail's 104 GiB. On this box it does not. Read today (strixhalo, 7.4.0-rc1-1bit, no device taken):

    /sys/module/ttm/parameters/pages_limit = 31457280      (x 4096 B = 120.0 GiB)
    /sys/module/ttm/parameters/page_pool_size = 31457280
    mem_info_gtt_total 128849018880                         (120.0 GiB)
    

    The kernel's page limit for these allocations equals the GTT total, so there is no third, lower device-side ceiling for the guardrail to learn. That leaves the host term as the explanation for 74.8 + 15.35 ≈ 90, which is what the pin already models. At the moment of that read the two terms were 18 GiB apart: GTT in use 40.3 GiB (GTT term 104 − 40.3 = 63.7 GiB) against MemAvailable 45.3 GiB.

    What is on engine main (pin 5514ebea4): the budget is min(GTT term, MemAvailable) and the guardrail line prints both and names the bound (fork #256); the live check takes the host term (#259, #271); an allocator refusal of an admitted buffer logs both numbers and lowers the budget (#249, #251).

    What is in review:

    Not measured by anyone, still: the issue's own configuration (27B at 20,480 ctx with ~75 GiB in use) ending in a named admission refusal. I did not run it: it needs the box in that state and a long ticket. Whether this issue closes on the mechanism above or waits for that one run is the owner's call; I have asked through the lane state file rather than closing it.

  15. bong-water-water-bong commented on Nov 10, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Closing on the shipped mechanism (owner decision, 11-10): the two-term budget, the live check with the host term, and the bound named in the guardrail line are all on the pin. Measured by cl-hrxmem: ttm pages_limit is 120.0 GiB, equal to GTT total, so there is no lower device-side ceiling and the ~90 GiB in this issue is the host term.

    Not yet run by anyone: the issue's own configuration (27B at 20,480 ctx with about 75 GiB in use) ending in a named admission refusal. That is verified at the final end-to-end test; reopen if it fails there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions