rocm-bootstrap 0.1.0__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.
@@ -0,0 +1,61 @@
1
+ """rocm-bootstrap: GPU detection and target groupings for the ROCm Python ecosystem.
2
+
3
+ This package provides:
4
+ - Canonical GFX target hierarchy (family/sub-family/target)
5
+ - Pure-Python GPU detection via Linux sysfs
6
+ - Python dist-safe and module-safe naming APIs
7
+ - WheelNext variant plugin for AMD GPU detection
8
+ """
9
+
10
+ from rocm_bootstrap.detect import DetectedGpu, detect_gfx_targets, detect_gpus
11
+ from rocm_bootstrap.naming import (
12
+ PackageNames,
13
+ bundle_names,
14
+ device_dist_name,
15
+ device_module_name,
16
+ is_valid_dist_name,
17
+ is_valid_module_name,
18
+ )
19
+ from rocm_bootstrap.targets import (
20
+ ALL_FAMILIES,
21
+ ALL_SUB_FAMILIES,
22
+ ALL_TARGETS,
23
+ GfxTarget,
24
+ PackagingLevel,
25
+ TargetBundle,
26
+ XnackMode,
27
+ all_bundles,
28
+ bundle_for_target,
29
+ lookup_bundle,
30
+ lookup_target,
31
+ packaging_chain,
32
+ parse_gfx_target_version,
33
+ )
34
+
35
+ __all__ = [
36
+ # targets
37
+ "GfxTarget",
38
+ "TargetBundle",
39
+ "PackagingLevel",
40
+ "XnackMode",
41
+ "ALL_FAMILIES",
42
+ "ALL_SUB_FAMILIES",
43
+ "ALL_TARGETS",
44
+ "all_bundles",
45
+ "bundle_for_target",
46
+ "lookup_bundle",
47
+ "lookup_target",
48
+ "packaging_chain",
49
+ "parse_gfx_target_version",
50
+ # naming
51
+ "PackageNames",
52
+ "bundle_names",
53
+ "device_dist_name",
54
+ "device_module_name",
55
+ "is_valid_dist_name",
56
+ "is_valid_module_name",
57
+ # detect
58
+ "DetectedGpu",
59
+ "detect_gpus",
60
+ "detect_gfx_targets",
61
+ ]
@@ -0,0 +1,121 @@
1
+ """Fakeable I/O layer for GPU detection.
2
+
3
+ ALL system I/O that detect.py needs lives here. Tests monkeypatch these
4
+ functions to inject fake sysfs content and clinfo output, so detect.py
5
+ never reads files or runs subprocesses directly.
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # KFD topology
14
+ # ---------------------------------------------------------------------------
15
+
16
+ KFD_TOPOLOGY = Path("/sys/class/kfd/kfd/topology/nodes")
17
+
18
+
19
+ def list_kfd_nodes() -> list[Path]:
20
+ """List /sys/class/kfd/kfd/topology/nodes/* directories.
21
+
22
+ Returns directories sorted by name (node number). Returns an empty
23
+ list if the KFD topology path does not exist.
24
+ """
25
+ if not KFD_TOPOLOGY.is_dir():
26
+ return []
27
+ return sorted(p for p in KFD_TOPOLOGY.iterdir() if p.is_dir() and p.name.isdigit())
28
+
29
+
30
+ def read_kfd_properties(node_path: Path) -> str:
31
+ """Read raw text of a KFD node's ``properties`` file.
32
+
33
+ Args:
34
+ node_path: Path to a KFD topology node directory
35
+ (e.g., ``/sys/class/kfd/kfd/topology/nodes/1``).
36
+
37
+ Returns:
38
+ Raw file content as a string.
39
+
40
+ Raises:
41
+ FileNotFoundError: If the properties file does not exist.
42
+ """
43
+ return (node_path / "properties").read_text()
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # DRM / ip_discovery
48
+ # ---------------------------------------------------------------------------
49
+
50
+ DRM_CLASS = Path("/sys/class/drm")
51
+
52
+
53
+ def list_drm_cards() -> list[Path]:
54
+ """List /sys/class/drm/card* directories (numbered cards only).
55
+
56
+ Returns directories sorted by name. Returns an empty list if the
57
+ DRM class path does not exist.
58
+ """
59
+ if not DRM_CLASS.is_dir():
60
+ return []
61
+ return sorted(
62
+ p
63
+ for p in DRM_CLASS.iterdir()
64
+ if p.is_dir() and p.name.startswith("card") and p.name[4:].isdigit()
65
+ )
66
+
67
+
68
+ def read_ip_discovery_version(card_path: Path) -> tuple[int, int, int] | None:
69
+ """Read GC IP version from ip_discovery sysfs.
70
+
71
+ Looks for ``<card_path>/device/ip_discovery/die/0/GC/0/{major,minor,revision}``.
72
+
73
+ Args:
74
+ card_path: Path to a DRM card directory
75
+ (e.g., ``/sys/class/drm/card1``).
76
+
77
+ Returns:
78
+ ``(major, minor, revision)`` tuple, or ``None`` if the
79
+ ip_discovery path does not exist.
80
+ """
81
+ gc_path = card_path / "device" / "ip_discovery" / "die" / "0" / "GC" / "0"
82
+ try:
83
+ major = int((gc_path / "major").read_text().strip())
84
+ minor = int((gc_path / "minor").read_text().strip())
85
+ revision = int((gc_path / "revision").read_text().strip())
86
+ except (FileNotFoundError, ValueError):
87
+ return None
88
+ return (major, minor, revision)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Windows: clinfo subprocess
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def run_clinfo() -> str:
97
+ """Run ``clinfo`` and return its stdout.
98
+
99
+ Used on Windows where sysfs is not available. Returns empty string
100
+ if clinfo is not found or fails.
101
+ """
102
+ try:
103
+ result = subprocess.run(
104
+ ["clinfo"],
105
+ capture_output=True,
106
+ text=True,
107
+ check=False,
108
+ )
109
+ return result.stdout
110
+ except FileNotFoundError:
111
+ return ""
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Environment variables
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ def get_env(key: str) -> str | None:
120
+ """Read an environment variable. Patchable for tests."""
121
+ return os.environ.get(key)
@@ -0,0 +1,307 @@
1
+ """GPU detection orchestration.
2
+
3
+ Detects AMD GPUs on the current system by reading kernel sysfs interfaces.
4
+ All I/O is delegated to the :mod:`rocm_bootstrap._platform` module so that
5
+ tests can monkeypatch it with fake sysfs content.
6
+
7
+ Detection chain:
8
+ 1. ``ROCM_BOOTSTRAP_DISABLE_DETECTION`` → return ``[]``
9
+ 2. ``ROCM_BOOTSTRAP_FORCE_GFX_ARCH`` → parse forced targets
10
+ 3. KFD topology (``/sys/class/kfd/kfd/topology/nodes/*/properties``)
11
+ 4. ip_discovery (``/sys/class/drm/card*/device/ip_discovery/die/0/GC/0/``)
12
+ 5. Return empty list if all fail
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import sys
19
+ from dataclasses import dataclass
20
+
21
+ from rocm_bootstrap import _platform
22
+ from rocm_bootstrap.targets import (
23
+ GfxTarget,
24
+ lookup_target,
25
+ packaging_chain,
26
+ parse_gfx_target_version,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DetectedGpu:
32
+ """A GPU detected on the system.
33
+
34
+ Attributes:
35
+ target: The :class:`~rocm_bootstrap.targets.GfxTarget` for this GPU.
36
+ node_id: KFD topology node index, or DRM card number.
37
+ gpu_id: KFD ``gpu_id`` property. 0 for CPU-only nodes.
38
+ pci_id: PCI device ID string if available (e.g., ``"0x7550"``).
39
+ """
40
+
41
+ target: GfxTarget
42
+ node_id: int
43
+ gpu_id: int = 0
44
+ pci_id: str | None = None
45
+
46
+
47
+ def detect_gpus() -> list[DetectedGpu]:
48
+ """Detect AMD GPUs on the current system.
49
+
50
+ Returns a list of :class:`DetectedGpu` instances, one per GPU found.
51
+ Returns an empty list if detection is disabled, no GPUs are found,
52
+ or the system lacks the required sysfs interfaces.
53
+
54
+ Environment variables:
55
+ ``ROCM_BOOTSTRAP_DISABLE_DETECTION``:
56
+ Set to ``1`` to skip detection entirely.
57
+ ``ROCM_BOOTSTRAP_FORCE_GFX_ARCH``:
58
+ Comma-separated list of GFX target names to return instead
59
+ of detecting (e.g., ``"gfx942,gfx942"`` for two MI300X GPUs).
60
+ """
61
+ # 1. Check disable flag
62
+ if _platform.get_env("ROCM_BOOTSTRAP_DISABLE_DETECTION") == "1":
63
+ return []
64
+
65
+ # 2. Check forced arch override
66
+ forced = _platform.get_env("ROCM_BOOTSTRAP_FORCE_GFX_ARCH")
67
+ if forced:
68
+ return _parse_forced_targets(forced)
69
+
70
+ # 3. Try KFD topology
71
+ gpus = _detect_via_kfd_topology()
72
+ if gpus:
73
+ return gpus
74
+
75
+ # 4. Try ip_discovery fallback
76
+ gpus = _detect_via_ip_discovery()
77
+ if gpus:
78
+ return gpus
79
+
80
+ # 5. Nothing found
81
+ return []
82
+
83
+
84
+ def detect_gfx_targets() -> list[GfxTarget]:
85
+ """Convenience: detect GPUs and return deduplicated GfxTarget list.
86
+
87
+ Returns unique targets in detection order (first occurrence kept).
88
+ """
89
+ seen: set[str] = set()
90
+ targets: list[GfxTarget] = []
91
+ for gpu in detect_gpus():
92
+ if gpu.target.name not in seen:
93
+ seen.add(gpu.target.name)
94
+ targets.append(gpu.target)
95
+ return targets
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Internal detection methods
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def _parse_forced_targets(forced: str) -> list[DetectedGpu]:
104
+ """Parse ROCM_BOOTSTRAP_FORCE_GFX_ARCH into DetectedGpu list."""
105
+ gpus: list[DetectedGpu] = []
106
+ for i, name in enumerate(forced.split(",")):
107
+ name = name.strip()
108
+ if not name:
109
+ continue
110
+ target = lookup_target(name)
111
+ gpus.append(DetectedGpu(target=target, node_id=i))
112
+ return gpus
113
+
114
+
115
+ def _parse_kfd_properties(text: str) -> dict[str, int]:
116
+ """Parse KFD properties file into a dict of name → int value.
117
+
118
+ Lines are formatted as ``key value`` pairs, one per line. Non-integer
119
+ values are skipped.
120
+ """
121
+ props: dict[str, int] = {}
122
+ for line in text.splitlines():
123
+ parts = line.split()
124
+ if len(parts) == 2:
125
+ try:
126
+ props[parts[0]] = int(parts[1])
127
+ except ValueError:
128
+ continue
129
+ return props
130
+
131
+
132
+ def _detect_via_kfd_topology() -> list[DetectedGpu]:
133
+ """Detect GPUs via KFD topology sysfs nodes."""
134
+ nodes = _platform.list_kfd_nodes()
135
+ if not nodes:
136
+ return []
137
+
138
+ gpus: list[DetectedGpu] = []
139
+ for node_path in nodes:
140
+ try:
141
+ text = _platform.read_kfd_properties(node_path)
142
+ except FileNotFoundError:
143
+ continue
144
+
145
+ props = _parse_kfd_properties(text)
146
+
147
+ # Skip CPU-only nodes (simd_count == 0 indicates no GPU CUs)
148
+ simd_count = props.get("simd_count", 0)
149
+ if simd_count == 0:
150
+ continue
151
+
152
+ gtv = props.get("gfx_target_version", 0)
153
+ if gtv == 0:
154
+ continue
155
+
156
+ target = parse_gfx_target_version(gtv)
157
+
158
+ node_id = int(node_path.name)
159
+ gpu_id = props.get("gpu_id", 0)
160
+ device_id = props.get("device_id")
161
+ pci_id = f"0x{device_id:x}" if device_id is not None else None
162
+
163
+ gpus.append(
164
+ DetectedGpu(
165
+ target=target,
166
+ node_id=node_id,
167
+ gpu_id=gpu_id,
168
+ pci_id=pci_id,
169
+ )
170
+ )
171
+
172
+ return gpus
173
+
174
+
175
+ def _detect_via_ip_discovery() -> list[DetectedGpu]:
176
+ """Detect GPUs via DRM ip_discovery sysfs (fallback)."""
177
+ cards = _platform.list_drm_cards()
178
+ if not cards:
179
+ return []
180
+
181
+ gpus: list[DetectedGpu] = []
182
+ for card_path in cards:
183
+ version = _platform.read_ip_discovery_version(card_path)
184
+ if version is None:
185
+ continue
186
+
187
+ major, minor, revision = version
188
+ gtv = major * 10000 + minor * 100 + revision
189
+ target = parse_gfx_target_version(gtv)
190
+
191
+ card_num = int(card_path.name.removeprefix("card"))
192
+ gpus.append(
193
+ DetectedGpu(
194
+ target=target,
195
+ node_id=card_num,
196
+ )
197
+ )
198
+
199
+ return gpus
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # CLI entry point
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ def main(argv: list[str] | None = None) -> None:
208
+ """CLI entry point for ``rocm-bootstrap-detect``."""
209
+ parser = argparse.ArgumentParser(
210
+ prog="rocm-bootstrap-detect",
211
+ description="Detect AMD GPUs and print target information.",
212
+ )
213
+ group = parser.add_mutually_exclusive_group()
214
+ group.add_argument(
215
+ "--unique",
216
+ "-u",
217
+ action="store_const",
218
+ dest="mode",
219
+ const="unique",
220
+ help="Print unique target names, one per line.",
221
+ )
222
+ group.add_argument(
223
+ "--verbose",
224
+ "-v",
225
+ action="store_const",
226
+ dest="mode",
227
+ const="verbose",
228
+ help="Human-readable output with hierarchy and generic ISA details.",
229
+ )
230
+ group.add_argument(
231
+ "--hierarchy",
232
+ action="store_const",
233
+ dest="mode",
234
+ const="hierarchy",
235
+ help="Print unique bundle hierarchies (target sub-family family).",
236
+ )
237
+ parser.set_defaults(mode="unique")
238
+ args = parser.parse_args(argv)
239
+
240
+ gpus = detect_gpus()
241
+
242
+ if args.mode == "verbose":
243
+ _print_verbose(gpus)
244
+ elif args.mode == "hierarchy":
245
+ _print_hierarchy(gpus)
246
+ else:
247
+ _print_unique(gpus)
248
+
249
+
250
+ def _print_unique(gpus: list[DetectedGpu]) -> None:
251
+ """Machine-consumable: one unique target name per line."""
252
+ for target in _unique_targets(gpus):
253
+ print(target.name)
254
+
255
+
256
+ def _print_hierarchy(gpus: list[DetectedGpu]) -> None:
257
+ """Unique packaging chains, space-separated: target sub_family family."""
258
+ seen: set[str] = set()
259
+ for target in _unique_targets(gpus):
260
+ if target.name in seen:
261
+ continue
262
+ seen.add(target.name)
263
+ chain = packaging_chain(target)
264
+ print(" ".join(b.key for b in chain))
265
+
266
+
267
+ def _print_verbose(gpus: list[DetectedGpu]) -> None:
268
+ """Human-readable output with full details."""
269
+ if not gpus:
270
+ print("No AMD GPUs detected.")
271
+ return
272
+
273
+ print(f"Detected {len(gpus)} AMD GPU(s):\n")
274
+ for gpu in gpus:
275
+ t = gpu.target
276
+ parts = [f" Node {gpu.node_id}: {t.name}"]
277
+ parts.append(f"major={t.major} minor={t.minor} stepping={t.stepping}")
278
+ if gpu.pci_id:
279
+ parts.append(f"PCI={gpu.pci_id}")
280
+ if gpu.gpu_id:
281
+ parts.append(f"gpu_id={gpu.gpu_id}")
282
+ print(" ".join(parts))
283
+
284
+ chain = packaging_chain(t)
285
+ # chain is (target_bundle, sub_family_bundle, family_bundle)
286
+ for bundle in chain[1:]: # skip target-level (already printed)
287
+ label = bundle.level.value.replace("_", "-")
288
+ line = f" {label}: {bundle.key} ({bundle.display_name})"
289
+ if bundle.llvm_generic:
290
+ line += f" generic: {bundle.llvm_generic}"
291
+ print(line)
292
+ print()
293
+
294
+
295
+ def _unique_targets(gpus: list[DetectedGpu]) -> list[GfxTarget]:
296
+ """Deduplicate targets preserving detection order."""
297
+ seen: set[str] = set()
298
+ targets: list[GfxTarget] = []
299
+ for gpu in gpus:
300
+ if gpu.target.name not in seen:
301
+ seen.add(gpu.target.name)
302
+ targets.append(gpu.target)
303
+ return targets
304
+
305
+
306
+ if __name__ == "__main__":
307
+ main()
@@ -0,0 +1,131 @@
1
+ """Python dist-safe and module-safe name generation for GFX targets.
2
+
3
+ This module provides deterministic, validated name generation for all
4
+ levels of the GFX packaging hierarchy. Every generated name is guaranteed
5
+ to be valid for its intended context (pip/wheel dist names or Python
6
+ module identifiers).
7
+
8
+ Naming convention:
9
+ - Bundle ``key`` uses underscores → directly a valid Python identifier.
10
+ - ``dist_name`` replaces ``_`` with ``-`` → valid wheel/package name.
11
+ - ``module_name`` IS the ``key`` → valid Python identifier.
12
+ - All names start with ``gfx`` → no leading digit issues.
13
+ """
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+
18
+ from rocm_bootstrap.targets import TargetBundle
19
+
20
+ # PEP 625 / PyPA: distribution names are lowercase, alphanumeric + hyphens +
21
+ # underscores + periods. Must start with alphanumeric.
22
+ _DIST_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class PackageNames:
27
+ """Dist-safe and module-safe names for a packaging level.
28
+
29
+ Attributes:
30
+ dist_name: Name safe for Python distribution/wheel usage.
31
+ Lowercase, uses hyphens as separators.
32
+ E.g., ``"gfx11-5"``, ``"gfx942"``.
33
+ module_name: Name safe for Python identifiers/modules.
34
+ Uses underscores, no hyphens, no leading digits.
35
+ E.g., ``"gfx11_5"``, ``"gfx942"``.
36
+ """
37
+
38
+ dist_name: str
39
+ module_name: str
40
+
41
+
42
+ def bundle_names(bundle: TargetBundle) -> PackageNames:
43
+ """Get Python-safe package names for a bundle.
44
+
45
+ The bundle's ``key`` is the ``module_name`` (already uses underscores).
46
+ The ``dist_name`` is derived by replacing ``_`` with ``-``.
47
+
48
+ Args:
49
+ bundle: Any :class:`~rocm_bootstrap.targets.TargetBundle`.
50
+
51
+ Returns:
52
+ :class:`PackageNames` with validated dist and module names.
53
+ """
54
+ module_name = bundle.key
55
+ dist_name = module_name.replace("_", "-")
56
+ return PackageNames(dist_name=dist_name, module_name=module_name)
57
+
58
+
59
+ def device_dist_name(prefix: str, bundle: TargetBundle) -> str:
60
+ """Generate a device package distribution name.
61
+
62
+ Combines a dist-name prefix with the bundle's dist name.
63
+
64
+ Examples::
65
+
66
+ device_dist_name("rocm-sdk-device", bundle_gfx11_5)
67
+ # -> "rocm-sdk-device-gfx11-5"
68
+
69
+ device_dist_name("amd-torch-device", bundle_gfx942)
70
+ # -> "amd-torch-device-gfx942"
71
+
72
+ Args:
73
+ prefix: Distribution name prefix (e.g., ``"rocm-sdk-device"``).
74
+ bundle: The :class:`~rocm_bootstrap.targets.TargetBundle`.
75
+
76
+ Returns:
77
+ Combined dist name string.
78
+ """
79
+ names = bundle_names(bundle)
80
+ return f"{prefix}-{names.dist_name}"
81
+
82
+
83
+ def device_module_name(prefix: str, bundle: TargetBundle) -> str:
84
+ """Generate a device package module name.
85
+
86
+ Combines a module-name prefix with the bundle's module name.
87
+
88
+ Examples::
89
+
90
+ device_module_name("rocm_sdk_device", bundle_gfx11_5)
91
+ # -> "rocm_sdk_device_gfx11_5"
92
+
93
+ Args:
94
+ prefix: Module name prefix (e.g., ``"rocm_sdk_device"``).
95
+ bundle: The :class:`~rocm_bootstrap.targets.TargetBundle`.
96
+
97
+ Returns:
98
+ Combined module name string.
99
+ """
100
+ names = bundle_names(bundle)
101
+ return f"{prefix}_{names.module_name}"
102
+
103
+
104
+ def is_valid_dist_name(name: str) -> bool:
105
+ """Check if a string is valid as a Python distribution name.
106
+
107
+ Per PEP 625 / PyPA naming specification: lowercase alphanumeric,
108
+ hyphens, underscores, periods. Must start and end with alphanumeric.
109
+
110
+ Args:
111
+ name: String to validate.
112
+
113
+ Returns:
114
+ ``True`` if valid.
115
+ """
116
+ return bool(_DIST_NAME_RE.match(name))
117
+
118
+
119
+ def is_valid_module_name(name: str) -> bool:
120
+ """Check if a string is valid as a Python module/identifier name.
121
+
122
+ Must be a valid Python identifier: starts with letter or underscore,
123
+ contains only letters, digits, underscores. Must not be a keyword.
124
+
125
+ Args:
126
+ name: String to validate.
127
+
128
+ Returns:
129
+ ``True`` if valid.
130
+ """
131
+ return name.isidentifier()