lmcache-cli 0.4.8.dev38__py3-none-any.whl → 0.4.8.dev42__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lmcache/_version.py +3 -3
- lmcache/v1/mp_coordinator/blend_client.py +35 -14
- lmcache/v1/mp_coordinator/blend_directory.py +196 -40
- lmcache/v1/mp_coordinator/http_apis/blend_directory_api.py +5 -1
- lmcache/v1/mp_coordinator/schemas.py +71 -4
- lmcache/v1/multiprocess/modules/blend_v3.py +5 -7
- {lmcache_cli-0.4.8.dev38.dist-info → lmcache_cli-0.4.8.dev42.dist-info}/METADATA +1 -1
- {lmcache_cli-0.4.8.dev38.dist-info → lmcache_cli-0.4.8.dev42.dist-info}/RECORD +12 -12
- {lmcache_cli-0.4.8.dev38.dist-info → lmcache_cli-0.4.8.dev42.dist-info}/WHEEL +0 -0
- {lmcache_cli-0.4.8.dev38.dist-info → lmcache_cli-0.4.8.dev42.dist-info}/entry_points.txt +0 -0
- {lmcache_cli-0.4.8.dev38.dist-info → lmcache_cli-0.4.8.dev42.dist-info}/licenses/LICENSE +0 -0
- {lmcache_cli-0.4.8.dev38.dist-info → lmcache_cli-0.4.8.dev42.dist-info}/top_level.txt +0 -0
lmcache/_version.py
CHANGED
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.4.8.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 4, 8, '
|
|
21
|
+
__version__ = version = '0.4.8.dev42'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 8, 'dev42')
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g9ffb104f3'
|
|
@@ -14,6 +14,7 @@ It is **opt-in**: with no coordinator URL configured the blend module receives
|
|
|
14
14
|
|
|
15
15
|
# Standard
|
|
16
16
|
from collections.abc import Callable
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
18
|
from dataclasses import dataclass
|
|
18
19
|
from queue import Empty, Queue
|
|
19
20
|
import os
|
|
@@ -21,6 +22,7 @@ import threading
|
|
|
21
22
|
|
|
22
23
|
# First Party
|
|
23
24
|
from lmcache.logging import init_logger
|
|
25
|
+
from lmcache.v1.mp_coordinator.schemas import encode_tokens
|
|
24
26
|
|
|
25
27
|
logger = init_logger(__name__)
|
|
26
28
|
|
|
@@ -67,10 +69,11 @@ class _MatchItem:
|
|
|
67
69
|
class BlendCoordinatorClient:
|
|
68
70
|
"""Background bridge from the blend module to the coordinator directory.
|
|
69
71
|
|
|
70
|
-
Thread-safe.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
Thread-safe. Handler threads enqueue publishes and match queries; one daemon
|
|
73
|
+
thread dequeues them, dispatching each match query to a thread pool so
|
|
74
|
+
round-trips run concurrently. Match results land in a dict the handler
|
|
75
|
+
polls. Match queries drain ahead of publishes so lookup latency is not held
|
|
76
|
+
up by best-effort store traffic.
|
|
74
77
|
"""
|
|
75
78
|
|
|
76
79
|
def __init__(
|
|
@@ -80,6 +83,7 @@ class BlendCoordinatorClient:
|
|
|
80
83
|
request_fn: _RequestFn | None = None,
|
|
81
84
|
request_timeout: float = 2.0,
|
|
82
85
|
match_budget_s: float = 2.0,
|
|
86
|
+
match_concurrency: int = 8,
|
|
83
87
|
) -> None:
|
|
84
88
|
"""Create the client and start its daemon.
|
|
85
89
|
|
|
@@ -91,10 +95,15 @@ class BlendCoordinatorClient:
|
|
|
91
95
|
testing). Must raise on transport failure.
|
|
92
96
|
request_timeout: Per-request HTTP timeout in seconds.
|
|
93
97
|
match_budget_s: Per-lookup wall-clock budget the blend module uses to
|
|
94
|
-
bound the optional global leg
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
bound the optional global leg, including queue wait.
|
|
99
|
+
match_concurrency: Max match round-trips in flight at once (the
|
|
100
|
+
match dispatch pool size). Must be >= 1.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If ``match_concurrency`` is not positive.
|
|
97
104
|
"""
|
|
105
|
+
if match_concurrency < 1:
|
|
106
|
+
raise ValueError(f"match_concurrency must be >= 1, got {match_concurrency}")
|
|
98
107
|
self.match_budget_s = match_budget_s
|
|
99
108
|
self._client = None
|
|
100
109
|
if request_fn is None:
|
|
@@ -119,6 +128,9 @@ class BlendCoordinatorClient:
|
|
|
119
128
|
self._results: dict[str, object] = {}
|
|
120
129
|
self._results_lock = threading.Lock()
|
|
121
130
|
self._stop = threading.Event()
|
|
131
|
+
self._match_pool = ThreadPoolExecutor(
|
|
132
|
+
max_workers=match_concurrency, thread_name_prefix="cb-coord-match"
|
|
133
|
+
)
|
|
122
134
|
self._worker = threading.Thread(
|
|
123
135
|
target=self._run, name="cb-coordinator-client", daemon=True
|
|
124
136
|
)
|
|
@@ -185,9 +197,10 @@ class BlendCoordinatorClient:
|
|
|
185
197
|
self._results.pop(rid, None)
|
|
186
198
|
|
|
187
199
|
def close(self) -> None:
|
|
188
|
-
"""Stop the daemon and close the HTTP client."""
|
|
200
|
+
"""Stop the daemon, drain the match pool, and close the HTTP client."""
|
|
189
201
|
self._stop.set()
|
|
190
202
|
self._worker.join(timeout=2.0)
|
|
203
|
+
self._match_pool.shutdown(wait=False)
|
|
191
204
|
if self._client is not None:
|
|
192
205
|
self._client.close()
|
|
193
206
|
|
|
@@ -211,20 +224,28 @@ class BlendCoordinatorClient:
|
|
|
211
224
|
url = os.getenv("LMCACHE_COORDINATOR_URL", "").strip()
|
|
212
225
|
if not url:
|
|
213
226
|
return None
|
|
227
|
+
concurrency = int(os.getenv("LMCACHE_COORDINATOR_BLEND_MATCH_CONCURRENCY", "8"))
|
|
214
228
|
timeout = float(os.getenv("LMCACHE_COORDINATOR_BLEND_TIMEOUT", "1.0"))
|
|
215
229
|
logger.info("Blend coordinator client enabled -> %s", url)
|
|
216
|
-
return cls(
|
|
230
|
+
return cls(
|
|
231
|
+
url,
|
|
232
|
+
request_timeout=timeout,
|
|
233
|
+
match_budget_s=timeout,
|
|
234
|
+
match_concurrency=concurrency,
|
|
235
|
+
)
|
|
217
236
|
|
|
218
237
|
# -- daemon ------------------------------------------------------------
|
|
219
238
|
|
|
220
239
|
def _run(self) -> None:
|
|
221
|
-
"""
|
|
240
|
+
"""Dispatch match queries to the pool (priority), then publishes."""
|
|
222
241
|
while not self._stop.is_set():
|
|
223
242
|
try:
|
|
224
|
-
self.
|
|
225
|
-
continue
|
|
243
|
+
item = self._match_q.get(timeout=0.05)
|
|
226
244
|
except Empty:
|
|
227
|
-
|
|
245
|
+
item = None
|
|
246
|
+
if item is not None:
|
|
247
|
+
self._match_pool.submit(self._handle_match, item)
|
|
248
|
+
continue
|
|
228
249
|
try:
|
|
229
250
|
self._handle_publish(self._publish_q.get_nowait())
|
|
230
251
|
except Empty:
|
|
@@ -239,7 +260,7 @@ class BlendCoordinatorClient:
|
|
|
239
260
|
"/blend/match",
|
|
240
261
|
{
|
|
241
262
|
"model_scope": item.model_scope,
|
|
242
|
-
"
|
|
263
|
+
"tokens_b64": encode_tokens(item.tokens),
|
|
243
264
|
},
|
|
244
265
|
)
|
|
245
266
|
matches = [
|
|
@@ -15,21 +15,39 @@ the request.
|
|
|
15
15
|
- **Probe stride** (the querying server's inference block size) controls which
|
|
16
16
|
request offsets can seed a match; sent per query, so servers with different
|
|
17
17
|
(per-machine, dynamic) block sizes interoperate.
|
|
18
|
-
- **Scope** =
|
|
18
|
+
- **Scope** = the model name; cross-model content never matches. ``cache_salt``
|
|
19
|
+
is **not** part of the scope: matched hashes are expanded to ObjectKeys with
|
|
20
|
+
the *requester's* salt at retrieve (``ipc_key_to_object_keys``), so a
|
|
21
|
+
cross-salt match simply misses at L2 -- isolation holds with one table per
|
|
22
|
+
model instead of one per ``(model, salt)``.
|
|
19
23
|
- **TP rank** is resolved at retrieve (``ipc_key_to_object_keys``), not here.
|
|
20
24
|
|
|
21
25
|
``object_key`` is the chunk's shared-L2 storage key (``th``), which is
|
|
22
26
|
prefix-bound and computed by the storing server -- the coordinator cannot derive
|
|
23
27
|
it, so it is supplied with each published range.
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
Concurrency: fingerprints are partitioned per scope (``_ScopeTable``), each
|
|
30
|
+
mutated and probed in place under its own lock; a top-level lock guards only the
|
|
31
|
+
scope map and the eviction map. The probe is vectorized, so the locked section
|
|
32
|
+
is short.
|
|
33
|
+
|
|
34
|
+
The probe mirrors the local matcher: the strided rolling hashes index a
|
|
35
|
+
per-scope **direct-address table** in one numpy gather, and only occupied slots
|
|
36
|
+
reach a short Python loop, where a full-64-bit re-check rejects bucket
|
|
37
|
+
collisions. Inserts write the table in place (O(1) per chunk); evictions
|
|
38
|
+
tombstone; a rebuild -- which also compacts tombstones -- happens only on the
|
|
39
|
+
write path, when the table outgrows its load factor. Tables are sized per scope
|
|
40
|
+
(power of two, a few times the entry count), so small scopes stay small, unlike
|
|
41
|
+
the local matcher's fixed 2^20 array.
|
|
42
|
+
|
|
43
|
+
Thread-safe and ephemeral. A stale entry or a (rare) bucket collision only costs
|
|
44
|
+
a wasted prefetch or a missed reuse, recomputed downstream -- never wrong KV.
|
|
28
45
|
|
|
29
46
|
See ``docs/design/v1/mp_coordinator/blend_lookup.md``.
|
|
30
47
|
"""
|
|
31
48
|
|
|
32
49
|
# Standard
|
|
50
|
+
from collections import defaultdict
|
|
33
51
|
from dataclasses import dataclass
|
|
34
52
|
import threading
|
|
35
53
|
|
|
@@ -41,6 +59,7 @@ from lmcache.logging import init_logger
|
|
|
41
59
|
from lmcache.v1.multiprocess.token_hasher import (
|
|
42
60
|
chunk_hash_windows_numba,
|
|
43
61
|
rolling_hash_windows_numba,
|
|
62
|
+
update_table_id_numba,
|
|
44
63
|
)
|
|
45
64
|
|
|
46
65
|
logger = init_logger(__name__)
|
|
@@ -51,13 +70,19 @@ logger = init_logger(__name__)
|
|
|
51
70
|
# so the two align. Constant across a coordinator's lifetime.
|
|
52
71
|
POLY_BASE = np.uint64(0x9E3779B97F4A7C15)
|
|
53
72
|
|
|
73
|
+
# Table size = smallest power of two >= _TABLE_GROWTH * live entries, keeping
|
|
74
|
+
# the load factor (and thus bucket-collision recall loss) low for a few
|
|
75
|
+
# bytes/chunk.
|
|
76
|
+
_TABLE_GROWTH = 4
|
|
77
|
+
_MIN_TABLE_SIZE = 1 << 10
|
|
78
|
+
|
|
54
79
|
|
|
55
80
|
@dataclass
|
|
56
81
|
class StoreRange:
|
|
57
82
|
"""One stored token range published by a blend server.
|
|
58
83
|
|
|
59
84
|
Attributes:
|
|
60
|
-
model_scope: Reuse-compatibility scope
|
|
85
|
+
model_scope: Reuse-compatibility scope (the model name).
|
|
61
86
|
tokens: The stored tokens (``token_ids[start:end]``). The coordinator
|
|
62
87
|
chunks these at ``chunk_size`` and hashes each chunk.
|
|
63
88
|
object_keys: Shared-L2 storage key (hex of the ObjectKey chunk hash) per
|
|
@@ -95,13 +120,94 @@ class GlobalMatch:
|
|
|
95
120
|
cur_st: int
|
|
96
121
|
|
|
97
122
|
|
|
123
|
+
class _ScopeTable:
|
|
124
|
+
"""One scope's fingerprint table, mutated in place under ``lock``.
|
|
125
|
+
|
|
126
|
+
Mirrors the local matcher: ``slots`` direct-addresses the low bits of a poly
|
|
127
|
+
hash to a compact entry id (``-1`` = empty, last writer wins on collision);
|
|
128
|
+
``hashes[cid]`` / ``locs[cid]`` hold the full hash (collision rejection) and
|
|
129
|
+
chunk location. ``poly_to_cid`` gives idempotent insert and eviction lookup.
|
|
130
|
+
Eviction tombstones ``locs[cid]``; a rebuild (on load-factor growth, or when
|
|
131
|
+
tombstones outnumber live entries) re-creates ``slots`` compacted.
|
|
132
|
+
|
|
133
|
+
All methods require the caller to hold ``lock``.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
"""Initialize an empty table at the minimum size."""
|
|
138
|
+
self.lock = threading.Lock()
|
|
139
|
+
self.poly_to_cid: dict[int, int] = {}
|
|
140
|
+
self.hashes: list[int] = []
|
|
141
|
+
self.locs: "list[_ChunkLoc | None]" = []
|
|
142
|
+
self.slots: np.ndarray = np.full(_MIN_TABLE_SIZE, -1, dtype=np.int64)
|
|
143
|
+
self.mask: np.uint64 = np.uint64(_MIN_TABLE_SIZE - 1)
|
|
144
|
+
|
|
145
|
+
def insert(self, poly: int, loc: _ChunkLoc) -> bool:
|
|
146
|
+
"""Insert one fingerprint, growing the table when needed.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
poly: The chunk's full 64-bit poly hash.
|
|
150
|
+
loc: The chunk's storage location.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
``True`` if newly inserted, ``False`` if the hash was present.
|
|
154
|
+
"""
|
|
155
|
+
if poly in self.poly_to_cid:
|
|
156
|
+
return False
|
|
157
|
+
cid = len(self.hashes)
|
|
158
|
+
self.hashes.append(poly)
|
|
159
|
+
self.locs.append(loc)
|
|
160
|
+
self.poly_to_cid[poly] = cid
|
|
161
|
+
self.slots[poly & int(self.mask)] = cid
|
|
162
|
+
if _TABLE_GROWTH * len(self.poly_to_cid) > self.slots.shape[0]:
|
|
163
|
+
self._rebuild()
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def evict(self, poly: int) -> bool:
|
|
167
|
+
"""Tombstone one fingerprint, compacting when tombstones dominate.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
poly: The chunk's full 64-bit poly hash.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
``True`` if evicted, ``False`` if the hash was absent.
|
|
174
|
+
"""
|
|
175
|
+
cid = self.poly_to_cid.pop(poly, None)
|
|
176
|
+
if cid is None:
|
|
177
|
+
return False
|
|
178
|
+
slot = poly & int(self.mask)
|
|
179
|
+
if self.slots[slot] == cid: # a colliding later insert may own the slot
|
|
180
|
+
self.slots[slot] = -1
|
|
181
|
+
self.locs[cid] = None
|
|
182
|
+
if len(self.poly_to_cid) < len(self.locs) // 2:
|
|
183
|
+
self._rebuild()
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def _rebuild(self) -> None:
|
|
187
|
+
"""Re-create ``slots`` sized to the live entries, dropping tombstones."""
|
|
188
|
+
live = [(poly, self.locs[cid]) for poly, cid in self.poly_to_cid.items()]
|
|
189
|
+
size = _MIN_TABLE_SIZE
|
|
190
|
+
while size < _TABLE_GROWTH * len(live):
|
|
191
|
+
size <<= 1
|
|
192
|
+
self.hashes = [poly for poly, _ in live]
|
|
193
|
+
self.locs = [loc for _, loc in live]
|
|
194
|
+
self.poly_to_cid = {poly: cid for cid, (poly, _) in enumerate(live)}
|
|
195
|
+
self.slots = np.full(size, -1, dtype=np.int64)
|
|
196
|
+
self.mask = np.uint64(size - 1)
|
|
197
|
+
if live:
|
|
198
|
+
update_table_id_numba(
|
|
199
|
+
np.array(self.hashes, dtype=np.uint64),
|
|
200
|
+
self.slots,
|
|
201
|
+
np.arange(len(live), dtype=np.int64),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
98
205
|
class GlobalBlendMatcher:
|
|
99
206
|
"""Thread-safe fleet-wide chunk fingerprint directory.
|
|
100
207
|
|
|
101
|
-
Hashes published token ranges into
|
|
102
|
-
tokens
|
|
103
|
-
|
|
104
|
-
publish/query/evict.
|
|
208
|
+
Hashes published token ranges into per-scope direct-address tables, mutated
|
|
209
|
+
in place, and matches request tokens with a vectorized strided rolling-hash
|
|
210
|
+
probe under the scope's lock.
|
|
105
211
|
"""
|
|
106
212
|
|
|
107
213
|
def __init__(self, chunk_size: int = 256, probe_stride: int = 1) -> None:
|
|
@@ -123,11 +229,28 @@ class GlobalBlendMatcher:
|
|
|
123
229
|
raise ValueError(f"probe_stride must be >= 1, got {probe_stride}")
|
|
124
230
|
self._chunk_size = chunk_size
|
|
125
231
|
self._probe_stride = probe_stride
|
|
232
|
+
# Top-level lock: guards _scopes and _by_key only (cheap, short holds).
|
|
126
233
|
self._lock = threading.Lock()
|
|
127
|
-
self.
|
|
234
|
+
self._scopes: dict[str, _ScopeTable] = {}
|
|
128
235
|
# Reverse map for eviction: object_key -> its (scope, poly_hash) keys.
|
|
129
236
|
self._by_key: dict[str, list[tuple[str, int]]] = {}
|
|
130
237
|
|
|
238
|
+
def _get_or_create_scope(self, model_scope: str) -> _ScopeTable:
|
|
239
|
+
"""Return the table for ``model_scope``, creating it if absent.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
model_scope: The reuse scope to fetch a table for.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The (possibly newly created) per-scope table.
|
|
246
|
+
"""
|
|
247
|
+
with self._lock:
|
|
248
|
+
table = self._scopes.get(model_scope)
|
|
249
|
+
if table is None:
|
|
250
|
+
table = _ScopeTable()
|
|
251
|
+
self._scopes[model_scope] = table
|
|
252
|
+
return table
|
|
253
|
+
|
|
131
254
|
def register(self, ranges: list[StoreRange]) -> int:
|
|
132
255
|
"""Hash and insert published token ranges (idempotent per chunk).
|
|
133
256
|
|
|
@@ -148,10 +271,10 @@ class GlobalBlendMatcher:
|
|
|
148
271
|
Number of chunk fingerprints newly inserted (excludes idempotent
|
|
149
272
|
skips).
|
|
150
273
|
"""
|
|
151
|
-
# Hash every range outside
|
|
274
|
+
# Hash every range outside any lock; keep only well-formed ranges.
|
|
152
275
|
prepared: list[tuple[str, np.ndarray, list[str], int]] = []
|
|
153
276
|
for rng in ranges:
|
|
154
|
-
arr = np.
|
|
277
|
+
arr = np.asarray(rng.tokens, dtype=np.uint64)
|
|
155
278
|
polys = chunk_hash_windows_numba(arr, self._chunk_size, POLY_BASE)
|
|
156
279
|
n_chunks = int(polys.shape[0])
|
|
157
280
|
if n_chunks != len(rng.object_keys):
|
|
@@ -169,18 +292,22 @@ class GlobalBlendMatcher:
|
|
|
169
292
|
prepared.append((rng.model_scope, polys, rng.object_keys, rng.old_st_base))
|
|
170
293
|
|
|
171
294
|
inserted = 0
|
|
172
|
-
|
|
173
|
-
|
|
295
|
+
for model_scope, polys, object_keys, old_st_base in prepared:
|
|
296
|
+
table = self._get_or_create_scope(model_scope)
|
|
297
|
+
new_keys: list[tuple[str, int]] = []
|
|
298
|
+
with table.lock:
|
|
174
299
|
for i in range(len(object_keys)):
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
300
|
+
poly = int(polys[i])
|
|
301
|
+
loc = _ChunkLoc(object_keys[i], old_st_base + i * self._chunk_size)
|
|
302
|
+
if table.insert(poly, loc):
|
|
303
|
+
new_keys.append((object_keys[i], poly))
|
|
304
|
+
if new_keys:
|
|
305
|
+
inserted += len(new_keys)
|
|
306
|
+
with self._lock:
|
|
307
|
+
for object_key, poly in new_keys:
|
|
308
|
+
self._by_key.setdefault(object_key, []).append(
|
|
309
|
+
(model_scope, poly)
|
|
310
|
+
)
|
|
184
311
|
return inserted
|
|
185
312
|
|
|
186
313
|
def remove(self, object_keys: list[str]) -> int:
|
|
@@ -192,45 +319,74 @@ class GlobalBlendMatcher:
|
|
|
192
319
|
Returns:
|
|
193
320
|
Number of fingerprint entries removed.
|
|
194
321
|
"""
|
|
195
|
-
|
|
322
|
+
# Collect keys under the top-level lock, then mutate per scope.
|
|
323
|
+
by_scope: dict[str, list[int]] = defaultdict(list)
|
|
196
324
|
with self._lock:
|
|
197
325
|
for object_key in object_keys:
|
|
198
|
-
for
|
|
199
|
-
|
|
326
|
+
for model_scope, poly in self._by_key.pop(object_key, []):
|
|
327
|
+
by_scope[model_scope].append(poly)
|
|
328
|
+
|
|
329
|
+
removed = 0
|
|
330
|
+
for model_scope, polys in by_scope.items():
|
|
331
|
+
with self._lock:
|
|
332
|
+
table = self._scopes.get(model_scope)
|
|
333
|
+
if table is None:
|
|
334
|
+
continue
|
|
335
|
+
with table.lock:
|
|
336
|
+
for poly in polys:
|
|
337
|
+
if table.evict(poly):
|
|
200
338
|
removed += 1
|
|
201
339
|
return removed
|
|
202
340
|
|
|
203
|
-
def match(
|
|
341
|
+
def match(
|
|
342
|
+
self, model_scope: str, tokens: "list[int] | np.ndarray"
|
|
343
|
+
) -> list[GlobalMatch]:
|
|
204
344
|
"""Match request tokens against the directory.
|
|
205
345
|
|
|
206
|
-
Rolls a chunk-window hash over the request
|
|
207
|
-
``probe_stride`` positions
|
|
208
|
-
|
|
346
|
+
Rolls a chunk-window hash over the request, then probes the scope's
|
|
347
|
+
direct-address table every ``probe_stride`` positions in one numpy
|
|
348
|
+
gather; a full 64-bit re-check in the sparse hit loop rejects bucket
|
|
349
|
+
collisions. De-duplicates by ``object_key``. Mirrors the local
|
|
209
350
|
``BlendTokenRangeMatcherV3.match_sub_sequence``.
|
|
210
351
|
|
|
211
352
|
Args:
|
|
212
|
-
model_scope: Scope to match within (
|
|
213
|
-
tokens: The request tokens.
|
|
353
|
+
model_scope: Scope to match within (the model name).
|
|
354
|
+
tokens: The request tokens (a ``list[int]`` or a ``uint64`` array).
|
|
214
355
|
|
|
215
356
|
Returns:
|
|
216
357
|
Matches in ascending ``cur_st`` order; empty if nothing matched.
|
|
217
358
|
"""
|
|
218
|
-
|
|
359
|
+
arr = np.asarray(tokens, dtype=np.uint64)
|
|
360
|
+
if arr.shape[0] < self._chunk_size:
|
|
219
361
|
return []
|
|
220
|
-
|
|
362
|
+
|
|
363
|
+
with self._lock:
|
|
364
|
+
table = self._scopes.get(model_scope)
|
|
365
|
+
if table is None:
|
|
366
|
+
return []
|
|
367
|
+
|
|
221
368
|
rolling = rolling_hash_windows_numba(arr, self._chunk_size, POLY_BASE)
|
|
222
|
-
|
|
369
|
+
probe = rolling[:: self._probe_stride]
|
|
223
370
|
matches: list[GlobalMatch] = []
|
|
224
371
|
seen: set[str] = set()
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
372
|
+
stride = self._probe_stride
|
|
373
|
+
with table.lock:
|
|
374
|
+
# One gather: strided hash -> slot's entry index (-1 = empty slot).
|
|
375
|
+
entry_ids = table.slots[probe & table.mask]
|
|
376
|
+
hit_positions = np.nonzero(entry_ids >= 0)[0]
|
|
377
|
+
for p in hit_positions.tolist():
|
|
378
|
+
cid = int(entry_ids[p])
|
|
379
|
+
if int(probe[p]) != table.hashes[cid]:
|
|
380
|
+
continue # bucket collision: different content in this slot
|
|
381
|
+
loc = table.locs[cid]
|
|
228
382
|
if loc is None or loc.object_key in seen:
|
|
229
|
-
continue
|
|
383
|
+
continue # evicted, or already matched
|
|
230
384
|
seen.add(loc.object_key)
|
|
231
385
|
matches.append(
|
|
232
386
|
GlobalMatch(
|
|
233
|
-
object_key=loc.object_key,
|
|
387
|
+
object_key=loc.object_key,
|
|
388
|
+
old_st=loc.old_st,
|
|
389
|
+
cur_st=p * stride,
|
|
234
390
|
)
|
|
235
391
|
)
|
|
236
392
|
return matches
|
|
@@ -24,6 +24,7 @@ from lmcache.v1.mp_coordinator.schemas import (
|
|
|
24
24
|
BlendMatchRequest,
|
|
25
25
|
BlendMatchResponse,
|
|
26
26
|
GlobalMatchModel,
|
|
27
|
+
decode_tokens,
|
|
27
28
|
)
|
|
28
29
|
|
|
29
30
|
logger = init_logger(__name__)
|
|
@@ -89,12 +90,15 @@ def evict_fingerprints(body: BlendEvictRequest, request: Request) -> BlendEvictR
|
|
|
89
90
|
def match_fingerprints(body: BlendMatchRequest, request: Request) -> BlendMatchResponse:
|
|
90
91
|
"""Match a request's rolling-hash array against the directory.
|
|
91
92
|
|
|
93
|
+
The request tokens arrive base64-packed (``tokens_b64``); they are decoded
|
|
94
|
+
to a ``uint64`` array and handed straight to the matcher.
|
|
95
|
+
|
|
92
96
|
Returns:
|
|
93
97
|
Matched chunks (``object_key`` / ``old_st`` / ``cur_st``), ascending by
|
|
94
98
|
``cur_st``.
|
|
95
99
|
"""
|
|
96
100
|
directory = _directory(request)
|
|
97
|
-
matches = directory.match(body.model_scope, body.
|
|
101
|
+
matches = directory.match(body.model_scope, decode_tokens(body.tokens_b64))
|
|
98
102
|
return BlendMatchResponse(
|
|
99
103
|
matches=[
|
|
100
104
|
GlobalMatchModel(object_key=m.object_key, old_st=m.old_st, cur_st=m.cur_st)
|
|
@@ -10,14 +10,58 @@ bodies and parse replies, so both sides agree on the schema in one place.
|
|
|
10
10
|
# Standard
|
|
11
11
|
from enum import Enum
|
|
12
12
|
from typing import Annotated
|
|
13
|
+
import base64
|
|
13
14
|
|
|
14
15
|
# Third Party
|
|
15
|
-
from pydantic import BaseModel, Field, StringConstraints
|
|
16
|
+
from pydantic import BaseModel, Field, StringConstraints, field_validator
|
|
17
|
+
import numpy as np
|
|
16
18
|
|
|
17
19
|
# First Party
|
|
18
20
|
from lmcache.v1.distributed.api import EncodedObjectKey # noqa: F401 re-exported
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
def encode_tokens(tokens: "list[int] | np.ndarray") -> str:
|
|
24
|
+
"""Encode token ids into a compact base64 wire string.
|
|
25
|
+
|
|
26
|
+
Token ids fit in ``uint32``, so a little-endian ``uint32`` buffer is far
|
|
27
|
+
smaller than a JSON integer list and decodes in one ``np.frombuffer`` call.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
tokens: Token ids (a ``list[int]`` or any array castable to ``uint32``).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Base64 of the little-endian ``uint32`` token buffer.
|
|
34
|
+
"""
|
|
35
|
+
arr = np.ascontiguousarray(np.asarray(tokens, dtype="<u4"))
|
|
36
|
+
return base64.b64encode(arr.tobytes()).decode("ascii")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_tokens(tokens_b64: str) -> np.ndarray:
|
|
40
|
+
"""Decode a base64 token string produced by :func:`encode_tokens`.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
tokens_b64: Base64 of a little-endian ``uint32`` token buffer.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A ``uint64`` array of token ids (widened so it feeds the hashers
|
|
47
|
+
directly).
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If ``tokens_b64`` is not valid base64 or not a multiple of
|
|
51
|
+
4 bytes.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
raw = base64.b64decode(tokens_b64, validate=True)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise ValueError(f"tokens_b64 is not valid base64: {exc}") from exc
|
|
57
|
+
if len(raw) % 4 != 0:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"tokens_b64 byte length {len(raw)} is not a multiple of 4 "
|
|
60
|
+
"(malformed uint32 token buffer)"
|
|
61
|
+
)
|
|
62
|
+
return np.frombuffer(raw, dtype="<u4").astype(np.uint64)
|
|
63
|
+
|
|
64
|
+
|
|
21
65
|
class RegisterRequest(BaseModel):
|
|
22
66
|
"""Body of a ``POST /instances`` registration request.
|
|
23
67
|
|
|
@@ -179,7 +223,7 @@ class StoreRangeModel(BaseModel):
|
|
|
179
223
|
chunk ``i`` maps to ``object_keys[i]`` at ``old_st_base + i * chunk_size``.
|
|
180
224
|
|
|
181
225
|
Attributes:
|
|
182
|
-
model_scope:
|
|
226
|
+
model_scope: Reuse scope (the model name).
|
|
183
227
|
tokens: The stored tokens (``token_ids[start:end]``).
|
|
184
228
|
object_keys: Shared-L2 storage key (hex) per chunk, in order.
|
|
185
229
|
old_st_base: Token position of the range's first token.
|
|
@@ -236,11 +280,34 @@ class BlendMatchRequest(BaseModel):
|
|
|
236
280
|
|
|
237
281
|
Attributes:
|
|
238
282
|
model_scope: Scope to match within.
|
|
239
|
-
|
|
283
|
+
tokens_b64: The request tokens, packed via :func:`encode_tokens`
|
|
284
|
+
(base64 little-endian ``uint32``).
|
|
240
285
|
"""
|
|
241
286
|
|
|
242
287
|
model_scope: str
|
|
243
|
-
|
|
288
|
+
tokens_b64: str = ""
|
|
289
|
+
|
|
290
|
+
@field_validator("tokens_b64")
|
|
291
|
+
@classmethod
|
|
292
|
+
def _validate_tokens_b64(cls, value: str) -> str:
|
|
293
|
+
"""Reject a malformed token buffer at request validation.
|
|
294
|
+
|
|
295
|
+
Without this, ``decode_tokens`` would raise ``ValueError`` inside the
|
|
296
|
+
route handler, which FastAPI surfaces as a 500 (server error) for what
|
|
297
|
+
is really bad client input. Validating here returns a 422 instead.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
value: The base64 ``tokens_b64`` field.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The unchanged value once it is confirmed decodable.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
ValueError: If ``value`` is not valid base64 or not a whole number
|
|
307
|
+
of ``uint32`` tokens (surfaced by FastAPI as 422).
|
|
308
|
+
"""
|
|
309
|
+
decode_tokens(value)
|
|
310
|
+
return value
|
|
244
311
|
|
|
245
312
|
|
|
246
313
|
class GlobalMatchModel(BaseModel):
|
|
@@ -1224,7 +1224,7 @@ class BlendV3Module(InstanceLivenessTarget):
|
|
|
1224
1224
|
if coordinator is None or not chunk_hashes:
|
|
1225
1225
|
return
|
|
1226
1226
|
try:
|
|
1227
|
-
model_scope =
|
|
1227
|
+
model_scope = key.model_name
|
|
1228
1228
|
store_range = {
|
|
1229
1229
|
"model_scope": model_scope,
|
|
1230
1230
|
"tokens": list(tokens_in_range),
|
|
@@ -1256,8 +1256,7 @@ class BlendV3Module(InstanceLivenessTarget):
|
|
|
1256
1256
|
tokens = list(key.token_ids)
|
|
1257
1257
|
if len(tokens) < self._ctx.chunk_size:
|
|
1258
1258
|
return False
|
|
1259
|
-
|
|
1260
|
-
coordinator.submit_match(key.request_id, model_scope, tokens)
|
|
1259
|
+
coordinator.submit_match(key.request_id, key.model_name, tokens)
|
|
1261
1260
|
return True
|
|
1262
1261
|
except Exception:
|
|
1263
1262
|
logger.warning(
|
|
@@ -1272,10 +1271,9 @@ class BlendV3Module(InstanceLivenessTarget):
|
|
|
1272
1271
|
|
|
1273
1272
|
Mirrors the prefix/sparse legs: ``return None`` to defer while pending.
|
|
1274
1273
|
A per-lookup wall-clock deadline (``job.coord_deadline``) bounds the
|
|
1275
|
-
total wait
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
(the daemon's later fill, if any, is dropped via ``take_match``).
|
|
1274
|
+
total wait, including queue/pool time. Past the deadline the leg is
|
|
1275
|
+
abandoned and the lookup proceeds local-only (the client's later fill,
|
|
1276
|
+
if any, is dropped via ``take_match``).
|
|
1279
1277
|
|
|
1280
1278
|
Args:
|
|
1281
1279
|
job: The per-request poll state.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lmcache-cli
|
|
3
|
-
Version: 0.4.8.
|
|
3
|
+
Version: 0.4.8.dev42
|
|
4
4
|
Summary: A LLM serving engine extension to reduce TTFT and increase throughput, especially under long-context scenarios.
|
|
5
5
|
Author-email: LMCache Team <lmcacheteam@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
lmcache/__init__.py,sha256=C7vwCTEvGEP_ca17O7YpXxz7Kqz-nPOHDPjCdzCdzRM,3958
|
|
2
|
-
lmcache/_version.py,sha256=
|
|
2
|
+
lmcache/_version.py,sha256=GD_w2b5I14ipJ41mA7IkKCtO0jiZLuHNRDucf6M0szo,543
|
|
3
3
|
lmcache/banner.py,sha256=fQFq8TX_OODhxu-zv4UAMPcbF6FizY2QF1zJPYU88mo,4070
|
|
4
4
|
lmcache/connections.py,sha256=5bSguAPmkJCRpgjkZd0ugGna6Zfm0pXG9vo-RGVXeFg,4965
|
|
5
5
|
lmcache/logging.py,sha256=7qOcguB6x0mr6TlyWvwcRS3Yk6DVfTq0ZM7ACh5DaT4,2662
|
|
@@ -339,14 +339,14 @@ lmcache/v1/lookup_client/record_strategies/memory_bloom_filter.py,sha256=J62TNb7
|
|
|
339
339
|
lmcache/v1/mp_coordinator/__init__.py,sha256=Z_XPvBbsaFmUcLWipthjjBduoDkjvqD3FfcV115Im_s,943
|
|
340
340
|
lmcache/v1/mp_coordinator/__main__.py,sha256=p962Qf2YipEW6xRLo3hmpzAbb5PPI9aZ269Rg_MxlnQ,769
|
|
341
341
|
lmcache/v1/mp_coordinator/app.py,sha256=Zd9ffQu6GopQpq7Q7URNdJFAGJUFho1SJerTcT_elFo,6578
|
|
342
|
-
lmcache/v1/mp_coordinator/blend_client.py,sha256=
|
|
343
|
-
lmcache/v1/mp_coordinator/blend_directory.py,sha256=
|
|
342
|
+
lmcache/v1/mp_coordinator/blend_client.py,sha256=_eMPadcPK_ID7NbU_Bvlwmdwdkz-eaJA6gzp9pBQIAU,10560
|
|
343
|
+
lmcache/v1/mp_coordinator/blend_directory.py,sha256=KGzaM_os1msn41GjOQ8POG2froXwXthY3aIDuCWBYmU,15865
|
|
344
344
|
lmcache/v1/mp_coordinator/config.py,sha256=Tk1tKFmBXAqcWvhfFhf6jKLU9xfO5UxrijKOcQfOodo,6416
|
|
345
345
|
lmcache/v1/mp_coordinator/registrar.py,sha256=5a_tgtm3CYvYPLhLkAvAMn4b_W9swKHss8TLknEv8LA,5073
|
|
346
346
|
lmcache/v1/mp_coordinator/registry.py,sha256=dYtRtW7tXuz6udGI5YfRQzBsOzY82hazPhbEU19lw9Y,5915
|
|
347
|
-
lmcache/v1/mp_coordinator/schemas.py,sha256=
|
|
347
|
+
lmcache/v1/mp_coordinator/schemas.py,sha256=Mou0HD6cXbr26ebQrlW4HhB6VAbhmyvhxDQaab0Y8_A,9645
|
|
348
348
|
lmcache/v1/mp_coordinator/http_apis/__init__.py,sha256=fRZK22mUV9kw77LXySq4cNgL7eg2JBVu8zuWhfhxREo,235
|
|
349
|
-
lmcache/v1/mp_coordinator/http_apis/blend_directory_api.py,sha256=
|
|
349
|
+
lmcache/v1/mp_coordinator/http_apis/blend_directory_api.py,sha256=kNumJuPk7yHqf9yAFOTNeQejpduMnizrxhqAuqHILTM,3224
|
|
350
350
|
lmcache/v1/mp_coordinator/http_apis/health_api.py,sha256=8BcdUAhP_wIDC4hs1eTBQ2e01U8PRlO9jwRWv6OYQ30,380
|
|
351
351
|
lmcache/v1/mp_coordinator/http_apis/instances_api.py,sha256=ZQLFJ6_4QTR043CB1BmIE8rWAnlVN0OD_feWOyWagKk,4574
|
|
352
352
|
lmcache/v1/mp_coordinator/http_apis/l2_api.py,sha256=H2vuwfnT-jnn-zrwd-K-bm8P5NM8s0lwWOIhY2-q9HM,6205
|
|
@@ -427,7 +427,7 @@ lmcache/v1/multiprocess/http_apis/status_api.py,sha256=0v-dqktWjs9thc5FmRykOPaGc
|
|
|
427
427
|
lmcache/v1/multiprocess/http_apis/version_api.py,sha256=A_haZ4cMmUp37aU3Qe4QVR1rgxnJFv88aRqILhLMq5Y,151
|
|
428
428
|
lmcache/v1/multiprocess/modules/__init__.py,sha256=bG7Ffen4LvQtgnYPFEpFccsWs81t4zqqeqn9ZeirH6E,38
|
|
429
429
|
lmcache/v1/multiprocess/modules/blend.py,sha256=NcqAG6j045gVK2lUQv1hTtQfzQdoe0YPo3fMiB56kU8,46972
|
|
430
|
-
lmcache/v1/multiprocess/modules/blend_v3.py,sha256=
|
|
430
|
+
lmcache/v1/multiprocess/modules/blend_v3.py,sha256=TV4tcwmaEdQXRYMJpX-kA8bKk_DjBilung6t9c4yUSc,74518
|
|
431
431
|
lmcache/v1/multiprocess/modules/engine_driven_transfer.py,sha256=sc0I40fWE9DhRoilvryb2LHOyd8vTv1dc5PM9lu5IHY,19193
|
|
432
432
|
lmcache/v1/multiprocess/modules/lmcache_driven_transfer.py,sha256=lBvu_VzmuZnxryo7mjGo0dr1dfP_rbIwGK0r83Sr_lY,43244
|
|
433
433
|
lmcache/v1/multiprocess/modules/lookup.py,sha256=3RKwuMLEHBHEbEyLKSD85kuRhWRNDLQS4kr_2SaTIsk,16850
|
|
@@ -572,9 +572,9 @@ lmcache/v1/utils/cache_utils.py,sha256=O4RO_BRAOXXPwc3SqV_osq66Rnf6U733gUlbiEHwE
|
|
|
572
572
|
lmcache/v1/utils/json_utils.py,sha256=WFrdauddZP8xuOqKqkhFlYyt3cNH-krNtVCRe4jIxeY,1724
|
|
573
573
|
lmcache/v1/utils/router_discovery.py,sha256=yZR3oTovQesNQzfa6EboyYPj798WVvUxZRJ6l4L-9bk,1867
|
|
574
574
|
lmcache/v1/utils/subclass_discovery.py,sha256=HmvDIEf8QI7GVeNzD-06zK8McjyX8yFMJNAOaR_85GU,9159
|
|
575
|
-
lmcache_cli-0.4.8.
|
|
576
|
-
lmcache_cli-0.4.8.
|
|
577
|
-
lmcache_cli-0.4.8.
|
|
578
|
-
lmcache_cli-0.4.8.
|
|
579
|
-
lmcache_cli-0.4.8.
|
|
580
|
-
lmcache_cli-0.4.8.
|
|
575
|
+
lmcache_cli-0.4.8.dev42.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
576
|
+
lmcache_cli-0.4.8.dev42.dist-info/METADATA,sha256=aX8MMhA_lVMrMLC8m8Wji6eT_DsHTrl6BeWQ3ZvY6MQ,11289
|
|
577
|
+
lmcache_cli-0.4.8.dev42.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
578
|
+
lmcache_cli-0.4.8.dev42.dist-info/entry_points.txt,sha256=Axef26i3pyPHL-zuNZWCubmM74zLa9tQnoMU1JrAZUg,50
|
|
579
|
+
lmcache_cli-0.4.8.dev42.dist-info/top_level.txt,sha256=7KnuVYNl7HkrnKK3wAN5qJJQSOcCv-l0TW4bzvH_SaU,8
|
|
580
|
+
lmcache_cli-0.4.8.dev42.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|