Skip to content

moe-cache: adaptive cpu-overlap calibration to replace fixed byte budget - #327

Merged
TheTom merged 1 commit into
TheTom:feature/turboquant-kv-cachefrom
giveen:moe-cache-improvement
Aug 31, 2026
Merged

moe-cache: adaptive cpu-overlap calibration to replace fixed byte budget#327
TheTom merged 1 commit into
TheTom:feature/turboquant-kv-cachefrom
giveen:moe-cache-improvement

Conversation

@giveen

@giveen giveen commented Aug 30, 2026

Copy link
Copy Markdown

PR: Adaptive cpu-overlap calibration for --moe-cache auto

Replaces a hardcoded byte-budget constant in the MoE expert cache's automatic
CPU/GPU overlap policy with a live calibration that measures real decode
throughput on the actual machine and picks the best setting itself, instead
of assuming a value tuned on different hardware.

Commit in this PR: moe-cache: adaptive cpu-overlap calibration to replace fixed byte budget


Background: what this actually fixes

When --moe-cache auto decides how much GPU-resident expert work to also run
on the CPU concurrently (the "overlap" policy — see docs/backend/MOE-CACHE.md),
it used a single compile-time constant: moe_cache_overlap_bytes_per_token = 8 MiB (moe-cache.cu:56), tuned once via a 4x RTX 3090 sweep and shipped as-is
for every machine. There was no mechanism to measure whether that assumption
held on different hardware.

It doesn't hold on a single RTX 5090 with a modest CPU thread budget: the GPU
finishes its share fast enough that any CPU overlap work becomes the critical
path instead of hidden, extra throughput. The fixed formula was actively
hurting decode speed on this box, not just leaving performance on the table.

Test environment

RTX 5090 (32 GB VRAM, single GPU), Laguna-S-2.1 118B-A8B MoE model, Q5_K_M
(~91 GB, routed experts spill to CPU host memory), 8 CPU threads pinned
(-t 8 -Cr 0-7 -tb 8 -Crb 0-7), --no-mmap, --fit on -fa on.


1. Motivating measurement — manual sweep, forcing the existing GGML_CUDA_MOE_CACHE_OVERLAP_CPU_ROWS override

Before writing any new code, confirmed the fixed formula was actually
mistuned on this hardware by manually overriding the row count via the
already-existing (undocumented-in-CLI) env var, 3 completion requests per
setting, first request per server discarded as warmup:

Config Steady-state decode (tok/s) vs. old auto
overlap=0 (forced) 36.9 (37.18, 36.54) +7.3%
auto (old fixed 8 MiB/token formula) 34.4 (34.58, 34.18) baseline
overlap=8 (forced, max) 32.9 (32.41, 33.40) −4.4%

Clean, monotonic — less CPU overlap is strictly better on this hardware. This
justified building an automatic calibration rather than just retuning the
constant, since the "right" answer here (zero) isn't expressible by the old
formula's design (it always hands over at least 1 row).

2. A real methodology bug caught and fixed before shipping

First calibration implementation tried each candidate sequentially in one
block (4 warmup scopes, 8 measured scopes, next candidate), no global
warmup. Result was wrong and worth recording as a cautionary log:

candidate=formula avg=56.971 ms/decode
candidate=0       avg=45.632 ms/decode
candidate=1       avg=43.232 ms/decode
candidate=2       avg=34.160 ms/decode
candidate=4       avg=32.441 ms/decode   <- picked as "best"
candidate=8       avg=36.575 ms/decode

That's a monotonic warm-up decay curve (CUDA kernel JIT, page cache fill,
thread/frequency governor settling), not a signal from the row count —
whichever candidate runs first in a sequential sweep is unfairly penalized.
Fixed by adding a 64-scope unmeasured global warm-up before any candidate
timing starts, plus a round-robin sweep (each candidate gets several short
trials across rounds, rotating which candidate goes first each round) instead
of one long block per candidate. Re-run with the fix:

[[warm-up complete, starting sweep]]
round=0 candidate=formula avg=28.713   round=1 candidate=0 avg=26.834   round=2 candidate=1 avg=28.464
round=0 candidate=0       avg=28.588   round=1 candidate=1 avg=30.595   round=2 candidate=2 avg=25.554
round=0 candidate=1       avg=27.873   round=1 candidate=2 avg=34.105   round=2 candidate=4 avg=26.801
round=0 candidate=2       avg=31.801   round=1 candidate=4 avg=31.331   round=2 candidate=8 avg=33.886
round=0 candidate=4       avg=32.209   round=1 candidate=8 avg=25.774   round=2 candidate=formula avg=28.025
round=0 candidate=8       avg=28.843   round=1 candidate=formula avg=26.138   round=2 candidate=0 avg=26.210
cpu-overlap calibration complete: rows=0 (27.211 ms/decode avg)

No more monotonic drift — values bounce around per candidate as expected from
real noise — and it converges to rows=0, matching the manual sweep above.

3. End-to-end validation against the real model

Phase tok/s
Old fixed-formula auto 34.4
Manually forced overlap=0 36.9
New calibration — full 220-token run (includes ~154 tokens of deliberate exploration across worse candidates) 30.0
New calibration — after convergence 37.1–37.7

The calibration log confirms it: cpu-overlap calibration complete: rows=0 (27.211 ms/decode avg) — reached automatically, no manual tuning. Post-
convergence throughput (37.1–37.7 tok/s) matches, and slightly beats, the
manually-discovered optimum, and beats the old automatic default by ~9%. The
one-time calibration cost (~154 decode steps, a few seconds) is paid once per
server process, not per request.

4. Unit tests

All 35 existing test-moe-cache cases still pass unchanged (2 skipped for
single-GPU/static-backend reasons, unrelated to this change). Added a
dedicated test, cache-cpu-overlap-calibration, that drives 170 single-token
decode scopes through a real session and asserts: the global warm-up
completes, exactly 18 round×candidate trial lines get logged (3 rounds × 6
candidates), and calibration reaches "complete" exactly once and stays locked
in afterward — so a regression to the warm-up length, the round-robin loop,
or a runaway re-triggering bug fails this test independent of real hardware
timing.

$ build/bin/test-moe-cache
...
cache-cpu-overlap: OK
cache-cpu-overlap-auto: OK
cache-cpu-overlap-calibration: OK
...
cache-route-override: SKIP (one CUDA device)
cache-backend-unload: SKIP (static backend)

Known limitations

  • Calibration constants (64-scope warm-up, 3 rounds, 5 scopes/candidate/round,
    candidate set {formula, 0, 1, 2, 4, 8}) are reasonable defaults for this
    workload, not swept against alternatives.
  • Only tested on a single RTX 5090; behavior on multi-GPU MoE-cache sessions
    (where the config is shared across devices) is exercised by the existing
    unit tests but not validated on real multi-GPU hardware.
  • The one-time calibration cost is paid on every fresh server process; for a
    workload that restarts very frequently with very short sessions, that cost
    may not fully amortize (though it's a few seconds against typical model
    load times of 15-30+ seconds for large MoE models).

Test commands

Unit tests:

cmake --build build --target test-moe-cache -j"$(nproc)"
build/bin/test-moe-cache

Manual override sweep (reproduces section 1):

for rows in 0 8; do
  GGML_CUDA_MOE_CACHE_OVERLAP_CPU_ROWS=$rows build/bin/llama-server \
    -m Laguna-S-2.1-UD-Q5_K_M-00001-of-00003.gguf -c 8192 -ngl auto --fit on -fa on \
    --no-mmap -t 8 -Cr 0-7 -tb 8 -Crb 0-7 -b 2048 -ub 2048 --parallel 1 \
    --moe-cache auto -lv 4 --host 127.0.0.1 --port 8097
  # then POST /completion and read timings.predicted_per_second
done

Real calibration run (no override — reproduces sections 2/3):

build/bin/llama-server \
  -m Laguna-S-2.1-UD-Q5_K_M-00001-of-00003.gguf -c 8192 -ngl auto --fit on -fa on \
  --no-mmap -t 8 -Cr 0-7 -tb 8 -Crb 0-7 -b 2048 -ub 2048 --parallel 1 \
  --moe-cache auto -lv 4 --host 127.0.0.1 --port 8097
# grep the log for "cpu-overlap calibration"; send a >=220-token completion
# request to give the full sweep room to converge

AI usage: yes

The automatic --moe-cache overlap policy picked how many GPU-resident
expert rows to route to the CPU using a hardcoded 8 MiB/token constant
tuned on a 4x RTX 3090 rig. On a single RTX 5090 with limited CPU
threads that assumption is wrong in the opposite direction: any CPU
overlap adds latency instead of hiding it, measured +7% decode
throughput at overlap=0 vs the old automatic default, and -4% at max
overlap.

Replace the fixed constant with a live calibration that runs at
session start: a long unmeasured global warm-up (JIT, page cache,
governor settling all swamp the real signal if skipped), then a
round-robin sweep over candidate row counts -- including the original
formula as one candidate -- averaged across multiple rounds so
residual drift doesn't bias whichever candidate runs first. Locks onto
the best-measured candidate for the rest of the session. Only active
when overlap_cpu_rows is left automatic; explicit
GGML_CUDA_MOE_CACHE_OVERLAP_CPU_ROWS still takes priority untouched.

Validated end-to-end against a real 118B-A8B MoE model: converges to
rows=0 on this hardware, matching the manually-discovered optimum
(37.1-37.7 tok/s post-convergence vs 34.4 tok/s for the old fixed
formula). Adds a dedicated unit test driving full convergence; all
existing moe-cache tests still pass unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@stiggy2k16

Copy link
Copy Markdown

This is definitely increasing token generation speeds for me, running DSv4 Flash UD-Q3_K_XL on an RTX 3090, RTX 3060, 128GB DDR5 and a Ryzen 7600.

These results are taken from the same Hermes session, build switched between turns. Around 100k context. So the prompt isn't completely identical before and after but it's very close.

Before:

2184.25.475.339 I slot print_timing: id  0 | task 71464 |        eval time =  275286.17 ms /  3169 tokens (   86.87 ms per token,    11.51 tokens per second)
2184.25.475.340 I slot print_timing: id  0 | task 71464 |       total time =  293085.85 ms /  3837 tokens
2184.25.475.341 I slot print_timing: id  0 | task 71464 |    graphs reused =      72923
2184.25.477.294 I slot      release: id  0 | task 71464 | stop processing: n_tokens = 87770, truncated = 0
2187.32.416.042 I slot print_timing: id  0 | task 74635 |        eval time =  160651.17 ms /  1931 tokens (   83.20 ms per token,    12.02 tokens per second)
2187.32.416.043 I slot print_timing: id  0 | task 74635 |       total time =  186063.47 ms /  4699 tokens
2187.32.416.043 I slot print_timing: id  0 | task 74635 |    graphs reused =      74815
2187.32.418.070 I slot      release: id  0 | task 74635 | stop processing: n_tokens = 89296, truncated = 0
2190.01.190.310 I slot print_timing: id  0 | task 76568 |        eval time =  126404.77 ms /  1489 tokens (   84.89 ms per token,    11.78 tokens per second)
2190.01.190.311 I slot print_timing: id  0 | task 76568 |       total time =  146903.00 ms /  2860 tokens
2190.01.190.312 I slot print_timing: id  0 | task 76568 |    graphs reused =      76272
2190.01.192.423 I slot      release: id  0 | task 76568 | stop processing: n_tokens = 90221, truncated = 0
2191.49.122.060 I slot print_timing: id  0 | task 78059 |        eval time =   86807.85 ms /  1062 tokens (   81.74 ms per token,    12.23 tokens per second)
2191.49.122.060 I slot print_timing: id  0 | task 78059 |       total time =  106953.18 ms /  2291 tokens
2191.49.122.061 I slot print_timing: id  0 | task 78059 |    graphs reused =      77309
2191.49.124.116 I slot      release: id  0 | task 78059 | stop processing: n_tokens = 91019, truncated = 0
2192.46.676.689 I slot print_timing: id  0 | task 79123 |        eval time =   41673.36 ms /   506 tokens (   82.36 ms per token,    12.14 tokens per second)
2192.46.676.689 I slot print_timing: id  0 | task 79123 |       total time =   56627.56 ms /   849 tokens
2192.46.676.690 I slot print_timing: id  0 | task 79123 |    graphs reused =      77803
2192.46.678.763 I slot      release: id  0 | task 79123 | stop processing: n_tokens = 90802, truncated = 0

After:

14.00.566.765 I slot print_timing: id  0 | task 0 |        eval time =   27969.72 ms /   408 tokens (   68.55 ms per token,    14.59 tokens per second)
14.00.566.765 I slot print_timing: id  0 | task 0 |       total time =  522454.39 ms / 77421 tokens
14.00.566.769 I slot print_timing: id  0 | task 0 |    graphs reused =        401
14.00.568.583 I slot      release: id  0 | task 0 | stop processing: n_tokens = 77420, truncated = 0
17.28.218.313 I slot print_timing: id  0 | task 419 |        eval time =  183575.24 ms /  2530 tokens (   72.56 ms per token,    13.78 tokens per second)
17.28.218.313 I slot print_timing: id  0 | task 419 |       total time =  203169.91 ms /  4003 tokens
17.28.218.314 I slot print_timing: id  0 | task 419 |    graphs reused =       2878
17.28.220.353 I slot      release: id  0 | task 419 | stop processing: n_tokens = 81011, truncated = 0
18.16.586.285 I slot print_timing: id  0 | task 2951 |        eval time =   31265.13 ms /   437 tokens (   71.54 ms per token,    13.98 tokens per second)
18.16.586.286 I slot print_timing: id  0 | task 2951 |       total time =   46457.07 ms /   872 tokens
18.16.586.286 I slot print_timing: id  0 | task 2951 |    graphs reused =       3306
18.16.588.182 I slot      release: id  0 | task 2951 | stop processing: n_tokens = 79349, truncated = 0
44.21.399.161 I slot print_timing: id  0 | task 3885 |        eval time =  211667.16 ms /  2846 tokens (   74.37 ms per token,    13.45 tokens per second)
44.21.399.161 I slot print_timing: id  0 | task 3885 |       total time =  229159.84 ms /  3565 tokens
44.21.399.162 I slot print_timing: id  0 | task 3885 |    graphs reused =       6568
44.21.401.114 I slot      release: id  0 | task 3885 | stop processing: n_tokens = 83816, truncated = 0
46.55.818.282 I slot print_timing: id  0 | task 6733 |        eval time =  132830.82 ms /  1783 tokens (   74.50 ms per token,    13.42 tokens per second)
46.55.818.282 I slot print_timing: id  0 | task 6733 |       total time =  152478.17 ms /  3050 tokens
46.55.818.283 I slot print_timing: id  0 | task 6733 |    graphs reused =       8312
46.55.820.239 I slot      release: id  0 | task 6733 | stop processing: n_tokens = 84016, truncated = 0

Appreciate these aren't precise like for like tests, but it's enough to show this PR is clearly faster in real world use. Happy to do more specific benchmarks if you need.

@TheTom

TheTom commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Thanks, @giveen. I reviewed the calibration state machine and policy switch. The warm-up plus rotated round-robin design avoids the first-pass bias you found, explicit overrides still bypass calibration, short or batched sessions safely retain the old formula, and the chosen row count locks after one sweep. We also now have independent real-world confirmation from @stiggy2k16 on a 3090 + 3060 setup at roughly 100K context, which is useful coverage beyond the 5090. The red server check is the unrelated slot-restore crash fixed in #324. Merging this.

@TheTom
TheTom merged commit daa4a32 into TheTom:feature/turboquant-kv-cache Aug 31, 2026
10 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants