chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,427 @@
# FlexKV ↔ sglang integration
A `RadixCache` subclass that routes sglang's host-tier KV cache through a
FlexKV [`KVManager`](https://github.com/taco-project/FlexKV) (CPU / SSD /
Remote offload). Same integration pattern as
[`LMCRadixCache`](../lmcache/README.md): `FlexKVRadixCache` overrides
`match_prefix` / `init_load_back` / `cache_finished_req` / `evict`; a
`FlexKVConnector` façade talks to `KVManager`, `KVTPClient`, and a
3-axis (PP × CP × TP) sync context.
---
## Quick start (single H20, single GPU, Qwen3-8B)
This walks through everything the verification on H20-GPU-11 actually
exercised. Adjust paths / model / GPU as needed.
### 1. Prereqs
* `lmsysorg/sglang:dev` (or any sglang container with CUDA 12.x + torch 2.10+).
* This sglang fork (branch `feat/flexkv-main-connector`) and FlexKV
(branch `main`) checked out somewhere reachable from the container
— e.g. `/raid/fly/sglang-connector-dir/{sglang,FlexKV}`. Verified
against FlexKV main at `aa74e39` (PR #184); older commits down to
the layerwise integration also work.
### 2. Start a container with both repos mounted
```bash
docker run -d --name flexkv-sglang \
--gpus all --ipc=host --network host \
--shm-size=32g --cap-add SYS_NICE --cap-add IPC_LOCK \
-v /raid/fly:/raid/fly \
--workdir /raid/fly/sglang-connector-dir \
--entrypoint "" \
lmsysorg/sglang:dev sleep infinity
docker exec flexkv-sglang bash -c "
apt-get update -qq &&
apt-get install -y numactl libnuma-dev libxxhash-dev liburing-dev cmake ninja-build
"
```
### 3. Install sglang fork (editable) + FlexKV
```bash
docker exec flexkv-sglang bash -c '
set -e
git config --global --add safe.directory "*"
# sglang fork: install in editable mode, replacing the prebuilt sglang
cd /raid/fly/sglang-connector-dir/sglang
pip install --no-deps -e python
# FlexKV: pin to main, init the xxHash submodule, debug C++ build.
cd /raid/fly/sglang-connector-dir/FlexKV
git checkout main && git pull --ff-only
git submodule update --init third_party/xxHash
pip install -q cython ninja pybind11
FLEXKV_ENABLE_METRICS=0 bash build.sh --debug
# Smoke check
python3 -c "
import sglang, flexkv
from flexkv.kvmanager import KVManager
from sglang.srt.mem_cache.storage.flexkv import flexkv_comm
from sglang.srt.mem_cache.registry import registered_radix_cache_backends
import sglang.srt.mem_cache.storage.flexkv # registers
print(\"flexkv ok\", flexkv.__file__)
print(\"sglang ok\", sglang.__file__)
print(\"registered backends:\", registered_radix_cache_backends())
"
'
```
If the build hangs on `pip install sglang-kernel`, see
[Troubleshooting](#troubleshooting).
### 4. Minimal FlexKV YAML
```yaml
# /raid/fly/sglang-connector-dir/flexkv_min.yaml
cpu_cache_gb: 16
```
That's enough to enable a 16 GiB CPU offload pool. See
[`example_config_mp.yaml`](example_config_mp.yaml) for SSD / remote /
distributed knobs.
### 5. Launch the server (MP / synchronous mode)
```bash
docker exec -d flexkv-sglang bash -c '
cd /raid/fly/sglang-connector-dir
CUDA_VISIBLE_DEVICES=0 \
SGLANG_SKIP_SGL_KERNEL_VERSION_CHECK=1 \
python3 -m sglang.launch_server \
--model-path /raid/fly/model/Qwen3-8B \
--port 30000 --tp-size 1 \
--enable-flexkv \
--flexkv-config-file /raid/fly/sglang-connector-dir/flexkv_min.yaml \
--mem-fraction-static 0.45 --max-running-requests 8 \
> /tmp/sglang.log 2>&1
'
```
`SGLANG_SKIP_SGL_KERNEL_VERSION_CHECK=1` bypasses the prebuilt
`sglang-kernel` version assertion (the `lmsysorg/sglang:dev` image ships
0.4.2.post2; main expects ≥ 0.4.3). Not a FlexKV-specific issue;
remove when the container image is refreshed.
Wait ~2 min for the model load + CUDA graph capture. Confirm with:
```bash
docker exec flexkv-sglang bash -c '
grep -E "fired up|Connector ready" /tmp/sglang.log | tail -2
'
```
Expected (key lines):
```
[FlexKV] Connector ready ...: layerwise=False, prefetch=False
The server is fired up and ready to roll!
```
### 6. Send a request and observe a cache hit
```bash
# First call: priming — fresh prefill, FlexKV stores the prefix.
docker exec flexkv-sglang bash -c '
curl -s http://127.0.0.1:30000/generate -X POST \
-H "Content-Type: application/json" \
-d "{\"text\": \"The capital of France is\",
\"sampling_params\": {\"max_new_tokens\": 5, \"temperature\": 0}}"
'
# Flush the GPU radix (FlexKV CPU pool keeps the data) and re-send.
docker exec flexkv-sglang bash -c '
curl -s http://127.0.0.1:30000/flush_cache -X POST
curl -s http://127.0.0.1:30000/generate -X POST \
-H "Content-Type: application/json" \
-d "{\"text\": \"The capital of France is\",
\"sampling_params\": {\"max_new_tokens\": 5, \"temperature\": 0}}"
'
```
Look at the second response's `meta_info`:
```json
"cached_tokens": 4,
"cached_tokens_details": { "device": 0, "host": 4 },
```
`host: 4` confirms the bytes came back from FlexKV's CPU pool. The
server log should also show a matching D2H/H2D bandwidth line:
```
[FLEXKV] ... H2D transfer request: N finished transfer data size: 0.0xx GB ... 30+ GB/s
```
### 7. Layerwise mode
Add `FLEXKV_ENABLE_LAYERWISE_TRANSFER=1` before `python3 -m
sglang.launch_server`. Everything else is identical. On the second
request you'll see `cached_tokens_details: {"device": N, "host": 0}`
(in IP mode the load happens inside `match_prefix` so sglang accounts
for it as device-side) and a log line `LAYERWISE transfer request: N
finished ...`. The startup log will also include
`[FlexKV] Eventfd handshake complete ... counters=3 layers=<N>`.
---
## Correctness verification
Numerical match against a no-FlexKV baseline (greedy decoding,
deterministic). Scripts are in this repo's testing notes; the canonical
two are reproduced below.
```bash
# Phase 1: capture the no-FlexKV baseline.
docker exec -d flexkv-sglang bash -c '
CUDA_VISIBLE_DEVICES=0 SGLANG_SKIP_SGL_KERNEL_VERSION_CHECK=1 \
python3 -m sglang.launch_server \
--model-path /raid/fly/model/Qwen3-8B --port 30000 --tp-size 1 \
--mem-fraction-static 0.45 > /tmp/sglang.log 2>&1
'
# ... wait until ready ...
docker exec flexkv-sglang python3 /raid/fly/sglang-connector-dir/sglang/python/sglang/srt/mem_cache/storage/flexkv/verify_outputs.py --phase baseline
docker exec flexkv-sglang bash -c "pkill -9 -f launch_server; sleep 3"
# Phase 2: relaunch with --enable-flexkv and compare.
docker exec -d flexkv-sglang bash -c '
CUDA_VISIBLE_DEVICES=0 SGLANG_SKIP_SGL_KERNEL_VERSION_CHECK=1 \
python3 -m sglang.launch_server \
--model-path /raid/fly/model/Qwen3-8B --port 30000 --tp-size 1 \
--enable-flexkv --flexkv-config-file /raid/fly/sglang-connector-dir/flexkv_min.yaml \
--mem-fraction-static 0.45 > /tmp/sglang.log 2>&1
'
# ... wait until ready ...
docker exec flexkv-sglang python3 /raid/fly/sglang-connector-dir/sglang/python/sglang/srt/mem_cache/storage/flexkv/verify_outputs.py --phase test
```
Expected last line: `Total mismatches: 0`. Each prompt is run twice
(R1 fresh / R2 after `flush_cache`); both R1 and R2 outputs must
byte-equal the baseline.
Repeat the Phase-2 launch with `FLEXKV_ENABLE_LAYERWISE_TRANSFER=1`
to validate the layerwise path.
---
## Selecting the backend
Two equivalent CLI flags:
```bash
# Auto-selection chain (matches --enable-lmcache style)
python3 -m sglang.launch_server --enable-flexkv \
--flexkv-config-file /path/to/flexkv_config.yaml ...
# Explicit registry path
python3 -m sglang.launch_server --radix-cache-backend flexkv \
--flexkv-config-file /path/to/flexkv_config.yaml ...
```
Either flag also sets `FLEXKV_CONFIG_PATH` so you can omit
`--flexkv-config-file` and configure FlexKV purely through env vars.
---
## Modes
### MP (synchronous, default)
* `match_prefix` calls `FlexKVConnector.lookup_kv` only.
* When `host_hit_length > 0`, the scheduler later calls
`init_load_back`, which allocates the uncached slots and fires
`retrieve_kv` (FlexKV `launch` + `wait`).
* `cache_finished_req` runs `put_match` + `launch` and stashes the
in-flight FlexKV task id. Source-node lock is held until
`check_completed_stores` (called from `check_hicache_events` /
`evict`) signals completion.
This is the path you'll use under any non-trivial deployment topology
(DP > 1, multi-instance, multi-node, ...).
### IP / layerwise (`FLEXKV_ENABLE_LAYERWISE_TRANSFER=1`)
* `match_prefix` allocates the uncached slots and fires
`start_load_kv_layerwise` immediately.
* A `FlexKVLayerDoneCounter` is registered onto sglang's KV pool via
`register_layer_transfer_counter`; the per-layer hook blocks each
forward layer on its own eventfd until the FlexKV transfer worker
signals the layer is staged.
* Layerwise mode requires the FlexKV transfer worker's UDS socket
(`/tmp/flexkv_layerwise_eventfd.sock` by default) to be reachable —
the connector handshakes with it at startup. The socket path is
computed by FlexKV's `build_layerwise_eventfd_socket_path` from the
same dp/pp/instance settings, so configuration is taken care of as
long as you launch FlexKV consistently.
---
## Files
* `flexkv_radix_cache.py``FlexKVRadixCache(RadixCache)`. Overrides
`match_prefix`, `init_load_back`, `cache_finished_req`, `evict`,
`check_hicache_events`, `reset`.
* `flexkv_connector.py``FlexKVConnector`. Owns the `KVManager`,
`KVTPClient`, and the cross-rank sync context. Public methods:
`lookup_kv`, `retrieve_kv`, `start_load_kv_layerwise`, `store_kv`,
`check_completed_stores`, `prefetch_async`, …
* `flexkv_comm.py``FlexKVComm` (3-axis PP × CP × TP sync built on
torch.distributed) + the eventfd / `SCM_RIGHTS` shims used by the
layerwise transfer UDS handshake. **`FlexKVLayerLoadingEvent` here
carries the layerwise correctness fix** (drain stale eventfd
signals on reset, switch `wait` to `select.select` to keep blocking
semantics on a NONBLOCK fd).
* `__init__.py` — registers the `"flexkv"` factory with
`sglang.srt.mem_cache.registry`.
---
## TP / PP / CP / DP
FlexKV runs one `KVManager` per DP route (=
`instance_id * dp_size + dp_rank`). Every other rank in the same
fan-out is the "sync follower" — `FlexKVComm` broadcasts the
leader's lookup / store decisions via gloo CPU groups so non-leader
ranks know which task ids and slot mappings to use.
Supported:
* **TP** (any size) — typical sglang topology.
* **DP** (`dp_size > 1`) and multi-instance — FlexKV automatically
switches its `KVManager` to server-client mode.
* **PP** (`pp_size > 1`) — including cross-node PP. The PP receiver
forwards its slot mappings back to FlexKV's
`TransferManagerOnRemote` via the same ZMQ channel used for GPU
registration.
* **CP** (`attn_cp_size > 1`) — sync handled symmetrically with TP.
* **DP attention** (`enable_dp_attention=True`) — the inner
`attn_tp_size` is what FlexKV uses for register-side routing.
---
## Environment variables
* `FLEXKV_CONFIG_PATH` — full FlexKV YAML / JSON config (also set
automatically by `--flexkv-config-file`).
* `FLEXKV_ENABLE_LAYERWISE_TRANSFER``1` to enable layerwise mode.
* `FLEXKV_LAYERWISE_EVENTFD_SOCKET` — UDS socket path (default
`/tmp/flexkv_layerwise_eventfd.sock`); auto-suffixed per
`(pp_rank, dp_client_id)` when those dims are > 1.
* `FLEXKV_MASTER_HOST` / `FLEXKV_MASTER_PORTS` — multi-node master
endpoint for `TransferManagerOnRemote`. Default
`localhost:5556,5557,5558`. With `nnodes > 1` we also fall back to
`server_args.dist_init_addr`'s host.
* `FLEXKV_KV_CACHE_DTYPE` — override KV dtype when sglang uses
`--kv-cache-dtype auto`.
* `SGLANG_SKIP_SGL_KERNEL_VERSION_CHECK` — bypass the prebuilt
`sglang-kernel` version assertion (not FlexKV-specific).
---
## Troubleshooting
* **`fatal: not a git repository ... third_party/xxHash`** — FlexKV's
build.sh needs an actual git checkout for the submodule. If you
rsync'd FlexKV without `.git/`, sync it: `rsync -az
/path/to/FlexKV/.git/ <remote>:<dir>/FlexKV/.git/` then
`git config --global --add safe.directory "*"`.
* **`fatal: detected dubious ownership`** — same fix:
`git config --global --add safe.directory "*"`.
* **`xxhash.h: No such file or directory`** — submodule not init'd.
`cd FlexKV && git submodule update --init third_party/xxHash`.
* **`dist/lease_meta_mempool.h: No such file or directory`** — your
rsync excluded `csrc/dist/`. The directory `FlexKV/csrc/dist/` is
source, not a build artifact; re-sync without `--exclude='dist'`.
* **`No module named 'Cython'`** — install: `pip install cython ninja pybind11`.
* **`sglang-kernel is installed with version 0.4.2.post2, which is
less than the minimum required version 0.4.3`** — either run with
`SGLANG_SKIP_SGL_KERNEL_VERSION_CHECK=1` or refresh
`pip install -U sglang-kernel`. The download is ~600 MB and can
take a long time on slow links.
* **`cudaHostRegister failed with error code 100` (cudaErrorNoDevice)**
— happens when the FlexKV transfer subprocess can't init CUDA on
the assigned device. Usually a stuck previous session; restart
the container.
* **`[FlexKV] Waiting for FlexKV ready` loops > 60 s** — the
KVManager subprocess crashed at boot. Check `/tmp/sglang.log` for
the actual stack (usually a CUDA-init or torch-mp issue).
* **Layerwise mode: server hangs at "Eventfd connected attempts=..."**
— the `LayerwiseTransferWorker` hasn't started yet. Wait — it can
take 20-30 s after `Eventfd server created`. If it never advances,
check the FlexKV-side log lines beginning with `[LayerwiseWorker]`.
---
## Status
* MP (synchronous) path — verified end-to-end on Qwen3-8B (H20-3e):
output byte-equal to no-FlexKV baseline across short / medium / long
prompts. ~3046 GB/s observed for D2H stores and ~37 GB/s for H2D
loads.
* IP (layerwise) path — verified end-to-end with the fix in
`flexkv_comm.py`. ~712 GB/s per-layer (smaller per-call payload).
* PP / CP / DP / multi-node — code paths driven by `FlexKVComm`,
carried over from the production-validated `BaseKVConnector`
integration. Not exercised in single-GPU smoke tests; needs a
multi-node run before shipping.
### Known limitations
* Hybrid models (Mamba / SWA / DSV4 indexer auxiliary pools) are not
supported through this connector — only the primary KV pool is
hooked up. HiCache's multi-pool `batch_*_v2` interface would map
here but requires `PoolTransfer` + `PoolHitPolicy` plumbing in
`FlexKVConnector`.
* Write-back acks are per-request (one `dec_lock_ref` per
`cache_finished_req`), not per-page like HiCache's
`flush_write_through_acks`.
* `--radix-cache-backend=flexkv` and `--enable-flexkv` are
mutually equivalent today; we don't yet emit a deprecation
warning if both are set.
## Benchmarks
Setup: Qwen3-8B on 1× H20. Server flags:
--attention-backend triton --mem-fraction-static 0.32
--max-running-requests 32 --chunked-prefill-size 16384
--context-length 32000
Workload: 120 prompts sampled from
[`princeton-nlp/SWE-bench_Lite_oracle`](https://huggingface.co/datasets/princeton-nlp/SWE-bench_Lite_oracle)
with input length ≤ 28k tokens (p50 = 7088, max = 27961). Two passes —
pass 1 populates the host cache, pass 2 is the measured run. `qps=2.0`,
`concurrency=24`, `max_new_tokens=32`, `temperature=0`.
### Warm-pass results
| Config | TTFT avg / p50 / p90 / p99 | E2E p50 | Throughput | Output tok/s | H2D / D2H |
| --- | --- | --- | --- | --- | --- |
| baseline | 6.86 / 8.04 / 9.88 / 10.89 s | 8.15 s | 1.86 req/s | 37.7 | — |
| `--enable-hierarchical-cache` | **0.04 / 0.04 / 0.06 / 0.06 s** | 0.23 s | 2.02 req/s | 40.8 | — |
| `--enable-flexkv` | **0.05 / 0.05 / 0.07 / 0.08 s** | 0.24 s | 2.02 req/s | 40.8 | 86 / 155 |
Server-side (via `ReqTimeStats` in the sglang log): 76 / 76 non-EOS-immediate
warm-pass requests have `cached_input_len == input_len` for both `hicache`
and `flexkv` (100 % prefix recovery); baseline stays at ~59 tokens
(system-prompt header only). The 86 `H2D transfer` log lines under `flexkv`
confirm the CPU-tier loadbacks actually fired.
### Output correctness
Byte-level diff of generated text across 32 prompts, `temperature=0`:
* baseline: cold pass == warm pass (32 / 32; fully deterministic without cache)
* `hicache`: warm vs baseline warm — 29 / 32 identical, 3 diverge
* `flexkv`: warm vs baseline warm — 29 / 32 identical, 3 diverge (mostly the same 3 as `hicache`)
The ~10 % divergence at `temperature=0` is the well-known KV-cache-reuse
artifact caused by floating-point non-associativity between "prefill in
place" and "load pre-computed KV" paths; it affects the mainline
`--enable-hierarchical-cache` at the same rate and is not FlexKV-specific.
@@ -0,0 +1,87 @@
"""FlexKV-backed RadixCache integration for sglang.
Two ways to select this backend at server launch:
1. ``--enable-flexkv`` (default chain in ``default_radix_cache_factory``)
2. ``--radix-cache-backend=flexkv`` (explicit registry path)
Importing this package registers the explicit name with the registry,
so the second form is available without further wiring.
"""
from __future__ import annotations
import logging
from sglang.srt.mem_cache.registry import register_radix_cache_backend
logger = logging.getLogger(__name__)
def _flexkv_factory(ctx):
"""Build a :class:`FlexKVRadixCache` from a ``TreeCacheBuildContext``.
``TreeCacheBuildContext`` carries TP rank/size and the TP group
coordinator, but not PP/CP. We pick those up from the global
accessors in :mod:`sglang.srt.distributed.parallel_state`; FlexKV
needs them to fan out lookup/store decisions across the full TP × CP
× PP topology.
"""
from sglang.srt.distributed.parallel_state import (
get_attn_cp_group,
get_attn_tp_group,
get_pp_group,
)
from sglang.srt.mem_cache.storage.flexkv.flexkv_radix_cache import (
FlexKVRadixCache,
)
server_args = ctx.server_args
# PP group is always available; attn TP / attn CP groups may share
# the regular TP group when attn DP is off — that's fine, the
# connector treats size-1 groups as no-ops.
try:
pp_group = get_pp_group()
except (RuntimeError, AssertionError):
pp_group = None
try:
attn_tp_group = get_attn_tp_group()
except (RuntimeError, AssertionError):
attn_tp_group = ctx.tp_group
try:
attn_cp_group = get_attn_cp_group()
except (RuntimeError, AssertionError):
attn_cp_group = None
# PP / CP ranks: use the group's own rank_in_group view if available;
# fall back to 0 for single-rank dims.
pp_rank = pp_group.rank_in_group if pp_group is not None else 0
attn_cp_rank = attn_cp_group.rank_in_group if attn_cp_group is not None else 0
return FlexKVRadixCache(
params=ctx.params,
model_config=ctx.model_config,
server_args=server_args,
tp_rank=ctx.tp_rank,
tp_size=ctx.tp_size,
# ``dp_rank`` isn't carried on TreeCacheBuildContext or ServerArgs
# at construction time; the connector normalizes ``None`` to 0
# for the single-DP-rank case that this factory targets.
dp_rank=None,
pp_rank=pp_rank,
attn_cp_rank=attn_cp_rank,
tp_group=ctx.tp_group,
pp_group=pp_group,
attn_tp_group=attn_tp_group,
attn_cp_group=attn_cp_group,
)
try:
register_radix_cache_backend("flexkv", _flexkv_factory)
except ValueError as exc:
# The registry refuses duplicates. Importing this package twice
# (e.g. via both --enable-flexkv and --radix-cache-backend=flexkv)
# is fine — log and move on.
logger.debug("flexkv backend already registered: %s", exc)
@@ -0,0 +1,38 @@
# Example FlexKV YAML config (passed to sglang via --flexkv-config-file).
#
# Equivalent env vars exist for every field — see flexkv/common/config.py
# (UserConfig.from_env). This file is a minimal CPU-only setup; uncomment
# the SSD / Remote / Redis sections to enable those tiers.
# ---- CPU host-side cache ----------------------------------------------
# Size of the FlexKV CPU pool. Used to derive `num_cpu_blocks` together
# with the model dtype, head dim, num kv heads, and page size.
cpu_cache_gb: 64
# Optional: pin the CPU pool using transparent huge pages.
# use_hugepage_cpu_buffer: false
# use_hugepage_tmp_buffer: false
# hugepage_size_bytes: 2097152
# ---- SSD tier ---------------------------------------------------------
# Set ssd_cache_gb > cpu_cache_gb to enable the SSD spill tier.
# ssd_cache_gb: 256
# ssd_cache_dir: "/mnt/nvme0/flexkv;/mnt/nvme1/flexkv" # ';'-separated for striping
# enable_gds: false # cuFile / GDS path
# ---- KV cache dtype override -----------------------------------------
# When sglang is launched with --kv-cache-dtype auto, FlexKV can't tell
# which dtype the actual KV tensors use. Set explicitly here.
# kv_cache_dtype: bfloat16
# ---- Peer / distributed sharing --------------------------------------
# enable_p2p_cpu: false
# enable_p2p_ssd: false
# enable_3rd_remote: false
# ---- Redis (for distributed metadata / KV sharing) -------------------
# redis_host: 127.0.0.1
# redis_port: 6379
# redis_password: null
# node_ttl_seconds: 60
# local_ip: 10.0.0.1
@@ -0,0 +1,662 @@
"""Communication helpers for the FlexKV connector.
FlexKV runs a single KVManager per DP group (typically the TP/CP/PP
sync leader's process). Every other rank in the same KV-cache-sharing
fan-out must be told the leader's decisions: which prefix matched in
FlexKV, which task id the leader allocated, which slot mappings to
send, etc.
This file provides:
* ``FlexKVComm`` — a 3-axis (PP × CP × TP) hierarchical sync context
built on torch.distributed (gloo CPU groups). Exposes ``scatter``,
``scatter_pp``, ``barrier`` and ``all_reduce_min`` plus role flags
(``is_sync_leader`` etc.) that the connector branches on.
* libc / ``eventfd`` shims used by the layerwise transfer worker
socket handshake.
* ``FlexKVLayerLoadingEvent`` and ``FlexKVLayerDoneCounter`` — the
eventfd-backed per-layer completion structures that the FlexKV
layerwise transfer worker signals into. Hooked into sglang's
``register_layer_transfer_counter`` so each layer's forward waits
for its own host→device copy.
"""
from __future__ import annotations
import ctypes
import errno
import logging
import os
import pickle
import socket
import struct
from datetime import timedelta
from typing import Any, Dict, List
import torch
import torch.distributed as dist
from sglang.srt.distributed.parallel_state import get_world_group
logger = logging.getLogger(__name__)
# PP-channel command tags (used by ``scatter_pp`` payloads). Sender and
# receiver assert on these to catch protocol drift early.
CMD_PUT_META = 2
CMD_LAYERWISE = 3
CMD_STORE_COMPLETE = 5
class FlexKVComm:
"""3-axis (PP × CP × TP) hierarchical sync for the FlexKV connector.
Notation:
* "sync leader" is the unique rank that talks to the FlexKV
KVManager: pp_rank=0, attn_cp_rank=0, attn_tp_rank=0.
* "PP stage leader" is the (cp=0, tp=0) rank within a PP stage —
it does cross-PP P2P (``scatter_pp``).
* Every rank participates in collective layers it belongs to.
Communication strategy:
* P2P (send/recv/isend/irecv) on CPU tensors → ``world_cpu_group``
(the global gloo group). Sub-group cpu_groups have unreliable
TCP pairs for direct P2P.
* Collectives (all_reduce / barrier) → sglang's sub-group
cpu_groups (fine for collectives).
"""
# P2P tags. World group is shared with sglang's own P2P, so we pick
# 4-byte tags that won't collide.
_TAG_SCATTER = int.from_bytes(b"FxSc", byteorder="big")
_TAG_PP = int.from_bytes(b"FxPP", byteorder="big")
_TAG_CP = int.from_bytes(b"FxCP", byteorder="big")
_TAG_TP = int.from_bytes(b"FxTP", byteorder="big")
_TAG_PP_AR_MIN = int.from_bytes(b"FxA2", byteorder="big")
_TAG_PP_BARRIER = int.from_bytes(b"FxB2", byteorder="big")
_TAG_PP_BARRIER_BCAST = int.from_bytes(b"FxB3", byteorder="big")
_TAG_AR_BCAST = int.from_bytes(b"FxAR", byteorder="big")
# Adaptive async-work reaper. gloo's isend Work objects do not auto-
# advance their "completed" state on poll, so a pure poll-based reaper
# leaks. We actively wait() the oldest works with a tiny timeout;
# the watermark grows on stuck reaps (slow / asymmetric peer) and
# shrinks back on clean reaps.
_REAP_HIGH_BASE = 1024
_REAP_HIGH_MAX = 32768
_REAP_MAX_DRAIN = 512
_REAP_PROBE = timedelta(milliseconds=1)
_REAP_LOG_EVERY = 64
def __init__(
self,
rank_info,
world_rank: int,
pp_group=None,
attn_tp_group=None,
attn_cp_group=None,
):
model_config = rank_info.model_config
self.world_rank = world_rank
self._async_works: List = []
self._reap_high: int = self._REAP_HIGH_BASE
self._reap_calls: int = 0
self._reap_stuck_total: int = 0
self._reap_drained_total: int = 0
# Accept either GroupCoordinator wrappers (has ``.cpu_group``) or
# raw ProcessGroups.
self.pp_cpu_group = (
getattr(pp_group, "cpu_group", pp_group) if pp_group is not None else None
)
self.attn_tp_cpu_group = (
getattr(attn_tp_group, "cpu_group", attn_tp_group)
if attn_tp_group is not None
else None
)
self.attn_cp_cpu_group = (
getattr(attn_cp_group, "cpu_group", attn_cp_group)
if attn_cp_group is not None
else None
)
self.pp_size = model_config.pp_size
self.attn_tp_size = model_config.attn_tp_size
self.attn_cp_size = model_config.attn_cp_size
self.pp_rank = rank_info.pp_rank
self.attn_tp_rank = rank_info.attn_tp_rank
self.attn_cp_rank = rank_info.attn_cp_rank
self.is_pp_stage_leader = self.attn_tp_rank == 0 and self.attn_cp_rank == 0
self.is_sync_leader = self.pp_rank == 0 and self.is_pp_stage_leader
self.is_pp_leader = self.pp_rank == 0 and self.is_pp_stage_leader
self.is_cp_leader = self.attn_cp_rank == 0
self.is_tp_leader = self.attn_tp_rank == 0
# P2P routing tables (computed once).
stride = self.attn_tp_size * self.attn_cp_size
self._pp_stage_leader_ranks = [s * stride for s in range(self.pp_size)]
pp_stage_offset = self.pp_rank * stride
self._cp_leader_ranks = (
[
pp_stage_offset + cp * self.attn_tp_size
for cp in range(self.attn_cp_size)
]
if self.attn_cp_size > 1
else []
)
if self.attn_tp_size > 1:
if self.attn_tp_cpu_group is None:
raise RuntimeError(
f"[FlexKV] attn_tp_size={self.attn_tp_size} > 1 but "
f"attn_tp_cpu_group is None — TP CPU group is required "
f"for scatter/collectives."
)
self._tp_group_ranks = [
dist.get_global_rank(self.attn_tp_cpu_group, i)
for i in range(self.attn_tp_cpu_group.size())
]
else:
self._tp_group_ranks = []
self._pp_group_global_ranks = (
[
dist.get_global_rank(self.pp_cpu_group, i)
for i in range(self.pp_cpu_group.size())
]
if self.pp_size > 1 and self.pp_cpu_group is not None
else []
)
self._pp_stage_member_ranks = list(
range(pp_stage_offset, pp_stage_offset + stride)
)
self.needs_sync = (
self.pp_size > 1 or self.attn_tp_size > 1 or self.attn_cp_size > 1
)
self._world_cpu_group = get_world_group().cpu_group
self.pp_group = (
self.pp_cpu_group
if (self.pp_size > 1 and self.is_pp_stage_leader)
else None
)
self.is_pp_active = self.pp_size > 1
self.is_pp_sender = self.is_pp_leader
self.is_pp_receiver = self.is_pp_stage_leader and not self.is_pp_leader
self.is_cross_node_pp = self.pp_size > rank_info.pp_size_per_node
self.should_send_slot_mapping_to_remote = (
self.is_pp_receiver and self.is_cross_node_pp
)
logger.info(
"[FlexKV] Comm init: rank=%d, pp=%d/%d, tp=%d/%d, cp=%d/%d, "
"sync_leader=%s, stage_leader=%s, cross_node_pp=%s",
world_rank,
self.pp_rank,
self.pp_size,
self.attn_tp_rank,
self.attn_tp_size,
self.attn_cp_rank,
self.attn_cp_size,
self.is_sync_leader,
self.is_pp_stage_leader,
self.is_cross_node_pp,
)
# ------------------------------------------------------------------
# Public collectives
# ------------------------------------------------------------------
def scatter(self, data: Any, blocking: bool = False) -> Any:
"""Hierarchical fan-out: sync_leader → PP stage leaders →
CP leaders → TP ranks. Returns the leader's payload on every rank.
``blocking=False`` queues isends and reaps later — fine for the
hot path; ``True`` blocks until the leader's sends drain (used
on shutdown / barriers).
"""
if self.pp_size > 1 and self.is_pp_stage_leader:
data = self._scatter_group(
data,
self._pp_stage_leader_ranks,
self.is_pp_leader,
self._TAG_PP,
blocking,
)
if self._cp_leader_ranks:
data = self._scatter_group(
data,
self._cp_leader_ranks,
self.is_cp_leader,
self._TAG_CP,
blocking,
)
if self._tp_group_ranks:
data = self._scatter_group(
data,
self._tp_group_ranks,
self.is_tp_leader,
self._TAG_TP,
blocking,
)
return data
def scatter_pp(self, data: Any) -> Any:
"""PP-only fan-out across PP stages (only stage leaders participate)."""
if not self._pp_group_global_ranks:
return data
is_leader = self._pp_group_global_ranks[0] == self.world_rank
return self._scatter_group(
data,
self._pp_group_global_ranks,
is_leader,
self._TAG_SCATTER,
blocking=False,
)
def all_reduce_min(self, value: int) -> int:
"""Hierarchical all_reduce(MIN) across TP, CP, PP.
Used to align FlexKV block-count limits across all ranks that
will register GPU buffers (each rank computes the maximum it can
support, and we take the MIN to land on a value everyone can
honor).
"""
tensor = torch.tensor(value, dtype=torch.int64)
if self.attn_tp_size > 1 and self.attn_tp_cpu_group is not None:
dist.all_reduce(tensor, op=dist.ReduceOp.MIN, group=self.attn_tp_cpu_group)
if self.attn_cp_size > 1 and self.attn_cp_cpu_group is not None:
dist.all_reduce(tensor, op=dist.ReduceOp.MIN, group=self.attn_cp_cpu_group)
if self.pp_size > 1 and self.is_pp_stage_leader:
self._pp_all_reduce_min_p2p(tensor)
if self.pp_size > 1:
self._bcast_to_stage_members(tensor, self._TAG_AR_BCAST)
return int(tensor.item())
def barrier(self) -> None:
if self.attn_tp_size > 1 and self.attn_tp_cpu_group is not None:
dist.barrier(group=self.attn_tp_cpu_group)
if self.attn_cp_size > 1 and self.attn_cp_cpu_group is not None:
dist.barrier(group=self.attn_cp_cpu_group)
if self.pp_size > 1 and self.is_pp_stage_leader:
self._pp_barrier_p2p()
if self.pp_size > 1:
dummy = torch.tensor([0], dtype=torch.int64)
self._bcast_to_stage_members(dummy, self._TAG_PP_BARRIER_BCAST)
# ------------------------------------------------------------------
# Internal scatter helper
# ------------------------------------------------------------------
def _scatter_group(
self,
data: Any,
group_ranks: List[int],
is_leader: bool,
tag: int,
blocking: bool = False,
) -> Any:
if not group_ranks or self.world_rank not in group_ranks:
return data
if is_leader:
dsts = [r for r in group_ranks if r != self.world_rank]
works = []
for dst in dsts:
works.extend(self._isend(dst, data, tag, self._world_cpu_group))
if blocking:
for w in works:
w.wait()
else:
self._reap_completed_async_works()
self._async_works.extend(works)
return data
return self._recv(group_ranks[0], tag, self._world_cpu_group)
def _reap_completed_async_works(self) -> None:
n = len(self._async_works)
if n <= self._reap_high:
return
drained = 0
stuck = False
for _ in range(self._REAP_MAX_DRAIN):
if not self._async_works:
break
w = self._async_works[0]
try:
w.wait(self._REAP_PROBE)
except RuntimeError:
stuck = True
break
self._async_works.pop(0)
drained += 1
self._reap_calls += 1
self._reap_drained_total += drained
if stuck:
self._reap_stuck_total += 1
prev_high = self._reap_high
if stuck:
self._reap_high = min(self._REAP_HIGH_MAX, self._reap_high * 2)
else:
self._reap_high = max(self._REAP_HIGH_BASE, self._reap_high // 2)
if self._reap_high != prev_high:
logger.debug(
"[FlexKV] reap watermark rank=%d %d->%d "
"(stuck=%s drained=%d backlog=%d)",
self.world_rank,
prev_high,
self._reap_high,
stuck,
drained,
n,
)
if self._reap_calls % self._REAP_LOG_EVERY == 0:
logger.debug(
"[FlexKV] reap stats rank=%d calls=%d drained=%d stuck=%d "
"backlog=%d high=%d",
self.world_rank,
self._reap_calls,
self._reap_drained_total,
self._reap_stuck_total,
len(self._async_works),
self._reap_high,
)
# ------------------------------------------------------------------
# Low-level send / recv on the world cpu group
# ------------------------------------------------------------------
def _isend(self, dst: int, data: Any, tag: int = 0, group=None) -> list:
serialized = bytearray(pickle.dumps(data))
t_size = torch.tensor([len(serialized)], dtype=torch.long)
t_data = torch.frombuffer(serialized, dtype=torch.uint8)
return [
dist.isend(t_size, dst=dst, tag=tag, group=group),
dist.isend(t_data, dst=dst, tag=tag, group=group),
]
def _recv(self, src: int, tag: int = 0, group=None) -> Any:
t_size = torch.tensor([0], dtype=torch.long)
dist.irecv(t_size, src=src, tag=tag, group=group).wait()
size = int(t_size.item())
if size == 0:
return []
t_data = torch.empty(size, dtype=torch.uint8)
dist.irecv(t_data, src=src, tag=tag, group=group).wait()
return pickle.loads(t_data.numpy().tobytes())
def _send_tensor(
self, tensor: torch.Tensor, dst: int, tag: int = 0, group=None
) -> None:
dist.send(tensor, dst=dst, tag=tag, group=group)
def _recv_tensor(
self, tensor: torch.Tensor, src: int, tag: int = 0, group=None
) -> None:
dist.recv(tensor, src=src, tag=tag, group=group)
def _bcast_to_stage_members(self, tensor: torch.Tensor, tag: int) -> None:
if not self.is_pp_stage_leader:
self._recv_tensor(
tensor,
src=self._pp_stage_leader_ranks[self.pp_rank],
tag=tag,
group=self._world_cpu_group,
)
return
for rank in self._pp_stage_member_ranks:
if rank != self.world_rank:
self._send_tensor(
tensor, dst=rank, tag=tag, group=self._world_cpu_group
)
def _pp_all_reduce_min_p2p(self, tensor: torch.Tensor) -> None:
leader_rank = self._pp_stage_leader_ranks[0]
other_leaders = self._pp_stage_leader_ranks[1:]
tag = self._TAG_PP_AR_MIN
if self.world_rank == leader_rank:
result = int(tensor.item())
for src in other_leaders:
other = torch.tensor(0, dtype=torch.int64)
self._recv_tensor(other, src=src, tag=tag, group=self._world_cpu_group)
result = min(result, int(other.item()))
tensor.fill_(result)
for dst in other_leaders:
self._send_tensor(tensor, dst=dst, tag=tag, group=self._world_cpu_group)
else:
self._send_tensor(
tensor, dst=leader_rank, tag=tag, group=self._world_cpu_group
)
self._recv_tensor(
tensor, src=leader_rank, tag=tag, group=self._world_cpu_group
)
def _pp_barrier_p2p(self) -> None:
leader_rank = self._pp_stage_leader_ranks[0]
other_leaders = self._pp_stage_leader_ranks[1:]
tag = self._TAG_PP_BARRIER
dummy = torch.tensor([1], dtype=torch.int64)
if self.world_rank == leader_rank:
for src in other_leaders:
self._recv_tensor(dummy, src=src, tag=tag, group=self._world_cpu_group)
for dst in other_leaders:
self._send_tensor(dummy, dst=dst, tag=tag, group=self._world_cpu_group)
else:
self._send_tensor(
dummy, dst=leader_rank, tag=tag, group=self._world_cpu_group
)
self._recv_tensor(
dummy, src=leader_rank, tag=tag, group=self._world_cpu_group
)
# ----------------------------------------------------------------------
# libc / eventfd / SCM_RIGHTS shims for the layerwise UDS handshake
# ----------------------------------------------------------------------
_libc = ctypes.CDLL("libc.so.6", use_errno=True)
_libc.eventfd.argtypes = [ctypes.c_uint, ctypes.c_int]
_libc.eventfd.restype = ctypes.c_int
_libc.read.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_size_t]
_libc.read.restype = ctypes.c_ssize_t
_libc.write.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_size_t]
_libc.write.restype = ctypes.c_ssize_t
EFD_SEMAPHORE = 0x1
EFD_NONBLOCK = 0x800
def eventfd(initval: int = 0, flags: int = 0) -> int:
fd = _libc.eventfd(ctypes.c_uint(initval), ctypes.c_int(flags))
if fd == -1:
err = ctypes.get_errno()
raise OSError(err, os.strerror(err))
return fd
def eventfd_write(fd: int, val: int) -> None:
v = ctypes.c_uint64(val)
n = _libc.write(fd, ctypes.byref(v), ctypes.sizeof(v))
if n != ctypes.sizeof(v):
err = ctypes.get_errno()
raise OSError(err, f"eventfd write failed: {os.strerror(err)}")
def eventfd_read(fd: int) -> int:
v = ctypes.c_uint64()
n = _libc.read(fd, ctypes.byref(v), ctypes.sizeof(v))
if n != ctypes.sizeof(v):
err = ctypes.get_errno()
if err == errno.EAGAIN:
return 0
raise OSError(err, f"eventfd read failed: {os.strerror(err)}")
return v.value
def send_fds(sock: socket.socket, fds: list, extra_data: bytes = b"x") -> None:
"""SCM_RIGHTS-send a list of file descriptors over a UDS socket."""
fds_packed = struct.pack(f"{len(fds)}i", *fds)
ancdata = [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds_packed)]
sock.sendmsg([extra_data], ancdata)
# ----------------------------------------------------------------------
# Layerwise transfer signaling (eventfd-backed)
# ----------------------------------------------------------------------
class FlexKVLayerLoadingEvent:
"""One per producer slot. Holds ``num_layers`` semaphore eventfds —
the FlexKV layerwise worker writes 1 to each as the corresponding
layer's H2D copy completes; the consumer (sglang's
``register_layer_transfer_counter`` hook) reads to wait for them."""
def __init__(self, num_layers: int):
self._num_layers = num_layers
# Semaphore mode so each read consumes exactly one signal. NONBLOCK
# lets ``reset_for_new_transfer`` drain leftover counter values
# without blocking; ``wait`` re-arms the fd to blocking before
# reading so consumers still get the desired blocking semantics.
self.load_event_fds: List[int] = [
eventfd(0, EFD_SEMAPHORE | EFD_NONBLOCK) for _ in range(num_layers)
]
self._finished = True
self.wait_remaining: List[int] = [1] * num_layers
def reset_for_new_transfer(self) -> None:
"""Drain any leftover signals from prior transfers, then arm.
Without this drain, a previous transfer that wrote N eventfd
signals but only had N-K reads (e.g. because the attention
backend skipped a layer's ``get_key_buffer`` call) leaves K
pending. The next transfer's first ``wait(layer)`` returns
immediately reading one of those stale signals, even though
the FlexKV worker hasn't actually finished that layer's H2D
yet — and forward proceeds with wrong KV data.
"""
import os
for fd in self.load_event_fds:
# The fd is NONBLOCK: read until EAGAIN. Each read is 8 bytes.
while True:
try:
if not os.read(fd, 8):
break
except BlockingIOError:
break
except OSError:
break
self._finished = False
self.wait_remaining = [1] * self._num_layers
def wait(self, layer_index: int) -> None:
"""Block until the FlexKV worker signals layer ``layer_index``.
The fd was created with EFD_NONBLOCK so reset can drain it. We
re-introduce the blocking semantics with ``select.select`` on a
NONBLOCK fd: the read after select is guaranteed to consume one
signal.
"""
import os
import select
assert 0 <= layer_index < self._num_layers
fd = self.load_event_fds[layer_index]
while True:
select.select([fd], [], [])
try:
buf = os.read(fd, 8)
if buf:
break
except BlockingIOError:
# Spurious wakeup; loop and re-select.
continue
if layer_index == self._num_layers - 1:
self._finished = True
def close(self) -> None:
for fd in self.load_event_fds:
try:
os.close(fd)
except Exception:
pass
self.load_event_fds.clear()
def __del__(self) -> None:
try:
self.close()
except Exception:
pass
class FlexKVLayerDoneCounter:
"""Triple-buffered slot-based layerwise counter.
The KV pool calls ``wait_until(layer_id)`` once per layer during
forward. We track which producer slot the current task is using and
block on that slot's ``layer_id``-th eventfd. Producer rotation lets
the next prefetch start before the current one finishes consuming.
"""
def __init__(self, num_layers: int, num_counters: int = 3):
self.num_layers = num_layers
self.num_counters = num_counters
self.events: List[FlexKVLayerLoadingEvent] = [
FlexKVLayerLoadingEvent(num_layers) for _ in range(num_counters)
]
self.producer_index = -1
self.consumer_index = -1
self._task_to_producer: Dict[int, int] = {}
def register_task(self, task_id: int, producer_id: int) -> None:
self._task_to_producer[task_id] = producer_id
def register_task_with_explicit_counter_id(
self, task_id: int, counter_id: int
) -> None:
if not 0 <= counter_id < self.num_counters:
raise ValueError(
f"Invalid counter_id={counter_id}, must be in [0, {self.num_counters})"
)
self._task_to_producer[task_id] = counter_id
self.events[counter_id].reset_for_new_transfer()
def update_producer(self) -> int:
self.producer_index = (self.producer_index + 1) % self.num_counters
assert self.events[
self.producer_index
]._finished, "Producer event should be finished before reuse"
return self.producer_index
def set_consumer(self, task_id: int) -> None:
if task_id < 0:
self.consumer_index = -1
return
producer_id = self._task_to_producer.pop(task_id, None)
self.consumer_index = producer_id if producer_id is not None else -1
def wait_until(self, threshold: int) -> None:
if self.consumer_index < 0:
return
event = self.events[self.consumer_index]
if event.wait_remaining[threshold] <= 0:
return
event.wait_remaining[threshold] -= 1
event.wait(threshold)
def reset(self) -> None:
self.producer_index = -1
self.consumer_index = -1
self._task_to_producer.clear()
def __del__(self) -> None:
try:
for event in self.events:
event.close()
self.events.clear()
except Exception:
pass
@@ -0,0 +1,925 @@
"""Wrapper around FlexKV ``KVManager`` for sglang.
The public surface is small (see "Public API" below). The class owns:
* the FlexKV ``KVManager`` (server-client mode when ``dp_size > 1`` or
multi-instance; in-process otherwise — handled by FlexKV itself);
* the per-rank ``KVTPClient`` that registers this rank's GPU KV cache
with the FlexKV TransferManager;
* an optional ``FlexKVLayerDoneCounter`` plus the UDS-side handshake
that wires its eventfds into the FlexKV layerwise transfer worker.
Cross-rank sync uses :class:`FlexKVComm`. Only the **sync leader**
(rank 0 of every PP × CP × TP axis) talks to ``KVManager``; other
ranks block on broadcast / barrier.
Modes:
* **MP / synchronous** (default): ``retrieve_kv`` fires ``launch``
and blocks on ``wait`` so the device slots are ready by the time
sglang's prefill runs.
* **Layerwise** (``FLEXKV_ENABLE_LAYERWISE_TRANSFER=1``): ``launch``
is fired with ``layerwise_transfer=True`` and the per-layer hook
registered via ``register_layer_transfer_counter`` blocks each
forward layer on its own eventfd.
"""
from __future__ import annotations
import logging
import os
import socket
import struct
import time
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import torch
from sglang.srt.mem_cache.storage.flexkv.flexkv_comm import (
CMD_LAYERWISE,
CMD_PUT_META,
CMD_STORE_COMPLETE,
FlexKVComm,
FlexKVLayerDoneCounter,
send_fds,
)
try:
from flexkv.common.request import KVResponseStatus
from flexkv.common.storage import KVCacheLayout, KVCacheLayoutType
from flexkv.integration.config import FlexKVConfig
from flexkv.kvmanager import KVManager
from flexkv.server.client import KVTPClient
from flexkv.transfer.layerwise import build_layerwise_eventfd_socket_path
from flexkv.transfer_manager import TransferManagerOnRemote
except ImportError as exc: # pragma: no cover - runtime check
raise RuntimeError(
"FlexKV is not installed. Please install the FlexKV package to use "
"--enable-flexkv."
) from exc
logger = logging.getLogger(__name__)
class FlexKVConnector:
"""A FlexKV-side façade used by :class:`FlexKVRadixCache`.
This class manages connection lifecycle and provides a small,
sgl-friendly contract over FlexKV's task-based API:
* ``lookup_kv`` — page-aligned hit count + a held task id.
* ``retrieve_kv`` — synchronous load (launch + wait).
* ``start_load_kv_layerwise`` — layerwise async load.
* ``store_kv`` — page-aligned write back.
* ``check_completed_stores`` — drain async store completions.
* ``prefetch_async`` / ``check_prefetch_progress`` /
``cancel_prefetch`` — opportunistic CPU↔SSD/Remote staging.
* ``release_pending`` — cancel a held task whose load won't run.
* ``reset`` / ``shutdown``.
"""
def __init__(
self,
*,
sgl_model_config: Any,
server_args: Any,
page_size: int,
kvcache: Any,
tp_rank: int,
dp_rank: Optional[int],
pp_rank: int,
attn_cp_rank: int,
pp_group: Any = None,
attn_tp_group: Any = None,
attn_cp_group: Any = None,
) -> None:
self.page_size = int(page_size)
# 1. Resolve FlexKV config from env + sglang server args.
self.flexkv_config = FlexKVConfig.from_env()
self.rank_info = self.flexkv_config.post_init_from_sglang_config(
sglang_config=sgl_model_config,
server_args=server_args,
page_size=self.page_size,
tp_rank=tp_rank,
pp_rank=pp_rank,
dp_rank=dp_rank if dp_rank is not None else 0,
attn_cp_rank=attn_cp_rank,
)
self.model_config = self.flexkv_config.model_config
self.cache_config = self.flexkv_config.cache_config
self._label = f"[model_config={self.model_config}, rank_info={self.rank_info}]"
# 2. Cross-rank sync context.
world_rank = (
torch.distributed.get_rank() if torch.distributed.is_initialized() else 0
)
self._sync_ctx = FlexKVComm(
rank_info=self.rank_info,
world_rank=world_rank,
pp_group=pp_group,
attn_tp_group=attn_tp_group,
attn_cp_group=attn_cp_group,
)
# 3. Align block counts across all ranks (MIN reduce) so each
# rank's KVManager registers compatible sizes.
for attr in ("num_cpu_blocks", "num_ssd_blocks", "num_remote_blocks"):
orig = getattr(self.cache_config, attr, None)
if orig is None or orig <= 0:
continue
aligned = self._sync_ctx.all_reduce_min(int(orig))
if aligned != orig:
logger.info(
"[FlexKV] Block count MIN alignment '%s': %d -> %d",
attr,
orig,
aligned,
)
setattr(self.cache_config, attr, aligned)
# 4. Extract MLA/MHA KV buffers + optional indexer buffers.
indexer_buffers = getattr(kvcache, "index_k_with_scale_buffer", None)
if hasattr(kvcache, "kv_buffer"):
# MLA: K and V share the same buffer (per-layer tensor).
kv_caches = list(kvcache.kv_buffer)
elif hasattr(kvcache, "k_buffer"):
# MHA: K buffers concatenated with V buffers, layer-first.
kv_caches = list(kvcache.k_buffer) + list(kvcache.v_buffer)
else:
raise AttributeError(
f"Unsupported KV cache type {type(kvcache).__name__}: "
f"expected kv_buffer (MLA/NSA) or k_buffer/v_buffer (MHA)."
)
self._kvcache = kvcache
# 5. On multi-node setups, every node beyond node 0 needs a
# TransferManagerOnRemote process (FlexKV side) before any rank
# on that node can register GPU buffers.
self._remote_process = None
if (
self.model_config.nnodes > 1
and self.rank_info.node_rank > 0
and self.rank_info.local_rank == 0
):
self._remote_process = TransferManagerOnRemote.create_process(
master_host=self.model_config.master_host,
master_ports=self.model_config.master_ports,
)
logger.info(
"[FlexKV] Launched TransferManagerOnRemote on node_rank=%d %s",
self.rank_info.node_rank,
self._label,
)
# 6. Bring up KVManager on the sync leader only.
self.kv_manager: Optional[KVManager] = None
if self._sync_ctx.is_sync_leader:
self.kv_manager = KVManager(
model_config=self.model_config,
cache_config=self.cache_config,
dp_client_id=self.rank_info.dp_client_id,
server_recv_port=self.flexkv_config.server_recv_port,
gpu_register_port=self.flexkv_config.gpu_register_port,
)
self.kv_manager.start()
# 7. Per-rank TP client registers this rank's GPU buffers.
self.tp_client = KVTPClient(
self.flexkv_config.gpu_register_port,
dp_client_id=self.rank_info.dp_client_id,
pp_rank=self.rank_info.pp_rank,
device_id=self.rank_info.local_rank,
)
self._register_with_retry(kv_caches, indexer_buffers)
# 8. Layerwise transfer plumbing.
self.enable_layerwise = bool(
int(os.environ.get("FLEXKV_ENABLE_LAYERWISE_TRANSFER", "0"))
)
self._layerwise_socket = build_layerwise_eventfd_socket_path(
dp_client_id=self.rank_info.dp_client_id,
pp_rank=self.rank_info.pp_rank,
model_config=self.model_config,
)
self._layerwise_eventfd_connect_max_retries = max(
360,
int(os.environ.get("FLEXKV_LAYERWISE_EVENTFD_CONNECT_MAX_RETRIES", "0")),
)
self.layer_done_counter: Optional[FlexKVLayerDoneCounter] = None
if self.enable_layerwise:
self.layer_done_counter = FlexKVLayerDoneCounter(
self.rank_info.num_layers_per_pp_stage
)
self._send_eventfds_to_worker()
# 9. Wait for the KVManager (and its remote subprocess) to be ready.
if self._sync_ctx.is_sync_leader:
self._wait_kv_manager_ready()
# 10. Per-rank in-flight tracking.
# Loads
self._pending_lookups: Dict[str, int] = {} # rid -> fkv_task_id
self._inflight_loads: Dict[int, int] = {} # producer_id -> rid hashlike
self._completed_layerwise: List[int] = []
self._launched_load_tids: List[int] = [] # leader-only, for periodic drain
# Stores
self._inflight_stores: Dict[str, int] = {} # rid -> fkv_task_id
# Prefetches
self._ongoing_prefetches: Dict[str, int] = {} # rid -> fkv_task_id
self._prefetch_enabled = bool(
self.cache_config.enable_ssd
or self.cache_config.enable_remote
or self.cache_config.enable_kv_sharing
)
logger.info(
"[FlexKV] Connector ready %s: layerwise=%s, prefetch=%s",
self._label,
self.enable_layerwise,
self._prefetch_enabled,
)
# ------------------------------------------------------------------
# Public API — lookup / load
# ------------------------------------------------------------------
def lookup_kv(
self,
token_ids: List[int],
token_mask: torch.Tensor,
rid: Optional[str] = None,
) -> Tuple[int, int]:
"""Page-aligned prefix lookup against FlexKV.
Args:
token_ids: full token id sequence we'd like to check.
token_mask: 1-D bool tensor or array, True for "this token is
*not* already on GPU and is a candidate for load-back".
rid: if set and hit > 0, the held FlexKV task id is stashed
under this key so a later ``retrieve_kv(rid, slots)`` call
can resolve it. If not set, the held task is cancelled when
hit > 0 and the caller didn't ask to track it.
Returns:
``(fkv_task_id, hit_count)``. ``hit_count`` is page-aligned
and may be smaller than the raw FlexKV match if the page
floor truncated it.
"""
fkv_task_id = -1
hit_length = 0
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
tids_np = np.asarray(token_ids, dtype=np.int64)
mask_np = self._as_numpy_mask(token_mask)
try:
res = self.kv_manager.get_match(token_ids=tids_np, token_mask=mask_np)
except Exception as exc: # noqa: BLE001
logger.warning("[FlexKV] get_match raised: %s", exc)
res = None
if res is None:
fkv_task_id = -1
hit_length = 0
else:
fkv_task_id, matched_mask = res
hit_length = int(matched_mask.sum()) if matched_mask is not None else 0
if self._sync_ctx.needs_sync:
payload = self._sync_ctx.scatter(
{"task_id": fkv_task_id, "hit": hit_length}
)
fkv_task_id = payload["task_id"]
hit_length = payload["hit"]
# Page-align: FlexKV transfers whole pages.
if hit_length > 0 and self.page_size > 1:
aligned = (hit_length // self.page_size) * self.page_size
if aligned < hit_length:
logger.debug(
"[FlexKV] lookup_kv: page-aligning hit %d -> %d (page=%d)",
hit_length,
aligned,
self.page_size,
)
hit_length = aligned
# Decide what to do with the held task. Three cases:
# 1. hit_length > 0 and rid given → stash for retrieve_kv later.
# 2. hit_length > 0 and rid is None → cancel; caller can't use it.
# 3. hit_length == 0 → no work to do; FlexKV already marked the
# empty graph COMPLETED inside get_match, cancel would warn.
if hit_length > 0 and rid is not None and fkv_task_id >= 0:
self._pending_lookups[rid] = fkv_task_id
elif hit_length > 0 and fkv_task_id >= 0 and self._sync_ctx.is_sync_leader:
assert self.kv_manager is not None
self.kv_manager.cancel([fkv_task_id])
return fkv_task_id, hit_length
def release_pending(self, rid: str) -> None:
"""Cancel the task held by an earlier ``lookup_kv(rid=...)`` that
won't be followed by a ``retrieve_kv`` (e.g. allocation failed)."""
fkv_task_id = self._pending_lookups.pop(rid, -1)
if fkv_task_id >= 0 and self._sync_ctx.is_sync_leader:
assert self.kv_manager is not None
self.kv_manager.cancel([fkv_task_id])
def retrieve_kv(
self,
rid: str,
slot_mapping: torch.Tensor,
) -> int:
"""Synchronous load: ``launch`` + ``wait``.
Returns the number of slots actually loaded. The caller is
responsible for having allocated ``slot_mapping`` of length
equal to ``hit_length`` from a prior ``lookup_kv``.
"""
fkv_task_id = self._pending_lookups.pop(rid, -1)
if fkv_task_id < 0:
return 0
slot_mapping_cpu = self._to_cpu_int64(slot_mapping)
# Cross-node PP receivers must send their slot mapping back to
# the TransferManagerOnRemote so the remote side knows where to
# land the H2D copies on its own GPUs.
if self._sync_ctx.should_send_slot_mapping_to_remote:
self._send_slot_mapping_to_remote(fkv_task_id, slot_mapping_cpu)
n = slot_mapping_cpu.numel()
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
self.kv_manager.launch(
task_ids=[fkv_task_id],
slot_mappings=[slot_mapping_cpu],
as_batch=True,
layerwise_transfer=False,
)
resp = self.kv_manager.wait([fkv_task_id], timeout=30.0)
if not (
fkv_task_id in resp
and resp[fkv_task_id].status == KVResponseStatus.SUCCESS
):
logger.warning(
"[FlexKV] retrieve_kv: task %d failed/timed out",
fkv_task_id,
)
n = 0
if self._sync_ctx.needs_sync:
self._sync_ctx.barrier()
return n
def start_load_kv_layerwise(
self,
rid: str,
slot_mapping: torch.Tensor,
) -> Tuple[int, int]:
"""Layerwise load. Fires ``launch(layerwise_transfer=True)`` and
returns ``(n_slots, producer_id)``. The caller registers
``producer_id`` with the layer hook so the KV pool blocks on
the right eventfds during forward."""
assert self.enable_layerwise and self.layer_done_counter is not None, (
"start_load_kv_layerwise called but layerwise transfer is "
"disabled. Set FLEXKV_ENABLE_LAYERWISE_TRANSFER=1."
)
fkv_task_id = self._pending_lookups.pop(rid, -1)
if fkv_task_id < 0:
return 0, -1
slot_mapping_cpu = self._to_cpu_int64(slot_mapping)
n = slot_mapping_cpu.numel()
if self._sync_ctx.should_send_slot_mapping_to_remote:
self._send_slot_mapping_to_remote(fkv_task_id, slot_mapping_cpu)
# Allocate / receive producer slot.
if self._sync_ctx.is_pp_receiver:
payload = self._sync_ctx.scatter_pp(None)
if payload.get("cmd") != CMD_LAYERWISE:
raise RuntimeError(
f"Tag mismatch: expected CMD_LAYERWISE, got "
f"{payload.get('cmd')}"
)
producer_id = int(payload["counter_id"])
self.layer_done_counter.register_task_with_explicit_counter_id(
fkv_task_id, producer_id
)
else:
producer_id = self.layer_done_counter.update_producer()
self.layer_done_counter.events[producer_id].reset_for_new_transfer()
self.layer_done_counter.register_task(fkv_task_id, producer_id)
if self._sync_ctx.is_pp_sender:
self._sync_ctx.scatter_pp(
{
"cmd": CMD_LAYERWISE,
"fkv_task_id": fkv_task_id,
"counter_id": producer_id,
}
)
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
self.kv_manager.launch(
task_ids=[fkv_task_id],
slot_mappings=[slot_mapping_cpu],
as_batch=True,
layerwise_transfer=True,
counter_id=producer_id,
)
self._launched_load_tids.append(fkv_task_id)
# Tell the layer hook which counter slot to wait on.
self.layer_done_counter.set_consumer(fkv_task_id)
return n, producer_id
def drain_launched_loads(self, threshold: int = 100) -> None:
"""Periodic non-blocking sweep on long-lived launched tasks so the
FlexKV pipe doesn't accumulate. No-op on non-leader ranks."""
if not self._sync_ctx.is_sync_leader or self.kv_manager is None:
return
if len(self._launched_load_tids) < threshold:
return
try:
self.kv_manager.try_wait(task_ids=list(self._launched_load_tids))
except Exception as exc: # noqa: BLE001
logger.debug("[FlexKV] drain_launched_loads try_wait: %s", exc)
self._launched_load_tids.clear()
# ------------------------------------------------------------------
# Public API — store
# ------------------------------------------------------------------
def store_kv(
self,
rid: str,
token_ids: List[int],
kv_indices: torch.Tensor,
) -> int:
"""Schedule a write back from GPU into FlexKV.
On the sync leader this runs ``put_match`` to discover which
tokens are NOT yet in FlexKV's CPU cache (= the "unmatched"
slice), then ``launch`` on those. On non-leaders the unmatched
mask is received over the PP fan-out so cross-node PP can
forward its slot mappings.
Returns the FlexKV task id of the in-flight store, or -1 if
nothing needed to be written.
"""
token_ids_np = np.asarray(token_ids, dtype=np.int64)
n = len(token_ids_np)
if n != len(kv_indices):
raise ValueError(
f"store_kv: token_ids has {n} entries but kv_indices "
f"has {len(kv_indices)} entries"
)
# Page-align inputs *before* put_match so the FlexKV allocator
# only reserves slots that line up with the slot_mapping we send.
if self.page_size > 1:
aligned_len = (n // self.page_size) * self.page_size
if aligned_len == 0:
self._send_pp_put_meta(-1, [])
return -1
if aligned_len < n:
token_ids_np = token_ids_np[:aligned_len]
kv_indices = kv_indices[:aligned_len]
fkv_task_id = -1
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
try:
res = self.kv_manager.put_match(token_ids=token_ids_np, token_mask=None)
except Exception as exc: # noqa: BLE001
logger.warning("[FlexKV] put_match raised: %s", exc)
res = None
if res is None:
self._send_pp_put_meta(-1, [])
return -1
fkv_task_id, unmatched_mask = res
self._send_pp_put_meta(fkv_task_id, unmatched_mask)
if int(unmatched_mask.sum()) > 0:
filtered = kv_indices[unmatched_mask]
slot_mapping_cpu = self._to_cpu_int64(filtered)
self.kv_manager.launch(
task_ids=[fkv_task_id],
slot_mappings=[slot_mapping_cpu],
as_batch=False,
layerwise_transfer=False,
)
self._inflight_stores[rid] = fkv_task_id
return fkv_task_id
return -1
# Non-leader path: receive the unmatched mask + maybe forward
# slot_mapping to the remote-side TransferManager.
if self._sync_ctx.is_pp_receiver:
payload = self._sync_ctx.scatter_pp(None)
if payload.get("cmd") != CMD_PUT_META:
raise RuntimeError(
f"Tag mismatch: expected CMD_PUT_META, got " f"{payload.get('cmd')}"
)
fkv_task_id = int(payload["fkv_task_id"])
mask_list = payload.get("unmatched_mask", [])
unmatched_mask = torch.tensor(mask_list, dtype=torch.bool)
if (
int(unmatched_mask.sum()) > 0
and fkv_task_id >= 0
and self._sync_ctx.should_send_slot_mapping_to_remote
):
filtered = kv_indices[unmatched_mask]
slot_mapping_cpu = self._to_cpu_int64(filtered)
self._send_slot_mapping_to_remote(fkv_task_id, slot_mapping_cpu)
self._inflight_stores[rid] = fkv_task_id
return fkv_task_id
def check_completed_stores(self) -> List[str]:
"""Return rids whose stores have completed since the last call."""
completed_rids: List[str] = []
completed_dict: Dict[int, Any] = {}
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
if self._inflight_stores:
fk_to_rid = {v: k for k, v in self._inflight_stores.items()}
try:
completed_dict = self.kv_manager.try_wait(
task_ids=list(fk_to_rid.keys())
)
except Exception as exc: # noqa: BLE001
logger.debug("[FlexKV] check_completed_stores: %s", exc)
completed_dict = {}
for fk_tid in completed_dict:
rid = fk_to_rid[fk_tid]
completed_rids.append(rid)
self._inflight_stores.pop(rid, None)
if self._sync_ctx.is_pp_sender:
self._sync_ctx.scatter_pp(
{
"cmd": CMD_STORE_COMPLETE,
"completed_fk_ids": list(completed_dict),
}
)
elif self._sync_ctx.is_pp_receiver:
payload = self._sync_ctx.scatter_pp(None)
if payload.get("cmd") != CMD_STORE_COMPLETE:
raise RuntimeError(
f"Tag mismatch: expected CMD_STORE_COMPLETE, got "
f"{payload.get('cmd')}"
)
fk_ids = payload.get("completed_fk_ids", [])
if fk_ids and self._inflight_stores:
fk_to_rid = {v: k for k, v in self._inflight_stores.items()}
for fk_tid in fk_ids:
if fk_tid in fk_to_rid:
rid = fk_to_rid[fk_tid]
completed_rids.append(rid)
self._inflight_stores.pop(rid, None)
if self._sync_ctx.needs_sync:
completed_rids = self._sync_ctx.scatter(completed_rids)
return completed_rids
def wait_store(self, rid: str, timeout: float = 30.0) -> bool:
"""Block until a single store task identified by ``rid`` finishes."""
fkv_task_id = self._inflight_stores.pop(rid, -1)
if fkv_task_id < 0:
return True
if not self._sync_ctx.is_sync_leader or self.kv_manager is None:
return True
try:
resp = self.kv_manager.wait([fkv_task_id], timeout=timeout)
except Exception as exc: # noqa: BLE001
logger.warning("[FlexKV] wait_store: %s", exc)
return False
return (
fkv_task_id in resp and resp[fkv_task_id].status == KVResponseStatus.SUCCESS
)
# ------------------------------------------------------------------
# Public API — prefetch
# ------------------------------------------------------------------
def prefetch_async(self, rid: str, token_ids: List[int]) -> int:
if not self._prefetch_enabled or not rid:
return -1
task_id = -1
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
try:
task_id = self.kv_manager.prefetch_async(
token_ids=np.asarray(token_ids, dtype=np.int64)
)
except Exception as exc: # noqa: BLE001
logger.debug("[FlexKV] prefetch_async: %s", exc)
task_id = -1
if self._sync_ctx.needs_sync:
payload = self._sync_ctx.scatter({"task_id": task_id})
task_id = payload["task_id"]
if task_id >= 0:
self._ongoing_prefetches[rid] = task_id
return task_id
def check_prefetch_progress(self, rid: str) -> bool:
if not self._prefetch_enabled:
return True
task_id = self._ongoing_prefetches.get(rid, -1)
if task_id < 0:
return True
done = False
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
try:
completed = self.kv_manager.try_wait(task_ids=[task_id])
except Exception: # noqa: BLE001
completed = {}
if task_id in completed:
done = True
if self._sync_ctx.needs_sync:
payload = self._sync_ctx.scatter({"done": done})
done = payload["done"]
if done:
self._ongoing_prefetches.pop(rid, None)
return done
def cancel_prefetch(self, rid: str) -> None:
self._pending_lookups.pop(rid, None)
# FlexKV doesn't currently support prefetch cancellation, but
# we still drop our tracking entry.
self._ongoing_prefetches.pop(rid, None)
# ------------------------------------------------------------------
# Layerwise transfer hooks
# ------------------------------------------------------------------
def register_layer_transfer_counter(self, kvcache: Any) -> None:
"""Register the FlexKVLayerDoneCounter onto sglang's KV pool so
each forward layer blocks on its eventfd. No-op when layerwise
is disabled."""
if (
self.layer_done_counter is None
or kvcache is None
or not hasattr(kvcache, "register_layer_transfer_counter")
):
return
kvcache.register_layer_transfer_counter(self.layer_done_counter)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def reset(self) -> None:
# Drop pending lookups (cancel their held tasks on the leader).
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
pending = [tid for tid in self._pending_lookups.values() if tid >= 0]
if pending:
try:
self.kv_manager.cancel(pending)
except Exception as exc: # noqa: BLE001
logger.debug("[FlexKV] reset cancel: %s", exc)
self._pending_lookups.clear()
self._ongoing_prefetches.clear()
self._inflight_loads.clear()
self._completed_layerwise.clear()
self._launched_load_tids.clear()
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
for fk_tid in list(self._inflight_stores.values()):
if fk_tid >= 0:
try:
self.kv_manager.wait([fk_tid], timeout=20.0)
except Exception: # noqa: BLE001
pass
self._inflight_stores.clear()
if self.layer_done_counter is not None:
self.layer_done_counter.reset()
def shutdown(self) -> None:
if self._sync_ctx.is_sync_leader and self.kv_manager is not None:
try:
self.kv_manager.shutdown()
except Exception as exc: # noqa: BLE001
logger.warning("[FlexKV] kv_manager.shutdown: %s", exc)
if self._remote_process is not None:
try:
self._remote_process.terminate()
self._remote_process.join(timeout=5.0)
if self._remote_process.is_alive():
self._remote_process.kill()
self._remote_process.join()
except Exception as exc: # noqa: BLE001
logger.warning("[FlexKV] remote process shutdown: %s", exc)
self._remote_process = None
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
@staticmethod
def _as_numpy_mask(mask) -> np.ndarray:
if mask is None:
return None
if isinstance(mask, torch.Tensor):
return mask.detach().cpu().numpy()
return np.asarray(mask)
@staticmethod
def _to_cpu_int64(tensor: torch.Tensor) -> torch.Tensor:
if tensor.is_cuda:
tensor = tensor.cpu()
return tensor.to(torch.int64)
def _wait_kv_manager_ready(self, poll_interval: float = 10.0) -> None:
assert self.kv_manager is not None
wait_count = 0
while not self.kv_manager.is_ready():
time.sleep(poll_interval)
wait_count += 1
logger.info(
"[FlexKV] Waiting for FlexKV ready %s (waited %.0fs)",
self._label,
wait_count * poll_interval,
)
logger.info("[FlexKV] FlexKV is ready %s", self._label)
def _register_with_retry(
self,
kv_caches: List[torch.Tensor],
indexer_buffers: Optional[List[torch.Tensor]] = None,
max_retries: int = 360,
) -> None:
"""Retry GPU registration. On node_rank>0, the
TransferManagerOnRemote may not be ready immediately; retry up
to ~6 minutes."""
for attempt in range(max_retries):
try:
self._register_to_server(kv_caches, indexer_buffers)
return
except Exception as exc: # noqa: BLE001
if attempt == max_retries - 1:
raise
if attempt % 30 == 0:
logger.info(
"[FlexKV] GPU register retry %s attempt=%d/%d " "error=%s",
self._label,
attempt + 1,
max_retries,
exc,
)
time.sleep(1.0)
def _register_to_server(
self,
kv_caches: List[torch.Tensor],
indexer_buffers: Optional[List[torch.Tensor]] = None,
) -> None:
assert len(kv_caches) > 0
assert (
kv_caches[0].ndim == 3
), f"Expected 3D KV cache tensor, got shape={kv_caches[0].shape}"
is_mla = self.model_config.use_mla
num_blocks, num_kv_heads, head_size = kv_caches[0].shape
gpu_layout = KVCacheLayout(
type=KVCacheLayoutType.LAYERFIRST,
num_layer=self.rank_info.num_layers_per_pp_stage,
num_block=num_blocks // self.page_size,
tokens_per_block=self.page_size,
num_head=num_kv_heads,
head_size=head_size,
is_mla=is_mla,
)
indexer_layout = None
if indexer_buffers is not None and len(indexer_buffers) > 0:
indexer_tensor = indexer_buffers[0]
assert indexer_tensor.ndim == 2, (
f"Expected 2D indexer tensor (num_pages, page_stride_size), "
f"got shape={indexer_tensor.shape}"
)
indexer_layout = KVCacheLayout(
type=KVCacheLayoutType.LAYERFIRST,
num_layer=len(indexer_buffers),
num_block=indexer_tensor.shape[0],
tokens_per_block=1,
num_head=1,
head_size=indexer_tensor.shape[1],
is_mla=True,
)
self.tp_client.register_to_server(
kv_caches=kv_caches,
kv_layout=gpu_layout,
indexer_buffers=indexer_buffers,
indexer_layout=indexer_layout,
)
logger.info("[FlexKV] Registered KV caches to server %s", self._label)
def _send_pp_put_meta(self, fkv_task_id: int, unmatched_mask) -> None:
if not self._sync_ctx.is_pp_active:
return
if hasattr(unmatched_mask, "tolist"):
mask_list = unmatched_mask.tolist()
else:
mask_list = list(unmatched_mask)
self._sync_ctx.scatter_pp(
{
"cmd": CMD_PUT_META,
"fkv_task_id": fkv_task_id,
"unmatched_mask": mask_list,
}
)
def _send_slot_mapping_to_remote(
self, task_id: int, slot_mapping_cpu: torch.Tensor
) -> None:
np_arr = slot_mapping_cpu.numpy()
self.tp_client.set_slot_mapping(task_id, np_arr)
def _send_eventfds_to_worker(self, retry_interval: float = 1.0) -> None:
"""UDS handshake with the FlexKV layerwise transfer worker.
Sends per-counter eventfd FDs over a unix domain socket using
``SCM_RIGHTS``. Retries connect (worker may not yet be up) and
retries the whole connect+send sequence on send error.
"""
max_retries = self._layerwise_eventfd_connect_max_retries
max_send_retries = 3
last_error: Optional[BaseException] = None
assert self.layer_done_counter is not None
for send_attempt in range(max_send_retries):
sock: Optional[socket.socket] = None
try:
# Phase 1: connect (worker may not yet be up).
for attempt in range(max_retries):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.connect(self._layerwise_socket)
logger.info(
"[FlexKV] Eventfd connected %s socket=%s attempts=%d",
self._label,
self._layerwise_socket,
attempt + 1,
)
break
except (FileNotFoundError, ConnectionRefusedError) as exc:
sock.close()
sock = None
if attempt == max_retries - 1:
raise RuntimeError(
f"[FlexKV] Failed to connect to eventfd socket "
f"{self._layerwise_socket} after {max_retries} attempts"
) from exc
time.sleep(retry_interval)
assert sock is not None
# Phase 2: send 16-byte metadata + per-counter FDs + read ACK.
num_counters = self.layer_done_counter.num_counters
metadata = struct.pack(
"iiii",
self.rank_info.tp_rank_per_node,
self.model_config.tp_size_per_node,
self.rank_info.num_layers_per_pp_stage,
num_counters,
)
sock.sendall(metadata)
for counter_id in range(num_counters):
fds = self.layer_done_counter.events[counter_id].load_event_fds
send_fds(sock, fds, struct.pack("i", counter_id))
sock.settimeout(30.0)
try:
ack = sock.recv(1)
except socket.timeout as exc:
raise RuntimeError(
"Timed out waiting for ACK from FlexKV layerwise worker"
) from exc
if not ack or ack[0] != 1:
raise RuntimeError(
f"FlexKV layerwise worker NACK'd eventfd transfer "
f"(ack={ack!r})"
)
logger.info(
"[FlexKV] Eventfd handshake complete %s counters=%d layers=%d",
self._label,
num_counters,
self.rank_info.num_layers_per_pp_stage,
)
return
except Exception as exc: # noqa: BLE001
last_error = exc
logger.warning(
"[FlexKV] Eventfd handshake send_attempt=%d/%d failed: %s",
send_attempt + 1,
max_send_retries,
exc,
)
finally:
if sock is not None:
sock.close()
time.sleep(retry_interval)
raise RuntimeError(
f"[FlexKV] Failed to send eventfds to {self._layerwise_socket} "
f"after {max_send_retries} attempts: {last_error}"
)
@@ -0,0 +1,511 @@
"""FlexKV-backed RadixCache for sglang.
This module exposes :class:`FlexKVRadixCache`, a subclass of
:class:`sglang.srt.mem_cache.radix_cache.RadixCache` that delegates
host-side prefix storage to a FlexKV ``KVManager``. The design mirrors
``LMCRadixCache`` (the LMCache integration) so the scheduler-side
contract is identical:
* MP (synchronous) mode — the default.
``match_prefix`` fires only a FlexKV LOOKUP and returns ``host_hit_length``;
the scheduler then calls :meth:`init_load_back` at dispatch time which
allocates slots and fires the FlexKV RETRIEVE.
* IP (layerwise) mode — enabled with ``FLEXKV_ENABLE_LAYERWISE_TRANSFER=1``.
``match_prefix`` allocates uncached slots and kicks off a layerwise
load; the per-layer hook registered via
``register_layer_transfer_counter`` then waits on each layer's
eventfd inside the model's forward pass.
Selection: ``--enable-flexkv`` on the sglang CLI routes the default
RadixCache factory here. See ``__init__.py`` in this package for the
``register_radix_cache_backend("flexkv", ...)`` entry-point that backs
the explicit ``--radix-cache-backend=flexkv`` form.
"""
from __future__ import annotations
import enum
import logging
import threading
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, Tuple
import torch
from sglang.srt.mem_cache.base_prefix_cache import (
EvictParams,
EvictResult,
InitLoadBackParams,
MatchPrefixParams,
MatchResult,
)
from sglang.srt.mem_cache.radix_cache import RadixCache, RadixKey, TreeNode
from sglang.srt.mem_cache.storage.flexkv.flexkv_connector import FlexKVConnector
if TYPE_CHECKING:
from sglang.srt.configs.model_config import ModelConfig
from sglang.srt.managers.schedule_batch import Req
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
from sglang.srt.server_args import ServerArgs
logger = logging.getLogger(__name__)
class FlexKVMode(enum.Enum):
MP = enum.auto() # synchronous lookup → retrieve in two phases
IP = enum.auto() # in-process layerwise transfer
@dataclass
class _LoadBackMarker:
"""State carried from a hit-producing ``match_prefix`` to its
matching ``init_load_back``. The detached ``RadixKey`` is a snapshot
of the matched key at lookup time (the live request key aliases
``req.fill_ids`` which keeps growing)."""
key: RadixKey
value_numel: int # device tokens already present at lookup time
class FlexKVRadixCache(RadixCache):
"""RadixCache extended with FlexKV host-tier IO."""
def __init__(
self,
params: CacheInitParams,
model_config: Optional[ModelConfig],
server_args: ServerArgs,
tp_rank: int,
tp_size: int,
dp_rank: Optional[int],
pp_rank: int,
attn_cp_rank: int,
tp_group=None,
pp_group=None,
attn_tp_group=None,
attn_cp_group=None,
) -> None:
super().__init__(params)
kvcache = self.token_to_kv_pool_allocator.get_kvcache()
# ``tp_group`` and ``attn_tp_group`` are sometimes passed
# interchangeably by sglang's factory; prefer the explicit
# ``attn_tp_group`` when given.
attn_tp_group_eff = attn_tp_group if attn_tp_group is not None else tp_group
self.flexkv_connector = FlexKVConnector(
sgl_model_config=model_config,
server_args=server_args,
page_size=params.page_size,
kvcache=kvcache,
tp_rank=tp_rank,
dp_rank=dp_rank,
pp_rank=pp_rank,
attn_cp_rank=attn_cp_rank,
pp_group=pp_group,
attn_tp_group=attn_tp_group_eff,
attn_cp_group=attn_cp_group,
)
self._mode = (
FlexKVMode.IP if self.flexkv_connector.enable_layerwise else FlexKVMode.MP
)
if self._mode is FlexKVMode.IP:
# Register the eventfd counter onto sglang's KV pool so each
# forward layer blocks on its own eventfd.
self.flexkv_connector.register_layer_transfer_counter(kvcache)
# CUDA streams (mirroring LMCRadixCache).
self.load_stream = torch.cuda.Stream()
self.store_stream = torch.cuda.Stream()
# Two-phase MP load: stash marker between ``match_prefix`` and
# ``init_load_back``.
self._load_markers: dict[str, _LoadBackMarker] = {}
# ``store_kv`` is async — we keep a lock on the source node
# until FlexKV signals completion, draining in ``evict`` /
# ``check_hicache_events``.
self._inflight_store_nodes: dict[str, TreeNode] = {}
self._node_lock = threading.Lock()
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def reset(self) -> None: # type: ignore[override]
super().reset()
if hasattr(self, "_load_markers"):
self._load_markers.clear()
if hasattr(self, "_inflight_store_nodes"):
with self._node_lock:
self._inflight_store_nodes.clear()
if hasattr(self, "flexkv_connector"):
self.flexkv_connector.reset()
def shutdown(self) -> None:
if hasattr(self, "flexkv_connector"):
self.flexkv_connector.shutdown()
# ------------------------------------------------------------------
# match_prefix
# ------------------------------------------------------------------
def match_prefix(self, params: MatchPrefixParams) -> MatchResult: # type: ignore[override]
"""Look up the longest cached prefix on host KV (FlexKV).
Dispatches to :meth:`_mp_match_prefix` or :meth:`_ip_match_prefix`
depending on whether layerwise transfer is enabled.
"""
key = params.key
if self.disable or not key:
return super().match_prefix(params)
# FlexKV operates at page granularity — round the lookup query
# down to a multiple of ``page_size`` so the hit count we report
# back to sglang matches what FlexKV can actually serve.
if self.page_size != 1:
aligned_len = (len(key) // self.page_size) * self.page_size
key = key[:aligned_len]
base_res = super().match_prefix(params)
if len(key) == 0:
return base_res
device_value: torch.Tensor = base_res.device_indices
last_node: TreeNode = base_res.last_device_node
if self._mode is FlexKVMode.MP:
if params.req is None:
return base_res
return self._mp_match_prefix(
key, base_res, device_value, last_node, params.req
)
return self._ip_match_prefix(key, base_res, device_value, last_node)
def _mp_match_prefix(
self,
key: RadixKey,
base_res: MatchResult,
device_value: torch.Tensor,
last_node: TreeNode,
req: Req,
) -> MatchResult:
"""LOOKUP-only path. Sets ``host_hit_length`` on the result so
the scheduler later invokes :meth:`init_load_back`."""
token_ids = key.raw_token_ids()
device_len = int(device_value.numel())
if device_len >= len(token_ids):
return base_res
# token_mask=True for tokens NOT on device — FlexKV decides
# which of those it can serve.
token_mask = torch.zeros(len(token_ids), dtype=torch.bool)
token_mask[device_len:] = True
fkv_task_id, hit = self.flexkv_connector.lookup_kv(
token_ids=token_ids, token_mask=token_mask, rid=req.rid
)
if hit <= 0:
return base_res
# Snapshot the matched key (the live key aliases ``req.fill_ids``).
if token_ids is key.token_ids:
token_ids_snap = token_ids[:]
else:
token_ids_snap = token_ids
self._load_markers[req.rid] = _LoadBackMarker(
key=RadixKey(token_ids_snap, key.extra_key, key.is_bigram),
value_numel=device_len,
)
return MatchResult(
device_indices=device_value,
last_device_node=last_node,
last_host_node=last_node,
best_match_node=last_node,
host_hit_length=hit,
)
def _ip_match_prefix(
self,
key: RadixKey,
base_res: MatchResult,
device_value: torch.Tensor,
last_node: TreeNode,
) -> MatchResult:
"""Layerwise path: allocate slots and fire ``start_load_kv_layerwise``
immediately. Per-layer hook waits during forward."""
token_ids = key.raw_token_ids()
device_len = int(device_value.numel())
if device_len >= len(token_ids):
return base_res
# Quick LOOKUP first to discover how many slots we'd need.
token_mask = torch.zeros(len(token_ids), dtype=torch.bool)
token_mask[device_len:] = True
# No rid here — IP mode self-pops; pass a synthetic stable key.
synthetic_rid = f"_ip_{id(key)}"
_, hit = self.flexkv_connector.lookup_kv(
token_ids=token_ids, token_mask=token_mask, rid=synthetic_rid
)
if hit <= 0:
return base_res
result = self._allocate_and_load(
key=key,
value_numel=device_len,
uncached_len=hit,
last_node=last_node,
load_fn=lambda slot_mapping: self.flexkv_connector.start_load_kv_layerwise(
synthetic_rid, slot_mapping
)[0],
)
if result is None:
return base_res
new_slots, new_node = result
return MatchResult(
device_indices=torch.cat([device_value, new_slots]),
last_device_node=new_node,
last_host_node=new_node,
best_match_node=new_node,
)
# ------------------------------------------------------------------
# init_load_back (MP RETRIEVE)
# ------------------------------------------------------------------
def init_load_back( # type: ignore[override]
self,
params: InitLoadBackParams,
) -> Tuple[torch.Tensor, Optional[TreeNode]]:
"""MP RETRIEVE. Allocates uncached slots and fires the FlexKV
load; inserts the resulting TreeNode."""
req = params.req
last_node: TreeNode = params.best_match_node
marker = self._load_markers.pop(req.rid, None)
if marker is None:
# ``match_prefix`` decided there was no work to do, but the
# scheduler still called us. Release any held task and
# return an empty load.
self.flexkv_connector.release_pending(req.rid)
return (
torch.empty((0,), dtype=torch.int64, device=self.device),
last_node,
)
result = self._allocate_and_load(
key=marker.key,
value_numel=marker.value_numel,
uncached_len=params.host_hit_length,
last_node=last_node,
load_fn=lambda slot_mapping: self.flexkv_connector.retrieve_kv(
req.rid, slot_mapping
),
)
if result is None:
# Allocation failed or load returned zero. ``retrieve_kv``
# already cancels/cleans up on failure paths; release_pending
# is idempotent for the case where allocation failed before
# we even popped the held task.
self.flexkv_connector.release_pending(req.rid)
return (
torch.empty((0,), dtype=torch.int64, device=self.device),
last_node,
)
return result
def _allocate_and_load(
self,
*,
key: RadixKey,
value_numel: int,
uncached_len: int,
last_node: TreeNode,
load_fn,
) -> Optional[Tuple[torch.Tensor, TreeNode]]:
"""Shared allocator + post-load bookkeeping for MP/IP.
Returns ``(token_slots[:fetched], new_node)`` on success.
``None`` on either allocation failure or zero retrieved (in
which case all slots are freed).
"""
if uncached_len <= 0:
return None
# Evict to make room when needed.
if self.token_to_kv_pool_allocator.available_size() < uncached_len:
self.evict(EvictParams(num_tokens=uncached_len))
token_slots = self.token_to_kv_pool_allocator.alloc(uncached_len)
if token_slots is None:
return None
# The FlexKV ``launch`` interface takes the slot indices for the
# tokens it will write — no leading ``-1`` padding (FlexKV has
# no concept of "skip these device slots, they're already
# cached"; we pass it exactly the destinations for the
# uncached tail).
num_retrieved = load_fn(token_slots.to(torch.int64))
if num_retrieved <= 0:
self.token_to_kv_pool_allocator.free(token_slots)
return None
# Free the tail of the over-allocation when FlexKV returned
# fewer than expected.
if num_retrieved < uncached_len:
self.token_to_kv_pool_allocator.free(token_slots[num_retrieved:])
fetched_slots = token_slots[:num_retrieved]
else:
fetched_slots = token_slots
new_node = TreeNode(priority=last_node.priority)
start = value_numel
end = start + num_retrieved
new_node.key = key[start:end]
new_node.value = fetched_slots
new_node.parent = last_node
last_node.children[new_node.key.child_key(self.page_size)] = new_node
self.evictable_size_ += num_retrieved
self._update_leaf_status(last_node)
self._update_leaf_status(new_node)
self._record_store_event(new_node.parent)
self._record_store_event(new_node)
return fetched_slots, new_node
# ------------------------------------------------------------------
# cache_finished_req (STORE)
# ------------------------------------------------------------------
def cache_finished_req( # type: ignore[override]
self, req: Req, is_insert: bool = True
) -> None:
"""Base cache_finished_req then fire an async FlexKV store."""
super().cache_finished_req(req, is_insert=is_insert)
if not is_insert:
self._load_markers.pop(req.rid, None)
return
# Compute the committed prefix mirroring LMCRadixCache's logic.
from sglang.srt.runtime_context import get_server_args
global_server_args = get_server_args()
topk = global_server_args.speculative_eagle_topk
enable_kv_committed_len = topk is None or topk == 1
if enable_kv_committed_len:
kv_committed_len = req.kv_committed_len
else:
kv_committed_len = len(req.origin_input_ids) + max(
len(req.output_ids) - 1, 0
)
token_ids = (req.origin_input_ids + req.output_ids)[:kv_committed_len]
if not token_ids:
return
kv_indices = self.req_to_token_pool.req_to_token[
req.req_pool_idx, :kv_committed_len
]
# Anchor on the new last_device_node so FlexKV's lock matches
# the node we'll later unlock when the store completes.
match_result = super().match_prefix(
MatchPrefixParams(key=RadixKey(token_ids, req.extra_key))
)
new_last_node = match_result.last_device_node
if new_last_node is None:
return
self.inc_lock_ref(new_last_node)
try:
with torch.cuda.stream(self.store_stream):
fkv_task_id = self.flexkv_connector.store_kv(
rid=req.rid,
token_ids=list(token_ids),
kv_indices=kv_indices,
)
except Exception: # noqa: BLE001
self.dec_lock_ref(new_last_node)
raise
if fkv_task_id < 0:
# Nothing to write back (either everything already in
# FlexKV, or put_match failed / returned None).
self.dec_lock_ref(new_last_node)
return
with self._node_lock:
self._inflight_store_nodes[req.rid] = new_last_node
# ------------------------------------------------------------------
# evict + completion draining
# ------------------------------------------------------------------
def evict(self, params: EvictParams) -> EvictResult: # type: ignore[override]
"""Drain completed stores before letting the base evict touch
the source nodes."""
if self.disable:
return EvictResult()
self._drain_completed_stores()
# Make sure the store stream's GPU work is observed before any
# eviction frees the source slots.
self.store_stream.synchronize()
return super().evict(params)
def check_hicache_events(self) -> None: # type: ignore[override]
"""Periodic non-blocking sweep called by the scheduler tick.
Drains both store completions (so source nodes get unlocked
quickly) and the launched-load tail (so the FlexKV pipe
doesn't accumulate)."""
self._drain_completed_stores()
self.flexkv_connector.drain_launched_loads()
def _drain_completed_stores(self) -> None:
completed_rids = self.flexkv_connector.check_completed_stores()
if not completed_rids:
return
with self._node_lock:
for rid in completed_rids:
node = self._inflight_store_nodes.pop(rid, None)
if node is not None:
self.dec_lock_ref(node)
# ------------------------------------------------------------------
# Optional pass-throughs used by the scheduler
# ------------------------------------------------------------------
def release_aborted_request(self, rid: str) -> None:
"""Clean up tracking for an aborted request without invoking FlexKV."""
self._load_markers.pop(rid, None)
with self._node_lock:
node = self._inflight_store_nodes.pop(rid, None)
if node is not None:
self.dec_lock_ref(node)
self.flexkv_connector.release_pending(rid)
self.flexkv_connector.cancel_prefetch(rid)
def prefetch_from_storage(
self, rid: str, last_host_node: TreeNode, token_ids
) -> None:
"""Kick off an opportunistic prefetch (SSD/Remote → CPU)."""
try:
self.flexkv_connector.prefetch_async(rid, list(token_ids))
except Exception as exc: # noqa: BLE001
logger.debug("[FlexKV] prefetch_from_storage: %s", exc)
def check_prefetch_progress(self, rid: str) -> bool:
return self.flexkv_connector.check_prefetch_progress(rid)
def terminate_prefetch(self, rid: str) -> None:
self.flexkv_connector.cancel_prefetch(rid)
def pop_prefetch_loaded_tokens(self, rid: str) -> int:
# FlexKV doesn't expose per-rid prefetched token counts yet.
return 0
@property
def hicache_storage_pass_prefix_keys(self) -> bool:
# We pass token ids, not opaque key strings, so no prefix-key
# accounting in the scheduler.
return False
@@ -0,0 +1,180 @@
"""End-to-end correctness check for the FlexKV sglang connector.
Run twice with different server configurations:
# 1. Baseline: launch sglang WITHOUT --enable-flexkv first, then:
python verify_outputs.py --phase baseline
# 2. Restart sglang WITH --enable-flexkv, then:
python verify_outputs.py --phase test
Each prompt is requested twice in the test phase:
* R1 (fresh) — first call after server start; FlexKV may still have
state from a previous test run, but match must equal baseline.
* R2 (cached) — after /flush_cache; the GPU radix is empty but
FlexKV's CPU pool keeps the data, so R2 should be a host hit.
Both R1 and R2 output_ids must byte-equal the baseline. Any mismatch
is reported and exit code is non-zero. Run again with
``FLEXKV_ENABLE_LAYERWISE_TRANSFER=1`` set on the server to exercise
the layerwise path.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.request
PROMPTS = [
(
"PROMPT_SHORT",
"The capital of France is",
12,
),
(
"PROMPT_MEDIUM",
"List the first ten prime numbers in order: 2, 3, 5, ",
24,
),
(
"PROMPT_LONG",
# Long enough to span many KV pages.
(
"In the year 2025, a research team at a major AI lab released a "
"report describing the architecture of a new large language "
"model. The report had several sections. Section one introduced "
"the model and its training data. Section two covered the "
"attention mechanism in detail, including how the keys and "
"values were managed. Section three discussed deployment, "
"including KV cache offloading to CPU memory and to disk. "
"Section four reported evaluation results on standard "
"benchmarks. Section five concluded with a discussion of "
"future work, including improvements to the offloading layer "
"and to the radix tree used to index cached prefixes. "
"Now, summarize the report in one sentence: "
),
60,
),
]
def _post(host: str, path: str, body=None, timeout=120) -> str:
if body is None:
req = urllib.request.Request(f"http://{host}{path}", method="POST")
else:
req = urllib.request.Request(
f"http://{host}{path}",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode()
def gen(host: str, text: str, max_new: int) -> dict:
raw = _post(
host,
"/generate",
{
"text": text,
"sampling_params": {
"max_new_tokens": max_new,
"temperature": 0.0,
},
},
)
return json.loads(raw)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--host",
default="127.0.0.1:30000",
help="sglang server host:port (default 127.0.0.1:30000)",
)
ap.add_argument(
"--phase",
choices=["baseline", "test"],
required=True,
help="baseline: record golden outputs; test: compare against them",
)
ap.add_argument(
"--baseline-file",
default="/tmp/flexkv_baseline.json",
help="where to write/read the baseline outputs",
)
args = ap.parse_args()
if args.phase == "baseline":
result = {}
for name, text, max_new in PROMPTS:
r = gen(args.host, text, max_new)
meta = r["meta_info"]
print(
f"[baseline] {name}: completion={meta['completion_tokens']}, "
f"cached={meta['cached_tokens']}, text={r['text']!r}"
)
result[name] = {
"text": r["text"],
"output_ids": r["output_ids"],
"completion_tokens": meta["completion_tokens"],
}
with open(args.baseline_file, "w") as f:
json.dump(result, f, indent=2)
print(f"\nWrote baseline to {args.baseline_file}")
return 0
with open(args.baseline_file) as f:
baseline = json.load(f)
errors = 0
for name, text, max_new in PROMPTS:
b = baseline[name]
# R1 (fresh): may or may not hit FlexKV depending on prior state.
r1 = gen(args.host, text, max_new)
m1 = r1["meta_info"]
ok1 = r1["output_ids"] == b["output_ids"]
print(
f"[test/{name}] R1 fresh: cached={m1['cached_tokens']}/"
f"{m1['prompt_tokens']}, details={m1.get('cached_tokens_details')}, "
f"output_match={'OK' if ok1 else 'MISMATCH'}"
)
if not ok1:
print(f" baseline: {b['text']!r}")
print(f" r1 : {r1['text']!r}")
errors += 1
# Give the async D2H store a beat to complete before we flush.
time.sleep(2)
_post(args.host, "/flush_cache")
time.sleep(1)
# R2 (cached): GPU radix is empty; FlexKV must serve the prefix.
r2 = gen(args.host, text, max_new)
m2 = r2["meta_info"]
ok2 = r2["output_ids"] == b["output_ids"]
ratio = m2["cached_tokens"] / max(1, m2["prompt_tokens"])
print(
f"[test/{name}] R2 cached: cached={m2['cached_tokens']}/"
f"{m2['prompt_tokens']} ({ratio:.1%}), "
f"details={m2.get('cached_tokens_details')}, "
f"output_match={'OK' if ok2 else 'MISMATCH'}"
)
if not ok2:
print(f" baseline: {b['text']!r}")
print(f" r2 : {r2['text']!r}")
errors += 1
print(f"\nTotal mismatches: {errors}")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())