@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 48f309029a
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 14, 05:30 UTC

[NIXL][Misc] Expose metrics from NIXL for logging to CLI (#25388)

Signed-off-by: NickLucche <nlucches@redhat.com>

Nicolò Lucchesi committed Oct 3, 2025 at 12:47 UTC 48f309029a3aa66b40b0613a9a7361b654b4865a
4 files changed +127 -28
requirements/kv_connectors.txt
+1 -1
@@ -1,2 +1,2 @@
1 lmcache
2 -nixl >= 0.5.1 # Required for disaggregated prefill
2 +nixl >= 0.6.0 # Required for disaggregated prefill
tests/v1/kv_connector/unit/test_nixl_connector.py
+54 -11
@@ -57,6 +57,26 @@ def clear_kv_transfer():
57 ensure_kv_transfer_shutdown()
58
59
60 +def get_default_xfer_telemetry(xferDurationS: float = 1,
61 + postDurationS: float = 1,
62 + totalBytes: int = 1,
63 + descCount: int = 1) -> dict:
64 +
65 + class AttributeDict(dict):
66 + __slots__ = ()
67 + __getattr__ = dict.__getitem__
68 + __setattr__ = dict.__setitem__ # type: ignore[assignment]
69 +
70 + # We can't instantiate nixlXferTelemetry because it's read only and
71 + # ray env does not have NIXL, so we must fake it
72 + return AttributeDict(
73 + xferDuration=xferDurationS * 1e6, # in us
74 + postDuration=postDurationS * 1e6, # in us
75 + totalBytes=totalBytes,
76 + descCount=descCount,
77 + )
78 +
79 +
80 class FakeNixlWrapper:
81 """Mock implementation of NixlWrapper for testing.
82
@@ -132,6 +152,9 @@ class FakeNixlWrapper:
152 def transfer(self, handle: int) -> str:
153 return "PROC"
154
155 + def get_xfer_telemetry(self, handle: int) -> dict:
156 + return get_default_xfer_telemetry()
157 +
158 ############################################################
159 # Follow are for changing the behavior during testing.
160 ############################################################
@@ -169,6 +192,11 @@ nixl_agent = FakeNixlWrapper
192 with open(os.path.join(pkg_root, "__init__.py"), "w") as f:
193 f.write(stub)
194
195 + # Mock nixlXferTelemetry class
196 + pkg_root2 = os.path.join(td, "nixl", "_bindings")
197 + os.makedirs(pkg_root2, exist_ok=True)
198 + with open(os.path.join(pkg_root2, "__init__.py"), "w") as f:
199 + f.write("class nixlXferTelemetry: pass")
200 # touch parent package
201 open(os.path.join(td, "nixl", "__init__.py"), "w").close()
202 yield td
@@ -575,7 +603,7 @@ def test_kv_connector_stats(dist_init):
603
604 # Verify stats values are recorded
605 assert not stats_after_transfer.is_empty()
578 - assert stats_after_transfer.data["num_successful_transfers"] == 1
606 + assert stats_after_transfer.num_successful_transfers == 1
607
608 # Verify stats are reset after retrieval
609 stats_after_reset = connector.get_kv_connector_stats()
@@ -599,16 +627,21 @@ def test_kv_connector_stats_aggregation():
627
628 # Record different transfers on each worker
629 # Worker 1: 2 transfers
602 - worker1_stats.record_transfer()
603 - worker1_stats.record_transfer()
630 + stats = get_default_xfer_telemetry()
631 + worker1_stats.record_transfer(stats)
632 + worker1_stats.record_transfer(stats)
633
634 # Worker 2: 1 transfer
606 - worker2_stats.record_transfer()
635 + worker2_stats.record_transfer(stats)
636
637 # Worker 3: 3 transfers
609 - worker3_stats.record_transfer()
610 - worker3_stats.record_transfer()
611 - worker3_stats.record_transfer()
638 + stats = get_default_xfer_telemetry(xferDurationS=2,
639 + postDurationS=2,
640 + totalBytes=2,
641 + descCount=2)
642 + worker3_stats.record_transfer(stats)
643 + worker3_stats.record_transfer(stats)
644 + worker3_stats.record_transfer(stats)
645
646 # Create ModelRunnerOutput instances for each worker
647 worker_outputs = []
@@ -636,7 +669,12 @@ def test_kv_connector_stats_aggregation():
669 aggregated_output.kv_connector_output.kv_connector_stats
670 assert isinstance(kv_connector_stats, NixlKVConnectorStats)
671 # Number of total transfers across all workers.
639 - assert kv_connector_stats.data["num_successful_transfers"] == 6
672 + assert kv_connector_stats.num_successful_transfers == 6
673 + # Logging proc, call reduce() to get CLI-friendly stats.
674 + cli_stats = kv_connector_stats.reduce()
675 + assert cli_stats["Avg xfer time (ms)"] == 1500.0
676 + assert cli_stats["Avg post time (ms)"] == 1500.0
677 + assert cli_stats["Avg number of descriptors"] == 1.5
678
679
680 def test_multi_kv_connector_stats_aggregation():
@@ -649,6 +687,7 @@ def test_multi_kv_connector_stats_aggregation():
687
688 from dataclasses import dataclass
689
690 + # Mock a KVConnectorStats class for testing aggregation over connectors.
691 @dataclass
692 class FooKVConnectorStats(KVConnectorStats):
693
@@ -676,7 +715,7 @@ def test_multi_kv_connector_stats_aggregation():
715 if nixl_count > 0:
716 nixl_stats = NixlKVConnectorStats()
717 for _ in range(nixl_count):
679 - nixl_stats.record_transfer()
718 + nixl_stats.record_transfer(get_default_xfer_telemetry())
719 data["NixlConnector"] = nixl_stats
720 if foo_count > 0:
721 foo_stats = FooKVConnectorStats()
@@ -712,8 +751,10 @@ def test_multi_kv_connector_stats_aggregation():
751 assert isinstance(kv_connector_stats, MultiKVConnectorStats)
752
753 # Validate per-connector totals across workers
715 - assert kv_connector_stats["NixlConnector"].data[
716 - "num_successful_transfers"] == 5
754 + assert isinstance(kv_connector_stats["NixlConnector"],
755 + NixlKVConnectorStats)
756 + assert kv_connector_stats["NixlConnector"].num_successful_transfers == 5
757 + assert isinstance(kv_connector_stats["FooConnector"], FooKVConnectorStats)
758 assert kv_connector_stats["FooConnector"].data["num_foo_transfers"] == 6
759
760
@@ -755,6 +796,8 @@ def test_abort_timeout_on_prefiller(monkeypatch, distributed_executor_backend):
796 "working_dir": working_dir, # ship fake nixl package
797 "env_vars": {
798 "VLLM_NIXL_ABORT_REQUEST_TIMEOUT": str(timeout),
799 + # TODO: for ray to carry over, remove once we set
800 + "NIXL_TELEMETRY_ENABLE": "1",
801 },
802 }
803 ray.init(runtime_env=runtime_env)
vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py
+69 -13
@@ -4,6 +4,7 @@ import contextlib
4 import copy
5 import logging
6 import math
7 +import os
8 import queue
9 import threading
10 import time
@@ -54,10 +55,12 @@ logger = init_logger(__name__)
55 # Lazy import nixl_wrapper to avoid loading nixl_bindings if nixl is not used
56 try:
57 from nixl._api import nixl_agent as NixlWrapper
58 + from nixl._bindings import nixlXferTelemetry
59 logger.info("NIXL is available")
60 except ImportError:
61 logger.warning("NIXL is not available")
62 NixlWrapper = None
63 + nixlXferTelemetry = None
64
65 try:
66 from nixl._api import nixl_agent_config
@@ -476,6 +479,9 @@ class NixlConnectorWorker:
479 self.nixl_backends = \
480 vllm_config.kv_transfer_config.get_from_extra_config(
481 "backends", ["UCX"])
482 + # TODO temporary, once nixl allows for telemetry flag in config
483 + # (next release), we can remove this env var.
484 + os.environ["NIXL_TELEMETRY_ENABLE"] = "1"
485 # Agent.
486 non_ucx_backends = [b for b in self.nixl_backends if b != "UCX"]
487 if nixl_agent_config is None:
@@ -1175,9 +1181,10 @@ class NixlConnectorWorker:
1181 for handle, _xfer_stime in handles:
1182 xfer_state = self.nixl_wrapper.check_xfer_state(handle)
1183 if xfer_state == "DONE":
1184 + # Get telemetry from NIXL
1185 + res = self.nixl_wrapper.get_xfer_telemetry(handle)
1186 + self.xfer_stats.record_transfer(res)
1187 self.nixl_wrapper.release_xfer_handle(handle)
1179 - # TODO (NickLucche) Get from NIXL telemetry once integrated
1180 - self.xfer_stats.record_transfer()
1188 elif xfer_state == "PROC":
1189 in_progress = True
1190 continue
@@ -1449,15 +1456,25 @@ class NixlKVConnectorStats(KVConnectorStats):
1456 """Container for transfer performance metrics"""
1457
1458 def __post_init__(self):
1452 - if "num_successful_transfers" not in self.data:
1453 - self.data["num_successful_transfers"] = 0
1459 + if not self.data:
1460 + # Empty container init, no data is passed in.
1461 + self.reset()
1462
1463 def reset(self):
1456 - self.data = {"num_successful_transfers": 0}
1464 + # Must be serializable
1465 + self.data: dict[str, list[float]] = {
1466 + "transfer_duration": [],
1467 + "post_duration": [],
1468 + "bytes_transferred": [],
1469 + "num_descriptors": [],
1470 + }
1471
1458 - def record_transfer(self):
1459 - # TODO: record actual transfer stats when available
1460 - self.data["num_successful_transfers"] += 1
1472 + def record_transfer(self, res: nixlXferTelemetry):
1473 + # Keep metrics units consistent with rest of the code: time us->s
1474 + self.data["transfer_duration"].append(res.xferDuration / 1e6)
1475 + self.data["post_duration"].append(res.postDuration / 1e6)
1476 + self.data["bytes_transferred"].append(res.totalBytes)
1477 + self.data["num_descriptors"].append(res.descCount)
1478
1479 def clone_and_reset(self) -> "NixlKVConnectorStats":
1480 old = copy.copy(self)
@@ -1465,16 +1482,55 @@ class NixlKVConnectorStats(KVConnectorStats):
1482 return old
1483
1484 def is_empty(self) -> bool:
1468 - return self.data["num_successful_transfers"] == 0
1485 + return self.num_successful_transfers == 0
1486
1487 def aggregate(self, other: KVConnectorStats) -> KVConnectorStats:
1488 if not other.is_empty():
1472 - self.data["num_successful_transfers"] += other.data[
1473 - "num_successful_transfers"]
1489 + for k, v in other.data.items():
1490 + accumulator = self.data[k]
1491 + assert isinstance(accumulator, list)
1492 + accumulator.extend(v)
1493 return self
1494
1495 def reduce(self) -> dict[str, Union[int, float]]:
1477 - # TODO: reduce stats to a single value, calculate latency/throughput
1496 + # Compute compact representative stats suitable for CLI logging
1497 + if self.is_empty():
1498 + return {
1499 + "Num successful transfers": 0,
1500 + "Avg xfer time (ms)": 0,
1501 + "P90 xfer time (ms)": 0,
1502 + "Avg post time (ms)": 0,
1503 + "P90 post time (ms)": 0,
1504 + "Avg MB per transfer": 0,
1505 + "Throughput (MB/s)": 0,
1506 + "Avg number of descriptors": 0,
1507 + }
1508 +
1509 + xfer_time = np.asarray(self.data["transfer_duration"])
1510 + post_time = np.asarray(self.data["post_duration"])
1511 + # Convert to MB for CLI logging.
1512 + mb = np.asarray(self.data["bytes_transferred"]) / 2**20
1513 + descs = np.asarray(self.data["num_descriptors"], dtype=np.uint32)
1514 + n = len(descs)
1515 + assert n == self.num_successful_transfers
1516 +
1517 + total_mb = mb.sum()
1518 + avg_mb = total_mb / n
1519 +
1520 + total_time_seconds = xfer_time.sum()
1521 + throughput_mb_s = total_mb / total_time_seconds
1522 +
1523 return {
1479 - "num_successful_transfers": self.data["num_successful_transfers"]
1524 + "Num successful transfers": n,
1525 + "Avg xfer time (ms)": round(xfer_time.mean() * 1e3, 3),
1526 + "P90 xfer time (ms)": round(np.percentile(xfer_time, 90) * 1e3, 3),
1527 + "Avg post time (ms)": round(post_time.mean() * 1e3, 3),
1528 + "P90 post time (ms)": round(np.percentile(post_time, 90) * 1e3, 3),
1529 + "Avg MB per transfer": round(avg_mb, 3),
1530 + "Throughput (MB/s)": round(throughput_mb_s, 3),
1531 + "Avg number of descriptors": round(descs.mean(), 1),
1532 }
1533 +
1534 + @property
1535 + def num_successful_transfers(self) -> int:
1536 + return len(self.data["transfer_duration"])
\ No newline at end of file
vllm/v1/metrics/loggers.py
+3 -3
@@ -62,7 +62,7 @@ class LoggingStatLogger(StatLoggerBase):
62 self.prefix_caching_metrics = PrefixCachingMetrics()
63 self.spec_decoding_logging = SpecDecodingLogging()
64 kv_tranfer_config = self.vllm_config.kv_transfer_config
65 - self.kv_transfer_logging = KVConnectorLogging(kv_tranfer_config)
65 + self.kv_connector_logging = KVConnectorLogging(kv_tranfer_config)
66 self.last_prompt_throughput: float = 0.0
67 self.last_generation_throughput: float = 0.0
68
@@ -101,7 +101,7 @@ class LoggingStatLogger(StatLoggerBase):
101 self.spec_decoding_logging.observe(
102 scheduler_stats.spec_decoding_stats)
103 if kv_connector_stats := scheduler_stats.kv_connector_stats:
104 - self.kv_transfer_logging.observe(kv_connector_stats)
104 + self.kv_connector_logging.observe(kv_connector_stats)
105 self.last_scheduler_stats = scheduler_stats
106
107 def log(self):
@@ -140,7 +140,7 @@ class LoggingStatLogger(StatLoggerBase):
140 self.prefix_caching_metrics.hit_rate * 100,
141 )
142 self.spec_decoding_logging.log(log_fn=log_fn)
143 - self.kv_transfer_logging.log(log_fn=log_fn)
143 + self.kv_connector_logging.log(log_fn=log_fn)
144
145 def log_engine_initialized(self):
146 if self.vllm_config.cache_config.num_gpu_blocks: