Skip to content
Open
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
cd88595
[Qwen 3.8] LMCache offload: GDN + PLE state images in QSA PAGE checkp…
qichu-yun Oct 19, 2026
6ad117b
[Qwen 3.8] Fetch chunk-aligned prompts back from CPU on both LMCache …
qichu-yun Oct 19, 2026
183cfec
fix(offload): namespace the LMCache engine id by server port
ZLkanyo009 Oct 20, 2026
d838980
test(qwen4_exp): drop an unused unpacked variable flagged by ruff
ZLkanyo009 Oct 20, 2026
484dc30
fix(qwen4-exp): clear released PAGE units in the QSA arena
ZLkanyo009 Oct 26, 2026
12dea23
fix(qwen4-exp): refuse P/D connectors until GDN/PLE state rides along
ZLkanyo009 Oct 27, 2026
409c46c
fix(offload): key the LMCache engine id on the resolved rendezvous port
ZLkanyo009 Oct 27, 2026
bb734a4
fix(qwen4-exp): use PAGE checkpoints only when an offload tier reads …
ZLkanyo009 Oct 27, 2026
d159907
fix(qwen4-exp): record the PLE conv slot as (channels, state_len) in …
ZLkanyo009 Oct 27, 2026
3dd40a4
fix(offload): key Qwen3.8's PAGE namespace on image input
ZLkanyo009 Oct 27, 2026
8d6e5b6
refactor(qwen4-exp): inherit the GDN PAGE contract through a pool hook
ZLkanyo009 Oct 27, 2026
274be1f
fix(gdn): check published PAGE regions against the arena's own offsets
ZLkanyo009 Oct 27, 2026
50f21df
docs(offload): describe the qwen tier's QSA image and the per-server …
ZLkanyo009 Oct 27, 2026
da48676
test(offload): cover the unwithheld full-prompt hit on Kimi-K3 and Qw…
ZLkanyo009 Oct 27, 2026
4b52c41
fix(lmcache-mp): store the prompt-end anchor first instead of every rung
ZLkanyo009 Oct 27, 2026
d5a9cfe
test(qwen4-exp): run the PAGE builder tests against a real AITER build
ZLkanyo009 Oct 27, 2026
9862cc9
Merge remote-tracking branch 'origin/main' into lingzha/qwen38-lmcache
ZLkanyo009 Oct 28, 2026
fe746ec
refactor(offload-mp): name the save-frontier memo fields and reuse _i…
ZLkanyo009 Oct 29, 2026
deedefe
fix(block-manager): keep the hash-block anchor when the tier grid flo…
ZLkanyo009 Oct 29, 2026
8ca0306
fix(qwen4-exp): refuse every non-LMCache transport by its canonical name
ZLkanyo009 Oct 29, 2026
bd39af4
refactor(qwen4-exp): drop clear_page_units and reset_entries
ZLkanyo009 Oct 29, 2026
a35e872
refactor(pool-layout): one region walk on LayerMajorArena for MHA, ML…
ZLkanyo009 Oct 29, 2026
5f90745
perf(gdn): key the PAGE region cache on the pool objects
ZLkanyo009 Oct 29, 2026
646a7f8
fix(gdn): page_unit_segments fails again without a checkpoint spec
ZLkanyo009 Oct 29, 2026
332d69a
refactor(qwen4-exp): drop the model-type literal from _uses_paged_che…
ZLkanyo009 Oct 29, 2026
3579186
refactor(qwen4-exp): declare the PLE slot once
ZLkanyo009 Oct 29, 2026
36c3f37
fix(qwen4-exp): key the PAGE layout on the draft fields and the SSM o…
ZLkanyo009 Oct 29, 2026
6496696
docs(gdn): describe Qwen3.8's PAGE path where the readers look for it
ZLkanyo009 Oct 29, 2026
26694d2
refactor(pool-layout): name the PAGE geometry for layer-major pools, …
ZLkanyo009 Oct 29, 2026
b38e691
fix(qwen4-exp): refuse lmcache_offload with quantized QSA caches unti…
ZLkanyo009 Oct 29, 2026
1dfe4e1
test(qwen4-exp): a BF16 target hands the MTP draft layer its own rows
ZLkanyo009 Oct 29, 2026
d9840a3
fix(offload): seed LMCache chunk keys with a multimodal prompt's media
ZLkanyo009 Oct 29, 2026
8abd77e
fix(block-manager): keep the prompt-end anchor past the resume point
ZLkanyo009 Oct 29, 2026
bf6293e
fix(lmcache-mp): store the highest held image directly
ZLkanyo009 Oct 29, 2026
90da188
fix(offload): report a hybrid's full-prompt hit whole only when a bou…
ZLkanyo009 Oct 29, 2026
73e0076
fix(offload): key the Qwen3.8 draft cache format only where draft row…
ZLkanyo009 Oct 29, 2026
80781a7
refactor(qwen4-exp): one deployability gate, transport checked first,…
ZLkanyo009 Oct 29, 2026
3d6edb9
docs(qwen4-exp): list the scale and draft fields of the QSA PAGE image
ZLkanyo009 Oct 29, 2026
7b27933
fix(block-manager): place the prompt-end anchor against the LMCache M…
ZLkanyo009 Oct 29, 2026
533e93d
fix(block-manager): ask the joint probe of the hit admission holds, o…
ZLkanyo009 Oct 29, 2026
497bbc7
fix(block-manager): end a midstep chunk on the anchor a tier grid wou…
ZLkanyo009 Oct 29, 2026
0818bfd
refactor(offload): one place asks whether a full-prompt hit withholds…
ZLkanyo009 Oct 29, 2026
fc07da3
fix(dsv4-offload): key LMCache through tier_token_ids like the other …
ZLkanyo009 Oct 29, 2026
42bac64
refactor(qwen4-exp): one aiter-free module decides the QSA arena's fi…
ZLkanyo009 Oct 29, 2026
2a78808
refactor(kv-transfer): declare reads_page_images on the connector reg…
ZLkanyo009 Oct 29, 2026
7ffe05f
fix(qwen4-exp): spell each PAGE field's shape and dtype in the layout id
ZLkanyo009 Oct 29, 2026
963d7fa
docs(offload): describe the seeded media keys
ZLkanyo009 Oct 29, 2026
0897671
Merge remote-tracking branch 'origin/main' into lz-a07/2636
ZLkanyo009 Nov 2, 2026
4c47d93
refactor(qwen38-offload): fold the QSA field and transfer helpers int…
ZLkanyo009 Nov 2, 2026
2fae196
test(qwen38-offload): keep the connector inputs and the text-prompt c…
ZLkanyo009 Nov 2, 2026
e7d8178
refactor(qwen38-offload): import StateTransfer at module top
ZLkanyo009 Nov 2, 2026
878c954
refactor(qwen38-offload): address the independent quality review
ZLkanyo009 Nov 2, 2026
c903175
fix(qwen38-offload): keep the QSA rules testable on the CPU runner
ZLkanyo009 Nov 2, 2026
027591b
refactor(qwen38-offload): fold the QSA rules back into qwen4_exp_attn
ZLkanyo009 Nov 2, 2026
12196f1
refactor(qwen38-offload): keep the diff to what Qwen3.8 offload needs
ZLkanyo009 Nov 2, 2026
3284885
chore(qwen38-offload): restore two moved lines and test the MHA regio…
ZLkanyo009 Nov 2, 2026
97af4f1
fix(qwen4-exp): register the QSA FP8 scale planes for KV offload (#2655)
ZLkanyo009 Nov 4, 2026
e5fafe3
Merge branch 'lingzha/offload-model-independent' (2caf12f1e) into lin…
ZLkanyo009 Nov 5, 2026
18ee7ac
Qwen3.8 offload: kernel-free QSA cache rules; neutral PAGE geometry n…
ZLkanyo009 Nov 5, 2026
acbd765
Qwen3.8 offload: spell the PAGE layout id in qsa_cache_rules
ZLkanyo009 Nov 5, 2026
7270f5a
Merge branch 'lingzha/offload-model-independent' (f1662fe4c) into lin…
ZLkanyo009 Nov 5, 2026
8eb092e
Merge branch 'lingzha/offload-model-independent' (4162fcde5) into lin…
ZLkanyo009 Nov 5, 2026
09edcad
Qwen3.8 offload: hoist the PAGE geometry imports in gdn_attn
ZLkanyo009 Nov 5, 2026
59feb21
Merge branch 'lingzha/offload-model-independent' (23d9ee232) into lin…
ZLkanyo009 Nov 5, 2026
3f6c4a4
Merge branch 'lingzha/offload-model-independent' (753a55282) into lin…
ZLkanyo009 Nov 5, 2026
22d2035
Merge branch 'lingzha/offload-model-independent' (45123b668) into lin…
ZLkanyo009 Nov 5, 2026
076142e
refactor(qwen3.8): tidy this PR's own additions
ZLkanyo009 Nov 6, 2026
422906a
Merge branch 'lz-a07/3027s' into lz-a07/2636s
ZLkanyo009 Nov 6, 2026
b7ea8c2
refactor(qwen3.8): one exit in state_transfer, drop an unreachable si…
ZLkanyo009 Nov 6, 2026
13515d8
Merge lingzha/offload-model-independent (#3027 205b5a390) into lingzh…
ZLkanyo009 Nov 9, 2026
0b8bc2d
fix(qwen38-mono): served steps write the QSA RoPE position cache
ZLkanyo009 Nov 9, 2026
829037d
Merge lingzha/offload-model-independent (#3027 1b996cbe2) into lingzh…
ZLkanyo009 Nov 9, 2026
0342e0f
Merge lingzha/offload-model-independent (#3027 816140eda) into lingzh…
ZLkanyo009 Nov 10, 2026
c343714
Merge lingzha/offload-model-independent (#3027 a0eb89e3f) into lingzh…
ZLkanyo009 Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions atom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class KVCacheTensor:
# without their exponents, and every index it computes stays in bounds while
# doing it. Whatever moves one must move the other.
index_scale: torch.Tensor | None = None
# Per-block planes a layer's pages carry beyond the fields above, in stored
# byte order (Qwen3.8's QSA side caches). Empty on most models.
extra_planes: tuple[torch.Tensor, ...] = ()
# ReplaySSM record buffers for linear-attention layers: this layer's slice
# of the (k, u, g) pools. None for every other attention type. Carried
# here because the layer-id -> linear-attn-index mapping already lives in
Expand Down
21 changes: 21 additions & 0 deletions atom/kv_transfer/disaggregation/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ class KVConnectorFactory:
# Whether a backend is a KV offload tier, whose scheduler half rewrites the
# completion sets it is handed (`process_completions`). Absent means no.
_offload: ClassVar[dict[str, bool]] = {}
# Whether a backend stores and restores a PAGE checkpoint image, the
# per-request state behind a hybrid's pages (Qwen3.8's GDN and PLE).
# Absent means no: a model whose state rides that image refuses it.
_reads_page_images: ClassVar[dict[str, bool]] = {}

@classmethod
def register(
Expand All @@ -108,6 +112,7 @@ def register(
copies_whole_block_regions: bool = False,
requires_pd_staging: bool = True,
offload: bool = False,
reads_page_images: bool = False,
) -> None:
"""Register a KV connector backend.

Expand All @@ -119,6 +124,8 @@ def register(
scheduler_class: Class name within *scheduler_module*.
offload: The backend is a KV offload tier; a ``multi`` runs at most
one (`is_offload_backend`).
reads_page_images: The backend carries PAGE checkpoint images
(`reads_page_images`).
"""
cls._registry[name] = {
"worker_module": worker_module,
Expand All @@ -143,6 +150,7 @@ def register(
cls._reads_block_regions[name] = bool(reads_block_regions)
cls._copies_whole_block_regions[name] = bool(copies_whole_block_regions)
cls._offload[name] = bool(offload)
cls._reads_page_images[name] = bool(reads_page_images)

@classmethod
def canonical_name(cls, value: object, *, path: str = "kv_transfer_config") -> str:
Expand Down Expand Up @@ -170,6 +178,19 @@ def is_offload_backend(cls, name: str) -> bool:

return cls._offload.get(name, False)

@classmethod
def reads_page_images(cls, name: str) -> bool:
"""Whether the registered backend ``name`` (canonical) carries PAGE
checkpoint images."""

return cls._reads_page_images.get(name, False)

@classmethod
def page_image_readers(cls) -> list[str]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please remove page_image_readers(). In production it appears only in one error message, and after _check_kv_transfer passes, _wants_paged_checkpoints' any(reads_page_images) is equivalent to bool(leaves) — a new public API plus two redundant checks (YAGNI). The message can name lmcache_offload / lmcache_mp directly, as the preceding error already does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 18ee7ac7b, together with its assertion in test_kv_connector_factory_topology.py. _wants_paged_checkpoints is bool(kv_transfer_leaves(config)), and the refusal names lmcache_offload / lmcache_mp directly. reads_page_images(name) remains, and check_kv_transfer uses it. The registry leak from the review body is fixed too: both finally blocks in test_fp4_indexer_kv_transfer_gate.py now pop _reads_page_images.

"""Every registered backend that carries PAGE checkpoint images."""

return sorted(name for name, reads in cls._reads_page_images.items() if reads)

@classmethod
def connector_name(cls, kv_transfer_config: dict[str, Any] | None) -> str | None:
"""Canonical name of the configured connector; None without a transfer config."""
Expand Down
21 changes: 17 additions & 4 deletions atom/kv_transfer/offload/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ For the in-process path, the public configuration remains
layouts: `m3` for MiniMax-M3 PAGE regions
(including its NSA index cache), `kimi_k3` when the text config has
`model_type == "kimi_linear"` (dense paged MLA KV plus a KDA per-request state
tier), `qwen` for GDN hybrids (`qwen3_next`, `qwen3_5_*`; a subclass of the
K3 tier over an MHA page image), `hybrid` when `hf_config.compress_ratios` is
tier), `qwen` for GDN hybrids (`qwen3_next`, `qwen3_5_*`, `qwen4_exp`; a
subclass of the K3 tier over a PAGE image in the MHA pool, or in the QSA arena
for Qwen3.8, whose image also carries the PLE windows), `hybrid` when
`hf_config.compress_ratios` is
present (DSV4 PAGE+SLOT), and `dense` otherwise.
`kv_transfer_config.offload_layout` can override compatible choices without
giving scheduler and worker different connector names. MiniMax-M3 cannot be
Expand Down Expand Up @@ -97,7 +99,7 @@ Four rules carry the module:
| `hybrid/dsv4/codec.py` | `DSV4PageSlotCodec`, `DSV4CheckpointCodec`, and `DSV4CheckpointStore`: unified GPU layout plans plus AOS1 framing/storage. |
| `hybrid/dsv4/triton_page_slot.py` | Raw-`uint8` PAGE/SLOT gather/scatter kernels; PAGE is forward-indexed and SLOT is reverse-indexed. |
| `hybrid/kimi_k3/connector.py` | Kimi-K3 worker and scheduler: the dense paged-KV path plus one extra leg for the KDA per-request state tier. |
| `hybrid/qwen/connector.py` | Qwen GDN worker and scheduler. Subclasses the K3 tier; the pages come from the MHA pool. |
| `hybrid/qwen/connector.py` | Qwen GDN worker and scheduler. Subclasses the K3 tier; the pages come from the MHA pool or Qwen3.8's QSA arena. |
| `hybrid/kimi_k3/staging.py` | Single-entry bounded GPU staging buffer, D2H/H2D copy stream, and producer event for one flat state entry per transfer. |
| `hybrid/kimi_k3/state_object.py` | One state checkpoint as a single opaque object keyed by ATOM's own hash, bypassing LMCache's `ChunkedTokenDatabase` (state bytes are not token-sliceable). |
| `hybrid/kimi_k3/state_tier.py` | Worker-side store/load driver for the state tier on its own executor; reports store/finished/failed hash sets for the engine-side `StateOffloadIndex` to apply. |
Expand Down Expand Up @@ -1178,6 +1180,16 @@ Under `--kv_cache_dtype fp8`, each KV block carries its own `k_scale` / `v_scale
(`dense/kv_byte_codec.py`). The scales travel with the quantized data, so a
reloaded fp8 block dequantizes identically; no scale is recomputed or dropped.

### Multimodal prompts: seeded keys

Image placeholder ids are the same for every picture, so the lookup, save and
load requests `ChunkedOffloadSchedulerBase` builds (in-process
`lmcache_offload` and `lmcache_mp`, including the native-state lookup) key
their ids through `tier_token_ids(seq, end)` (`_offload_common.py`): the first
4 ids of a prompt with a `cache_seed` become digests of
`(cache_seed, position, id)` with bit 31 set, off every vocabulary id. Text
prompts key unchanged.

### Invariants enforced in code

| Invariant | Where | Why |
Expand Down Expand Up @@ -1409,7 +1421,8 @@ env var. This per-thread isolation is exactly why load and save never contend.
`lookup_server_worker_ids=[0]` so rank 0 is the authoritative lookup answerer at
TP>1. `build_lmcache_metadata()` fills `kv_shape` from `hf_config` and pins a
shared `engine_id` so the scheduler's lookup client and the workers' lookup
servers derive the **same** ZMQ socket path.
servers derive the **same** ZMQ socket path, one per server
(`data_parallel_base_port`) and DP rank.
- **`metadata.py`** — `ATOMRawBytesLMCacheMetadata` overrides LMCache's allocation
to hand out opaque `uint8` MemoryObjs (`get_shapes` returns
`nblocks × bytes_per_block`) and asserts
Expand Down
2 changes: 2 additions & 0 deletions atom/kv_transfer/offload/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
aliases=("LMCacheOffloadConnector", "LMCacheConnectorV1"),
requires_pd_staging=False,
offload=True,
reads_page_images=True,
# The dense codec builds from `KVCacheTensor`s and ignores the region map;
# the hybrid, m3 and kimi_k3 layouts do read it, and which one applies comes
# from the model, so `topology_reads_block_regions` refines this by layout.
Expand All @@ -47,6 +48,7 @@
aliases=("LMCacheMPConnector",),
requires_pd_staging=False,
offload=True,
reads_page_images=True,
# Groups regions by per-block shape and copies whole blocks, so a layer may
# publish extra planes (the FP4 indexer's e8m0 scale) as more regions.
copies_whole_block_regions=True,
Expand Down
50 changes: 47 additions & 3 deletions atom/kv_transfer/offload/_offload_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
from __future__ import annotations

import collections
import hashlib
import json
import logging
import os
import struct
import threading
import time
import weakref
Expand Down Expand Up @@ -63,6 +65,38 @@ def tokens_to_tensor(tokens: list[int]) -> torch.Tensor:
return torch.from_numpy(np.asarray(tokens, dtype=np.int64))


# 4 x 31 bits covers the 64-bit `cache_seed`. The high bit keeps a keyed id off
# every vocabulary id; the LMCache MP server packs an id into 32 bits.
_MEDIA_KEY_TOKENS = 4
_MEDIA_KEY_BIT = 1 << 31
_MEDIA_KEY_PERSON = b"atom-lmc-media"


def tier_token_ids(seq, end: int) -> list[int]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The multimodal-key fix (tier_token_ids) misses the DSV4 scheduler: hybrid/dsv4/connector.py still builds lookup / save / load keys from raw list(seq.token_ids[:n]) at L1971, L2374 and L2441. DeepSeek-V4.1 is multimodal and has compress_ratios, so it takes the hybrid layout; under lmcache_offload, two requests with identical text but different images would hit each other's LMCache KV while the HBM-side state chain already carries cache_seed, so KV and state disagree. The description lists it under "Not changed", but per Fix-then-sweep it belongs in the same change. (I haven't run V4.1 to confirm it reaches this path.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in #3027, which now holds the multimodal-key fix. DSV4's lookup, load and save all key through tier_token_ids (hybrid/dsv4/connector.py).

I swept the SLOT boundary chain too (_chained_prefix_hashes(seed=…)); it's the same bug, per Fix-then-sweep. That chain started from −1, while BlockManager's chain starts from cache_seed, so two image prompts with the same text still shared a SLOT boundary hash.

Tests: test_dsv4_lookup_is_keyed_the_same_way and test_a_dsv4_save_keys_its_pages_and_slot_by_the_media. Reverting the lookup, the save or the seed each fails one of them.

"""``seq``'s first ``end`` token ids, as LMCache keys them.

Image placeholder ids are the same for every image, so the media identity
(``Sequence.cache_seed``, which also seeds the state images' hashes) is
folded into the first ids. A text prompt keys exactly as before, and a
prefix's keys are those of the whole prompt.
"""
tokens = list(seq.token_ids[:end])
seed = getattr(seq, "cache_seed", -1)
if seed is None or int(seed) == -1:
return tokens
salt = int(seed).to_bytes(16, "little", signed=True)
for i in range(min(_MEDIA_KEY_TOKENS, len(tokens))):
digest = hashlib.blake2b(
salt + struct.pack("<Iq", i, int(tokens[i])),
digest_size=4,
person=_MEDIA_KEY_PERSON,
).digest()
tokens[i] = _MEDIA_KEY_BIT | (
int.from_bytes(digest, "little") & (_MEDIA_KEY_BIT - 1)
)
return tokens


def validated_kv_role(kvc: dict) -> str:
role = kvc.get("kv_role", "offload")
if role not in _VALID_KV_ROLES:
Expand Down Expand Up @@ -679,13 +713,21 @@ def _chunk_floor(self, tokens: int) -> int:
chunk = int(self.chunk_size or 256)
return (max(0, int(tokens)) // chunk) * chunk

def _loadable_hit(self, hit: int, num_prompt: int) -> int:
def _withholds_last_prompt_token(self, seq) -> bool:
"""Whether a full-prompt hit gives back its last token (`_loadable_hit`):
a load that ends at the prompt's end leaves nothing to compute, unless
the layout's claim stops short of the load on its own."""
del seq
return True

def _loadable_hit(self, hit: int, num_prompt: int, seq=None) -> int:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_loadable_hit(hit, num_prompt, seq=None) now has two modes depending on whether the caller passes seq: the base hook is del seq; return True and only Qwen overrides it, while dsv4/connector.py:2012 and mooncake_store/scheduler.py:539 don't pass seq and get the old semantics. A future caller can silently fall into the old mode. Make seq required at every call site, or have subclasses override _loadable_hit itself.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in #3027: _loadable_hit(hit, num_prompt, seq) has one signature, seq is required, and all three callers pass it (chunked, dsv4/connector.py and mooncake_store/scheduler.py).

"""Turn a lookup hit into a length the external tier can actually serve.

Two steps, in this order:

* A hit covering the whole prompt leaves nothing to compute, so step
back one token.
back one token -- unless `seq` is given and
`_withholds_last_prompt_token(seq)` says its claim stops short.
* Floor to a chunk. `retrieve` resolves the tier at chunk granularity,
so a `hit` that is not a chunk multiple names tokens the tier does
not hold and the load's `ret_mask[hbm:lmc].all()` check can never
Expand All @@ -698,7 +740,9 @@ def _loadable_hit(self, hit: int, num_prompt: int) -> int:
"""

hit = int(hit)
if hit == int(num_prompt):
if hit == int(num_prompt) and (
seq is None or self._withholds_last_prompt_token(seq)
):
hit -= 1
return self._chunk_floor(hit)

Expand Down
9 changes: 5 additions & 4 deletions atom/kv_transfer/offload/chunked_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from atom.kv_transfer.offload import config as offcfg
from atom.kv_transfer.offload._offload_common import (
OffloadSchedulerMixin,
tier_token_ids,
validated_kv_role,
)
from atom.kv_transfer.offload.metadata import (
Expand Down Expand Up @@ -172,7 +173,7 @@ def _begin_load_lifecycle(self, seq) -> None:

def _lookup_token_ids(self, seq) -> list[int]:
"""The prompt extent this layout can safely ask the tier to resume."""
return list(seq.token_ids[: seq.num_prompt_tokens])
return tier_token_ids(seq, seq.num_prompt_tokens)

def _lookup_len(self, seq) -> int:
"""`len(self._lookup_token_ids(seq))` without copying the prompt."""
Expand Down Expand Up @@ -511,7 +512,7 @@ def _answer_from_tier_hit(self, seq, sid: str, hit: int) -> tuple[int, bool]:
return 0, False
num_prompt = seq.num_prompt_tokens
frontier = int(seq.num_cached_tokens)
hit = self._loadable_hit(hit, num_prompt)
hit = self._loadable_hit(hit, num_prompt, seq)
if self._hit_cap_hook is not None:
# After `_loadable_hit`, so the hook caps the hit that will
# actually be requested; before the save floor is recorded, so a
Expand Down Expand Up @@ -656,7 +657,7 @@ def _build_save_request(
"""Admit any layout-specific sources before advancing the watermark."""
return LMCacheReqMeta(
req_id=seq.id,
token_ids=list(seq.token_ids[:aligned]),
token_ids=tier_token_ids(seq, aligned),
block_ids=block_ids,
save_spec=SaveSpec(skip_leading_tokens=saved, can_save=True),
is_last_prefill=is_last_prefill,
Expand All @@ -677,7 +678,7 @@ def _build_load_request(
"""
return LMCacheReqMeta(
req_id=seq.id,
token_ids=list(seq.token_ids[:transfer_end]),
token_ids=tier_token_ids(seq, transfer_end),
block_ids=list(seq.block_table),
load_spec=load_spec,
load_operation=load_operation,
Expand Down
32 changes: 29 additions & 3 deletions atom/kv_transfer/offload/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
}
# GDN/linear model types carrying a per-request recurrent state but NOT in the
# `kimi_linear` family `kimi_k3` owns. Same set as `attn_family` resolves to
# `Family.GDN`, but note the attribute skew: the guard below reads
# `Family.GDN` or `Family.QSA_GDN`, but note the attribute skew: the guard below reads
# `hf_config.model_type` while `attn_family` reads the text config. On the
# native path they agree only because `get_hf_config` flattens the text type
# up; `is_vllm()` drops `qwen3_5`/`qwen3_5_moe` from that flatten, so if
Expand All @@ -75,7 +75,13 @@
# delivered only through transfer_tensors.block_regions, so the dense codec --
# which builds from per-layer KVCacheTensor K/V -- would silently drop it.
_GDN_LINEAR_MODEL_TYPES = frozenset(
{"qwen3_next", "qwen3_next_mtp", "qwen3_5_text", "qwen3_5_moe_text"}
{
"qwen3_next",
"qwen3_next_mtp",
"qwen3_5_text",
"qwen3_5_moe_text",
"qwen4_exp_text",
}
)

# Layouts that own a tier for a model's per-request *recurrent* state.
Expand Down Expand Up @@ -379,6 +385,13 @@ def build_page_namespace(
getattr(config, "speculative_config", None)
),
}
from atom.utils import get_hf_text_config

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

build_page_namespace imports atom.model_ops.attentions.qwen4_exp_attn.qsa_page_segments inside the function — the only place kv_transfer reaches back into a model's attention backend, and through ops/qsa.py it pulls in triton and aiter. That breaks the module boundary (and is a local import), and it's also why these pure config rules can't be tested on CPU CI (see the comment on the test file). qsa_caches_quantized, qsa_draft_rows, qsa_row_space, qsa_caches_rope_positions, qsa_page_segments and qsa_cache_formats are all pure config — move them to a kernel-free module both sides import.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 18ee7ac7b. The rules moved to atom/model_ops/qwen4_exp/qsa_cache_rules.py, which imports neither Triton nor AITER:

  • cache formats;
  • draft rows and row space;
  • rope positions;
  • PAGE segments;
  • the transfer check;
  • since acbd76533, the layout id spelling.

offload/config.py now imports qsa_page_segments, get_hf_text_config and attn_family at module level, and the function-local imports are gone. qwen4_exp_attn, qsa_attention and ops/qsa read the rules from there.

This reverses my 11-02 change that folded the rules back into qwen4_exp_attn.py. I'm following your review here.

from atom.utils.selector import Family, attn_family

if attn_family(get_hf_text_config(hf)) is Family.QSA_GDN:
from atom.model_ops.attentions.qwen4_exp_attn import qsa_page_segments

document.update(qsa_page_segments(config))
canonical = json.dumps(
document,
sort_keys=True,
Expand Down Expand Up @@ -448,7 +461,7 @@ def build_lmcache_config(


def lmcache_engine_id(config) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please split out the model-independent changes. Four changes here aren't specific to Qwen3.8 and affect every model: per-port lmcache_engine_id namespacing (this function), tier_token_ids seeding multimodal keys, native-state MP anchors aligned to the chunk grid, and the whole-chunk give-back. They change the shared scheduling core (block_manager) and every LMCache user's behaviour, buried in a 1912-line model PR, so they can't be reviewed or reverted on their own. Please move them into separate PRs, each with its own validation. Also please remove the type(port) is int check and its comment about bool.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Split out as #3027: per-port engine id, multimodal keys, MP anchors on the chunk grid, and the whole-chunk give-back, with their tests and docs. This PR's base is now #3027's branch, so the diff here is Qwen3.8 only. Merge order: #3027 → this PR → #2668.

The four are in one PR rather than four, because they share the scheduler files and test modules. Each has its own section in #3027's description, with its own validation: CPU results now, and a GPU check per change listed as pending. I won't ask for a merge until those are in. I'm happy to split it further if you prefer.

The type(port) is int check and its comment are removed. The port is now int(... or 0) and used only if port > 0 (atom/kv_transfer/offload/config.py:476-488 in #3027). valar-cop had asked for that check to be restored in r4439120010, so cc @valar-cop: it's removed per this review.

"""Return the LMCache engine id for this process, namespaced by DP rank.
"""Return the LMCache engine id for this process: one per server and DP rank.

A scheduler's ``LookupClient`` and its workers' ``LookupServer`` derive the
same ZMQ ipc socket path from this id, so it must be identical inside one
Expand All @@ -460,6 +473,13 @@ def lmcache_engine_id(config) -> str:
Under ``--enable-dp-attention`` this is the normal case, not a corner case:
``CoreManager`` folds TP into DP, so each GPU becomes its own replica with
``tensor_parallel_size == 1``.

The socket lives under a fixed host directory, so two servers on one host
would both bind ``dp0`` and a scheduler would follow the other server's
hits. ``data_parallel_base_port`` (the runners' TCPStore port, bound by
``CoreManager`` for the server's lifetime) tells servers apart; unresolved
(0) or not a port, the DP rank alone is used. The id is not part of a
chunk's key.
"""
dp_rank = int(
getattr(
Expand All @@ -469,6 +489,12 @@ def lmcache_engine_id(config) -> str:
)
or 0
)
port = getattr(
getattr(config, "parallel_config", None), "data_parallel_base_port", None
)
# `type(...) is int`: a bool is an int, and True would name port 1.
if type(port) is int and port > 0:
return f"atom-offload-r{port}-dp{dp_rank}"
return f"atom-offload-dp{dp_rank}"


Expand Down
2 changes: 2 additions & 0 deletions atom/kv_transfer/offload/dense/kv_byte_codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ def __init__(
# `build_page_namespace` has no segment order in it to notice --
# so a reorder has to bump `PAGE_LAYOUT_VERSION`.
getattr(kvt, "index_scale", None),
# Same rule: new tails, never a reorder.
*getattr(kvt, "extra_planes", ()),
):
if t is not None and isinstance(t, torch.Tensor) and t.numel() > 0:
self._segments.append(t)
Expand Down
20 changes: 18 additions & 2 deletions atom/kv_transfer/offload/hybrid/qwen/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

Qwen has no second codec. ``KimiK3OffloadConnector`` already packs
``page_unit_views`` and unpacks ``state_entry_views``, and it does not assume
KDA shapes. Qwen publishes those views from its MHA pool
KDA shapes. Qwen publishes those views from its MHA pool, Qwen3.8 from its QSA arena
(``pool_layout/mha_page_unit_geometry``) under the ``gdn-paged-state`` layout id
spelled in ``GDNAttentionMetadataBuilder.state_transfer``. These
subclasses exist so the selected layout is ``qwen`` rather than ``kimi_k3``.
Expand All @@ -20,7 +20,7 @@


class QwenOffloadConnector(KimiK3OffloadConnector):
"""Worker side: dense MHA KV plus the GDN page-image state tier."""
"""Worker side: the paged KV (MHA or QSA) plus the GDN page-image state tier."""


class QwenOffloadScheduler(KimiK3OffloadScheduler):
Expand All @@ -29,3 +29,19 @@ class QwenOffloadScheduler(KimiK3OffloadScheduler):
Inherits ``StateOffloadFace`` through K3, so ``isinstance`` routing still
sends state stores and loads here.
"""

def _withholds_last_prompt_token(self, seq) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nothing in _withholds_last_prompt_token is Qwen-specific — it only reads has_per_req_cache, _block_manager and joint_boundary_tokens — yet it lives on QwenOffloadScheduler, while its parent KimiK3OffloadScheduler (kimi_k3/connector.py:590, same _decide_load_after_alloc at L783) keeps the base behaviour. So Kimi-K3 under lmcache_offload with a chunk-aligned prompt still steps a full-prompt hit back a whole chunk and recomputes it. Please move this override up to KimiK3OffloadScheduler (or the state mixin) — it already gates on has_per_req_cache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in #3027: the override is now KimiK3OffloadScheduler._withholds_last_prompt_token, so Kimi-K3 and its Qwen GDN subclasses all get it, and qwen/connector.py no longer has it. The generic default (always withhold) stays in _offload_common.

test_a_hybrid_full_prompt_hit_keeps_its_last_chunk runs for kimi-k3, qwen3-next and qwen3.5 there, and this PR adds the qwen3.8 row. The Kimi-K3 GPU check (the same chunk-aligned prompt twice, then GSM8K) is listed as pending in #3027's description.

"""Not when the joint boundary lies in the withheld chunk.

A hybrid claims the joint boundary, which already leaves a token to
compute. Withholding would floor a chunk-aligned prompt a whole chunk
below the rungs it kept near its end, so no boundary forms. A hit no
boundary backs is still withheld: `_decide_load_after_alloc` refuses it.
"""
if not getattr(seq, "has_per_req_cache", False):
return super()._withholds_last_prompt_token(seq)
num_prompt = int(seq.num_prompt_tokens)
withheld = self._chunk_floor(num_prompt - 1)
if self._block_manager is None or withheld == self._chunk_floor(num_prompt):
return True
return self._block_manager.joint_boundary_tokens(seq, num_prompt) <= withheld
Loading
Loading