重新思考 SGLang HiCache 的边界:从 L2 卸载到 Cache Runtime

从 HiCache L2 跨进程拆分出发,沿着 TreeCore、ReqToTokenPool、KV allocator、CUDA Graph 与 transfer path,重新收敛 logical cache control 和 GPU execution runtime 的 ownership boundary。

Code walkthroughsSGLang runtimeLLM servingKV cacheHiCacheSGLang

English version: Rethinking the SGLang HiCache Boundary: From L2 Offload to a Cache Runtime

这篇文章记录的是一次仍在进行中的设计推演,而不是已经定稿的 SGLang 架构。

起点是我在 SGLang 提出的 #37372: Out-of-process HiCache data plane with device-memory IPC。最初的问题很直接:能否把 HiCache 的 GPU ↔ Host transfer、Host KV pool 和 L3 I/O 从 scheduler 进程中拆出去,由独立 daemon 通过 CUDA IPC 操作 GPU KV?

随着 Unified Cache external linker、Rust TreeCore、ReqToTokenPool、CUDA Graph,以及 shared-replica 相关路径逐渐放到同一张图里,这个问题发生了变化。

现在我更关心的是:

SGLang 应该怎样分离 logical cache control 与 physical GPU/cache execution,同时不破坏现有 scheduler hot path,也不牺牲 compute / transfer overlap?

本文基于 2026-09-03 附近的 SGLang main,主要参考:

  • #37372 — out-of-process HiCache data plane
  • #35687 — Unified Cache external linker
  • #37381 — external linker end-to-end integration
  • #32710 — Rust TreeCore backend
  • #35648 — same-GPU replicas / shared KV pool

下面的 CacheRuntimeLocalGPURuntimeRequestRuntimeHandleCacheExtentRefBatchPlan 等名字仍然只是 design working names。

最初的假设:把 HiCache L2 data plane 搬到 daemon

最早的设计大致是:

text
Scheduler process
  ├── scheduling
  ├── TreeCore
  ├── prefix matching
  ├── GPU allocator
  └── backup / restore intent
              │
              │ CUDA IPC + control messages
              ▼
HiCache daemon
  ├── imported GPU KV mappings
  ├── H2D / D2H
  ├── Host KV pool
  ├── transfer queues
  └── L3 clients

这个方案有两个直观吸引力:

  1. storage / transfer work 可以从 scheduler 进程中移走;
  2. daemon 可以独立管理 pinned Host memory、transfer queue、batching、throttling 和 L3 connection。

但继续沿着 CUDA 和 current forward path 往下看后,我现在认为这个 split 预设得太早了。

关键问题不是“另一个进程能不能通过 CUDA IPC 看见 GPU KV”,而是:

control process 为什么需要拥有或理解 physical GPU state?

如果 ownership boundary 设计正确,它可能根本不需要。

当前 SGLang 的 Scheduler process 本身就是 control + GPU runtime 的合体

今天的 Scheduler 并不是一个只做 request policy 的轻量 control plane。

它会创建 TpModelWorker,而 worker 内部持有 ModelRunner。同一个 ownership domain 里还可以直接拿到:

text
ReqToTokenPool
TokenToKVPoolAllocator
ModelRunner
forward_stream
CUDA Graph state
HiCache controller / transfer state

例如 decode path 大致是:

text
Scheduler
  ↓
ScheduleBatch.prepare_for_decode()
  ↓
alloc_for_decode()
  ↓
KV allocator
  ↓
ReqToTokenPool.write()
  ↓
ModelRunner forward

这在 single-engine serving 下非常直接,也很高效。

所以当前架构的问题并不是“Scheduler 性能一定不够”,而是:

scheduling policy 与 physical GPU/cache mechanism 没有清晰的 ownership boundary。

单实例时这不是 bug;当我们开始考虑 shared replicas、runtime restart、L3 isolation 或更明确的 GPU resource scheduling 时,它才逐渐成为架构限制。

TreeCore split 已经给了一个很重要的 seam

当前 cache_action.py 的描述很直接:

text
A TreeCore emits CacheActions through the TreeCoreInterface
to guide Controller behavior.

TreeCore 负责 tree structure、matching、LRU、lock/ref 等逻辑,再产生 CacheAction,由 controller / pool side 执行实际动作。

概念上已经是:

text
TreeCore
   │
   │ decision / CacheAction
   ▼
Controller
   │
   ▼
physical pools

问题在于现有 CacheAction 仍然可以携带 torch.Tensor physical indices,例如:

text
FreeDeviceKV(indices)
FreeComponentDeviceSlot(indices)

这在 in-process 里很好用,但如果未来真的出现 runtime boundary,它就会变成 representation leak。

因此,比设计一个新的 HiCacheDaemonProtocol 更自然的方向,是继续沿着现有 seam,把 control 对 raw physical representation 的依赖逐步收窄。

ReqToTokenPool 是真正困难的 ownership boundary

ReqToTokenPool 看起来像 bookkeeping,但实际上同时包含三类东西:

text
request lifetime
+
request-row allocator
+
GPU-resident request -> KV location table

其中 req_to_token 本身是 device tensor,attention backend 会直接使用它;prefill/extenddecode allocation 也会直接更新它。

所以如果把 model execution 和 physical cache execution 分出去,至少有一点非常明确:

device-side ReqToToken table、KV allocator 和 model execution 应该留在同一个 GPU execution domain。

这也是为什么简单地把现有 method 机械改成 RPC 会出问题。

如果 decode 变成:

text
allocate RPC
→ ReqToToken write RPC
→ forward RPC

那么 IPC 会直接进入 per-token hot path。

这是我现在会写进设计里的第一条硬约束:

No per-token IPC.

Design update — 2026-09-03:ownership boundary 是架构,process topology 只是实现

继续分析 current Scheduler、ModelRunner、HiCache transfer path 和 CUDA execution model 后,我现在不再认为“独立 HiCache transfer daemon”应该是第一实现。

更准确的架构问题应该是:

text
What belongs to control?
What belongs to GPU execution?

而不是先问:

text
Which process should own L2?

我现在倾向的目标边界是:

layer view

当前更倾向的 control / GPU runtime boundary

Control 决定 WHAT;GPU Runtime 决定 HOW / WHEN on the GPU。

Logical Cache / Scheduling Control

policy and logical state

SchedulerTreeCoreprefix matchingadmissioncache policylock/ref semantics

Runtime contract

batch-oriented intent / completion

BatchPlanRequestRuntimeHandleCacheExtentRefcapacitygeneration

GPU Execution Runtime

physical state and CUDA ordering

ModelRunnerReqToTokenKV allocatorGPU KV poolsCUDA GraphH2D/D2Hstreams/events

这句话是这一轮讨论里最重要的结论:

Process topology is an implementation choice. Ownership boundary is the architecture.

也就是说,同一个 runtime contract 可以先有:

text
LocalGPURuntime     # same process
ProcessGPURuntime   # separate process
SharedGPURuntime    # future shared authority

第一阶段完全不需要预设最终一定是 daemon。

为什么 compute、H2D、D2H 应该留在同一个 GPU execution domain

compute / I/O overlap 的来源不是“多一个进程”,而是 GPU 本身的 execution resources:

text
compute stream
H2D stream
D2H stream
CUDA events
copy engines

例如 restore 的真正 dependency 是:

text
H2D stream
    │
    │ restore KV
    ▼
KV_READY event
    │
    ▼
compute stream
    │
    ▼
attention / forward

backup 则是:

text
compute produces KV
    │
    ▼
CUDA event
    │
    ▼
D2H stream
    │
    ▼
pinned Host buffer

如果 H2D/D2H 和 model compute 位于同一个 CUDA execution domain,这些 dependency 可以用本地 stream/event 精确表达。

如果为了 process isolation 把 transfer CUDA execution 移到另一个 context,反而会引入:

text
CUDA IPC mapping
cross-process event lifetime
context scheduling
failure cleanup
extra completion protocol

所以我现在更倾向:

把 cache transfer control 从 Scheduler 中拆开,但把 H2D/D2H execution 和 ModelRunner 放在一起。

这并不意味着只用一个 CUDA stream。恰恰相反,GPU Runtime 内部应该能够独立调度:

text
compute stream
H2D stream
D2H stream

并根据 decode latency、PCIe/HBM pressure 和 transfer priority 做 overlap。

四个 runtime invariant

这轮设计可以先锁四条 invariant,而不是先锁 RPC library。

1. No per-token IPC

下面这些都应该 runtime-local:

text
KV alloc/free
ReqToToken write
attention metadata
CUDA Graph replay
kernel launch
physical free-list manipulation

跨 boundary 的粒度至少应该是一次 scheduling/batch decision,而不是一次 memory operation。

2. CUDA-dependent ordering stays runtime-local

Control side 不应该持有或操作:

text
cudaStream_t
cudaEvent_t
GPU pointer
CUDA Graph execution state

它只描述 dependency 和 intent。

3. Control does not own physical GPU addresses

Control 最多持有:

text
RequestRuntimeHandle
RuntimePageId / PageRun
CacheExtentRef
generation

而不是:

text
torch.Tensor device_indices
raw GPU pointer
allocator free list

4. Compute / transfer overlap is a runtime responsibility

Scheduler 可以说:

text
Batch N+1 needs extent X

但由 runtime 决定:

text
which H2D stream
which event
when compute waits
whether D2H write-back should be throttled

PrepareBatch / ExecuteBatch 比一个大 forward RPC 更适合 overlap

如果只有:

text
execute_batch(N+1)

那么 runtime 直到真正要运行 N+1 时才知道需要 restore 什么,H2D overlap window 会很短。

更合适的是两阶段:

text
prepare_batch(plan)
    -> PreparedBatchHandle

execute_batch(handle)
    -> BatchCompletion

Scheduler 在 GPU 正执行 Batch N 时,已经可以开始准备 N+1:

call path

Prepare N+1 while N is computing

SchedulerGPU RuntimeH2D StreamCompute Stream
  1. 1

    Scheduler

    GPU Runtime

    prepare_batch(N+1)

  2. 2

    GPU Runtime

    H2D Stream

    prefetch / restore N+1

  3. 3

    Compute Stream

    Compute Stream

    Batch N compute continues

  4. 4

    H2D Stream

    GPU Runtime

    KV_READY(N+1)

  5. 5

    Scheduler

    GPU Runtime

    execute_batch(N+1)

  6. 6

    GPU Runtime

    Compute Stream

    launch when dependencies are ready

目标时间线是:

text
time ─────────────────────────────────────>

CPU scheduler    prepare N+1
                 ███████

GPU compute N    ███████████████████

H2D for N+1             ███████

GPU compute N+1                       █████████████

这和 SGLang 已有 overlap scheduling 的方向是兼容的:CPU 准备下一批时,不只构造 scheduling metadata,也可以更早地产生 cache prefetch intent。

Runtime 内部不一定需要“Compute worker / I/O worker”两个 GPU worker

GPU concurrency 主要来自 CUDA stream,而不是 CPU worker 数量。

一个 runtime dispatcher 就可以向不同 stream enqueue:

text
GPU Runtime process
│
├── runtime dispatcher / execution loop
│
├── compute stream
├── H2D stream
├── D2H stream
└── CUDA events

CPU thread 拆分真正有价值的地方更偏向 blocking / host-side I/O,例如:

text
Mooncake RPC
RDMA completion
NVMe / filesystem
storage retry
Host buffer management

这些可以放到 storage worker 或独立 storage service,而不需要把 CUDA transfer ownership 一起搬走。

RequestRuntimeHandle 与 CacheExtentRef

如果 Scheduler 不再拥有 physical allocator,就不能继续把裸 req_pool_idx 和 physical page tensor 当长期 contract。

一个可能的 request handle 是:

text
RequestRuntimeHandle {
    runtime_id
    slot_id
    generation
}

Scheduler 持有 handle,但 slot allocation 和 generation authority 在 runtime。

对于 prefix cache,我现在也不急着一步到位设计复杂的 distributed object。

第一阶段可以从 typed page IDs / page runs 开始:

text
CacheExtentRef {
    runtime_id
    generation
    page_runs[]
}

PageRun {
    first_page
    page_count
}

重点不是这些 ID 是否立即完全 opaque,而是:

它们由 runtime 定义生命周期,control 不把它们当作自己拥有的 physical address。

未来如果 shared replicas / migration 真正需要,再演进成更强的 opaque extent model。

TreeCore node 最终保存什么仍然是最重要的开放问题

当前 TreeCore / MatchResult 仍然和 physical device_indices 有联系。

如果长期目标是清晰的 control/runtime split,那么 TreeCore node 的 value 可能需要沿着下面的方向演进:

text
today
raw physical tensors / indices

        ↓

phase 1
runtime page IDs / page runs

        ↓

future
opaque CacheExtentRef

我不建议第一阶段直接把 TreeCore 变成 remote metadata service。

TreeCore、prefix matching、LRU、lock/ref 仍然应该留在 control side;需要收窄的是它对 physical placement 的了解程度。

第一实现应该是 LocalGPURuntime,而不是 daemon

这也是当前 design update 最具体的工程结论。

第一阶段应该先做:

text
Scheduler
    │
    │ RuntimeInterface
    ▼
LocalGPURuntime
    ├── ReqToToken
    ├── KV allocator
    ├── physical pools
    ├── ModelRunner
    ├── CUDA Graph
    └── H2D / D2H

仍然是:

text
same process

这样可以只验证 ownership boundary,而不用同时解决:

text
IPC transport
CUDA IPC
cross-process CUDA events
daemon lifecycle
crash cleanup

如果同一个 RuntimeInterface 在 local implementation 下就很难成立,那么直接引入 daemon 只会把错误的 seam 固化下来。

什么时候才值得变成 ProcessGPURuntime

out-of-process 不应该是目的,而应该由收益触发。

我认为至少出现下面一种明确需求时才值得认真做:

  1. scheduler / cache CPU interference 已经能在 benchmark 中看到;
  2. 需要 scheduler 与 GPU runtime 独立 lifecycle;
  3. 需要 shared replica / shared KV allocator authority;
  4. 需要多个 control clients 驱动同一个 GPU execution runtime;
  5. 需要更强的 fault isolation。

这时 process split 更可能是:

text
Scheduler process
      │
      │ RuntimeInterface over IPC
      ▼
GPU Runtime process
      ├── ModelRunner
      ├── ReqToToken
      ├── allocator
      ├── KV pool
      └── H2D / D2H

而不是原始 RFC 中的:

text
Scheduler + ModelRunner
      │ CUDA IPC
      ▼
HiCache transfer daemon

也就是说:

如果真的跨进程,更自然的是把 physical GPU execution 整体形成一个 runtime ownership domain,而不是单独把 transfer 从 ModelRunner 身边切走。

L1 ↔ L2 仍然是最好的 cache validation surface

虽然第一阶段不再以 daemon 为目标,L1 ↔ L2 仍然是很好的 runtime boundary 测试面。

可以从:

text
single scheduler
single GPU / TP-rank semantics
Full Attention first
LocalGPURuntime
TreeCore control-local
L1 ↔ L2 backup / restore

开始。

真正应该验证的是:

text
prepare_batch(N+1)
能否在 compute(N) 时提前 H2D

D2H write-back
能否在不伤害 decode ITL 的情况下后台推进

Scheduler
是否已经不再直接依赖 allocator / ReqToToken physical mutation

而不是证明 CUDA IPC 本身可用。

buffer_only 进一步证明 API 不应该围绕 L2 service 建模

buffer_only 下 Host memory 甚至不是真正意义上的 L2 residency,它只是 GPU ↔ storage 之间的 staging buffer:

text
GPU KV
  │ D2H
  ▼
pinned Host staging
  │ storage write
  ▼
L3

或者:

text
L3
  │ storage read
  ▼
pinned Host staging
  │ H2D
  ▼
GPU KV

因此 PinnedHostBufferPool、H2D/D2H streams 和 CUDA event 应该更接近 GPU Runtime;storage network / filesystem / Mooncake client 则可以独立出去。

更自然的分界是:

text
GPU Runtime
    ├── GPU-facing Host staging
    └── CUDA transfer ordering

Storage side
    ├── Mooncake / RDMA / network
    ├── NVMe
    ├── retry / replication
    └── storage metadata

External linker 仍然保持 orthogonal

我仍然不建议把 UnifiedCacheLinker 和新的 runtime boundary 混成一个 abstraction。

text
UnifiedCacheLinker
  direct L1 <-> external/global cache path

RuntimeInterface
  control <-> physical GPU/cache execution boundary

以后 linker execution 如果需要运行在 GPU Runtime 里,可以作为 runtime capability 接进去,但两者解决的问题不同。

Shared replicas 是判断这个 abstraction 是否足够 general 的测试题

#35648 的 same-GPU replicas 场景仍然非常有价值,因为它暴露了:

text
allocation authority
publication
reference lifetime
eviction
generation
failure recovery

如果将来:

text
Scheduler A ─┐
Scheduler B ─┼── SharedGPURuntime
Scheduler C ─┘       ├ allocator
                      └ shared KV pool

那么 single authoritative allocator 比每个 scheduler 各自维护 raw physical index 更自然。

但这只是 design test,不应该成为 v1 scope。第一阶段不需要解决 multi-scheduler TreeCore、distributed refcount 或 MPS lifecycle。

更新后的演进顺序

我现在会把实现顺序改成:

text
Phase 0 — define ownership

  明确 Scheduler / TreeCore / ReqToToken /
  allocator / pools / streams 谁是 authoritative owner。

Phase 1 — LocalGPURuntime, same process

  引入 RuntimeInterface。
  Scheduler 不再直接做 physical KV allocation / ReqToToken mutation。
  暂时没有 daemon、CUDA IPC。

Phase 2 — L1 ↔ L2 under RuntimeInterface

  加入 prepare_batch / execute_batch。
  验证 compute(N) 与 H2D(N+1)、D2H write-back 的 overlap。

Phase 3 — benchmark the boundary

  TTFT / ITL / scheduler CPU / GPU bubbles /
  transfer throughput / HBM & PCIe contention。

Phase 4 — optional ProcessGPURuntime

  只有当 isolation、shared authority 或 measurable interference
  真正需要时再把 RuntimeInterface 换成 IPC implementation。

Phase 5 — shared replicas / broader cache state

  再讨论 multi-scheduler authority、shared KV、
  SWA / Mamba / DSA 等 multi-component state。

这仍然不是 roadmap,而是尽量把不同风险分开验证。

现在真正的问题

最初的问题是:

HiCache L2 能否独立运行在另一个进程中?

后来变成:

SGLang 能否把 logical cache control 与 physical memory/execution runtime 拆开?

现在我认为还可以再精确一点:

Can SGLang define a GPU-runtime ownership boundary that keeps per-token physical cache work and CUDA ordering local, while allowing the scheduler to prepare future cache dependencies early enough to preserve or improve compute/transfer overlap?

如果答案是可以,那么 daemon 只是 RuntimeInterface 的一种部署方式,而不是 architecture 本身。

如果答案是否,也应该尽早知道究竟是哪里要求 tight coupling:ReqToTokenPool、CUDA Graph、attention metadata、TreeCore physical representation,还是 speculative / hybrid cache path。

这也是当前设计最值得先验证的部分。

这篇文章会随着 #37372 的讨论继续更新。它记录的不是一份冻结的 RFC,而是这个 ownership boundary 如何随着代码路径和硬件约束被逐步推导出来。