重新思考 SGLang HiCache 的边界:从 L2 卸载到 Cache Runtime
从 HiCache L2 跨进程拆分出发,沿着 TreeCore、ReqToTokenPool、KV allocator、CUDA Graph 与 transfer path,重新收敛 logical cache control 和 GPU execution runtime 的 ownership boundary。
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
下面的 CacheRuntime、LocalGPURuntime、RequestRuntimeHandle、CacheExtentRef、BatchPlan 等名字仍然只是 design working names。
最初的假设:把 HiCache L2 data plane 搬到 daemon
最早的设计大致是:
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
这个方案有两个直观吸引力:
- storage / transfer work 可以从 scheduler 进程中移走;
- 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 里还可以直接拿到:
ReqToTokenPool
TokenToKVPoolAllocator
ModelRunner
forward_stream
CUDA Graph state
HiCache controller / transfer state
例如 decode path 大致是:
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 的描述很直接:
A TreeCore emits CacheActions through the TreeCoreInterface
to guide Controller behavior.
TreeCore 负责 tree structure、matching、LRU、lock/ref 等逻辑,再产生 CacheAction,由 controller / pool side 执行实际动作。
概念上已经是:
TreeCore
│
│ decision / CacheAction
▼
Controller
│
▼
physical pools
问题在于现有 CacheAction 仍然可以携带 torch.Tensor physical indices,例如:
FreeDeviceKV(indices)
FreeComponentDeviceSlot(indices)
这在 in-process 里很好用,但如果未来真的出现 runtime boundary,它就会变成 representation leak。
因此,比设计一个新的 HiCacheDaemonProtocol 更自然的方向,是继续沿着现有 seam,把 control 对 raw physical representation 的依赖逐步收窄。
ReqToTokenPool 是真正困难的 ownership boundary
ReqToTokenPool 看起来像 bookkeeping,但实际上同时包含三类东西:
request lifetime
+
request-row allocator
+
GPU-resident request -> KV location table
其中 req_to_token 本身是 device tensor,attention backend 会直接使用它;prefill/extend 和 decode allocation 也会直接更新它。
所以如果把 model execution 和 physical cache execution 分出去,至少有一点非常明确:
device-side ReqToToken table、KV allocator 和 model execution 应该留在同一个 GPU execution domain。
这也是为什么简单地把现有 method 机械改成 RPC 会出问题。
如果 decode 变成:
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”应该是第一实现。
更准确的架构问题应该是:
What belongs to control?
What belongs to GPU execution?
而不是先问:
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
Runtime contract
batch-oriented intent / completion
GPU Execution Runtime
physical state and CUDA ordering
这句话是这一轮讨论里最重要的结论:
Process topology is an implementation choice. Ownership boundary is the architecture.
也就是说,同一个 runtime contract 可以先有:
LocalGPURuntime # same process
ProcessGPURuntime # separate process
SharedGPURuntime # future shared authority
第一阶段完全不需要预设最终一定是 daemon。
为什么 compute、H2D、D2H 应该留在同一个 GPU execution domain
compute / I/O overlap 的来源不是“多一个进程”,而是 GPU 本身的 execution resources:
compute stream
H2D stream
D2H stream
CUDA events
copy engines
例如 restore 的真正 dependency 是:
H2D stream
│
│ restore KV
▼
KV_READY event
│
▼
compute stream
│
▼
attention / forward
backup 则是:
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,反而会引入:
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 内部应该能够独立调度:
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:
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 不应该持有或操作:
cudaStream_t
cudaEvent_t
GPU pointer
CUDA Graph execution state
它只描述 dependency 和 intent。
3. Control does not own physical GPU addresses
Control 最多持有:
RequestRuntimeHandle
RuntimePageId / PageRun
CacheExtentRef
generation
而不是:
torch.Tensor device_indices
raw GPU pointer
allocator free list
4. Compute / transfer overlap is a runtime responsibility
Scheduler 可以说:
Batch N+1 needs extent X
但由 runtime 决定:
which H2D stream
which event
when compute waits
whether D2H write-back should be throttled
PrepareBatch / ExecuteBatch 比一个大 forward RPC 更适合 overlap
如果只有:
execute_batch(N+1)
那么 runtime 直到真正要运行 N+1 时才知道需要 restore 什么,H2D overlap window 会很短。
更合适的是两阶段:
prepare_batch(plan)
-> PreparedBatchHandle
execute_batch(handle)
-> BatchCompletion
Scheduler 在 GPU 正执行 Batch N 时,已经可以开始准备 N+1:
call path
Prepare N+1 while N is computing
- 1
Scheduler
GPU Runtime
prepare_batch(N+1)
- 2
GPU Runtime
H2D Stream
prefetch / restore N+1
- 3
Compute Stream
Compute Stream
Batch N compute continues
- 4
H2D Stream
GPU Runtime
KV_READY(N+1)
- 5
Scheduler
GPU Runtime
execute_batch(N+1)
- 6
GPU Runtime
Compute Stream
launch when dependencies are ready
目标时间线是:
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:
GPU Runtime process
│
├── runtime dispatcher / execution loop
│
├── compute stream
├── H2D stream
├── D2H stream
└── CUDA events
CPU thread 拆分真正有价值的地方更偏向 blocking / host-side I/O,例如:
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 是:
RequestRuntimeHandle {
runtime_id
slot_id
generation
}
Scheduler 持有 handle,但 slot allocation 和 generation authority 在 runtime。
对于 prefix cache,我现在也不急着一步到位设计复杂的 distributed object。
第一阶段可以从 typed page IDs / page runs 开始:
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 可能需要沿着下面的方向演进:
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 最具体的工程结论。
第一阶段应该先做:
Scheduler
│
│ RuntimeInterface
▼
LocalGPURuntime
├── ReqToToken
├── KV allocator
├── physical pools
├── ModelRunner
├── CUDA Graph
└── H2D / D2H
仍然是:
same process
这样可以只验证 ownership boundary,而不用同时解决:
IPC transport
CUDA IPC
cross-process CUDA events
daemon lifecycle
crash cleanup
如果同一个 RuntimeInterface 在 local implementation 下就很难成立,那么直接引入 daemon 只会把错误的 seam 固化下来。
什么时候才值得变成 ProcessGPURuntime
out-of-process 不应该是目的,而应该由收益触发。
我认为至少出现下面一种明确需求时才值得认真做:
- scheduler / cache CPU interference 已经能在 benchmark 中看到;
- 需要 scheduler 与 GPU runtime 独立 lifecycle;
- 需要 shared replica / shared KV allocator authority;
- 需要多个 control clients 驱动同一个 GPU execution runtime;
- 需要更强的 fault isolation。
这时 process split 更可能是:
Scheduler process
│
│ RuntimeInterface over IPC
▼
GPU Runtime process
├── ModelRunner
├── ReqToToken
├── allocator
├── KV pool
└── H2D / D2H
而不是原始 RFC 中的:
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 测试面。
可以从:
single scheduler
single GPU / TP-rank semantics
Full Attention first
LocalGPURuntime
TreeCore control-local
L1 ↔ L2 backup / restore
开始。
真正应该验证的是:
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:
GPU KV
│ D2H
▼
pinned Host staging
│ storage write
▼
L3
或者:
L3
│ storage read
▼
pinned Host staging
│ H2D
▼
GPU KV
因此 PinnedHostBufferPool、H2D/D2H streams 和 CUDA event 应该更接近 GPU Runtime;storage network / filesystem / Mooncake client 则可以独立出去。
更自然的分界是:
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。
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 场景仍然非常有价值,因为它暴露了:
allocation authority
publication
reference lifetime
eviction
generation
failure recovery
如果将来:
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。
更新后的演进顺序
我现在会把实现顺序改成:
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 如何随着代码路径和硬件约束被逐步推导出来。