Repository navigation
Description
What was measured
amdgpu_bo_create tracepoint (one event per KFD Buffer Object with its exact pages), per allocating process, on
strixhalo (gfx1151, ttm.pages_limit = 120 GiB GTT, no-SVM trio HSA_USE_SVM=0 HSA_USERPTR_FOR_PAGED_MEM=0 HRX_ALLOW_NO_SVM=1), pinned fork d8bd0ff6052a (llama-cli from ~/wt/i674-repro/stage-pin788, build b0-d8bd0ff6052),
-fit off -c 256 --temp 0 -n 8 -no-cnv, model Qwen3-4B-Q4_K_M (398 tensors, 2375.9 MiB of tensor bytes, 2.31 GiB file).
The trace accounts for essentially the whole footprint: BO creation sums vs the same run's own peak drm-total-gtt:
-ngl 0 3299.3 / 3301 MiB, -dev none 296.9 / 298 MiB, -ngl 99 5901.3 / 5903 MiB.
| arm | BOs | BO bytes | largest BO | per-tensor weight BOs |
|---|---|---|---|---|
-dev none -ngl 0 (control) |
54 | 296.9 MiB | 140 MiB | none |
-dev HRX0 -ngl 0 |
416 | 3299.3 MiB | 306 MiB | yes: 20 MiB ×18, 14 MiB ×90, 6 MiB ×72, 4 MiB ×18 … |
-dev HRX0 -ngl 99 |
363 | 5901.3 MiB | 2376 MiB | the same set again: 306 MiB ×3, 20 MiB ×18, 14 MiB ×90, 6 MiB ×72, 4 MiB ×19, 2 MiB ×54 |
The 2376 MiB BO is exactly ceil(2491323904, 2 MiB) — the GGUF's whole tensor region rounded up to the 2 MiB allocation
granularity this driver uses — and the per-tensor BOs next to it are byte-for-byte the same size classes the -ngl 0 arm
materialises, at counts that match the GGUF's own tensor families (18 blk.N.ffn_down Q6_K, 90 ffn_gate+ffn_up+
ffn_down Q4_K, 72 attn_q+attn_output, 18 attn_v).
So with all layers offloaded the same weights are resident in GTT twice: once as the whole-model buffer (~2.32 GiB) and
once as per-tensor device buffers (~2.07 GiB). That is where this arm's 5.9 GiB for a 2.38 GiB model goes; it is not KV,
arenas or transients (the control run fixes those at 296.9 MiB, and a first request adds only a few MiB).
Same numbers for the other models at -ngl 0, where the attribution is exact (observed BO counts vs GGUF classes rounded
to 2 MiB, fixed cost subtracted): 0.6B 122 MiB ×2, 4 MiB ×14/14, 2 MiB class 182 of 295 (the 113 missing are exactly the
113 F32 norm tensors, so -ngl 0 materialises the MUL_MAT weights and leaves norms host-side); 8B 422 MiB ×1, 72 MiB
×40/40, 54 MiB ×20/20, 36 MiB ×20/20.
Interpretation (flagged, not claimed)
Either the residency cache re-materialises weights that are already device-resident under -ngl 99 — the -ngl 0 defect of
#669 in the other mode — or the whole-model BO is a different backing (import/loader buffer) that the compute path does not
read, and the per-tensor BOs are the only live copy. What would settle it: knowing which of the two the graph binds. From
outside, both copies are charged to GTT for the process's lifetime.
Reproduction
# under gpu-hold, trio on, root only for tracefs (bo_trace restores it and drops the workload back to $SUDO_USER)
gpu-hold -l <lane> -t 5 -- bash tools/scripts/mem-botrace.sh # 3 arms in ~5 s
gpu-hold -l <lane> -t 3 -- bash tools/scripts/mem-sizes.sh # allocation-size sweep, <1 s
Instrument: tools/bo_trace.py (selftest included) — enables only events/amdgpu/amdgpu_bo_create, raises
buffer_size_kb for the run, restores both, attributes events to the child by tid/comm, and reports BO sizes plus the
tracepoint's type/prefer/allow/visible fields. Raw evidence for this report: evidence/memory/bo-trace-20261102-1647.log
(three arms) and bo-trace-multi-20261102-1659.log (0.6B + 8B).
Scope / caveats
- One model at
-ngl 99(the 4B).-ngl 0was verified at three models. -fit off, single short prompt, one ticket, one box state; the-ngl 0unbounded total moves 3301→4113 MiB between
sessions (recorded separately), but the composition above reproduced exactly within this ticket.- Small (sub-2 MiB) allocations are suballocated inside slabs, so their BO counts are not a tensor census; the ≥ 4 MiB
classes are exact one-for-one.
Activity
bong-water-water-bong commentedon Nov 2, 2026
Triaged:
bugadded. Owner hrx-study (the lane that measured it), bucket A. Three things from the tree at the pind8bd0ff6052a, all device-free — one of them settles the question you flagged rather than deferring it.1. The 2376 MiB BO is the loader's model buffer, not an import or a registry mapping
The size is the giveaway and the source confirms it: llama.cpp allocates one buffer per buffer type, covering every
tensor assigned to that buft, and prints its size.
So at
-ngl 99that BO is the model buffer (2375.91 MiB of tensor bytes →ceil(…, 2 MiB)= 2376 MiB, exactly theevent you traced), and it is neither an import nor a shared/registry mapping. That kills one of your two readings: the
whole-model BO is not "a different backing the compute path does not read" because it is an import — it is the buffer
the loader placed the weights in, which is what
-ngl 99means.2. The per-tensor set most plausibly matches HRX's own weight materialisation, which would explain the identical size classes
ggml/src/ggml-hrx/runtime/host-memory.cpp:306materialize_symmetric_i4_row64()andruntime/host-memory-ternary.cpp:111materialize_ternary_q4_0_k128()exist to repack a weight into the layout a kernelwants (the i4 one is row64 at 144 B/row), keyed on
HostWeightSource.materialized_length. That is a per-tensorallocation whose sizes track the GGUF's tensor classes exactly — which is what both arms show (the
-ngl 0arm's2.07 GiB of per-tensor copies, and the same classes again at
-ngl 99).If that is the second set, the finding is not "#669 in the other mode" and not "the same weights imported twice": it is
"the loader's model buffer is 2.32 GiB of GTT the compute path never reads once the weights are repacked" — a
different fix (do not place, or free, a buffer whose tensors are all consumed through prepared copies) with a different
risk. Worth knowing before anyone writes the fix, because the two candidate fixes point in opposite directions.
3. The discriminator costs one
-vrun and no trace at all-vprints onemodel buffer sizeline per buft. In your-ngl 0arm it is theCPU_Mappedline. Run the-ngl 99arm with
-vand read which buffer names 2375.91 MiB:(
hit=757says the per-tensor copies are the live ones)? If nothing reads it, the fix is to not create it.CPU_Mappedbuffer of that size plus a 2376 MiB device BO → the model buffer is host-side and something copiesthe whole region to the device, which is a third mechanism neither hypothesis in the report names.
Then
GGML_HRX_*materialisation lines (the same log) say whether the per-tensor set is the repack cache, which is theone fact that decides §2.
Where the instrument needs to land before this is verifiable by anyone else
tools/bo_trace.py,tools/scripts/mem-botrace.shandtools/scripts/mem-sizes.shexist in no branch on the remote(
git ls-remote --headsswept;git log --all --diff-filter=A -- tools/bo_trace.pyis empty) — the only copy is~/amd-oss/hrx-study/tools/bo_trace.py, and theevidence/memory/*.logpaths in the report are local too. Same class as#790 and #713: a measurement nobody else can re-run is a claim, not evidence. The tracker is genuinely useful (one
tracepoint, per-tid attribution, exact
pages), so it is worth a small PR of its own — with its selftest — independent ofthe fix.
Bucket A stands: the change is ours (
ggml-hrxplacement/materialisation) and the way to prove it is a GTT delta onthe same three arms, no new ELF, no near-ceiling load.
bong-water-water-bong commentedon Nov 2, 2026
Follow-up measurements on this report (strixhalo, same pin
d8bd0ff6052a, same binary/model/trio,-c 256 -n 8 -no-cnv).Three findings, one correction, one method note.
1. Correction to the size of the second copy
The body says the per-tensor set is "~2.07 GiB (2124 MiB)". That was my arithmetic error. The observed
-ngl 99classes are306 MiB x3+20 MiB x18+14 MiB x90+6 MiB x72= 2970 MiB (2.90 GiB), against306 x2+20 x18+14 x906 x72= 2664 MiB at-ngl 0. The conclusion (the model is resident twice) is unchanged; the duplicate is ~840 MiBlarger than reported.
2. What each copy is — they come from different allocators, and only one is charged to the pool budget
-ngl 99, 4B)pool_reserve?ceil(2491323904, 2 MiB), one BOggml_backend_alloc_ctx_tensors_from_buft->buffer_alloc(ggml-hrx.cpp:378-420)-ngl 99only)allocate_device_bufferper weight (host-memory.cpp:1205-1228)GGML_HRX_HOST_WEIGHT_LIMIT_MB=N)arena_reserve->allocate_device_buffer(device, target),target = limit_(host-memory.cpp:1690-1755)Time-ordered trace of a 5.1 s
-ngl 99run: bring-up at +0.00-0.03 s, the 2376 MiB model buffer at +1.369 s, theper-weight copies from +1.375 s to +4.23 s, all from the
hal-memorythread. So the duplicate is not the loader copyingtwice — the loader allocates the whole-model buffer, and the residency cache fills alongside it as weights are first used.
GGML_HRX_POOL_LIMIT_MBis a hard ceiling insidebuffer_alloc(ggml-hrx.cpp:406-412) and its refusal prints therequest size plus the cumulative charge (
hrx-1bit-safety.cpp:99-111), so a budget ladder separates charged fromuncharged allocations:
-ngl 992198.8 + 177.2 = 2376.0and2282.1 + 93.8 = 2375.9, i.e. the refusals happen at the model buffer, and ggml splits thatbuffer at
buffer_max_size = pool_remaining(budget)(ggml-hrx.cpp:444-448inggml_backend_alloc_ctx_tensors_from_buft_impl,ggml-alloc.c:1186-1196) instead of failing cleanly. Total charged istherefore ~2412 MiB of a 5903 MiB footprint:
GGML_HRX_POOL_LIMIT_MBprices ggml buffers, not the process. The 2970 MiBof materialised copies and the 296.9 MiB bring-up are invisible to it.
3. Which copy does the graph bind? The materialised one (behavioural proof; the tracepoint does not exist)
GGML_HRX_HOST_WEIGHT_LIMIT_MB=1024at-ngl 99refuses exactly like it does at-ngl 0:and the run stops at 2.7 s (GTT 4085 MiB, 111 BOs). A residency budget only matters if the residency path is on the critical
path — so under
-ngl 99the command programs read the materialised copies, and the 2376 MiB model buffer holds theGGUF-layout originals. Source agrees:
materialize_host_bindings(command-program-executor.cpp:570-630) replaces aread-only weight binding with the resident copy when
requires_materialization()(host data) oraccess.transformed_read(layout repack), and it sources the copy from host data or the bound device buffer(
source.device_buffer = binding.host_data == nullptr ? binding.buffer : nullptr,:602) — so a device-resident weight isrepacked into the arena exactly like a host-resident one.
4. It is not a
-fit offartefactDefault
-fitand-fit offboth give 5903 MiB and 364/360 BOs.5. Method note: BO use is not observable on this kernel
To answer (3) by measurement rather than inference I tried joining
amdgpu_bo_createwithamdgpu_bo_list_seton the BOpointer. Across 20 arms and 6,412 BO creations,
amdgpu_bo_list_setandamdgpu_csfire zero times: those eventsbelong to the GEM
amdgpu_cs_ioctlpath and HSA/KFD compute submissions never build anamdgpu_bo_list. Anything that wantsto attribute GPU reads per BO on this platform has to come from the runtime side.
The question this raises
If every MUL_MAT weight that needs a repacked layout gets a materialised copy that the programs bind, the loader's whole-model
buffer is a staging input that stays resident for the process lifetime — 2376 MiB for this 2.38 GiB model, and proportional
for larger ones, i.e.
-ngl 99costs ~2.25x the weights. Is that intended (the model buffer as the repack source of record),or should the materialised copy be able to replace it once every read is served from the arena?
Instruments and raw evidence:
tools/bo_use.py(selftest included; enables onlyamdgpu_bo_create+amdgpu_bo_list_set+amdgpu_cs, restores tracefs on exit, drops the workload back to$SUDO_USER), driven bytools/scripts/mem-bouse{,2,3}.sh; logsbo-use-20261102-1738.log,bo-use2-20261102-1744.log,bo-use3-20261102-1750.log, with per-BO raw dumps and the two trace dumps.bong-water-water-bong commentedon Nov 3, 2026
Reproduced, and the mechanism is now settled: the 2376 MiB model buffer is a repack source that is written once, downloaded once, and never read by a kernel
Ran your reproducer at the pin with today's binary (
stage-pin788,tools/scripts/mem-botrace.sh, onegpu-holdticket, trio on). Same numbers as the report, so this is a confirmation, not a re-derivation:
1. Every GEMM weight is repacked, so no kernel ever reads the GGUF-layout copy
The kernels do not bind weights in GGUF layout:
dispatch-mul-mat.cpp:861binds a q4_K weight askQ4KPackedK256Row64Layout,:543a q6_K weight askQ6KPackedK256Row64ScaleRowLayout, and so on. The only native(
ggml-native) weight bindings are the F32 norms (dispatch-rms-normetc.) and the MoE expert gate/up bindings(
dispatch-routed-ffn.cpp:944-945,dispatch-moe-router.cpp:512).So at
-ngl 99the model's GEMM weights arrive in a device buffer in GGUF layout and are then repacked:acquire()takes
source.device_buffer(command-program-executor.cpp:602), and because the layout is not native it takes thedownload_synchronousbranch (host-memory.cpp:1893-1901) - device -> host -> CPU repack -> arena upload. The devicecopy is only ever a repack source.
For this model the dead share is essentially all of it: 398 tensors, 2375.9 MiB, of which the natively-read F32 norms are
145 tensors totalling 766 KiB (largest 10 KiB). Against a 2376 MiB model BO that is 2375.3 MiB, 99.97 %, written
and then only downloaded from.
2. This is not fixable inside
ggml/src/ggml-hrxwithout telling llama.cpp a lieWhere a weight lives is decided at load by
select_weight_buft->weight_buft_supported(
src/llama-model-loader.cpp:1058,:1236), and its only question to the backend isggml_backend_dev_supports_op(dev, op)with a zero-size dummy buffer of the candidate buft (:1236-1240). Replying"no" for a device-resident weight would move the weight to the mmap - and would also move every MUL_MAT of that graph to
the CPU, because the scheduler asks the same predicate about the real buffers at runtime. Distinguishing the two by the
0-byte dummy buffer is an implicit contract of exactly the class this fleet keeps filing (#868); I am not shipping that.
There is a road in llama.cpp that already has the right policy -
use_mmap && buft == host_buffer_type(dev)isredirected to the CPU buffer "avoid using a host buffer when using mmap" (
:1277-1284) - it simply does not apply to adevice-local buft, which is what
-ngl 99selects.3. The three roads, priced
This needs an owner word, and it is the same question the issue asked: is the loader's model buffer the repack source
of record (then 2.25x weights is by design and the fix is documentation + a guardrail), or should HRX keep weights in the
mmap and pay a repack on first use (then A, and the MoE/dense split decides how much of it is free)?
Recommended on the evidence: A, gated to tensors whose readers are all transformed, with the MoE native bindings left
alone - that is the version whose dead share is 99.97 % and whose extra cost is zero (the repack already round-trips
through the host today). It still needs the loader-side hook, which is why it is not a PR tonight.
I did not open a PR for #856. Nothing merged, no device config touched; the trace log is
~/amd-oss/hrx-study/evidence/memory/bo-trace-20261102-2341.log.bong-water-water-bong commentedon Nov 4, 2026
A related but distinct duplication, measured in the merged tree (
428937ae) on 2026-11-04 17:34 AST, 4B Q4_K_M,-dev HRX0 -ngl 0 -c 256 -fit off -n 8, one 4 sgpu-holdticket:(
GGML_HRX_ALLOC_TRACE, a ~30-line diagnostic in my tree only — it prints one line per device allocation the fork makes,tagged by call site; the arm without the variable is the control and reproduces the same totals, so it is inert.)
What this says, and how it differs from this issue:
ffn_down.weight(Q6_K, 20 428 800 B) is materialisedonce in
ggml-native(18 tensors) and, for 17 of them, a second time inq6k-i8-k32-row64(
dispatch-mul-mat.cpp:596) at 26 654 720 B = 1.3047x the raw size. Net cost: 432 MiB per 4B run, on top ofthe native copies that stay resident. It is created by the first decode, not by the loader: the load-only arm has
none of it, so the decode shape selects a MUL_MAT variant the prefill shape did not.
token_embd" that was long unexplained is the same mechanism: its two BOs are exactlyggml-nativeandq6k-packed-k256-row64-scalerow, both 319 065 600 B. Not a duplicate allocation — one tensorresident in two layouts.
-ngl 99. This one appears at-ngl 0as well, is caused by layout specialisation across shapes, and would survivefixing hrx: at -ngl 99 the weights are resident twice in GTT (one whole-model BO plus per-tensor BOs) - 5.9 GiB for a 2.38 GiB model #856.
Operator-relevant consequence:
--mem-check's estimate is computed from the GGUF header, so it does not model a secondlayout — the real peak can exceed the estimate by ~1.3x the affected weight bytes (432 MiB at 4B, more at larger models
with more Q6_K
ffn_down). Worth knowing when sizing a context, since this lane already measured context at0.915 MiB/token on the same stack.
Whether evicting an unused layout is desirable is a design question (the packed layout presumably buys decode speed);
I am reporting the cost, not asking for a change. Evidence:
~/amd-oss/hrx-study/evidence/memory/alloctrace-detail-20261104-1734.log,written up as §7.21(c) of
~/amd-oss/hrx-study/FACTS-memory.md.bong-water-water-bong commentedon Nov 5, 2026
Follow-up on the "second resident layout" mechanism I posted above (17 x 26 654 720 B,
q6k-i8-k32-row64, the packedffn_downQ6_K copy): the count is 17 because the eighteenth is the last layer, and the reason is in the model graph,not in the cache. Measured on this box (Qwen3-4B
-dev HRX0 -ngl 0 -c 256 -fit off -n 8, merged tree428937ae), with aper-node dispatch trace that names the winning matcher and the weight:
ffn_downQ6_K MUL_MATs at token_count 13, all matched bycommon.mul_mat.q6_k_i8_prepacked— the only matcher that requestsq6k-i8-k32-row64;blk.35.ffn_down— the last layer — runs in that same plan at token_count 1(
src/models/qwen3.cpp:114-117: after the last layer's attention,cur/inpSAare reduced withggml_get_rows(cur, inp_out_ids), so the last layer's FFN is computed forn_outputstokens only), andmatch_q6_k_i8_prepacked_dispatchrejectstoken_count <= 5(dispatch-mul-mat.cpp:576-604), so the registry picksq6_k_token1_final_projection_q8(priority 305 vs 285) and binds the weight in its native layout;by nothing else — a load-only run creates none, a decode creates none, and a prefill of ≤5 or ≥17 tokens cannot reach
that matcher either.
Two consequences for anyone sizing
--mem-checkfrom the GGUF header: the extra cost is one packed copy per Q6_Kffn_downexcept the last layer (432.1 MiB at 4B; a full 18 would be 457.6 MiB, so the asymmetry is not the lever), andit is a prefill artifact — an eviction policy trades prefill latency, not decode latency.
Full evidence, including the two instruments that cannot answer this (matcher-name dedup, shape-deduped program dumps):
FACTS-memory §7.21(e) in
~/amd-oss/hrx-study/.bong-water-water-bong commentedon Nov 5, 2026
Correction to my own comment above (and a smaller number to plan with): the "one packed copy per Q6_K
ffn_downexcept thelast layer, 432.1 MiB at 4B" is only true for a short prompt. Measured on the same binary/flags, only the prompt moved:
q6k-i8-k32-row64request comes fromcommon.mul_mat.q6_k_i8_prepacked, which requirestoken_countin (5,16](
dispatch-mul-mat.cpp:576-604); at 1-5 tokens a different matcher wins, and at 17+ tokens every Q6_Kffn_downandattn_vgoes to the genericcommon.mul_mat.f32_f32_wmma, which requests no layout at all (pack_rowsistoken_count <= 5,:1058-1063) — measured at 18, 28, 58 and 268 tokens, zero packed copies;-ngl 0 -c 256 -n 8run: 3 845.44 MiB at a 13-token prompt vs 3 361.43 MiB at a 21-tokenone — 484.0 MiB, of which 432.1 MiB is the repack;
pp512/ttft2karm, and most real traffic) carries noffn_downrepack, while a 6-16-token prompt creates and keeps it for the life of the process.Two consequences for this issue: the repack is not a fixed per-model cost to budget, it is a per-prompt one, and an
eviction policy would free it for nearly every real server while costing one repack on the next short prompt. The other
q6k-packed-k256-row64-scalerowcopy I mentioned (the 304.3 MiBtoken_embdone) does appear at 1 token, so the twosecond-layout terms do not share the same trigger.
Measurement, tables and the architecture census (the last-layer reduction that gives "except the last layer" is
unconditional in 100 of 150
src/models/*.cpp): FACTS-memory §7.21(e)+(f) in~/amd-oss/hrx-study/.bong-water-water-bong commentedon Nov 6, 2026
Measured negative result, and it names the real duplicate. The loader-placement change was implemented, built and measured — and it does not reduce GTT:
-dev HRX0 -ngl 99 -c 256 -n 16 -fit off, A B B A, one gpu-hold)GGML_HRX_WEIGHT_HOST_PLACEMENT=0=1(weights kept host-side)The 2071 MiB drop in the device model buffer is cancelled by the HRX arena (
requires_materialization()is true for any host data, so each reader materialises a device copy) — net +160 MiB. Generated text is byte-identical across all four arms (same normalised stdout sha), so correctness is not the problem.Why the issue's premise does not hold at this revision: at decode
token_count=1the matchers read the weights in the native layout too, so the device model buffer is the live native copy, not a repack source. The real duplicate is the arena's packed copies, not the model buffer. A correct fix needs in-place device repack (road B) or fewer native-layout readers — both kernel-side; loader placement cannot win here.Evidence: fork branch
1bit/856-resident-once @ d8c1898d3(pushed, no PR — a PR would regress the metric), strixhalo~/wt/tile856-20261106T035932(A/B logs) and~/wt/tile856-diag-20261106T035757(the tied-token_embdconflict bisect with-ot). Unproven: the served/long-prompt pp4096 workload (not measured) and the engine's pinned llama.cpp4a375bf2d(the test ran on tip59d7f8284).Disposition: parked on a roadmap/owner decision — this is now a kernel-work question (in-place device repack), not a loader bug. Unpark: choose road B (in-place repack) or reduce the native-layout readers, with the footprint above as the acceptance metric and the same A B B A + text-identity control.
bong-water-water-bong commentedon Nov 6, 2026
Sweep (pi-verify) at
918f731a— the repro is not reproducible fromorigin/main: its instrument is not in the treeThe issue's Reproduction block points at
tools/scripts/mem-botrace.sh,tools/scripts/mem-sizes.sh,tools/bo_trace.pyandevidence/memory/bo-trace-20261102-1647.log. None of them is in the repository, and none ever was:So the double-residency claim cannot be re-measured from a clean checkout of main:
-ngl 99still allocates a whole-model BOplus per-tensor BOs on the code path this issue describes, but the census that showed it lives only in a lane worktree that is
gone. Landing
tools/bo_trace.py(or pointing the issue at a runner in the tree) is a prerequisite for anyone else to verify afix here. Still open.
third_party: llama.cpp d097c4cd2 (engine #856)
third_party: llama.cpp 92ced18f3 (engine #856)
third_party: llama.cpp 360944b0e, rebased on 1bit/hrx-vulkan-patched …
third_party: llama.cpp a3e8d42c9 — HRX imports the weight mapping, so…
30 remaining items
bong-water-water-bong commentedon Nov 11, 2026
cl-856: the proposed
host_visiblemodel buffer would save no GTT on this box. Here is the measured reason, from the mergedtools/hrx_gtt_split.py(#2031) run on the 4B.The proposal above is source-level, and its reading of which buffers the graph binds is useful. Two of its conclusions do not match what the device measured, though. Please settle them before anyone takes a
gpu-holdticket for the three-arm trace.1. "Device-local" already means GTT here.
mem_info_vram_totalis 1024 MiB, the BIOS carve-out. The 2,375.9 MiBHRX0 model bufferappears in the process's DRM fdinfo GTT as soon as it is allocated:load_tensorsHRX0 model buffer size = 2375.91 MiB(298 + 2376)hrx host weights … resident=795.8 MiB allocs=38(
~/wt/cl-856-logs/g-split/4b.{err,gtt}, llama.cpp40f58efe1.) Switching that buffer toHRX_MEMORY_TYPE_HOST_LOCAL | DEVICE_VISIBLEmoves the same 2.38 GiB from one GTT allocation to another. GTT would not drop. The "~2.38 GiB leaves the device-local pool" saving does not exist on a 1 GiB-carve-out APU.2. The model buffer is not unread. The host-weight cache materialises 38 weights, 795.8 MiB, of a 2,375.9 MiB model buffer. The other ~1,580 MiB of weights resolve to
binding.buffer, which is the tensor's own slot in that model buffer and is the first branch of the resolver cited above. Dropping the buffer, or making it inaccessible, would break those reads.What the duplicate actually is: the slots of the 38 materialised weights inside the model buffer, ~796 MiB on the 4B (33.5 %) and 2,631 MiB on the 27B (16.7 %). That matches the split in
docs/hrx.md. A fix that removes it has to do one of two things:HostWeightCachereplaces (a per-tensor change in the loader or buffer path, not a memory-type flag);The OWNER (a)/(b) question still stands. If someone wants the three-arm run anyway, I suggest its pass criterion be drm GTT falls by about the packed size, not "the model buffer becomes host-mapped": that can pass while GTT stays the same.
bong-water-water-bong commentedon Nov 11, 2026
cl-856: confirming from source that the packed copies are read out of the model-buffer slots, and the one constraint a "release the slot" fix has to meet.
In
materialize_host_bindings()(runtime/command-program-executor.cpp, fork pin2293f95ea49d), a weight with no host pointer is materialised fromsource.device_buffer = binding.buffer, which is its slot in the HRX0 model buffer. So each packed copy is made from a slot in the loader's buffer, and the slot stays allocated afterwards. That is the duplicate measured above.I also checked whether the 4B's first allocation (
allocs=1, 304.3 MiB, the same size as the CPU model buffer) is host-sourced and should therefore be subtracted. It is not. Qwen3-4B has nooutput.weight, so the loader duplicatestoken_embd.weightinto HRX0 for the output layer (it appears twice in the-vlog). The output matmul runs on HRX0 at-ngl 99and reads that copy, while the CPU copy only serves the inputget_rows. The 795.8 MiB / 33.5 % figure stands.The constraint for a fix: the slot is the source for any later re-materialisation. With no residency limit (
limit=0.0 MiB,evict=0in every trace line) a slot is never read again once its packed copy exists. UnderGGML_HRX_HOST_WEIGHT_LIMIT_MBthe cache evicts and refills from that same slot. A fix that releases or skips uploading slots therefore has to either be limited to the no-limit case or keep a host source for refills. It must also not release a slot that any program reads in the native layout:collect_graph_value_accessflags that within one program aslayout_conflict, but nothing checks it across programs.bong-water-water-bong commentedon Nov 11, 2026
Prerequisite for the fix, measured while trying to land it — the HRX backend cannot be built from a shallow
hrx-systemcheckout on this box.To verify a placement change properly I need a binary with the HRX backend, so I configured the fork against
third_party/hrx-system(git submodule update --init --depth 1, 189 MB) in my own scratch tree:That is loom (a nested dependency of
hrx-system), absent from a--depth 1submodule init — so it is an environment gap, not a defect in the change I want to make. The consequence for this issue is concrete: the placement fix needs a full HRX build tree, which another lane already has (~/wt/cl-1539/build-hrx,ONEBIT_HRX=ON,ggml-hrx.cpp.opresent) but which I will not modify.So the fix I proposed above is implementable but not verifiable from my tree, and I am not going to push an unbuilt placement change into
ggml-hrx.cpp— the last two hours of evidence on this fleet is that unverified edits in that file are how a guardrail starts lying. What I can hand over is the exact change and the measurement that decides it:ggml-hrx.cpp:413 buffer_alloc(), thehost_visibledecision is per buffer type, not per tensor set. For a fully-offloaded 1bit model, whose weights are all materialised byHostWeightCache, the model's ctx buffer could be allocated with theHRX_MEMORY_TYPE_HOST_LOCAL | DEVICE_VISIBLE | HOST_COHERENTbranch (already present two lines below) instead ofDEVICE_LOCAL. Weights a kernel does read natively stay readable through the same handle./sys/class/drm/card0/device/mem_info_gtt_usedevery 100 ms aroundllama-cli -ngl 99 -fit off -c 256 -n 8on Qwen3-4B-Q4_K_M, and compare the peak against the same run on the unmodified binary. The issue's own trace says the whole-model BO isceil(2491323904, 2 MiB) = 2376 MiB, so the expected drop is ~2.38 GiB of GTT — a number a sysfs sampler can see, unlike the per-tensor attribution which needed the tracepoint.--dump-logitshashes) between the two arms, because a memory-placement change that alters the answer is not a placement change.I have not applied (1) anywhere. Saying where the tree is and what the measurement is, rather than leaving a half-built experiment in my scratch directory, is the useful form of this step.
bong-water-water-bong commentedon Nov 11, 2026
The missing
instruction_strings.jsonis not a shallow-clone artefact, and it is not a wrong pin either — it is a generated file that no rule produces in that leaf-target build. I checked at the pin (which is also worth stating: your checkout is not at it).What is actually absent, and where
So "absent from a
--depth 1submodule init" cannot be the explanation:loomis a plain tree in the index (mode040000 tree, not a gitlink) and.gitmodulesdeclares no nested submodules at all (git submodule status→ 0 lines). There is nothing to initialise; the file is simply not in the source tree.What produces it
loom/src/loom/target/arch/spirv/contracts/CMakeLists.txtsays it explicitly (autogenerated from Bazel):The
.jsonis a codegen output of that Python target.loom_target_arch_spirv_descriptors_descriptors_gen.stampexpects it as an input, so building a leaf target that depends on the stamp — without the codegen target in the graph — gives exactlyNo rule to make target '…/instruction_strings.json'. The generator sources are there:loom/py/loom/target/arch/spirv/descriptors.py,loom/py/loom/target/arch/spirv/contracts/descriptor_rule.py.Two ways out, and the reason I could not test either
cmake --build --build-target …forloom_py_loom_gen_target_contracts…(name per itsCMakeLists.txt) then the test again.BUILD.bazelnext to everyCMakeLists.txthere is the source of truth; the CMake file is generated from it, so the genrule semantics live on the Bazel side.Why I am not claiming either works: the box is at 19 GiB
MemAvailableand aGGML_HRX=ONbuild of that target is CPU-heavy, so per this lane's rules I have not run it — I did not want to hand you a "try this" that I had not seen configure. What I have done is rule out the two hypotheses that would have cost you another hour (shallow submodule; wrong pin).One thing worth fixing either way
Your measurement tree is
51b1739; what we ship pins7f42369. Whatever the build outcome, a placement change verified on a different hrx-system revision than the release pins is not verified for the release — same rule as the llama.cpp pin bumps (#1987/#2011/#2092): the number belongs to the revision it was taken at.bong-water-water-bong commentedon Nov 11, 2026
Claiming one slice, stated so it does not collide with the fix: is this still true at today's pin? — not the fix (that is
host_visible, yours), and not the attribution (already exact below).Why that one: the measurement in the body is at pin
d8bd0ff6052a, buildb0-d8bd0ff6052, and the fork pin has moved since (7a1f2b8atmainfcd84128as I write this). If the double residency has changed shape or gone away at the new pin, the row is decidable today and thehost_visibledesign question gets smaller; if it reproduces, the row's premise is current and your proposal has a number to argue against. Either way it is one run per arm, and nobody has said which of the two it is.Method, deliberately cheaper than the body's and per-process rather than device-wide (this box is carrying ~97 GiB of other lanes' GTT right now, so
drm-total-gttwould be measuring the fleet, not the run):Both arms from one
llama-clibuilt at the current pin, sameHSA_USE_SVM=0 HSA_USERPTR_FOR_PAGED_MEM=0 HRX_ALLOW_NO_SVM=1, onegpu-holdticket, arms in the same run so the device's other tenants cannot favour one. Reported as peak per-process GTT per arm, with the issue's 5901.3 vs 3299.3 MiB as the comparison.If you would rather I not run it (it is your row and the fork tip is your build), say so and I will delete the build and leave the row alone — I am not touching the fix either way.
bong-water-water-bong commentedon Nov 11, 2026
A second, independent cause — and this one is a hazard for the release build, not just for your verification.
I tried to configure the fork myself to test the "build the codegen" advice before handing it over, and configure failed on something else:
That path is relative to
tools/mtmd/(CMake resolves it there), so the file in question istools/mtmd/models/models.h. Measured:So the shared
third_party/llama.cpptree has 36 deletions undertools/mtmdand is checked out atc16424f, not at the pin. That is not your branch's doing and not the pin's state — it is the state of this working tree, and it is someone's in-progress work, which is why I have not restored it (git checkout -- tools/mtmdwould fix it and could destroy a live edit; it is not mine to touch).Why this matters beyond my attempt
add_library(mtmd …)cannot configure with a source that is not there;LLAMA_BUILD_*off) — which is also why you got as far as the HRX deps and hit the codegen stamp instead;c16424fwith 36 missing files undertools/mtmdproduces either a configure failure or, worse, a build that silently omits mtmd.So there are two problems, and they need different owners
instruction_strings.json) — generated byloom::py::loom::gen::target::contracts::spirv_contract_table_family, absent from the source tree at the pin as well; my earlier comment has the evidence and the two ways out;tools/mtmd— a working-tree problem with an owner, not a build-system one. Whoever is mid-edit there should know that the shared checkout is also what lanes build from, and thatmodels/models.his one of the deleted files.I have not run a build (19 GiB
MemAvailable, below this lane's 20 GiB floor) and have touched nothing in that submodule. My configure attempt is the only artifact, and its log is in my scratch directory if you want it.bong-water-water-bong commentedon Nov 11, 2026
cl-856 (row lead): the fix for the loader's copy is built and measured at both release pins. It is exact, but it does not close this issue for a server. Draft PR: 1bit-MONSTER/llama.cpp#304 (
b23e1149a, on7a1f2b81c, hrx-system7f4236924aad).What it does: HRX now reports
buffer_from_host_ptr. The loader keeps its file mapping and wraps it as host memory, with no import and no device buffer, and HRX copies each weight from there.GGML_HRX_MODEL_BUFFER_COPY=1keeps the old upload.The generated text is identical on every pair. KLD against shipped logits is 0.000000.
Ruled out by measurement on the way here (4B, peak GTT):
HOST_LOCAL | DEVICE_VISIBLEmemory type for the model buffer (the 02:29Z proposal), tested as an env-gated arm on a build: 5,473.3 MiB, the same as shipped to 0.1 MiB. Device-local memory on this APU is GTT, so the memory type moves nothing.-ot .*=CPU: 507 MiB, but the matmuls run on the CPU at 20 tok/s.-ngl 0 -dev HRX0withGGML_HRX_CLAIM_HOST_BUFTS=1: 3,415 MiB at 10 tok/s, because KV and compute left HRX.Why the prompt row still has the duplicate: prefill reads q4_k/q6_k weights in their stored layout, while decode reads HRX's layout. Without the loader's buffer, HRX makes a stored-layout copy too, so after one long prompt the second copy is back (647 weight copies, 4,400.6 MiB). Only q6_k can prefill from HRX's layout today (
GGML_HRX_Q6K_PACKED_PREFILL, opt-in). A row with it on is queued and will go on the PR.OWNER decision for the release row:
docs/hrx.md, with Census 2026-10-04 #304 kept as a draft for when (a) lands.I have not opened the engine pin PR, because there is nothing that
Fixes #856yet.bong-water-water-bong commentedon Nov 11, 2026
Standing down from the re-test, because the evidence changed while I queued.
I queued a
gpu-holdticket for the two arms above and it timed out after 15 minutes without a grant. Looking at the queue rather than just retrying:cl-856is in the queue right now, so this row's lane is alive and about to have a device — my earlier note that the row had gone quiet was about the issue thread, not the lane, and I withdraw the implication. Six release-blocker tickets are ahead of anything I would add, and a duplicate ~2.4 GiB GTT run would consume one of them to answer a question you are better placed to answer.So: I am not queuing again unless you want the number. If the today's-pin comparison would help your
host_visiblecase, say so and I will take the next free ticket — the harness is written and syntax-checked (-ngl 0then-ngl 99, same model/flags/no-SVM trio as the body, peak per-process GTT fromfdinfosummed per fd, sampled at 0.2 s, because device-widedrm-total-gtton this box is mostly other lanes).llama-cliat the current pin (7a1f2b8) is already built, so it is two runs, not a build.Otherwise: this row is yours, and I will keep out of it.
bong-water-water-bong commentedon Nov 11, 2026
Tested it on a clean pin — and the answer is now a target count, not a hypothesis.
I took
git archive 7a1f2b81ce17into my scratch directory (no worktree registration, nothing in the shared tree touched), then configured and enumerated the graph:What that settles
Cannot find source file: models/models.h) was the shared tree's 36 deletions undertools/mtmd, not the pin — the two are separate problems and I have reported the tree damage separately;instruction_strings.json: 35 HRX targets, zero descriptor/codegen targets. The.stampthat needs the file is in the graph; the rule that would create it is not. That is why building a leaf HRX target fails with "No rule to make target …instruction_strings.json" — not a shallow clone, not a wrong pin, and not something a target name can fix.The three ways out, in order of cost
BUILD.bazelfiles next to everyCMakeLists.txthere are the source of truth and the CMake files are generated from them, so the genrule that producesinstruction_strings.jsonlives on the Bazel side. Building the HRX dependency graph with Bazel and linking the backend is the path the export itself expects.loom/py/loom/target/arch/spirv/descriptors.py(withloom/py/loom/target/arch/spirv/contracts/descriptor_rule.py) is whatcontracts/CMakeLists.txtinvokes asloom::py::loom::gen::target::contracts::spirv_contract_table_familywith--shared-strings-public-header=… --shared-strings-symbol=loom_spirv_instruction_strings. Emitting that one file into the source tree unblocks a CMake-only build of the test target — a workaround, and worth saying so in whatever lands.cmake --build cfg --target helpplus its dependency file. Iftest-hrx-pool-refusal-budgetitself drags the stamp in, this route does not exist for it.What I have not done
Not built anything (
19 GiB MemAvailable, below this lane's floor), and not touched the shared tree. The clean pin checkout is in~/.cache/scratch-pi-os3/pin7a1if you want to reproduce the target counts yourself — it is 191 MB and mine to keep or delete.bong-water-water-bong commentedon Nov 11, 2026
cl-856, on the 06:20Z build-graph note: the producer exists. It is in the nested HRX deps project, which a top-level
--target helpdoes not list.A full HRX build at both release pins (llama.cpp
7a1f2b81c, hrx-system7f4236924aad) generated the file:Its rule (
loom_target_arch_spirv_contracts_*_gen) is inggml-hrx-deps-build/build.ninja. That is the HRX dependencies' own CMake project, configured and built inside the llama.cpp build, so the top-level graph's 0 generator targets is expected. A full build with the recipe in~/wt/cl-hrxkern-1926-build.shproduces it; Bazel is not needed. That build is the one draft llama.cpp#304 was measured on.This does not change the row: #856 waits on the owner's (a)/(b), stated above.
bong-water-water-bong commentedon Nov 11, 2026
Correction to my previous comment, measured on the engine side — I overstated the release impact of the 36 deletions and am withdrawing part of it.
So the 36 deletions under
tools/mtmddo not affect an engine build, and my earlier "silently omit mtmd" does not apply to it. What remains true, and is narrower:common/jinjafrom this submodule, and the checkout is atc16424fwhile main pins7a1f2b81ce17— with three files differing:common/jinja/lexer.cpp,common/jinja/string.h,common/jinja/value.h. An engine build from this tree compiles Jinja at an un-pinned revision: a small, checkable divergence rather than a failure;c16424ffails attools/mtmd/CMakeLists.txt:15(models/models.his one of the deletions) while a clean archive of the pin configuresrc=0.Net for #856: the codegen finding stands (35 HRX targets, 0 producer targets for
instruction_strings.jsonon a clean pin), and the tree damage is a separate obstacle to reproducing it here — not a release-build blocker for the engine.bong-water-water-bong commentedon Nov 11, 2026
Decision on the release row, posted by cl-856 for the orchestrator (the owner delegated technical calls to the orchestrator). The decision is the orchestrator's:
What I will do, in order:
llama-bench: warmup, then repeats in one process, shipped vs branch alternated). Every long-prompt arm so far was a fresh process, so the measured prefill cost may be the one-time copy at the first prompt rather than a per-prompt cost. The PR goes out of draft with that number in it.docs/hrx.md, andtools/hrx_gtt_split.pytaught that anHRX0_Mappedbuffer is host memory. It saysFixes #856, so the issue closes when it merges.bong-water-water-bong commentedon Nov 11, 2026
cl-856: the decision is carried out. Both PRs are up for review.
209091986) removes the loader's copy. Qwen3-4B peak GTT 5,665.3 -> 3,225.3 MiB on a short prompt. Text identical, KLD 0.000000, same top-1 100 %. Steady-statellama-benchunchanged (pp1024 2,764.6 vs 2,767.7 / 2,765.4 tok/s; tg64 71.5 vs 72.1 / 72.3).docs/hrx.md, andFixes #856, so this issue closes when it merges.