Skip to content

CPU offload and prefetch: the measured ceiling

Date: 2026-09-15 Commit: 043b981 Script: scripts/profile_cpu_offload.py Artifact: /tmp/cpu_offload_profile.json from python scripts/profile_cpu_offload.py --stage all --json /tmp/cpu_offload_profile.json Issue: #156 (CPU Offloading with Prefetch Pipeline), roadmap entry 15 of #151

Issue #156 promises four things for layer-granularity CPU offloading: a 70B FP4 model runnable on a 2080 Ti, decode at ≥ 5 tok/s, prefetch hiding > 80 % of the H2D traffic, and token parity. None of the four had been measured on this host, and the arithmetic behind them does not close on its own terms. The roadmap's section 2.1 budgets "Decode: ~5 tok/s(每层 ~200ms,其中 H2D ~100ms + 计算 ~100ms)" (docs/architecture/pocketllm_roadmap_old_hardware.md:170) — ~200 ms per layer, of which H2D ~100 ms and compute ~100 ms — for a 70B FP4 layer that is 0.42 GiB. At the measured 10.53 GiB/s that is 39 ms of copy, not 100 ms; and the compute half is 3.6 ms per layer at one row, not 100 ms (Result 4).

Roadmap entry 15 of #151 asks for exactly this measurement before any throughput is promised:

Start with real-checkpoint memory and transfer profiles, distinguish cold storage from warm host-cache behavior, and establish the overlap ceiling before promising throughput.

This page records that measurement. Nothing here changes a runtime path, and no engine code was written: the numbers are the go/no-go input for whether layer-granularity offload should be built at all.

What was measured, and the distinction that makes it a new question

Every CPU/GPU mixed path in this repository is expert-granular: GLM's active-expert staging, the Qwen4-Exp host-resident expert shard, the cpp_engine GGUF Q2 staging. Expert granularity works because MoE routes each token to a handful of experts, so most expert bytes never move at all — Qwen4-Exp moves one 56.25 GiB/rank shard once and then only the routed experts, which is why it gained 2.07× on prefill (Qwen4-Exp heterogeneous TP4 performance).

Layer granularity — llama.cpp's --gpu-layers N — moves every weight of every offloaded layer on every token. Nothing in this repository implements it: a whole-repository grep for gpu_layers|gpu-layers|n_gpu_layers matches only this page and the script behind it, both naming the flag to say what is not implemented. The two are not comparable budgets, and only the second is what #156 asks for.

The difference is arithmetic, not a matter of degree. A GLM-5.2 MoE block is 6,070,800,384 B, of which 5,838,471,168 B is ffn_{gate,up,down}_exps. With expert_used_count = 8 out of 256 experts, the arithmetic reads 0.386 GiB of that block — so offloading the layer moves 14.6× what the layer needs. Under expert granularity the same block moves 1.0× of what it needs. That ratio, reported as move_amplification, is the whole question this page answers.

Measurement conditions

Item Value
Host x86_64 CUDA machine, root
GPU 4 × RTX 2080 Ti, 22528 MiB each, sm_75
PCIe / topology Gen3; GPUs 0–1 on NUMA node 0 (PHB pair), GPUs 2–3 on node 1 (NV2 pair); every cross-pair SYS
CPU / RAM 2 × Intel Xeon E5-2696 v4 @ 2.20 GHz, 2 NUMA nodes, ~1 TiB RAM
OS / Python Ubuntu 22.04.5, kernel 5.15, CPython 3.11.14 (conda deepseek)
torch / CUDA torch 2.9.1+cu128, CUDA runtime 12.8
Checkpoints GLM-5.2-GGUF-UD-Q4_K_M (/mnt/data3/GLM-5.2-GGUF/UD-Q4_K_M, 11 shards), Qwen3.8-Flash-Next (/mnt/data1/modelscope/Qwen/Qwen3.8-Flash-Next), Qwen3.8-27B-FP8 (/mnt/data2/Qwen3.8-27B-FP8) — GGUF/safetensors headers and directory listings only, no tensor data read. No 70B checkpoint of any kind is present.
Stage wall time storage 46.07 s, h2d 4.11 s, overlap 33.41 s, budget 0.22 s

--stage storage samples 2 GiB per mount from a fixed offset of 64 MiB into the largest file on each mount — for this run an unrelated 95.7 GiB tarball on /mnt/data1, the Qwen3.8-27B-NVFP4 checkpoint on /mnt/data2, and a GLM-5.2 Q2_K_XL shard on /mnt/data3 — and evicts only that range (posix_fadvise(POSIX_FADV_DONTNEED)) rather than dropping the whole page cache, which would perturb unrelated work on a shared machine. --stage h2d stays under 1 GiB per GPU. --stage overlap runs one GPU at a time and peaks at ~13 GiB of device memory, because the GLM spec's double buffer is two 6.07 GiB staging buffers plus the layer's own weights. --stage budget reads GGUF/safetensors headers only and never touches tensor data.

Every rate below is wall time, with the target device drained by torch.cuda.synchronize(device) after the loop. A bare torch.cuda.synchronize() drains only the current device, and timing cuda:3 that way reports rates four orders of magnitude too high because nothing waits for the copy just enqueued; that bug was found and fixed during this work and the rates below are from after the fix.

Result 1: every checkpoint mount on this host is a spinning disk

Mount Device Model Kind Cold GiB/s Warm GiB/s Warm, 4 threads GiB/s
/mnt/data1 /dev/sdc ST4000VX015-3CU1 rotational 0.096 (20.86 s) 3.00 10.77
/mnt/data2 /dev/sdb1 ST1000DM003-1SB1 rotational 0.194 (10.28 s) 3.08 11.65
/mnt/data3 /dev/sda HGST HSH721414AL rotational 0.209 (9.59 s) 4.15 11.86

Producing command: python scripts/profile_cpu_offload.py --stage storage.

Cold is single-threaded after evicting the sampled range; warm is the same read repeated (page cache); 4-thread is four threads over disjoint 8 MiB chunks of the same range. All three mounts report ROTA=1 in lsblk.

This corrects a premise of the plan this work was scoped under, which recorded /mnt/data2 as an SSD on the strength of a 2.92 GiB/s reading. That reading was a warm page-cache hit, not a disk rate: /mnt/data2 is a 931 GiB Seagate ST1000DM003 on /dev/sdb, and its cold read is 0.194 GiB/s — the same order as the other two, not 15× faster. The real SSDs on this host are sdd, sde and nvme0n1, and none of them holds a checkpoint. The three cold figures are also the noisiest numbers in this record: on rotating media the rate depends on which part of the platter the sampled region happens to occupy, and repeat runs of this stage have differed by around a quarter at the same offset. The ordering and the conclusion survive that; the third digit does not.

The consequence is the one the roadmap already anticipated: the host can only feed PCIe out of page cache. Reading weights from disk is 50–110× slower than the PCIe link, so any offload design here has to be "host RAM resident, PCIe transferred", and prefetching from disk cannot be hidden behind anything. 1 TiB of RAM is enough to hold GLM-5.2 Q4 (434 GiB) entirely, so the residency part is affordable; the transfer budget below is what is left to argue about.

Result 2: H2D sweep — where copies stop being latency-bound

Copy size Pinned GiB/s Pageable GiB/s Ratio
4 KiB 0.46 0.46 1.00
16 KiB 1.57 1.57 1.00
64 KiB 6.81 4.01 1.70
256 KiB 9.82 6.71 1.46
1 MiB 10.29 10.06 1.02
4 MiB 10.38 10.13 1.02
16 MiB 10.44 10.17 1.03
64 MiB 10.51 9.40 1.12
256 MiB 10.53 8.27 1.27

Producing command: python scripts/profile_cpu_offload.py --stage h2d.

One GPU (cuda:0), pinned source allocated with pin_memory=True and pageable source with a plain torch.empty; both sources are written before timing, because a never-written anonymous page reads back as the shared zero page and would make the read side of the copy free, flattering the pageable column.

Two conclusions that a double-buffered pipeline depends on:

  • At 64 KiB and below a copy is latency-bound, not bandwidth-bound. 64 KiB reaches only 6.8 GiB/s of the 10.53 GiB/s plateau, 16 KiB reaches 1.6 GiB/s and 4 KiB reaches 0.46 GiB/s. A staging ring whose chunks are small loses most of the link to per-copy latency; the chunk that matters here is a whole layer, hundreds of MiB to GiB, so this is not a problem in practice — but it does rule out "prefetch in small pieces" as a design.
  • Pinned is never slower than pageable, and pageable only keeps up in a narrow window. Pinned holds 10.3–10.5 GiB/s from 1 MiB upwards. Pageable matches it to within 3 % at 1–16 MiB, and then falls away to 9.40 GiB/s at 64 MiB and 8.27 GiB/s at 256 MiB — 11 % and 21 % behind, at exactly the chunk sizes a layer-granular pipeline would use. The staging ring has to be pinned.

Four GPUs, 64 MiB each, concurrent:

Mode Aggregate GiB/s Per GPU GiB/s
Pinned 40.80 10.20
Pageable 16.50 4.13

This is 40.80 GiB/s against the recorded 42.42 GiB/s aggregate in Qwen4-Exp heterogeneous TP4 performance, and three further runs of this stage landed at 40.99, 41.26 and 41.64 GiB/s — within 4 % of the recorded figure. The link is not the constraint on four concurrent copies: each GPU gets its own ~10.2 GiB/s.

How much of this table is reproducible: the three repeat runs put the pinned plateau from 1 MiB up at 10.3–11.1 GiB/s (± 4 %) and the aggregate at 40.8–41.6 GiB/s, but they moved the latency-bound points and the pageable tail by as much as 30 % — 256 MiB pageable was 6.7–8.3 GiB/s across the four runs, and 64 KiB pinned was 6.8–9.2. The shape is identical in every run and only the shape is load-bearing here.

Result 3: NUMA placement is not a lever on this host

64 MiB pinned buffers, 5 timed iterations each after 2 warm-ups, page placement verified from /proc/self/numa_maps:

Buffer pages on node Target GiB/s
0 cuda:0 10.76
0 cuda:3 10.59
1 cuda:0 10.52
1 cuda:3 10.64

Both local pairings are ahead of their remote counterparts — node-0 pages to cuda:0 by 0.24 GiB/s (2.3 %) and node-1 pages to cuda:3 by 0.06 GiB/s (0.5 %) — which is the expected direction but an order of magnitude below the 10–20 % a remote-node penalty usually costs, and well inside the ± 4 % run-to-run spread of the pinned plateau measured in Result 2. The link, not the page placement, is the limit, and the reason is worth recording because it is not what the plan assumed:

  • torch.empty(..., pin_memory=True) ignores thread NUMA affinity here. Under both node-0 and node-1 CPU affinity, a 64 MiB pinned buffer lands entirely on node 1 — recorded as {'1': 16384} pages in both cases. cudaHostAlloc places the pages itself and, on this driver, picks node 1 regardless.
  • To actually pin pages on a chosen node, the script allocates with numa_alloc_onnode, verifies the placement, and then calls cudaHostRegister on that anonymous buffer (class _NodePinned). Only then does the A/B vary the node at all, and the answer is that it does not matter.

So the honest conclusion is not "NUMA pinning is a win" but "NUMA pinning is achievable and buys nothing on this host": whichever node the pages sit on, the copy runs at the PCIe plateau. Any future plan that lists NUMA placement as a lever for H2D bandwidth has been measured and should be dropped.

A constraint that must survive into any implementation: cpp_engine/tests/probe_host_register.cpp:1-11 bans registering an entire checkpoint's file-backed mmap, because pinning 80 GB+ of file pages poisons Linux dirty-page accounting and stalls unrelated fsync, build and git I/O system-wide.

156's "CPU weights must be pinned" can therefore only be built as a **bounded

anonymous pinned staging ring** — which is exactly what _NodePinned is, and nothing like pinning a checkpoint.

Result 4: the overlap ceiling — the core number

Real layer byte counts and real GEMM shapes, on cuda:0, fp16, timed over 4 layers per iteration of a double-buffered two-stream pipeline modelled on src/models/qwen4_exp/moe.py:360-426. hidden is (serial − overlap) / copy_only: zero when the pipeline saves nothing, and 1.0 when the copy is entirely hidden, which needs compute ≥ copy. When compute < copy the best it can reach is compute / copy, because a pipeline cannot beat the transfer it is hiding behind — it can only spend as much time as it has compute to spend. That bound is the last column.

GLM-5.2 Q4 MoE block — 6.07 GiB per layer, move_amplification 14.6×

Rows Copy ms/layer Compute ms/layer Serial ms/layer Overlap ms/layer Hidden Max hideable (compute / copy)
1 (decode) 540.48 1.18 541.79 541.31 0.09 % 0.22 %
512 (prefill) 540.24 6.26 546.83 542.35 0.83 % 1.16 %

Llama-70B FP4 dense layer — 0.42 GiB per layer, move_amplification 1.0×

Rows Copy ms/layer Compute ms/layer Serial ms/layer Overlap ms/layer Hidden Max hideable (compute / copy)
1 (decode) 39.79 3.58 43.54 40.88 6.67 % 9.01 %
512 (prefill) 39.89 15.25 55.02 43.83 28.04 % 38.23 %

Copy rate is 10.46–10.64 GiB/s in all four rows, matching the sweep's plateau.

Producing command: python scripts/profile_cpu_offload.py --stage overlap --overlap-rows 1 512.

The GLM row carries 6.07 GiB through PCIe to run 1.18 ms of arithmetic. The pipeline cannot hide what is not there to hide behind: when compute < copy the most a perfect pipeline can hide is compute / copy, and in all four rows the measured fraction sits below that bound — recovering 40 % of it in the GLM decode case and 72–74 % in the other three. The 0.8–3.9 ms/layer by which the measured overlap exceeds max(copy, compute) is per-layer event and launch overhead, and it does not shrink with the ratio. So the gap to the bound is the pipeline's own cost, not a shape that a better implementation could recover, and the bound itself is what #156 is arguing with.

The consequence is that "prefetch hides >80 % of H2D" is not a tuning target that was missed — it is unreachable for these shapes. On the column above, hiding 80 % of the transfer needs compute ≥ 0.8 × copy; on the more generous reading, hiding 80 % of the whole step ((serial − overlap) / serial ≥ 0.8) needs compute ≥ 4 × copy. For the GLM block at decode, compute is 1/458th of the copy, and its ceiling is 0.22 % under either formula. Prefill is the only phase with a meaningful ratio — 28 % measured, 38 % attainable on the 70B layer — which is the opposite of what #156 targets: prefill is already amortized over many tokens, and decode is the phase where the layer must be re-fetched for every single token.

Result 5: what the arithmetic allows, per checkpoint and TP degree

--stage budget reads headers only and computes, for each checkpoint, how many whole layers fit in a 22 GiB card, how many must be offloaded, and the resulting token-rate ceiling. It charges whole layers against the GPU budget with no allowance for KV cache, activations or compute buffers, so the resident count is an upper bound and the offloaded count a lower bound — the real numbers are worse, not better.

Checkpoint TP GiB/layer/rank Resident Offloaded Transfer GiB/token Serial tok/s Perfect-overlap tok/s Compute share of serial
GLM-5.2 Q4 (434 GiB, 79 L, 256 exp, top-8) 1 5.47 3 76 415.75 0.02526 0.02532 0.23 %
2 2.74 7 72 196.93 0.05321 0.05346 0.45 %
4 1.37 14 65 88.89 0.1174 0.1184 0.90 %
Qwen3.8-Flash-Next (335 GiB, 48 L, 512 exp, top-10) 1 4.83 4 44 212.46 0.04942 0.04955 0.26 %
2 2.41 9 39 94.16 0.1112 0.1118 0.51 %
4 1.21 18 30 36.21 0.2877 0.2907 1.02 %
Qwen3.8-27B-FP8 (28.75 GiB, 64 L, dense) 1 0.35 61 3 1.06 9.555 9.890 3.38 %
2 0.18 64 0
4 0.09 64 0
Llama-70B FP4 (arithmetic model, 33.87 GiB, 80 L) 1 0.42 51 29 12.28 0.8330 0.8575 2.85 %
2 0.21 80 0
4 0.11 80 0

Producing command: the --stage all run above. The budget stage takes its bandwidth and compute from the stages that ran before it, so run on its own it has neither — --stage budget alone falls back to 10.61 GiB/s and 1.0 ms/layer and says so in the artifact's bandwidth_source / compute_source fields, which is how a standalone run is told apart from this table. --bandwidth-gib-s and --compute-ms-per-layer override either.

Assumptions recorded in the artifact, and both of them measured rather than borrowed from a document: bandwidth 10.53 GiB/s, the best pinned point of the sweep in Result 2, and compute 1.18 ms per layer per token, the GLM-5.2 MoE block at one row from Result 4 (plus 22 GiB per card). The rows are not missing data: at those TP degrees zero layers need to be offloaded, so there is no transfer to ceiling against.

Two published figures come back out of the metadata, which is the check that the header reading is not inventing structure: Qwen3.8-Flash-Next's routed-expert set is 225.0 GiB, exactly 56.25 GiB per rank at TP4 as Qwen4-Exp heterogeneous TP4 performance records, and GLM-5.2-UD-Q4_K_M reads back as 79 layers, hidden 6144, 256 experts, top-8 with a 5.65 GiB median layer.

The one input that is measured on the wrong thing is the compute term. 1.18 ms per layer is the GLM-5.2 MoE block at one row, and the budget charges it to every checkpoint. For the 70B dense layer the same stage measured 3.58 ms instead, so substituting its own number:

Case Compute charged Compute measured Serial tok/s (charged → measured)
GLM-5.2 Q4, TP4, 65 layers offloaded 76.7 ms 76.7 ms 0.1174 → 0.1174
Llama-70B FP4, TP1, 29 layers offloaded 34.2 ms 103.9 ms 0.833 → 0.787

The GLM row is already using its own measurement. The 70B ceiling falls 5.5 % and its compute share rises from 2.85 % to 8.2 %. Neither changes a conclusion below, and on the numbers a real 70B implementation would have to quote the second column.

Verdict on #156's four acceptance claims

Claim Verdict
70B FP4 runs on a 2080 Ti Partly, and not for the stated reason. 33.87 GiB at 4-bit weight-only fits in 22 GiB at TP2 (16.93 GiB/rank) and TP4 (8.47 GiB/rank) with zero offloaded layers — so #156's premise holds only at TP1. At TP1 the ceiling is 0.79–0.83 tok/s. Not verifiable end to end on this host: no 70B FP4 checkpoint exists here, so the entry is published Llama-70B geometry, not a measurement.
decode ≥ 5 tok/s Refuted by bandwidth, not by implementation quality. 5 tok/s allows 200 ms/token, i.e. 2.11 GiB of offloaded traffic per token at the measured 10.53 GiB/s. One 70B FP4 layer is 0.42 GiB, so at most 4 of 80 could be offloaded — leaving 76 layers, 32.2 GiB, resident on a 22 GiB card. The roadmap's own split (16 layers resident, 64 offloaded, :168) moves 27.1 GiB/token, which is 2.57 s at the measured rate: 0.39 tok/s, 13× short. The best offloaded ceiling measured on any checkpoint here is 0.29 tok/s (Flash-Next TP4) and 0.83 tok/s for the 70B FP4 model at TP1.
prefetch hides > 80 % of H2D Refuted for decode, unreachable in principle. Maximum hideable is compute / copy; measured 1.18 ms of compute against a 540 ms copy for a GLM MoE block (0.09 % hidden, 0.22 % attainable), and 3.58 ms against a 39.8 ms copy for a 70B dense layer (6.67 %, 9.01 % attainable). 512-row prefill — the phase #156 does not target — is the best case at 28.0 % of 38.2 %.
token parity Not measured. No layer-granularity offload path exists in this repository to hold parity, and this work deliberately added none.

Everything that points the other way is worth stating too: the prefill half of the roadmap's arithmetic does hold. At TP1 the offloaded 70B FP4 layers move 12.28 GiB per 512-row chunk, 1.17 s at the measured 10.53 GiB/s if the pipeline is perfect, which is 439 tok/s and the right order for the roadmap's "~200 tok/s (H2D-bound)" (:169). Prefill is transfer-bound and amortizes the transfer over the chunk; decode is the same transfer with nothing to amortize it over.

Why the ceiling is where it is, in one line

For a layer that must move B bytes and compute t seconds, decode throughput is bounded by 1 / (B / 10.53 GiB/s) no matter how good the overlap is, once B / 10.53 GiB/s > t. Every offloaded configuration in the table above is in that regime. The only way out is to move fewer bytes per token, which is precisely what expert granularity already does — and that is a design this repository has already shipped and measured, not one that is still available to win.

Reproduction

# The full run behind this page (~84 s wall, GPU-side allocations of a few GiB)
python scripts/profile_cpu_offload.py --stage all --json /tmp/cpu_offload_profile.json

# Individual stages
python scripts/profile_cpu_offload.py --stage storage --storage-sample-mb 2048
python scripts/profile_cpu_offload.py --stage h2d
python scripts/profile_cpu_offload.py --stage overlap --overlap-rows 1 512
python scripts/profile_cpu_offload.py --stage budget

--stage budget looks for the checkpoints it knows about at fixed paths; pass --checkpoint <path> (repeatable) to add one, --no-synthetic-70b to drop the published-geometry 70B entry, and --bandwidth-gib-s / --compute-ms-per-layer to replace the constants it derives from the other stages. No reference model was run, so there is no parity comparison in this record and none is claimed.