from __future__ import annotations
import logging
import os
import re
import socket
import struct
import time
from typing import Callable, Optional
logger = logging.getLogger("modelexpress.nic_metrics")
_IB_SYSFS = "/sys/class/infiniband"
_COUNTER_WORD_BYTES = 4
def list_all_ib_devices() -> list[str]:
try:
return sorted(os.listdir(_IB_SYSFS))
except Exception:
return []
def resolve_ib_device(device_id: int) -> Optional[str]:
try:
from . import ucx_utils
pinned = ucx_utils.probe_nic_pin_for_device(device_id)
if not pinned:
return None
return pinned.split(":", 1)[0] or None
except Exception as e: logger.debug("Could not resolve IB device for GPU %s: %s", device_id, e)
return None
def _read_int(path: str) -> Optional[int]:
try:
with open(path) as f:
return int(f.read().strip())
except Exception:
return None
def _parse_rate_bytes_per_sec(rate_str: str) -> Optional[float]:
m = re.search(r"([0-9.]+)\s*Gb/sec", rate_str)
if not m:
return None
gbps = float(m.group(1))
return gbps * 1e9 / 8.0
_NETLINK_RDMA = 20
_NL_MSG_DEV_GET = (5 << 10) | 1 _NL_MSG_STAT_GET = (5 << 10) | 17 _NLA_DEV_INDEX = 1
_NLA_DEV_NAME = 2
_NLA_PORT_INDEX = 3
_NLA_HWCOUNTERS = 80
_NLA_HWCOUNTER_ENTRY = 81
_NLA_HWCOUNTER_ENTRY_NAME = 82
_NLA_HWCOUNTER_ENTRY_VALUE = 83
_NLM_F_REQUEST = 0x01
_NLM_F_ACK = 0x04
_NLM_F_DUMP = 0x300
_NLMSG_ERROR = 2
_NLMSG_DONE = 3
_NL_TX_BYTES = "rdma_tx_bytes"
_NL_RX_BYTES = "rdma_rx_bytes"
_NL_TIMEOUT_SECS = 2.0
def _nl_align(n: int) -> int:
return (n + 3) & ~3
def _nl_attr(attr_type: int, payload: bytes) -> bytes:
length = 4 + len(payload)
return (
struct.pack("=HH", length, attr_type)
+ payload
+ b"\0" * (_nl_align(length) - length)
)
def _nl_parse_attrs(buf: bytes) -> dict[int, list[bytes]]:
out: dict[int, list[bytes]] = {}
off = 0
while off + 4 <= len(buf):
alen, atype = struct.unpack_from("=HH", buf, off)
if alen < 4 or off + alen > len(buf):
break
out.setdefault(atype & 0x3FFF, []).append(buf[off + 4 : off + alen])
off += _nl_align(alen)
return out
def _nl_messages(sock: "socket.socket"):
while True:
data = sock.recv(65536)
if not data:
return
off = 0
while off + 16 <= len(data):
mlen, mtype, _flags, _seq, _pid = struct.unpack_from("=IHHII", data, off)
if mlen < 16 or off + mlen > len(data):
return
if mtype in (_NLMSG_DONE, _NLMSG_ERROR):
return
yield mtype, data[off + 16 : off + mlen]
off += _nl_align(mlen)
def _nl_resolve_device_index(device: str) -> Optional[int]:
try:
with socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, _NETLINK_RDMA) as sock:
sock.settimeout(_NL_TIMEOUT_SECS)
sock.send(
struct.pack(
"=IHHII", 16, _NL_MSG_DEV_GET, _NLM_F_REQUEST | _NLM_F_DUMP, 1, 0
)
)
for _mtype, payload in _nl_messages(sock):
attrs = _nl_parse_attrs(payload)
name = attrs.get(_NLA_DEV_NAME, [b""])[0].rstrip(b"\0")
index = attrs.get(_NLA_DEV_INDEX)
if index and name.decode("utf-8", "replace") == device:
return int(struct.unpack("=I", index[0][:4])[0])
except Exception:
return None
return None
def _nl_read_hw_counters(device: str, port: int) -> dict[str, int]:
index = _nl_resolve_device_index(device)
if index is None:
return {}
counters: dict[str, int] = {}
try:
with socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, _NETLINK_RDMA) as sock:
sock.settimeout(_NL_TIMEOUT_SECS)
body = _nl_attr(_NLA_DEV_INDEX, struct.pack("=I", index)) + _nl_attr(
_NLA_PORT_INDEX, struct.pack("=I", port)
)
sock.send(
struct.pack(
"=IHHII",
16 + len(body),
_NL_MSG_STAT_GET,
_NLM_F_REQUEST | _NLM_F_ACK,
1,
0,
)
+ body
)
for _mtype, payload in _nl_messages(sock):
for nest in _nl_parse_attrs(payload).get(_NLA_HWCOUNTERS, []):
entries = _nl_parse_attrs(nest).get(_NLA_HWCOUNTER_ENTRY, [])
for entry in entries:
fields = _nl_parse_attrs(entry)
name = (
fields.get(_NLA_HWCOUNTER_ENTRY_NAME, [b""])[0]
.rstrip(b"\0")
.decode("utf-8", "replace")
)
raw = fields.get(_NLA_HWCOUNTER_ENTRY_VALUE, [b""])[0]
if name and len(raw) >= 8:
counters[name] = int(struct.unpack("=Q", raw[:8])[0])
except Exception:
return {}
return counters
class NicUtilizationSampler:
def __init__(
self,
device: Optional[str],
port: int = 1,
*,
_reader: Optional[Callable[[str], Optional[int]]] = None,
_clock: Callable[[], float] = time.monotonic,
_link_bytes_per_sec: Optional[float] = None,
_nl_counters: Callable[[str, int], dict[str, int]] = _nl_read_hw_counters,
) -> None:
self._device = device
self._port = port
self._read_int = _reader or _read_int
self._clock = _clock
self._nl_counters = _nl_counters
self._link_bps = _link_bytes_per_sec
if self._link_bps is None and device is not None:
self._link_bps = self._read_link_bps()
self._last_t: Optional[float] = None
self._last_bytes: Optional[tuple[int, int]] = None
def _base(self) -> str:
return f"{_IB_SYSFS}/{self._device}/ports/{self._port}"
def _read_link_bps(self) -> Optional[float]:
try:
with open(f"{self._base()}/rate") as f:
return _parse_rate_bytes_per_sec(f.read().strip())
except Exception:
return None
def _read_bytes(self) -> Optional[tuple[int, int]]:
base = f"{self._base()}/counters"
tx = self._read_int(f"{base}/port_xmit_data")
rx = self._read_int(f"{base}/port_rcv_data")
if tx is not None and rx is not None:
return tx * _COUNTER_WORD_BYTES, rx * _COUNTER_WORD_BYTES
return self._read_bytes_netlink()
def _read_bytes_netlink(self) -> Optional[tuple[int, int]]:
if self._device is None:
return None
counters = self._nl_counters(self._device, self._port)
tx = counters.get(_NL_TX_BYTES)
rx = counters.get(_NL_RX_BYTES)
if tx is None or rx is None:
return None
return tx, rx
def sample(self) -> Optional[float]:
if self._device is None or not self._link_bps:
return None
now = self._clock()
cur = self._read_bytes()
if cur is None:
return None
prev_t, prev = self._last_t, self._last_bytes
self._last_t, self._last_bytes = now, cur
if prev is None or prev_t is None:
return None dt = now - prev_t
if dt <= 0:
return None
tx_rate = max(0, cur[0] - prev[0]) / dt
rx_rate = max(0, cur[1] - prev[1]) / dt
util = max(tx_rate, rx_rate) / self._link_bps
return min(1.0, max(0.0, util))
class SourceLoadSampler:
def __init__(
self,
device_id: int,
*,
_resolver: Callable[[int], Optional[str]] = resolve_ib_device,
_lister: Callable[[], list[str]] = list_all_ib_devices,
_sampler_factory: Callable[[Optional[str]], "NicUtilizationSampler"] = (
NicUtilizationSampler
),
) -> None:
affine = _resolver(device_id)
devices = [affine] if affine else _lister()
self._samplers = [_sampler_factory(d) for d in devices if d]
def sample(self) -> Optional[float]:
readings = [v for v in (s.sample() for s in self._samplers) if v is not None]
return max(readings) if readings else None
def make_source_load_provider(device_id: int) -> Callable[[], Optional[float]]:
from . import envs
nic = SourceLoadSampler(device_id).sample
url = envs.MX_P2P_RUNTIME_METRICS_URL
if not url:
return nic
from .runtime_load import RuntimeLoadProvider
runtime = RuntimeLoadProvider(url).sample
def blended() -> Optional[float]:
readings = [v for v in (nic(), runtime()) if v is not None]
return max(readings) if readings else None
return blended