lmcache-cli 0.4.8.dev24__py3-none-any.whl → 0.4.8.dev38__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.
Files changed (69) hide show
  1. lmcache/_version.py +3 -3
  2. lmcache/cli/commands/bench/engine_bench/command.py +38 -2
  3. lmcache/cli/commands/bench/engine_bench/config.py +2 -0
  4. lmcache/cli/commands/bench/engine_bench/interactive/schema.py +21 -0
  5. lmcache/cli/commands/bench/engine_bench/request_sender.py +16 -1
  6. lmcache/cli/commands/bench/engine_bench/workloads/__init__.py +30 -0
  7. lmcache/cli/commands/bench/engine_bench/workloads/long_doc_qa.py +9 -0
  8. lmcache/cli/commands/coordinator.py +20 -0
  9. lmcache/cli/commands/tool/__init__.py +3 -2
  10. lmcache/cli/commands/tool/transfer_channel_benchmark.py +53 -0
  11. lmcache/integration/vllm/vllm_multi_process_adapter.py +18 -3
  12. lmcache/tools/transfer_channel_benchmark/README.md +128 -0
  13. lmcache/tools/transfer_channel_benchmark/__init__.py +1 -0
  14. lmcache/tools/transfer_channel_benchmark/__main__.py +39 -0
  15. lmcache/tools/transfer_channel_benchmark/benchmark.py +385 -0
  16. lmcache/tools/transfer_channel_benchmark/config.py +252 -0
  17. lmcache/v1/config.py +6 -0
  18. lmcache/v1/distributed/api.py +64 -0
  19. lmcache/v1/distributed/config.py +200 -9
  20. lmcache/v1/distributed/l2_adapters/base.py +25 -1
  21. lmcache/v1/distributed/l2_adapters/config.py +22 -0
  22. lmcache/v1/distributed/l2_adapters/fault_inject_l2_adapter.py +462 -0
  23. lmcache/v1/distributed/l2_adapters/s3_l2_adapter.py +235 -4
  24. lmcache/v1/distributed/l2_adapters/serde_wrapper.py +13 -1
  25. lmcache/v1/distributed/memory_manager/l1_memory_manager.py +30 -8
  26. lmcache/v1/distributed/storage_manager.py +23 -0
  27. lmcache/v1/distributed/transfer_channel/__init__.py +105 -0
  28. lmcache/v1/distributed/transfer_channel/abstract.py +149 -0
  29. lmcache/v1/distributed/transfer_channel/api.py +47 -0
  30. lmcache/v1/distributed/transfer_channel/factory.py +100 -0
  31. lmcache/v1/distributed/transfer_channel/impl/__init__.py +9 -0
  32. lmcache/v1/distributed/transfer_channel/impl/nixl_impl.py +591 -0
  33. lmcache/v1/memory_management.py +455 -0
  34. lmcache/v1/mp_coordinator/app.py +33 -5
  35. lmcache/v1/mp_coordinator/blend_client.py +2 -2
  36. lmcache/v1/mp_coordinator/config.py +33 -0
  37. lmcache/v1/mp_coordinator/http_apis/l2_api.py +19 -75
  38. lmcache/v1/mp_coordinator/l2/event_listener.py +22 -45
  39. lmcache/v1/mp_coordinator/l2/eviction_manager.py +117 -79
  40. lmcache/v1/mp_coordinator/l2/resync_manager.py +149 -0
  41. lmcache/v1/mp_coordinator/l2/usage_manager.py +57 -70
  42. lmcache/v1/mp_coordinator/registry.py +10 -0
  43. lmcache/v1/mp_coordinator/schemas.py +9 -28
  44. lmcache/v1/mp_observability/event.py +10 -3
  45. lmcache/v1/mp_observability/subscribers/metrics/cb_server.py +11 -0
  46. lmcache/v1/mp_observability/subscribers/tracing/cb_server.py +30 -9
  47. lmcache/v1/multiprocess/config.py +65 -0
  48. lmcache/v1/multiprocess/custom_types.py +23 -1
  49. lmcache/v1/multiprocess/engine_context.py +1 -1
  50. lmcache/v1/multiprocess/engine_module.py +65 -0
  51. lmcache/v1/multiprocess/http_apis/l2_api.py +241 -0
  52. lmcache/v1/multiprocess/modules/blend_v3.py +265 -35
  53. lmcache/v1/multiprocess/modules/engine_driven_transfer.py +165 -74
  54. lmcache/v1/multiprocess/modules/lmcache_driven_transfer.py +150 -29
  55. lmcache/v1/multiprocess/modules/management.py +87 -7
  56. lmcache/v1/multiprocess/modules/p2p_controller.py +245 -0
  57. lmcache/v1/multiprocess/mq.py +5 -0
  58. lmcache/v1/multiprocess/protocols/__init__.py +2 -0
  59. lmcache/v1/multiprocess/protocols/base.py +5 -0
  60. lmcache/v1/multiprocess/protocols/controller.py +6 -2
  61. lmcache/v1/multiprocess/protocols/p2p.py +61 -0
  62. lmcache/v1/multiprocess/server.py +48 -20
  63. lmcache/v1/storage_backend/nixl_storage_backend.py +28 -4
  64. {lmcache_cli-0.4.8.dev24.dist-info → lmcache_cli-0.4.8.dev38.dist-info}/METADATA +1 -1
  65. {lmcache_cli-0.4.8.dev24.dist-info → lmcache_cli-0.4.8.dev38.dist-info}/RECORD +69 -52
  66. {lmcache_cli-0.4.8.dev24.dist-info → lmcache_cli-0.4.8.dev38.dist-info}/WHEEL +0 -0
  67. {lmcache_cli-0.4.8.dev24.dist-info → lmcache_cli-0.4.8.dev38.dist-info}/entry_points.txt +0 -0
  68. {lmcache_cli-0.4.8.dev24.dist-info → lmcache_cli-0.4.8.dev38.dist-info}/licenses/LICENSE +0 -0
  69. {lmcache_cli-0.4.8.dev24.dist-info → lmcache_cli-0.4.8.dev38.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.dev24'
22
- __version_tuple__ = version_tuple = (0, 4, 8, 'dev24')
21
+ __version__ = version = '0.4.8.dev38'
22
+ __version_tuple__ = version_tuple = (0, 4, 8, 'dev38')
23
23
 
24
- __commit_id__ = commit_id = 'gf52b174ca'
24
+ __commit_id__ = commit_id = 'ge1fdbc634'
@@ -33,7 +33,10 @@ from lmcache.cli.commands.bench.engine_bench.stats import (
33
33
  FinalStats,
34
34
  StatsCollector,
35
35
  )
36
- from lmcache.cli.commands.bench.engine_bench.workloads import create_workload
36
+ from lmcache.cli.commands.bench.engine_bench.workloads import (
37
+ create_workload,
38
+ validate_max_output_length_supported,
39
+ )
37
40
  from lmcache.logging import init_logger
38
41
 
39
42
  if TYPE_CHECKING:
@@ -42,6 +45,10 @@ if TYPE_CHECKING:
42
45
 
43
46
  logger = init_logger(__name__)
44
47
 
48
+ # Default for --ldqa-max-output-length; centralized so the "max output length
49
+ # explicitly set" check stays in sync with the parser.
50
+ _LDQA_MAX_OUTPUT_LENGTH_DEFAULT = 128
51
+
45
52
 
46
53
  # ---------------------------------------------------------------------------
47
54
  # Parser registration
@@ -147,6 +154,15 @@ def register_engine_parser(
147
154
  action="store_true",
148
155
  help="Suppress real-time progress display.",
149
156
  )
157
+ parser.add_argument(
158
+ "--ignore-eos",
159
+ action="store_true",
160
+ help=(
161
+ "Force generation to run for the full output length by ignoring "
162
+ "the model's EOS token (vLLM sampling extension). Makes decode "
163
+ "throughput reproducible regardless of when the model would stop."
164
+ ),
165
+ )
150
166
  parser.add_argument(
151
167
  "--no-interactive",
152
168
  action="store_true",
@@ -223,6 +239,16 @@ def register_engine_parser(
223
239
  default=3,
224
240
  help="Max concurrent in-flight requests (default: 3).",
225
241
  )
242
+ group.add_argument(
243
+ "--ldqa-max-output-length",
244
+ type=int,
245
+ default=_LDQA_MAX_OUTPUT_LENGTH_DEFAULT,
246
+ help=(
247
+ f"Max tokens to generate per benchmark query "
248
+ f"(default: {_LDQA_MAX_OUTPUT_LENGTH_DEFAULT}). Combine with "
249
+ "--ignore-eos for a reproducible decode phase."
250
+ ),
251
+ )
226
252
 
227
253
  # --- Multi-round-chat workload args ---
228
254
  mrc_group = parser.add_argument_group(
@@ -410,6 +436,7 @@ def _export_config(
410
436
  state.set("workload", config.workload)
411
437
  state.set("kv_cache_volume", config.kv_cache_volume_gb)
412
438
  state.set("tokens_per_gb_kvcache", config.tokens_per_gb_kvcache)
439
+ state.set("ignore_eos", config.ignore_eos)
413
440
 
414
441
  # Workload-specific args from namespace
415
442
  for item in state.get_workload_items():
@@ -524,6 +551,11 @@ def run_engine_bench(command: "BaseCommand", args: argparse.Namespace) -> None:
524
551
  # 1. Parse config
525
552
  config = parse_args_to_config(args)
526
553
 
554
+ # 1a. A max output length can only be set for workloads that have a
555
+ # max-output-length parameter; reject it for any other workload.
556
+ if args.ldqa_max_output_length != _LDQA_MAX_OUTPUT_LENGTH_DEFAULT:
557
+ validate_max_output_length_supported(config.workload)
558
+
527
559
  # 1b. --export-config: save resolved config and exit
528
560
  export_path = getattr(args, "export_config", None)
529
561
  if export_path:
@@ -546,7 +578,11 @@ def run_engine_bench(command: "BaseCommand", args: argparse.Namespace) -> None:
546
578
  )
547
579
 
548
580
  # 3. Create request sender (callbacks wired after workload creation)
549
- request_sender = RequestSender(config.engine_url, config.model)
581
+ request_sender = RequestSender(
582
+ config.engine_url,
583
+ config.model,
584
+ ignore_eos=config.ignore_eos,
585
+ )
550
586
 
551
587
  # 4. Create workload
552
588
  workload = create_workload(
@@ -39,6 +39,7 @@ class EngineBenchConfig:
39
39
  export_csv: bool
40
40
  export_json: bool
41
41
  quiet: bool
42
+ ignore_eos: bool = False
42
43
 
43
44
  def __post_init__(self) -> None:
44
45
  if not self.engine_url:
@@ -246,4 +247,5 @@ def parse_args_to_config(args: argparse.Namespace) -> EngineBenchConfig:
246
247
  export_csv=not args.no_csv,
247
248
  export_json=args.json,
248
249
  quiet=args.quiet,
250
+ ignore_eos=args.ignore_eos,
249
251
  )
@@ -179,6 +179,18 @@ ALL_ITEMS: list[ConfigItem] = [
179
179
  default=100.0,
180
180
  phase=PHASE_GENERAL,
181
181
  ),
182
+ ConfigItem(
183
+ key="ignore_eos",
184
+ display_name="Ignore EOS",
185
+ description=(
186
+ "Force generation to run for the full output length by ignoring "
187
+ "the model's EOS token (vLLM extension). Makes decode throughput "
188
+ "reproducible."
189
+ ),
190
+ input_type="bool",
191
+ default=False,
192
+ phase=PHASE_GENERAL,
193
+ ),
182
194
  # ── Phase 3: long-doc-permutator ─────────────────────────────────
183
195
  ConfigItem(
184
196
  key="ldp_num_contexts",
@@ -266,6 +278,15 @@ ALL_ITEMS: list[ConfigItem] = [
266
278
  condition=_workload_is("long-doc-qa"),
267
279
  phase=PHASE_WORKLOAD,
268
280
  ),
281
+ ConfigItem(
282
+ key="ldqa_max_output_length",
283
+ display_name="Max output length (tokens)",
284
+ description="Max tokens to generate per benchmark query.",
285
+ input_type="int",
286
+ default=128,
287
+ condition=_workload_is("long-doc-qa"),
288
+ phase=PHASE_WORKLOAD,
289
+ ),
269
290
  # ── Phase 3: multi-round-chat ─────────────────────────────────────
270
291
  ConfigItem(
271
292
  key="mrc_shared_prompt_length",
@@ -73,10 +73,12 @@ class RequestSender:
73
73
  model: str,
74
74
  completions_mode: bool = False,
75
75
  on_finished: list[OnFinishedCallback] = [], # noqa: B006
76
+ ignore_eos: bool = False,
76
77
  ) -> None:
77
78
  self._model = model
78
79
  self._completions_mode = completions_mode
79
80
  self._on_finished = list(on_finished)
81
+ self._ignore_eos = ignore_eos
80
82
 
81
83
  base_url = _normalize_url(engine_url)
82
84
  api_key = os.getenv("OPENAI_API_KEY", "")
@@ -220,7 +222,18 @@ class RequestSender:
220
222
  messages: list[dict[str, str]],
221
223
  max_tokens: int,
222
224
  ) -> collections.abc.AsyncIterator:
223
- """Dispatch the streaming API call (chat or completions)."""
225
+ """Dispatch the streaming API call (chat or completions).
226
+
227
+ When ``ignore_eos`` is set on the sender, ``{"ignore_eos": true}`` is
228
+ added to the request body (a vLLM sampling extension) so generation
229
+ always runs for the full ``max_tokens`` instead of stopping at the
230
+ model's EOS token. This makes decode-throughput numbers reproducible.
231
+ """
232
+ # Attach extra_body only when ignore_eos is set; otherwise send the
233
+ # plain request so no vLLM-specific field reaches non-vLLM backends.
234
+ extra: dict[str, dict[str, bool]] = {}
235
+ if self._ignore_eos:
236
+ extra["extra_body"] = {"ignore_eos": True}
224
237
  if self._completions_mode:
225
238
  prompt = messages[0]["content"] if messages else ""
226
239
  return await self._client.completions.create(
@@ -230,6 +243,7 @@ class RequestSender:
230
243
  max_tokens=max_tokens,
231
244
  temperature=0.0,
232
245
  stream_options={"include_usage": True},
246
+ **extra,
233
247
  )
234
248
  return await self._client.chat.completions.create(
235
249
  model=self._model,
@@ -238,4 +252,5 @@ class RequestSender:
238
252
  max_tokens=max_tokens,
239
253
  temperature=0.0,
240
254
  stream_options={"include_usage": True},
255
+ **extra,
241
256
  )
@@ -52,6 +52,7 @@ __all__ = [
52
52
  "RandomPrefillConfig",
53
53
  "RandomPrefillWorkload",
54
54
  "create_workload",
55
+ "validate_max_output_length_supported",
55
56
  ]
56
57
 
57
58
  _WORKLOAD_NAMES = (
@@ -62,6 +63,34 @@ _WORKLOAD_NAMES = (
62
63
  "random-prefill",
63
64
  )
64
65
 
66
+ # Workloads that expose a user-configurable max output length, via a
67
+ # ``max_output_length`` arg (``--ldqa-max-output-length`` / ``--mrc-output-length``).
68
+ _WORKLOADS_WITH_MAX_OUTPUT_LENGTH: frozenset[str] = frozenset(
69
+ {"long-doc-qa", "multi-round-chat"}
70
+ )
71
+
72
+
73
+ def validate_max_output_length_supported(workload: str) -> None:
74
+ """Validate that a max output length can be specified for ``workload``.
75
+
76
+ Only workloads with a max-output-length parameter (``long-doc-qa``,
77
+ ``multi-round-chat``) support setting it; every other workload fixes its
78
+ generation length internally, so requesting one is rejected.
79
+
80
+ Args:
81
+ workload: The selected workload name (``EngineBenchConfig.workload``).
82
+
83
+ Raises:
84
+ ValueError: If ``workload`` has no max-output-length parameter.
85
+ """
86
+ if workload in _WORKLOADS_WITH_MAX_OUTPUT_LENGTH:
87
+ return
88
+ supported = ", ".join(sorted(_WORKLOADS_WITH_MAX_OUTPUT_LENGTH))
89
+ raise ValueError(
90
+ f"max output length cannot be specified for the {workload!r} workload: "
91
+ f"it has no max-output-length parameter. Supported workloads: {supported}."
92
+ )
93
+
65
94
 
66
95
  def create_workload(
67
96
  config: EngineBenchConfig,
@@ -114,6 +143,7 @@ def create_workload(
114
143
  query_per_document=args.ldqa_query_per_document,
115
144
  shuffle_policy=args.ldqa_shuffle_policy,
116
145
  num_inflight_requests=args.ldqa_num_inflight_requests,
146
+ max_output_length=args.ldqa_max_output_length,
117
147
  )
118
148
  return LongDocQAWorkload(
119
149
  config=ld_workload_config,
@@ -27,6 +27,7 @@ class LongDocQAConfig:
27
27
  num_documents: int = 1
28
28
  shuffle_policy: str = "random"
29
29
  num_inflight_requests: int = 3
30
+ max_output_length: int = 128
30
31
 
31
32
  def __post_init__(self) -> None:
32
33
  if self.document_length <= 0:
@@ -37,6 +38,10 @@ class LongDocQAConfig:
37
38
  raise ValueError(
38
39
  f"query_per_document must be >= 1, got {self.query_per_document}"
39
40
  )
41
+ if self.max_output_length < 1:
42
+ raise ValueError(
43
+ f"max_output_length must be >= 1, got {self.max_output_length}"
44
+ )
40
45
  if self.num_inflight_requests < 1:
41
46
  raise ValueError(
42
47
  f"num_inflight_requests must be >= 1, got {self.num_inflight_requests}"
@@ -56,6 +61,7 @@ class LongDocQAConfig:
56
61
  query_per_document: int = 2,
57
62
  shuffle_policy: str = "random",
58
63
  num_inflight_requests: int = 3,
64
+ max_output_length: int = 128,
59
65
  ) -> "LongDocQAConfig":
60
66
  """Create a config with ``num_documents`` computed from KV cache budget.
61
67
 
@@ -66,6 +72,7 @@ class LongDocQAConfig:
66
72
  query_per_document: Number of questions per document.
67
73
  shuffle_policy: Request ordering — ``"random"`` or ``"tile"``.
68
74
  num_inflight_requests: Max concurrent in-flight requests.
75
+ max_output_length: Max tokens to generate per benchmark query.
69
76
 
70
77
  Returns:
71
78
  A fully-resolved LongDocQAConfig with computed num_documents.
@@ -88,6 +95,7 @@ class LongDocQAConfig:
88
95
  num_documents=num_documents,
89
96
  shuffle_policy=shuffle_policy,
90
97
  num_inflight_requests=num_inflight_requests,
98
+ max_output_length=max_output_length,
91
99
  )
92
100
 
93
101
 
@@ -265,6 +273,7 @@ class LongDocQAWorkload(BaseWorkload):
265
273
  await self._request_sender.send_request(
266
274
  request_id,
267
275
  messages,
276
+ max_tokens=self._config.max_output_length,
268
277
  )
269
278
  finally:
270
279
  self._semaphore.release()
@@ -101,6 +101,24 @@ class CoordinatorCommand(BaseCommand):
101
101
  "quota, 0.0 (exclusive) to 1.0 (default: 1.0)."
102
102
  ),
103
103
  )
104
+ parser.add_argument(
105
+ "--blend-chunk-size",
106
+ type=int,
107
+ default=None,
108
+ help=(
109
+ "Tokens per chunk for the global CacheBlend directory; must "
110
+ "equal the LMCache chunk size the blend servers use (default: 256)."
111
+ ),
112
+ )
113
+ parser.add_argument(
114
+ "--blend-probe-stride",
115
+ type=int,
116
+ default=None,
117
+ help=(
118
+ "Positions between CacheBlend match probes; 1 probes every "
119
+ "offset for full recall (default: 1)."
120
+ ),
121
+ )
104
122
 
105
123
  def execute(self, args: argparse.Namespace) -> None:
106
124
  """Build the coordinator config and serve the app with uvicorn.
@@ -145,6 +163,8 @@ class CoordinatorCommand(BaseCommand):
145
163
  ("eviction_check_interval", args.eviction_check_interval),
146
164
  ("eviction_ratio", args.eviction_ratio),
147
165
  ("trigger_watermark", args.trigger_watermark),
166
+ ("blend_chunk_size", args.blend_chunk_size),
167
+ ("blend_probe_stride", args.blend_probe_stride),
148
168
  )
149
169
  if value is not None
150
170
  }
@@ -15,7 +15,7 @@ import sys
15
15
 
16
16
  # First Party
17
17
  from lmcache.cli.commands.base import BaseCommand
18
- from lmcache.cli.commands.tool import cache_simulator
18
+ from lmcache.cli.commands.tool import cache_simulator, transfer_channel_benchmark
19
19
 
20
20
 
21
21
  class ToolCommand(BaseCommand):
@@ -46,9 +46,10 @@ class ToolCommand(BaseCommand):
46
46
  inner = parser.add_subparsers(
47
47
  dest="tool_name",
48
48
  required=True,
49
- metavar="{cache-simulator}",
49
+ metavar="{cache-simulator,transfer-channel-benchmark}",
50
50
  )
51
51
  cache_simulator.register(inner)
52
+ transfer_channel_benchmark.register(inner)
52
53
 
53
54
  def execute(self, args: argparse.Namespace) -> None:
54
55
  """Dispatch is handled per-tool via parser.set_defaults(func=...).
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``lmcache tool transfer-channel-benchmark`` sub-subcommand wiring.
3
+
4
+ Argument definitions and execution logic live in the benchmark module:
5
+
6
+ * :func:`~lmcache.tools.transfer_channel_benchmark.benchmark.add_benchmark_arguments`
7
+ * :func:`~lmcache.tools.transfer_channel_benchmark.benchmark.run_benchmark`
8
+ """
9
+
10
+ # Standard
11
+ import argparse
12
+ import sys
13
+
14
+
15
+ def register(subparsers: argparse._SubParsersAction) -> None:
16
+ """Register the ``transfer-channel-benchmark`` sub-subcommand.
17
+
18
+ Only the torch-free ``config`` module is imported here, so registering this
19
+ tool (which happens on every ``lmcache`` invocation) does not require torch
20
+ or the distributed runtime. Those are imported in ``execute``.
21
+
22
+ Args:
23
+ subparsers: The subparsers action from the ``lmcache tool`` parser.
24
+ """
25
+ # First Party
26
+ from lmcache.tools.transfer_channel_benchmark.config import (
27
+ add_benchmark_arguments,
28
+ )
29
+
30
+ parser = subparsers.add_parser(
31
+ "transfer-channel-benchmark",
32
+ help="Benchmark transfer channel read throughput (server/client).",
33
+ description=(
34
+ "Throughput benchmark for the LMCache transfer channel. Run one "
35
+ "process with --role server and another with --role client."
36
+ ),
37
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
38
+ )
39
+ add_benchmark_arguments(parser)
40
+ parser.set_defaults(func=execute)
41
+
42
+
43
+ def execute(args: argparse.Namespace) -> None:
44
+ """Run the benchmark and exit non-zero on failure.
45
+
46
+ Args:
47
+ args: Parsed CLI arguments.
48
+ """
49
+ # First Party
50
+ from lmcache.tools.transfer_channel_benchmark.benchmark import run_benchmark
51
+
52
+ if not run_benchmark(args):
53
+ sys.exit(1)
@@ -271,14 +271,21 @@ def _raise_server_unreachable(server_url: str, timeout: float) -> NoReturn:
271
271
  def send_ping(
272
272
  mq_client: MessageQueueClient,
273
273
  timeout: float,
274
+ instance_id: int | None = None,
274
275
  ) -> bool:
275
276
  """Send a PING request and return the result.
276
277
 
278
+ Args:
279
+ mq_client: The message queue client.
280
+ timeout: Seconds to wait for the server's response.
281
+ instance_id: The worker's instance ID so the server can refresh its
282
+ liveness, or None for an untracked prober (scheduler adapter).
283
+
277
284
  Returns:
278
285
  True if server is healthy, False on timeout or error.
279
286
  """
280
287
  try:
281
- future = send_lmcache_request(mq_client, RequestType.PING, [])
288
+ future = send_lmcache_request(mq_client, RequestType.PING, [instance_id])
282
289
  return future.result(timeout=timeout)
283
290
  except TimeoutError:
284
291
  return False
@@ -389,6 +396,7 @@ class HeartbeatThread(PeriodicThread):
389
396
  mq_client: MessageQueueClient,
390
397
  health_event: threading.Event,
391
398
  interval: float = DEFAULT_HEARTBEAT_INTERVAL,
399
+ instance_id: int | None = None,
392
400
  ):
393
401
  """
394
402
  Args:
@@ -398,6 +406,9 @@ class HeartbeatThread(PeriodicThread):
398
406
  Adapters check this event to decide whether to proceed
399
407
  with operations or enter degraded mode.
400
408
  interval: Seconds between heartbeat pings and ping timeout.
409
+ instance_id: The worker's instance ID sent with each PING so the
410
+ server can refresh its liveness, or None for an untracked
411
+ prober (the scheduler adapter).
401
412
  """
402
413
  super().__init__(
403
414
  name="lmcache-heartbeat",
@@ -407,6 +418,7 @@ class HeartbeatThread(PeriodicThread):
407
418
  self._mq_client = mq_client
408
419
  self._health_event = health_event
409
420
  self._interval = interval
421
+ self._instance_id = instance_id
410
422
 
411
423
  # Optional callback invoked on the unhealthy->healthy edge,
412
424
  # before the health event is set. See register_recover_callback.
@@ -445,7 +457,9 @@ class HeartbeatThread(PeriodicThread):
445
457
  UNREGISTER must not re-register a ghost context.
446
458
  """
447
459
  was_healthy = self._health_event.is_set()
448
- healthy = send_ping(self._mq_client, timeout=self._interval)
460
+ healthy = send_ping(
461
+ self._mq_client, timeout=self._interval, instance_id=self._instance_id
462
+ )
449
463
 
450
464
  if self.stop_requested:
451
465
  return ThreadRunSummary(
@@ -1170,7 +1184,7 @@ class LMCacheMPWorkerAdapter:
1170
1184
  ``last_seen``, so it is never reaped while alive -- no re-registration
1171
1185
  is needed at startup, and the first store/retrieve is not gated. The
1172
1186
  recover callback still re-registers on a genuine unhealthy->healthy
1173
- edge (server restart) and on freeze-gap recovery.
1187
+ edge (server restart).
1174
1188
  """
1175
1189
  if self._heartbeat is not None:
1176
1190
  return
@@ -1181,6 +1195,7 @@ class LMCacheMPWorkerAdapter:
1181
1195
  mq_client=self.mq_client,
1182
1196
  health_event=self._health_event,
1183
1197
  interval=self._heartbeat_interval,
1198
+ instance_id=self.instance_id,
1184
1199
  )
1185
1200
  heartbeat.register_recover_callback(self._reregister_kv_caches_callback)
1186
1201
  heartbeat.start()
@@ -0,0 +1,128 @@
1
+ # Transfer Channel Throughput Benchmark
2
+
3
+ Measures read throughput (GB/s) of the LMCache **transfer channel**
4
+ (`lmcache/v1/distributed/transfer_channel/`) for batched peer-to-peer reads.
5
+
6
+ Unlike a raw-tensor microbenchmark, this tool uses LMCache's `L1MemoryManager`
7
+ to initialize the registered memory region and to **allocate the transferred
8
+ objects** on both sides, so it exercises the same memory path production uses.
9
+
10
+ ## How it works
11
+
12
+ The benchmark runs as **two separate processes**:
13
+
14
+ - **server** — uses an `L1MemoryManager` to allocate a registered L1 buffer and
15
+ a pool of source memory objects, registers the buffer with the transfer
16
+ channel, and publishes the source object catalog (`offset`/`size` per object)
17
+ over a small ZMQ side-channel.
18
+ - **client** — fetches the catalog, allocates its own destination objects via an
19
+ `L1MemoryManager`, connects the transfer channel, and repeatedly reads a random
20
+ `--num-objects` subset of the source objects, then reports throughput.
21
+
22
+ A side-channel is needed because the transfer channel handshake only exchanges
23
+ the whole-buffer registration, not per-object offsets.
24
+
25
+ > Only the `nixl` transfer channel type is registered today. The tool is generic
26
+ > over `--transfer-channel-type`; an unknown type raises a clear error.
27
+
28
+ ## Usage
29
+
30
+ Run via the module or the `lmcache` CLI. Start the server first.
31
+
32
+ ### `python -m`
33
+
34
+ ```bash
35
+ # terminal 1 — server
36
+ python -m lmcache.tools.transfer_channel_benchmark \
37
+ --role server --transfer-channel-type nixl \
38
+ --url 0.0.0.0:7600 --control-url 0.0.0.0:7610 \
39
+ --buffer-size 2GB --page-size 512KB --object-size 10MB
40
+
41
+ # terminal 2 — client
42
+ python -m lmcache.tools.transfer_channel_benchmark \
43
+ --role client --transfer-channel-type nixl \
44
+ --url 127.0.0.1:7600 --control-url 127.0.0.1:7610 \
45
+ --listen-url 0.0.0.0:7601 \
46
+ --page-size 512KB --object-size 10MB \
47
+ --num-objects 10 --iters 3 --warmup 1
48
+ ```
49
+
50
+ ### `lmcache tool`
51
+
52
+ ```bash
53
+ lmcache tool transfer-channel-benchmark --role server --url 0.0.0.0:7600 \
54
+ --control-url 0.0.0.0:7610 --buffer-size 2GB --object-size 10MB
55
+ lmcache tool transfer-channel-benchmark --role client --url 127.0.0.1:7600 \
56
+ --control-url 127.0.0.1:7610 --object-size 10MB --num-objects 10
57
+ ```
58
+
59
+ The server always writes a deterministic per-object byte pattern (object index
60
+ mod 256) into its source objects, so adding `--verify` on the **client** checks
61
+ the transferred bytes against that pattern (no `--verify` needed on the server).
62
+
63
+ ## Key options
64
+
65
+ | Option | Role | Meaning |
66
+ | --- | --- | --- |
67
+ | `--role {server,client}` | both | Which side to run (required). |
68
+ | `--transfer-channel-type` | both | Implementation to benchmark (default `nixl`). |
69
+ | `--nixl-backend` | both | nixl backend, e.g. `UCX` (nixl-specific). |
70
+ | `--url` | both | Server binds its transfer-channel server here; client dials it. |
71
+ | `--listen-url` | client | Client's own (mandatory) transfer-channel server bind. |
72
+ | `--control-url` | both | Catalog side-channel: server binds, client connects. |
73
+ | `--buffer-size` | server | Registered L1 source buffer size (e.g. `8GB`). |
74
+ | `--page-size` | both | Page / alignment size; **must match** on both sides. |
75
+ | `--object-size` | both | Per-object size; multiple of `--page-size`. |
76
+ | `--num-objects` | client | Objects transferred per read. |
77
+ | `--num-source-objects` | server | Source pool size (default `5 * --num-objects`). |
78
+ | `--iters` / `--warmup` | client | Measured / warmup read iterations. |
79
+ | `--seed` | client | RNG seed for the read subset. |
80
+ | `--verify` | client | Verify transferred bytes against the server's known pattern. |
81
+ | `--use-lazy` | both | Use the lazy L1 allocator (experimental for registration). |
82
+ | `--server-timeout` | server | Seconds to serve catalog requests before exiting. |
83
+
84
+ ## Notes
85
+
86
+ - `--page-size` must be identical on server and client (enforced via the catalog
87
+ handshake) so remote page-index math lines up.
88
+ - With the default (non-lazy) allocator the whole `--buffer-size` is allocated up
89
+ front and may be CUDA-pinned; keep it within available host memory.
90
+ - Requires a working transfer channel runtime (for `nixl`, a UCX backend).
91
+
92
+ ## Performance: NUMA placement
93
+
94
+ On a multi-NUMA host with NICs spread across nodes (e.g. an 8-NIC, 2-socket
95
+ box), run **both** the server and client under `numactl --interleave=all`:
96
+
97
+ ```bash
98
+ numactl --interleave=all \
99
+ python -m lmcache.tools.transfer_channel_benchmark --role server ...
100
+ ```
101
+
102
+ Without it the registered buffer is allocated on a single NUMA node, so only the
103
+ rails local to that node reach full bandwidth and the rest are throttled by the
104
+ cross-socket link — roughly halving throughput. In testing on a 2-NUMA / 8-rail
105
+ host this was the difference between **~110 GB/s** (no interleave) and
106
+ **~210 GB/s** (`--interleave=all`), with everything else identical. Page size
107
+ only matters in the small regime (descriptor-bound below ~64KB); from ~128KB up
108
+ the transfer is bandwidth-bound and flat.
109
+
110
+ ## Troubleshooting
111
+
112
+ - **Client hangs after "connected" or during connect, then raises
113
+ `TimeoutError` after ~60s.** The transfer-channel handshake could not reach the
114
+ server. Check that `--url`/`--control-url` on the client point at the *server's*
115
+ reachable address (a common mistake is a typo such as a trailing dot:
116
+ `10.0.0.5.:7600`), that the server is running, and that the ports are open
117
+ between hosts:
118
+ ```bash
119
+ nc -vz <server-host> 7600 # transfer channel
120
+ nc -vz <server-host> 7610 # catalog side-channel
121
+ ```
122
+ The handshake has a fixed 60s timeout, so a misconfiguration fails with a clear
123
+ error instead of hanging forever.
124
+ - **`page_size`/`object_size` mismatch error.** The client validates these against
125
+ the server's catalog; pass the same `--page-size` and `--object-size` on both.
126
+ - **Allocation `RuntimeError` on the server.** The source pool
127
+ (`--num-source-objects` × `--object-size`) must fit in `--buffer-size`; increase
128
+ the buffer or reduce the pool/object size.
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``python -m lmcache.tools.transfer_channel_benchmark`` entry point."""
3
+
4
+ # Standard
5
+ import argparse
6
+ import sys
7
+
8
+ # First Party
9
+ # Only the torch-free config module is imported eagerly so that `-h` works
10
+ # without torch installed. The runtime (which needs torch) is imported after
11
+ # arguments are parsed.
12
+ from lmcache.tools.transfer_channel_benchmark.config import add_benchmark_arguments
13
+
14
+
15
+ def main() -> int:
16
+ """Parse arguments and run the transfer channel benchmark.
17
+
18
+ Returns:
19
+ Process exit code (0 on success, 1 on failure).
20
+ """
21
+ parser = argparse.ArgumentParser(
22
+ prog="python -m lmcache.tools.transfer_channel_benchmark",
23
+ description="Throughput benchmark for the LMCache transfer channel.",
24
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
25
+ )
26
+ add_benchmark_arguments(parser)
27
+ args = parser.parse_args()
28
+
29
+ # Imported here (not at module load) so this entry point can show --help
30
+ # without requiring torch; importing it raises a clear error if torch is
31
+ # missing.
32
+ # First Party
33
+ from lmcache.tools.transfer_channel_benchmark.benchmark import run_benchmark
34
+
35
+ return 0 if run_benchmark(args) else 1
36
+
37
+
38
+ if __name__ == "__main__":
39
+ sys.exit(main())