Skip to content

feat: add stage-one radix prefix cache - #99

Open
zmnobug wants to merge 1 commit into
hw-native-sys:mainfrom
zmnobug:feature/sglang-radix-stage1
Open

feat: add stage-one radix prefix cache#99
zmnobug wants to merge 1 commit into
hw-native-sys:mainfrom
zmnobug:feature/sglang-radix-stage1

Conversation

@zmnobug

@zmnobug zmnobug commented Jul 20, 2026

Copy link
Copy Markdown

Summary

  • add an opt-in, page-aligned compressed radix prefix-cache backend while keeping hash as the default
  • reuse existing paged KV storage through explicit request/cache page ownership, radix path locks, canonical page deduplication, and leaf-LRU eviction
  • integrate radix lookup and incremental prefix publication with chunked prefill, decode, finish, abort, and preemption lifecycles
  • defer cold in-batch consumers behind a shared-prefix producer so they can reuse the producer's committed KV pages
  • add focused tree, KV ownership, scheduler, cleanup, CLI, and Qwen serving coverage
  • run the Radix CPU regression suite automatically in the GitHub Actions unit-tests job

Closes #38.

Behavior

The existing behavior remains unchanged unless the new backend is selected:

pypto-serving \
  --model /path/to/Qwen3-14B \
  --prefix-cache-backend radix

hash remains the CLI and engine default. The radix backend operates entirely in the serving scheduler and KV metadata layer; it continues to use the existing block table, slot mapping, paged KV tensors, and Qwen attention kernels.

Radix entries are namespaced by model ID and contain only complete KV pages. A request retains matched pages and locks the matched path while active. Completed page-aligned prompt and generated-token prefixes are published incrementally. When duplicate prefixes were computed concurrently, requests converge on the canonical radix pages and release redundant physical pages.

For a cold group of requests sharing the first full page, one request is scheduled as the prefix producer. Other consumers are deferred until the producer commits the shared prefix, then they reuse its canonical KV pages and prefill only their unmatched suffixes.

Validation

  • python -m pytest -q tests/test_radix_cache.py: 11 passed
  • python -m pytest -q tests/test_batching.py tests/test_parallel.py: 30 passed
  • workflow YAML parsing and Radix CI-step presence check: passed
  • changed-file Ruff checks: passed
  • header check: passed
  • English-only check: passed
  • git diff --check: passed

Qwen3-14B NPU Hash versus Radix comparison

A separate local benchmark was used to quantify the behavior without adding benchmark-only scripts or generated reports to this PR. The workload ran on one Ascend A2/A3 NPU with page size 128:

  • 3 independent cold-prefix groups
  • 16 concurrent requests per group
  • 384 shared prompt tokens plus 8 request-specific tokens
  • 1 generated token per request with greedy sampling
  • identical prompts and scheduler capacity for Hash and Radix
Metric, three groups Hash Radix Radix effect
Scheduled prefill tokens 18,816 1,536 91.84% reduction
Matched prefix tokens 0 17,280 5,760 per group
Scheduler steps 3 6 Two steps per Radix group
Scheduler CPU time 0.92 ms 11.67 ms 10.75 ms additional CPU time
Worker step wall time 13,624.75 ms 2,736.36 ms 4.979x speedup
Mean group makespan 4,542.19 ms 918.14 ms 4.947x speedup
Mean TTFT 4,542.16 ms 879.38 ms 5.165x speedup
TTFT p50 4,530.72 ms 916.38 ms 4.944x speedup
TTFT p95 4,596.16 ms 933.82 ms 4.922x speedup
Greedy output token IDs Reference Identical No output difference observed

The Hash backend sees an empty cache at the beginning of each cold group and schedules all 16 prompts, computing 16 * 392 = 6,272 prefill tokens. The Radix backend schedules one prefix producer, then the remaining 15 requests reuse its three committed pages and compute only their unique suffixes: 392 + 15 * 8 = 512 prefill tokens per group.

Worker step wall time starts before the main process submits a step and ends when it receives the worker result. It includes IPC, worker-side input preparation, NPU dispatch and execution, sampling, and result IPC; it is not a pure device-kernel measurement. This is an intentionally prefix-heavy workload and should not be interpreted as a universal 5x speedup for unrelated prompts.

The measurements were collected on 2026-07-14 before rebasing onto the latest main. A post-rebase local NPU rerun is currently blocked during Qwen kernel compilation by a local PyPTO parser/version incompatibility (hidden_states type reassignment), before either prefix-cache backend executes. The default Hash control fails at the same compile point; CPU scheduler/cache regression coverage passes on the rebased branch.

Scope

This Stage 1 change establishes the core radix tree, page ownership, scheduler integration, lifecycle cleanup, cold in-batch reuse, and tests. The standalone NPU benchmark implementation and detailed report remain in a separate local commit and are intentionally not included in this PR because they are validation-only artifacts. More advanced SGLang scheduling policies, richer cache namespaces, session semantics, and optional partial-page behavior remain follow-up work.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cb660e33-2e11-43b8-9046-f0dfb8f7a786

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an optional page-aligned Radix prefix-cache backend with CLI and engine wiring, KV ownership tracking, scheduler integration, tests, an NPU benchmark, and documentation. Hash remains the default backend.

Changes

Radix Prefix Cache

Layer / File(s) Summary
Cache contracts and radix storage
pypto_serving/serving/memory/prefix_cache/*, pypto_serving/serving/memory/request_kv_pool.py
Defines prefix-cache result types and interfaces, implements compressed radix matching, insertion, locking, eviction, and manages request page-to-slot mappings.
KV ownership and eviction integration
pypto_serving/serving/memory/kv_cache.py
Integrates RadixCache with KV allocation and eviction, separating active-request and radix-cache references.
Radix scheduling and request lifecycle
pypto_serving/serving/sched/scheduler.py
Adds radix matching, page allocation, in-batch deduplication, prefix publication, statistics, preemption, and cleanup paths.
Runtime backend selection
pypto_serving/cli/main.py, pypto_serving/serving/engine/async_engine.py
Adds --prefix-cache-backend {hash,radix}, propagates the selection into scheduler configuration, and carries model_id on requests.
Validation, benchmark, and usage documentation
tests/test_radix_cache.py, tests/test_qwen3_serving.py, tests/bench_radix_npu.py, README.md, docs/dev/radix_npu_benchmark.md
Tests radix matching, reuse, ownership, lifecycle handling, and CLI behavior; adds backend comparison measurements and usage documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ReplicaEngineCore
  participant Scheduler
  participant KvCacheManager
  participant RadixCache
  Client->>ReplicaEngineCore: submit prompt
  ReplicaEngineCore->>Scheduler: enqueue Request with model_id
  Scheduler->>KvCacheManager: match_radix_prefix(token_ids)
  KvCacheManager->>RadixCache: match_prefix(RadixKey)
  RadixCache-->>KvCacheManager: matched page slots
  Scheduler->>Scheduler: allocate unmatched pages
  Scheduler->>KvCacheManager: insert_radix_prefix(completed prefix)
  Scheduler-->>Client: generated output
Loading

Poem

I’m a bunny with pages tucked neat,
Radix hops where prefixes meet.
Hash still waits as the default tune,
Shared KV paths now bloom like June.
Tests and benchmarks thump their feet—
A cached carrot makes reuse sweet!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the requested radix backend, CLI option, page reuse, cleanup, and validation coverage from #38.
Out of Scope Changes check ✅ Passed The added docs, tests, and benchmark are directly tied to the radix backend work.
Title check ✅ Passed The title clearly summarizes the main change: adding a stage-one radix prefix cache backend.
Description check ✅ Passed The description matches the changeset and accurately summarizes the radix backend, tests, and benchmark work.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a Stage 1 SGLang-style Radix prefix cache backend as an alternative to the default Hash-based prefix cache. It adds the RadixCache implementation (a page-aligned compressed radix tree), a RequestKVPool for logical slot mapping, and updates the Scheduler to support radix matching, incremental caching, and in-batch deduplication (deferring requests to reuse cold shared prefixes). Additionally, CLI options, comprehensive unit tests, and a performance benchmark on the Qwen3-14B NPU path are provided. No review comments were submitted, so there is no feedback to address.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (4)
tests/bench_radix_npu.py (2)

332-335: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add strict=True to the output-equality zip.

If the two backends' group counts ever diverge (e.g. a run terminates early), zip silently truncates the comparison instead of surfacing the mismatch, undermining the "Greedy output token IDs identical" correctness check this benchmark relies on.

🐛 Proposed fix
         outputs_identical = all(
             left.output_token_ids == right.output_token_ids
-            for left, right in zip(results[0].groups, results[1].groups)
+            for left, right in zip(results[0].groups, results[1].groups, strict=True)
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/bench_radix_npu.py` around lines 332 - 335, Update the output-equality
comparison that assigns outputs_identical to call zip with strict=True, so
differing group counts raise an error instead of silently truncating the
correctness check.

Source: Linters/SAST tools


134-138: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Benchmark reaches into ReplicaEngineCore private internals.

_pause_background_loop toggles core._running/core._loop_task, and _drive_group (lines 186-187) pushes/pulls directly on core._input_queue/core._output_queue. These are private attributes of a class defined in async_engine.py (a prior layer); any refactor there can silently break this benchmark without a public contract signal.

♻️ Suggested direction

Expose a small public API on ReplicaEngineCore (e.g. pause_background_loop() / resume_background_loop() and submit_step(command) -> step_output) that the benchmark can call instead of touching _running, _loop_task, _input_queue, _output_queue directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/bench_radix_npu.py` around lines 134 - 138, Replace the benchmark’s
direct access to ReplicaEngineCore private state in _pause_background_loop and
_drive_group with a small public API on ReplicaEngineCore, such as
pause_background_loop(), resume_background_loop(), and submit_step(command) ->
step_output. Update the benchmark to use these methods instead of _running,
_loop_task, _input_queue, or _output_queue, while preserving its current
pause/resume and step-processing behavior.
pypto_serving/serving/sched/scheduler.py (2)

277-319: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Defer path correctness relies on an implicit num_computed_tokens == 0 invariant.

The deferral branch calls _release_radix_request(request), which resets radix state but not num_computed_tokens. This is only safe because _should_defer_for_radix_producer returns False whenever request.num_computed_tokens > 0, so the branch is reachable only when _match_radix_request matched zero slots. The invariant holds today, but a defensive reset would make it robust against future refactors of the match/defer sequencing.

♻️ Optional defensive reset in `_release_radix_request`
     def _release_radix_request(self, request: Request) -> None:
         pages = self.request_kv_pool.free(request.request_id)
         if pages:
             self.kv_cache_manager.release_pages_from_request(pages)
         if request.last_node is not None:
             self.kv_cache_manager.radix_cache.dec_lock_ref(request.last_node)
         request.cached_block_ids = []
         request.allocated_block_ids = []
         request.prefix_indices = []
         request.last_node = None
         request.cache_protected_len = 0
         request.kv_committed_len = 0
         request.num_blocks_cached = 0
+        request.num_computed_tokens = 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pypto_serving/serving/sched/scheduler.py` around lines 277 - 319, Ensure the
radix-cache deferral path preserves a zero computed-token state by resetting
request.num_computed_tokens when releasing a deferred request. Update
_release_radix_request, or the defer branch around
_should_defer_for_radix_producer, so future changes to matching order cannot
retain stale computed-token progress.

606-623: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

In-batch dedup scans all running producers per waiting request.

_should_defer_for_radix_producer is O(running × page_size) per waiting request due to re-tupling producer.all_token_ids[:page_size] for each candidate on every schedule pass. It's bounded by max_num_running_reqs and page size, so it's fine at current scales, but if page_size or batch width grows this becomes a hot-path cost. Consider precomputing/caching each running request's first-page tuple if profiling later shows scheduler CPU pressure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pypto_serving/serving/sched/scheduler.py` around lines 606 - 623, The current
implementation repeatedly constructs each producer’s first-page tuple inside
_should_defer_for_radix_producer; if profiling identifies scheduler CPU
pressure, cache or precompute the first-page identity for running requests and
reuse it during comparisons, while preserving the existing namespace, request,
token-count, and page-size checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pypto_serving/serving/sched/scheduler.py`:
- Around line 277-319: Ensure the radix-cache deferral path preserves a zero
computed-token state by resetting request.num_computed_tokens when releasing a
deferred request. Update _release_radix_request, or the defer branch around
_should_defer_for_radix_producer, so future changes to matching order cannot
retain stale computed-token progress.
- Around line 606-623: The current implementation repeatedly constructs each
producer’s first-page tuple inside _should_defer_for_radix_producer; if
profiling identifies scheduler CPU pressure, cache or precompute the first-page
identity for running requests and reuse it during comparisons, while preserving
the existing namespace, request, token-count, and page-size checks.

In `@tests/bench_radix_npu.py`:
- Around line 332-335: Update the output-equality comparison that assigns
outputs_identical to call zip with strict=True, so differing group counts raise
an error instead of silently truncating the correctness check.
- Around line 134-138: Replace the benchmark’s direct access to
ReplicaEngineCore private state in _pause_background_loop and _drive_group with
a small public API on ReplicaEngineCore, such as pause_background_loop(),
resume_background_loop(), and submit_step(command) -> step_output. Update the
benchmark to use these methods instead of _running, _loop_task, _input_queue, or
_output_queue, while preserving its current pause/resume and step-processing
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7f1a4c6a-9dda-4139-b99a-f5f6e35cdf23

📥 Commits

Reviewing files that changed from the base of the PR and between bfaa295 and ef5aef8.

📒 Files selected for processing (13)
  • README.md
  • docs/dev/radix_npu_benchmark.md
  • pypto_serving/cli/main.py
  • pypto_serving/serving/engine/async_engine.py
  • pypto_serving/serving/memory/kv_cache.py
  • pypto_serving/serving/memory/prefix_cache/__init__.py
  • pypto_serving/serving/memory/prefix_cache/base.py
  • pypto_serving/serving/memory/prefix_cache/radix.py
  • pypto_serving/serving/memory/request_kv_pool.py
  • pypto_serving/serving/sched/scheduler.py
  • tests/bench_radix_npu.py
  • tests/test_qwen3_serving.py
  • tests/test_radix_cache.py

@zmnobug
zmnobug force-pushed the feature/sglang-radix-stage1 branch from ef5aef8 to df87896 Compare July 20, 2026 07:30
@superxf

superxf commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

把你的 radix tree加到ci里看护一下吧

@zmnobug
zmnobug force-pushed the feature/sglang-radix-stage1 branch from df87896 to 80e668d Compare July 21, 2026 12:33
@zmnobug

zmnobug commented Jul 22, 2026

Copy link
Copy Markdown
Author

把你的 radix tree加到ci里看护一下吧

已添加 CI 看护。现在 CI 会执行 tests/test_radix_cache.py,覆盖前缀匹配、插入与拆分、引用计数、LRU 淘汰、重复页面收敛及请求清理等 Radix Tree 核心行为,共 11 个测试用例。相关修改已更新到当前 PR。

@coderabbitai coderabbitai Bot mentioned this pull request Jul 23, 2026
@zmnobug
zmnobug force-pushed the feature/sglang-radix-stage1 branch from 80e668d to fe25f87 Compare July 29, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Add radix prefix cache backend for paged KV reuse

2 participants