Skip to content

perf(kvflash): async page-out D2H and page-in H2D via pinned copy stream#408

Merged
davide221 merged 4 commits into
Luce-Org:mainfrom
cheese-cakee:perf/kvflash-async-dma
Jun 19, 2026
Merged

perf(kvflash): async page-out D2H and page-in H2D via pinned copy stream#408
davide221 merged 4 commits into
Luce-Org:mainfrom
cheese-cakee:perf/kvflash-async-dma

Conversation

@cheese-cakee

@cheese-cakee cheese-cakee commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

KvFlashPager currently blocks the decode thread for every page-out (D2H, 1.26 ms p50) and page-in (H2D, 0.63 ms p50) via synchronous ggml_backend_tensor_get/set. This PR replaces those with cudaMemcpyAsync on a dedicated page_stream_, making page-out fire-and-forget and page-in paid at most once per alloc_span() call.

Changes

  • server/src/common/kvflash_pager.h
    • Include gpu_runtime_compat.h when DFLASH27B_BACKEND_CUDA, DFLASH27B_BACKEND_HIP, or GGML_USE_HIP is set; define KVFLASH_HAS_ASYNC_DMA
    • ChunkState::host_data: std::vector<uint8_t>void * (cudaMallocHost-pinned) under KVFLASH_HAS_ASYNC_DMA
    • Add cudaStream_t page_stream_ and bool has_pending_page_in_ to KvFlashPager
    • Add destructor + private cleanup_() that syncs and frees pinned buffers then destroys the stream
    • attach(): calls cleanup_() before re-init, creates stream with cudaStreamCreate
    • reset(): syncs stream + frees all pinned host_data before clearing chunks_
    • zero_free_blocks(): syncs stream after async memsets
    • copy_chunk(): cudaMemcpyAsync(DeviceToHost / HostToDevice, page_stream_) replaces ggml_backend_tensor_get/set
    • zero_block(): cudaMemsetAsync(..., page_stream_) replaces ggml_backend_tensor_set with zero buffer
    • alloc_span() / reselect(): cudaStreamSynchronize(page_stream_) only when has_pending_page_in_ is set; page-outs left async

How it works

All transfers share page_stream_, giving this ordering guarantee within each alloc_span() / reselect() call:

D2H (evict chunk C to host)
  → cudaMemsetAsync (zero freed GPU slot)
    → H2D (recall chunk C' from host)

No cross-stream events needed. The CPU thread is unblocked after issuing the transfers; it syncs only if a page-in was queued (H2D must land before the attention forward reads the recalled rows).

Performance / Numbers

Measured baselines from server/DESIGN.md and test_kvflash profile (E):

Operation Before (synchronous) After (async)
page_out D2H p50 1.26 ms — blocks decode thread queued async; ~0 ms on critical path
page_in H2D p50 0.63 ms — blocks decode thread one cudaStreamSynchronize per step with recall
N evictions + M recalls sum of individual transfers single sync at end covers all

Numbers not yet validated on RTX 3090 (no local GPU). Benchmark via test_kvflash --skip-profile section E, or by instrumenting alloc_span() with std::chrono.

Limitations / Known follow-up

  • No cross-stream sync between page_stream_ and ggml's compute stream: relies on ggml_backend_graph_compute already synchronising before alloc_span() is called, which holds for the current single-threaded inference loop. If a future caller issues an async graph compute and calls alloc_span() concurrently the ordering must be revisited.
  • Page-out D2H is fire-and-forget; if reset() is called before the DMA completes, reset() syncs page_stream_ first — safe, but adds latency at reset time.
  • cudaStreamCreate return value is not checked; a failed create leaves page_stream_ = nullptr, in which case cudaMemcpyAsync/cudaMemsetAsync fall back to the default (synchronous) stream — correct but unintentionally blocking.
  • CUDA graph for KVFlash spec-verify path will need cudaEvent_t page_done_event_ so the compute graph can cudaStreamWaitEvent instead of a hard sync; that is left for the graph PR.

Verification / Test plan

  • Build: cmake -B server/build -S server -DCMAKE_BUILD_TYPE=Release && cmake --build server/build --target test_kvflash -j
  • Correctness: server/build/test_kvflash <qwen35.gguf> — suites A–D (relocation, paging, reselect/recall) must pass bit-exact vs baseline
  • Performance: suite E decode ms/step and page-event microbenchmark at pool=1K/4K / logical=128K
  • HIP: gfx1151 ROCm build (same gpu_runtime_compat.h shim used by qwen35_dflash_target.cpp since qwen35: include gpu_runtime_compat.h for HIP builds #395)

Review in cubic

Replace synchronous ggml_backend_tensor_get/set in KvFlashPager::copy_chunk
with cudaMemcpyAsync on a dedicated page_stream_.  Host backing buffers
(ChunkState::host_data) are now cudaMallocHost-pinned for maximum PCIe
throughput instead of std::vector<uint8_t> in pageable memory.
zero_block() uses cudaMemsetAsync on the same stream so the slot zero is
always serialised after the preceding D2H and before any subsequent H2D
that reuses the slot.

Page-out D2H fires and is left running — no CPU block after eviction.
alloc_span() synchronises page_stream_ only when a page-in (H2D) was
issued this step, ensuring recalled KV data is resident before the next
attention forward.  reselect() applies the same single-sync pattern after
batching N evictions + M recalls.

Because all transfers and memsets share one stream, the ordering guarantee
is: D2H(evict) → memset(zero) → H2D(recall) within a single
alloc_span/reselect call, with no need for cross-stream events.

Build guard: KVFLASH_HAS_ASYNC_DMA is defined only when
DFLASH27B_BACKEND_CUDA, DFLASH27B_BACKEND_HIP, or GGML_USE_HIP is set.
Non-CUDA builds fall back to the original ggml_backend_tensor_get/set path
with std::vector<uint8_t> backing unchanged.

Expected: page_out latency (1.26 ms p50 per 64-token chunk) and page_in
latency (0.63 ms p50) no longer on the decode-thread critical path.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 1 file

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread server/src/common/kvflash_pager.h Outdated
Comment thread server/src/common/kvflash_pager.h
…ool state

- Check cudaMallocHost return in page_out(), return false on failure
- Check cudaMemcpyAsync return in copy_chunk(), log D2H/H2D direction
- Check cudaMemsetAsync return in zero_block(), log on failure
- Check cudaFreeHost return in reset() and cleanup_(), log on failure
- Remove pool state (chunks_, free_blocks_, n_blocks_) from cleanup_()

cleanup_() now only handles GPU resources (stream + pinned memory).
Pool state is managed by reset() and the vector destructor.
uint64_t clock_ = 0;
uint64_t epoch_ = 0;

#ifdef KVFLASH_HAS_ASYNC_DMA

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any side impact to introduce this option? If this is always better, maybe we can default on this.

@cheese-cakee

cheese-cakee commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Small clarification first — KVFLASH_HAS_ASYNC_DMA isn't a flag you can toggle, it's a compile-time #ifdef that auto-enables on every CUDA/HIP build:

#if defined(DFLASH27B_BACKEND_CUDA) || defined(DFLASH27B_BACKEND_HIP) || defined(GGML_USE_HIP)
#include "gpu_runtime_compat.h"
#define KVFLASH_HAS_ASYNC_DMA 1
#endif

The sync path is just the CPU fallback. So it's already "on" everywhere a GPU exists.

On side impacts, the main one is pinned host memory. cudaMallocHost allocations can't be swapped and compete with CUDA's own pinned pool, so over-allocating hurts the whole system. The ordering assumption (sync before alloc_span) also only holds because the current compute loop is single-threaded, would need cross-stream events if that changes.

Lucebox Test added 2 commits June 19, 2026 13:28
…ream lifecycle

page_in()/page_out() copy on page_stream_ asynchronously; alloc_span() and
reselect() batch the page-in sync, which is correct for the decode path. A
caller that issues page_in() directly and then reads device KV (the
test_kvflash roundtrip) raced the async H2D on HIP, where the default stream
does not implicitly synchronize with page_stream_ (masked on CUDA). Add a
public synchronize_paging(), route the alloc_span/reselect batched syncs through
it, and call it in the roundtrip test before reading device KV.

Harden the stream lifecycle: check cudaStreamCreate() and fall back to the
default stream on failure; free pinned host_data unconditionally in cleanup_()
so a failed create does not leak; synchronize page_stream_ unconditionally
(null == default stream).

Realign server/deps/llama.cpp to main (574be6132) so merging this branch does
not downgrade the submodule.
@davide221 davide221 merged commit d279eab into Luce-Org:main Jun 19, 2026
5 checks passed
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 23, 2026
Add serialize()/deserialize() to KvFlashPager (snapshot the full resident+paged
KV in logical chunk order; header-validated against layout) and a factored
for_each_segment() helper. serde uses synchronous get/set and adapts to the
pinned void* host_data of the async-DMA path (Luce-Org#408). Add critical-chunk pinning
(pin_range/is_pinned/unpin_all + a best-effort deadlock floor) OR-ed into the
ensure_free_block + reselect protections; empty by default (byte-identical
non-pin path). CPU unit test (no GPU) covers serde round-trip, header-guard
reject, pinning, deadlock guard, reset.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
Add serialize()/deserialize() to KvFlashPager (snapshot the full resident+paged
KV in logical chunk order; header-validated against layout) and a factored
for_each_segment() helper. serde uses synchronous get/set and adapts to the
pinned void* host_data of the async-DMA path (Luce-Org#408). Add critical-chunk pinning
(pin_range/is_pinned/unpin_all + a best-effort deadlock floor) OR-ed into the
ensure_free_block + reselect protections; empty by default (byte-identical
non-pin path). CPU unit test (no GPU) covers serde round-trip, header-guard
reject, pinning, deadlock guard, reset.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
…r KVFlash

Drive the MoE cold-expert hybrid path through KVFlash's resident pool: prompts
larger than the pool prefill via a chunk loop over hybrid_forward_batch (eviction
automatic in alloc_span); the restore residual delta routes through the same
chunked path. Pooled snapshot save/restore serializes the pager into the prefix
snapshot (PrefixSnapshot += is_pooled + blob; snapshot_target_cache/restore gain
skip_kv; the blob rides the disk prefix-cache via a named tensor so cross-turn
128K restore composes). Drafter-scorer residency + DFLASH_KVFLASH_PIN_SPANS
critical-chunk pinning wired in. Composes with the landed KVFlash (Luce-Org#373/Luce-Org#408/Luce-Org#385)
and MoE restore (Luce-Org#362); serde adapts to the async pinned host_data.

GPU gate (RTX 3090): pooled prefill preserves sink context + stable across pool
sizes; cross-turn disk restore round-trips losslessly.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
Add serialize()/deserialize() to KvFlashPager (snapshot the full resident+paged
KV in logical chunk order; header-validated against layout) and a factored
for_each_segment() helper. serde uses synchronous get/set and adapts to the
pinned void* host_data of the async-DMA path (Luce-Org#408). Add critical-chunk pinning
(pin_range/is_pinned/unpin_all + a best-effort deadlock floor) OR-ed into the
ensure_free_block + reselect protections; empty by default (byte-identical
non-pin path). CPU unit test (no GPU) covers serde round-trip, header-guard
reject, pinning, deadlock guard, reset.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
Add serialize()/deserialize() to KvFlashPager (snapshot the full resident+paged
KV in logical chunk order; header-validated against layout) and a factored
for_each_segment() helper. serde uses synchronous get/set and adapts to the
pinned void* host_data of the async-DMA path (Luce-Org#408). Add critical-chunk pinning
(pin_range/is_pinned/unpin_all + a best-effort deadlock floor) OR-ed into the
ensure_free_block + reselect protections; empty by default (byte-identical
non-pin path). CPU unit test (no GPU) covers serde round-trip, header-guard
reject, pinning, deadlock guard, reset.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
…r KVFlash

Drive the MoE cold-expert hybrid path through KVFlash's resident pool: prompts
larger than the pool prefill via a chunk loop over hybrid_forward_batch (eviction
automatic in alloc_span); the restore residual delta routes through the same
chunked path. Pooled snapshot save/restore serializes the pager into the prefix
snapshot (PrefixSnapshot += is_pooled + blob; snapshot_target_cache/restore gain
skip_kv; the blob rides the disk prefix-cache via a named tensor so cross-turn
128K restore composes). Drafter-scorer residency + DFLASH_KVFLASH_PIN_SPANS
critical-chunk pinning wired in. Composes with the landed KVFlash (Luce-Org#373/Luce-Org#408/Luce-Org#385)
and MoE restore (Luce-Org#362); serde adapts to the async pinned host_data.

GPU gate (RTX 3090): pooled prefill preserves sink context + stable across pool
sizes; cross-turn disk restore round-trips losslessly.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
Add serialize()/deserialize() to KvFlashPager (snapshot the full resident+paged
KV in logical chunk order; header-validated against layout) and a factored
for_each_segment() helper. serde uses synchronous get/set and adapts to the
pinned void* host_data of the async-DMA path (Luce-Org#408). Add critical-chunk pinning
(pin_range/is_pinned/unpin_all + a best-effort deadlock floor) OR-ed into the
ensure_free_block + reselect protections; empty by default (byte-identical
non-pin path). CPU unit test (no GPU) covers serde round-trip, header-guard
reject, pinning, deadlock guard, reset.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
…r KVFlash

Drive the MoE cold-expert hybrid path through KVFlash's resident pool: prompts
larger than the pool prefill via a chunk loop over hybrid_forward_batch (eviction
automatic in alloc_span); the restore residual delta routes through the same
chunked path. Pooled snapshot save/restore serializes the pager into the prefix
snapshot (PrefixSnapshot += is_pooled + blob; snapshot_target_cache/restore gain
skip_kv; the blob rides the disk prefix-cache via a named tensor so cross-turn
128K restore composes). Drafter-scorer residency + DFLASH_KVFLASH_PIN_SPANS
critical-chunk pinning wired in. Composes with the landed KVFlash (Luce-Org#373/Luce-Org#408/Luce-Org#385)
and MoE restore (Luce-Org#362); serde adapts to the async pinned host_data.

GPU gate (RTX 3090): pooled prefill preserves sink context + stable across pool
sizes; cross-turn disk restore round-trips losslessly.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
Add serialize()/deserialize() to KvFlashPager (snapshot the full resident+paged
KV in logical chunk order; header-validated against layout) and a factored
for_each_segment() helper. serde uses synchronous get/set and adapts to the
pinned void* host_data of the async-DMA path (Luce-Org#408). Add critical-chunk pinning
(pin_range/is_pinned/unpin_all + a best-effort deadlock floor) OR-ed into the
ensure_free_block + reselect protections; empty by default (byte-identical
non-pin path). CPU unit test (no GPU) covers serde round-trip, header-guard
reject, pinning, deadlock guard, reset.
dusterbloom added a commit to dusterbloom/lucebox-hub that referenced this pull request Jun 24, 2026
…r KVFlash

Drive the MoE cold-expert hybrid path through KVFlash's resident pool: prompts
larger than the pool prefill via a chunk loop over hybrid_forward_batch (eviction
automatic in alloc_span); the restore residual delta routes through the same
chunked path. Pooled snapshot save/restore serializes the pager into the prefix
snapshot (PrefixSnapshot += is_pooled + blob; snapshot_target_cache/restore gain
skip_kv; the blob rides the disk prefix-cache via a named tensor so cross-turn
128K restore composes). Drafter-scorer residency + DFLASH_KVFLASH_PIN_SPANS
critical-chunk pinning wired in. Composes with the landed KVFlash (Luce-Org#373/Luce-Org#408/Luce-Org#385)
and MoE restore (Luce-Org#362); serde adapts to the async pinned host_data.

GPU gate (RTX 3090): pooled prefill preserves sink context + stable across pool
sizes; cross-turn disk restore round-trips losslessly.
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.

3 participants