| #!/usr/bin/env python3 | |
| # SPDX-License-Identifier: Linux-OpenIB | |
| # Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES | |
| # PYTHON_ARGCOMPLETE_OK | |
| from __future__ import annotations | |
| import argparse | |
| import collections | |
| import copy | |
| import importlib | |
| import inspect | |
| import itertools | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import textwrap | |
| from abc import ABC, abstractmethod | |
| from base64 import b64encode, b64decode | |
| from enum import Enum | |
| from typing import * | |
| from zlib import compress, decompress | |
| DEVDIR = os.environ.get("RDMA_TOPO_DEVDIR", "/sys/bus/pci/devices/") | |
| BDF_RE = re.compile(r"^([0-9a-f]+?):([0-9a-f]{2}?):([0-9a-f]{2}?)\.([0-9a-f])$") | |
| pci_vendors = { | |
| "MELLANOX": 0x15B3, | |
| "NVIDIA": 0x10DE, | |
| } | |
| PCI_EXT_CAP_ID_ACS = 0x000D | |
| PCI_EXT_CAP_ID_ATS = 0x000F | |
| PCI_VPD_LRDT = 0x80 # Large Resource Data Type flag | |
| PCI_VPD_END_SMALL = 0x78 # Small Resource End Tag | |
| PCI_VPD_END_LARGE = 0x79 # Large Resource End Tag | |
| PCI_VPD_LRDT_ID = 0x82 # Identifier String | |
| PCI_VPD_LRDT_RO = 0x90 # VPD-R (Read-Only) | |
| class CommandError(Exception): | |
| class Level(Enum): | |
| ERROR = "E" | |
| WARNING = "W" | |
| def __init__(self, reason: str, level: Level = Level.ERROR): | |
| self.reason = reason | |
| self.level = level | |
| super().__init__(f"{level.value}: {reason}") | |
| def __str__(self): | |
| return f"{self.level.value}: {self.reason}" | |
| class TopoNotSupportedError(CommandError): | |
| def __init__(self, reason: str = ""): | |
| details = "" | |
| if len(reason) > 0: | |
| details = f": {reason}" | |
| super().__init__(f"No supported topology detected{details}", | |
| CommandError.Level.ERROR) | |
| class TopoUnexpectedError(CommandError): | |
| def __init__(self, reason: str = ""): | |
| details = "" | |
| if len(reason) > 0: | |
| details = f": {reason}" | |
| super().__init__( | |
| (f"Unexpected topology structure{details}. Are you running on a " | |
| "production system? If yes, please report this issue."), | |
| CommandError.Level.ERROR, | |
| ) | |
| class DumpParsingError(CommandError): | |
| def __init__(self, reason: str): | |
| super().__init__(f"Malformed dump: {reason}", CommandError.Level.ERROR) | |
| def yesno(b: bool) -> str: | |
| return "yes" if b else "no" | |
| class SysfsDevice(object): | |
| REQUIRED_KEYS = ["realpath", "config", "modalias"] | |
| ENCODED_KEYS = ["config", "vpd"] | |
| @property | |
| def realpath(self) -> str: | |
| return self.data["realpath"] | |
| @property | |
| def config(self) -> bytes: | |
| return self.data["config"] | |
| @property | |
| def iommu_group(self) -> Optional[int]: | |
| return self.data.get("iommu_group", None) | |
| @property | |
| def modalias(self) -> str: | |
| return self.data["modalias"] | |
| @property | |
| def numa_node(self) -> Optional[int]: | |
| return self.data.get("numa_node", None) | |
| @property | |
| def vpd(self) -> Optional[bytes]: | |
| return self.data.get("vpd", None) | |
| @property | |
| def subsystems(self) -> Optional[Dict[str, List[str]]]: | |
| return self.data.get("subsystems", None) | |
| @property | |
| def id(self) -> str: | |
| return os.path.basename(self.data["realpath"]) | |
| def __init__(self, id: str): | |
| def read(*parts: str) -> bytes: | |
| with open(os.path.join(devdir, *parts), "rb") as F: | |
| return F.read() | |
| def string(b: bytes) -> str: | |
| return b.decode("ascii").strip() | |
| def subsystems() -> Dict[str, List[str]]: | |
| res: Dict[str, List[str]] = collections.defaultdict(list) | |
| for fn in os.listdir(devdir): | |
| if fn in {"drm", "infiniband", "net", "nvme"}: | |
| res[fn].extend(os.listdir(os.path.join(devdir, fn))) | |
| return dict(res) | |
| def iommu_group() -> int: | |
| return int( | |
| os.path.basename(os.readlink(os.path.join(devdir, "iommu_group"))) | |
| ) | |
| devdir = os.path.join(DEVDIR, id) | |
| readers = { | |
| "realpath": lambda: os.path.realpath(devdir), | |
| "config": lambda: read("config"), | |
| "iommu_group": iommu_group, | |
| "modalias": lambda: string(read("modalias")), | |
| "numa_node": lambda: int(string(read("numa_node"))), | |
| "vpd": lambda: read("vpd"), | |
| "subsystems": subsystems, | |
| } | |
| self.data: Dict[str, Any] = {} | |
| for k, reader in readers.items(): | |
| try: | |
| self.data[k] = reader() | |
| except FileNotFoundError as e: | |
| if k in SysfsDevice.REQUIRED_KEYS: | |
| raise CommandError(f"Missing required sysfs path: {e.filename}") | |
| self.data[k] = None | |
| except PermissionError as e: | |
| raise CommandError( | |
| f"Cannot read sysfs path: {e.filename}. Are you root?" | |
| ) | |
| @classmethod | |
| def from_dict(cls, data: Dict[str, Any]) -> SysfsDevice: | |
| obj = object.__new__(cls) | |
| obj.data = copy.deepcopy(data) | |
| for k in SysfsDevice.REQUIRED_KEYS: | |
| if k not in obj.data or obj.data[k] is None: | |
| raise ValueError(f"Missing required key '{k}'") | |
| for k in SysfsDevice.ENCODED_KEYS: | |
| if k in obj.data and obj.data[k] is not None: | |
| try: | |
| obj.data[k] = decompress(b64decode(obj.data[k])) | |
| except Exception as e: | |
| raise ValueError(f"Invalid encoded value for key '{k}': {e}") from e | |
| return obj | |
| def to_dict(self) -> Dict[str, Any]: | |
| res = copy.deepcopy(self.data) | |
| for k in SysfsDevice.ENCODED_KEYS: | |
| if k not in res: | |
| continue | |
| if res[k] is not None: | |
| res[k] = b64encode(compress(res[k])).decode("ascii") | |
| else: | |
| del res[k] | |
| return res | |
| def parse_vpd( | |
| vpd: Optional[bytes], | |
| ) -> Tuple[Optional[str], Optional[str], Optional[str]]: | |
| """Parse VPD name, V3 UUID, and serial number (SN)""" | |
| if vpd is None: | |
| return None, None, None | |
| name = None | |
| v3 = None | |
| sn = None | |
| def items(data: bytes) -> Generator[Tuple[int, bytes]]: | |
| while len(data) > 0: | |
| tag = data[0] | |
| if tag in [PCI_VPD_END_SMALL, PCI_VPD_END_LARGE]: | |
| break | |
| if tag & PCI_VPD_LRDT: | |
| offset = 3 | |
| if len(data) < 3: | |
| break | |
| length = int.from_bytes(data[1:3], "little") | |
| else: | |
| offset = 1 | |
| length = tag & 0x07 | |
| if length > len(data) - offset: | |
| break | |
| yield (tag, data[offset : offset + length]) | |
| data = data[offset + length :] | |
| def keywords(data: bytes) -> Generator[Tuple[str, bytes]]: | |
| while len(data) >= 4: | |
| length = int(data[2]) | |
| if length > len(data) - 3: | |
| break | |
| yield (data[:2].decode("ascii"), data[3 : 3 + length]) | |
| data = data[3 + length :] | |
| try: | |
| for tag, item in items(vpd): | |
| if tag == PCI_VPD_LRDT_ID: | |
| name = item.decode("ascii").strip() | |
| if tag == PCI_VPD_LRDT_RO: | |
| for keyword, value in keywords(item): | |
| if keyword == "V3": | |
| v3 = value.decode("ascii") | |
| elif keyword == "SN": | |
| sn = value.decode("ascii") | |
| except UnicodeDecodeError: | |
| pass | |
| return (v3, name, sn) | |
| def parse_ext_cap(config: bytes, cap_id: int) -> Optional[bytes]: | |
| """Parse an extended capability from the PCI configuration space""" | |
| if len(config) < 0x104: | |
| return None | |
| offset = 0x100 | |
| while offset and offset < len(config) - 4: | |
| header = int.from_bytes(config[offset : offset + 4], "little") | |
| next_offset = (header >> 20) & 0xFFC | |
| if next_offset == 0 or next_offset <= offset + 4 or next_offset > len(config): | |
| next_offset = len(config) | |
| if (header & 0xFFFF) == cap_id: | |
| return config[offset + 4 : next_offset] | |
| if next_offset == len(config): | |
| break | |
| offset = next_offset | |
| return None | |
| def parse_acs_ctrl(config: bytes) -> Optional[int]: | |
| """Parse the ACS control register from the PCI configuration space""" | |
| raw = parse_ext_cap(config, PCI_EXT_CAP_ID_ACS) | |
| if raw is None or len(raw) < 4: | |
| return None | |
| return int.from_bytes(raw[2:4], "little") | |
| def has_ats_cap(config: bytes) -> bool: | |
| """True if the device exposes an ATS capability""" | |
| return parse_ext_cap(config, PCI_EXT_CAP_ID_ATS) is not None | |
| def kernel_acs_isolated(device_type: str) -> str: | |
| if device_type == "vera_rp": | |
| # Advertises P2P Completion Redirect (bit-3) as read-only and disabled. | |
| return "xx101x1" | |
| return "xx111x1" | |
| def PCI_VDEVICE(vendor: str, device_id: int) -> re.Pattern: | |
| """Match a Vendor and device ID""" | |
| vendor_id = pci_vendors[vendor] | |
| return re.compile(rf"^pci:v{vendor_id:08X}d{device_id:08X}.*$") | |
| def PCI_DEVICE_CLASS(cid: int) -> re.Pattern: | |
| """Match by exact programming class using the int coding from the kernel""" | |
| class_id = (cid >> 16) & 0xFF | |
| subclass_id = (cid >> 8) & 0xFF | |
| progif = cid & 0xFF | |
| return re.compile(rf"^pci:.*bc{class_id:02X}sc{subclass_id:02X}i{progif:02X}.*$") | |
| def PCI_NVGPU() -> re.Pattern: | |
| """Match all NVIDIA GPUs""" | |
| vendor_id = pci_vendors["NVIDIA"] | |
| class_id = 0x03 | |
| return re.compile(rf"^pci:v{vendor_id:08X}.*bc{class_id:02X}.*$") | |
| # Table of modalias matches to the device_type string. | |
| # Order is important. The first matching device type is used. | |
| pci_device_types = { | |
| PCI_VDEVICE("NVIDIA", 0x22B1): "grace_rp", # NVIDIA Grace PCI Root Port Bridge | |
| PCI_VDEVICE("NVIDIA", 0x22B2): "grace_rp", # NVIDIA Grace PCI Root Port Bridge | |
| PCI_VDEVICE("NVIDIA", 0x22B8): "grace_rp", # NVIDIA Grace PCI Root Port Bridge | |
| PCI_VDEVICE("NVIDIA", 0x2F95): "vera_rp", # NVIDIA Vera PCIe Root Port Bridge | |
| PCI_VDEVICE("NVIDIA", 0x2F96): "vera_rp", # NVIDIA Vera PCIe Root Port Bridge | |
| PCI_VDEVICE("MELLANOX", 0x1021): "cx_nic", # ConnectX-7 | |
| PCI_VDEVICE("MELLANOX", 0x1023): "cx_nic", # ConnectX-8 | |
| PCI_VDEVICE("MELLANOX", 0x1025): "cx_nic", # ConnectX-9 | |
| PCI_VDEVICE("MELLANOX", 0xA2DC): "bf3_nic", # BlueField-3 | |
| PCI_VDEVICE("MELLANOX", 0x2100): "cx_dma", # ConnectX-8 DMA Controller | |
| PCI_VDEVICE("MELLANOX", 0x197B): "bf3_switch", # USP/DSP of a BF3 switch | |
| PCI_VDEVICE("MELLANOX", 0x197C): "cx_switch", # USP/DSP of a CX switch | |
| PCI_VDEVICE("MELLANOX", 0x1979): "cx_switch", # USP/DSP of a CX switch | |
| PCI_DEVICE_CLASS(0x010802): "nvme", | |
| PCI_NVGPU(): "nvgpu", | |
| PCI_DEVICE_CLASS(0x060400): "bridge", # Generic PCI-PCI bridge / Root Port | |
| } | |
| dump_ignored = [ | |
| PCI_DEVICE_CLASS(0x060000), # Generic system peripheral | |
| PCI_DEVICE_CLASS(0x060100), # ISA bridge | |
| PCI_DEVICE_CLASS(0x080700), # Non-Essential Instrumentation | |
| PCI_DEVICE_CLASS(0x088000), # System peripheral | |
| PCI_DEVICE_CLASS(0x110100), # Performance counters | |
| PCI_DEVICE_CLASS(0x130000), # Non-Essential Instrumentation | |
| ] | |
| class PCIBDF( | |
| collections.namedtuple("PCIBDF", ["segment", "bus", "device", "function"]) | |
| ): | |
| """Bus Device Function for a PCI device""" | |
| def as_pci(self): | |
| return f"{self.segment}:{self.bus}:{self.device}.{self.function}" | |
| def __str__(self): | |
| return self.as_pci() | |
| def __repr__(self): | |
| return f"PCIBDF({self.segment}, {self.bus}, {self.device}, {self.function})" | |
| def to_pcibdf(s: str) -> Optional[PCIBDF]: | |
| g = BDF_RE.match(s) | |
| if not g: | |
| return None | |
| return PCIBDF(*g.groups()) | |
| class PCIDevice(object): | |
| device_type = "" | |
| vpd_v3: Optional[str] = None | |
| vpd_name: Optional[str] = None | |
| vpd_sn: Optional[str] = None | |
| parent: PCIDevice = None | |
| def __init__(self, bdf: PCIBDF, sysfs_device: SysfsDevice): | |
| self.bdf = bdf | |
| self.sysfs_device = sysfs_device | |
| self.iommu_group = self.sysfs_device.iommu_group | |
| self.numa_node = self.sysfs_device.numa_node | |
| self.modalias = self.sysfs_device.modalias | |
| parent = os.path.basename(os.path.dirname(self.sysfs_device.realpath)) | |
| self.parent_bdf = to_pcibdf(parent) | |
| for k, v in pci_device_types.items(): | |
| if k.match(self.modalias): | |
| if self.parent_bdf is None and v == "bridge": | |
| v = "generic_rp" | |
| self.device_type = v | |
| break | |
| self.children: Set[PCIDevice] = set() | |
| self.has_ats = False | |
| def finish_loading(self): | |
| """Do more expensive parsing operations""" | |
| if self.device_type == "cx_nic" or self.device_type == "cx_dma": | |
| self.vpd_v3, self.vpd_name, self.vpd_sn = parse_vpd(self.sysfs_device.vpd) | |
| if "switch" in self.device_type or self.device_type.endswith("_rp"): | |
| self.has_acs = self.get_acs_ctrl() is not None | |
| if self.device_type == "cx_nic": | |
| self.has_ats = has_ats_cap(self.sysfs_device.config) | |
| def iterdownstream(self) -> Generator[PCIDevice, None, None]: | |
| """Iterate over all downstream devices of this device recursively""" | |
| for pdev in self.children: | |
| yield pdev | |
| yield from pdev.iterdownstream() | |
| def iterfulltree(self): | |
| for pdev in self.iterupstream_path(): | |
| if not pdev.parent: | |
| yield from pdev.iterdownstream() | |
| def iterupstream_path(self): | |
| """Iterate over each step along the upstream path from the devices | |
| parent to the root.""" | |
| pdev = self.parent | |
| while pdev: | |
| yield pdev | |
| pdev = pdev.parent | |
| def __repr__(self): | |
| return f"PCIDevice({self.bdf})" | |
| def get_acs_ctrl(self): | |
| """Read the ACS control register from the PCI configuration space""" | |
| return parse_acs_ctrl(self.sysfs_device.config) | |
| def get_subsystems(self): | |
| """Return a list of subsystem the PCI device is connected to""" | |
| return self.sysfs_device.subsystems or {} | |
| class NVCX_Topo: | |
| class NIC(object): | |
| def __init__(self, pfs: Set[PCIDevice]): | |
| assert len(pfs) > 0 | |
| self.pfs = pfs | |
| self.parent = min(pfs, key=lambda pf: pf.bdf).parent | |
| self.vpd_sn = min(pfs, key=lambda pf: pf.bdf).vpd_sn | |
| if not all(pf.parent == self.parent for pf in pfs): | |
| raise TopoUnexpectedError("All PFs of a NIC must have the same parent") | |
| if not all(pf.vpd_sn == self.vpd_sn for pf in pfs): | |
| raise TopoUnexpectedError("All PFs of a NIC must have the same VPD SN") | |
| @property | |
| def primary_pf(self) -> PCIDevice: | |
| return min(self.pfs, key=lambda pf: pf.bdf) | |
| def to_dict(self) -> Dict[str, Any]: | |
| return { | |
| "parent_bdf": str(self.parent.bdf) if self.parent else "UNKNOWN", | |
| "ats": self.primary_pf.has_ats, | |
| "pf_bdfs": [ | |
| str(pf.bdf) for pf in sorted(self.pfs, key=lambda pf: pf.bdf) | |
| ], | |
| } | |
| def str_single_pf(self) -> str: | |
| res = "" | |
| res += f"\tNIC ATS: {yesno(self.primary_pf.has_ats)}\n" | |
| subsystems: Dict[str, Set[str]] = collections.defaultdict(set) | |
| for pf in self.pfs: | |
| for k, v in pf.get_subsystems().items(): | |
| subsystems[k].update(v) | |
| res += print_list("RDMA device", subsystems["infiniband"]) | |
| res += print_list("Net device", subsystems["net"]) | |
| return res[:-1] | |
| def __str__(self) -> str: | |
| parent_bdf = self.parent.bdf if self.parent else "UNKNOWN" | |
| res = f"RDMA NIC Parent={parent_bdf}\n" | |
| res += print_list("NIC PCI device", [str(pf.bdf) for pf in self.pfs]) | |
| res += self.str_single_pf() | |
| return res | |
| class Board(object): | |
| def __init__(self, nics: Set[NVCX_Topo.NIC]): | |
| def nic_sort_key(nic): | |
| if nic.parent: | |
| return (0, nic.parent.bdf) | |
| return (1, nic.primary_pf.bdf) | |
| self.nics = sorted(nics, key=nic_sort_key) | |
| self.sn = next(iter(nics)).vpd_sn | |
| if not all(nic.vpd_sn == self.sn for nic in nics): | |
| raise TopoUnexpectedError("All NICs of a Board must have the same VPD SN") | |
| def to_dict(self) -> Dict[str, Any]: | |
| return { | |
| "board_sn": self.sn or "UNKNOWN", | |
| "nics": [nic.to_dict() for nic in self.nics], | |
| } | |
| def __str__(self) -> str: | |
| board_sn = self.sn or "UNKNOWN" | |
| res = f"RDMA NIC Board={board_sn}\n" | |
| for nic in self.nics: | |
| res += textwrap.indent(str(nic), "\t") | |
| res += "\n" | |
| return res[:-1] | |
| def __init__(self, pfs: Set[PCIDevice]): | |
| assert len(pfs) > 0 | |
| pfs_by_sn: Dict[Optional[str], Set[PCIDevice]] = collections.defaultdict(set) | |
| for pf in pfs: | |
| pfs_by_sn[pf.vpd_sn].add(pf) | |
| boards = set() | |
| for board_pfs in pfs_by_sn.values(): | |
| pfs_by_nic: Dict[Optional[PCIDevice], Set[PCIDevice]] = ( | |
| collections.defaultdict(set) | |
| ) | |
| for pf in board_pfs: | |
| pfs_by_nic[pf.parent].add(pf) | |
| nics: Set[NVCX_Topo.NIC] = set() | |
| for nic_pfs in pfs_by_nic.values(): | |
| nics.add(NVCX_Topo.NIC(nic_pfs)) | |
| boards.add(NVCX_Topo.Board(nics)) | |
| self.boards = sorted(boards, key=lambda b: b.sn or "") | |
| @property | |
| def primary_pf(self) -> PCIDevice: | |
| return min(self.pfs, key=lambda pf: pf.bdf) | |
| @property | |
| def pfs(self) -> Set[PCIDevice]: | |
| return set(pf for board in self.boards for nic in board.nics for pf in nic.pfs) | |
| def to_dict(self) -> Dict[str, Any]: | |
| if len(self.pfs) == 1: | |
| return { | |
| "rdma_nic_pf_bdf": str(next(iter(self.pfs)).bdf), | |
| "rdma_nic_ats": next(iter(self.pfs)).has_ats, | |
| } | |
| return { | |
| "rdma_nic_boards": [board.to_dict() for board in self.boards] | |
| } | |
| def topo_str_key(self) -> str: | |
| if len(self.pfs) == 1: | |
| return f"RDMA NIC={next(iter(self.pfs)).bdf}" | |
| return "" | |
| def topo_str(self) -> str: | |
| if len(self.pfs) == 1: | |
| return self.boards[0].nics[0].str_single_pf() | |
| res = "" | |
| for board in self.boards: | |
| res += textwrap.indent(str(board), "\t") | |
| res += "\n" | |
| return res[:-1] | |
| class NVCX_Complex(ABC): | |
| @property | |
| @abstractmethod | |
| def primary_nic(self) -> PCIDevice: | |
| """Primary ConnectX PF for this complex.""" | |
| pass | |
| @abstractmethod | |
| def compute_acs(self, virt: Optional[bool]) -> Dict[PCIDevice, str]: | |
| """Computes the ACS values for this complex. | |
| Used to implement commands which check and/or set ACS values. | |
| """ | |
| pass | |
| @abstractmethod | |
| def to_dict(self) -> Dict[str, Any]: | |
| """Returns a JSON-serializable dictionary which represents this complex. | |
| Used to implement topology dump command with `-j / --json` flag. | |
| Output format should be maintained for backwards compatibility. | |
| """ | |
| pass | |
| @abstractmethod | |
| def check(self, virt: Optional[bool]) -> bool: | |
| """Runs additional checks on this complex. | |
| Returns True if all checks pass, False otherwise. | |
| Used to implement the `check` command. | |
| """ | |
| pass | |
| @abstractmethod | |
| def __str__(self) -> str: | |
| """Returns a string representation of this complex. | |
| Used to implement the `topo` command. | |
| """ | |
| pass | |
| class NVCX_DMA_Complex(NVCX_Complex): | |
| """Hold the related PCI functions together. A complex includes a CX PF, a CX | |
| DMA function, an GPU and related PCI switches in the DMA function | |
| segment.""" | |
| def __init__(self, cx_pfs: Set[PCIDevice], cx_dma: PCIDevice, nvgpu: PCIDevice): | |
| self.cx_pfs = cx_pfs - {cx_dma} | |
| self.cx_pf = sorted(self.cx_pfs, key=lambda x: x.bdf)[0] | |
| self.cx_dma = cx_dma | |
| self.nvgpu = nvgpu | |
| # Identify the switch ports that are part of the shared path that | |
| # handles the P2P traffic | |
| self.shared_usp = self.__find_shared_usp() | |
| for pdev in self.cx_dma.iterupstream_path(): | |
| if pdev in self.shared_usp.children: | |
| self.cx_dma_dsp = pdev | |
| for pdev in self.nvgpu.iterupstream_path(): | |
| if pdev in self.shared_usp.children: | |
| self.nvgpu_dsp = pdev | |
| # There can be a NVMe device connected to the CX NIC as well. For NVMe | |
| # it is best to match with GPUs on the same socket, so a NUMA aware | |
| # approach would be fine, but also the GPU/NIC/NVMe could be | |
| # consistently paired based on the physical layout. | |
| self.nvmes: Set[PCIDevice] = set() | |
| for pdev in self.cx_pf.iterfulltree(): | |
| if pdev.device_type == "nvme": | |
| self.nvmes.add(pdev) | |
| @property | |
| def primary_nic(self) -> PCIDevice: | |
| return self.cx_pf | |
| def __find_shared_usp(self) -> PCIDevice: | |
| """Find the USP that is shared by both devices, the immediate downstream | |
| bus is the point in the topology where P2P traffic will switch from an | |
| upstream to downstream direction.""" | |
| common_path = set(self.cx_dma.iterupstream_path()).intersection( | |
| set(self.nvgpu.iterupstream_path()) | |
| ) | |
| assert common_path | |
| for pdev in self.cx_dma.iterupstream_path(): | |
| if pdev in common_path: | |
| assert pdev.device_type == "cx_switch" | |
| for i in pdev.children: | |
| assert i.device_type == "cx_switch" | |
| return pdev | |
| def compute_acs(self, _: Optional[bool]) -> Dict[PCIDevice, str]: | |
| acs: Dict[PCIDevice, str] = {} | |
| # For the DSP in the shared switch toward the CX8 DMA Direct interface: | |
| # Enable these bits: | |
| # bit-4 : ACS Upstream Forwarding | |
| # bit-3 : ACS P2P Completion Redirect | |
| # bit-0 : ACS Source Validation | |
| # Disable these bits: | |
| # bit-2 : ACS P2P Request Redirect | |
| assert self.cx_dma_dsp.has_acs | |
| acs[self.cx_dma_dsp] = "xx110x1" | |
| # For the DSP in the shared switch toward the GPU: | |
| # Enable the following bits: | |
| # bit-4 : ACS Upstream Forwarding | |
| # bit-2 : ACS P2P Request Redirect | |
| # bit-0 : ACS Source Validation | |
| # Disable the following bits: | |
| # bit-3 : ACS P2P Completion Redirect | |
| assert self.nvgpu_dsp.has_acs | |
| acs[self.nvgpu_dsp] = "xx101x1" | |
| # Disable ACS SV on the root port, this forces the entire segment | |
| # into one iommu_group and avoids kernel bugs building groups for | |
| # irregular ACS. | |
| for pdev in self.cx_dma_dsp.iterupstream_path(): | |
| if not pdev.parent: | |
| assert pdev.has_acs | |
| acs[pdev] = "xx111x0" | |
| return acs | |
| def to_dict(self) -> Dict[str, Any]: | |
| res = { | |
| "rdma_nic_pf_bdf": str(self.cx_pf.bdf), | |
| "rdma_dma_bdf": str(self.cx_dma.bdf), | |
| "gpu_bdf": str(self.nvgpu.bdf), | |
| "subsystems": {}, | |
| } | |
| devname = self.cx_pf.vpd_name | |
| if devname: | |
| res["rdma_nic_vpd_name"] = self.cx_pf.vpd_name | |
| if self.cx_pf.numa_node is not None: | |
| res["numa_node"] = self.cx_pf.numa_node | |
| if self.nvmes: | |
| res["nvme_bdf"] = str(next(iter(self.nvmes)).bdf) | |
| for pdev in sorted( | |
| itertools.chain(self.cx_pfs, [self.nvgpu, self.cx_dma], self.nvmes), | |
| key=lambda x: x.bdf, | |
| ): | |
| subsys = pdev.get_subsystems() | |
| if subsys: | |
| res["subsystems"][str(pdev.bdf)] = { | |
| subsys: list(devs) for subsys, devs in subsys.items() | |
| } | |
| return res | |
| def __str__(self): | |
| res = f"RDMA NIC={self.cx_pf.bdf}, GPU={self.nvgpu.bdf}, RDMA DMA Function={self.cx_dma.bdf}\n" | |
| devname = self.cx_pf.vpd_name | |
| if devname: | |
| res += f"\t{devname}\n" | |
| if self.cx_pf.numa_node is not None: | |
| res += f"\tNUMA Node: {self.cx_pf.numa_node}\n" | |
| if len(self.cx_pfs): | |
| res += print_list("NIC PCI device", [str(I.bdf) for I in self.cx_pfs]) | |
| subsystems: Dict[str, Set[str]] = collections.defaultdict(set) | |
| for pdev in itertools.chain(self.cx_pfs, [self.nvgpu, self.cx_dma], self.nvmes): | |
| for k, v in pdev.get_subsystems().items(): | |
| subsystems[k].update(v) | |
| res += print_list("RDMA device", subsystems["infiniband"]) | |
| res += print_list("Net device", subsystems["net"]) | |
| res += print_list("DRM device", subsystems["drm"]) | |
| res += print_list("NVMe device", subsystems["nvme"]) | |
| return res[:-1] | |
| def check(self, _: Optional[bool]) -> bool: | |
| # Correct iommu_groups are required to avoid NVreg_GrdmaPciTopoCheckOverride | |
| if ( | |
| self.cx_dma.iommu_group == self.nvgpu.iommu_group | |
| and self.cx_dma.iommu_group is not None | |
| ): | |
| check_ok( | |
| f"Kernel iommu_group for DMA {self.cx_dma.bdf} and GPU {self.nvgpu.bdf} are both {self.cx_dma.iommu_group}" | |
| ) | |
| return True | |
| check_fail( | |
| f"Kernel iommu_group for DMA {self.cx_dma.bdf} and GPU {self.nvgpu.bdf} are not equal {self.cx_dma.iommu_group} != {self.nvgpu.iommu_group}" | |
| ) | |
| return False | |
| class NVCX_Inline_Complex(NVCX_Complex): | |
| def __init__( | |
| self, | |
| root_port: PCIDevice, | |
| shared_usp: PCIDevice, | |
| cx_pf: PCIDevice, | |
| nvgpu: PCIDevice, | |
| ): | |
| self.root_port = root_port | |
| self.cx_pf = cx_pf | |
| self.nvgpu = nvgpu | |
| self.cx_pf_dsp = None | |
| self.nvgpu_dsp = None | |
| for dsp in shared_usp.children: | |
| for pdev in dsp.iterdownstream(): | |
| if pdev.device_type == "cx_nic": | |
| if self.cx_pf_dsp is not None: | |
| raise TopoNotSupportedError( | |
| f"Multiple CX NIC DSPs under the same shared switch" | |
| ) | |
| self.cx_pf_dsp = dsp | |
| break | |
| if pdev.device_type == "nvgpu": | |
| if self.nvgpu_dsp is not None: | |
| raise TopoNotSupportedError( | |
| f"Multiple GPU DSPs under the same shared switch" | |
| ) | |
| self.nvgpu_dsp = dsp | |
| break | |
| if not self.cx_pf_dsp: | |
| raise TopoUnexpectedError(f"CX NIC DSP not found in the topology") | |
| if not self.nvgpu_dsp: | |
| raise TopoUnexpectedError(f"GPU DSP not found in the topology") | |
| @property | |
| def primary_nic(self) -> PCIDevice: | |
| return self.cx_pf | |
| def compute_acs(self, virt: Optional[bool]) -> Dict[PCIDevice, str]: | |
| if not self.cx_pf_dsp.has_acs: | |
| raise TopoUnexpectedError(f"CX NIC DSP {self.cx_pf_dsp.bdf} lacks ACS") | |
| if not self.nvgpu_dsp.has_acs: | |
| raise TopoUnexpectedError(f"GPU DSP {self.nvgpu_dsp.bdf} lacks ACS") | |
| if not self.root_port.has_acs: | |
| raise TopoUnexpectedError(f"Root port {self.root_port.bdf} lacks ACS") | |
| if virt is None: | |
| raise TopoUnexpectedError("Could not determine virt mode") | |
| if virt: | |
| return { | |
| # The DSPs of the NIC which is non DD in the shared switch should | |
| # have the following enabled: | |
| # bit-6 : ACS Direct Translated P2P | |
| # bit-4 : ACS Upstream Forwarding | |
| # bit-3 : ACS P2P Completion Redirect | |
| # bit-2 : ACS P2P Request Redirect | |
| # bit-0 : ACS Source Validation | |
| self.cx_pf_dsp: "1x111x1", | |
| # The DSPs of the GPU in the shared switch and the RP of the NIC/GPU | |
| # should have the following enabled, matching the kernel default: | |
| # bit-4 : ACS Upstream Forwarding | |
| # bit-3 : ACS P2P Completion Redirect | |
| # bit-2 : ACS P2P Request Redirect | |
| # bit-0 : ACS Source Validation | |
| self.nvgpu_dsp: kernel_acs_isolated(self.nvgpu_dsp.device_type), | |
| self.root_port: kernel_acs_isolated(self.root_port.device_type), | |
| } | |
| else: | |
| return { | |
| # The DSPs of both the NIC and GPU in the shared switch and | |
| # RPs of the NIC/GPU should have the following disabled: | |
| # bit-4 : ACS Upstream Forwarding | |
| # bit-3 : ACS P2P Completion Redirect | |
| # bit-2 : ACS P2P Request Redirect | |
| # bit-0 : ACS Source Validation | |
| self.cx_pf_dsp: "xx000x0", | |
| self.nvgpu_dsp: "xx000x0", | |
| self.root_port: "xx000x0", | |
| } | |
| def to_dict(self) -> Dict[str, Any]: | |
| res = { | |
| "rdma_nic_pf_bdf": str(self.cx_pf.bdf), | |
| "gpu_bdf": str(self.nvgpu.bdf), | |
| "subsystems": {}, | |
| } | |
| devname = self.cx_pf.vpd_name | |
| if devname: | |
| res["rdma_nic_vpd_name"] = self.cx_pf.vpd_name | |
| if self.cx_pf.numa_node is not None: | |
| res["numa_node"] = self.cx_pf.numa_node | |
| res["rdma_nic_ats"] = self.cx_pf.has_ats | |
| for pdev in sorted( | |
| itertools.chain([self.cx_pf, self.nvgpu]), | |
| key=lambda x: x.bdf, | |
| ): | |
| subsys = pdev.get_subsystems() | |
| if subsys: | |
| res["subsystems"][str(pdev.bdf)] = { | |
| subsys: list(devs) for subsys, devs in subsys.items() | |
| } | |
| return res | |
| def __check_ats(self, virt: bool) -> bool: | |
| status = "available" if self.cx_pf.has_ats else "not available" | |
| msg = f"ATS capability for {self.cx_pf.device_type} {self.cx_pf.bdf} is {status}" | |
| if self.cx_pf.has_ats != virt: | |
| check_fail(msg) | |
| return False | |
| check_ok(msg) | |
| return True | |
| def __check_iommu_group(self, virt: bool) -> bool: | |
| cxpf = f"{self.cx_pf.device_type} {self.cx_pf.bdf}" | |
| nvgpu = f"{self.nvgpu.device_type} {self.nvgpu.bdf}" | |
| prefix = f"Kernel iommu_group for {cxpf} and {nvgpu}" | |
| equal = f"equal {self.cx_pf.iommu_group} == {self.nvgpu.iommu_group}" | |
| not_equal = f"not equal {self.cx_pf.iommu_group} != {self.nvgpu.iommu_group}" | |
| if virt: | |
| if self.cx_pf.iommu_group is None: | |
| check_fail(f"Kernel iommu_group is missing for {cxpf}") | |
| return False | |
| if self.nvgpu.iommu_group is None: | |
| check_fail(f"Kernel iommu_group is missing for {nvgpu}") | |
| return False | |
| if self.cx_pf.iommu_group == self.nvgpu.iommu_group: | |
| check_fail(f"{prefix} are {equal}") | |
| return False | |
| check_ok(f"{prefix} are {not_equal}") | |
| return True | |
| else: | |
| if self.cx_pf.iommu_group is None and self.nvgpu.iommu_group is None: | |
| check_ok(f"{prefix} are not set") | |
| return True | |
| if self.cx_pf.iommu_group != self.nvgpu.iommu_group: | |
| check_fail(f"{prefix} are {not_equal}") | |
| return False | |
| check_ok(f"{prefix} are {equal}") | |
| return True | |
| def check(self, virt: Optional[bool]) -> bool: | |
| assert virt is not None | |
| res_ats = self.__check_ats(virt) | |
| res_iommu_group = self.__check_iommu_group(virt) | |
| return res_ats and res_iommu_group | |
| def __str__(self): | |
| res = f"RDMA NIC={self.cx_pf.bdf}, GPU={self.nvgpu.bdf}\n" | |
| devname = self.cx_pf.vpd_name | |
| if devname: | |
| res += f"\t{devname}\n" | |
| if self.cx_pf.numa_node is not None: | |
| res += f"\tNUMA Node: {self.cx_pf.numa_node}\n" | |
| res += f"\tNIC ATS: {yesno(self.cx_pf.has_ats)}\n" | |
| subsystems: Dict[str, Set[str]] = collections.defaultdict(set) | |
| for pdev in [self.cx_pf, self.nvgpu]: | |
| for k, v in pdev.get_subsystems().items(): | |
| subsystems[k].update(v) | |
| res += print_list("RDMA device", subsystems["infiniband"]) | |
| res += print_list("Net device", subsystems["net"]) | |
| res += print_list("DRM device", subsystems["drm"]) | |
| res += print_list("NVMe device", subsystems["nvme"]) | |
| return res[:-1] | |
| class NVCX_NUMA_Complex(NVCX_Complex): | |
| def __init__( | |
| self, | |
| numa_node: int, | |
| cx_pfs: Set[PCIDevice], | |
| nvgpus: Set[PCIDevice], | |
| ): | |
| if not cx_pfs: | |
| raise TopoUnexpectedError("No CX NICs found in NUMA-based complex") | |
| if not nvgpus: | |
| raise TopoUnexpectedError("No GPUs found in NUMA-based complex") | |
| self.numa_node = numa_node | |
| self.nvgpus = nvgpus | |
| self.cx_topo = NVCX_Topo(cx_pfs) | |
| @property | |
| def primary_nic(self) -> PCIDevice: | |
| return self.cx_topo.primary_pf | |
| def compute_acs(self, _: Optional[bool]) -> Dict[PCIDevice, str]: | |
| # All are connected to RPs, which are handled by PCITopo.compute_acs | |
| return {} | |
| def to_dict(self) -> Dict[str, Any]: | |
| res = { | |
| "numa_node": self.numa_node, | |
| "gpu_bdfs": [str(I.bdf) for I in self.nvgpus], | |
| "subsystems": {}, | |
| } | |
| res.update(self.cx_topo.to_dict()) | |
| devname = self.cx_topo.primary_pf.vpd_name | |
| if devname: | |
| res["rdma_nic_vpd_name"] = devname | |
| for pdev in sorted( | |
| itertools.chain(self.cx_topo.pfs, self.nvgpus), | |
| key=lambda x: x.bdf, | |
| ): | |
| subsystems = pdev.get_subsystems() | |
| if subsystems: | |
| res["subsystems"][str(pdev.bdf)] = { | |
| subsys: list(devs) for subsys, devs in subsystems.items() | |
| } | |
| return res | |
| def __check_iommu_group(self) -> bool: | |
| devs = [*self.cx_topo.pfs, *self.nvgpus] | |
| iommu_groups = [dev.iommu_group for dev in devs] | |
| devs_without_iommu_group = [dev for dev in devs if dev.iommu_group is None] | |
| if len(devs_without_iommu_group) > 0: | |
| bdfs = ", ".join([str(dev.bdf) for dev in devs_without_iommu_group]) | |
| check_fail( | |
| f"Kernel iommu_group missing for devices on NUMA node {self.numa_node}: {bdfs}" | |
| ) | |
| return False | |
| if len(iommu_groups) == len(set(iommu_groups)): | |
| check_ok( | |
| f"All kernel iommu_groups for NUMA node {self.numa_node} are unique" | |
| ) | |
| return True | |
| duplicate_groups = [ | |
| group for group in iommu_groups if iommu_groups.count(group) > 1 | |
| ] | |
| check_fail( | |
| (f"Multiple devices share the same kernel iommu_group for " | |
| f"NUMA node {self.numa_node}: {duplicate_groups}") | |
| ) | |
| return False | |
| def __check_ats(self) -> bool: | |
| result = True | |
| for cx_pf in self.cx_topo.pfs: | |
| if cx_pf.has_ats: | |
| check_fail( | |
| f"ATS capability for {cx_pf.device_type} {cx_pf.bdf} is available" | |
| ) | |
| result = False | |
| else: | |
| check_ok( | |
| f"ATS capability for {cx_pf.device_type} {cx_pf.bdf} is not available" | |
| ) | |
| return result | |
| def check(self, virt: Optional[bool]) -> bool: | |
| res_ats = self.__check_ats() | |
| res_iommu_group = self.__check_iommu_group() | |
| return res_ats and res_iommu_group | |
| def __str__(self) -> str: | |
| res = f"NUMA Node={self.numa_node}\n" | |
| devname = self.primary_nic.vpd_name | |
| if devname: | |
| res += f"\t{devname}\n" | |
| res += f"{self.cx_topo.topo_str()}\n" | |
| res += print_list("GPU PCI device", [str(I.bdf) for I in self.nvgpus]) | |
| gpu_subsystems: Set[str] = set() | |
| for pdev in self.nvgpus: | |
| subsys = pdev.get_subsystems() | |
| if "drm" in subsys: | |
| gpu_subsystems.update(subsys["drm"]) | |
| if len(gpu_subsystems) > 0: | |
| res += print_list("DRM device", gpu_subsystems) | |
| return res[:-1] | |
| def check_parent(pdev: PCIDevice, parent_type: str): | |
| if not pdev or not pdev.parent: | |
| return None | |
| if pdev.parent.device_type != parent_type: | |
| return None | |
| return pdev.parent | |
| class PCITopo(object): | |
| """Load the PCI topology from sysfs and organize it""" | |
| class TopoType(Enum): | |
| INLINE = "Inline" | |
| DMA = "DMA-based" | |
| NUMA = "NUMA-based" | |
| def __init__( | |
| self, | |
| sysfs_dump: Optional[str] = None, | |
| virt: Optional[bool] = None, | |
| autodetect_virt: bool = True, | |
| ): | |
| if sysfs_dump: | |
| sysfs_devices = self.__parse_dump(sysfs_dump) | |
| else: | |
| sysfs_devices = [SysfsDevice(fn) for fn in os.listdir(DEVDIR)] | |
| self.devices = self.__load_devices(sysfs_devices) | |
| self.nvcxs: List[NVCX_Complex] = [] | |
| self.type = self.__detect_topo_type() | |
| if self.type == self.TopoType.DMA and virt is not None: | |
| raise CommandError( | |
| f"--virt / --no-virt is not supported on {self.type.value} topologies" | |
| ) | |
| self.virt = virt | |
| self._autodetect_virt = autodetect_virt | |
| for pdev in self.devices.values(): | |
| pdev.finish_loading() | |
| self.__build_topo() | |
| def __detect_topo_type(self) -> TopoType: | |
| if any(pdev.device_type == "cx_dma" for pdev in self.devices.values()): | |
| return self.TopoType.DMA | |
| if any( | |
| pdev.device_type == "nvgpu" | |
| and pdev.parent is not None | |
| and pdev.parent.device_type.endswith("_rp") | |
| for pdev in self.devices.values() | |
| ): | |
| return self.TopoType.NUMA | |
| found = { | |
| "cx_switch": False, | |
| "nvgpu": False, | |
| "cx_nic": False, | |
| } | |
| for pdev in self.devices.values(): | |
| if pdev.device_type not in found.keys(): | |
| continue | |
| found[pdev.device_type] = True | |
| if all(found.values()): | |
| return self.TopoType.INLINE | |
| raise TopoNotSupportedError() | |
| def __parse_dump(self, filename: str) -> List[SysfsDevice]: | |
| res: List[SysfsDevice] = [] | |
| try: | |
| with open(filename, "rt") as F: | |
| data = json.load(F) | |
| if not isinstance(data, list): | |
| raise ValueError(f"Expected list, got '{type(data).__name__}'") | |
| num_items = len(data) | |
| for i, item in enumerate(data): | |
| if not isinstance(item, dict): | |
| raise ValueError( | |
| f"Item {i}/{num_items}: Expected dictionary, got '{type(item).__name__}'" | |
| ) | |
| try: | |
| res.append(SysfsDevice.from_dict(item)) | |
| except Exception as e: | |
| raise ValueError(f"Item {i}/{num_items}: {e}") from e | |
| return res | |
| except (json.JSONDecodeError, ValueError) as e: | |
| raise DumpParsingError(str(e)) | |
| except (FileNotFoundError, PermissionError) as e: | |
| raise CommandError(f"Failed to read sysfs dump file: {e}") | |
| def __load_devices(self, sysfs_devices: List[SysfsDevice]): | |
| res: Dict[PCIBDF, PCIDevice] = {} | |
| for sdev in sysfs_devices: | |
| bdf = to_pcibdf(sdev.id) | |
| if not bdf: | |
| continue | |
| assert bdf not in res | |
| res[bdf] = PCIDevice(bdf, sdev) | |
| for pdev in res.values(): | |
| if pdev.parent_bdf and pdev.parent_bdf in res: | |
| pdev.parent = res[pdev.parent_bdf] | |
| pdev.parent.children.add(pdev) | |
| return res | |
| def __get_nvcx_complex(self, cx_dma: PCIDevice): | |
| """Match the topology for the switch complex using a CX DMA function and a | |
| single GPU. It has two nested switches: | |
| RP --> SW -> CX_DMA | |
| -> SW -> GPU | |
| """ | |
| assert cx_dma.device_type == "cx_dma" | |
| if not cx_dma.vpd_v3: | |
| raise TopoUnexpectedError(f"CX DMA function {cx_dma} does not have a VPD V3 UUID") | |
| # The DMA and PF are matched using the UUID from the VPD | |
| cx_pfs = self.vpd_v3s.get(cx_dma.vpd_v3) | |
| if cx_pfs is None: | |
| raise TopoUnexpectedError( | |
| f"CX DMA function {cx_dma} does not have a matching PF, V3 UUID matching failed" | |
| ) | |
| # Path from the DMA to the root port | |
| cx_dma_dsp = check_parent(cx_dma, "cx_switch") | |
| cx_usp = check_parent(cx_dma_dsp, "cx_switch") | |
| grace_rp = check_parent(cx_usp, "grace_rp") | |
| if not grace_rp: | |
| raise TopoUnexpectedError( | |
| f"CX DMA function {cx_dma} has an unrecognized upstream path" | |
| ) | |
| # Path from the GPU to the root port | |
| nvgpus = [ | |
| pdev for pdev in grace_rp.iterdownstream() if pdev.device_type == "nvgpu" | |
| ] | |
| if len(nvgpus) != 1: | |
| raise TopoUnexpectedError(f"CX DMA function {cx_dma} does not have a nearby GPU") | |
| nvgpu = nvgpus[0] | |
| nvgpu_dsp2 = check_parent(nvgpu, "cx_switch") | |
| nvgpu_usp2 = check_parent(nvgpu_dsp2, "cx_switch") | |
| nvgpu_dsp1 = check_parent(nvgpu_usp2, "cx_switch") | |
| if cx_usp != check_parent(nvgpu_dsp1, "cx_switch"): | |
| raise TopoNotSupportedError( | |
| f"CX DMA function {cx_dma} has an unrecognized upstream path from the GPU" | |
| ) | |
| # Sanity check there is nothing unexpected in the topology | |
| alldevs = { | |
| cx_dma, | |
| cx_dma_dsp, | |
| cx_usp, | |
| nvgpu, | |
| nvgpu_dsp2, | |
| nvgpu_usp2, | |
| nvgpu_dsp1, | |
| } | |
| topodevs = set(grace_rp.iterdownstream()) | |
| if alldevs != topodevs: | |
| raise TopoUnexpectedError( | |
| f"CX DMA function {cx_dma} has unexpected PCI devices in the topology" | |
| ) | |
| return NVCX_DMA_Complex(cx_pfs, cx_dma, nvgpu) | |
| def __get_nvcx_inline_complex(self, nvgpu: PCIDevice): | |
| """Match the topology for the inline complex using a GPU and a CX NIC. | |
| RP --> SW -> CX_NIC | |
| -> SW -> GPU | |
| """ | |
| assert nvgpu.device_type == "nvgpu" | |
| nvgpu_dsp2 = check_parent(nvgpu, "cx_switch") | |
| nvgpu_usp2 = check_parent(nvgpu_dsp2, "cx_switch") | |
| nvgpu_dsp1 = check_parent(nvgpu_usp2, "cx_switch") | |
| shared_usp1 = check_parent(nvgpu_dsp1, "cx_switch") | |
| if not shared_usp1: | |
| raise TopoNotSupportedError( | |
| f"GPU {nvgpu.bdf} has an unrecognized upstream path" | |
| ) | |
| for pdev in shared_usp1.iterupstream_path(): | |
| if pdev.device_type == "generic_rp": | |
| root_port = pdev | |
| break | |
| else: | |
| raise TopoUnexpectedError( | |
| f"Could not find root port for shared USP {shared_usp1.bdf}" | |
| ) | |
| for pdev in shared_usp1.iterdownstream(): | |
| if pdev.device_type == "cx_nic": | |
| cx_nic = pdev | |
| break | |
| else: | |
| raise TopoUnexpectedError(f"GPU {nvgpu} does not have a nearby CX NIC") | |
| return NVCX_Inline_Complex(root_port, shared_usp1, cx_nic, nvgpu) | |
| def __get_nvcx_numa_complex(self, numa_node: int): | |
| """Match the topology for the NUMA complex. | |
| All CX NICs and GPUs on the same NUMA node are part of the same complex. | |
| """ | |
| cx_pfs = [ | |
| pdev | |
| for pdev in self.devices.values() | |
| if pdev.numa_node == numa_node and pdev.device_type == "cx_nic" | |
| ] | |
| nvgpus = [ | |
| pdev | |
| for pdev in self.devices.values() | |
| if pdev.numa_node == numa_node and pdev.device_type == "nvgpu" | |
| ] | |
| return NVCX_NUMA_Complex(numa_node, cx_pfs, nvgpus) | |
| def __auto_detect_virt(self) -> bool: | |
| """Auto-detect if virtualization will be used on this system""" | |
| first = self.nvcxs[0].primary_nic.has_ats | |
| if not all(nvcx.primary_nic.has_ats == first for nvcx in self.nvcxs): | |
| raise TopoNotSupportedError( | |
| "Could not auto-detect virtualization. CX NICs have different" | |
| " ATS settings. Try explicitly setting --virt or --no-virt." | |
| ) | |
| return first | |
| def __build_topo(self): | |
| """Collect cross-device information together and build the NVCX_Complex | |
| objects for the cx_dma functions""" | |
| self.vpd_v3s: Dict[str, Set[PCIDevice]] = collections.defaultdict(set) | |
| for pdev in self.devices.values(): | |
| # Many PCI functions may share the same V3 | |
| if pdev.vpd_v3: | |
| self.vpd_v3s[pdev.vpd_v3].add(pdev) | |
| if self.type == self.TopoType.DMA: | |
| for pdev in self.devices.values(): | |
| if pdev.device_type == "cx_dma": | |
| nvcx = self.__get_nvcx_complex(pdev) | |
| self.nvcxs.append(nvcx) | |
| elif self.type == self.TopoType.INLINE: | |
| for pdev in self.devices.values(): | |
| if pdev.device_type == "nvgpu": | |
| nvcx = self.__get_nvcx_inline_complex(pdev) | |
| self.nvcxs.append(nvcx) | |
| elif self.type == self.TopoType.NUMA: | |
| numa_nodes = { | |
| pdev.numa_node | |
| for pdev in self.devices.values() | |
| if pdev.numa_node is not None and pdev.numa_node >= 0 | |
| } | |
| for numa in numa_nodes: | |
| self.nvcxs.append(self.__get_nvcx_numa_complex(numa)) | |
| if len(self.nvcxs) == 0: | |
| raise TopoNotSupportedError( | |
| f"No supported complex found for {self.type.value} topology" | |
| ) | |
| if self.type == self.TopoType.INLINE: | |
| if self.virt is None and self._autodetect_virt: | |
| self.virt = self.__auto_detect_virt() | |
| self.nvcxs.sort(key=lambda x: x.primary_nic.bdf) | |
| def compute_acs(self): | |
| """Return a dictionary of PCI devices and the ACS mask the device should | |
| have""" | |
| acs: Dict[PCIDevice, str] = {} | |
| for nvcx in self.nvcxs: | |
| acs.update(nvcx.compute_acs(self.virt)) | |
| # Enable, using kernel default, or disable ACS on all other CX | |
| # bridges and Grace, Vera RPs based on the virt parameter or if the | |
| # topology is DMA-based or NUMA-based. | |
| # | |
| # To enable (matches kernel default): | |
| # bit-4 : ACS Upstream Forwarding | |
| # bit-3 : ACS P2P Completion Redirect | |
| # bit-2 : ACS P2P Request Redirect | |
| # bit-0 : ACS Source Validation | |
| for pdev in self.devices.values(): | |
| if ( | |
| pdev not in acs | |
| and ( | |
| "switch" in pdev.device_type | |
| or pdev.device_type in ["grace_rp", "vera_rp"] | |
| ) | |
| and pdev.has_acs | |
| ): | |
| acs[pdev] = ( | |
| kernel_acs_isolated(pdev.device_type) | |
| if self.type in [self.TopoType.DMA, self.TopoType.NUMA] or self.virt | |
| else "xx000x0" | |
| ) | |
| return acs | |
| def add_sysfs_dump_argument(parser): | |
| parser.add_argument( | |
| "-F", | |
| "--sysfs-dump-file", | |
| action="store", | |
| default=None, | |
| dest="sysfs_dump", | |
| help="Use a file produced by the rdma_topo dump command as input", | |
| ) | |
| def add_virt_argument(parser: argparse.ArgumentParser) -> None: | |
| parser.add_argument( | |
| "--virt", | |
| action=argparse.BooleanOptionalAction, | |
| default=None, | |
| dest="virt", | |
| help="Whether virtualization will be used on this system. Auto-detect if not set.", | |
| ) | |
| # ------------------------------------------------------------------- | |
| def print_list(title: str, items: list[str]): | |
| if not items: | |
| return "" | |
| if len(items) > 1: | |
| title = title + "s" | |
| list_str = ", ".join(sorted(items)) | |
| return f"\t{title}: {list_str}\n" | |
| def args_topology(parser): | |
| parser.add_argument( | |
| "-j", | |
| "--json", | |
| action="store_true", | |
| dest="json", | |
| help="Output in machine readable JSON format", | |
| ) | |
| add_sysfs_dump_argument(parser) | |
| def topo_json(topo: PCITopo): | |
| import json | |
| jtop = [] | |
| for nvcx in topo.nvcxs: | |
| jtop.append(nvcx.to_dict()) | |
| print(json.dumps(jtop, indent=4)) | |
| def cmd_topology(args): | |
| """List the ConnectX NICs in the system with the corresponding NIC | |
| function, associated GPU, and, optionally, DMA Direct function.""" | |
| topo = PCITopo(args.sysfs_dump, virt=None, autodetect_virt=False) | |
| if args.json: | |
| return topo_json(topo) | |
| for nvcx in topo.nvcxs: | |
| print(nvcx) | |
| cmd_topology.__aliases__ = ("topo",) | |
| # ------------------------------------------------------------------- | |
| def update_file(fn: str, new_content: str): | |
| """Make fn have new_content. If fn already has new_content nothing is | |
| done.""" | |
| try: | |
| with open(fn, "rt") as F: | |
| old = F.read() | |
| if old == new_content: | |
| return False | |
| except FileNotFoundError: | |
| pass | |
| with tempfile.NamedTemporaryFile(dir=os.path.dirname(fn), mode="wt") as F: | |
| F.write(new_content) | |
| F.flush() | |
| os.chmod(F.name, 0o644) | |
| try: | |
| os.link(F.name, fn) | |
| except FileExistsError: | |
| os.unlink(fn) | |
| os.link(F.name, fn) | |
| return True | |
| def args_write_grub_acs(parser): | |
| parser.add_argument( | |
| "-n", | |
| "--dry-run", | |
| action="store_true", | |
| dest="dry_run", | |
| help="Output the grub configuration to stdout and make no changes", | |
| ) | |
| parser.add_argument( | |
| "--output", | |
| action="store", | |
| default="/etc/default/grub.d/config-acs.cfg", | |
| help="Grub dropin file to use for the kernel command line", | |
| ) | |
| add_virt_argument(parser) | |
| def cmd_write_grub_acs(args): | |
| """Generate a grub dropin file to have the kernel commandline set the | |
| required ACS flags during system boot. This is the recommended way to | |
| configure ACS on systems but requires a compatible kernel. | |
| If the system does not have any need of ACS flags the dropin file will be | |
| removed. This command is intended for Debian style systems with a | |
| /etc/default/grub.d and update-grub command.""" | |
| try: | |
| topo = PCITopo(None, args.virt) | |
| except TopoNotSupportedError as e: | |
| if args.dry_run: | |
| raise | |
| if os.path.exists(args.output): | |
| os.unlink(args.output) | |
| raise CommandError( | |
| (f"Found ACS drop-in file {args.output} but the system does " | |
| "not have a supported topology. File deleted."), | |
| CommandError.Level.WARNING, | |
| ) | |
| return | |
| acs = topo.compute_acs() | |
| config_acs = [ | |
| f"{acs}@{pdev.bdf}" | |
| for pdev, acs in sorted(acs.items(), key=lambda x: x[0].bdf) | |
| if acs != kernel_acs_isolated(pdev.device_type) | |
| ] | |
| acs_arg = ";".join(config_acs) | |
| grub_conf = [ | |
| f"# Generated by {sys.argv[0]} do not change. ACS settings for RDMA GPU Direct", | |
| f'GRUB_CMDLINE_LINUX="$GRUB_CMDLINE_LINUX pci=config_acs=\\"{acs_arg}\\""', | |
| ] | |
| grub_conf = "\n".join(grub_conf) | |
| if args.dry_run: | |
| print(grub_conf) | |
| return | |
| try: | |
| os.makedirs(os.path.dirname(args.output)) | |
| except FileExistsError: | |
| pass | |
| if update_file(args.output, grub_conf + "\n"): | |
| subprocess.check_call(["update-grub"]) | |
| # ------------------------------------------------------------------- | |
| def combine_acs(cur_acs, new_acs): | |
| for idx, val in enumerate(new_acs[::-1]): | |
| if val == "1": | |
| cur_acs = cur_acs | (1 << idx) | |
| elif val == "0": | |
| cur_acs = cur_acs & (0xFFFF ^ (1 << idx)) | |
| return cur_acs | |
| def args_setpci_acs(parser): | |
| parser.add_argument( | |
| "-n", | |
| "--dry-run", | |
| action="store_true", | |
| dest="dry_run", | |
| help="Output the setpci commands to stdout and make no changes", | |
| ) | |
| add_virt_argument(parser) | |
| def cmd_setpci_acs(args): | |
| """Execute a series of set_pci commands that will immediately change the ACS | |
| settings to the required values. This is compatible with older kernels, but | |
| is not recommended. The kernel must boot with ACS enabled and the GPU driver | |
| must have the NVreg_GrdmaPciTopoCheckOverride=1 reg key set to disable | |
| safety checks that old kernels cannot support. | |
| NOTE: In this configuration unprivileged userspace can trigger platform RAS | |
| failures, use with caution! | |
| """ | |
| topo = PCITopo(None, args.virt) | |
| acs = topo.compute_acs() | |
| cmds: List[List[str]] = [] | |
| for pdev, acs in sorted(acs.items(), key=lambda x: x[0].bdf): | |
| cur_acs = pdev.get_acs_ctrl() | |
| if cur_acs is None: | |
| raise CommandError( | |
| f"Could not read ACS control register for {pdev.device_type} {pdev.bdf}" | |
| ) | |
| new_acs = combine_acs(cur_acs, acs) | |
| if new_acs == cur_acs: | |
| continue | |
| cmd = ["setpci", "-r", "-s", str(pdev.bdf), f"ECAP_ACS+0x6.w={new_acs:04x}"] | |
| cmds.append(cmd) | |
| if args.dry_run: | |
| for cmd in cmds: | |
| print(" ".join(cmd)) | |
| return | |
| for cmd in cmds: | |
| subprocess.check_call(cmd) | |
| # ------------------------------------------------------------------- | |
| def args_check(parser): | |
| add_sysfs_dump_argument(parser) | |
| add_virt_argument(parser) | |
| def check_ok(msg: str): | |
| print(f"OK\t{msg}") | |
| def check_fail(msg: str): | |
| print(f"FAIL\t{msg}") | |
| def cmd_check(args): | |
| """Check that the running kernel and PCI environment are setup correctly for | |
| GPU Direct with ConnectX DMA Direct PCI functions.""" | |
| topo = PCITopo(args.sysfs_dump, args.virt) | |
| if topo.type == PCITopo.TopoType.DMA: | |
| check_ok("All ConnectX DMA functions have correct PCI topology") | |
| elif topo.type == PCITopo.TopoType.INLINE: | |
| check_ok("All NIC/GPU complexes have correct PCI topology") | |
| elif topo.type == PCITopo.TopoType.NUMA: | |
| check_ok("All NUMA-based complexes have correct PCI topology") | |
| fatal = False | |
| acs = topo.compute_acs() | |
| for pdev, acs in sorted(acs.items(), key=lambda x: x[0].bdf): | |
| cur_acs = pdev.get_acs_ctrl() | |
| if cur_acs is None: | |
| check_fail( | |
| f"Could not read ACS control register for {pdev.device_type} {pdev.bdf}" | |
| ) | |
| fatal = True | |
| continue | |
| new_acs = combine_acs(cur_acs, acs) | |
| if new_acs == cur_acs: | |
| check_ok( | |
| f"ACS for {pdev.device_type} {pdev.bdf} has correct values {cur_acs:07b} = {acs}" | |
| ) | |
| else: | |
| check_fail( | |
| f"ACS for {pdev.device_type} {pdev.bdf} has incorrect values {cur_acs:07b} != {acs}, (0x{cur_acs:x} != 0x{new_acs:x})" | |
| ) | |
| fatal = True | |
| for nvcx in topo.nvcxs: | |
| if not nvcx.check(topo.virt): | |
| fatal = True | |
| if fatal: | |
| sys.exit(100) | |
| # ------------------------------------------------------------------- | |
| def args_dump(parser): | |
| pass | |
| def cmd_dump(args) -> None: | |
| """Dump the PCI topology to a file that can be used as input""" | |
| sd_json: List[Dict[str, Any]] = [] | |
| for fn in sorted(os.listdir(DEVDIR)): | |
| sd = SysfsDevice(fn) | |
| if any(d.match(sd.modalias) for d in dump_ignored): | |
| continue | |
| sd_json.append(sd.to_dict()) | |
| json.dump(sd_json, sys.stdout, indent=4) | |
| # ------------------------------------------------------------------- | |
| def load_all_commands(name): | |
| module = importlib.import_module(name) | |
| for k in dir(module): | |
| fn = getattr(module, k) | |
| argsfn = getattr(module, "args_" + k[4:], None) | |
| if argsfn is None or not k.startswith("cmd_") or not inspect.isfunction(fn): | |
| continue | |
| yield (k, fn, argsfn) | |
| def get_cmd_aliases(fn): | |
| if hasattr(fn, "__aliases__"): | |
| return fn.__aliases__ | |
| return () | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| description="""NVIDIA ConnectX GPU Direct ACS tool for Direct NIC platforms | |
| This tool is used to view and control the PCI Access Control Flags (ACS) related | |
| to the Direct NIC topology on supported NVIDIA platforms with ConnectX and | |
| Blackwell family GPUs. | |
| Direct NIC platforms have a unique multipath PCI topology where the ConnectX | |
| has a main PCI function and a related DMA Direct function linked to the GPU. | |
| This platform requires specific ACS flags in the PCI topology for reliable | |
| operation, this tool helps users generate ACS settings for the local system. | |
| """, | |
| ) | |
| subparsers = parser.add_subparsers(title="Sub Commands", dest="command") | |
| subparsers.required = True | |
| commands = [I for I in load_all_commands(__name__)] | |
| commands.sort() | |
| # build sub parsers for all the loaded commands | |
| for k, fn, argsfn in commands: | |
| sparser = subparsers.add_parser( | |
| k[4:].replace("_", "-"), aliases=get_cmd_aliases(fn), help=fn.__doc__ | |
| ) | |
| sparser.required = True | |
| argsfn(sparser) | |
| sparser.set_defaults(func=fn) | |
| try: | |
| import argcomplete | |
| argcomplete.autocomplete(parser) | |
| except ImportError: | |
| pass | |
| # argparse will set 'func' to the cmd_* that executes this command | |
| args = parser.parse_args() | |
| try: | |
| args.func(args) | |
| except CommandError as e: | |
| print(e) | |
| sys.exit(100 if e.level == CommandError.Level.ERROR else 0) | |
| main() |