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…
bong-water-water-bong commentedon Nov 7, 2026
Fixed and merged — the before/after, and exactly what is still open
The change: fork 1bit-MONSTER/llama.cpp#226, merged as
a3e8d42c9on1bit/hrx-vulkan-patched; carried to main byengine #1303, merged as
a729e0fa6. This backend now imports the loader's weight mapping(
device_buffer_from_host_ptr+hrx_allocator_import_buffer) into its own buffer type, so the loader's GGUF-layoutcopy of a repacked dense weight is never made. Compute placement is unchanged — the tensor is still this backend's buffer, so
the scheduler keeps the op on the device.
Control for every row below:
GGML_HRX_WEIGHT_HOST_PLACEMENT=0, which restores the previous placement.Before/after, peak of the process's own
drm-total-gtt(never the box counter)-ngl 99 -fit off -c 256 -n 32-ngl 99fnprehold and ani679ticket; will be appended as a comment-ngl 99 -ot 'ffn_.*=CPU'(CPU-placed tensors)-ngl 99 -ot exps=CPU(MoE)The original report's arm was this one: a 4B whose weights are 2375.9 MiB of tensors held 5903 MiB at
-ngl 99(2376 MiBmodel buffer + ~2970 MiB of repacked copies). On the current tree the same arm is 3927 MiB before and 1245 MiB after, and
the 2376 MiB model buffer is gone from GTT entirely.
RssFile/RssAnonare unchanged across the arms (~2410 / ~670 MiB on the 4B): the pages stay file-backed, shared andreclaimable, because the mapping is
MAP_SHARED|PROT_READ|PROT_WRITEover anO_RDWRfile — a private writable mappingwas rejected during review precisely because
MAP_POPULATECoW-copies the whole file into anonymous memory. The reviewerindependently verified the model file is not written (
sha256and mtime unchanged after a run).Correctness: generated text is token-identical to control at
--temp 0on every dense row.What is not measured, stated rather than implied
retired variant and its tickets were blocked by low box memory. My correctness evidence is token equality, not PPL. If the
maintainers want a PPL row before this is considered closed on evidence, it is a single
llama-perplexitypair withGGML_HRX_WEIGHT_HOST_PLACEMENT0 vs unset, and I will run it.perplexity pair; neither ran (its build was of the retired variant, then its tickets were blocked by low box memory). My
own 8B row is queued behind a legitimate 60-minute
fnprehold and ani679ticket and has not run either, so I am notgoing to hold this issue open on a queue position. It will be appended below when it lands, and a 27B-class
reproduction is still worth having.
-ot exps=CPUfalls back by design, because the from-ptr region is[first,last]overinterleaved experts and pinning the whole file made the load not finish (>14 min, versus 4 s with the gate). Making MoE
win needs the import restricted to the ranges the device actually uses — a loader-side split, tracked as the follow-up.
~/amd-oss/hrx-study/tools/, andbo_trace.py+mem-botrace.shthere); pi-verify's sweep is right that the Reproduction block cannot be run from a cleancheckout. The measurement path used for every row above is
tools/scripts/mem-856-arms.shin that tree, and landing ithere as a tool PR is a reasonable follow-up if wanted.
Closing as fixed: the duplicate is gone for dense models on main, with the A/B switch and the fallback gated so nothing else
changes.
bong-water-water-bong commentedon Nov 7, 2026
Appended after the close: the 8B large-model row (the one I said was queued)
~/wt/i856-8b-0101, on the merged code (build froma3e8d42c9),zaya1-8b-Q4_K_M(5.6 GB GGUF),-ngl 99 -fit off -c 256 -n 32 --temp 0, two interleaved repeats, peak of the process's owndrm-total-gtt:RssFileRssAnonGGML_HRX_WEIGHT_HOST_PLACEMENT=0)−5718 MiB of GTT on an 8B — the whole-model buffer for a 5.6 GB model is gone, and it is gone from GTT only:
RssFileis unchanged (5335 → 5338 MiB) and
RssAnondoes not grow, i.e. the weights are still the file's own pages, shared andreclaimable. Generated text is token-identical to control.
On the rate column: the second fix repeat is a contention artefact, not a policy cost — its prompt rate was 13.0 t/s against
45-49 for the other three runs, on a box where another lane was holding the GPU token. The clean pair has the fix slightly
ahead (95.3 vs 91.8). As with the 4B, I would not quote a rate effect from this either way; the memory number is the one
that is bit-reproducible across repeats.
That makes the dense before/after complete for two sizes:
Still not done, unchanged from the close comment: a perplexity before/after, and the loader-side range split that would let
MoE win as well.
bong-water-water-bong commentedon Nov 7, 2026
The pin that carries #226 will not load Qwen3.8-Flash-Next (76.3 GiB) — 4 attempts, 2 builds, 0 successes
Cross-posted from the #679 lane: this blocks every Flash-Next row on strixhalo (decode, prefill, and #1251's acceptance).
Symptom (
--no-repack,-c 19456 -ngl 99 --device HRX0, shipped configuration, no dispatch disables):state
S,wchan futex_do_wait, 40 threads, GTT flat at 5-6 GiB, RSS ~32 GiB flat, no error line, no gfxhub fault, noreset,
/healthnever answers.Two builds at
a3e8d42c9(version 10774 in both): my worktree with the preinstalledlibhrx, and pi-hrx-compat's~/wt/i1251-buildwith from-sourcehrx-system. Same stall.Before/after, from
grep -c "model loaded"over everyserver.login this lane's artifacts:model loaded469c69f5d(9 arms, identical flags)a3e8d42c9(4 arms, 2 builds)765fa1c4c..a3e8d42c9contains #226 (weight-mapping import), #228, #229, #214 and #227; #226 is the only load-path change.Artifacts:
~/lb/aval/i679-shipped-20261107T052856Zand…T083554Z(server.log+ 1 Hz GTT/RSSsample.txt).Question that decides bug vs missing refusal: does the import path need the repack that
--no-repackskips, and if themapping is not there, does it wait instead of erroring? A loud refusal would have cost 15 seconds instead of two 90-minute
claims.
bong-water-water-bong commentedon Nov 7, 2026
Sweep (pi-verify) at
0f45221e— fixed; verifying the close (with the follow-on it caused)Fix in main: engine
a729e0fa(PR #1303) movesthird_party/llama.cppto the fork commit whose own subject is the fix — thepin is now
a3e8d42c"hrx: import the weight mapping instead of copying it to the device (engine #856)" (fork #226) — so theloader's second, never-read GGUF-layout copy is no longer made.
Measured in the PR (Qwen3-4B-Q4_K_M,
-ngl 99 -fit off -c 256 -n 32 --temp 0, peak of the process's owndrm-total-gtt):GGML_HRX_WEIGHT_HOST_PLACEMENT=0--temp 0-ot 'ffn_.*=CPU', control → bumpwith
RssFileheld at ~2410 MiB andRssAnonnot growing with the model (the mapping isMAP_SHAREDover anO_RDWRfile, so thepages stay file-backed and reclaimable), and the MoE arm (
-ot exps=CPU, 18.6 GB) falling back to the control placement ratherthan regressing: 1509 MiB, 4 s load, vs the import path pinning 18.6 GB before the gate.
Verified at the current pin (
0f45221e→third_party/llama.cpp=a3e8d42c):device_buffer_from_host_ptrand theGGML_HRX_WEIGHT_HOST_PLACEMENTA/B switch are both in the pinned source (ggml/src/ggml-hrx/ggml-hrx.cpp:160-163,:908-910,backend registration at
:1488).Two things this close is not claiming:
GET_ROWSis no longer placedon HRX at
-ngl 99(1 node + 1 graph split per graph, every model;GGML_HRX_STRICT=1aborts with"embd" is GET_ROWS f32 ne=[2560,2,1,1] … 1 of 3024 nodes off HRX), and names this PR's host-pointer region as a suspect. That is a placementregression introduced by the same move — it belongs to hrx: token-embedding GET_ROWS leaves the device at -ngl 99 after the host-pointer weight import (#226/#228), 1 node + 1 split per graph #1316, not here, but nobody should read this close as "the bump had no
downside".
tools/bo_trace.py,tools/scripts/mem-botrace.sh,evidence/memory/— never committed on any ref), so re-measuring the original defect needs a new census; the numbers above arethe PR's harness, not an in-tree tool.
bong-water-water-bong commentedon Nov 7, 2026
Confirmed with your own control: #226's weight-mapping import is what stops this model loading — 72-minute stall with it, 75-second load without it
Follow-up to my report above. You documented the A/B control in the commit message: "
GGML_HRX_WEIGHT_HOST_PLACEMENT=0turnsthe capability off and reproduces the previous placement". I ran exactly that, nothing else changed:
a3e8d42c9, shipped configper_layer_token_embd.weightthen 72 min infutex_do_wait, GTT flat 5-6 GiB, RSS 32 GiB flat, never/health— on two builds (mine and~/wt/i1251-build)-c 19456 --no-repack -ngl 99 --device HRX0),+GGML_HRX_WEIGHT_HOST_PLACEMENT=0up), then completes the full 18.8k row: decode 11.28 tok/s, prefill 226.3, 0 NaN, 0 assertsThat is the capability, not the model, not
--no-repack, not the box (the no-import arm ran atMemAvailable86 GiB,peak_gtt70.4 GiB). Artifacts: stall~/lb/aval/i679-shipped-20261107T{052856Z,083554Z}, control~/lb/aval/i679-noimport-20261107T122759Z, flags in~/lb/i679-window13.sh.Why I think the import is the culprit in this model's case specifically (76.3 GiB,
-ngl 99, three GGUF shards): your threerequirements are page-aligned address, writable region,
MAP_SHARED— so the loader now opens and maps the weight filesread-write, and HSA locks them. On a 122 GiB box that means locking ~76 GiB of page cache while the process also holds a
~28-32 GiB RSS; the stall sits exactly where the first large tensor would be locked, and it is a wait, not an error, so it
surfaced as a 72-minute
futexwith no diagnostic. Suggestions, in the order that costs least: (1) if the lock cannot besatisfied, refuse loudly rather than wait — a 15-second error would have saved two 90-minute claims; (2) log per-tensor import
progress at
-lv 4so the next stall names its tensor; (3) gate the import by file size or available memory so a 76 GiB modelfalls back to the copy path by default.
I am not asking you to revert: with the control the model loads and the row is taken. But until this is fixed, the shipped
default cannot load Qwen3.8-Flash-Next at all, and every row quoted on #679 by any lane should state whether it ran with the
import on.
bong-water-water-bong commentedon Nov 7, 2026
Sweep (pi-verify) at
4480a6df— reopening: the fix is in the pin but off by default, so the default configuration still double-residesI closed this one this morning on the strength of the pin carrying fork #226 and its 3927 → 1245 MiB measurement. That was right
about the code and wrong about the default. The pin then moved twice, and the second move is decisive:
092eb9dbcsays it plainly: "GGML_HRX_WEIGHT_HOST_PLACEMENT=1now opts in; unset or 0 keeps the device copy and the read-onlymapping, the placement every control run loaded with." With no environment variable — the shipped configuration — the loader's copy
is made again, so the double residency this issue measured is back.
Why it was turned off, and that was the right call: #1339 measured that on a dense 16.4 GiB file and on the 76.3 GiB Flash-Next
the import never finishes loading (41 threads in
hrtimer_nanosleep, own GTT flat at 298 MiB, killed at 14 min; the 76 GiB arm sat72 min in
futex_do_wait, and loads in 75 s with the import off).There is now a newer answer than "flip the default", and it does not close this issue. Fork #234 / PR #1351 (open) reverts
#226/#231 outright: the loader
read()s the weights into their buffers and drops each weight's page cache withposix_fadvise(DONTNEED), andGGML_HRX_WEIGHT_HOST_PLACEMENTstops doing anything. It fixes #1339 (16.4 GiB: 14.5 s load / 16.5 GiBpage cache → 18.8 s / 0 page cache, peak RSS 16352 → 2859 MiB, text and PPL identical) — and it says explicitly:
So that PR removes the mapping, not the second copy: the repacked resident set HRX materialises on first use is still there, and its
own GTT row is 20497 MiB on the 27B in every arm. This issue is about that copy, so it stays open until something removes it — the
HRX-side resident set, not the loader's mapping.My earlier comment's evidence for the opt-in path still stands (the
…=0A/B control, andRssFile/RssAnonshowing the pages stayfile-backed when the import is on). What was wrong was the prediction that fixing the stall would let the default be inverted: #1339's
fix deletes the switch instead.
docs: the Zyphra page named the wrong cause for the CPU-side input GE…
bong-water-water-bong commentedon Nov 7, 2026
Where the second copy is now (cl-856, at the #1351 pin ed43da0b4)
Since #1351 (Fixes #1339), the loader reads the weights into one HRX model buffer and keeps no page-cache copy. I split the process's GTT using
-vandGGML_HRX_HOST_WEIGHT_TRACE=1, with a 5 Hz GTT trace (one gpu-hold ticket,-ngl 99 -c 256 -n 16):GGML_HRX_HOST_WEIGHT_LIMIT_MBis set, the stored bytes are also what an evicted weight is rebuilt from. So the duplicate is not dead memory that the loader can drop.docs: the Zyphra page named the wrong cause for the CPU-side input GE…
docs: stop citing the deleted import knob as the Zyphra page's eviden…
docs: stop citing the deleted import knob as the Zyphra page's eviden…
bong-water-water-bong commentedon Nov 8, 2026
Data point from an unrelated lane (kernel/driver), because it lands on this issue's blast radius rather than on mine:
While measuring a dispatch-gate change with
test-backend-ops -o MUL_MAT -b HRX0on 2026-11-08 23:58 AST, the box had90.6 GiB of 120 GiB GTT in use (other lanes' models). The HRX0 guardrail printed, correctly:
and then, at case ~253 of the suite, the process died with SIGSEGV instead of that refusal:
So on a box at ~90 GiB the "refuse, do not fault" path is not what a large case actually gets — it segfaults. That matters for
this issue because duplicated GTT residency is exactly what pushes the box into that regime: at 40-60 GiB of resident
weights, a suite or a second model can cross into it and take the process down rather than getting a clean error.
Not my repo and not a claim about the cause: it is one observation, from a stock
mainbinary (no local kernel changes), withthe exact case and the guardrail line above. If it is useful, the reproduction is just that suite on a loaded box; if it is
already known, ignore it.