Skip to content

perf(io): read expert slices straight into the cache, with no bounce copy - #143

Draft
Helldez wants to merge 7 commits into
mainfrom
perf/odirect-zero-copy
Draft

perf(io): read expert slices straight into the cache, with no bounce copy#143
Helldez wants to merge 7 commits into
mainfrom
perf/odirect-zero-copy

Conversation

@Helldez

@Helldez Helldez commented Aug 1, 2026

Copy link
Copy Markdown
Owner

What

--odirect-zero-copy (off by default): expert slices are read straight into the cache buffers, with no bounce buffer and no copy. Measured on device: +20% tok/s, identical bytes read, byte-identical output.

This is not a decode feature — every expert read in the engine goes through this path, so it applies to the plain streaming baseline, expert dropping, the prefetchers, everything.

Why the copy existed

O_DIRECT requires the file offset, the transfer length and the buffer address to be aligned. Expert slices are a whole number of pages long, but a gguf's tensor data does not begin on a page boundary — general.alignment is 32 by default, and on the model family measured here every expert offset lands a constant 1152 bytes past one:

offset 898471040  mod 4096 = 1152
offset 900830336  mod 4096 = 1152
offset 903779456  mod 4096 = 1152      (every read in a 12k-read trace)

So the reader could not ask the kernel for the payload directly. It pulled the enclosing aligned window — which starts 1152 bytes early — into a per-lane bounce buffer, then memcpy'd the payload back by that remainder so it landed where mul_mat_id looks for it.

The copy was therefore never a safety measure. It was a shift correction, costing 200+ MiB a token (~450 MB/token of DRAM traffic once the write side is counted) on a device whose decode is already memory-bound.

Why it can go

The shift is only needed because the destination does not share the file's remainder — and it can. This engine reserves the per-layer buffers itself (vm_reserve, then tensor->data is rebound), so it controls their addresses. With the flag on, each buffer is placed at an address carrying its own tensor's remainder. Every expert inherits it, because the per-expert stride is a multiple of the page size. The aligned window then maps onto the buffer at the same relative position and the read goes straight in.

The overhang, and why it is safe by construction

A read now writes up to shift bytes before its slice and the rest of a page after it — bytes belonging to the neighbouring experts. Three facts make that sound rather than lucky:

  1. Under this placement buffer and file differ by a constant offset, so the overhung bytes receive their own correct file contents. Nothing is corrupted even in principle.
  2. The written range is exactly the range the entry's page commit already covers (round_down(dst)round_up(dst+slice)), so no uncommitted page is ever touched. This falls out of the arithmetic, not from a new invariant.
  3. Eviction has always released only pages fully contained in a slice, never one shared with a neighbour — so a boundary page cannot be decommitted under a lane that is writing it.

A compute thread reading a neighbour's boundary concurrently sees identical values.

Results (device, Qwen3.6-35B-A3B Q4_0, cache 2000 MiB, overlap, 4 lanes)

Interleaved cells, so thermal drift hits both configurations equally:

cell tok/s compute stall read
off (pass a) 3.90 0.161 0.067 230.77 MiB/tok
zero-copy (pass a) 4.43 0.142 0.055 230.77 MiB/tok
off (pass b) 3.40 0.200 0.062 230.77 MiB/tok
zero-copy (pass b) 4.37 0.144 0.054 230.77 MiB/tok

+20% median (3.65 → 4.40), with the win landing exactly where the mechanism predicts: the compute residual falls 0.180 → 0.143 s/token. Bytes read are identical to the byte in every cell, and the generated text is byte-identical with the flag on and off (greedy, same prompt, same md5) — which is the correctness proof.

The phone was warm and its own baseline drifted 3.90 → 3.40 across the run, so the ratio is the claim, not the absolutes. Note the last and hottest cell (zero-copy, pass b) still beats the first and coolest baseline cell by 12%.

On the host gates

The gates cannot prove this path, and the PR does not pretend otherwise: on a tiny test model the per-expert stride is not a multiple of the page size, so the placement declines and G8 / G4e demonstrate only that the flag is harmless. Rather than let a silent no-op read as a pass, the engine states which happened:

bmoe: odirect-zero-copy ON — 120/120 layer buffers placed to skip the bounce copy   (device, real model)
bmoe: odirect-zero-copy INERT — 0/12 layer buffers placed to skip the bounce copy   (host, tiny model)

The device run above is the proof. Gates 7/7.

Fallbacks

Back to the bounce whenever the remainder would leave the tensor under-aligned for the compute kernels (below 64 bytes), the per-expert stride is not a multiple of the alignment, or O_DIRECT was refused at open. Buffered mode already read straight into the destination and is untouched.

Scope

Off by default pending a confirmation run on a cool device. Docs: docs/moe-streaming.md, CHANGELOG under Unreleased. New CSV preamble key odirect_zero_copy=.

@Helldez

Helldez commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Correction: the +20% above was measured where the bug could not show

Running this in the demo app — long session, several turns — it measured slower than the baseline, not faster:

tok/s major faults / token
flag off 5.02 / 5.58 1.2 / 6.7
flag on 4.57 / 4.41 66 / 332

Reversing the order of the runs changed nothing, so it is neither warm-up nor thermal drift. The fault count rises turn over turn (66 → 332), which is the shape of a leak rather than of a fixed cost, and the process's swap grew 274 → 364 MiB across the session while the flag-off control stayed flat at 272.

Cause

The placement moves each layer buffer off the page boundary on purpose. That makes the first and last page of every expert slice shared with the neighbouring expert — where the aligned placement had no partial pages at all.

Eviction releases only the pages an entry fully covers, and it must: it may never free a page a neighbour is still using. Under the aligned placement that rule was exact, because "fully covered" meant all of them. Under the shift it leaks two pages per eviction — bytes the cache counts as freed that the kernel still holds.

The arithmetic matches the measurement: commit covers 145 pages per slice, release covers 143, and a turn evicts ~5200 entries → ~41 MiB a turn, against a measured swap growth of 9 MiB in the first turn and 44 in the second.

Fix

Eviction now also takes a boundary page once the neighbour sharing it is gone — not resident (cvalid_) and with no speculative read outstanding (spec_remaining_). Both are read without the lanes' mutex, which is safe in the only direction that matters: staging happens on the eval thread, so nothing raises them concurrently, and a lane can only lower spec_remaining_ toward zero — a stale read is stale-high and merely defers a release to the next eviction. The outermost pages of a buffer border its own padding, which nobody else owns.

Why the original measurement missed it

Every device run behind the +20% was short, single-turn, in a fresh process (24-40 tokens). A leak has no time to accumulate there. Only the app's long session shows it — the trap this project had already recorded once, that a single-shot bench cannot see a reclaim problem.

Neither number in this PR is settled. The +20% and the regression were both measured against a leaking build; the headline needs re-taking on a long app session before it means anything. The flag stays off by default.

Helldez added 6 commits August 1, 2026 23:46
…copy

O_DIRECT wants the file offset, the length and the buffer address aligned.
Expert slices are a whole number of pages long, but a gguf's tensor data does
not start on a page boundary: general.alignment is 32 by default, so on the
measured model family every expert offset sits a constant 1152 bytes past one.
The reader therefore pulled the enclosing aligned window into a per-lane bounce
buffer and memcpy'd the payload back by that remainder — over 200 MiB a token
of pure shift correction, on a device whose decode is already memory-bound.

That shift is only needed because the destination did not share the file's
remainder. It can: this engine reserves the per-layer buffers itself and rebinds
tensor->data onto them. With --odirect-zero-copy each buffer is placed at an
address carrying its own tensor's remainder; every expert inherits it because
the per-expert stride is a multiple of the page size, so the aligned window maps
onto the buffer in place and the read needs no copy anywhere.

The window overhangs its neighbours' slices. That is safe by construction, not
by luck: under this placement buffer and file differ by a constant offset, so
the overhung bytes receive their own correct file contents, and they land in
pages the entry's commit already covers exactly — eviction never releases a page
shared with a neighbour, which was already true before this change.

Measured on device (Qwen3.6-35B-A3B Q4_0, cache 2000 MiB, overlap, 4 lanes,
interleaved cells so thermal drift hits both): +20% tok/s, 3.65 -> 4.40 median,
compute residual 0.180 -> 0.143 s/token, identical bytes read (230.77 MiB/token
in every cell) and byte-identical generated text with the flag on and off. The
phone was warm and its baseline drifted 3.90 -> 3.40 across the run, so the
ratio is the claim, not the absolutes; a cool-device confirmation is owed, which
is why the flag ships off.

This is not specific to any decode feature — every expert read goes through this
path, and so does the dense loader's.

The host gates cannot prove the path: a tiny test model's per-expert stride is
not a multiple of the page size, so the placement declines and G8/G4e show only
that the flag is harmless. Rather than let that read as a pass, the engine
reports which happened (odirect-zero-copy ON|INERT — n/m layer buffers placed).
The device run above is the real proof. Gates 7/7.
…phone

The engine flag is useless in the app without a way to turn it on, and the
device A/B this change still owes has to be run through the app as well as the
CLI. The switch sits under Direct I/O and greys out without it, since O_DIRECT
is what forces the bounce copy in the first place.

Worded as a pure speed knob, because it is one: unlike expert dropping or
route-ahead it is lossless by construction — the same bytes land in the same
places, and the generated text is byte-identical with it on and off.

It joins the session signature, so flipping it reopens the session rather than
silently carrying the old setting into the next generation.
Deciding the zero-copy path from the destination's alignment alone was wrong,
and it broke the dense loader: it reads through its own FileReader into
separately sized per-tensor buffers, and the aligned window is longer than the
tensor it was asked for. The most ordinary case of all triggers it — a
page-aligned tensor offset read into a page-aligned buffer matches the remainder
test with shift 0, then overruns the buffer by up to a page. On device with
--dense-weights ahwb (the app's default, which the earlier CLI runs did not use)
this surfaced as `pread failed` and a failed session open.

read() now takes allow_in_place, which is a promise: the pages either side of the
request, out to the enclosing alignment boundaries, are the caller's to clobber.
Only the expert cache can make it, and only for the buffers actually placed for
it — recorded per buffer rather than re-derived, because a remainder of zero is a
valid placement and must not be confused with a declined one. That distinction
was a second latent bug: with the remainder zero the extra page was not reserved,
so the last expert's window would have run past the end of the reservation.

G9 gates the combination that failed (zero-copy + the anon dense loader). It
bites on any model, unlike G8/G4e, whose expert-side placement goes inert on a
tiny test model. Verified on device with the exact failing configuration: 120/120
buffers placed, generation byte-identical with the flag on and off, 3.39 -> 4.00
tok/s in the same pair. Gates 7/7.
The zero-copy placement moves each layer buffer off the page boundary on
purpose, which makes the first and last page of EVERY expert slice shared with
the neighbouring expert. Eviction releases only the pages an entry fully
covers — correct, and previously exact, because the page-aligned placement had
no partial pages at all. With the shift it leaks two pages per eviction: the
cache accounts the bytes as freed while the kernel still holds them.

Measured in the app, where a session evicts thousands of entries and lives long
enough for it to accumulate: the process's anonymous footprint grew ~40 MiB a
turn and went to zram, and the decode paid it back as major faults — 1-7 per
token without the flag, 66 on the first turn with it and 332 on the second,
rising turn over turn, which is the shape of a leak rather than a cost. Net
effect in the app: 5.02-5.58 tok/s without, 4.41-4.57 with. Reversing the run
order changed nothing, so it was not warm-up or thermal drift.

A boundary page is now released as well, once the neighbour sharing it is gone —
`cvalid_` covers demand reads (an entry is valid from the moment it is staged)
and `spec_remaining_` covers speculative ones. Both are read without the lanes'
mutex, safe in the one direction that matters: staging happens on this thread so
nothing raises them concurrently, and a lane can only lower spec_remaining_
toward zero, so a stale read is stale-high and merely defers a release to the
next eviction. The first and last expert of a buffer border its own padding,
which nobody else owns.

Why the earlier device runs missed it: they were short, single-turn, in a fresh
process — 24 to 40 tokens, where the leak has no time to accumulate. The app's
long session is the only place it shows, which is the trap this project already
recorded once: a single-shot bench cannot see a reclaim problem.

Gates 7/7 on the host. The claim this branch carries still needs re-measuring in
the app, on a long session, before its headline number means anything.
The +20% in this branch was measured with short single-turn CLI runs, which is
exactly the regime where a page leak cannot accumulate. In a long app session
the same build measured slower than the baseline until the boundary pages were
released. Both the win and the regression are now recorded, along with why the
earlier measurement could not have caught it, and neither number is presented as
settled until a long session re-measures it.
G8 and G9 were already taken, by cache-aware expert dropping and by the
prediction probe, so this branch's two gates answered to labels that name a
different feature in the same binary's output. A failing "G9" could not be
read without opening the file. They become G11 and G12.

The index at the top of the file had also stopped describing the file below
it: it predates both the drop gates and these, so the map named neither. It
now lists them, and says that gate numbers are allocated once and never
reused, which is the rule this collision broke.
The combination produces wrong bytes with no error reported. Measured against a real model: same revision, same gguf, same flags, correct text on Android and garbage on Windows. Everything on this side is proven correct (requests, destination arithmetic, commit ranges, alignment, the partial-read retry), so the fault is below us; the difference is that in place the DMA target is freshly committed shared memory a compute thread is concurrently reading, where the bounce had a private buffer and a CPU copy ordered against the ready flag. Serial is correct on Windows, so only the overlapped path falls back, and it says so rather than degrading in silence. Tracked in issue #149.
@Helldez
Helldez marked this pull request as draft August 1, 2026 23:15
@Helldez

Helldez commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Parked, not abandoned. The Windows corruption is tracked in #149 and this branch now carries a guard that falls back to the bounce buffer under --overlap\ on Windows instead of producing wrong bytes silently, so nothing here is unsafe to build. It stays out of v0.19.0: the flag is experimental, the win it claims is owed a re-measurement after the boundary-page fix, and the gate that should protect it (G4e) is still inert on the tiny fixture. Landing it would mean shipping a feature whose only automated coverage proves nothing.

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