Repository navigation
perf(expert): the cache fill's stage buffers pinned with cudaHostAlloc - #1237
ZhongUncle wants to merge 1 commit into
Conversation
…th cudaHostAlloc A staged blob (the GGUF role slices assembled into one buffer) is handed to ExpertCache::fill_slot's cudaMemcpyAsync as-is. From a pageable buffer the driver stages every copy through an internal bounce buffer - an extra copy at a fraction of the transfer rate, on the calling thread - so the admit path, the profile prefill and the lent-slot refills all pay it per expert. Allocate the pool's buffers with cudaHostAlloc instead, falling back to pageable for a buffer when the driver refuses (pinned memory is scarce on the small-RAM machines this path serves). Co-Authored-By: Claude Code <noreply@anthropic.com>
|
The micro-benchmark behind the TL;DR's first bullet and finding 2, as offered in the PR body — posted here so the source stays with the PR. It is not part of the diff. Build and run (any NVIDIA GPU; adjust nvcc -O2 -arch=sm_86 bench_stage_copy.cu -o bench_stage_copy
./bench_stage_copy # defaults: 1.5 MiB, ring 64, 8 reps// bench_stage_copy.cu - micro-bench for P2: cudaMemcpyAsync H2D from a pageable staging buffer vs a
// cudaHostAlloc'd one, at the IQ2_XS pack's largest expert blob (default 1.5 MiB), over a rotating ring of
// 64 buffers so no single buffer stays L3-resident or branch-predictable (the P1 round-3 lesson).
//
// Two variants per buffer type:
// copy-only: the ring is filled once up front; each copy reads a buffer last touched 64 copies ago.
// fill-then-copy: each buffer is memcpys-filled right before its copy - the engine's pattern, where
// fill_stage assembles the blob and the cache fill copies it right away.
//
// Metrics per copy: host wall time inside cudaMemcpyAsync (a pageable source makes the driver stage through
// an internal bounce buffer on the calling thread) and GPU event time on the stream.
//
// Build: nvcc -O2 -arch=sm_86 bench_stage_copy.cu -o bench_stage_copy
// Run: ./bench_stage_copy [blob_mib=1.5] [ring=64] [reps=8]
#include <cuda_runtime.h>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <new>
#include <vector>
static void die(cudaError_t e, const char* what) {
if (e != cudaSuccess) { std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e)); std::exit(1); }
}
struct Arm {
const char* name;
bool pinned;
std::vector<uint8_t*> bufs;
};
int main(int argc, char** argv) {
const double mib = argc > 1 ? std::atof(argv[1]) : 1.5;
const int ring = argc > 2 ? std::atoi(argv[2]) : 64;
const int reps = argc > 3 ? std::atoi(argv[3]) : 8;
const size_t n = (size_t) (mib * 1048576.0);
uint8_t* dst = nullptr;
die(cudaMalloc(&dst, n), "cudaMalloc");
cudaStream_t st;
die(cudaStreamCreate(&st), "cudaStreamCreate");
cudaEvent_t ev0, ev1;
die(cudaEventCreate(&ev0), "event");
die(cudaEventCreate(&ev1), "event");
uint8_t* fill_src = (uint8_t*) std::malloc(n);
for (size_t i = 0; i < n; ++i) fill_src[i] = (uint8_t) (i * 2654435761u >> 13);
Arm arms[2] = {{"pageable new[]", false}, {"cudaHostAlloc", true}};
for (Arm& a : arms) {
a.bufs.resize((size_t) ring, nullptr);
for (int i = 0; i < ring; ++i) {
if (a.pinned) die(cudaHostAlloc((void**) &a.bufs[(size_t) i], n, cudaHostAllocDefault), "cudaHostAlloc");
else a.bufs[(size_t) i] = new (std::nothrow) uint8_t[n];
if (!a.bufs[(size_t) i]) { std::fprintf(stderr, "alloc failed\n"); return 1; }
std::memset(a.bufs[(size_t) i], 0x5a, n); // pages resident before any timing
}
}
std::printf("blob %.2f MiB x %d-ring x %d reps (%d copies per arm per variant)\n", mib, ring, reps,
ring * reps);
std::printf("%-16s %-14s %10s %10s %10s\n", "arm", "variant", "host us", "gpu us", "GB/s(host)");
for (int variant = 0; variant < 2; ++variant) {
for (Arm& a : arms) {
double host_us = 0.0, gpu_ms = 0.0;
for (int r = 0; r < reps; ++r)
for (int i = 0; i < ring; ++i) {
uint8_t* src = a.bufs[(size_t) i];
if (variant == 1) std::memcpy(src, fill_src, n); // fill_stage's fresh assembly
die(cudaEventRecord(ev0, st), "rec0");
const auto t0 = std::chrono::steady_clock::now();
die(cudaMemcpyAsync(dst, src, n, cudaMemcpyHostToDevice, st), "memcpy");
const auto t1 = std::chrono::steady_clock::now();
die(cudaEventRecord(ev1, st), "rec1");
die(cudaStreamSynchronize(st), "sync"); // per copy: no cross-copy pipelining
host_us += std::chrono::duration<double, std::micro>(t1 - t0).count();
float ms = 0.0f;
die(cudaEventElapsedTime(&ms, ev0, ev1), "elapsed");
gpu_ms += ms;
}
const double copies = (double) ring * reps;
const double us = host_us / copies, gus = (double) gpu_ms * 1000.0 / copies;
std::printf("%-16s %-14s %10.1f %10.1f %10.2f\n", a.name,
variant == 0 ? "copy-only" : "fill-then-copy", us, gus,
(double) n / (us * 1e-6) / 1e9);
}
}
for (Arm& a : arms)
for (uint8_t* p : a.bufs)
if (a.pinned) (void) cudaFreeHost(p);
else delete[] p;
std::free(fill_src);
cudaFree(dst);
cudaEventDestroy(ev0);
cudaEventDestroy(ev1);
cudaStreamDestroy(st);
return 0;
} |
…ache fill copies from, pinned with cudaHostAlloc perf(expert): the stage buffers the cache fill copies from, pinned with cudaHostAlloc A staged blob (the GGUF role slices assembled into one buffer) is handed to ExpertCache::fill_slot's cudaMemcpyAsync as-is. From a pageable buffer the driver stages every copy through an internal bounce buffer - an extra copy at a fraction of the transfer rate, on the calling thread - so the admit path, the profile prefill and the lent-slot refills all pay it per expert. Allocate the pool's buffers with cudaHostAlloc instead, falling back to pageable for a buffer when the driver refuses (pinned memory is scarce on the small-RAM machines this path serves). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157fjixQD2nRxvCvtXq66DD
|
Thanks @ZhongUncle. Ported with your authorship, as an opt-in in 0.1.41: |
…1221#1520 (release staged pages), Niko1221#1107 (grouped gather on short prompts) Three of the candidates we read apply and are not superseded by upstream: - Niko1221#1660 lets the MMQ resident sort take native-layout packs (slot/src/off are built the same way for both layouts; the PR measures 1.2K prompt 7.27 -> 5.32 s and 35K 63.3 -> 54.1 s on 4xGP100, same bits). - Niko1221#1520 extends STRATA_FILE_RELEASE to prefill staging copies (opt-in; the PR's own numbers: +11.7 GiB available RAM for 3.7-4.4% prefill - a trade worth testing on this 32 GB box). - Niko1221#1107 stages the short-prompt walk through a grouped ring (STAGE_GRP=32) instead of one slot at a time. Not applied, and why: Niko1221#1101 and Niko1221#1237 are already upstream in newer form (upstream has the stager sleep plus a stager_sleep helper and a batched copy_blobs loop, and pinned stage buffers behind STAGE_PIN_DEFAULT=0 - the PR's older always-pin revision would re-arm the IQ3_S decode corruption the release gate found).
…batch Niko1221#1416 is superseded by upstream 0.1.41 (which carries STRATA_PREFILL_CPU_SHARE_MAX and a newer two-argument arm_cpu_share the PR lacks); Niko1221#1101 and Niko1221#1237 are upstream already, and Niko1221#1237's older always-pin revision would re-arm the IQ3_S decode corruption the 0.1.41 gate found. Applied instead: Niko1221#1660 (native-layout MMQ resident sort), Niko1221#1520 (release staged pages, opt-in) and Niko1221#1107 (grouped gather on short prompts) - +4.7% prefill at 4K, +4.3% at 16K, with STRATA_FILE_RELEASE measured at -6.3% and left off. Adds pr-triage.py, pr-check.py, the report and the cached candidate diffs.
…is recycled (#1237) A page-locked stage buffer is the source of the cache fill's cudaMemcpyAsync, which the DMA reads after the call has returned; a pageable one is staged by the driver before it returns. The pool recycles a buffer after kStageAge layers or 256 assemblies, so a 4096-token prompt read (up to ~500 distinct experts per layer) could overwrite a buffer whose copy had not run yet: the corrupted IQ3_S decode of #1237. note_async_read(blob, stream) records an event per pinned buffer after each queued copy (hit-path fill, peer fill, remote-expert swap); claim_stage waits for it before it hands the buffer out again. Pageable buffers and every other source are unchanged. STRATA_STAGE_PIN stays opt-in.
The refills of the slots a prompt borrowed (and the VRAM-resize refill) queue hundreds of cudaMemcpyAsync out of blob_stable() pointers, which for a GGUF source are the recycled stage buffers. With STRATA_STAGE_PIN=1 the 257th assembly overwrote a buffer whose copy had not run: IQ3_XXS lowmem (budget 12) decoded token id 0 after a 4096-token prompt. Each queued copy now records the fence of its buffer. exact_pp 4096 and 20000 tokens with STRATA_STAGE_PIN=1: EXACT.
…-support Upstream now carries these of our picks, in its own versions: Niko1221#1237 (pinned stage buffers, now with fences and the STRATA_STAGE_PIN switch), Niko1221#1332, Niko1221#1359, Niko1221#1372, Niko1221#1370, Niko1221#1251, Niko1221#1190 and Niko1221#1283. CPU help for short prompts: Strata's own CPU prefill share (0.1.41: on by default below 1,024 tokens on one NVIDIA GPU; -20..-34% prompt time at 512-1,000 tokens on an RTX 3060 in upstream's measurement) replaces architectds' CPU assist, which measured +11% at ~560 tokens on our desktop. prefill.cpp is upstream's, with the pool's hooks (remote_next to the workers, a headless worker stage) and Niko1221#1368's quantize-once put back. A pool PC runs its own layers alone (no local stages), so the share's default applies on every PC. Pipelined decode: upstream's N-stage loop (front stages + the last) with the pool's remote stage as the last (pl_remote via stage_ver_p, pl_ns = 2 on a pool coordinator), and architectds' verified-path-first yield, the lookup carried across windows and the pool head's penalty rows ported onto it. Batch: upstream's batch_step (the adaptive tier in batch windows, per-stage timing, Niko1221#879 non-finite logits) with Niko1221#1290's per-slot row width and the pool head's slot histories; the pool's batch groups keep their PoolLink stage. Also: the conversation cache's eviction keeps both the pool-borrowed entry and pinned prefixes (evict_oldest); the verifier's adaptive-tier gate (STRATA_ADAPT_OVERLAP) also guards the overlapped split's prelaunch (Niko1221#1120); the server's read-once body takes upstream's size limit; replicas and the pool roles both start engines; serve/pool.py keeps the engine's newer spawn tuple; the worker's parking reads available_host_bytes(); setup tests that need a ready-made engine are skipped (StrataPool compiles its own). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157fjixQD2nRxvCvtXq66DD
…is recycled (Niko1221#1237) A page-locked stage buffer is the source of the cache fill's cudaMemcpyAsync, which the DMA reads after the call has returned; a pageable one is staged by the driver before it returns. The pool recycles a buffer after kStageAge layers or 256 assemblies, so a 4096-token prompt read (up to ~500 distinct experts per layer) could overwrite a buffer whose copy had not run yet: the corrupted IQ3_S decode of Niko1221#1237. note_async_read(blob, stream) records an event per pinned buffer after each queued copy (hit-path fill, peer fill, remote-expert swap); claim_stage waits for it before it hands the buffer out again. Pageable buffers and every other source are unchanged. STRATA_STAGE_PIN stays opt-in.
The refills of the slots a prompt borrowed (and the VRAM-resize refill) queue hundreds of cudaMemcpyAsync out of blob_stable() pointers, which for a GGUF source are the recycled stage buffers. With STRATA_STAGE_PIN=1 the 257th assembly overwrote a buffer whose copy had not run: IQ3_XXS lowmem (budget 12) decoded token id 0 after a 4096-token prompt. Each queued copy now records the fence of its buffer. exact_pp 4096 and 20000 tokens with STRATA_STAGE_PIN=1: EXACT.
|
Thanks. This is in since 0.1.41 as STRATA_STAGE_PIN=1 (opt-in). It stays opt-in because pinned buffers made the cache-fill copy asynchronous and a stage buffer could be reused before its copy had finished; a fix of the recycle path in src/core/expert_source.cpp would let us make it the default. |
|
Thanks for the pull request. We're resetting the tracker for 0.2.0-beta. There are more than 1,300 open issues and pull requests, too many for us to review properly, so we're closing everything that isn't being worked on right now. Closed here does not mean rejected. To reopen it takes about two minutes:
The rules are in CONTRIBUTING.md. |
…iko1221#879-class NaN) blob() hands out a pointer into the 64-slot staging ring (kStagingSlots) and overwrites it on the next fetch. blob_stable() forwards to it unchanged, so callers that copy immediately are fine but anything that holds the pointer is not. The prefill queues ~508 blob_stable() pointers per layer (prefill.cpp:2394-2419) before Stager::work() dereferences them, and it never calls note_async_read(), so no slot was ever marked busy and every fetch overwrote a slot the queue still referenced. Experts were aliased onto each other's weights: 192 of 508 at layer 0, 43 of them differing from the file source. Most of that is finite garbage; ~16 overflowed to inf/NaN, which propagated to 100% non-finite from layer 1 on and surfaced as the Niko1221#879 "non-finite logits in a prompt window" five tokens from the end of the prompt - the verify window reports the position, it does not cause it. Every other caller that holds a staged blob pairs it with note_async_read() (tag Niko1221#1237); prefill is the one path that queues blob pointers and was missed. Fix: declare the daemon-served blobs transient, which routes prefill through the existing Job::from path where Stager::work() calls copy_blobs() straight into its own pinned buffer at copy time, so no pointer outlives the call. It also removes one 1.87 MB memcpy per expert. Verified: duplicate staged-blob hashes 192 -> 0; the config that failed on every run is green with no workaround; a 12,629-token prompt completes on the stock config at ~104-130 tok/s prefill (the no-streaming workaround measured 60-85).
Applied from Niko1221/Strata upstream commit 5635496. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
blob()serves experts from the stage buffers whenever the resident expert arena is opted out of (--mmap-experts) or too small for the expert set. The stage buffers are the pool that assembles each blob from the GGUF's three role slices.They are allocated with a bare
new[], so they are pageable. AcudaMemcpyAsyncfrom pageable memory first copies the data into a driver-internal bounce buffer, on the calling thread, before the DMA can start. Every consumer pays this per expert: the profile prefill's blocking fill (generate.cpp:4114), the lent-slot refills, the adaptive tier's swaps, and the admit path where it runs.This PR allocates the stage buffers with
cudaHostAllocinstead, falling back to pageable per buffer when the driver refuses (pinned memory is scarce on exactly the small-RAM machines this path serves). Nothing else changes: same pool, same claim/reuse policy, same bytes copied — a buffer whose only job is to be the source of an async H2D copy should be pinned.TL;DR — pinning the stage buffers makes every staged H2D copy cheaper. The cost is ≈0.3 s slower startup (profile prefill ≈7%; ≈0.2 s of it measured pinning cost, the rest unexplained) and ~386 MiB of pinned RAM. It only matters when
blob()serves from the stage buffers; otherwise they sit idle. Decode tok/s is roughly flat.cudaMemcpyAsyncfrom pageable vs 2–3 µs from pinned; end-to-end 219–256 → 144–145 µs (1.5–1.8×), the pinned copy at 10.8 of the card's probed 11.1 GB/s--mmap-expertsin both arms: the adaptive tier's swap round 4.20–4.30 → 0.90–1.18 ms (3.6–4.8×, four interleaved pairs); the lent-slot refill 165–169 → 104–105 ms for 530 slots (1.6×)blob()directly and the stage buffers sit idle — this PR is for the configs where they don'tWhat changes
claim_stage:new uint8_t[stage_blob_]→cudaHostAlloc(..., cudaHostAllocDefault), with a per-buffer pageable fallbackunique_ptrgains a small deleter that frees each buffer the way it was allocatedcudaHostAllocDefault, notMapped: a stage buffer is only ever the source of an H2D copy, never addressed by the devicestage_mu_exactly where thenew[]was; a failedcudaHostAlloc's sticky error is cleared on the spotcudaHostAllocserialization reaches decodeclose()path that alreadycudaFreeHosts the complement and exchange buffers — no new lifetime questionsMeasured results
Measured on:
--mmap-expertsin both arms (see the scope note),--spec 4 --spec-min-p 0.5+ the MTP drafterMicro-benchmark — per-copy cost of one 1.5 MiB blob, 64-buffer rotating ring (no buffer is re-read within 64 copies), 512 copies per arm per variant, quiet machine:
cudaMemcpyAsyncnew[]new[]fill_stageassembles the blob and the cache fill copies it right awayEngine A/B — four interleaved baseline/patched pairs, 512 generated tokens after an 873-token prompt (raw lines in the appendix):
Findings
generate.cpp:10610) issues one async H2D per swapped expert fromblob()'s stage buffer, on its own thread; the logged ms/round times that loop. From pageable memory each issue costs the ~110–170 µs bounce copy measured in the micro-benchmark, from pinned memory ~3 µs. Decode tok/s does not move (finding 4): at this swap cadence (every 4 rounds) the copies are already off the critical path. The gain is headroom on the swap thread, which should matter at a higher cadence or on a slower CPU; the copies are also expected to overlap the MTP drafting that follows (the loop's own comment,generate.cpp:10615).generate.cpp:10180) queues 530 staged copies and waits once: pageable pays ~117 µs more per slot than pinned (313 → 197 µs), the bounce-staging term.blob()(three memcpys out of the mapped GGUF) then a blockingcudaMemcpy. The sweep of 10,244 distinct experts grows the stage pool to its ~257-buffer steady state before recycling kicks in; a buffer becomes reusable 256 assemblies after its last use.cudaHostAllocof 1.5 MiB measures 831 µs here vs 0.2 µs fornew[], so pinning the ~257 buffers accounts for ≈0.21 s — about two-thirds of the measured mean slowdown (4.5 → 4.8 s, +0.3 s); the remaining ~0.1 s is unexplained. Per-copy costs are otherwise strictly lower when pinned (findings 1–2), the fill loop is dominated by the mmap read (~440 µs per slot), and the loss is paid once at startup.Validation
--mmap-expertsruns; the micro-benchmark isolates the mechanismFileExpertSource: the stage buffers are pinned (cudaHostAlloc)exactly once; no baseline run doesslot 0 verifiedpasses in every run (the prefill reads one slot back and compares bytes)ctest87/89 —ple_parityandexpert_multi_testfail identically on the base commit on this host (the first needs the Q2_0 pack's GGUF, not present here; the second needs AVX512, which this Xeon lacks)Limits
blob()— the stage buffers are idle, and nothing measurable changes. The measured--mmap-expertsconfig stands in for any machine whose arena is smaller than its expert set.--prefilloff, that growth — and its ~0.2 s — moves out of startup and into the first stage-buffer users: the lent-slot refill alone queues 530 slots, more than the pool's steady state, so the refill would pay it inside its own ~0.1 s window. That configuration was not measured.expert_source.cpp:2721) is not measurable in this pack'sgenerateconfigs:--specrequires the token graph, and the graph decides hits on the device with residency frozen per token — "nothing is admitted during a token" (expert_source.cpp:2391; all four runs logged0 admitted). That path consumes the same buffers through the samefill_slot, so it stands to gain what findings 1–2 measure; it is cited by code inspection, not measured here.Reproduce
The micro-benchmark is not part of this PR; its parameters and build line are in the appendix, and its ~100-line source can be posted as a comment here if anyone wants to rerun it.
Raw `--stats` lines and the micro-benchmark
Engine A/B, all eight runs — 873-token task prompt, 512 generated tokens, interleaved baseline/patched pairs; both arms
--mmap-experts(supports findings 1–4):The trajectory families above are discussed in finding 4: replicas within an arm mostly agree in every logged counter (baseline r2–r4, patched r1/r2/r4); baseline r1's slow decode is the cold first run of the session (14.2 s; every later run 12.5–13.1 s), and its lookup total matches the patched family — so the families' arm correlation is weaker than it first looks.
Micro-benchmark — RTX 3080, 1.5 MiB blobs, 64-buffer rotating ring, 8 reps (512 copies per arm per variant), quiet machine.
host µsis wall time insidecudaMemcpyAsyncon the calling thread;end-to-end µsis stream time between events bracketing the copy, one synchronize per copy so nothing pipelines (supports the TL;DR and finding 2). Build and run:nvcc -O2 -arch=sm_86 bench_stage_copy.cu -o bench_stage_copy ./bench_stage_copy # defaults: 1.5 MiB, ring 64, 8 repsOne-time allocation cost, 1.5 MiB buffers, same rig (supports finding 3):
new[]0.2 µs;cudaHostAlloc831 µs per buffer when accumulating as the pool does — ×257 buffers ≈ 0.2 s once at startup.Work with Claude Code