Skip to content

llama: batched readahead for lazily read gather tables - #137

Open
danielhanchen wants to merge 1 commit into
base/upstream-ca3d5a3e1from
prefetch/lazy-tensor-row-readahead
Open

llama: batched readahead for lazily read gather tables#137
danielhanchen wants to merge 1 commit into
base/upstream-ca3d5a3e1from
prefetch/lazy-tensor-row-readahead

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 28, 2026

Copy link
Copy Markdown
Member

Batched readahead for gather tables read through TENSOR_READ_LAZY.

Based on ggml-org/llama.cpp master ca3d5a3e1. unslothai/llama.cpp:master does not carry TENSOR_READ_LAZY or qwen4exp yet, so this PR targets base/upstream-ca3d5a3e1 (that upstream commit pushed as a branch) to keep the diff to the six files this change touches.

The problem

#27794 added TENSOR_READ_LAZY, which skips the eager pull-in for a large per-layer embedding table and marks its byte range MADV_RANDOM. That saves real memory, but MADV_RANDOM also switches off the kernel's own readahead, and nothing was put in its place. A sparse gather over the table then takes one synchronous fault per row.

Measured on the 26.8 GiB iq4_nl PLE table with a real 320,000 gather trace, no model and no GPU involved, median of three runs on an evicted page cache:

policy ns/gather major faults
no advice (kernel readahead on) 34,767 5,044
MADV_RANDOM only, what ships today 130,112 205,849
MADV_RANDOM plus this change 5,634 16

MADV_RANDOM on its own is 3.7x slower than leaving the mapping alone. This is the same effect ggml-org#27794's own gemma-4 table shows, where the MADV_RANDOM variant scored worse than skipping the prefetch alone.

The change

llama_mmap::prefetch_rows() takes the row indices a batch is about to gather, merges them to whole pages, and issues the reads as one batch: MADV_WILLNEED on POSIX, PrefetchVirtualMemory on Windows, no-op elsewhere. llama_model::prefetch_rows() finds the mapping holding the tensor and returns if there is none, so it is inert for anything not read out of a mapping. Both TENSOR_READ_LAZY users call it from set_input, which is far enough ahead of the gather for the reads to be in flight.

151 insertions, 1 deletion, six files. No ggml changes at all, so no new op and no backend work.

No new user-facing flag. The behaviour belongs to TENSOR_READ_LAZY, which already has --tensor-read-lazy; a second knob for the half that makes the first one usable would be the wrong shape.

Results

Cold page cache, three repetitions, llama-batched-bench -npp 512 -ntg 128 -npl 1, one B200. Four arms per repetition so that lazy off appears on both builds: those two agree within noise throughout, which is what makes the rest of the table attributable to this change alone.

qwen4exp, UD-IQ1_S, 26.8 GiB table

build lazy prompt t/s generation t/s peak RSS
base off 1810 / 1867 / 1853 93.9 / 94.1 / 93.2 71.3 GB
this PR off 1847 / 1835 / 1840 93.8 / 93.7 / 93.6 71.3 GB
base on 198 / 201 / 224 68.2 / 68.0 / 63.0 42.9 GB
this PR on 1397 / 1364 / 1337 93.0 / 92.8 / 92.3 42.9 GB

gemma-4-E4B-it Q4_K_M, 1.94 GB table, the model ggml-org#27794 was measured on

build lazy prompt t/s generation t/s peak RSS
base off 2420 / 2480 / 2598 207.5 / 207.6 / 208.5 5.4 GB
this PR off 2495 / 2433 / 2453 207.7 / 207.8 / 208.2 5.4 GB
base on 894 / 955 / 853 182.8 / 182.9 / 172.4 3.5 GB
this PR on 2522 / 2547 / 2564 180.3 / 191.1 / 193.2 3.5 GB

Prompt throughput returns to eager parity on both models while keeping the whole memory saving lazy reading buys: 28.4 GB on qwen4exp, 1.9 GB on gemma-4.

Correctness

Readahead cannot change arithmetic, and that is verified rather than assumed. Greedy generation from the base build and from this branch, same prompt, same file, --tensor-read-lazy on, is byte-identical on both models.

What this does not claim

  • Generation throughput is fully recovered on qwen4exp (92-93 against 93-94 eager). On gemma-4 the three runs are 180.3, 191.1 and 193.2 against a 172-183 baseline: the spreads overlap, so the honest reading is "not worse", not a recovery. Prompt throughput is the real result there.
  • Everything here is the cold-cache regime. Warm, the table is resident and every arm converges. This is a claim about first touch and about machines that cannot hold the table, which is the case the feature exists for, not a steady-state serving speedup.
  • One machine, one NVMe, Linux. The Windows path is written but has not been compiled or run.
  • Not applying MADV_RANDOM at all would also recover much of the loss, and is a smaller change. The gather benchmark says this is still 7.4x better than that (4,666 against 34,767 ns/gather), but that arm has not been measured at model level.

Warm cache: no measurable difference either way

Once the rows a workload touches are resident, the hints buy nothing, and the question is whether they cost anything. Five repetitions, same run repeated with no eviction, --tensor-read-lazy on on both builds:

build prompt t/s generation t/s
base 1769 / 1734 / 1717 / 1679 / 1761 92.99 / 93.55 / 93.77 / 92.11 / 92.72
this PR 1656 / 1672 / 1698 / 1720 / 1713 91.11 / 92.15 / 92.49 / 92.78 / 93.14

Means are 1732 against 1692 for prompt and 93.0 against 92.3 for generation, so roughly 2% and 1% in favour of the base build, but the ranges overlap and the ordering flips between repetitions. Five runs cannot resolve a difference this small. The honest statement is that warm behaviour is unchanged, with a possible small overhead from the extra madvise calls that would need a much longer run to measure.

Worth recording how that number moved: the first two repetitions alone showed a clean 5% loss with no overlap, and three more turned it into noise.

Where the memory actually goes

The peak RSS column above is the whole process, so it is worth separating. Page cache residency after a cold run, measured per file with mincore:

lazy off lazy on
the PLE table alone (26.82 GiB) 26.82 GiB, 100% 0.06 GiB, 0.23%
the rest of the model 40.75 GiB, 100% 40.75 GiB, 100%

Lazy reading removes essentially all of the table, and that is the whole 28.4 GB of RSS saving. What stays resident is the quantized weights, which are read through the mapping while being copied to the device and are left in page cache afterwards. That is a separate problem from this PR, and --load-mode is the lever for it.

The 0.23% figure is low because this benchmark decodes 640 tokens and touches few rows. A gather trace over 60,000 tokens of wikitext needs about 0.39 GB of page cache to serve half its gathers and 1.91 GB to serve 90%, so a long-running server settles at a few GB resident for the table rather than at zero.

TENSOR_READ_LAZY skips the eager pull-in for a large embedding table and
marks its range MADV_RANDOM. That also turns off the kernel readahead, so
a sparse gather over the table costs one synchronous fault per row.

Issue the reads for the rows a batch is about to gather, as one batch of
MADV_WILLNEED (PrefetchVirtualMemory on Windows), from set_input. Rows are
merged to whole pages first, so 16 gathers become a couple of hints.

Wired into the two TENSOR_READ_LAZY users, qwen4exp and gemma4. Hints only,
so results are unchanged.
@danielhanchen
danielhanchen requested a review from CISC as a code owner August 28, 2026 03:58
@danielhanchen

Copy link
Copy Markdown
Member Author

Placing the per-layer embedding table, and the full measurement set

Collecting everything measured around this patch, since the placement question comes up
independently of the readahead and the two interact.

Where the PLE table actually lives

per_layer_token_embd is a model level tensor, so -ngl 999 does not put it on the GPU. It
offloads repeating layers and the output head only. The table stays host side by default, exactly
like token_embd. There is no architecture specific rule doing this.

The verbose load says so directly, on UD-Q4_K_XL:

load_tensors: offloading 47 repeating layers to GPU
load_tensors:   CPU_Mapped model buffer size = 51880.13 MiB
load_tensors:        CUDA0 model buffer size = 78056.46 MiB

51880 MiB is 54.4 GB, the q8_0 PLE table to the byte.

So the four knobs, and what each one is worth:

flag effect
-ot "per_layer_token_embd=CPU" no-op, frees 4 MiB. It was already there
-ot "per_layer_token_embd=CUDA0" forces it onto the device. Costs 36.6 GB (Q2) or 51.9 GB (Q4) of VRAM for under 1% throughput. Do not do this
--tensor-read-lazy on|off|auto keeps it file backed and read on demand. auto is the default and enables it above 4 GiB
--load-mode none|mmap|mlock|dio how everything else is read. none is buffered read, no mmap

The reverse experiment is the informative one, because it is also the control proving -ot works on
this tensor at all:

CPU buffer CUDA0 buffer tg@0 tg@8192
Q2, default 37038 MiB 47322 MiB 73.80 65.42
Q2, PLE to GPU 417 MiB 83944 MiB 73.05 65.12
Q4, default 51880 MiB 78056 MiB 70.79 63.56
Q4, PLE to GPU 644 MiB 129937 MiB 70.91 62.58

Moving 36.6 GB and 51.9 GB across the bus changes throughput by under 1% in both directions. The
recommendation is therefore not "move the table to CPU", it is never force it to GPU: that is
the difference between a one card and a two card deployment for no measurable gain.

Why the batched readahead is needed

TENSOR_READ_LAZY applies MADV_RANDOM to the lazy ranges, which switches the kernel's own
readahead off, and nothing was put in its place. A sparse gather then costs one synchronous fault
per row.

tests/ple_gather_bench.c replays 320,000 real gather indices against the real 26.8 GiB iq4_nl PLE
byte range, evicting it before each arm. No model, no GPU, no graph. Median of three:

policy ns/gather major faults
no advice, kernel readahead active 34,767 5,044
MADV_RANDOM only, what mainline ships 130,112 205,849
MADV_RANDOM plus batched WILLNEED, 1 step ahead 5,634 16
MADV_RANDOM plus batched WILLNEED, 4 steps ahead 4,666 16

Note that plain MADV_RANDOM is 3.7x slower than leaving the mapping alone entirely, despite this
access pattern having zero page locality. Readahead was pulling 128 KB per fault and the Zipfian
reuse was hitting it.

Model level A/B

One binary per row group, cold cache, three reps, llama-batched-bench -npp 512 -ntg 128 -npl 1,
one B200. Four arms per rep so lazy=off appears on both builds: if the two eager arms
disagreed, the builds would differ by something other than this patch and nothing else in the table
could be attributed. They agree within noise throughout.

qwen4exp UD-IQ1_S, 26.8 GiB iq4_nl table

build lazy prompt t/s gen t/s peak RSS table resident after
master off 1810 / 1867 / 1853 93.9 / 94.1 / 93.2 71.3 GB 100%
patched off 1847 / 1835 / 1840 93.8 / 93.7 / 93.6 71.3 GB 100%
master on 198 / 201 / 224 68.2 / 68.0 / 63.0 42.9 GB 0.23%
patched on 1397 / 1364 / 1337 93.0 / 92.8 / 92.3 42.9 GB 0.23%

gemma-4-E4B-it Q4_K_M, 1.94 GB q5_K table, the model ggml-org#27794 was measured on

build lazy prompt t/s gen t/s peak RSS table resident after
master off 2420 / 2480 / 2598 207.5 / 207.6 / 208.5 5.4 GB 100%
patched off 2495 / 2433 / 2453 207.7 / 207.8 / 208.2 5.4 GB 100%
master on 894 / 955 / 853 182.8 / 182.9 / 172.4 3.5 GB 1.49%
patched on 2522 / 2547 / 2564 180.3 / 191.1 / 193.2 3.5 GB 1.49%

Lazy reading costs 7 to 9x prompt throughput on qwen4exp and 2.7x on gemma4. The patch returns
prompt throughput to eager parity on both while keeping the whole RSS saving, 28.4 GB and 1.9 GB.
Generation is fully recovered on qwen4exp and partly on gemma4, where the honest reading of
180 to 193 against a 182 baseline and 208 eager is "no worse, probably a little better", not a
recovery.

gemma4 also reproduces ggml-org#27794's own finding independently: -12% tg from lazy reading there, against
the -10.7% in that PR's table.

This is the default path, not an opt-in one

LLAMA_TENSOR_READ_LAZY_AUTO is the default and auto_min_size is 4 GiB. The smallest PLE tier
shipped is 26.8 GiB, so lazy reading is enabled for every published quant of this model with no
flag passed
. Measured on stock upstream ca3d5a3e1, cold, same harness:

invocation prompt t/s gen t/s VmHWM
no flags at all 204.57 68.78 42.9 GB
--tensor-read-lazy off 1846.25 93.95 71.3 GB

A 9.0x prompt regression out of the box. There is also no signal at default verbosity: neither
llama-cli nor llama-batched-bench prints anything about it, and the line only appears under
-v:

create_tensor: tensor per_layer_token_embd.weight (size = 27465 MiB) lazy read enabled

So without the readahead this presents as "the model is slow", not as a flag question.

Interaction with ggml-org#27837

On current master, --tensor-read-lazy is gated on use_mmap, so --load-mode none silently
discards it and the table goes into a 26.8 GiB anonymous buffer. ggml-org#27837 removes that gate. Stacking
it with this patch (ca3d5a3e1 plus a7cfd8864 plus 280e76452 plus 4e1865e34, all cherry-picks
clean, only src/llama-model.cpp auto-merged) makes the useful combination reachable.

Full matrix, cold cache, three reps, one binary, UD-IQ1_S:

load mode lazy prompt t/s gen t/s VmHWM PLE resident weight page cache
none off 1823 / 1774 / 1865 91.9 / 92.6 / 92.7 29.4 GB 26.8 GiB (100%) 21.0 GiB
none on 1482 / 1541 / 1514 87.2 / 90.9 / 85.1 1.4 GB 0.06 GiB (0.2%) 21.0 GiB
mmap off 1733 / 1797 / 1842 91.5 / 93.2 / 92.7 71.3 GB 26.8 GiB (100%) 21.0 GiB
mmap on 1192 / 1325 / 1309 91.3 / 90.6 / 91.2 42.8 GB 0.06 GiB (0.2%) 21.0 GiB

Three things worth drawing out. Process memory falls to 1.4 GB, 21x below the same load mode
without lazy and 51x below plain mmap. --load-mode none beats mmap on both lazy settings and by
more when lazy is on, 1514 against 1309, since the mmap arm pays soft faults on the weights on top
of the PLE's hard ones. And none plus lazy off is the trap in that table: it reads as the fast
row at 1823 but it is the row where the flag was accepted and ignored and the table is in RAM.

What the page cache actually does over time

The 0.2% residency above is what a 640 token run touches, not a steady state. Nothing in this patch
evicts anything. Cold evict the region, then run llama-perplexity over 40 chunks of wikitext-2:

point tokens PLE resident % of 26.82 GiB
cold start 0 0.00 GiB 0.00%
+45 s ~35k 2.16 GiB 8.07%
end 81,920 2.87 GiB 10.68%

Residency grows and is never dropped, but sublinearly: 2.16 GiB in the first 45 s, only +0.71 GiB
over the remainder. The hot core fills quickly and new pages get rarer, so it approaches the working
set rather than the tensor size.

MADV_DONTNEED is deliberately absent. With roughly 24x read amplification on this pattern,
dropping a page you are about to need again converts a one time cost into a recurring one. Leaving
reclaim to the kernel's LRU keeps the hot core resident and the cold 90% on disk, and those pages
are clean and file backed so they are reclaimed first under pressure.

The access pattern this is built for

scripts/ple_access_pattern.py resolves gathers to 4 KB pages over 297,053 tokens of wikitext:

table 13.3M pages, 54.4 GB
gathers 4,752,848
distinct pages touched 2,356,128
consecutive gathers on the same page 0 (0.0000%)
mean gathers per touched page 2.02

Not one of 4.75M consecutive pairs shared a page, which is structural: the 16 heads sit at offsets
20M rows apart and within a head the index is hash % vocab. Every access is an independent 4 KB
fetch for about 170 useful bytes. That is why sequential readahead cannot help and why the hints
have to be batched per gather instead.

The temporal side is what makes it workable anyway: 50% of gathers are served by 1.23 GB of cache,
90% by 7.70 GB.

Correctness

These are hints and cannot change arithmetic, but that is verified rather than asserted. Greedy
generation, same prompt, same file, --tensor-read-lazy on, master against the patched build:
byte identical on both qwen4exp and gemma4. Repeated across the stacked build under all three
of none + lazy, mmap + lazy and none + eager: also byte identical, with the first divergence
at char 510 of 556 being the CLI's own throughput footer.

Practical guidance, UD-IQ1_S (72.5 GB total: 28.8 GB PLE plus 43.7 GB weights)

hardware without this patch with it
128 GB Mac, 5090 plus 64 GB RAM --tensor-read-lazy off, fast, 71.3 GB either, lazy on frees 28 GB
64 GB Mac no good option: eager needs 71.3 GB, lazy gives 204 t/s lazy on, about 1400 t/s at 42.9 GB
16 GB GPU plus 32 GB RAM none --load-mode none --tensor-read-lazy on, also needs ggml-org#27837

Per quant tier, total / PLE / weights in GB, for sizing:

IQ1_S    72.5 / 28.8 / 43.7      Q4_K_XL  111.3 / 28.8 / 82.5
IQ1_M    74.5 / 28.8 / 45.7      Q5_K_XL  158.3 / 54.4 / 103.9
Q2_K_XL  78.9 / 28.8 / 50.1      Q6_K_XL  169.2 / 54.4 / 114.8
IQ3_XXS  82.0 / 28.8 / 53.2      Q8_0     188.2 / 54.4 / 133.8
Q3_K_XL  90.0 / 28.8 / 61.2      BF16     354.0 / 102.4 / 251.6
IQ4_XS   93.7 / 28.8 / 64.9

What is not claimed

Warm cache shows no effect. The first two reps looked like a clean 5% regression with no
overlap; three more made it noise, means 1732 against 1692 prompt t/s with ranges overlapping and
the ordering flipping. Everything above lives in the cold regime, which is the regime the feature
exists for.

The "skip prefetch but do not apply MADV_RANDOM" arm was never run at model level. It needs a
second gate. The microbenchmark says this patch beats it 7.4x, 4,666 against 34,767 ns/gather, but
that is not a model level number.

Partial offload numbers are upper bounds. The offload sweep on UD-IQ1_S with lazy on gives
48/48 layers 1679 prompt and 93.3 gen, 30/48 gives 321 and 23.7, 16/48 gives 275 and 14.6, 0/48
gives 191 and 6.3, but all of those had the CPU side weights fully cached.

Two harness failures worth recording

The first microbenchmark run reported this patch as worthless, 14.8 s against 13.3 s with identical
major fault counts. madvise needs a page aligned address and the page numbers were being counted
from the region rather than from the mapping, so every call returned EINVAL and the arm silently
degraded into the plain MADV_RANDOM it was being compared against. The published result would have
been a confident null. There is now an advise_failed counter that aborts the arm.

Separately, the first byte identity check returned four matching md5s, all of them
d41d8cd98f00b204e9800998ecf8427e, which is the md5 of the empty string. -no-cnv no longer exists
on master, every arm errored, and 2>/dev/null ate the message. Four matching hashes is exactly
what a pass looks like.

@danielhanchen

Copy link
Copy Markdown
Member Author

Addendum: ggml-org#27837 measured on its own, without this patch

The matrix in the previous comment was run entirely on the stacked build, so every row of it
carried this patch. That left one case unmeasured, and it is the one that decides whether this
patch still matters if ggml-org#27837 lands first. Filling it in.

temp/wt_27837only is commit 5f1367ef3, which is ca3d5a3e1 plus a84c8a600 plus 280e76452
(ggml-org#27837), with prefetch_rows confirmed absent from the tree. Build 10667. Same harness as before:
cold cache, three reps, llama-batched-bench -npp 512 -ntg 128 -npl 1, UD-IQ1_S, one B200.

ggml-org#27837 alone

load mode lazy prompt t/s gen t/s VmHWM PLE resident
none off 1562 / 1712 / 1822 91.0 / 92.2 / 93.3 29.4 GB 100%
none on 358 / 349 / 352 66.3 / 64.7 / 66.4 1.3 GB 0.2%
mmap off 1812 / 1810 / 1804 93.3 / 93.2 / 93.3 71.3 GB 100%
mmap on 332 / 339 / 344 65.1 / 64.3 / 59.9 42.9 GB 0.2%

Side by side with the stacked build

Both builds share the same eager rows, which is the control: none/off at 1562 to 1822 against
1774 to 1865, and mmap/off at 1804 to 1812 against 1733 to 1842. They agree within noise, so the
two builds differ by this patch and nothing else that the benchmark can see.

configuration ggml-org#27837 alone ggml-org#27837 plus this patch ratio
--load-mode none --tensor-read-lazy on 352 PP, 65.8 TG 1514 PP, 87.7 TG 4.3x prompt, 1.33x gen
--load-mode mmap --tensor-read-lazy on 338 PP, 63.1 TG 1275 PP, 91.0 TG 3.8x prompt, 1.44x gen

RSS is identical between the two builds in both rows, 1.3 to 1.4 GB and 42.9 GB, so the readahead
costs nothing in memory. It only recovers throughput.

Correction to an earlier estimate

I previously described ggml-org#27837 without this patch as landing "at about 200 t/s prompt". That was an
inference from master's mmap plus lazy figure of 198 to 224, not a measurement, and it was too
pessimistic by about 1.7x. The measured value is 349 to 358.

The reason is worth stating because it is a point in ggml-org#27837's favour: ggml-org#27837 improves the lazy
path on its own
, from master's 198 to 224 up to 332 to 344 in the same mmap plus lazy
configuration, roughly 1.6x, before any readahead is involved. Its dedicated context and buffer
type handling for lazy tensors is doing real work.

That does not change the conclusion, and arguably sharpens it. ggml-org#27837 makes the low memory
configuration reachable at all: --load-mode none --tensor-read-lazy on goes from silently
discarding the flag and putting 26.8 GiB in an anonymous buffer, to 1.3 GB of RSS with the table on
disk. But at 352 t/s prompt against 1822 eager, that configuration is still paying a 5.2x penalty
against having the table in RAM, and this patch removes most of it, taking it to 1514.

So the two changes are complementary rather than alternatives. ggml-org#27837 decides whether the table
can stay on disk. This patch decides whether that is worth doing.

@danielhanchen

Copy link
Copy Markdown
Member Author

How to keep the PLE table on disk while everything else stays in RAM and VRAM

The target configuration, stated plainly: every tensor except the per-layer embedding table is
read straight into ordinary buffers and device memory with no mmap, and the PLE table alone stays
on disk and is paged in on demand. On this model that is 26.8 GiB of the file that never needs to
be resident.

The command is:

llama-cli -m model.gguf -ngl 999 --load-mode none --tensor-read-lazy on

It needs both ggml-org#27837 and this patch. They are not alternatives and neither
one is sufficient. Measured on UD-IQ1_S, one B200, cold page cache, three repetitions,
llama-batched-bench -npp 512 -ntg 128 -npl 1:

build prompt t/s gen t/s VmHWM PLE resident
master today flag silently ignored 29.4 GB 100%
this patch alone flag silently ignored 29.4 GB 100%
ggml-org#27837 alone 358 / 349 / 352 66.3 / 64.7 / 66.4 1.3 GB 0.2%
ggml-org#27837 plus this patch 1482 / 1541 / 1514 87.2 / 90.9 / 85.1 1.4 GB 0.2%

For reference, the same build with the table in RAM (--load-mode none --tensor-read-lazy off)
runs 1823 / 1774 / 1865 prompt and 91.9 / 92.6 / 92.7 generation at 29.4 GB. So the finished
configuration gives up about 17% of prompt throughput and 3% of generation to move 26.8 GiB off the
heap.

Why each half is required

ggml-org#27837 decides whether the table can stay on disk at all. On master --load-mode none sets
use_mmap = false (llama-model-loader.cpp:559), and the lazy gate requires it:

if ((flags & TENSOR_READ_LAZY) && use_mmap && tensor_read_lazy != LLAMA_TENSOR_READ_LAZY_OFF) {

so --tensor-read-lazy on is accepted and discarded, with no error and no log line at default
verbosity. The table lands in a 26.8 GiB anonymous buffer. ggml-org#27837 gives lazy tensors their own ggml
context and maps that context whatever the load mode:

// a lazy context is mapped whatever the load mode, but the memory-fit pass maps nothing
const bool is_lazy_mapped = ctx_key.lazy && !ml.no_alloc;
if ((ml.use_mmap || is_lazy_mapped) && use_mmap_buffer && buffer_from_host_ptr_supported && is_default_buft) {

That is the split the configuration depends on: two contexts, two placement policies, one model.

This patch decides whether that is worth doing. Lazy reading applies MADV_RANDOM, which turns
the kernel's readahead off and puts nothing in its place, so each gathered row costs a synchronous
fault. At 352 t/s against 1822 eager that is a 5.2x penalty for keeping the table on disk. The
batched readahead here removes most of it, 4.3x on prompt and 1.33x on generation, and RSS is
identical between the two builds in every row, 1.3 against 1.4 GB. It buys throughput and costs no
memory.

This patch alone changes nothing in this configuration, and that is structural rather than a
measurement. llama_model::prefetch_rows walks the model's mappings looking for one that contains
the tensor. Without ggml-org#27837 there is no mapping under --load-mode none, so it returns immediately.

What this does not require

-ot per_layer_token_embd.weight=CPU is not needed and does nothing here. The table is a
model-level tensor, so -ngl 999 already leaves it host side, and under ggml-org#27837 a lazy tensor
returns its buffer type before the override list is consulted
(llama-model-loader.cpp:1204 against :1227), so the pattern is unreachable. Measured separately,
the override frees 4 MiB, because the tensor was never on the GPU. The reverse experiment is the one
with content: forcing the table onto the device costs 52 GB of VRAM and changes throughput by under
1% in both directions.

Master also warns about this from the other side, which is worth quoting because it agrees with the
recommendation above:

llama_model_loader: tensor overrides to CPU are used with mmap enabled -
consider using --load-mode none for better performance

If you want the weights genuinely pinned

--load-mode none is a buffered read into ordinary buffers, not mlock. The pinned form is
--load-mode mlock --tensor-read-lazy on, and the code already separates the two cases correctly:
use_mlock is set for that mode (llama-model.cpp:1392) and applied to the allocated host buffer
(:1757), while the lazy tensor is explicitly exempted in load_all_data:

// locking a lazy tensor would fault all of it in, which is what lazy avoids
if (lmlocks && !lazy.has(cur)) {

So mlock pins everything except the PLE table, by design. I have not benchmarked that combination
and am not claiming numbers for it; the reading above is from the source, and every figure in this
comment came from --load-mode none.

Recommendation today

ggml-org#27837 is still open, so the configuration above is not reachable on master. Until it lands the
usable form is --load-mode mmap --tensor-read-lazy on, which still holds the table at 0.2%
residency but maps the weights too, giving 42.8 GB VmHWM and about 15% less prompt throughput than
the none variant. With this patch that arm runs 1192 / 1325 / 1309 prompt against master's
198 / 201 / 224.

Correctness is unaffected in every case. The readahead is a hint and cannot change arithmetic;
greedy output is byte-identical with and without it, and byte-identical across all four load
mode and lazy combinations.

@danielhanchen

Copy link
Copy Markdown
Member Author

Exactly which tensors this affects, and which it does not

A question worth answering in the thread, because the flag name suggests something broader than
what it does: --tensor-read-lazy on does not make the token embeddings or the LM head lazy.
It is not a global switch. It is a mode that gates tensors the model code has explicitly marked
with TENSOR_READ_LAZY, and there are exactly two such create_tensor sites in the whole tree:

src/models/qwen4exp.cpp:140   per_layer_token_embd
src/models/gemma4.cpp:53      per_layer_tok_embd

Both are per-layer embedding tables, which is also why this patch has only those two call sites.
In the same qwen4exp function, a few lines above:

tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), { n_embd, n_vocab }, 0);
output   = create_tensor(tn(LLM_TENSOR_OUTPUT,     "weight"), { n_embd, n_vocab }, TENSOR_NOT_REQUIRED);

Flags 0 and TENSOR_NOT_REQUIRED. Neither is eligible.

The 4 GiB threshold is a filter, not a selector

This is the part that is easy to read backwards. The size test only ever removes tensors from the
lazy set; it never adds one. The outer gate is the flag:

if ((flags & TENSOR_READ_LAZY) && use_mmap && tensor_read_lazy != LLAMA_TENSOR_READ_LAZY_OFF) {
    constexpr size_t auto_lazy_min_size = 4ull * 1024 * 1024 * 1024;
    if (tensor_read_lazy == LLAMA_TENSOR_READ_LAZY_ON || ggml_nbytes(cur) > auto_lazy_min_size) {

ggml-org#27837 restructures this into lazy_read::add and drops the use_mmap term, but keeps the same
shape: the flag decides eligibility, the size only filters within it.

mode which tensors become lazy
off none
auto (default) flagged and larger than 4 GiB
on flagged, any size

An unflagged tensor is never lazy at any size. A hypothetical 5 GiB expert tensor stays fully
resident.

Measured on UD-IQ1_S

The load emits exactly one lazy read enabled line for the whole model:

add: tensor per_layer_token_embd.weight (size = 27465 MiB) lazy read enabled

If the embeddings or the head were eligible there would be three. The resulting placement, with
--load-mode none -ngl 999:

load_tensors:        CUDA0 model buffer size = 41368.28 MiB
load_tensors:    CUDA_Host model buffer size =   341.02 MiB
load_tensors:   CPU_Mapped model buffer size = 27465.95 MiB

and the three tensors in question, read out of the GGUF:

tensor type size how it is read
token_embd.weight Q4_K 341.02 MiB eagerly, fully resident
output.weight Q4_K 341.02 MiB eagerly, fully resident
per_layer_token_embd.weight IQ4_NL 26.82 GiB mmap-backed, demand paged

Both 341.02 MiB tensors are read in full; one sits in the pinned CUDA_Host buffer and the other
inside the 41368.28 MiB CUDA0 allocation. Only CPU_Mapped is the lazy one. Of 1224 tensors in
this file, 1223 are read eagerly and the largest of them is 0.44 GiB
(blk.N.ffn_down_exps.weight).

Note that in this particular file the only tensor over 4 GiB is also the only flagged one, so
auto and on happen to agree and the file cannot distinguish the two rules. Where they come
apart is gemma-4-E4B: its per-layer table is 1.94 GB, flagged but under the threshold, so auto
skips it and only on engages it. That is the practical reason to pass --tensor-read-lazy on
explicitly rather than relying on the default.

A control for the -ot claim in the previous comment

The earlier comment said -ot per_layer_token_embd.weight=CPU is unreachable when the tensor is
lazy, on the strength of the source ordering. Here is the measurement, with the control that makes
the null result mean something. Same build, same file, cold cache:

arm lazy read enabled override log line buffer for the table VmHWM
lazy on, no -ot yes none CPU_Mapped 27465.95 MiB 2.04 GB
lazy on, -ot ...=CPU yes none CPU_Mapped 27465.95 MiB 2.04 GB
lazy off, -ot ...=CPU no buffer type overridden to CPU CPU 27465.95 MiB 30.16 GB

The third row is the point. A null result from row 2 on its own is indistinguishable from a
pattern that never matched or a flag that was misspelled. Row 3 passes the identical string and the
override line appears, so the flag, the pattern and the code path are all live. They are simply not
reached when the tensor is lazy.

Three independent signals agree: the debug line, the buffer name (CPU_Mapped is the mmap-backed
buffer_from_host_ptr path, plain CPU is an allocated buffer the data was copied into), and
28.1 GB of peak RSS, which is the table. Rows 1 and 2 differ by 484 KB, which is noise.

danielhanchen added a commit that referenced this pull request Aug 31, 2026
…161)

* unsloth: repin ggml-org#25731 to the commit that merges onto b10705

edee0e1 stopped applying to the current base tag, which failed the pin
preflight. Because the preflight stops at the first conflict, it also meant the
two AMD fixes pinned by #160 were never dry-run merged at all.

44eb88e is the head after the upstream conflicts were resolved; the PR is
MERGEABLE upstream again as of 2026-08-31T08:06Z.

* unsloth: repoint the AMD pins at commits based on an upstream ancestor

#157 and #158 were branched from fork master, which is right for a small PR
diff and wrong for a pin. Fork master has diverged from upstream (it deletes a
dozen upstream workflow files), so merging those commits onto the base tag
dragged the whole divergence in and conflicted modify/delete on twelve
.github/workflows files. That is what exited the preflight with no message
after 'ok #137'.

The pins that work are branched from an upstream commit that is an ancestor of
both master and the base tag, so they carry only their own delta. Both branches
are now rebased onto 11cd988 on that pattern, and verified locally to merge
onto b10705 for a net +35/-3 across 4 files, which is the two fixes and nothing
else.

---------

Co-authored-by: danielhanchen <elliegouldingstuff@gmail.com>
danielhanchen added a commit that referenced this pull request Aug 31, 2026
* unsloth: pin ggml-org#27941, #152 and #154

ggml-org#27941 at 02eb201, no longer a draft and MERGEABLE upstream. It fixes
four qwen4exp correctness defects and is the only account of the Flash-Next
reporter who is on Vulkan only, where the allocator variable is never set. The
previous pin PR #148 carried the stale 8161d11 and is closed.

#152 at 258345e and #154 at 31e432e both merge onto b10708 on their own.

#137 is already pinned at 4e1865e, which is still its head, so no change.

#142 and #144 are NOT pinned here: both predate an upstream refactor that folded
lazy_mode and model_shared into a lazy struct, so they conflict on src/llama.cpp
and src/llama-model-loader.cpp against b10708. b10708 has 'ml.lazy.mode =
params.lazy_mode' where those branches still write 'ml.lazy_mode' and
'ml.model_shared'. They need rebasing onto current upstream before they can be
pinned; pinning them now would fail the resolve.

* unsloth: repin ggml-org#27941 to 6b2b85c

The PR moved on at 10:44Z, after 02eb201 was pinned. Verified to merge onto the
current base tag: 6 files changed, 480 insertions, 138 deletions.

* unsloth: pin #144, rebased, and leave #142 out as contained in it

#144 rebased onto b10709 at 6fc8df1. Two of its nine commits were dropped
rather than carried: 'qwen4exp: fix QSA correctness defects and harden metadata
loading' and the comment tidy on top of it. That work is what ggml-org#27941
supersedes, and keeping both copies is what made the two pins conflict in
llama-kv-cells.h and llama-memory-hybrid-idx.cpp. Listed after ggml-org#27941 so the
upstream version of that work lands first.

#142 is deliberately NOT pinned: its single commit is byte-identical to #144's
c7bd6f2 apart from the lazy API spelling, so #144 already contains it and
pinning both would apply the same change twice.

Verified: b10709 + ggml-org#27941 + #144 merges clean, 24 files, +612/-94.

* unsloth: repin ggml-org#27754 to 949f7ef

The PR moved on at 10:39Z; 5796547 was pinned by #159 earlier today. Verified
to merge onto b10709: 44 files changed, 2673 insertions, 38 deletions.

Every entry in the set is now at its PR's current head.

---------

Co-authored-by: danielhanchen <elliegouldingstuff@gmail.com>
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