Files
TheRock/rocm-systems/shared/kpack/python/rocm_kpack/wheel_splitter.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

1656 lines
62 KiB
Python

"""
Wheel splitter for separating host and device code in Python wheels.
This module provides functionality to split a fat Python wheel (e.g., PyTorch
with embedded HIP device code) into:
- A host wheel: original package with device code stripped and kpack refs injected
- Device wheels: one per GPU architecture, containing .kpack archives
The device wheels overlay onto the host package's directory structure so that
at install time, the .kpack/ directory merges with the host package tree.
"""
import hashlib
import json
import os
import re
import shutil
import stat
import time
import zipfile
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass, field
from email.parser import HeaderParser
from pathlib import Path
try:
import rocm_bootstrap
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
"rocm-bootstrap is required for wheel splitting.\n"
"Install it with: pip install rocm-bootstrap"
) from e
from rocm_kpack.artifact_splitter import ExtractedKernel
from rocm_kpack.artifact_utils import (
extract_architecture_from_target,
scan_directory,
)
from rocm_kpack.binutils import BundledBinary, Toolchain
from rocm_kpack.compression import ZstdCompressor
from rocm_kpack.database_handlers import DatabaseHandler, get_database_handlers
from rocm_kpack.kpack import PackedKernelArchive
from rocm_kpack.kpack_transform import (
NotFatBinaryError,
is_fat_binary,
kpack_offload_binary,
)
def _normalize_arch(arch: str) -> str:
"""Normalize architecture name to canonical form (colons for xnack).
Tensile filenames use hyphens (gfx90a-xnack+) while ELF bundle targets
use colons (gfx90a:xnack+). Normalize to colons for consistency.
"""
return arch.replace("-xnack", ":xnack")
def _arch_to_bundle_key(arch: str) -> str:
"""Strip xnack suffix to get the bare architecture (bundle key).
xnack+/- variants collapse into the same device wheel as the bare arch.
The individual kpack files preserve the full arch name.
Examples:
gfx942:xnack+ → gfx942
gfx90a:xnack- → gfx90a
gfx1100 → gfx1100
gfx11 → gfx11
"""
return re.sub(r":xnack[+-]$", "", arch)
class WheelSplitError(Exception):
"""Base error for wheel splitting operations."""
pass
class InvalidWheelError(WheelSplitError):
"""Input is not a valid Python wheel."""
pass
class NoFatBinariesError(WheelSplitError):
"""No fat binaries found in wheel."""
pass
@dataclass
class WheelIdentity:
"""Parsed wheel filename and dist-info fields."""
name: str # e.g., "torch"
version: str # e.g., "2.10.0+rocm7.1"
python_tag: str # e.g., "cp313"
abi_tag: str # e.g., "cp313"
platform_tag: str # e.g., "manylinux_2_28_x86_64"
dist_info_name: str # e.g., "torch-2.10.0+rocm7.1.dist-info"
@dataclass
class FatBinaryInfo:
"""A fat binary found in the wheel."""
absolute_path: Path
overlay_relative_path: str # relative to overlay root, e.g. "lib/libtorch_hip.so"
@dataclass
class SplitResult:
"""Result of a wheel split operation."""
host_wheel_path: Path
variant_wheel_path: Path | None = None
device_wheel_paths: dict[str, Path] = field(default_factory=dict)
architectures_found: list[str] = field(default_factory=list)
fat_binaries_processed: int = 0
@dataclass
class TransformResult:
"""Result of transforming a single fat binary."""
overlay_relative_path: str
original_size: int
new_size: int
skipped: bool
@dataclass
class DatabaseFileRef:
"""A database file detected by a handler."""
absolute_path: Path
overlay_relative_path: str # relative to overlay root
architecture: str # normalized (colons for xnack)
handler_name: str
@dataclass
class ExtractedKernelRef:
"""Lightweight reference to an extracted kernel stored on disk.
Used instead of ExtractedKernel for cross-process communication to avoid
pickling large kernel_data bytes through IPC pipes. The parent process
reads kernel data from disk when building kpack archives.
"""
target_name: str
kernel_file: Path # Path to kernel data on disk
kernel_size: int
source_binary_relpath: str
source_prefix: str
architecture: str
@dataclass
class TransformArgs:
"""Arguments for a single fat binary transformation."""
binary_path: Path
temp_output: Path
search_pattern: str
kernel_name: str
overlay_relative_path: str
def parse_wheel_identity(wheel_root: Path) -> WheelIdentity:
"""Parse wheel identity from the dist-info directory.
Args:
wheel_root: Root directory of the (exploded) wheel.
Returns:
WheelIdentity with parsed fields.
Raises:
InvalidWheelError: If the wheel structure is invalid.
"""
dist_info_dirs = list(wheel_root.glob("*.dist-info"))
if len(dist_info_dirs) != 1:
raise InvalidWheelError(
f"Expected exactly one .dist-info directory in {wheel_root}, "
f"found {len(dist_info_dirs)}"
)
dist_info_dir = dist_info_dirs[0]
dist_info_name = dist_info_dir.name
# Parse METADATA for authoritative Name and Version
metadata_file = dist_info_dir / "METADATA"
if not metadata_file.exists():
raise InvalidWheelError(f"No METADATA file in {dist_info_dir}")
metadata_text = metadata_file.read_text(encoding="utf-8")
parser = HeaderParser()
metadata = parser.parsestr(metadata_text)
name = metadata.get("Name")
version = metadata.get("Version")
if not name or not version:
raise InvalidWheelError(f"METADATA missing Name or Version in {dist_info_dir}")
# Parse WHEEL file for tags
wheel_file = dist_info_dir / "WHEEL"
if not wheel_file.exists():
raise InvalidWheelError(f"No WHEEL file in {dist_info_dir}")
wheel_text = wheel_file.read_text(encoding="utf-8")
wheel_meta = parser.parsestr(wheel_text)
# Get the first Tag: line (there may be multiple for multi-platform wheels)
tag = wheel_meta.get("Tag")
if not tag:
raise InvalidWheelError(f"WHEEL file missing Tag in {dist_info_dir}")
tag_parts = tag.split("-")
if len(tag_parts) != 3:
raise InvalidWheelError(
f"Invalid wheel Tag format '{tag}', expected 'python-abi-platform'"
)
python_tag, abi_tag, platform_tag = tag_parts
return WheelIdentity(
name=name,
version=version,
python_tag=python_tag,
abi_tag=abi_tag,
platform_tag=platform_tag,
dist_info_name=dist_info_name,
)
def compute_wheel_filename(name: str, version: str, identity: WheelIdentity) -> str:
"""Compute a PEP 427 wheel filename.
Args:
name: Distribution name (dashes are normalized to underscores).
version: Version string.
identity: WheelIdentity for tags.
Returns:
Filename like "amd_torch_device_gfx942-2.10.0+rocm7.1-cp313-cp313-manylinux_2_28_x86_64.whl"
"""
normalized_name = name.replace("-", "_")
return (
f"{normalized_name}-{version}"
f"-{identity.python_tag}-{identity.abi_tag}-{identity.platform_tag}.whl"
)
def compute_dist_info_name(name: str, version: str) -> str:
"""Compute dist-info directory name per PEP 427.
Args:
name: Distribution name.
version: Version string.
Returns:
e.g. "amd_torch_device_gfx942-2.10.0+rocm7.1.dist-info"
"""
normalized_name = name.replace("-", "_")
return f"{normalized_name}-{version}.dist-info"
def generate_record(
wheel_root: Path, dist_info_dir_name: str, verbose: bool = False
) -> str:
"""Generate RECORD file content with SHA256 hashes for all files.
Uses streaming hash computation to avoid loading large files into memory.
Args:
wheel_root: Root directory of the wheel.
dist_info_dir_name: Name of the dist-info directory.
verbose: Print progress for large files.
Returns:
RECORD file content as a string.
"""
lines: list[str] = []
for file_path in sorted(wheel_root.rglob("*")):
if not file_path.is_file():
continue
rel_path = file_path.relative_to(wheel_root).as_posix()
# RECORD itself has no hash
if rel_path == f"{dist_info_dir_name}/RECORD":
lines.append(f"{rel_path},,")
continue
size = file_path.stat().st_size
if verbose and size > 50 * 1024 * 1024:
print(f" Hashing: {rel_path} ({size / (1024*1024):.0f} MB)")
# Stream hash to avoid loading entire file into memory
h = hashlib.sha256()
with open(file_path, "rb") as f:
while True:
chunk = f.read(1024 * 1024) # 1MB chunks
if not chunk:
break
h.update(chunk)
lines.append(f"{rel_path},sha256={h.hexdigest()},{size}")
return "\n".join(lines) + "\n"
def _bundle_key_to_dist_name(device_package_prefix: str, bundle_key: str) -> str:
"""Convert a bundle key to a dist package name via rocm_bootstrap.
Args:
device_package_prefix: Package name prefix, e.g. "amd-torch-device".
bundle_key: Bundle key, e.g. "gfx942", "gfx11", "gfx12_0".
Returns:
Dist name, e.g. "amd-torch-device-gfx942", "amd-torch-device-gfx12-0".
"""
bundle = rocm_bootstrap.lookup_bundle(bundle_key)
return rocm_bootstrap.device_dist_name(device_package_prefix, bundle)
def generate_device_metadata(
host_identity: WheelIdentity,
bundle_key: str,
device_package_prefix: str,
device_requires_dist: list[str],
) -> str:
"""Generate METADATA content for a device wheel.
Args:
host_identity: Identity of the host wheel.
bundle_key: Bundle key, e.g. "gfx942", "gfx11", "gfx12_0".
device_package_prefix: Package name prefix, e.g. "amd-torch-device".
device_requires_dist: Additional Requires-Dist entries (may contain @GFXARCH@).
Returns:
METADATA file content.
"""
device_name = _bundle_key_to_dist_name(device_package_prefix, bundle_key)
bundle = rocm_bootstrap.lookup_bundle(bundle_key)
lines = [
"Metadata-Version: 2.4",
f"Name: {device_name}",
f"Version: {host_identity.version}",
f"Summary: AMD device kernels ({bundle.display_name}) for {host_identity.name}",
f"Requires-Dist: {host_identity.name} == {host_identity.version}",
]
for dep in device_requires_dist:
if "@GFXARCH@" in dep and bundle.level != rocm_bootstrap.PackagingLevel.TARGET:
# `@GFXARCH@` deps name per-target packages (e.g.
# rocm-sdk-device-<target>) which are only published for
# PackagingLevel.TARGET bundles. Family/sub-family device wheels
# are co-installed with a target wheel that carries those deps.
continue
expanded = dep.replace("@GFXARCH@", bundle_key)
lines.append(f"Requires-Dist: {expanded}")
return "\n".join(lines) + "\n"
def generate_wheel_file(identity: WheelIdentity) -> str:
"""Generate WHEEL file content for a device wheel.
Args:
identity: WheelIdentity for tags.
Returns:
WHEEL file content.
"""
tag = f"{identity.python_tag}-{identity.abi_tag}-{identity.platform_tag}"
return (
f"Wheel-Version: 1.0\n"
f"Generator: rocm-kpack-split-python-wheels\n"
f"Root-Is-Purelib: false\n"
f"Tag: {tag}\n"
)
def _copy_tree(src: Path, dst: Path) -> None:
"""Copy a directory tree preserving metadata.
Symlinks are preserved as symlinks (not followed).
"""
dst.mkdir(parents=True, exist_ok=True)
for dirpath, dirnames, filenames in os.walk(src):
src_dir = Path(dirpath)
rel = src_dir.relative_to(src)
dst_dir = dst / rel
dst_dir.mkdir(parents=True, exist_ok=True)
for fname in filenames:
src_file = src_dir / fname
dst_file = dst_dir / fname
if src_file.is_symlink():
link_target = os.readlink(src_file)
os.symlink(link_target, dst_file)
else:
shutil.copy2(src_file, dst_file)
def zip_wheel(source_dir: Path, output_path: Path) -> None:
"""Zip a directory into a .whl file.
Args:
source_dir: Root directory to zip.
output_path: Output .whl file path.
"""
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
for file_path in sorted(source_dir.rglob("*")):
if file_path.is_file():
arcname = file_path.relative_to(source_dir).as_posix()
zf.write(file_path, arcname)
def _extract_wheel_preserving_permissions(
wheel_path: Path, destination_dir: Path
) -> None:
"""Extract a wheel while restoring Unix mode bits from zip metadata.
Python's zipfile extraction intentionally does not apply the Unix mode bits
stored in ZipInfo.external_attr. Wheels can contain executables, so restore
those bits before later copy/repack steps preserve the extracted tree.
"""
deferred_directory_modes: list[tuple[Path, int]] = []
with zipfile.ZipFile(wheel_path, "r") as zf:
for member in zf.infolist():
extracted_path = Path(zf.extract(member, destination_dir))
mode = stat.S_IMODE(member.external_attr >> 16)
if mode == 0:
continue
if member.is_dir():
deferred_directory_modes.append((extracted_path, mode))
else:
extracted_path.chmod(mode)
for directory_path, mode in deferred_directory_modes:
if directory_path.exists():
directory_path.chmod(mode)
def _extract_single_binary(
fat_binary_path: Path,
overlay_relative_path: str,
kernel_staging_dir: Path,
) -> list[ExtractedKernelRef]:
"""Extract device kernels from a single fat binary to disk.
Module-level function for use with ProcessPoolExecutor. Writes kernel
data to files under kernel_staging_dir instead of returning bytes,
avoiding large IPC serialization overhead.
Args:
fat_binary_path: Absolute path to the fat binary.
overlay_relative_path: Path relative to the overlay root.
kernel_staging_dir: Directory to write kernel files into.
Returns:
List of ExtractedKernelRef objects with file paths.
"""
binary = BundledBinary(fat_binary_path)
code_object_index: dict[str, int] = defaultdict(int)
refs: list[ExtractedKernelRef] = []
# Create a subdirectory per binary to avoid filename collisions
binary_name = Path(overlay_relative_path).name
binary_staging = kernel_staging_dir / binary_name
binary_staging.mkdir(parents=True, exist_ok=True)
with binary.unbundle() as unbundled:
for target_name, file_name in unbundled.target_list:
arch = extract_architecture_from_target(target_name)
if arch:
src_path = unbundled.dest_dir / file_name
index = code_object_index[arch]
code_object_index[arch] += 1
indexed_relpath = f"{overlay_relative_path}#{index}"
# Move kernel file to staging (avoids a copy since unbundle
# temp dir is cleaned up when the context manager exits)
dest_name = f"{arch}_{index}_{file_name}"
dest_path = binary_staging / dest_name
shutil.copy2(src_path, dest_path)
kernel_size = dest_path.stat().st_size
refs.append(
ExtractedKernelRef(
target_name=target_name,
kernel_file=dest_path,
kernel_size=kernel_size,
source_binary_relpath=indexed_relpath,
source_prefix="",
architecture=arch,
)
)
return refs
def _transform_single_binary(
binary_path: Path,
temp_output: Path,
search_pattern: str,
kernel_name: str,
overlay_relative_path: str,
verbose: bool,
) -> TransformResult:
"""Transform a single fat binary in the host staging directory.
This is a module-level function so it can be submitted to a thread pool.
Each invocation operates on separate files with no shared mutable state.
Args:
binary_path: Path to the fat binary in the staging dir.
temp_output: Path for the temporary transformed output.
search_pattern: Kpack search pattern with @GFXARCH@ placeholder.
kernel_name: Kernel name for the kpack reference.
overlay_relative_path: For reporting.
verbose: Print transformation details.
Returns:
TransformResult with size info.
"""
original_size = binary_path.stat().st_size
try:
kpack_offload_binary(
input_path=binary_path,
output_path=temp_output,
kpack_search_paths=[search_pattern],
kernel_name=kernel_name,
verbose=verbose,
)
os.replace(temp_output, binary_path)
if not binary_path.exists():
raise WheelSplitError(
f"Binary disappeared after transformation: {binary_path}"
)
new_size = binary_path.stat().st_size
return TransformResult(
overlay_relative_path=overlay_relative_path,
original_size=original_size,
new_size=new_size,
skipped=False,
)
except NotFatBinaryError:
return TransformResult(
overlay_relative_path=overlay_relative_path,
original_size=original_size,
new_size=original_size,
skipped=True,
)
finally:
if temp_output.exists():
temp_output.unlink()
@dataclass
class KpackResult:
"""Result of creating a single kpack archive."""
arch: str
kpack_path: Path
kpack_size: int
num_kernels: int
elapsed: float
def _create_single_kpack(
arch: str,
ref_list: list[ExtractedKernelRef],
group_name: str,
kpack_staging: Path,
compression: str,
compression_level: int,
) -> KpackResult:
"""Create a kpack archive for a single architecture.
Module-level function for use with ProcessPoolExecutor. Each arch is
fully independent — separate kernel files, separate output archive.
"""
t0 = time.monotonic()
compressor = None
if compression == "zstd":
compressor = ZstdCompressor(compression_level=compression_level)
archive = PackedKernelArchive(
group_name=group_name,
gfx_arch_family=arch,
gfx_arches=[arch],
compressor=compressor,
)
for ref in ref_list:
hsaco_data = ref.kernel_file.read_bytes()
prepared = archive.prepare_kernel(
relative_path=ref.source_binary_relpath,
gfx_arch=ref.architecture,
hsaco_data=hsaco_data,
)
archive.add_kernel(prepared)
archive.finalize_archive()
kpack_file = kpack_staging / f"{group_name}_{arch}.kpack"
archive.write(kpack_file)
if not kpack_file.exists() or kpack_file.stat().st_size == 0:
raise WheelSplitError(f"Failed to create kpack archive: {kpack_file}")
elapsed = time.monotonic() - t0
return KpackResult(
arch=arch,
kpack_path=kpack_file,
kpack_size=kpack_file.stat().st_size,
num_kernels=len(ref_list),
elapsed=elapsed,
)
class WheelSplitter:
"""Splits a Python wheel into host + per-arch device wheels."""
def __init__(
self,
device_package_prefix: str,
overlay_root: str,
toolchain: Toolchain,
device_requires_dist: list[str] | None = None,
database_handlers: list[DatabaseHandler] | None = None,
compression: str = "zstd",
compression_level: int = 3,
verbose: bool = False,
jobs: int = 1,
generate_variant_wheel: bool = False,
variant_label: str = "variant",
):
"""
Args:
device_package_prefix: Package name prefix for device wheels (e.g., "amd-torch-device").
overlay_root: Directory within the wheel where .kpack/ is placed (e.g., "torch/").
toolchain: Toolchain instance for binary operations.
device_requires_dist: Additional Requires-Dist entries for device wheels.
Supports @GFXARCH@ placeholder.
database_handlers: Handlers for arch-specific database files (rocblas, hipblaslt, etc.).
compression: Compression scheme for kpack archives ("none" or "zstd").
compression_level: Zstd compression level (1-22).
verbose: Enable verbose output.
jobs: Number of parallel workers for extraction and transformation (default 1).
generate_variant_wheel: If True, also generate a PEP 817 variant wheel with
variant_properties markers. The classic wheel only uses extras.
variant_label: Label appended to the variant wheel filename per PEP 817
(e.g., "amd" produces ...-amd.whl). Default: "variant".
"""
self.device_package_prefix = device_package_prefix
# Normalize overlay_root: strip trailing slash, ensure no leading slash
self.overlay_root = overlay_root.strip("/")
self.toolchain = toolchain
self.device_requires_dist = device_requires_dist or []
self.database_handlers = database_handlers or []
self.compression = compression
self.compression_level = compression_level
self.verbose = verbose
# Windows ProcessPoolExecutor caps at 61 (WaitForMultipleObjects limit).
max_jobs = 61 if os.name == "nt" else jobs
self.jobs = max(1, min(jobs, max_jobs))
self.generate_variant_wheel = generate_variant_wheel
self.variant_label = variant_label
def split(
self,
input_path: Path,
output_dir: Path,
output_format: str = "wheel",
) -> SplitResult:
"""Split a wheel into host and device components.
Args:
input_path: Path to input .whl file or exploded wheel directory.
output_dir: Output directory for generated wheels.
output_format: "wheel" for .whl files, "directory" for exploded dirs.
Returns:
SplitResult with paths to generated outputs.
Raises:
InvalidWheelError: If the input is not a valid wheel.
NoFatBinariesError: If no fat binaries are found.
"""
output_dir.mkdir(parents=True, exist_ok=True)
# Phase 0: Resolve input
wheel_root, is_temporary = self._resolve_input(input_path)
try:
return self._split_impl(wheel_root, output_dir, output_format)
finally:
if is_temporary:
shutil.rmtree(wheel_root, ignore_errors=True)
def _resolve_input(self, input_path: Path) -> tuple[Path, bool]:
"""Resolve input to a working directory.
Returns:
(wheel_root, is_temporary) — is_temporary means we extracted to a temp
dir that should be cleaned up.
"""
if input_path.is_dir():
return input_path, False
elif input_path.is_file() and input_path.suffix == ".whl":
# Extract to a temp directory adjacent to the input
temp_dir = input_path.parent / (input_path.stem + ".tmp_extract")
if temp_dir.exists():
shutil.rmtree(temp_dir)
temp_dir.mkdir()
if self.verbose:
print(f"Extracting {input_path} to {temp_dir}")
_extract_wheel_preserving_permissions(input_path, temp_dir)
return temp_dir, True
else:
raise InvalidWheelError(
f"Input must be a .whl file or directory, got: {input_path}"
)
def _split_impl(
self, wheel_root: Path, output_dir: Path, output_format: str
) -> SplitResult:
"""Core split implementation operating on an exploded wheel directory."""
# Phase 1: Parse identity
identity = parse_wheel_identity(wheel_root)
if self.verbose:
print(f"Wheel: {identity.name} {identity.version}")
print(
f"Tag: {identity.python_tag}-{identity.abi_tag}-{identity.platform_tag}"
)
overlay_dir = wheel_root / self.overlay_root
if not overlay_dir.exists():
raise InvalidWheelError(
f"Overlay root '{self.overlay_root}' does not exist in wheel"
)
# Phase 2: Scan for fat binaries
if self.verbose:
print(f"\nPhase 2: Scanning for fat binaries under {self.overlay_root}/")
fat_binaries = self._scan_fat_binaries(overlay_dir)
if not fat_binaries:
raise NoFatBinariesError(
f"No fat binaries found under {self.overlay_root}/"
)
if self.verbose:
print(f"Found {len(fat_binaries)} fat binaries")
# Sort largest first so the process pool starts the critical path early
fat_binaries.sort(key=lambda fb: fb.absolute_path.stat().st_size, reverse=True)
# Phase 2b: Scan for database files (rocblas, hipblaslt, etc.)
db_refs_by_arch: dict[str, list[DatabaseFileRef]] = defaultdict(list)
if self.database_handlers:
db_refs_by_arch = self._scan_database_files(overlay_dir)
# Phase 3: Extract kernels from fat binaries
if self.verbose:
print(f"\nPhase 3: Extracting device kernels")
kernel_staging_dir = output_dir / ".kernel_staging"
kernel_staging_dir.mkdir(parents=True, exist_ok=True)
try:
refs_by_arch = self._extract_kernels(fat_binaries, kernel_staging_dir)
# Merge architecture sets from ELF extraction and database scanning
all_arches: set[str] = set(refs_by_arch.keys())
all_arches.update(db_refs_by_arch.keys())
architectures = sorted(all_arches)
if self.verbose:
print(f"Architectures found: {', '.join(architectures)}")
for arch in architectures:
parts = []
if arch in refs_by_arch:
parts.append(f"{len(refs_by_arch[arch])} kernels")
if arch in db_refs_by_arch:
parts.append(f"{len(db_refs_by_arch[arch])} db files")
print(f" {arch}: {', '.join(parts)}")
# Phase 4: Create kpack archives (staged to output_dir temporarily)
group_name = identity.name.lower().replace("-", "_")
kpack_by_arch = self._create_kpack_archives(
refs_by_arch, group_name, output_dir
)
finally:
shutil.rmtree(kernel_staging_dir, ignore_errors=True)
# Phase 4b: Group by bundle key (collapse xnack variants)
# kpack_by_arch may have keys like gfx942, gfx942:xnack+, gfx942:xnack-
# These all collapse into bundle key "gfx942" with multiple kpack files.
kpacks_by_bundle: dict[str, list[tuple[str, Path]]] = defaultdict(list)
for arch, kpack_path in kpack_by_arch.items():
bundle_key = _arch_to_bundle_key(arch)
kpacks_by_bundle[bundle_key].append((arch, kpack_path))
db_refs_by_bundle: dict[str, list[DatabaseFileRef]] = defaultdict(list)
for arch, refs in db_refs_by_arch.items():
bundle_key = _arch_to_bundle_key(arch)
db_refs_by_bundle[bundle_key].extend(refs)
bundle_keys = sorted(
set(kpacks_by_bundle.keys()) | set(db_refs_by_bundle.keys())
)
if self.verbose and bundle_keys != architectures:
print(f" Bundle keys (after xnack collapse): {', '.join(bundle_keys)}")
# Phase 5: Create host wheel
host_filename = compute_wheel_filename(
identity.name, identity.version, identity
)
host_staging = output_dir / (host_filename.removesuffix(".whl") + ".staging")
if host_staging.exists():
shutil.rmtree(host_staging)
if self.verbose:
print(f"\nPhase 5: Creating host wheel: {host_filename}")
print(f" Copying wheel contents to staging...")
_copy_tree(wheel_root, host_staging)
if self.verbose:
print(f" Copy complete. Transforming fat binaries...")
self._transform_host_binaries(host_staging, fat_binaries, identity, group_name)
# Remove database files from host staging
if db_refs_by_arch:
self._remove_database_files_from_host(host_staging, db_refs_by_arch)
# Rewrite host METADATA (classic: extras only, no variant markers)
self._rewrite_host_metadata(
host_staging, identity, set(bundle_keys), include_variant_markers=False
)
# Regenerate RECORD for host wheel
if self.verbose:
print(f" Generating RECORD (hashing all files)...")
record_content = generate_record(
host_staging, identity.dist_info_name, verbose=self.verbose
)
record_path = host_staging / identity.dist_info_name / "RECORD"
record_path.write_text(record_content, encoding="utf-8")
# Package host wheel
if output_format == "wheel":
host_output = output_dir / host_filename
if self.verbose:
print(f" Zipping to {host_output}")
zip_wheel(host_staging, host_output)
else:
host_output = output_dir / host_filename.removesuffix(".whl")
if host_output.exists():
shutil.rmtree(host_output)
host_staging.rename(host_output)
# Phase 5b: Generate variant wheel (PEP 817) if requested
variant_output = None
if self.generate_variant_wheel:
variant_output = self._create_variant_wheel(
host_staging if output_format == "wheel" else host_output,
identity,
set(bundle_keys),
output_dir,
output_format,
)
# Clean up host staging (deferred until after variant wheel uses it)
if output_format == "wheel" and host_staging.exists():
shutil.rmtree(host_staging)
# Phase 6: Create device wheels (one per bundle key)
device_paths: dict[str, Path] = {}
for bundle_key in bundle_keys:
device_path = self._create_device_wheel(
bundle_key,
identity,
group_name,
kpacks_by_bundle.get(bundle_key, []),
db_refs_by_bundle.get(bundle_key, []),
output_dir,
output_format,
)
device_paths[bundle_key] = device_path
return SplitResult(
host_wheel_path=host_output,
variant_wheel_path=variant_output,
device_wheel_paths=device_paths,
architectures_found=architectures,
fat_binaries_processed=len(fat_binaries),
)
def _rewrite_host_metadata(
self,
host_staging: Path,
identity: WheelIdentity,
bundle_keys: set[str],
include_variant_markers: bool = False,
) -> None:
"""Rewrite host METADATA to add extras and deps.
Inserts into the existing METADATA header section:
- Requires-Dist: rocm-bootstrap
- Per-target Provides-Extra + Requires-Dist (extras-based)
- Provides-Extra: device-all (with all device wheels)
- If include_variant_markers: also adds variant_properties markers
"""
metadata_path = host_staging / identity.dist_info_name / "METADATA"
existing = metadata_path.read_text(encoding="utf-8")
lines: list[str] = []
version = identity.version
# Add rocm-bootstrap dependency
lines.append("Requires-Dist: rocm-bootstrap")
# Collect target-level bundle keys (those are the ones we generate
# extras and variant markers for).
target_keys: list[str] = []
for key in sorted(bundle_keys):
bundle = rocm_bootstrap.lookup_bundle(key)
if bundle.level == rocm_bootstrap.PackagingLevel.TARGET:
target_keys.append(key)
# For each target, compute packaging chain and generate extras.
# Per-target extras are prefixed with "device-" to mirror the
# convention used by rocm-sdk-core (rocm[device-gfx1201], ...) so
# one find-links URL exposes a uniform install command across the
# SDK + PyTorch stack.
all_device_dist_names: set[str] = set()
for amdgpu_target in target_keys:
chain = rocm_bootstrap.packaging_chain(amdgpu_target)
lines.append(f"Provides-Extra: device-{amdgpu_target}")
for chain_bundle in chain:
if chain_bundle.key not in bundle_keys:
continue
dist_name = rocm_bootstrap.device_dist_name(
self.device_package_prefix, chain_bundle
)
all_device_dist_names.add(dist_name)
# NOTE: This format is parsed verbatim by
# _add_variant_markers_to_metadata(). If you change it,
# update the regex there too.
lines.append(
f"Requires-Dist: {dist_name} == {version}; "
f'extra == "device-{amdgpu_target}"'
)
if include_variant_markers:
lines.append(
f"Requires-Dist: {dist_name} == {version}; "
f'"amd :: gfx_arch :: {amdgpu_target}" in variant_properties'
)
# Also add non-target bundle keys (family, sub-family) to the "device-all" set
for key in sorted(bundle_keys):
bundle = rocm_bootstrap.lookup_bundle(key)
if bundle.level != rocm_bootstrap.PackagingLevel.TARGET:
dist_name = rocm_bootstrap.device_dist_name(
self.device_package_prefix, bundle
)
all_device_dist_names.add(dist_name)
# "device-all" extra: includes every device wheel. The prefix
# matches the per-target extras above so every kpack-emitted extra
# starts with "device-".
lines.append("Provides-Extra: device-all")
for dist_name in sorted(all_device_dist_names):
lines.append(
f"Requires-Dist: {dist_name} == {version}; " f'extra == "device-all"'
)
# Insert new headers before the body (after the last header line).
# METADATA uses RFC 822 format: headers, blank line, body.
# Find the first blank line that separates headers from body.
header_end = existing.find("\n\n")
if header_end != -1:
header_section = existing[: header_end + 1] # includes trailing \n
body_section = existing[header_end + 1 :] # includes leading \n
new_content = header_section + "\n".join(lines) + "\n" + body_section
else:
# No body — just append
if not existing.endswith("\n"):
existing += "\n"
new_content = existing + "\n".join(lines) + "\n"
metadata_path.write_text(new_content, encoding="utf-8")
if self.verbose:
n_extras = len(target_keys) + 1 # +1 for "device-all"
n_device = len(all_device_dist_names)
label = "variant" if include_variant_markers else "classic"
print(
f" Host metadata ({label}): {n_extras} extras, "
f"{n_device} device wheels referenced"
)
def _create_variant_wheel(
self,
host_staging_or_dir: Path,
identity: WheelIdentity,
bundle_keys: set[str],
output_dir: Path,
output_format: str,
) -> Path:
"""Create a PEP 817 variant wheel from the classic host staging.
The variant wheel has the same content as the classic host wheel but with:
- variant_properties markers in METADATA alongside extras
- variant.json in dist-info with provider/variant metadata
For .whl output, host_staging_or_dir is the staging dir (still present).
For directory output, it's the renamed output dir (we copy from it).
"""
if self.verbose:
print(f"\nPhase 5b: Creating variant wheel")
# Create variant staging by copying classic host
variant_staging = output_dir / f"{identity.name}-{self.variant_label}.staging"
if variant_staging.exists():
shutil.rmtree(variant_staging)
_copy_tree(host_staging_or_dir, variant_staging)
# Rewrite METADATA with variant markers (overwrite the classic METADATA)
# We need to start from the original METADATA, not the already-rewritten one.
# But the staging already has the classic rewrite. So we re-run the rewrite
# on the copy — since _rewrite_host_metadata reads existing content and
# inserts headers, we need to start fresh. Easiest: restore original METADATA
# from the classic staging (which has extras already), then just add variant
# markers. Actually, simpler: just re-read the already-rewritten classic
# METADATA and add variant markers to it.
self._add_variant_markers_to_metadata(variant_staging, identity, bundle_keys)
# Add variant.json
self._write_variant_json(variant_staging, identity, bundle_keys)
# Regenerate RECORD
record_content = generate_record(
variant_staging, identity.dist_info_name, verbose=False
)
record_path = variant_staging / identity.dist_info_name / "RECORD"
record_path.write_text(record_content, encoding="utf-8")
# Package variant wheel
# PEP 817 filename: {name}-{ver}-{py}-{abi}-{plat}-{variant_label}.whl
variant_filename = compute_wheel_filename(
identity.name, identity.version, identity
).replace(".whl", f"-{self.variant_label}.whl")
if output_format == "wheel":
variant_output = output_dir / variant_filename
if self.verbose:
print(f" Zipping variant wheel to {variant_output}")
zip_wheel(variant_staging, variant_output)
shutil.rmtree(variant_staging)
else:
variant_output = output_dir / variant_filename.removesuffix(".whl")
if variant_output.exists():
shutil.rmtree(variant_output)
variant_staging.rename(variant_output)
if self.verbose:
print(f" Variant wheel: {variant_output}")
return variant_output
def _add_variant_markers_to_metadata(
self,
staging: Path,
identity: WheelIdentity,
bundle_keys: set[str],
) -> None:
"""Add variant_properties markers to an already-rewritten METADATA.
The classic host METADATA already has extras-based Requires-Dist lines.
This adds the corresponding variant_properties marker lines after each
extras line for target-level bundle keys.
"""
metadata_path = staging / identity.dist_info_name / "METADATA"
existing = metadata_path.read_text(encoding="utf-8")
version = identity.version
# Collect target keys and their packaging chains
new_lines: list[str] = []
for line in existing.splitlines():
new_lines.append(line)
# After each extras-based Requires-Dist for a device wheel,
# add the variant_properties version
if not line.startswith("Requires-Dist:") or "extra ==" not in line:
continue
# The "device-all" extra aggregates every device wheel and
# must not get a variant marker — variants are per-target.
if 'extra == "device-all"' in line:
continue
# Extract the dist requirement and target name.
# This regex must match the format generated by _rewrite_host_metadata():
# f"Requires-Dist: {dist_name} == {version}; "
# f'extra == "device-{amdgpu_target}"'
# If that format changes, this regex must be updated to match.
# The captured extra is the user-facing "device-<arch>" form;
# strip the "device-" prefix to recover the bare gfx target
# used in the variant_properties marker.
match = re.match(r'(Requires-Dist: .+ == .+); extra == "([\w-]+)"', line)
if match:
req_part = match.group(1)
amdgpu_target = match.group(2).removeprefix("device-")
new_lines.append(
f"{req_part}; "
f'"amd :: gfx_arch :: {amdgpu_target}" in variant_properties'
)
metadata_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
if self.verbose:
print(f" Added variant markers to METADATA")
def _write_variant_json(
self,
staging: Path,
identity: WheelIdentity,
bundle_keys: set[str],
) -> None:
"""Write variant.json to the dist-info directory.
Contains PEP 817 variant provider and property metadata.
"""
target_keys = []
for key in sorted(bundle_keys):
bundle = rocm_bootstrap.lookup_bundle(key)
if bundle.level == rocm_bootstrap.PackagingLevel.TARGET:
target_keys.append(key)
variants = {}
for target in target_keys:
variants[target] = {"amd": {"gfx_arch": [target]}}
variant_metadata = {
"providers": {
"amd": {
"install-time": True,
"plugin-api": "amd_variant_provider.plugin:AMDVariantPlugin",
"requires": ["amd-variant-provider >= 1.0"],
}
},
"default-priorities": {
"namespace": ["amd"],
"property": {"amd": {"gfx_arch": target_keys}},
},
"variants": variants,
}
variant_json_path = staging / identity.dist_info_name / "variant.json"
variant_json_path.write_text(
json.dumps(variant_metadata, indent=2) + "\n", encoding="utf-8"
)
if self.verbose:
print(f" Wrote variant.json ({len(target_keys)} targets)")
def _scan_fat_binaries(self, overlay_dir: Path) -> list[FatBinaryInfo]:
"""Scan for fat binaries under the overlay root.
Uses a lightweight header scan (is_fat_binary) that reads only a few
KB per file — section headers for ELF, PE section table for COFF.
Non-binary files are rejected by magic number check without reading
further. Works for both Linux (ELF) and Windows (PE/COFF) wheels.
"""
fat_binaries: list[FatBinaryInfo] = []
checked = 0
for file_path, direntry in scan_directory(overlay_dir):
if not direntry.is_file(follow_symlinks=False):
continue
checked += 1
if is_fat_binary(file_path):
rel_path = file_path.relative_to(overlay_dir).as_posix()
fat_binaries.append(
FatBinaryInfo(
absolute_path=file_path,
overlay_relative_path=rel_path,
)
)
if self.verbose:
size_mb = file_path.stat().st_size / (1024 * 1024)
print(f" Fat binary: {rel_path} ({size_mb:.1f} MB)")
if self.verbose:
print(f" Scanned {checked} files, found {len(fat_binaries)} fat binaries")
return fat_binaries
def _scan_database_files(
self, overlay_dir: Path
) -> dict[str, list[DatabaseFileRef]]:
"""Scan for arch-specific database files using registered handlers.
Returns:
Dict mapping normalized architecture to list of DatabaseFileRef.
"""
if self.verbose:
print(f"\nPhase 2b: Scanning for database files")
print(f" Handlers: {', '.join(h.name() for h in self.database_handlers)}")
refs_by_arch: dict[str, list[DatabaseFileRef]] = defaultdict(list)
counts_by_handler: dict[str, int] = defaultdict(int)
size_by_handler: dict[str, int] = defaultdict(int)
for file_path, direntry in scan_directory(overlay_dir):
if not direntry.is_file(follow_symlinks=False):
continue
for handler in self.database_handlers:
raw_arch = handler.detect(file_path, overlay_dir)
if raw_arch is not None:
arch = _normalize_arch(raw_arch)
rel_path = file_path.relative_to(overlay_dir).as_posix()
refs_by_arch[arch].append(
DatabaseFileRef(
absolute_path=file_path,
overlay_relative_path=rel_path,
architecture=arch,
handler_name=handler.name(),
)
)
counts_by_handler[handler.name()] += 1
size_by_handler[handler.name()] += direntry.stat().st_size
break # first handler wins
if self.verbose:
total_files = sum(counts_by_handler.values())
total_size = sum(size_by_handler.values())
print(
f" Found {total_files} database files ({total_size / (1024*1024):.0f} MB)"
)
for name in sorted(counts_by_handler):
print(
f" {name}: {counts_by_handler[name]} files "
f"({size_by_handler[name] / (1024*1024):.0f} MB)"
)
return refs_by_arch
def _remove_database_files_from_host(
self,
host_staging: Path,
db_refs_by_arch: dict[str, list[DatabaseFileRef]],
) -> None:
"""Remove database files from host staging directory."""
overlay_dir = host_staging / self.overlay_root
removed = 0
removed_bytes = 0
for refs in db_refs_by_arch.values():
for ref in refs:
host_file = overlay_dir / ref.overlay_relative_path
if host_file.exists():
removed_bytes += host_file.stat().st_size
host_file.unlink()
removed += 1
# Clean up empty directories left behind
for dirpath, dirnames, filenames in os.walk(overlay_dir, topdown=False):
if not filenames and not dirnames:
try:
Path(dirpath).rmdir()
except OSError:
pass # not empty or root
if self.verbose:
print(
f" Removed {removed} database files from host "
f"({removed_bytes / (1024*1024):.0f} MB)"
)
def _extract_kernels(
self,
fat_binaries: list[FatBinaryInfo],
kernel_staging_dir: Path,
) -> dict[str, list[ExtractedKernelRef]]:
"""Extract device kernels from fat binaries to disk.
When jobs > 1, fat binaries are extracted in parallel using a process
pool. Kernel data is written to files under kernel_staging_dir to avoid
pickling large byte buffers through IPC.
Args:
fat_binaries: List of fat binaries to extract.
kernel_staging_dir: Directory for extracted kernel files.
Returns:
Dict mapping architecture to list of ExtractedKernelRef.
"""
refs_by_arch: dict[str, list[ExtractedKernelRef]] = defaultdict(list)
total = len(fat_binaries)
phase_start = time.monotonic()
if self.jobs <= 1:
for idx, fb in enumerate(fat_binaries, 1):
if self.verbose:
size_mb = fb.absolute_path.stat().st_size / (1024 * 1024)
print(
f" [{idx}/{total}] Extracting: "
f"{fb.overlay_relative_path} ({size_mb:.1f} MB)"
)
t0 = time.monotonic()
refs = _extract_single_binary(
fb.absolute_path,
fb.overlay_relative_path,
kernel_staging_dir,
)
elapsed = time.monotonic() - t0
for r in refs:
refs_by_arch[r.architecture].append(r)
if self.verbose:
size_mb = fb.absolute_path.stat().st_size / (1024 * 1024)
archs = sorted({r.architecture for r in refs})
print(
f" -> {len(refs)} kernels, {elapsed:.1f}s, "
f"{size_mb:.1f} MB, arches: {', '.join(archs)}"
)
else:
if self.verbose:
print(f" Using {self.jobs} parallel workers")
with ProcessPoolExecutor(max_workers=self.jobs) as executor:
future_to_fb: dict[object, tuple[FatBinaryInfo, float]] = {}
for fb in fat_binaries:
future = executor.submit(
_extract_single_binary,
fb.absolute_path,
fb.overlay_relative_path,
kernel_staging_dir,
)
future_to_fb[future] = (fb, time.monotonic())
completed = 0
for future in as_completed(future_to_fb):
completed += 1
fb, submit_time = future_to_fb[future]
elapsed = time.monotonic() - submit_time
refs = future.result()
for r in refs:
refs_by_arch[r.architecture].append(r)
if self.verbose:
size_mb = fb.absolute_path.stat().st_size / (1024 * 1024)
archs = sorted({r.architecture for r in refs})
print(
f" [{completed}/{total}] {fb.overlay_relative_path} "
f"({len(refs)} kernels, {elapsed:.1f}s, "
f"{size_mb:.1f} MB, arches: {', '.join(archs)})"
)
if self.verbose:
total_elapsed = time.monotonic() - phase_start
total_kernels = sum(len(v) for v in refs_by_arch.values())
print(
f" Extraction complete: {total_kernels} kernels in {total_elapsed:.1f}s"
)
return refs_by_arch
def _create_kpack_archives(
self,
refs_by_arch: dict[str, list[ExtractedKernelRef]],
group_name: str,
output_dir: Path,
) -> dict[str, Path]:
"""Create kpack archives, one per architecture.
Reads kernel data from files referenced by ExtractedKernelRef objects.
When jobs > 1, archives are created in parallel (each arch is independent).
Returns:
Dict of arch -> kpack file path (in a temp staging area under output_dir).
"""
kpack_staging = output_dir / ".kpack_staging"
kpack_staging.mkdir(parents=True, exist_ok=True)
if self.verbose:
print(
f"\nPhase 4: Creating kpack archives ({len(refs_by_arch)} architectures)"
)
phase_start = time.monotonic()
kpack_paths: dict[str, Path] = {}
# Sort by kernel count descending so largest archives start first
sorted_arches = sorted(
refs_by_arch.items(), key=lambda kv: len(kv[1]), reverse=True
)
if self.jobs <= 1:
for arch, ref_list in sorted_arches:
result = _create_single_kpack(
arch,
ref_list,
group_name,
kpack_staging,
self.compression,
self.compression_level,
)
kpack_paths[result.arch] = result.kpack_path
if self.verbose:
print(
f" {result.arch}: {result.num_kernels} kernels, "
f"{result.kpack_size} bytes, {result.elapsed:.1f}s"
)
else:
if self.verbose:
print(f" Using {self.jobs} parallel workers")
total = len(sorted_arches)
with ProcessPoolExecutor(max_workers=self.jobs) as executor:
futures = {}
for arch, ref_list in sorted_arches:
future = executor.submit(
_create_single_kpack,
arch,
ref_list,
group_name,
kpack_staging,
self.compression,
self.compression_level,
)
futures[future] = arch
completed = 0
for future in as_completed(futures):
completed += 1
result = future.result()
kpack_paths[result.arch] = result.kpack_path
if self.verbose:
print(
f" [{completed}/{total}] {result.arch}: "
f"{result.num_kernels} kernels, "
f"{result.kpack_size} bytes, {result.elapsed:.1f}s"
)
if self.verbose:
total_elapsed = time.monotonic() - phase_start
total_size = sum(p.stat().st_size for p in kpack_paths.values())
print(
f" Kpack complete: {len(kpack_paths)} archives, "
f"{total_size / (1024*1024):.0f} MB in {total_elapsed:.1f}s"
)
return kpack_paths
def _compute_kpack_search_pattern(
self, overlay_relative_path: str, group_name: str
) -> str:
"""Compute the @GFXARCH@ search pattern from a binary to its kpack file.
Args:
overlay_relative_path: Path relative to overlay root (e.g., "lib/libtorch_hip.so").
group_name: Group name for kpack files (e.g., "torch").
Returns:
Relative search pattern like "../.kpack/torch_@GFXARCH@.kpack".
"""
parts = Path(overlay_relative_path).parts
# Depth = number of directory levels (excluding the file itself)
depth = len(parts) - 1
kpack_pattern = f".kpack/{group_name}_@GFXARCH@.kpack"
if depth == 0:
return kpack_pattern
up_path = "/".join([".."] * depth)
return f"{up_path}/{kpack_pattern}"
def _transform_host_binaries(
self,
host_staging: Path,
fat_binaries: list[FatBinaryInfo],
identity: WheelIdentity,
group_name: str,
) -> None:
"""Transform fat binaries in the host staging directory.
When jobs > 1, binary transformations run in parallel. Each binary
is transformed independently (separate input/output files, no shared
mutable state).
"""
overlay_dir = host_staging / self.overlay_root
# Build per-binary arguments
all_args: list[TransformArgs] = []
for fb in fat_binaries:
binary_path = overlay_dir / fb.overlay_relative_path
if not binary_path.exists():
raise WheelSplitError(
f"Fat binary not found in host staging: {fb.overlay_relative_path}"
)
all_args.append(
TransformArgs(
binary_path=binary_path,
temp_output=binary_path.with_suffix(
binary_path.suffix + ".kpacked"
),
search_pattern=self._compute_kpack_search_pattern(
fb.overlay_relative_path, group_name
),
kernel_name=fb.overlay_relative_path,
overlay_relative_path=fb.overlay_relative_path,
)
)
if self.jobs <= 1:
# Serial path
for args in all_args:
if self.verbose:
print(f" Transforming: {args.overlay_relative_path}")
print(f" Search pattern: {args.search_pattern}")
result = _transform_single_binary(
args.binary_path,
args.temp_output,
args.search_pattern,
args.kernel_name,
args.overlay_relative_path,
self.verbose,
)
self._report_transform_result(result)
else:
# Parallel path
if self.verbose:
print(f" Using {self.jobs} parallel workers for transformation")
with ProcessPoolExecutor(max_workers=self.jobs) as executor:
futures = {}
for args in all_args:
future = executor.submit(
_transform_single_binary,
args.binary_path,
args.temp_output,
args.search_pattern,
args.kernel_name,
args.overlay_relative_path,
False, # verbose=False in workers
)
futures[future] = args.overlay_relative_path
completed = 0
total = len(futures)
for future in as_completed(futures):
completed += 1
result = future.result()
if self.verbose:
print(f" [{completed}/{total}] ", end="")
self._report_transform_result(result)
def _report_transform_result(self, result: TransformResult) -> None:
"""Print verbose output for a single binary transformation."""
if not self.verbose:
return
if result.skipped:
print(f" Skipping (no device code): {result.overlay_relative_path}")
else:
delta = result.new_size - result.original_size
if delta < 0:
print(
f" Transformed: {result.overlay_relative_path} "
f"({result.new_size} bytes, saved {-delta} bytes)"
)
else:
print(
f" Transformed: {result.overlay_relative_path} "
f"({result.new_size} bytes, +{delta} bytes overhead)"
)
def _create_device_wheel(
self,
bundle_key: str,
host_identity: WheelIdentity,
group_name: str,
kpack_entries: list[tuple[str, Path]],
db_refs: list[DatabaseFileRef],
output_dir: Path,
output_format: str,
) -> Path:
"""Create a device wheel for a specific bundle key.
Args:
bundle_key: Bundle key, e.g. "gfx942", "gfx11", "gfx12_0".
kpack_entries: List of (arch, kpack_path) tuples. Multiple entries
occur when xnack variants collapse into the same bundle key.
db_refs: Database files to include for this bundle.
Returns:
Path to the generated device wheel (file or directory).
"""
device_name = _bundle_key_to_dist_name(self.device_package_prefix, bundle_key)
device_dist_info = compute_dist_info_name(device_name, host_identity.version)
device_filename = compute_wheel_filename(
device_name, host_identity.version, host_identity
)
if self.verbose:
print(f"\nCreating device wheel: {device_filename}")
# Create staging directory
staging = output_dir / (device_filename.removesuffix(".whl") + ".staging")
if staging.exists():
shutil.rmtree(staging)
staging.mkdir()
# Place kpack file(s) under overlay_root/.kpack/
# Multiple entries when xnack variants (gfx942, gfx942:xnack+, etc.)
# collapse into the same bundle key.
for arch, kpack_path in kpack_entries:
kpack_dest_dir = staging / self.overlay_root / ".kpack"
kpack_dest_dir.mkdir(parents=True, exist_ok=True)
kpack_dest = kpack_dest_dir / f"{group_name}_{arch}.kpack"
shutil.copy2(kpack_path, kpack_dest)
# Copy database files preserving their overlay-relative paths
for ref in db_refs:
dest = staging / self.overlay_root / ref.overlay_relative_path
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ref.absolute_path, dest)
# Create dist-info
dist_info_dir = staging / device_dist_info
dist_info_dir.mkdir()
# METADATA
metadata_content = generate_device_metadata(
host_identity,
bundle_key,
self.device_package_prefix,
self.device_requires_dist,
)
(dist_info_dir / "METADATA").write_text(metadata_content, encoding="utf-8")
# WHEEL
wheel_content = generate_wheel_file(host_identity)
(dist_info_dir / "WHEEL").write_text(wheel_content, encoding="utf-8")
# top_level.txt
(dist_info_dir / "top_level.txt").write_text(
f"{self.overlay_root}\n", encoding="utf-8"
)
# RECORD (must be last)
record_content = generate_record(staging, device_dist_info)
(dist_info_dir / "RECORD").write_text(record_content, encoding="utf-8")
# Package
if output_format == "wheel":
device_output = output_dir / device_filename
zip_wheel(staging, device_output)
shutil.rmtree(staging)
else:
device_output = output_dir / device_filename.removesuffix(".whl")
if device_output.exists():
shutil.rmtree(device_output)
staging.rename(device_output)
if self.verbose:
print(f" Written: {device_output}")
return device_output