perf(loader): UMA-gated eviction + parallel warmer + zero-copy uploads — 2-3.4x faster dense loads, ~6x faster A3B MoE loads on dGPUs; fix sequential sampler seed - #617
Draft
ghazni101 wants to merge 11 commits into
Conversation
added 11 commits
August 22, 2026 15:12
….75s on gfx1100
qwen3.5 single-GPU (pp=1) loads ran every tensor through
pread + posix_fadvise(DONTNEED), evicting model pages from the page cache
after each read and forcing a full disk re-read of the model on every
load. That eviction exists for unified-memory APUs (Strix Halo etc.)
where cached pages and hipMalloc'd VRAM staging share physical RAM — but
it was applied unconditionally, including on discrete GPUs where the
page cache is free bandwidth.
- HfqFile gains evict_page_cache (default true = historical behavior;
UMA paths unchanged) + set_evict_page_cache/evicts_page_cache.
tensor_data_vec / tensor_data_pread / drop_pages_range skip fadvise
when it is off; HIPFIRE_PAGE_EVICTION=1 forces the old behavior.
- arch_is_uma() classifies gfx1103/1150/1151/1152 as UMA.
- qwen35 carrier (both pp=1 and pp>1 paths) disables eviction on
non-UMA devices before loading.
- warm_page_cache(): one POSIX_FADV_WILLNEED over the data region so
disk DMA overlaps GPU init and early uploads instead of serializing
behind per-tensor reads.
Measurements (Qwen3.5-9b.mq4 md5 296092bf1e6a45d78c1acf815eb93366,
gfx1100 RX 7900 XTX, fresh daemon process per run, spawn→loaded JSON):
baseline (master): median 4338 ms n=5 spread 3.6%
attempt1 (eviction gate): median 4364 ms no change — first gate
landed only in the pp>1 path, pp=1 was
still evicting
attempt1b (pp=1 gate): median 1805 ms -58%
attempt2 (+WILLNEED warmup): median 1751 ms n=7 spread 3.4%
Reference: ROCmFPX llama-completion loading unsloth Qwen3.5-9B-Q4_K_M.gguf
(md5 d3842bd7fba0ff2e6771a89272c5b862), spawn→'main: llama threadpool
init': median 1835 ms, n=5 spread 3.4%.
Isolation evidence: microbench H2D pageable memcpy 27.5 GB/s, warm pread
6.4 GB/s — neither load-limiting once the page cache survives; the old
path re-read from disk at ~1.3 GB/s effective.
Verification: hipfire-runtime suite 550+8 tests pass; daemon smoke
generate on the 9B produces coherent decoded text.
New tooling: scripts/bench_model_load.py measures both engines with
identical windows (hipfire: daemon spawn → {"type":"loaded"};
rocmfpx: spawn → 'llama threadpool init' log line, process killed at the
marker so prompt eval/gen are excluded).
Follow-up to the eviction gate. Under real cache pressure the qwen35
load path was still disk-bound: serial per-tensor pread sustains only
~1.7 GB/s on this box's btrfs md0 (single FADV_WILLNEED does not
saturate the array), while llama.cpp's mmap readahead overlaps reads
with copies and wins. Measured: 6 concurrent readers reach ~4.5 GB/s.
- start_cache_warmup(): 4 worker threads pread the data region into the
page cache chunk-sequentially (16 MiB chunks) while the loader
uploads tensors; CacheWarmerGuard stops them on drop. dGPU-only
(no-op when page-cache eviction is on, i.e. UMA).
- mostly_page_cached(): mincore over an untouched shared mapping; when
≥90% of pages are already resident the warmer skips (a fully cached
file needs no re-read, and spawning workers costs ~0.3 s of CPU
contention on 9B-class loads).
Measurements (fresh daemon process per run, spawn→loaded):
27B (qwen3.5-27b.mq4 sha256 ea615949…8576e, 14.984 GB):
pre-warmer median 9901 ms → post 4308 ms (n=5, spread 6.5%)
ROCmFPX llama-completion, same-family weights
(unsloth Qwen3.5-27B-Q4_K_M.gguf md5 a34c995b30e7ada6e89c8a3c0a0e479b,
spawn→'llama threadpool init'): steady-state median 5908 ms.
9B (md5 296092bf…): 2108 ms vs rocmfpx steady-state 2189 ms.
Verification: hipfire-runtime suite 550 tests pass; daemon smoke
generate produces coherent decoded output on both 9B and 27B.
…s at 11-16 GB/s Follow-up to the eviction gate + parallel warmer. With page cache warm, the remaining wall was the heap staging copy: every tensor went page-cache -> pread Vec -> hipMemcpy. Microbench (h2d_mmap_bench, new): heap-src H2D 15-27 GB/s; mmap-src with warm PTEs 15.2 GB/s; mmap-src cold PTEs 4.0 GB/s (soft faults inside the copy loop); pread path ~4-6 GB/s aggregate. Changes: - qwen35 HfqSource::prepare keeps the mmap alive on dGPUs (gated on !evicts_page_cache, i.e. exactly where eviction is already disabled). - load_weight_tensor / read_embed / lm_head reads prefer the borrowed mmap slice (tensor_data) and fall back to pread on UMA (mmap dropped there) via a Cow helper. No owned per-tensor Vec on the hot path. - populate_mapping() was tried and REVERTED: a blocking MADV_POPULATE before the upload loop measured identical totals — it relocates the same soft-fault work into one serial stall. Documented in prepare()'s comment so nobody re-adds it blindly. Measured steady-state medians (fresh daemon process per run; shared box, other agent active -> +/-20% run-to-run noise): qwen3.5-9b: 1.6-1.9 s (was 2.11 with warmer only) qwen3.5-27b: 3.9-4.4 s (was 4.31) qwen3.8-27b: 3.65-3.80 s (was 4.31) Trace on 27B: embed 77 ms/1.24 GB (~16 GB/s), lm_head 26 ms, 64-layer sweep ~1.1 s (~11 GB/s). Remaining budget is fixed init (~0.5 s), post-KV tail (~0.6 s), and disk state. Verification: hipfire-runtime suite 550 tests pass.
Adding h2d_microbench/h2d_mmap_bench as auto-discovered examples pushed ungated_examples to 34 (ceiling 32). They are GPU-only tools by nature — exactly what required-features gating is for — so declare them under 'deltanet' like the other hardware-bound probes. ungated_examples back to 32; leanup-ratchets 21/21 OK locally.
check-crate-maps --check drifted on hipfire-arch-qwen35, hipfire-loader, hipfire-quantize and hipfire-runtime: the load-fix commits changed symbols/counts those generated blocks track. Regenerated; ratchets 21/21 OK, crate-maps 39/39 match.
… green The clippy (advisory) job exits 101 on current stable — pre-existing on master too (verified: pure 80a572c fails identically; toolchain bump to clippy 1.98 made these deny-level): - hipfire-ds4-parent codec.rs/gemm_ref.rs/layer_ref.rs test indices: '0 * bk + N' erasing_op patterns -> plain indices (row 0), with the block-coordinate kept in comments. - hipfire-ds4-parent gemm_ref.rs a_s[0 * 2 + 1] -> a_s[1]. - saddle-lab test_forward_slots_golden: DECODE_STEPS was left at 0 by a debugging session ('was 3' comment), making an empty range (reversed_empty_ranges is deny). Restored to 3, which re-enables the intended decode steps through forward_scratch — the canonical entry point per the note in the same file. cargo clippy --workspace --all-targets --locked now exits 0.
…d 0x13579BDF Sequential (noslots) serve requests on the retained-PM4 route replayed the identical xorshift stream for every request: ar::generate hardcoded rng_state = 0x13579BDF (qwen path) / 42 (llama path) and reset the process-global CPU sampler RNG to 0x13579BDF. With temp>0 every request with the same prompt produced byte-identical output, run to run. The sampler kernels themselves are launched raw-HIP and are never captured into the PM4 tape, so the frozen RNG came from seeding, not replay. Thread a request_seed parameter through generate() and derive it at the daemon call site from the same attempt key the continuous-batch scheduler already uses (batch_rng_for_key(AttemptKey)), which exists precisely to give 'each lane/request a distinct stream rather than the same fixed seed'. temp=0 greedy stays deterministic; batch/EP paths are untouched. Carries the rustfmt debt for the two touched files required by CI's changed-file fmt gate.
…cache The packed-expert sweep dominates A3B MoE load time (~28s of ~31s on ornith-1.5-35b-a3b.mq4r): every layer pread 512 expert tensors into fresh heap Vecs, then hipMemcpy'd them to VRAM — two full host copies per blob on top of the disk/page-cache read. c6edb84 wired zero-copy mmap uploads for dense tensors but missed this path. Add HfqFile::data_range() and a contiguity probe: when all experts of a blob lie back-to-back in-file and the pages are resident (mincore, >=90%), upload_raw copies straight out of the mapping — no staging Vec. Evicted-cache cold starts keep the parallel-pread path: a borrowed-slice H2D from disk soft-faults serially inside the copy loop (~0.25 GB/s), worse than the lane-parallel reader. Also log per-load sweep timing ('weight sweep: N ms') for ops visibility. Measured on gfx1100, warm page cache: weight sweep 28s -> 2.2s, daemon start-to-ready ~31s -> ~5s.
…ader read_job() called fadvise(DONTNEED) on every segment unconditionally, so any load routed through the lane-parallel reader — including the packed- MQ4 expert sweep that dominates A3B MoE loads — evicted its own pages and re-read the entire model from disk on every restart (~25s for an 18.8 GB mq4r on gfx1100), contradicting the carrier's dGPU keep-resident policy and keeping HfqFile::mostly_page_cached() false forever. Thread hfq.evicts_page_cache() through read_jobs_from_path/read_job so only UMA / explicit opt-in evicts.
mostly_page_cached() maps + mincores the whole model file (~0.4 s on an 18.8 GB mq4r) and was called once per layer — ~17 s of pure residency checking per load. Compute it once per process instead.
Contributor
Author
|
Round 2 pushed to this branch (4 new commits, New:
All gates pass (fmt, MQ4 speed floor); both PM4 serving containers rebuilt and verified end-to-end. |
ghazni101
marked this pull request as draft
August 23, 2026 04:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
hipfire's model→VRAM load path was leaving most of its bandwidth on the
table for three stacked reasons, each found by measurement:
read ended with
posix_fadvise(DONTNEED), forcing a full diskre-read of the model on every load. Correct on unified-memory APUs
(Strix Halo etc.), pure loss on discrete GPUs.
FADV_WILLNEEDdoes notsaturate this box's btrfs md0 (~1.7 GB/s serial vs ~4.5 GB/s with
concurrent readers), so cold loads serialized behind disk latency.
preadinto a fresh Vec, thenhipMemcpyfrom that copy: page-cache → heap → VRAM instead ofpage-cache → VRAM.
Three commits, one per cause:
9842657fgate HFQ page-cache eviction on UMA:HfqFilegains
evict_page_cache(defaulttrue= historical/UMA behavior);the qwen35 carrier disables it on non-UMA devices (both pp=1 and
pp>1 paths);
HIPFIRE_PAGE_EVICTION=1restores old behavior;arch_is_uma()classifies gfx1103/1150/1151/1152. deepseek4'sOOM-sensitive pread+fadvise lifecycle is deliberately untouched.
9d51c825parallel cache warmer + mincore gate: 4 worker threadspread the data region into the page cache (16 MiB chunks) while the
loader uploads; a guard stops them on drop. Skipped entirely when
mincoreshows ≥90 % of pages already resident.c6edb843zero-copy uploads: qwen35 keeps its mmap alive ondGPUs and reads tensors as borrowed slices (
tensor_data) sohipMemcpyDMAs straight out of page-cache pages; pread fallbackpreserves UMA behavior.
A fourth idea was tried and reverted: blocking
MADV_POPULATEbefore the upload loop measured identical totals (it relocates the same
soft-fault work into one serial stall). Documented in
prepare()'scomment so it isn't re-added blindly.
New tooling:
scripts/bench_model_load.pymeasures hipfire and theROCmFPX llama.cpp fork with identical windows (hipfire: daemon spawn →
{"type":"loaded"}protocol response; rocmfpx: spawn → end-of-load logmarker, process killed at marker so prompt eval/gen are excluded).
Microbenchmarks:
h2d_mmap_bench.rs,h2d_microbench.rs.Round 2: A3B MoE loads + a latent eviction leak (follow-up campaign)
Deploying the branch to the Ornith-1.5-35B-A3B mq4r serving container
exposed three more issues, found by phase-profiling real daemon loads:
0493003f) — thepacked-MQ4 loader (
try_load_packed_mq4_experts) still pread all512 experts per blob into fresh heap Vecs before
hipMemcpy. Thissweep was ~28 s of every ~31 s load on the A3B model. Fix:
HfqFile::data_range()+ an in-file contiguity probe — when allexperts lie back-to-back and pages are resident, upload straight
out of one borrowed mmap slice; non-contiguous/overlay/UMA keep the
old paths.
761de5d9) —read_job()calledfadvise(DONTNEED)after every segment,unconditionally, so any load routed through the lane-parallel
reader re-read the whole model from disk (~0.7 GB/s) on every
restart. This contradicted the carrier's dGPU keep-resident policy;
hfq.evicts_page_cache()is now threaded throughread_jobs_from_path/read_job.d2072ddf) — the first cut of thezero-copy gate called
mostly_page_cached()(fresh map+mincore ofthe full multi-GB file) once per layer ≈ 17 s per load of pure
checking. Memoized per process.
Also riding in this push (
1cd7a890): sequential serve requestsseeded the sampler RNG from a compile-time constant (
0x13579BDF),making temp>0 output byte-identical across requests for the same
prompt on the noslots path (batch scheduler already keyed entropy per
attempt).
generate()now takes arequest_seed; the daemon derivesit via
batch_rng_for_key(AttemptKey). Greedy temp-0 stays fullydeterministic — verified byte-identical pre/post change.
Measurements
gfx1100 (RX 7900 XTX), fresh daemon process per run, medians of ≥5 runs.
Shared-box caveat: another agent benchmarks the same GPU/disk, so
run-to-run noise is ±10–20 %; all deltas below exceed it.
Isolation microbenchmarks (
h2d_mmap_bench, 15.7 GB warm file):heap-src H2D 15–27 GB/s · mmap-src warm-PTEs 15.2 GB/s · mmap-src
cold-PTEs 4.0 GB/s · old pread path ~4–6 GB/s aggregate.
End-to-end load, hipfire daemon:
296092bf…)ea615949…8576e)2fb2edc2…)*qwen3.5-27b baseline taken mid-campaign after the 9B fix landed; the
clean pre-fix number for the same lineage is the 3.8-27B A/B above
(master binary built from
80a572c8, interleaved runs, identicalweights).
**Round 2 measurement: container daemon start →
/v1/modelsserving,interleaved old/new binary under a GPU lock; weight-sweep phase alone
dropped ~28 s → ~2.7 s (host-read 0 ms = borrowed-mmap path engaged).
Reference point — ROCMFPX llama.cpp fork loading equivalent weights,
same measurement window:
d3842bd7…)a34c995b…)Traced phase timings post-fix (27B): embed 77 ms / 1.24 GB
(~16 GB/s), lm_head 26 ms, full 64-layer sweep ~1.1 s (~11 GB/s).
Remaining budget: fixed init (~0.5 s), post-KV tail (~0.6 s), disk state.
Verification
cargo test -p hipfire-runtime: 550 tests pass at each commit.baseline floor, decode +9 %).
string-reverse function), 27B (mutex paragraph), 3.8-27B (17×23=391,
19×21=399 with correct reasoning chains).
this branch (
ornith-mq4rp,qwen36-35b-mq4r, both retained-PM4rigs) and verified through their real HTTP serving path — load,
greedy determinism, temp>0 sampling variation, and PM4 route
activation checked end-to-end after each deploy.
completions 3/3 at temp 0.8; fixed binary produced distinct
generations 5/5, greedy unchanged.
binary + model md5s recorded in the commit messages.
Notes for reviewers
gate initially landed only in the pp>1 path; the blocking-populate
experiment; the ungated first cut of the zero-copy gate, which let
cold-start H2D soft-fault serially off disk at ~0.25 GB/s — now
mincore-gated) — each documented with measurements rather than hidden.
so no other load path changes behavior by default.
fadvise per read, owned Vecs.
included here because it was developed against and validated on this
branch's deployment targets. Happy to split it out if reviewers
prefer.