Skip to content

[None][perf] KVv2: zero-materialization token path + SHA-256 hash batching - #3

Draft
lancelly wants to merge 22 commits into
lowsfer:kvCacheManagerV2-cppfrom
lancelly:kvv2-zeromat-hashbatch
Draft

[None][perf] KVv2: zero-materialization token path + SHA-256 hash batching#3
lancelly wants to merge 22 commits into
lowsfer:kvCacheManagerV2-cppfrom
lancelly:kvv2-zeromat-hashbatch

Conversation

@lancelly

@lancelly lancelly commented Jul 20, 2026

Copy link
Copy Markdown

Two admission-path optimizations for the C++ KVCacheManagerV2, ported from our GB300 disagg production study (DeepSeek-V4-Pro, 4×TP8 ctx + 1×TP16 gen, conc=1000, agentx workload, ~110k requests per arm, controlled same-binary A/B).

Commit 1: zero-materialization token path (+2.1% e2e req/s)

Admitting a long-context request round-trips every token id through a Python object: get_tokens() materializes a list of PyLongs, Python slices it, and castTokenIterable unboxes each element back (~24 ns/token each way). At 200k-token prompts this is the dominant _schedule cost — measured 43% of ctx _schedule wall on this workload.

  • GenericLlmRequest.get_tokens_bytes(beam): raw int32 token buffer, one memcpy
  • prepare_context: memoryview(...).cast('i') + zero-copy slice for the reuse lookup (text-only prompts; multimodal keeps the list path)
  • castTokenIterable: buffer-protocol fast path for contiguous int32/int64 buffers; reserve() + PyLong_CheckExact fast loop on the iterable fallback
  • python wrapper packs plain lists into array('q') so the fallback also crosses as a buffer

Measured: ctx _schedule 8.95 → 5.07 ms/iter (−43%), per-request token prep 1.20 → 0.05 ms, admission of >20k-token requests 9.4 → 5.9 ms; e2e +2.1% req/s, TTFT p50 −5.2%, ITL p50 −4.0%. Behavior equivalence validated over 230k requests (identical reuse/commit counters vs the list path).

Commit 2: batch token ids into 4KB SHA-256 writes (CPU-side win, e2e-neutral)

The bulk Hasher::update overload writes each token as a separate 8-byte CSHA256::Write; with the transform on ARMv8 SHA extensions, the per-Write bookkeeping dominates. Packing ids into a 512-token stack buffer (one Write per 4KB) gives 9.5 → 6.5 ns/token (1.47×) on Grace. Byte stream is identical → digests unchanged (1000-trial randomized equivalence incl. buffer rollover and DigestToken boundaries), so the security invariant documented on BlockKey is untouched.

Full disclosure: hashing is only ~11% of ctx _schedule after commit 1, so e2e movement was within noise (+0.2% req/s) — the win is CPU cycles and a shorter admission tail. Take it or leave it independently of commit 1; the stale "batching only helps Python" comment it removes predates the hw-SHA switch either way.

lowsfer and others added 22 commits July 20, 2026 10:08
Add the C++ translation of KVCacheManagerV2 under
cpp/tensorrt_llm/batch_manager/kv_cache_manager_v2/ with nanobind
bindings, vendored BLAKE3, build integration, and unit tests.

Turn tensorrt_llm/runtime/kv_cache_manager_v2/__init__.py into a
backend dispatcher (TLLM_KV_CACHE_MANAGER_V2_BACKEND, default: cpp)
and add the C++-backend introspection paths in pyexecutor and tests.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Signed-off-by: Jin Li <59594262+liji-nv@users.noreply.github.com>
Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Signed-off-by: Jin Li <59594262+liji-nv@users.noreply.github.com>
Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Signed-off-by: Jin Li <59594262+liji-nv@users.noreply.github.com>
Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Port of main commit 7d600ec (DSv4 PR-6): a null beam_block page may
now also mean a skipped stale block, not only an SWA scratch block, so
drop the two TLLM_CHECK_DEBUG(mEnableSwaScratchReuse) checks in the
page unlock/release paths and update the comments to match the Python
reference implementation.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
… KVCacheManagerV2

Translate two main-branch Python changes in runtime/kv_cache_manager_v2/ into the
C++ backend under cpp/tensorrt_llm/batch_manager/kv_cache_manager_v2/:

Translated from:
- 17190cf [feat] add commit min snapshot for SSM reuse (NVIDIA#15752)
    config: drop HelixConfig/ssm_reuse_interval, add commit_min_snapshot;
    page: add SsmCommittedPage + UncommittedPage::convertToSsmCommitted;
    blockRadixTree: unlinkPage(expectedPage) guard + snapshot-length-aware SSM
    match truncation; kvCache: commit(is_end), _commitBlock(commitSsm,moveSsm),
    rewritten _snapshotSsmToTreeBlock, new _copyPageToTreeBlock and
    _snapshotPartialBlockToTree, plus commit_min_snapshot assert relaxations;
    nanobind: commit(is_end) and config/manager surface updates.
- 52fc8f4 [fix] Reserve worst-case SWA slots to avoid single-request deadlock
    (NVIDIA#15588, nvbugs/6330273)
    storageManager::computeMinSlotsFromConstraints SWA worst-case floor;
    lifeCycleRegistry getStaleRange comment.

Also in this change:
- Mirror the Python Block._release_pages() eager reclaim into C++
  Block::releasePages()/removeSubtree so page reclamation does not depend on
  ~Block() destruction timing.
- Add kv::AssertionError + a nanobind translator to Python AssertionError so
  config validation and the commit history-alignment check match the Python
  backend's exception type.
- Add white-box reuse-tree introspection helpers (attention/swa/ssm life-cycle
  ids, reuse_match_pages) to the C++ _introspection submodule and _introspection.py,
  and route the shared tests through them instead of Python-only internals.
- Adapt the SWA event-manager tests (small window; deeper load for the migration
  test to force both pool groups to migrate under the new slot floor).
- _core/_kv_cache.py: add the same VIRTUAL_STOP loop-break/partial-snapshot guard
  the C++ commit() uses, keeping the two backends behaviourally aligned.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Hash token sequences into radix-tree block keys with SHA-256 in the C++
KVCacheManagerV2, matching the Python backend's hashlib.sha256 so both
backends produce byte-identical block keys and KV cache event hashes.

Vendor Bitcoin Core's MIT-licensed SHA-256 under 3rdparty/sha256/, reduced
to the single-block CSHA256 hasher: a portable scalar core plus runtime
dispatch to x86 SHA-NI or ARMv8 crypto extensions, built as plain C++17.

- blockRadixTree: incremental digests via CSHA256; SHA256AutoDetect() selects
  the hardware transform once per process.
- eventManager: emit the v2_sha256 / v2_sha256_64 event hash algorithms.
- CMake: per-architecture -msha / -march=armv8-a+crypto wiring on the
  vendored translation units; include root 3rdparty/sha256.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Signed-off-by: Jin Li <59594262+liji-nv@users.noreply.github.com>
(cherry picked from commit f0c86ab)
hostPrefaultChunk() referenced MADV_POPULATE_WRITE unconditionally, which is
only defined with glibc >= 2.34 / Linux >= 5.14 headers. On the Rocky8
package-sanity CI images the macro is undeclared, breaking the
tensorrt_llm_batch_manager_static compile and cascading into all downstream
C++/PyTorch test stages.

Guard the fast madvise path with #ifdef MADV_POPULATE_WRITE and fall back to
explicitly touching the pages via memset when it is unavailable, matching the
existing EINVAL/ENOSYS runtime fallback.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
StorageManager::newGpuSlots and newSlotsForPoolGroup gained defaulted
MigrationRecorder/DropRecorder parameters (commit 741ab91, stats API
migration), but the static_assert member-function-pointer typedefs in
kvCacheManagerV2TypedIndexTest.cpp were not updated. A default argument
does not change a function-pointer type, so std::is_same failed and the
static_assert hard-failed compilation of the batch_manager C++ unit tests.

Append the two recorder parameters to StorageManagerNewGpuSlots and
StorageManagerNewSlotsForPoolGroup so the typedefs match the current API.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
ReuseScope.salt (low 8 bytes of a sha256 digest) and lora_id can occupy
the full unsigned 64-bit range, but the C++ ReuseScope stored them as
int64_t and the nanobind casters used std::optional<int64_t>. Passing a
salt >= 2**63 from Python raised TypeError at ReuseScope(salt=...) on the
C++ backend.

Type them as uint64 end-to-end, mirroring the RequestIdType approach
already used for request ids (rather than reinterpret-casting at the
nanobind boundary):

- ReuseScope::loraId -> std::optional<LoraTaskIdType>, salt ->
  std::optional<std::uint64_t>; add a LoraTaskIdType alias in the V2
  common.h next to RequestIdType.
- Propagate uint64 through the V1 event-hash path (V1RootAttrs,
  hashV1BlockKey) so no implicit narrowing occurs.
- Update the nanobind ReuseScope binding, optionalIntToObject,
  castOptionalIntAttr, and the int lora_task_id fallback to uint64.

Block-key and V1 event hashes are unchanged: toBytes/hash mixing
serialize the raw little-endian 8 bytes, which are bit-identical for a
given value whether stored as int64 or uint64, and the Python side
already serializes with signed=False.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
…key-gen helpers as public API

Two related cleanups to KVCacheManagerV2's reuse-key handling.

C++ hashing:
- Add Hasher::update(ReuseScope) that serializes the scope straight into the
  SHA-256 state via a shared emitReuseScopeBytes() emitter, removing the
  std::vector allocation the Hasher(ReuseScope) constructor incurred on the
  key-generation hot path.
- Drop ReuseScope::toBytes() and its nanobind `to_bytes` binding. Its only
  consumer was a unit test; production hashing never used it (a C++ ReuseScope
  is hashed internally and never round-tripped through Python).

Public key-gen API:
- Move the pure-Python reference helpers (gen_multimodal_cache_key_tokens,
  Hasher, reuse_scope_to_bytes, sequence_to_blockchain_keys) into a new
  backend-neutral module _cache_key.py and export
  gen_multimodal_cache_key_tokens and sequence_to_blockchain_keys from the
  package on both backends (added to __all__ and the .pyi stub).
- _block_radix_tree.py re-exports from _cache_key; ReuseScope.to_bytes()
  delegates to reuse_scope_to_bytes(). Removes the cpp dispatcher branch's
  duplicate gen_multimodal_cache_key_tokens.
- reuse_scope_to_bytes() serializes from the scope's fields, so it is
  backend-neutral (no dependency on a .to_bytes() method) and byte-identical
  to the C++ emitReuseScopeBytes() and the Python signed=False layout.

Tests:
- Import sequence_to_blockchain_keys from the public API in test_router and
  test_kv_cache_salting.
- Rewrite the salting test to assert distinct scopes seed distinct blockchain
  keys (digests) instead of asserting on raw to_bytes output.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
The nanobind module registers its own OutOfPagesError exception class, so
'except OutOfPagesError' clauses importing the pure-Python class from
kv_cache_manager_v2._exceptions never match on the C++ backend and the
guarded allocation paths crash instead of backing off. The package
__init__ already re-exports the backend-appropriate class; import that.

Signed-off-by: Lance Liao <108499334+lancelly@users.noreply.github.com>
(cherry picked from commit fdbe4f9)
release_index_slot passes None to clear the DSA index buffer, but the buf
argument rejected None at the nanobind layer (missing .none() annotation)
even though the lambda body already handles is_none(). On the C++ backend
every context worker died with 'incompatible function arguments ... NoneType'
at the first index-slot release.

Split from Lance Liao's commit 15a90e1 (the
constraint-floor half of that change lands in a separate commit).

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
…resume on both backends

Ports the DSv4 warmup-starvation fix (PR NVIDIA#16269) to KVCacheManagerV2's
constraint-based min-slot computation, on both the C++ and pure-Python
backends so they stay in parity.

Two behavior changes in computeMinSlotsFromConstraints /
_compute_min_slots_from_constraints:
- Constraints stay feasibility floors even under an explicit
  initial_pool_ratio (previously they were dropped for min-slots), so a
  declared batch that needs more than its target pool-group share clamps
  that share up instead of starving during warmup.
- The per-constraint slot floors are scaled by 1/max_util_for_resume
  (clamped to the binding (0, 1) range) because KvCache::resume rejects any
  pool group above that utilization, so the floor must leave headroom.

max_util_for_resume is threaded from KVCacheManagerConfig through the
StorageManager constructor on both backends.

The C++ side is a rewrite of Lance Liao's commit
15a90e1 (the constraint-floor half): this
branch's computeMinSlotsFromConstraints was reworked with structural
SWA/SSM floors after his fork point, and neither his branch's nor this
branch's Python _storage_manager.py had the fix, so a mechanical
cherry-pick did not apply and the Python backend needed the same change.

Update the init-ratio test accordingly: with the fix an infeasible
initial_pool_ratio is overridden by the constraint's feasibility floor, so
test_constraint_floor_overrides_infeasible_initial_pool_ratio asserts the
clamped-up share (both backends produce an identical ratio).

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
The license_checker rejected pendingStats.h and stats.h because their
boilerplate wrapped as 'permissions and limitations under / the License'
instead of the canonical 'permissions and / limitations under the License'
used by every passing C++ source file. Fix the wrap in the two flagged
headers plus the sibling kvCacheManagerV2StatsTest.cpp (same style).

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
test_kv_pool_rebalance imported OutOfPagesError from the ._exceptions
submodule (pure-python class), but py_executor catches the dispatcher
package's OutOfPagesError, which is _cpp.OutOfPagesError under the C++
backend. The test raised the python class while the code caught the C++
class, so the expected-failure path was not swallowed and the test failed
under the cpp backend (it passed under the python backend where both are
the same class). Import the backend-aware symbol so the class matches the
one py_executor catches, mirroring the py_executor import.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
…for unregistered role

get_index_k_buffer returns None when a layer has no INDEX_KEY buffer
registered (dense layers, base V2 manager). It detected this by catching
KeyError from the role probe. That holds on the python backend (dict
miss -> KeyError), but the C++ backend's getBufferAttr throws
std::out_of_range, which nanobind maps to IndexError, so the guard
missed it and the exception escaped -> the None-path contract broke and
TestIndexKeyBufferAccessor::test_accessor_returns_none_on_dense_layer and
::test_accessor_returns_none_on_base_v2_default_manager failed under the
C++ backend on H100/B300.

Catch (KeyError, IndexError) so 'role not registered -> None' holds on
either backend. Verified on H100 with TLLM_KV_CACHE_MANAGER_V2_BACKEND=cpp:
the full TestIndexKeyBufferAccessor class passes (was 2 failing).

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
The radix tree is a globally shared cross-request/cross-tenant cache
index whose prefix matching is decided purely by digest equality (no
token re-verification), and its hashed input (tokens, user-supplied
cache_salt, multimodal bytes) is attacker-influenceable. Document, on
both the C++ (blockRadixTree Hasher/BlockKey) and Python (_cache_key
Hasher) sides plus the kvcache doc, that the block-key hash must stay
cryptographic, collision-resistant, and >=256-bit (SHA-256), and must
not be swapped for a non-cryptographic hash or truncated without adding
a token-content equality check on match. Comment/doc-only.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
…l init

MpiPoolSession.shutdown() is reachable from __del__ (via shutdown_abort)
and may run on a session that never completed __init__ -- e.g. after
release_exit_joins() marks the pool dead, or in the released-session
path exercised by test_proxy_fast_death.py::
test_pool_session_shutdown_never_blocks_after_release, which builds the
object with __new__. shutdown() read self.n_workers (log line) and
self._wait_shutdown unguarded, raising AttributeError, while the
_pool_dead check right above already used getattr defensively.

Guard both accesses with getattr, matching the existing _pool_dead
style, so a released/partially-constructed session shuts down without
crashing. This unbreaks a cross-PR merge-skew regression on main: the
unguarded self.n_workers log came from NVIDIA#16456 while the __new__-based
test came from NVIDIA#16312; each was green alone but the combination fails
deterministically. Also add the missing D205 docstring blank lines the
ruff-legacy gate flagged on the touched file. Verified: the whole
test_proxy_fast_death.py file now passes (23 passed, was 1 failed).

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
… create_kv_cache

Admitting a long-context request previously round-tripped every token id
through a Python object: get_tokens() materializes a list of PyLongs from
the C++ token vector, Python slices it, and castTokenIterable unboxes each
element back to int64 (~24ns/token each way; several ms per 200k-token
admission, the dominant _schedule cost after the C++ manager landed).

Bypass the materialization entirely for text-only prompts:
- GenericLlmRequest.get_tokens_bytes(beam): export the int32 token vector
  as raw bytes, one memcpy
- prepare_context: wrap it in memoryview(...).cast('i'); slicing the view
  for the reuse lookup is zero-copy; multimodal requests keep the list path
- castTokenIterable: accept contiguous int32 ('i') and int64 ('q'/'l')
  buffers via the buffer protocol and bulk-fill the TokenIdExt vector;
  reserve() + PyLong_CheckExact fast path on the generic iterable fallback
- create_kv_cache python wrapper: pack plain-int lists into array('q') so
  even the fallback path crosses the binding as a buffer

Measured on GB300 4-ctx/1-gen disagg (DeepSeek-V4-Pro, conc=1000, agentx
workload, ~110k requests per arm, controlled A/B on the same binary):
ctx _schedule wall 8.95 -> 5.07 ms/iter (-43%), per-request token prep
1.20 -> 0.05 ms; e2e +2.1% req/s, TTFT p50 -5.2%, ITL p50 -4.0%.
Behavior equivalence validated over 230k requests (identical reuse/commit
counters vs the list path).

Signed-off-by: Lance Liao <108499334+lancelly@users.noreply.github.com>
The bulk update() overload wrote each token id as a separate 8-byte
CSHA256::Write. Once the transform itself runs on the ARMv8 SHA
extensions, the per-Write incremental buffering/bookkeeping dominates
the hash cost. Pack plain token ids into a 512-token stack buffer and
flush with one Write per 4KB (falling back to per-element update() for
multimodal digest tokens). The byte stream is identical, so digests are
unchanged - verified with a 1000-trial randomized equivalence test on
Grace covering buffer rollover and DigestToken boundaries.

The old comment claiming batching only helps Python predates the
hw-SHA switch and is dropped.

Measured on GB300 4-ctx/1-gen disagg (DeepSeek-V4-Pro, conc=1000,
15.6B hashed tokens per arm): hash cost 9.5 -> 6.5 ns/token (1.47x).
Hashing is ~11% of ctx _schedule after the zero-materialization path,
so e2e movement is within noise (+0.2% req/s); the win is CPU cycles
and a shorter admission tail, not throughput.

Signed-off-by: Lance Liao <108499334+lancelly@users.noreply.github.com>
@lowsfer
lowsfer force-pushed the kvCacheManagerV2-cpp branch 7 times, most recently from 3b6d8ed to cf45113 Compare July 21, 2026 15:10
@lowsfer
lowsfer force-pushed the kvCacheManagerV2-cpp branch 22 times, most recently from 0d02e17 to 4fb042f Compare July 28, 2026 07:11
@lowsfer
lowsfer force-pushed the kvCacheManagerV2-cpp branch 4 times, most recently from cef027a to f0852d3 Compare July 29, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants