Skip to content

hrx: import the weight mapping instead of copying it to the device (engine #856) - #226

Merged
bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/856-resident-once-pi
Nov 7, 2026
Merged

bong-water-water-bong merged 1 commit into
1bit/hrx-vulkan-patchedfrom
1bit/856-resident-once-pi

Conversation

@bong-water-water-bong

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

Copy link
Copy Markdown

At -ngl 99 the loader places every offloaded weight in one device model buffer (src/llama-model.cpp, one buffer per
buft), and this backend serves a dense MUL_MAT weight from the repacked resident copy it materialises on first use. The
GGUF-layout copy is therefore a second copy of the same bytes that nothing reads: 5.9 GiB of GTT for a 2.38 GiB model,
2376 MiB of it that buffer (engine ggml-org#856, measured with the amdgpu_bo_create tracepoint).

This backend can now take the region itself instead. device_buffer_from_host_ptr imports the loader's mapping with
hrx_allocator_import_buffer, and the tensor stays in this backend's own buffer type, so the scheduler keeps the op
on the device and no offload_op is involved; the copy the loader would have made is gone.

Two requirements the import turned out to have, both measured rather than assumed:

  • A page-aligned address. The loader hands over mapping + tensor_offset; an unaligned lock fails with a bare
    HSA_STATUS_ERROR. The import covers the page-aligned superset and keeps the aligned address as the buffer base.
  • A writable region. hsa_amd_memory_lock is read-write only (access_policy.c passes flags=0), and a read-only
    mapping fails the same way — proven with a three-way probe on one 16 MiB region:
    PROT_READ → FAIL(2), PROT_READ|PROT_WRITE → OK(0), anonymous rw → OK(0). It must be MAP_SHARED: a private
    writable mapping is CoW-copied by MAP_POPULATE/mlock, which is exactly the failure this PR had before review. The
    weight files are opened O_RDWR and mapped MAP_SHARED|PROT_READ|PROT_WRITE only when a non-CPU device reports
    caps.buffer_from_host_ptr
    and every file of the model passes a write-access check — so every other backend, load
    mode and non-HRX run maps exactly as before.

GGML_HRX_WEIGHT_HOST_PLACEMENT=0 turns the capability off and reproduces the previous placement exactly; that is the
A/B control used below.

Measured (strixhalo, gfx1151, no-SVM trio, Qwen3-4B-Q4_K_M, -ngl 99 -fit off -c 256 -n 32 --temp 0)

Peak of the process's own drm-total-gtt from /proc/<pid>/fdinfo, plus RssFile/RssAnon from /proc/<pid>/status:

arm peak own GTT RssFile RssAnon generated text
control (GGML_HRX_WEIGHT_HOST_PLACEMENT=0) 3927 MiB 2410 MiB 670 MiB —
this PR 1245 MiB (−2682) 2412 MiB 670 MiB identical
-ot 'ffn_.*=CPU', control 2135 MiB 2415 MiB 388 MiB —
-ot 'ffn_.*=CPU', this PR 1005 MiB (−1130) 2413 MiB 687 MiB identical

GTT is bit-exact across repeats and the generated text is token-identical at --temp 0. Decode on a clean box state measured
73.8 → 71.5 t/s (−3 %); on a loaded box the arms are within noise of each other in both directions.

The pages stay file-backed and shared. That is the point of MAP_SHARED: RssFile holds at ~2410 MiB in every arm and
RssAnon does not grow with the model. A private writable mapping would instead have been CoW-copied wholesale by
MAP_POPULATE/mlock (which fault pages in with FOLL_WRITE) — measured on a 1 GiB file as +1024 MiB of RssAnon,
against +0 for the same mapping read page by page.

MoE / CPU-placed tensors

Qwen3-Coder-30B-A3B-Instruct-Q4_K_M (18.6 GB) at -ngl 99 -ot exps=CPU: control 1509 MiB GTT, 19.4 t/s. The import
initially never finished loading (>14 min, killed at hold expiry), because the from-ptr region is [first, last] of the
context's tensors and the experts are interleaved in the same file — so it pinned all 18.6 GB to serve ~1.5 GB of weights.
With the gate below the same arm loads in 4 s at 1509 MiB (identical to control), i.e. it falls back rather than
regressing. -ngl 0 with the mapping active loads the same file in 127 s, which is what attributes the cost to the pinning
rather than to the mapping.

The gate: the from-ptr path is taken only when each file's region is tight against the context's own bytes
(last - first <= ctx_bytes + ctx_bytes/8 + 1 MiB); otherwise that context uses a plain device buffer. Dense models keep
the win (4B: 3927 → 1245 MiB with identical text). Restricting the import to the ranges a device actually uses, so MoE
wins too, is a loader-side split and is named as the follow-up.

Why not the simpler alternative

Keeping the weight in a host buffer type costs −2582 MiB of GTT and −66 % of decode (63.7 → 21.6 t/s), because a
host-resident weight hands its node to the CPU: the scheduler's escape hatch for that needs offload_op
(ggml-backend.cpp), which this backend does not implement, and host-buft claiming is deliberately opt-in since ggml-org#1078.
Claiming it instead restores the rate but costs +458 MiB over doing nothing. Both measured; the import avoids the whole
question by keeping the buffer this backend's own.

Scope

Dense models. MoE experts are contracted by MUL_MAT_ID and keep their device copy — a per-tensor split is the follow-up
named in engine ggml-org#856.

@bong-water-water-bong

Copy link
Copy Markdown
Author

#226 ready for review at 164d1f8

@bong-water-water-bong
bong-water-water-bong changed the base branch from master to 1bit/hrx-vulkan-patched November 7, 2026 00:45
@bong-water-water-bong

Copy link
Copy Markdown
Author

Both points addressed, plus the row you asked for.

1. Branch base — retargeted and rebased

You are right, and it was the more dangerous of the two. #226 is now based on 1bit/hrx-vulkan-patched, and the commit is rebased onto that branch's tip (765fa1c4c, i.e. on top of #216/#218/765fa1c4), not on the upstream mirror. Head is now d097c4cd2; engine ggml-org#1303 has been re-pinned to it and main merged, so it is no longer BEHIND.

git ls-remote check: d097c4cd2 is on 1bit/856-resident-once-pi, whose parent is 765fa1c4c. A routine bump to the branch tip will therefore carry ggml-org#856 rather than drop it.

2. The mapping — shared, not private, and the comment now says why

Your measurement is the correct one and mine was wrong: it was never "locking a private writable mapping" at import time, it was MAP_POPULATE/mlock faulting a private writable mapping with FOLL_WRITE, i.e. CoW-copying the file at load. So a private mapping was the wrong mechanism entirely. The change is now:

  • MAP_SHARED|PROT_READ|PROT_WRITE, with the model files opened O_RDWR ("r+b") — HSA locks a shared writable mapping with nothing copied, every page stays in the page cache, still shared across processes, and none of the vm.overcommit behaviour changes.
  • Gated exactly as before (a non-CPU device must report caps.buffer_from_host_ptr) plus a write-access check on every file of the model up front (access(W_OK)); if any file cannot be opened for writing, the whole run falls back to the shared read-only mapping it has always had.
  • The misleading comment in src/llama-mmap.cpp is replaced with your numbers and the real mechanism.

Measured on this build (peak of the process's own drm-total-gtt, RssFile/RssAnon from /proc/<pid>/status):

arm peak own GTT RssFile RssAnon
4B -ngl 99, control (GGML_HRX_WEIGHT_HOST_PLACEMENT=0) 3927 MiB 2410 MiB 670 MiB
4B -ngl 99, this change 1245 MiB (−2682) 2412 MiB 670 MiB
4B -ngl 99 -ot 'ffn_.*=CPU', control 2135 MiB 2415 MiB 388 MiB
4B -ngl 99 -ot 'ffn_.*=CPU', this change 1005 MiB (−1130) 2413 MiB 687 MiB

The anonymous copy is gone: RssAnon is unchanged on the plain arm and moves by ~300 MiB (not the file) on the CPU-placed arm, while RssFile holds at ~2410 in every arm — the pages stay file-backed and shared. Generated text is token-identical, so nothing about the computation changed.

The -ot exps=CPU row — partial, and I am flagging the reason rather than filling it in

The only MoE on this box is Qwen3-Coder-30B-A3B-Instruct-Q4_K_M (18.6 GB). Control arm completed:

30B-A3B -ngl 99 -ot exps=CPU, control value
peak own GTT 1509 MiB
RssFile / RssAnon 15849 MiB / 186 MiB
generation 19.4 t/s

The import arm of that row did not finish loading inside a 12-minute hold — it was still in the load spinner when the ticket expired (I killed the child rather than let it run outside a hold). The box was heavily loaded at the time (another lane was running a 400 s GPU job against the same token queue), so I cannot yet say whether this is contention or a real load-time cost of pinning a large model's weights. Either way it is a fact about this change that is not in the PR body yet, and I would rather you see it than have me average it away.

What I did instead, to test the property your point is actually about (CPU-placed tensors must stay file-backed and reclaimable), is the -ot 'ffn_.*=CPU' row above: the CPU-placed tensors stay in the shared mapping (RssFile 2413 vs 2415), and only the device-resident part changes. That is the same mechanism as exps=CPU, on a model that loads in seconds.

I will re-run the 30B row — both arms, with -ot exps=CPU — on a quiet box and post the numbers here before this is READY, and if it turns out that importing a large model costs load time, I will say so and let you decide whether the gate should exclude MoE streaming.

@bong-water-water-bong

Copy link
Copy Markdown
Author

The -ot exps=CPU row — and it found a real blocker, which is now gated

You were right to ask for it. On Qwen3-Coder-30B-A3B-Instruct-Q4_K_M (18.6 GB, -ngl 99 -ot exps=CPU):

run load+run peak own GTT RssFile / RssAnon gen t/s
control (…HOST_PLACEMENT=0) — 1509 MiB 15849 / 186 MiB 19.4
import, before the gate >14 min, still loading (killed at hold expiry) — — —
import, with the gate below 4 s 1509 MiB — 17.2
isolation: -ngl 0 (mapping active, no device weights) 127 s 344 MiB — 36.0

Mechanism, not contention: the from-ptr buffer is created per (ctx, file) over [first, last] of that context's
tensors. With CPU-placed experts interleaved in the same file, that range is the whole 18.6 GB, so the import pins the
entire file to serve ~1.5 GB of device-resident weights, and the load never finishes. -ngl 0 with the same writable
mapping loads the same file in 127 s, so the mapping is not the cost — the pinning is.

Gate (src/llama-model.cpp): the from-ptr path is taken only when each file's region is tight against the context's
own tensor bytes (last - first <= ctx_bytes + ctx_bytes/8 + 1 MiB); otherwise that context falls back to a plain device
buffer. Dense models are unaffected:

4B -ngl 99, gated build peak own GTT gen t/s text
control 3927 MiB 49.4 —
this change 1245 MiB (−2682) 53.1 identical

So the honest scope is what the PR already says — dense models win, MoE under -ot keeps today's behaviour instead of
regressing. Making the MoE case win as well needs the region restricted to the ranges the device actually imports, which is
a loader-side split rather than a one-line gate; that is the follow-up, not something this PR claims.

One more thing worth your attention, because a validator could have caught it and I did not: the first version of that gate
was written ctx_bytes / 8 + (size_t) 1 << 20, which parses as ((ctx_bytes/8) + 1) << 20 because shift binds looser than
+ — megabytes of slack, so every region would have looked "tight" and the gate would have appeared to work while the MoE
case stayed broken. It is parenthesised now, and the numbers above are from the fixed build.

…ngine ggml-org#856)

At -ngl 99 the loader places every offloaded weight in one device buffer, and HRX serves a dense MUL_MAT weight from the
repacked resident copy it materialises on first use - so the loader's GGUF-layout copy is a second copy of the same bytes
that nothing reads: 5.9 GiB of GTT for a 2.38 GiB model, 2376 MiB of it that buffer.

This backend can now take the region itself. device_buffer_from_host_ptr imports the loader's mapping with
hrx_allocator_import_buffer and the tensor stays in this backend's own buffer type, so the scheduler keeps the op on the
device; the loader's copy is never made.

Three requirements the import turned out to have, all measured:

  * a page-aligned address - the loader hands over mapping + tensor offset, and an unaligned lock fails with a bare
    HSA_STATUS_ERROR;
  * a writable region - HSA locks read-write, and a read-only mapping fails the same way;
  * MAP_SHARED, not MAP_PRIVATE. MAP_POPULATE and mlock fault pages in with FOLL_WRITE, which breaks CoW on a private
    writable mapping and copies the whole file into anonymous memory at load time: +1024 MiB of RssAnon for a 1 GiB
    file, against +0 for the same mapping read page by page. A shared writable mapping keeps every page in the page
    cache, still shared between processes, and HSA locks it without copying.

So the weight files are opened read-write and mapped MAP_SHARED|PROT_READ|PROT_WRITE when - and only when - a non-CPU
device reports caps.buffer_from_host_ptr, which is this backend alone, and every file of the model passed a write-access
check; otherwise the mapping is exactly the shared read-only one it always was. GGML_HRX_WEIGHT_HOST_PLACEMENT=0 turns the
capability off and reproduces the previous placement, which is the A/B control.

The import is taken only for a region that is tight against the ctx'\''s own tensors (last - first <= ctx_bytes +
ctx_bytes/8 + 1 MiB, per file); otherwise that ctx falls back to a plain device buffer. Without it a MoE under
-ot exps=CPU imports the whole file - the experts are interleaved in the same mapping, so the range spans all 18.6 GB
- and the load does not finish (>14 min against 4 s with the gate, same model, same flags), for ~1.5 GB of
device-resident weights.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 360944b0e on the loader/mmap half, which is the half that can hurt a user — the GTT saving that is the point of the PR needs an HRX build and a device, and I did not spend an hour of this queue's time on it. What I did instead: read every hunk, compiled and linked the whole non-HRX tree at this head, and traced the one regression this change could plausibly cause (read-only model files) to the code that prevents it.

Builds and links here (GGML_HRX=OFF, Release, Ninja, thermal-run): 332/332, llama-cli links. Per-TU -fsyntax-only is also clean on the four src/ files that carry the change (llama-mmap.cpp, llama-model-loader.cpp, llama-model.cpp, llama.cpp). So the new writable parameter plumbed through llama_mmap → llama_model_loader → init_mappings and the "r+b" open are consistent everywhere they are used, in the configuration every non-HRX user has.

The read-only regression is handled, and this is the part I would have blocked on. A shared writable mapping needs the file opened "r+b" and a W_OK on every shard, which is not true of a model on a read-only mount, a root-owned-but-world-readable file, or a signed/read-only medium. src/llama.cpp builds the flag accordingly:

  • it probes the capability across both params.devices and, when that is NULL (what llama-cli and the server pass), the global device registry — and the comment records that the first version only walked params.devices and therefore never saw the capability at all, which is the kind of bug that makes a zero-copy patch silently do nothing;
  • it excludes GGML_BACKEND_DEVICE_TYPE_CPU, because the CPU backend advertises buffer_from_host_ptr too and does not want a writable mapping;
  • it then requires access(path, W_OK) == 0 for every split and the main file before enabling, otherwise falling back to the old read-only mapping.

So a read-only model degrades to exactly today's behaviour, and only when an importing device is present and every file is writable does the new path engage.

The import itself is defensive in the right places (ggml-hrx.cpp): the region is imported as the page-aligned superset with the delta kept (hsa_amd_memory_lock rejects an unaligned address — the comment says that was measured, and the arithmetic aligned = ptr & ~(page-1), import_size = size + delta is right); the buffer context is flagged imported so buffer_free skips the pool release (an imported region was never pool-allocated — releasing it would corrupt the pool's accounting); a failed import logs a WARN and returns nullptr, which is the documented "fall back to a device buffer" path in the loader; and the region is registered in host_buffers so the in-place get/set and direct-binding paths still find it. The capability is advertised through device_i.buffer_from_host_ptr (was nullptr) and gated by GGML_HRX_WEIGHT_HOST_PLACEMENT=0, which is the A/B control.

The MAP_SHARED-not-MAP_PRIVATE reasoning is the sharpest bit of the PR and it is stated with its measurement: MAP_POPULATE/mlock fault with FOLL_WRITE, so a private writable mapping breaks CoW and copies the file into anon memory (+1024 MiB RssAnon for a 1 GiB file, measured), whereas MAP_SHARED|PROT_READ and a shared writable mapping both add nothing. Since the mapping is only made writable when an importing device exists, MAP_SHARED is right for the whole lifetime — a second process mapping the same file still shares the page cache, which the comment says explicitly.

What I did not verify, and it is the PR's headline: the GTT drop itself — "5.9 GiB of GTT for a 2.38 GiB model, 2376 MiB of it that buffer", from the amdgpu_bo_create tracepoint. That needs GGML_HRX=ON, which on this box means the ExternalProject build of hrx + loom + IREE (tens of minutes) and then a GPU window; I judged that not the right spend for one PR at this hour and said so rather than letting a READY imply it. The re-derivation is GGML_HRX=WEIGHT/unset vs GGML_HRX_WEIGHT_HOST_PLACEMENT=0 with the same model at -ngl 99, tracepoint GTT total plus RssAnon — and the =0 arm is in the PR, so the A/B is one env var. If that pair does not move GTT, this verdict is about the code only.

Refs engine#856 and no close: the issue's other causes are not in this diff.

@bong-water-water-bong
bong-water-water-bong merged commit a3e8d42 into 1bit/hrx-vulkan-patched Nov 7, 2026
@bong-water-water-bong

Copy link
Copy Markdown
Author

Rebased-base A/B is in (~/wt/i856-rebase-2301, head 360944b0e on branch tip 21af1a924, 2 interleaved repeats):

4B -ngl 99 -fit off -c 256 -n 32 --temp 0 peak own GTT RssFile RssAnon gen t/s (median) text
control (GGML_HRX_WEIGHT_HOST_PLACEMENT=0) 3927 MiB 2410 MiB 670 MiB 67.8 —
this change 1245 MiB (−2682) 2412 MiB 365-670 MiB 74.5 identical

Nothing changed behaviourally across the rebase: GTT is bit-identical to the pre-rebase numbers (−2682 MiB), RssAnon does
not grow with the model, the generated text is token-identical, and the rate difference is inside noise (this run it is
slightly in the change's favour, 74.5 vs 67.8 — the earlier clean measurement had it 3 % the other way, so I would not quote
either as an effect).

So the state of ggml-org#1303 is: code-side accepted at your last review, re-pinned to 360944b0e (whose parent is
21af1a924, so landing #226 is a fast-forward that yields exactly this sha), engine head 516f097, MERGEABLE. The only
outstanding item is the fork merge, which needs the owner; and cl-856's large-model GTT + perplexity numbers, which are
theirs and not yet posted.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant