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 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.dev38'
22
- __version_tuple__ = version_tuple = (0, 4, 8, 'dev38')
21
+ __version__ = version = '0.4.8.dev42'
22
+ __version_tuple__ = version_tuple = (0, 4, 8, 'dev42')
23
23
 
24
- __commit_id__ = commit_id = 'ge1fdbc634'
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. Publishes and match queries are enqueued by blend handler
71
- threads and serviced by one daemon thread; match results are stored in a dict
72
- the handler polls. Match queries are prioritized over publishes so lookup
73
- latency is not held up by best-effort store traffic.
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. Unlike ``request_timeout`` (one
95
- round-trip), this bounds total time including queue wait behind
96
- other match queries, since the daemon services them serially.
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(url, request_timeout=timeout, match_budget_s=timeout)
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
- """Service match queries (priority) then publishes until stopped."""
240
+ """Dispatch match queries to the pool (priority), then publishes."""
222
241
  while not self._stop.is_set():
223
242
  try:
224
- self._handle_match(self._match_q.get(timeout=0.05))
225
- continue
243
+ item = self._match_q.get(timeout=0.05)
226
244
  except Empty:
227
- pass
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
- "tokens": item.tokens,
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** = ``f"{model_name}@{cache_salt}"``; cross-scope content never matches.
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
- Thread-safe (single lock, mirroring ``InstanceRegistry``) and ephemeral. A stale
26
- entry (chunk evicted from L2 but not yet removed here) only causes a wasted
27
- prefetch downstream, then recompute -- never wrong KV.
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, ``f"{model_name}@{cache_salt}"``.
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 a poly-hash table and matches request
102
- tokens by a strided rolling-hash probe. All public methods take the internal
103
- lock, so the directory stays consistent under concurrent
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._index: dict[tuple[str, int], _ChunkLoc] = {}
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 the lock; keep only well-formed ranges.
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.array(rng.tokens, dtype=np.uint64)
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
- with self._lock:
173
- for model_scope, polys, object_keys, old_st_base in prepared:
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
- key = (model_scope, int(polys[i]))
176
- if key in self._index:
177
- continue
178
- object_key = object_keys[i]
179
- self._index[key] = _ChunkLoc(
180
- object_key, old_st_base + i * self._chunk_size
181
- )
182
- self._by_key.setdefault(object_key, []).append(key)
183
- inserted += 1
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
- removed = 0
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 key in self._by_key.pop(object_key, []):
199
- if self._index.pop(key, None) is not None:
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(self, model_scope: str, tokens: list[int]) -> list[GlobalMatch]:
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 and probes the table every
207
- ``probe_stride`` positions; a hit is an exact 64-bit poly match (the dict
208
- key is the full hash). De-duplicates by ``object_key``. Mirrors
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 (``f"{model_name}@{cache_salt}"``).
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
- if len(tokens) < self._chunk_size:
359
+ arr = np.asarray(tokens, dtype=np.uint64)
360
+ if arr.shape[0] < self._chunk_size:
219
361
  return []
220
- arr = np.array(tokens, dtype=np.uint64)
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
- n_positions = int(rolling.shape[0])
369
+ probe = rolling[:: self._probe_stride]
223
370
  matches: list[GlobalMatch] = []
224
371
  seen: set[str] = set()
225
- with self._lock:
226
- for q_pos in range(0, n_positions, self._probe_stride):
227
- loc = self._index.get((model_scope, int(rolling[q_pos])))
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, old_st=loc.old_st, cur_st=q_pos
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.tokens)
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: ``f"{model_name}@{cache_salt}"`` reuse 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
- tokens: The request tokens (the coordinator hashes and probes them).
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
- tokens: list[int] = Field(default_factory=list)
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 = f"{key.model_name}@{key.cache_salt}"
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
- model_scope = f"{key.model_name}@{key.cache_salt}"
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: the daemon services match queries serially, so queue backlog
1276
- can keep a query ``PENDING`` well past one HTTP round-trip. Once the
1277
- deadline passes, the leg is abandoned and the lookup proceeds local-only
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.dev38
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=jJS7CZ5JP6NMPW0z0tMjYwZ0Dhm2V1JDscwM-L_lWlg,543
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=V5OYF6RzkTaCKdoq1U0LsIC2LjjHPoUR79PWKW3mccU,9691
343
- lmcache/v1/mp_coordinator/blend_directory.py,sha256=abK1SeyqWt9xsFxPcD0lCsObmPrLTEoPqnOzpEMWjCU,9517
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=1Z2rITRm5jrXDz1vqLDqybj49cZaEpO2nVJPgfhidXA,7392
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=nK0Ba7s83SMV8Zj_hU7Bw7HV2b5C3CLxnHv6LFarY7E,3044
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=9TkVNJ60QHR_J0fhcJN4wZDHFbp6DrPcCnbrZZl8ytE,74710
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.dev38.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
576
- lmcache_cli-0.4.8.dev38.dist-info/METADATA,sha256=89s3_qlQDNUmBlLf9Lg4UNJTtMDly3H_nBWiWe83tjw,11289
577
- lmcache_cli-0.4.8.dev38.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
578
- lmcache_cli-0.4.8.dev38.dist-info/entry_points.txt,sha256=Axef26i3pyPHL-zuNZWCubmM74zLa9tQnoMU1JrAZUg,50
579
- lmcache_cli-0.4.8.dev38.dist-info/top_level.txt,sha256=7KnuVYNl7HkrnKK3wAN5qJJQSOcCv-l0TW4bzvH_SaU,8
580
- lmcache_cli-0.4.8.dev38.dist-info/RECORD,,
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,,