Repository navigation
[Qwen 3.8] LMCache offload for QSA + GDN + PLE state #2636
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: lingzha/offload-model-independent
Are you sure you want to change the base?
Changes from 56 commits
cd88595
6ad117b
183cfec
d838980
484dc30
12dea23
409c46c
bb734a4
d159907
3dd40a4
8d6e5b6
274be1f
50f21df
da48676
4b52c41
d5a9cfe
9862cc9
fe746ec
deedefe
8ca0306
bd39af4
a35e872
5f90745
646a7f8
332d69a
3579186
36c3f37
6496696
26694d2
b38e691
1dfe4e1
d9840a3
8abd77e
bf6293e
90da188
73e0076
80781a7
3d6edb9
7b27933
533e93d
497bbc7
0818bfd
fc07da3
42bac64
2a78808
7ffe05f
963d7fa
0897671
4c47d93
2fae196
e7d8178
878c954
c903175
027591b
12196f1
3284885
97af4f1
e5fafe3
18ee7ac
acbd765
7270f5a
8eb092e
09edcad
59feb21
3f6c4a4
22d2035
076142e
422906a
b7ea8c2
13515d8
0b8bc2d
829037d
0342e0f
c343714
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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]: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The multimodal-key fix (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I swept the SLOT boundary chain too ( Tests: |
||
| """``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: | ||
|
|
@@ -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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in #3027: |
||
| """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 | ||
|
|
@@ -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) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
@@ -379,6 +385,13 @@ def build_page_namespace( | |
| getattr(config, "speculative_config", None) | ||
| ), | ||
| } | ||
| from atom.utils import get_hf_text_config | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in
This reverses my 11-02 change that folded the rules back into |
||
| 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, | ||
|
|
@@ -448,7 +461,7 @@ def build_lmcache_config( | |
|
|
||
|
|
||
| def lmcache_engine_id(config) -> str: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| """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 | ||
|
|
@@ -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( | ||
|
|
@@ -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}" | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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``. | ||
|
|
@@ -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): | ||
|
|
@@ -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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in #3027: the override is now
|
||
| """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 | ||
There was a problem hiding this comment.
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_transferpasses,_wants_paged_checkpoints'any(reads_page_images)is equivalent tobool(leaves)— a new public API plus two redundant checks (YAGNI). The message can namelmcache_offload/lmcache_mpdirectly, as the preceding error already does.There was a problem hiding this comment.
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 intest_kv_connector_factory_topology.py._wants_paged_checkpointsisbool(kv_transfer_leaves(config)), and the refusal nameslmcache_offload/lmcache_mpdirectly.reads_page_images(name)remains, andcheck_kv_transferuses it. The registry leak from the review body is fixed too: bothfinallyblocks intest_fp4_indexer_kv_transfer_gate.pynow pop_reads_page_images.