Skip to content

qwen4exp: direct reads for the lazy PLE table (>2x prefill performance improvement on GB10) - #28136

Open
coder543 wants to merge 2 commits into
ggml-org:masterfrom
coder543:master
Open

qwen4exp: direct reads for the lazy PLE table (>2x prefill performance improvement on GB10)#28136
coder543 wants to merge 2 commits into
ggml-org:masterfrom
coder543:master

Conversation

@coder543

@coder543 coder543 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Overview

For Qwen3.8-Flash-Next, I've been confused about the very inconsistent prefill speeds. A simple benchmark would show 700+ tok/s, so then I would start a real task, and suddenly I'm only seeing 300 tok/s. Very frustrating. This PR yields a 2x to 3x improvement in real world use, at least in my testing on my DGX Spark.

I spent a few hours this evening digging into it. Once again, the answer is mmap. It's always mmap. I really wish Nvidia would fix whatever is going on there. The simple prefill benchmark I had been running used a lot of repeated tokens, so there was very little PLE data needed, which made prompt processing fast. On real world inputs, suddenly quite few more PLE reads were needed, which caused the performance to slow way down due to mmap.

I haven't tested this on any other systems, but maybe these changes are actually broadly beneficial for PLE performance? mmap even when well-behaved is going to cause quite a bit of over-read: likely several kilobytes of wasted reads for every ~100 bytes of useful data.

This PR is a very 'direct' solution to the problem I've been seeing. In an ideal world, maybe this would even be handled by something more elegant like io_uring. But, this works, and I tried to keep the patch as small as it reasonably could be.

In my testing, this boosts performance on real world input text from about 300 tok/s up to around 750 or 800 tok/s on DGX Spark, which is far better, without requiring the PLE to be pinned to RAM.

I wanted to make this new on-direct the default behavior for GB10 owners, but I decided there wasn't an obvious way to do that which wouldn't be controversial in PR review. Maybe if other people test this PR and find that it helps on a broader range of systems, then this could become the default 'on' mode for all supported systems, with the mmap path being an alternative/fallback option.

Requirements

  • I have read and agree with the contributing guidelines Yes
  • AI usage disclosure: Yes. This is heavily written and reviewed by AI. GLM-5.3 wrote the code, GPT-5.6 Sol reviewed it across multiple rounds to whittle away at rough edges. I've also reviewed the code myself and it seems fine to me now. I've tested it and does work on my system.

@coder543
coder543 requested review from a team, CISC and ggerganov as code owners September 1, 2026 03:42
@github-actions github-actions Bot added model Model specific examples labels Sep 1, 2026
@nkoriyama

Copy link
Copy Markdown

I independently tested --lazy-mode on-direct on an RX 9070 XT / Linux / Vulkan / NVMe system using a real 23,664-token prompt and a strict-cold procedure: the PLE byte range is evicted before each cold run and verified with mincore; when posix_fadvise(DONTNEED) does not meet the cold-residency threshold, the server is restarted to release the mapping.

The measurements below use a build with instrumentation-only changes to record pread/dequant timing and RUSAGE_THREAD counters; the direct-read execution path itself is unchanged from the PR.

With the ordinary lazy mmap path, the cold PLE work reproduced the behavior I had previously localized independently: 207,913 major faults occurred inside the PLE get_rows row loop (208,165 process-wide, 99.88%), costing 18.01 s in that serialized row loop, or about 86.6 µs per major fault.

With the direct-read path, PLE-related major faults were effectively eliminated: 43 process-wide major faults with one direct-read worker and 77 with the PR-default worker count, versus ~208k with mmap.

I also ran the direct reader with a single worker to separate the explicit-read path itself from parallelism:

path events / reads cold local/stage wall
mmap demand faults 207,913 faults 18.01 s
direct, 1 worker 296,120 row reads 15.75 s
direct, PR default (32 workers on this 16-thread CPU) 296,189 row reads 2.39 s

The one-worker direct path was only ~1.15x faster than the serialized mmap path, while the PR-default 32 dedicated read workers reduced the stage wall by a further ~6.6x. Per-row read service time rose from 52.5 µs at one worker to 211.2 µs at 32, but aggregate read service divided by stage wall corresponds to ~26x effective read concurrency. So on this system the dominant gain comes from exposing substantial I/O concurrency, despite higher per-read latency with many concurrent read workers.

Note the counters are not 1:1 — the mmap figure counts major faults while the direct figures count row reads, and the PR dedups per ubatch rather than globally.

As a separate causal check with a different implementation, preloading the exact PLE hot set (208,771 pages / 815.5 MiB, derived from the gathered row indices) eliminated 207,488 / 207,488 PLE-loop major faults and reduced the same PLE-local cold cost from 17.9 s to 0.35 s, against a warm floor of ~0.16 s. That independently supports cold sparse PLE backing acquisition as the bottleneck.

I did not observe a measurable PLE-local warm regression with the PR-default configuration (~154.6 ms direct vs ~154.0 ms mmap in this test; the measurement boundaries are not exactly identical).

End-to-end prompt time is reported only as a reference on this machine because the 72.4 GB model substantially exceeds 16 GB VRAM and whole-request timing is highly sensitive to unrelated model/page-cache residency.

@Rhonstin

Rhonstin commented Sep 1, 2026

Copy link
Copy Markdown

Independent test on an unusual but real low-end serving config: 2× Xeon E5-2620 (Sandy Bridge, no AVX2/BMI2/FMA), 15 GB RAM, and 8 GPUs (2× RTX 3090 + 6× CMP 90HX) all behind PCIe Gen2 x4. The model is Qwen3.8-Flash-Next UD-Q3_K_XL (84 GB, 3 shards) fully offloaded, with the ~6 GB IQ1_S PLE table served from an NVMe SSD via --lazy-mode — the table has to stay on disk here, since the host only has 15 GB of RAM. 160K context slot, ubatch 256, no speculative decoding during the benchmark, to isolate the PLE path.

I patched this PR onto my tree and compared --lazy-mode on vs --lazy-mode on-direct with a strict-cold procedure: sync && echo 3 > /proc/sys/vm/drop_caches before every server start, then a 65,715-token prompt (random filler, single user turn). "Warm" is a second, differently-seeded 65K prompt, so the server KV prefix cache is never hit — only the PLE page cache state differs between the two runs. pp is the server-reported prompt_per_second.

--lazy-mode prefill 65K, cold prefill 65K, warm
on 403 t/s (first pass: 374) 471 t/s
on-direct 552 / 558 t/s 593 / 575 t/s

That is ~+37% cold and ~+25% warm prefill on this box — smaller than the >2x seen on GB10, which I'd attribute to the fast NVMe already masking part of the demand-paging cost, but very consistent across repeats. Decode at 60K context is unchanged within noise (15.7–19.2 t/s in both modes).

Two notes from porting:

  1. Applies cleanly except for a small context clash in load_arch_tensors() — my tree carries qwen4exp : add NextN/MTP draft head (--spec-type draft-mtp) for Qwen3.8-Flash-Next #27836's NextN/MTP head changes, so the local PLE block differs slightly from current master. The direct-reader block itself dropped in verbatim and worked on the first try.
  2. With the MTP head enabled the absolute prefill numbers are lower (~240 t/s baseline), but the relative win holds there too.

Adopting on-direct for this server — thanks!

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

mmap is a good default for streaming a file in once, and a poor fit for a scattered gather of tiny rows: a full page faulted to serve about ninety bytes, readahead waste on top, and a synchronous fault that drives the device at QD1 whatever it is capable of. Explicit reads from several workers fix both the amplification and the concurrency.

Unified memory is the worst case here, since the weights leave nothing for the page cache and the rows are effectively always cold. It is going to matter on Apple silicon too, for the same reason.

On a machine with spare RAM the alternative is to keep the table resident on the host, which gives full memory bandwidth and immunity to another model evicting the cache, but that option disappears as soon as the table cannot be resident.

@Rhonstin

Rhonstin commented Sep 1, 2026

Copy link
Copy Markdown

Confirmed from the "table cannot be resident" side of that fork: this box has 15 GB of host RAM with an 84 GB model mapped, so the page cache holds ~11 GB total and gets churned the moment anything else touches the disk — the resident-table option is off the table by construction. The +37% cold / +25% warm prefill I posted above is exactly that regime, and the numbers were stable across repeats.

The QD1 point also matches what I saw: cold prefill on on was the worst case (374–403 t/s), while on-direct with the worker pool pulled NVMe out of the serialized-fault pattern (552–558 t/s).

@michal-zurkowski

Copy link
Copy Markdown

Strix Halo (gfx1151) on-direct gives +20–32% cold prefill

llama.cpp build 10743 54ed7d366 + this PR, ROCm 10.0, 128 GB unified memory (125 GiB usable), NVMe.

Model unsloth/Qwen3.8-Flash-Next-GGUF (qwen4exp). PLE table per_layer_token_embd.weight is
26.8 GiB in UD-Q4_K_XL (103.7 GiB total) and 50.7 GiB in UD-Q5_K_XL (147.4 GiB total larger than
the 125 GiB of RAM
, so only loadable at all because of lazy PLE).

Method. Only --lazy-mode varies, stock defaults otherwise:

llama-server --model <gguf> -c 131072 -lm dio --lazy-mode {off|on|on-direct}

Per rep: drop_caches → start server → send prompt (cold) → send again (warm) → kill.
5 reps × 2 quants × 3 modes = 180 timed prefills, no failures. /completion with
n_predict:1, cache_prompt:false so only prefill is timed; numbers are the server's own
timings.prompt_per_second. (Verified cache_prompt:false really defeats slot reuse: the warm
request picks the same slot at f_sim_best = 1.000 and still re-evals all N tokens.)
P1/P2/P3 are three real-world app-spec prompts.

Cold prefill, tok/s (mean ± stdev, n=5)

prompt tokens Q4 on Q4 on-direct Q5 on Q5 on-direct
P1 2392 250 ± 2 325 ± 1 +30% 256 ± 1 337 ± 2 +32%
P2 3767 264 ± 1 321 ± 1 +22% 269 ± 1 332 ± 1 +23%
P3 6490 259 ± 0 311 ± 0 +20% 266 ± 0 323 ± 1 +21%

Warm is a no-op -0.4% to +2.1% across all 12 cells, as expected once the rows are in page
cache. on-direct is also flat cold→warm, i.e. insensitive to cache state.

What changed (per cold prefill, mean, UD-Q5_K_XL; Q4 identical pattern)

prompt on majflt on-direct majflt on-direct syscr read MiB (both)
P1 30732 10 32274 139.9
P2 39126 0 45472 152.8
P3 61395 0 80733 239.8

read_bytes is byte-identical between the two modes: same data, different fetch. ~31–61k major
faults become 0, replaced by explicit preads. syscr is deterministic per prompt (same to the
digit across all 5 reps).

--lazy-mode off

Doesn't fail on the oversized model - it swaps. UD-Q5_K_XL loads in 159 s vs 19 s for
on-direct, parking ~43 GiB in swap (avail. drops to ~5 GiB), and its cold prefill is still
slower (312–318 vs 323–337). Q4: 34 s load, ~7 GiB swapped. No case for off on either quant here.

The gain decays with prompt length (+31% at 2.4k tok → +21% at 6.5k), since prefill compute grows
while PLE fetch is ~fixed per unique row.

@Farenheith

Farenheith commented Sep 2, 2026

Copy link
Copy Markdown

If I understood correctly, this PR's new --lazy-mode on-direct seems to be the ideal approach for local inference of Qwen3.8-flash-next and any future models using PLE/n-gram tables.

Since these tables are accessed via direct reads into temporary, short-lived working buffers rather than being mapped into virtual memory, they never accumulate in the OS page cache. This keeps the persistent memory footprint limited strictly to the model's main weights and KV cache.

If so, loading a model with -ngl 99 --lazy-mode on-direct allows offloading 100% of the weights to the accelerator while keeping the n-gram table permanently on the SSD with minimal I/O overhead.

This is gold!

@pwilkin pwilkin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I feel like this is a valid investment for the big gains it provides, although I'm not sure if Windows support should be a part of this or a followup PR. But we'd probably need @ngxson to chime in here.

@ggerganov

Copy link
Copy Markdown
Member

If we end up needing this, I would like to see some cleaner way to implement this. Not sure what exactly, but the proposed change would not scale well with more models that might need this in the future. Even Gemma today should benefit from this.

@gabrielfreire

gabrielfreire commented Sep 2, 2026

Copy link
Copy Markdown

I had to add the following line to llama-mmap.h in order to compile this `

#include <string>

am I the only one who had this issue? I am on windows

Also

load_arch_tensors: --lazy-mode on-direct is not supported on this platform, using lazy mmap reads

Is windows support coming?

dzannotti added a commit to dzannotti/llama.cpp that referenced this pull request Sep 2, 2026
ggml-org#28136 was written against a master where the PLE tensor was required, giving an
outer-scope `const auto & ple_w = ml.require_weight(...)` that its direct-read
path uses for the file index and offset. Master has since made the tensor optional
(`if (const auto * ple_w = ml.get_weight(...))`, block-scoped pointer), so the
merge is textually clean but does not compile: 'ple_w' was not declared in this scope.

Hoist the lookup to outer scope as a pointer and guard the direct-read path on it.
A metadata-only model has no file to pread from, so skipping direct reads there is
the correct behaviour, not just the compiling one.

Semantic conflict, not a textual one, so rerere cannot replay it -- this commit has
to be re-applied by hand each time release is rebuilt.
@eiffel31

eiffel31 commented Sep 2, 2026

Copy link
Copy Markdown

@coder543 I would like to reproduce the tests on GB10. Could you please specify the exact model used (which quant?) and the full command line?

@coder543

coder543 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

I did my best to make it compile under Windows, but I don't have a Windows machine available at the moment. I can push a fix for that later. I didn't realize that MSVC would have a problem with string there. If someone wants to contribute full Windows support, that would be great, but as mentioned, I don't have Windows available at the moment to test with.

If @ggerganov or someone else wants to suggest a better implementation, I would be happy to take a look at that, but otherwise, this PR seems like a real performance win and its hopefully fairly unobtrusive since it is very limited in scope.

For Gemma 4 E2B/E4B, the access pattern seems to benefit a lot less from direct reads since it is returning ~5KiB per access, instead of randomly accessing very small values the way that qwen4exp does with its PLE layout, but I agree it would make sense to support as an option. When I tested Gemma 4 E2B with on-direct, I saw somewhere between "no improvement" and a 2% regression in prefill speeds with on-direct versus on.

For consistency, it would be a nice option to have on Gemma, but the benefit mostly seems to be exclusive to qwen4exp for now. I would be happy to refactor this to be shared by Gemma 4 E2B/E4B and Qwen3.8-Flash-Next if that would be helpful?

@eiffel31 the model that I was testing is this one. The command line is nothing special, what matters is setting --load-mode none and changing --lazy-mode between on and on-direct, as well as using realistic input text, not artificial prompt text that is a small string repeated thousands of times. Real text causes a lot more PLE entries to be accessed, whereas a single repeated string does not. I also used -ub 2048.

Each cold PLE row demand-faults a 4 KiB page for ~90 bytes of data,
capping cold diverse-text prefill at 218-360 tok/s vs ~785 warm on
GB10. All n-gram row indices of a ubatch are known host-side before
the graph runs, so under the new LLAMA_LAZY_MODE_DIRECT
(--lazy-mode on-direct) they are staged into an input tensor with
sorted, deduplicated, parallel pread()s and dequantized exactly like
ggml_get_rows; downstream kernels unchanged, table stays on disk.

Cold diverse prefill on qwen3.8-flash-next: 542-741 tok/s (2.0-3.1x,
within ~6% of warm); warm, decode and greedy outputs bit-identical.
Move the row reader into arch-agnostic src/llama-lazy-reader.h, owned
by llama_model_base keyed by tensor name; arches opt in with one
load_lazy_reader call. qwen4exp now uses the shared reader, and
gemma4 stages its per-layer rows the same way. Gemma's ~5 KiB
contiguous rows show no prefill win either way, which is why the
reader stays opt-in per tensor rather than unconditional.

Also fix the missing <string> include in llama-mmap.h (MSVC needs it
for the name() declaration) and make the help text generic so it does
not stale when arches opt in.
@coder543

coder543 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

I have pushed a commit showing what this looks like if refactored to support gemma4 as well.

@vlascik

vlascik commented Sep 3, 2026

Copy link
Copy Markdown

I don't have a Windows machine available

FYI Windows ISOs are downloadable for free and can be installed in a VM in <20 minutes and <30GB, the only "limitation" is a watermark on desktop, which you can ignore indefinitely.

@mjungnickel18

Copy link
Copy Markdown

@coder543 I implemented the Windows side of the direct-read path and measured it — the port works and is correct, but on this box it is 2–4% slower than the mmap path, not faster. Patch and numbers below; the honest summary is that the win looks platform-specific rather than universal.

Patch (applies to c6a9e5c): https://gist.github.com/mjungnickel18/083da5cde978ca3b102952029d4a991a — 106 insertions across llama-lazy-reader.h and llama-model.cpp, take it as your own if it is useful.

What the port does

ReadFile() with an OVERLAPPED offset is the Windows pread(): the offset travels with the call, so the gather workers issue positioned reads on one handle without racing over a shared file pointer. One manual-reset event per worker, ERROR_IO_PENDING completed through GetOverlappedResult. The handle comes from CreateFileW with FILE_FLAG_RANDOM_ACCESS — the closest Windows has to your posix_fadvise(POSIX_FADV_RANDOM) — plus FILE_FLAG_OVERLAPPED. The path is converted UTF-8 → UTF-16 so non-ASCII model paths keep working.

One gotcha for whoever reviews it: windows.h needs NOMINMAX here, otherwise its min/max macros break std::min in unrelated translation units that include this header (it fails in src/llama.cpp, which is a confusing place to land).

Activation confirmed in the log rather than assumed:

load_lazy_reader: direct reads enabled for per_layer_token_embd.weight:
                  320001536 rows of 90 bytes at file offset 528501568, 32 threads

Measurement

RTX 3090 24 GB, Windows 11 native (not WSL), MSVC 19.44, CUDA 13.1, 96 GB DDR4, NVMe, 250 W. Qwen3.8-Flash-Next UD-Q3_K_XL, -ngl 99 --n-cpu-moe 38 -c 65536 -np 1 -fa on -ub 2048 -ctk/v q8_0. Prompt text is wikitext-2 (7,971 distinct words in 56k) rather than repeated filler, "cache_prompt": false on every request, cached_tokens == 0 verified, three-prompt warmup after each server start, medians of 3, two full interleaved cycles:

prefill tok/s ~3.8k ~13.5k ~41.5k
cycle 1 --lazy-mode on 403 432 415
cycle 1 --lazy-mode on-direct 387 421 406
cycle 2 --lazy-mode on 403 428 417
cycle 2 --lazy-mode on-direct 391 417 404

Both cycles agree, SD 2–19 within each cell. Greedy output is identical between the two modes.

Why I think it goes the other way here

Your DGX Spark result reads to me as "mmap is pathologically slow on that platform" rather than "explicit reads are fast" — you said as much yourself. On NTFS with 96 GB of RAM the 27 GiB table sits warm in the file cache, so an mmap fault is nearly free, and what is left is one ReadFile syscall per 90-byte row. At ~41k tokens × 16 PLE heads that is hundreds of thousands of syscalls per prefill, and they cost more than the faults they replace.

That would predict the direct path wins on Windows exactly where the table does not fit in the file cache — a smaller-RAM box, or a machine under memory pressure. I cannot produce that state on this one (evicting 27 GiB of standby list reliably is its own project), so I have not measured it.

Suggestion: if you take the patch, it may be worth leaving on-direct opt-in on Windows rather than making it the default there, until someone reports the low-memory case. Happy to run further cells — this is a standing native-Windows install with the model already on disk.

Two smaller notes:

  • The #include <string> that @gabrielfreire hit is already fixed on c6a9e5c; I did not need it.
  • The gemma4 refactor in c6a9e5c compiles clean under MSVC as well.

@eiffel31

eiffel31 commented Sep 3, 2026

Copy link
Copy Markdown

FYI Windows ISOs are downloadable for free and can be installed in a VM in <20 minutes and <30GB, the only "limitation" is a watermark on desktop, which you can ignore indefinitely.

This could help, for sure. Do you have a download link?

@eiffel31

eiffel31 commented Sep 3, 2026

Copy link
Copy Markdown

@mjungnickel18

Measurement

RTX 3090 24 GB, Windows 11 native (not WSL), MSVC 19.44, CUDA 13.1, 96 GB DDR4, NVMe, 250 W. Qwen3.8-Flash-Next UD-Q3_K_XL, -ngl 99 --n-cpu-moe 38 -c 65536 -np 1 -fa on -ub 2048 -ctk/v q8_0.

You have to use a model bigger than your memory in order to test this PR or mmap. This Q3 model is too small, so there is no cache eviction. Q4 or Q5 are more interesting tests with this machine.

TrevorS added a commit to TrevorS/llama.cpp that referenced this pull request Sep 4, 2026
…eam ggml-org#28136, adapted)

The per-layer n-gram table (26.8 GiB, IQ4_NL) stays a lazy mmap, and every
gathered row was a demand fault of a 4 KB page for a 90-byte row: the
threaded gather plus MADV_WILLNEED took a cold 37k real-text prefill from
211 to 368 t/s on GB10 and stopped there. coder543's reader (llama-lazy-
reader.h, taken as-is) serves the rows of a ubatch with explicit preads on
a thread pool from an independently opened descriptor advised
POSIX_FADV_RANDOM, dequantized to F32, so the table's pages are never
touched; they measured 300 -> 750-800 t/s on a Spark against the plain
mmap path.

Adapted to our tree: the model keeps a reader per PLE tensor
(load_lazy_reader), the PLE graph input carries either the row indices
(mmap path) or the staged F32 rows (direct path), set_input gathers on
the reader and hands the graph the same [ple_head_dim, n_heads * n_tokens]
F32 tensor ggml_get_rows would have produced, so nothing downstream
changes. The loader treats on-direct like on for the lazy decision.

Gated bit-identical against the mmap path (tiny-model logits, and the
real model's PPL 1.8175 on 10 chunks either way). Against our threaded
gather it loses: cold real-text prefill with the table evicted, GB10, P70,

    37k  mmap+threads+willneed 361.2 t/s   on-direct 330.2 t/s
    140k                       183.9 t/s             169.1 t/s

so mmap stays the default and on-direct is opt-in (-lzm on-direct); the
code is kept as upstream carries it so the next rebase takes it clean.
@nazeshinjite

Copy link
Copy Markdown

Data point from the other side of the hardware fence: on an M5 Max 128GB on Metal, on-direct is a wash to marginally slower warm. The value for me is elsewhere: under mmap, a large varied prefill fault-accumulates the PLE pages into the page cache as an ~11 GB transient burst, which on a unified-memory box eats the headroom it shares with weights and KV, tipping the machine into memory pressure. With --lazy-mode on-direct the resident footprint stays flat — a ~105k-token representative varied prefill here held wired flat with just +232 MiB pageins total and zero swap — which is exactly what lets me run a larger backbone and keep a higher-precision PLE table (Q8 engrams) that mmap would otherwise push into OOM. I'd frame this as a memory-headroom feature as much as a prefill-speed one, worth keeping even on hardware where the speedup doesn't reproduce.

@mjungnickel18

Copy link
Copy Markdown

@eiffel31 you were right, and my earlier Windows numbers were measuring the wrong thing. Redone on UD-Q4_K_XL with a cold-cache protocol: direct reads are +91% to +133% cold on Windows, and my "leave it opt-in on Windows" suggestion should be disregarded.

What was wrong with the Q3 measurement

Same box as before (RTX 3090 24 GB, Windows 11 native, MSVC 19.44, CUDA 13.1, 96 GB DDR4, NVMe, 250 W), same Windows port of the direct-read path (gist).

UD-Q3_K_XL leaves the server at ~36 GB RSS, so ~55 GB of RAM stays free as file cache. The PLE table is 320,001,536 rows of 90 bytes (~28.8 GB), and the touched row set is far smaller than that — after any warmup it simply sits in the file cache, and every arm measures the warm case. That is the only state I could produce, so I reported the warm state as if it were the result.

UD-Q4_K_XL has the same PLE geometry (90-byte rows — the 359,682 explicit reads below move 31 MB, i.e. 90.4 B per read), but the heavier non-PLE weights push the server to 65 GB RSS, leaving ~19 GB for the cache. Now the table cannot stay resident, which is exactly the regime the PR targets.

Cold protocol on Windows

For anyone else measuring this on Windows: RAMMap64 -Et (Sysinternals) empties the standby list and is the practical equivalent of echo 3 > /proc/sys/vm/drop_caches. It needs no elevation and does not touch the server's private weights under --load-mode none.

It is not sufficient on its own: mmap'd PLE pages touched by a previous request live in the process working set and survive the flush. My first attempt showed a "cold" repeat running at 515 tok/s with 89 hard faults, versus 231 tok/s and 28,325 hard faults for the genuine first run. So every cold data point below is a fresh server process plus an emptied standby list.

Setup: -ngl 99 --n-cpu-moe 40 -c 32768 -np 1 -fa on -ub 2048 -ctk q8_0 -ctv q8_0 --load-mode none --lazy-mode {on|on-direct}, model on NVMe. Prompts are wikitext-2 (7,971 distinct words), cache_prompt:false, max_tokens:1. Two cycles at different corpus offsets, so the cold rows differ between cycles; warm is the identical request repeated immediately after the cold one.

Prefill, tok/s

prompt on (cycle 1 / 2) on-direct (cycle 1 / 2)
3,387 tok cold 179.9 / 196.4 428.1 / 450.1 +133%
warm 540.6 / 559.8 515.3 / 530.9 −4.9%
13,657 tok cold 230.2 / 233.5 495.4 / 500.5 +115%
warm 584.9 / 591.7 558.5 / 567.3 −4.3%
29,657 tok cold 260.0 / 259.2 495.2 / 497.1 +91%
warm 572.3 / 571.5 547.5 / 548.3 −4.2%

The cold gain decays with prompt length (+133% → +91%) as prefill compute grows against a roughly fixed PLE fetch cost, which matches @michal-zurkowski's Strix Halo trend. on-direct is also nearly flat cold→warm (495 vs 548 at 30k), i.e. largely insensitive to cache state.

Counters (cold, 29,657 tok)

on on-direct
hard faults (Memory\Page Reads) 220,164 / 215,628 297,165 / 289,488
server read syscalls (ReadOperationCount) 0 359,682
bytes read by those calls 31 MB (90.4 B/call)

Note the disk touches do not go down — both arms fetch a similar amount, and the 90 bytes per call is exactly one PLE row. What changes is that the fetches stop being serialized demand faults. That independently reproduces @nkoriyama's Linux finding (1 worker only ~1.15x, the worker pool another ~6.6x) on a completely different OS and I/O stack.

Revised recommendation

My earlier note said to keep on-direct opt-in on Windows. That was based on a measurement where the table could not be anything but warm, so please disregard it. On Windows the same rule applies as everywhere else in this thread: the direct path wins whenever the PLE table cannot stay in the page cache, and costs a consistent ~4–5% when it can. The warm regression is the only thing my Q3 numbers actually established (2–4% there, 4–5% here), and it is the same effect in both.

Happy to run further cells — this is a standing native-Windows install with both quants on disk.

TrevorS added a commit to TrevorS/llama.cpp that referenced this pull request Sep 4, 2026
…eam ggml-org#28136, adapted)

The per-layer n-gram table (26.8 GiB, IQ4_NL) stays a lazy mmap, and every
gathered row was a demand fault of a 4 KB page for a 90-byte row: the
threaded gather plus MADV_WILLNEED took a cold 37k real-text prefill from
211 to 368 t/s on GB10 and stopped there. coder543's reader (llama-lazy-
reader.h, taken as-is) serves the rows of a ubatch with explicit preads on
a thread pool from an independently opened descriptor advised
POSIX_FADV_RANDOM, dequantized to F32, so the table's pages are never
touched; they measured 300 -> 750-800 t/s on a Spark against the plain
mmap path.

Adapted to our tree: the model keeps a reader per PLE tensor
(load_lazy_reader), the PLE graph input carries either the row indices
(mmap path) or the staged F32 rows (direct path), set_input gathers on
the reader and hands the graph the same [ple_head_dim, n_heads * n_tokens]
F32 tensor ggml_get_rows would have produced, so nothing downstream
changes. The loader treats on-direct like on for the lazy decision.

Gated bit-identical against the mmap path (tiny-model logits, and the
real model's PPL 1.8175 on 10 chunks either way). Against our threaded
gather it loses: cold real-text prefill with the table evicted, GB10, P70,

    37k  mmap+threads+willneed 361.2 t/s   on-direct 330.2 t/s
    140k                       183.9 t/s             169.1 t/s

so mmap stays the default and on-direct is opt-in (-lzm on-direct); the
code is kept as upstream carries it so the next rebase takes it clean.
@mab776

mab776 commented Sep 5, 2026

Copy link
Copy Markdown

I just tested this PR because I can't wait to get this improvement on Qwen Next, I figured the numbers might be worth adding here.

TLDR: +167% cold prefill at 3k, decaying to +11% at 200k, and the warm cost stays under 6%

My setup is a bit different from what I've seen in the thread. Linux, Blackwell, two mismatched GPUs, and not much RAM.

  • RTX PRO 5000 Blackwell 48 GB + RTX 5070 Ti 16 GB, 61 GB system RAM, NVMe, CUDA 12.8.1
  • unsloth/Qwen3.8-Flash-Next-GGUF UD-IQ3_XXS, 82 GB on disk with a 28.8 GB PLE table
  • master@427291b5b + this PR (c6a9e5c9a, merged clean), .devops/cuda.Dockerfile --target server, CUDA_DOCKER_ARCH=120
  • -ngl 99 -sm layer -ts 47,14 -c 262144 -fa on -ctk q8_0 -ctv q8_0 --parallel 1

I have 64 GB of VRAM and 61 GB of RAM, so with a 82 GB model the PLE table can never stay cached. I think that's the normal case for people running this at home, not the exception.

Same binary for both test suites (auto and on-direct), only --lazy-mode flipped. Varied prompts (no repeated sentences), cache_prompt:false, n_predict:1. Every cold run is a fresh process plus sync; echo 3 > /proc/sys/vm/drop_caches. Warm tests are the same request fired again right after.

Cold prefill across depths. I used your three lengths so it lines up with the Windows numbers, then went to deeper context:

prompt auto on-direct
3,342 584.2 tok/s 1559.3 +167%
13,633 771.3 1734.8 +125%
29,637 900.0 1673.0 +86%
59,990 978.1 1463.9 +50%
129,955 864.1 1053.5 +22%
199,989 745.4 828.5 +11%

I ran the warm cache test at every depth too, because what I'd want to know as a reviewer is whether this hurts the common case for people whose table does fit in cache:

prompt auto warm on-direct warm
3,342 2018.7 tok/s 1956.4 -3.1%
13,633 1999.3 1886.6 -5.6%
29,637 1827.1 1718.2 -6.0%
59,990 1483.8 1457.0 -1.8%
129,955 1062.8 1060.3 -0.2%
199,989 836.4 836.1 -0.04%

So the warm cost is real but bounded, worst around -6% at 30k, and it basically disappears past 130k. That lines up with the 4-5% reported on Windows.

On the cold prefill, I get the same decay shape as @mjungnickel18's numbers, just carried further. The gain of on-direct keeps shrinking as prefill compute grows against a roughly fixed PLE fetch cost. At 130k it's down to +22% and at 200k to +11% (268.3s to 241.4s). Still worth having! But we cannot expect the 2x prefill speed improvement at long context.

On the Q3 vs Q4 correction above, I got caught by the same trap. My original benchmarks built context by repeating one sentence thousands of times and they gave me ~1818 tok/s. Real workloads on the same server gave ~609 tok/s. It was cache state. A repeated sentence keeps reading the same small part of the table, so it stays in cache and you only ever measure the warm case! Any benchmark built from repeated filler will show this PR doing nothing. Probably worth a line in the PR description, since repeating a sentence to build context is a pretty common shortcut.

Let me know if there are other tests you'd want on my hardware.

Thanks for this PR, it makes a real difference for local Qwen Next.

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

Labels

examples model Model specific

Projects

None yet

Development

Successfully merging this pull request may close these issues.