From afab0bd1382fbd020d2273ed2a59219813baa4b9 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 17 Aug 2026 10:27:26 -0400 Subject: [PATCH 1/4] ci: experiment with upstream Windows CUDA packs --- .github/workflows/native_release.yml | 199 ++++++++++ scripts/verify_release_provenance.py | 26 ++ tools/package_upstream_cuda.py | 392 ++++++++++++++++++++ tools/smoke_windows_cuda_pack.py | 82 ++++ tools/tests/test_package_upstream_cuda.py | 171 +++++++++ tools/tests/test_smoke_windows_cuda_pack.py | 25 ++ 6 files changed, 895 insertions(+) create mode 100644 tools/package_upstream_cuda.py create mode 100644 tools/smoke_windows_cuda_pack.py create mode 100644 tools/tests/test_package_upstream_cuda.py create mode 100644 tools/tests/test_smoke_windows_cuda_pack.py diff --git a/.github/workflows/native_release.yml b/.github/workflows/native_release.yml index 06fbf50..6465607 100644 --- a/.github/workflows/native_release.yml +++ b/.github/workflows/native_release.yml @@ -63,6 +63,10 @@ jobs: if [ -z "$RELEASE_TAG" ]; then RELEASE_TAG="$TAG" fi + if [ "$RELEASE_TAG" = "cuda-prebuilt-experiment" ] && [ "${{ github.event.inputs.publish_release }}" != "false" ]; then + echo "cuda-prebuilt-experiment is strictly non-publishing." >&2 + exit 1 + fi if ! [[ "$RELEASE_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then echo "Invalid native_release_tag: $RELEASE_TAG" >&2 exit 1 @@ -108,6 +112,7 @@ jobs: build-android: needs: resolve-tag + if: ${{ github.event.inputs.native_release_tag != 'cuda-prebuilt-experiment' }} runs-on: ubuntu-latest env: CCACHE_DIR: ${{ github.workspace }}/.ccache @@ -290,6 +295,7 @@ jobs: build-apple: needs: resolve-tag + if: ${{ github.event.inputs.native_release_tag != 'cuda-prebuilt-experiment' }} runs-on: macos-latest env: CCACHE_DIR: ${{ github.workspace }}/.ccache @@ -360,6 +366,7 @@ jobs: build-linux: needs: resolve-tag + if: ${{ github.event.inputs.native_release_tag != 'cuda-prebuilt-experiment' }} runs-on: ubuntu-latest env: CCACHE_DIR: ${{ github.workspace }}/.ccache @@ -614,6 +621,7 @@ jobs: build-linux-hip: needs: resolve-tag + if: ${{ github.event.inputs.native_release_tag != 'cuda-prebuilt-experiment' }} runs-on: ubuntu-latest container: rocm/dev-ubuntu-22.04:6.1.2 env: @@ -724,6 +732,7 @@ jobs: build-windows: needs: resolve-tag + if: ${{ github.event.inputs.native_release_tag != 'cuda-prebuilt-experiment' }} runs-on: ${{ matrix.runs_on }} env: CMAKE_C_COMPILER_LAUNCHER: sccache @@ -993,6 +1002,196 @@ jobs: C:\vcpkg\installed key: vcpkg-${{ runner.os }}-${{ matrix.vcpkg_triplet }}-${{ env.VCPKG_CACHE_VERSION }} + windows-cuda-prebuilt-experiment: + needs: resolve-tag + if: >- + ${{ github.event.inputs.native_release_tag == 'cuda-prebuilt-experiment' && + github.event.inputs.publish_release == 'false' }} + runs-on: windows-latest + permissions: + contents: read + env: + GH_TOKEN: ${{ github.token }} + LLAMA_CPP_TAG: ${{ needs.resolve-tag.outputs.llama_cpp_ref }} + LLAMA_CPP_COMMIT: ${{ needs.resolve-tag.outputs.llama_cpp_commit }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify packaging unit contracts + run: python -m unittest discover -s tools/tests -v + + - name: Resolve and download exact release assets + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $downloadDir = Join-Path $env:RUNNER_TEMP 'cuda-prebuilt-downloads' + New-Item -ItemType Directory -Force -Path $downloadDir | Out-Null + + $upstreamRelease = gh api "repos/ggml-org/llama.cpp/releases/tags/$env:LLAMA_CPP_TAG" | + ConvertFrom-Json + if ($upstreamRelease.target_commitish -ne $env:LLAMA_CPP_COMMIT) { + throw "Upstream release target $($upstreamRelease.target_commitish) does not match resolved commit $env:LLAMA_CPP_COMMIT" + } + + $nativeTagTree = git ls-tree "refs/tags/$env:LLAMA_CPP_TAG" third_party/llama.cpp + if (-not $nativeTagTree) { + throw "Native release tag $env:LLAMA_CPP_TAG does not record third_party/llama.cpp" + } + $nativeTagLlamaCommit = ($nativeTagTree -split '\s+')[2] + if ($nativeTagLlamaCommit -ne $env:LLAMA_CPP_COMMIT) { + throw "Native release tag records llama.cpp $nativeTagLlamaCommit, expected $env:LLAMA_CPP_COMMIT" + } + + $nativeRelease = gh api "repos/leehack/llamadart-native/releases/tags/$env:LLAMA_CPP_TAG" | + ConvertFrom-Json + $coreName = "llamadart-native-windows-x64-$env:LLAMA_CPP_TAG.tar.gz" + $assetNames = @( + $coreName, + "llama-$env:LLAMA_CPP_TAG-bin-win-cuda-12.4-x64.zip", + 'cudart-llama-bin-win-cuda-12.4-x64.zip', + "llama-$env:LLAMA_CPP_TAG-bin-win-cuda-13.3-x64.zip", + 'cudart-llama-bin-win-cuda-13.3-x64.zip' + ) + + $metadata = @{} + foreach ($assetName in $assetNames) { + $release = if ($assetName -eq $coreName) { $nativeRelease } else { $upstreamRelease } + $asset = $release.assets | Where-Object name -EQ $assetName | Select-Object -First 1 + if (-not $asset) { + throw "Required release asset is missing: $assetName" + } + if ($asset.digest -notmatch '^sha256:[0-9a-f]{64}$') { + throw "Release asset has no usable SHA-256 digest: $assetName" + } + $metadata[$assetName] = $asset.digest.Substring(7) + $repository = if ($assetName -eq $coreName) { + 'leehack/llamadart-native' + } else { + 'ggml-org/llama.cpp' + } + gh release download $env:LLAMA_CPP_TAG ` + --repo $repository ` + --pattern $assetName ` + --dir $downloadDir + if ($LASTEXITCODE -ne 0) { + throw "Failed to download $repository release asset $assetName" + } + $path = Join-Path $downloadDir $assetName + $actual = (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $metadata[$assetName]) { + throw "Downloaded asset digest mismatch for $assetName" + } + } + + $metadata | ConvertTo-Json | Set-Content ` + (Join-Path $downloadDir 'release-assets.json') + "CUDA_PREBUILT_DOWNLOAD_DIR=$downloadDir" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Extract exact native core + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $coreDir = Join-Path $env:RUNNER_TEMP 'native-core' + New-Item -ItemType Directory -Force -Path $coreDir | Out-Null + $archive = Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR ` + "llamadart-native-windows-x64-$env:LLAMA_CPP_TAG.tar.gz" + tar -xzf $archive -C $coreDir + if ($LASTEXITCODE -ne 0) { + throw 'Failed to extract exact native core bundle' + } + foreach ($name in @('ggml-base.dll', 'ggml.dll')) { + if (-not (Test-Path (Join-Path $coreDir $name))) { + throw "Native core bundle is missing $name" + } + } + "CUDA_PREBUILT_CORE_DIR=$coreDir" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Package CUDA 12.4 and CUDA 13.3 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $outputDir = Join-Path $env:RUNNER_TEMP 'cuda-packs' + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + $metadata = Get-Content ` + (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR 'release-assets.json') | + ConvertFrom-Json -AsHashtable + $core = Join-Path $env:CUDA_PREBUILT_CORE_DIR 'ggml-base.dll' + $timer = [Diagnostics.Stopwatch]::StartNew() + + foreach ($version in @('12.4', '13.3')) { + $backendName = "llama-$env:LLAMA_CPP_TAG-bin-win-cuda-$version-x64.zip" + $runtimeName = "cudart-llama-bin-win-cuda-$version-x64.zip" + python tools/package_upstream_cuda.py ` + --tag $env:LLAMA_CPP_TAG ` + --llama-commit $env:LLAMA_CPP_COMMIT ` + --cuda-version $version ` + --backend-archive (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR $backendName) ` + --backend-sha256 $metadata[$backendName] ` + --runtime-archive (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR $runtimeName) ` + --runtime-sha256 $metadata[$runtimeName] ` + --core-dll $core ` + --output-dir $outputDir + if ($LASTEXITCODE -ne 0) { + throw "CUDA $version packaging failed" + } + } + $timer.Stop() + "CUDA_PREBUILT_OUTPUT_DIR=$outputDir" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CUDA pack preparation: $([math]::Round($timer.Elapsed.TotalSeconds, 1)) seconds" | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Smoke renamed CUDA backends through ggml loader + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($major in @('12', '13')) { + $smokeDir = Join-Path $env:RUNNER_TEMP "cuda-smoke-$major" + New-Item -ItemType Directory -Force -Path $smokeDir | Out-Null + $pack = Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` + "llamadart-native-windows-x64-cuda$major-$env:LLAMA_CPP_TAG.tar.gz" + tar -xzf $pack -C $smokeDir + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract CUDA $major pack" + } + foreach ($name in @('ggml-base.dll', 'ggml.dll', 'vcomp140.dll')) { + $source = Join-Path $env:CUDA_PREBUILT_CORE_DIR $name + if (Test-Path $source) { + Copy-Item $source $smokeDir + } + } + python tools/smoke_windows_cuda_pack.py ` + --directory $smokeDir ` + --backend "ggml-cuda-$major.dll" + if ($LASTEXITCODE -ne 0) { + throw "CUDA $major loader smoke failed" + } + } + + - name: Record pack checksums + shell: pwsh + run: | + Get-ChildItem $env:CUDA_PREBUILT_OUTPUT_DIR -Filter '*.tar.gz' | + Sort-Object Name | + ForEach-Object { + $digest = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$digest $($_.Name)" + } | Set-Content (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR 'SHA256SUMS') + Get-Content (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR 'SHA256SUMS') | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Upload non-publishing CUDA packs + uses: actions/upload-artifact@v4 + with: + name: windows-cuda-prebuilt-${{ needs.resolve-tag.outputs.llama_cpp_ref }} + path: ${{ runner.temp }}/cuda-packs/* + compression-level: 0 + retention-days: 7 + package-and-release: needs: [resolve-tag, build-android, build-apple, build-linux, build-linux-hip, build-windows] runs-on: macos-latest diff --git a/scripts/verify_release_provenance.py b/scripts/verify_release_provenance.py index b22947b..3b5d25a 100755 --- a/scripts/verify_release_provenance.py +++ b/scripts/verify_release_provenance.py @@ -79,6 +79,32 @@ def verify_workflow_contract(errors: list[str]) -> None: errors, ) + experiment_marker = "cuda-prebuilt-experiment" + require( + workflow.count( + "if: ${{ github.event.inputs.native_release_tag != " + f"'{experiment_marker}' }}" + ) + == 5, + "the CUDA prebuilt experiment must skip every normal platform build job", + errors, + ) + require( + f'if [ "$RELEASE_TAG" = "{experiment_marker}" ]' in workflow + and "is strictly non-publishing" in workflow, + "the CUDA prebuilt experiment marker must reject publishing dispatches", + errors, + ) + require( + "windows-cuda-prebuilt-experiment:" in workflow + and "github.event.inputs.publish_release == 'false'" in workflow + and "python tools/package_upstream_cuda.py" in workflow + and "python tools/smoke_windows_cuda_pack.py" in workflow + and "compression-level: 0" in workflow, + "the non-publishing Windows experiment must package, loader-smoke, and upload precompressed CUDA packs", + errors, + ) + def verify_manifest_contract(errors: list[str]) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/tools/package_upstream_cuda.py b/tools/package_upstream_cuda.py new file mode 100644 index 0000000..6f5bd57 --- /dev/null +++ b/tools/package_upstream_cuda.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Validate and repackage an exact-tag upstream Windows CUDA backend. + +The upstream llama.cpp release splits ``ggml-cuda.dll`` from the CUDA runtime +DLLs. This tool verifies both source archives, checks the PE contracts against +the exact locally built ``ggml-base.dll``, and emits a collision-free optional +CUDA pack for llamadart-native consumers. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import gzip +import hashlib +import json +import mmap +from pathlib import Path +import re +import shutil +import struct +import tarfile +import tempfile +import zipfile + + +PE_MACHINE_AMD64 = 0x8664 +PE32_PLUS_MAGIC = 0x20B + + +class PackagingError(RuntimeError): + """Raised when an upstream asset violates the CUDA pack contract.""" + + +@dataclass(frozen=True) +class PeInfo: + machine: int + optional_magic: int + exports: frozenset[str] + imports: dict[str, frozenset[str]] + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require_sha256(path: Path, expected: str) -> str: + actual = sha256(path) + normalized = expected.removeprefix("sha256:").lower() + if actual != normalized: + raise PackagingError( + f"SHA-256 mismatch for {path.name}: expected {normalized}, got {actual}" + ) + return actual + + +class PeReader: + def __init__(self, path: Path) -> None: + self.path = path + self._source = path.open("rb") + try: + self.data = mmap.mmap(self._source.fileno(), 0, access=mmap.ACCESS_READ) + except (OSError, ValueError): + self._source.close() + raise PackagingError(f"Unable to map PE file: {path.name}") + self.sections: list[tuple[int, int, int, int]] = [] + + def close(self) -> None: + self.data.close() + self._source.close() + + def _unpack(self, fmt: str, offset: int) -> tuple[int, ...]: + size = struct.calcsize(fmt) + if offset < 0 or offset + size > len(self.data): + raise PackagingError(f"Malformed PE structure in {self.path.name}") + return struct.unpack_from(fmt, self.data, offset) + + def _cstring(self, offset: int) -> str: + if offset < 0 or offset >= len(self.data): + raise PackagingError(f"Malformed PE string in {self.path.name}") + end = self.data.find(b"\0", offset) + if end < 0: + raise PackagingError(f"Unterminated PE string in {self.path.name}") + try: + return self.data[offset:end].decode("ascii") + except UnicodeDecodeError as error: + raise PackagingError(f"Non-ASCII PE string in {self.path.name}") from error + + def _rva_offset(self, rva: int) -> int: + if rva == 0: + return 0 + for virtual_address, virtual_size, raw_offset, raw_size in self.sections: + span = max(virtual_size, raw_size) + if virtual_address <= rva < virtual_address + span: + offset = raw_offset + rva - virtual_address + if offset >= len(self.data): + break + return offset + raise PackagingError( + f"PE RVA 0x{rva:x} is outside file-backed sections in {self.path.name}" + ) + + def read(self) -> PeInfo: + if len(self.data) < 0x40 or self.data[:2] != b"MZ": + raise PackagingError(f"Not a PE file: {self.path.name}") + (pe_offset,) = self._unpack(" set[str]: + if directory_rva == 0: + return set() + offset = self._rva_offset(directory_rva) + fields = self._unpack(" dict[str, frozenset[str]]: + if directory_rva == 0: + return {} + offset = self._rva_offset(directory_rva) + imports: dict[str, frozenset[str]] = {} + while True: + original_thunk, timestamp, forwarder, name_rva, first_thunk = self._unpack( + " PeInfo: + reader = PeReader(path) + try: + info = reader.read() + finally: + reader.close() + if info.machine != PE_MACHINE_AMD64: + raise PackagingError( + f"Expected x86-64 PE image in {path.name}, got machine 0x{info.machine:x}" + ) + return info + + +def find_unique_member(archive: zipfile.ZipFile, expected_name: str) -> str: + matches = [ + name + for name in archive.namelist() + if not name.endswith("/") and Path(name).name.lower() == expected_name.lower() + ] + if len(matches) != 1: + raise PackagingError( + f"Expected exactly one {expected_name} in {Path(archive.filename).name}, " + f"found {len(matches)}" + ) + return matches[0] + + +def extract_member(archive_path: Path, member_name: str, output: Path) -> None: + with zipfile.ZipFile(archive_path) as archive: + member = find_unique_member(archive, member_name) + output.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member) as source, output.open("wb") as destination: + shutil.copyfileobj(source, destination) + + +def validate_backend( + backend_path: Path, + core_path: Path, + cuda_major: str, +) -> tuple[PeInfo, PeInfo]: + backend = inspect_pe(backend_path) + core = inspect_pe(core_path) + if "ggml_backend_init" not in backend.exports: + raise PackagingError("Upstream CUDA backend does not export ggml_backend_init") + + core_imports = backend.imports.get("ggml-base.dll") + if not core_imports: + raise PackagingError("Upstream CUDA backend does not import ggml-base.dll") + missing = sorted(core_imports - core.exports) + if missing: + raise PackagingError( + "CUDA backend imports symbols absent from the exact core: " + + ", ".join(missing) + ) + + expected_cublas = f"cublas64_{cuda_major}.dll" + if expected_cublas not in backend.imports: + raise PackagingError( + f"CUDA backend does not import the expected runtime {expected_cublas}" + ) + return backend, core + + +def write_deterministic_tar_gz(source_dir: Path, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as raw: + with gzip.GzipFile( + filename="", fileobj=raw, mode="wb", compresslevel=6, mtime=0 + ) as compressed: + with tarfile.open(fileobj=compressed, mode="w") as archive: + for path in sorted(source_dir.iterdir()): + info = archive.gettarinfo(str(path), arcname=path.name) + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + with path.open("rb") as source: + archive.addfile(info, source) + + +def package(args: argparse.Namespace) -> Path: + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", args.tag) is None: + raise PackagingError(f"Invalid llama.cpp tag: {args.tag}") + if re.fullmatch(r"[0-9a-fA-F]{40}", args.llama_commit) is None: + raise PackagingError("llama.cpp commit must be a full 40-character SHA") + cuda_major = args.cuda_version.split(".", 1)[0] + if cuda_major not in {"12", "13"}: + raise PackagingError("Only CUDA 12.x and CUDA 13.x packs are supported") + + expected_backend_name = ( + f"llama-{args.tag}-bin-win-cuda-{args.cuda_version}-x64.zip" + ) + expected_runtime_name = ( + f"cudart-llama-bin-win-cuda-{args.cuda_version}-x64.zip" + ) + for actual, expected in ( + (args.backend_archive.name, expected_backend_name), + (args.runtime_archive.name, expected_runtime_name), + ): + if actual != expected: + raise PackagingError( + f"Upstream asset version mismatch: expected {expected}, got {actual}" + ) + + backend_archive_sha = require_sha256( + args.backend_archive, args.backend_sha256 + ) + runtime_archive_sha = require_sha256( + args.runtime_archive, args.runtime_sha256 + ) + + with tempfile.TemporaryDirectory(prefix="llamadart-cuda-pack-") as directory: + staging = Path(directory) + backend_name = f"ggml-cuda-{cuda_major}.dll" + backend_path = staging / backend_name + extract_member(args.backend_archive, "ggml-cuda.dll", backend_path) + + runtime_names = [ + f"cudart64_{cuda_major}.dll", + f"cublas64_{cuda_major}.dll", + f"cublasLt64_{cuda_major}.dll", + ] + for runtime_name in runtime_names: + extract_member(args.runtime_archive, runtime_name, staging / runtime_name) + + backend, _ = validate_backend(backend_path, args.core_dll, cuda_major) + for runtime_name in runtime_names: + inspect_pe(staging / runtime_name) + + files = [] + for path in sorted(staging.iterdir()): + files.append( + {"name": path.name, "sha256": sha256(path), "size": path.stat().st_size} + ) + + metadata = { + "contract_version": 1, + "llama_cpp_tag": args.tag, + "llama_cpp_commit": args.llama_commit, + "platform": "windows", + "arch": "x64", + "backend": "cuda", + "cuda_version": args.cuda_version, + "cuda_major": int(cuda_major), + "backend_library": backend_name, + "source_assets": [ + { + "name": args.backend_archive.name, + "sha256": backend_archive_sha, + "url": "https://github.com/ggml-org/llama.cpp/releases/" + f"download/{args.tag}/{args.backend_archive.name}", + }, + { + "name": args.runtime_archive.name, + "sha256": runtime_archive_sha, + "url": "https://github.com/ggml-org/llama.cpp/releases/" + f"download/{args.tag}/{args.runtime_archive.name}", + }, + ], + "backend_exports": sorted(backend.exports), + "backend_imports": { + name: sorted(symbols) for name, symbols in sorted(backend.imports.items()) + }, + "files": files, + } + metadata_path = staging / "cuda-pack.json" + metadata_path.write_text( + json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + output = args.output_dir / ( + f"llamadart-native-windows-x64-cuda{cuda_major}-{args.tag}.tar.gz" + ) + write_deterministic_tar_gz(staging, output) + return output + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--llama-commit", required=True) + parser.add_argument("--cuda-version", required=True) + parser.add_argument("--backend-archive", required=True, type=Path) + parser.add_argument("--backend-sha256", required=True) + parser.add_argument("--runtime-archive", required=True, type=Path) + parser.add_argument("--runtime-sha256", required=True) + parser.add_argument("--core-dll", required=True, type=Path) + parser.add_argument("--output-dir", required=True, type=Path) + return parser.parse_args() + + +def main() -> int: + try: + output = package(parse_args()) + except (PackagingError, OSError, zipfile.BadZipFile) as error: + print(f"ERROR: {error}") + return 1 + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/smoke_windows_cuda_pack.py b/tools/smoke_windows_cuda_pack.py new file mode 100644 index 0000000..8920c52 --- /dev/null +++ b/tools/smoke_windows_cuda_pack.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Smoke a repackaged CUDA backend through the exact Windows ggml loader.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import os +from pathlib import Path + + +LOAD_WITH_ALTERED_SEARCH_PATH = 0x00000008 + + +def require_file(path: Path) -> None: + if not path.is_file(): + raise RuntimeError(f"Required smoke file is missing: {path}") + + +def smoke(directory: Path, backend_name: str) -> dict[str, object]: + if os.name != "nt": + raise RuntimeError("Windows CUDA pack smoke must run on Windows") + + directory = directory.resolve() + backend_path = directory / backend_name + ggml_path = directory / "ggml.dll" + for path in (backend_path, ggml_path, directory / "ggml-base.dll"): + require_file(path) + + with os.add_dll_directory(str(directory)): + backend_library = ctypes.CDLL( + str(backend_path), winmode=LOAD_WITH_ALTERED_SEARCH_PATH + ) + getattr(backend_library, "ggml_backend_init") + + ggml_library = ctypes.CDLL( + str(ggml_path), winmode=LOAD_WITH_ALTERED_SEARCH_PATH + ) + load = ggml_library.ggml_backend_load + load.argtypes = [ctypes.c_char_p] + load.restype = ctypes.c_void_p + unload = ggml_library.ggml_backend_unload + unload.argtypes = [ctypes.c_void_p] + unload.restype = None + + registry = load(os.fsencode(backend_path)) + if not registry: + raise RuntimeError( + f"ggml_backend_load rejected the repackaged backend: {backend_path}" + ) + unload(registry) + + return { + "backend": backend_name, + "directory": str(directory), + "direct_load": True, + "ggml_backend_load": True, + "ggml_backend_unload": True, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", required=True, type=Path) + parser.add_argument("--backend", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + result = smoke(args.directory, args.backend) + except (AttributeError, OSError, RuntimeError) as error: + print(f"ERROR: {error}") + return 1 + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/tests/test_package_upstream_cuda.py b/tools/tests/test_package_upstream_cuda.py new file mode 100644 index 0000000..151b1af --- /dev/null +++ b/tools/tests/test_package_upstream_cuda.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock +import zipfile + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import package_upstream_cuda as subject # noqa: E402 + + +def write_zip(path: Path, entries: dict[str, bytes]) -> None: + with zipfile.ZipFile(path, "w") as archive: + for name, contents in entries.items(): + archive.writestr(name, contents) + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class PackageUpstreamCudaTest(unittest.TestCase): + def test_hash_mismatch_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + archive = Path(directory) / "asset.zip" + archive.write_bytes(b"asset") + with self.assertRaisesRegex(subject.PackagingError, "SHA-256 mismatch"): + subject.require_sha256(archive, "0" * 64) + + def test_member_lookup_rejects_ambiguous_backend(self) -> None: + with tempfile.TemporaryDirectory() as directory: + archive_path = Path(directory) / "backend.zip" + write_zip( + archive_path, + {"ggml-cuda.dll": b"one", "nested/ggml-cuda.dll": b"two"}, + ) + with zipfile.ZipFile(archive_path) as archive: + with self.assertRaisesRegex(subject.PackagingError, "exactly one"): + subject.find_unique_member(archive, "ggml-cuda.dll") + + def test_backend_rejects_core_version_skew(self) -> None: + backend = subject.PeInfo( + machine=subject.PE_MACHINE_AMD64, + optional_magic=subject.PE32_PLUS_MAGIC, + exports=frozenset({"ggml_backend_init"}), + imports={ + "ggml-base.dll": frozenset({"ggml_present", "ggml_missing"}), + "cublas64_13.dll": frozenset(), + }, + ) + core = subject.PeInfo( + machine=subject.PE_MACHINE_AMD64, + optional_magic=subject.PE32_PLUS_MAGIC, + exports=frozenset({"ggml_present"}), + imports={}, + ) + with mock.patch.object(subject, "inspect_pe", side_effect=[backend, core]): + with self.assertRaisesRegex(subject.PackagingError, "ggml_missing"): + subject.validate_backend(Path("backend"), Path("core"), "13") + + def test_package_rejects_asset_from_a_different_tag(self) -> None: + args = argparse.Namespace( + tag="b-new", + llama_commit="1" * 40, + cuda_version="13.3", + backend_archive=Path("llama-b-old-bin-win-cuda-13.3-x64.zip"), + backend_sha256="0" * 64, + runtime_archive=Path("cudart-llama-bin-win-cuda-13.3-x64.zip"), + runtime_sha256="0" * 64, + core_dll=Path("ggml-base.dll"), + output_dir=Path("output"), + ) + with self.assertRaisesRegex(subject.PackagingError, "version mismatch"): + subject.package(args) + + def test_packages_only_variant_specific_runtime_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + backend_archive = root / "llama-b-test-bin-win-cuda-13.3-x64.zip" + runtime_archive = ( + root / "cudart-llama-bin-win-cuda-13.3-x64.zip" + ) + core = root / "ggml-base.dll" + core.write_bytes(b"core") + write_zip( + backend_archive, + {"ggml-cuda.dll": b"backend", "ggml-base.dll": b"wrong-core"}, + ) + write_zip( + runtime_archive, + { + "cudart64_13.dll": b"cudart", + "cublas64_13.dll": b"cublas", + "cublasLt64_13.dll": b"cublas-lt", + "cublas64_12.dll": b"wrong-variant", + }, + ) + backend_info = subject.PeInfo( + machine=subject.PE_MACHINE_AMD64, + optional_magic=subject.PE32_PLUS_MAGIC, + exports=frozenset({"ggml_backend_init"}), + imports={ + "ggml-base.dll": frozenset({"ggml_abort"}), + "cublas64_13.dll": frozenset(), + }, + ) + core_info = subject.PeInfo( + machine=subject.PE_MACHINE_AMD64, + optional_magic=subject.PE32_PLUS_MAGIC, + exports=frozenset({"ggml_abort"}), + imports={}, + ) + args = argparse.Namespace( + tag="b-test", + llama_commit="1" * 40, + cuda_version="13.3", + backend_archive=backend_archive, + backend_sha256=digest(backend_archive), + runtime_archive=runtime_archive, + runtime_sha256=digest(runtime_archive), + core_dll=core, + output_dir=root / "output", + ) + inspections = [ + backend_info, + core_info, + core_info, + core_info, + core_info, + ] + with mock.patch.object(subject, "inspect_pe", side_effect=inspections): + output = subject.package(args) + + self.assertTrue(output.is_file()) + with subject.tarfile.open(output, "r:gz") as archive: + names = set(archive.getnames()) + self.assertEqual( + names, + { + "cuda-pack.json", + "ggml-cuda-13.dll", + "cudart64_13.dll", + "cublas64_13.dll", + "cublasLt64_13.dll", + }, + ) + + def test_archive_is_reproducible_across_output_names(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + (source / "payload.dll").write_bytes(b"payload") + first = root / "first.tar.gz" + second = root / "second.tar.gz" + subject.write_deterministic_tar_gz(source, first) + subject.write_deterministic_tar_gz(source, second) + self.assertEqual(first.read_bytes(), second.read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_smoke_windows_cuda_pack.py b/tools/tests/test_smoke_windows_cuda_pack.py new file mode 100644 index 0000000..e1133c0 --- /dev/null +++ b/tools/tests/test_smoke_windows_cuda_pack.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from pathlib import Path +import sys +import unittest + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import smoke_windows_cuda_pack as subject # noqa: E402 + + +class SmokeWindowsCudaPackTest(unittest.TestCase): + @unittest.skipIf(os.name == "nt", "non-Windows contract") + def test_rejects_non_windows_host(self) -> None: + with self.assertRaisesRegex(RuntimeError, "must run on Windows"): + subject.smoke(Path("unused"), "ggml-cuda-13.dll") + + +if __name__ == "__main__": + unittest.main() From c322a00487dec090385d1fc50a3c9ef84497d97b Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 17 Aug 2026 10:34:14 -0400 Subject: [PATCH 2/4] ci: handle GPU-less CUDA pack smoke --- .github/workflows/native_release.yml | 12 +++++-- scripts/verify_release_provenance.py | 3 +- tools/package_upstream_cuda.py | 38 +++++++++++++++++++++-- tools/smoke_windows_cuda_pack.py | 36 ++++++++++++++++++--- tools/tests/test_package_upstream_cuda.py | 28 +++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/.github/workflows/native_release.yml b/.github/workflows/native_release.yml index 6465607..d42fe08 100644 --- a/.github/workflows/native_release.yml +++ b/.github/workflows/native_release.yml @@ -1164,9 +1164,15 @@ jobs: Copy-Item $source $smokeDir } } - python tools/smoke_windows_cuda_pack.py ` - --directory $smokeDir ` - --backend "ggml-cuda-$major.dll" + $smokeArgs = @( + 'tools/smoke_windows_cuda_pack.py', + '--directory', $smokeDir, + '--backend', "ggml-cuda-$major.dll" + ) + if ($major -eq '12') { + $smokeArgs += '--allow-missing-nvidia-driver' + } + python @smokeArgs if ($LASTEXITCODE -ne 0) { throw "CUDA $major loader smoke failed" } diff --git a/scripts/verify_release_provenance.py b/scripts/verify_release_provenance.py index 3b5d25a..6a864f2 100755 --- a/scripts/verify_release_provenance.py +++ b/scripts/verify_release_provenance.py @@ -99,7 +99,8 @@ def verify_workflow_contract(errors: list[str]) -> None: "windows-cuda-prebuilt-experiment:" in workflow and "github.event.inputs.publish_release == 'false'" in workflow and "python tools/package_upstream_cuda.py" in workflow - and "python tools/smoke_windows_cuda_pack.py" in workflow + and "tools/smoke_windows_cuda_pack.py" in workflow + and "python @smokeArgs" in workflow and "compression-level: 0" in workflow, "the non-publishing Windows experiment must package, loader-smoke, and upload precompressed CUDA packs", errors, diff --git a/tools/package_upstream_cuda.py b/tools/package_upstream_cuda.py index 6f5bd57..d33aee9 100644 --- a/tools/package_upstream_cuda.py +++ b/tools/package_upstream_cuda.py @@ -26,6 +26,16 @@ PE_MACHINE_AMD64 = 0x8664 PE32_PLUS_MAGIC = 0x20B +WINDOWS_EXTERNAL_IMPORTS = frozenset( + { + "kernel32.dll", + "msvcp140.dll", + "nvcuda.dll", + "vcomp140.dll", + "vcruntime140.dll", + "vcruntime140_1.dll", + } +) class PackagingError(RuntimeError): @@ -249,6 +259,27 @@ def validate_backend( return backend, core +def validate_dependency_closure(images: dict[str, PeInfo]) -> set[str]: + available = {name.lower() for name in images} + external: set[str] = set() + missing: set[str] = set() + for image in images.values(): + for imported_name in image.imports: + name = imported_name.lower() + if name in available: + continue + if name.startswith("api-ms-win-") or name in WINDOWS_EXTERNAL_IMPORTS: + external.add(name) + continue + missing.add(name) + if missing: + raise PackagingError( + "CUDA pack has unresolved non-system DLL imports: " + + ", ".join(sorted(missing)) + ) + return external + + def write_deterministic_tar_gz(source_dir: Path, output: Path) -> None: output.parent.mkdir(parents=True, exist_ok=True) with output.open("wb") as raw: @@ -312,9 +343,11 @@ def package(args: argparse.Namespace) -> Path: for runtime_name in runtime_names: extract_member(args.runtime_archive, runtime_name, staging / runtime_name) - backend, _ = validate_backend(backend_path, args.core_dll, cuda_major) + backend, core = validate_backend(backend_path, args.core_dll, cuda_major) + images = {backend_name: backend, "ggml-base.dll": core} for runtime_name in runtime_names: - inspect_pe(staging / runtime_name) + images[runtime_name] = inspect_pe(staging / runtime_name) + external_imports = validate_dependency_closure(images) files = [] for path in sorted(staging.iterdir()): @@ -350,6 +383,7 @@ def package(args: argparse.Namespace) -> Path: "backend_imports": { name: sorted(symbols) for name, symbols in sorted(backend.imports.items()) }, + "external_imports": sorted(external_imports), "files": files, } metadata_path = staging / "cuda-pack.json" diff --git a/tools/smoke_windows_cuda_pack.py b/tools/smoke_windows_cuda_pack.py index 8920c52..36caace 100644 --- a/tools/smoke_windows_cuda_pack.py +++ b/tools/smoke_windows_cuda_pack.py @@ -18,7 +18,12 @@ def require_file(path: Path) -> None: raise RuntimeError(f"Required smoke file is missing: {path}") -def smoke(directory: Path, backend_name: str) -> dict[str, object]: +def smoke( + directory: Path, + backend_name: str, + *, + allow_missing_nvidia_driver: bool = False, +) -> dict[str, object]: if os.name != "nt": raise RuntimeError("Windows CUDA pack smoke must run on Windows") @@ -29,9 +34,25 @@ def smoke(directory: Path, backend_name: str) -> dict[str, object]: require_file(path) with os.add_dll_directory(str(directory)): - backend_library = ctypes.CDLL( - str(backend_path), winmode=LOAD_WITH_ALTERED_SEARCH_PATH - ) + try: + backend_library = ctypes.CDLL( + str(backend_path), winmode=LOAD_WITH_ALTERED_SEARCH_PATH + ) + except OSError: + if not allow_missing_nvidia_driver: + raise + try: + ctypes.CDLL("nvcuda.dll") + except OSError: + return { + "backend": backend_name, + "directory": str(directory), + "direct_load": False, + "ggml_backend_load": False, + "ggml_backend_unload": False, + "skip_reason": "nvcuda.dll is unavailable on this runner", + } + raise getattr(backend_library, "ggml_backend_init") ggml_library = ctypes.CDLL( @@ -64,13 +85,18 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--directory", required=True, type=Path) parser.add_argument("--backend", required=True) + parser.add_argument("--allow-missing-nvidia-driver", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() try: - result = smoke(args.directory, args.backend) + result = smoke( + args.directory, + args.backend, + allow_missing_nvidia_driver=args.allow_missing_nvidia_driver, + ) except (AttributeError, OSError, RuntimeError) as error: print(f"ERROR: {error}") return 1 diff --git a/tools/tests/test_package_upstream_cuda.py b/tools/tests/test_package_upstream_cuda.py index 151b1af..afedbe0 100644 --- a/tools/tests/test_package_upstream_cuda.py +++ b/tools/tests/test_package_upstream_cuda.py @@ -67,6 +67,34 @@ def test_backend_rejects_core_version_skew(self) -> None: with self.assertRaisesRegex(subject.PackagingError, "ggml_missing"): subject.validate_backend(Path("backend"), Path("core"), "13") + def test_dependency_closure_rejects_unknown_runtime(self) -> None: + image = subject.PeInfo( + machine=subject.PE_MACHINE_AMD64, + optional_magic=subject.PE32_PLUS_MAGIC, + exports=frozenset(), + imports={"unexpected-runtime.dll": frozenset()}, + ) + with self.assertRaisesRegex( + subject.PackagingError, "unexpected-runtime.dll" + ): + subject.validate_dependency_closure({"backend.dll": image}) + + def test_dependency_closure_allows_nvidia_driver(self) -> None: + image = subject.PeInfo( + machine=subject.PE_MACHINE_AMD64, + optional_magic=subject.PE32_PLUS_MAGIC, + exports=frozenset(), + imports={ + "nvcuda.dll": frozenset(), + "api-ms-win-crt-runtime-l1-1-0.dll": frozenset(), + }, + ) + external = subject.validate_dependency_closure({"backend.dll": image}) + self.assertEqual( + external, + {"nvcuda.dll", "api-ms-win-crt-runtime-l1-1-0.dll"}, + ) + def test_package_rejects_asset_from_a_different_tag(self) -> None: args = argparse.Namespace( tag="b-new", From 57da8b5515296e85e49dc8595ab65907f1283b87 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 17 Aug 2026 11:07:13 -0400 Subject: [PATCH 3/4] ci: harden CUDA pack compatibility checks --- .github/workflows/native_release.yml | 43 ++++ scripts/verify_release_provenance.py | 6 +- tools/cuda_pack_contract.py | 184 ++++++++++++++++ tools/package_upstream_cuda.py | 26 ++- tools/tests/test_cuda_pack_contract.py | 143 +++++++++++++ tools/tests/test_package_upstream_cuda.py | 28 ++- tools/tests/test_verify_cuda_pack.py | 130 +++++++++++ tools/verify_cuda_pack.py | 250 ++++++++++++++++++++++ 8 files changed, 803 insertions(+), 7 deletions(-) create mode 100644 tools/cuda_pack_contract.py create mode 100644 tools/tests/test_cuda_pack_contract.py create mode 100644 tools/tests/test_verify_cuda_pack.py create mode 100644 tools/verify_cuda_pack.py diff --git a/.github/workflows/native_release.yml b/.github/workflows/native_release.yml index d42fe08..fe06dc7 100644 --- a/.github/workflows/native_release.yml +++ b/.github/workflows/native_release.yml @@ -1014,6 +1014,8 @@ jobs: GH_TOKEN: ${{ github.token }} LLAMA_CPP_TAG: ${{ needs.resolve-tag.outputs.llama_cpp_ref }} LLAMA_CPP_COMMIT: ${{ needs.resolve-tag.outputs.llama_cpp_commit }} + CUDA_CUOBJDUMP_VERSION: '13.3.29' + CUDA_CUOBJDUMP_ARCHIVE_SHA256: '50c8ab72fdfec7e5958fbd61988719460093d7ac86e2893377e77c8d908a2350' steps: - uses: actions/checkout@v4 with: @@ -1022,6 +1024,31 @@ jobs: - name: Verify packaging unit contracts run: python -m unittest discover -s tools/tests -v + - name: Install exact GPU-less fatbin inspector + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $archiveName = "cuda_cuobjdump-windows-x86_64-$env:CUDA_CUOBJDUMP_VERSION-archive.zip" + $archive = Join-Path $env:RUNNER_TEMP $archiveName + $url = "https://developer.download.nvidia.com/compute/cuda/redist/cuda_cuobjdump/windows-x86_64/$archiveName" + Invoke-WebRequest -Uri $url -OutFile $archive + $actual = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:CUDA_CUOBJDUMP_ARCHIVE_SHA256) { + throw "NVIDIA cuobjdump archive digest mismatch: expected $env:CUDA_CUOBJDUMP_ARCHIVE_SHA256, got $actual" + } + $destination = Join-Path $env:RUNNER_TEMP 'cuda-cuobjdump' + Expand-Archive -Path $archive -DestinationPath $destination + $tools = @(Get-ChildItem $destination -Recurse -File -Filter 'cuobjdump.exe') + if ($tools.Count -ne 1) { + throw "Expected exactly one cuobjdump.exe, found $($tools.Count)" + } + & $tools[0].FullName --version + if ($LASTEXITCODE -ne 0) { + throw 'Pinned cuobjdump executable failed its version probe' + } + "CUDA_CUOBJDUMP=$($tools[0].FullName)" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Resolve and download exact release assets shell: pwsh run: | @@ -1134,6 +1161,7 @@ jobs: --runtime-archive (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR $runtimeName) ` --runtime-sha256 $metadata[$runtimeName] ` --core-dll $core ` + --cuobjdump $env:CUDA_CUOBJDUMP ` --output-dir $outputDir if ($LASTEXITCODE -ne 0) { throw "CUDA $version packaging failed" @@ -1145,6 +1173,21 @@ jobs: "CUDA pack preparation: $([math]::Round($timer.Elapsed.TotalSeconds, 1)) seconds" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + - name: Independently verify finished CUDA packs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + python tools/verify_cuda_pack.py ` + --pack (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` + "llamadart-native-windows-x64-cuda12-$env:LLAMA_CPP_TAG.tar.gz") ` + --pack (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` + "llamadart-native-windows-x64-cuda13-$env:LLAMA_CPP_TAG.tar.gz") ` + --expected-tag $env:LLAMA_CPP_TAG ` + --expected-commit $env:LLAMA_CPP_COMMIT + if ($LASTEXITCODE -ne 0) { + throw 'Finished CUDA pack verification failed' + } + - name: Smoke renamed CUDA backends through ggml loader shell: pwsh run: | diff --git a/scripts/verify_release_provenance.py b/scripts/verify_release_provenance.py index 6a864f2..a7f0bb3 100755 --- a/scripts/verify_release_provenance.py +++ b/scripts/verify_release_provenance.py @@ -98,11 +98,15 @@ def verify_workflow_contract(errors: list[str]) -> None: require( "windows-cuda-prebuilt-experiment:" in workflow and "github.event.inputs.publish_release == 'false'" in workflow + and "Install exact GPU-less fatbin inspector" in workflow + and "CUDA_CUOBJDUMP_ARCHIVE_SHA256" in workflow + and "--cuobjdump $env:CUDA_CUOBJDUMP" in workflow and "python tools/package_upstream_cuda.py" in workflow + and "python tools/verify_cuda_pack.py" in workflow and "tools/smoke_windows_cuda_pack.py" in workflow and "python @smokeArgs" in workflow and "compression-level: 0" in workflow, - "the non-publishing Windows experiment must package, loader-smoke, and upload precompressed CUDA packs", + "the non-publishing Windows experiment must inspect fatbins, independently verify, loader-smoke, and upload precompressed CUDA packs", errors, ) diff --git a/tools/cuda_pack_contract.py b/tools/cuda_pack_contract.py new file mode 100644 index 0000000..b366538 --- /dev/null +++ b/tools/cuda_pack_contract.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Shared, GPU-less compatibility contracts for optional Windows CUDA packs.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +from pathlib import Path +import re +import subprocess +from typing import Any, Iterable, Mapping + + +class CudaContractError(RuntimeError): + """Raised when CUDA device code or compatibility metadata is invalid.""" + + +CUOBJDUMP_VERSION = "13.3.29" +CUOBJDUMP_SHA256 = "b6f56c1eb5edd046949f9c947e730a1bf0ed5beff6fc20f8ccafd8a1f5d2eff1" + + +@dataclass(frozen=True) +class CudaVariant: + cuda_version: str + cuda_major: int + minimum_compute_capability: int + minimum_driver_family: int + ptx_architectures: frozenset[str] + sass_architectures: frozenset[str] + + +# These are the exact GGML_NATIVE=OFF defaults in llama.cpp b10453 for the two +# upstream Windows release variants. Treat changes as an intentional contract +# update instead of silently widening or narrowing supported hardware. +CUDA_VARIANTS: dict[str, CudaVariant] = { + "12.4": CudaVariant( + cuda_version="12.4", + cuda_major=12, + minimum_compute_capability=50, + minimum_driver_family=525, + ptx_architectures=frozenset({"50", "61", "70", "75", "80", "90"}), + sass_architectures=frozenset({"86", "89"}), + ), + "13.3": CudaVariant( + cuda_version="13.3", + cuda_major=13, + minimum_compute_capability=75, + minimum_driver_family=580, + ptx_architectures=frozenset({"75", "80", "90"}), + sass_architectures=frozenset({"86", "89", "120a", "121a"}), + ), +} + + +_ARCHITECTURE_PATTERN = re.compile(r"\bsm_([0-9]+[a-z]?)\b", re.IGNORECASE) + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _run_cuobjdump(cuobjdump: Path, *arguments: str) -> str: + try: + result = subprocess.run( + [str(cuobjdump), *arguments], + check=False, + capture_output=True, + text=True, + timeout=120, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise CudaContractError(f"Unable to run cuobjdump: {error}") from error + output = "\n".join(part for part in (result.stdout, result.stderr) if part) + if result.returncode != 0: + detail = output.strip() or f"exit code {result.returncode}" + raise CudaContractError(f"cuobjdump failed: {detail}") + return output + + +def parse_listed_architectures(output: str) -> frozenset[str]: + """Return architecture suffixes from cuobjdump list output.""" + + return frozenset(match.lower() for match in _ARCHITECTURE_PATTERN.findall(output)) + + +def inspect_device_code( + cuobjdump: Path, + backend: Path, + variant: CudaVariant, +) -> dict[str, Any]: + """Inspect and strictly match the PTX/SASS fatbin contract.""" + + version_output = _run_cuobjdump(cuobjdump, "--version").strip() + if CUOBJDUMP_VERSION not in version_output: + raise CudaContractError( + f"Expected cuobjdump {CUOBJDUMP_VERSION}, got {version_output!r}" + ) + inspector_sha256 = file_sha256(cuobjdump) + if inspector_sha256 != CUOBJDUMP_SHA256: + raise CudaContractError( + "cuobjdump executable digest differs from the pinned redistributable" + ) + sass = parse_listed_architectures( + _run_cuobjdump(cuobjdump, "--list-elf", str(backend)) + ) + ptx = parse_listed_architectures( + _run_cuobjdump(cuobjdump, "--list-ptx", str(backend)) + ) + if sass != variant.sass_architectures: + raise CudaContractError( + f"CUDA {variant.cuda_version} SASS architectures differ: " + f"expected {sorted(variant.sass_architectures)}, got {sorted(sass)}" + ) + if ptx != variant.ptx_architectures: + raise CudaContractError( + f"CUDA {variant.cuda_version} PTX architectures differ: " + f"expected {sorted(variant.ptx_architectures)}, got {sorted(ptx)}" + ) + return { + "inspector": { + "name": "NVIDIA cuobjdump", + "sha256": inspector_sha256, + "version": version_output, + }, + "ptx_architectures": sorted(ptx), + "sass_architectures": sorted(sass), + } + + +def validate_variant_metadata(manifest: Mapping[str, Any]) -> CudaVariant: + """Validate compatibility and fatbin fields against the known variant.""" + + cuda_version = manifest.get("cuda_version") + variant = CUDA_VARIANTS.get(cuda_version) + if variant is None: + raise CudaContractError(f"Unsupported CUDA pack version: {cuda_version!r}") + if manifest.get("cuda_major") != variant.cuda_major: + raise CudaContractError(f"CUDA {cuda_version} major-version metadata differs") + compatibility = manifest.get("compatibility") + device_code = manifest.get("device_code") + expected_compatibility = { + "minimum_compute_capability": variant.minimum_compute_capability, + "minimum_driver_family": variant.minimum_driver_family, + } + if compatibility != expected_compatibility: + raise CudaContractError( + f"CUDA {cuda_version} compatibility metadata differs from contract" + ) + if not isinstance(device_code, Mapping): + raise CudaContractError(f"CUDA {cuda_version} device-code metadata is missing") + if set(device_code.get("ptx_architectures", [])) != set( + variant.ptx_architectures + ): + raise CudaContractError(f"CUDA {cuda_version} PTX metadata differs") + if set(device_code.get("sass_architectures", [])) != set( + variant.sass_architectures + ): + raise CudaContractError(f"CUDA {cuda_version} SASS metadata differs") + return variant + + +def select_cuda_pack( + manifests: Iterable[Mapping[str, Any]], + *, + compute_capability: int, + driver_family: int, +) -> Mapping[str, Any] | None: + """Select the newest compatible pack from validated manifests.""" + + compatible: list[tuple[int, Mapping[str, Any]]] = [] + for manifest in manifests: + variant = validate_variant_metadata(manifest) + if ( + compute_capability >= variant.minimum_compute_capability + and driver_family >= variant.minimum_driver_family + ): + compatible.append((variant.cuda_major, manifest)) + if not compatible: + return None + return max(compatible, key=lambda item: item[0])[1] diff --git a/tools/package_upstream_cuda.py b/tools/package_upstream_cuda.py index d33aee9..18cae82 100644 --- a/tools/package_upstream_cuda.py +++ b/tools/package_upstream_cuda.py @@ -23,6 +23,12 @@ import tempfile import zipfile +from cuda_pack_contract import ( + CUDA_VARIANTS, + CudaContractError, + inspect_device_code, +) + PE_MACHINE_AMD64 = 0x8664 PE32_PLUS_MAGIC = 0x20B @@ -303,9 +309,12 @@ def package(args: argparse.Namespace) -> Path: raise PackagingError(f"Invalid llama.cpp tag: {args.tag}") if re.fullmatch(r"[0-9a-fA-F]{40}", args.llama_commit) is None: raise PackagingError("llama.cpp commit must be a full 40-character SHA") - cuda_major = args.cuda_version.split(".", 1)[0] - if cuda_major not in {"12", "13"}: - raise PackagingError("Only CUDA 12.x and CUDA 13.x packs are supported") + variant = CUDA_VARIANTS.get(args.cuda_version) + if variant is None: + raise PackagingError( + "Only the audited CUDA 12.4 and CUDA 13.3 packs are supported" + ) + cuda_major = str(variant.cuda_major) expected_backend_name = ( f"llama-{args.tag}-bin-win-cuda-{args.cuda_version}-x64.zip" @@ -334,6 +343,7 @@ def package(args: argparse.Namespace) -> Path: backend_name = f"ggml-cuda-{cuda_major}.dll" backend_path = staging / backend_name extract_member(args.backend_archive, "ggml-cuda.dll", backend_path) + device_code = inspect_device_code(args.cuobjdump, backend_path, variant) runtime_names = [ f"cudart64_{cuda_major}.dll", @@ -356,7 +366,7 @@ def package(args: argparse.Namespace) -> Path: ) metadata = { - "contract_version": 1, + "contract_version": 2, "llama_cpp_tag": args.tag, "llama_cpp_commit": args.llama_commit, "platform": "windows", @@ -365,6 +375,11 @@ def package(args: argparse.Namespace) -> Path: "cuda_version": args.cuda_version, "cuda_major": int(cuda_major), "backend_library": backend_name, + "compatibility": { + "minimum_compute_capability": variant.minimum_compute_capability, + "minimum_driver_family": variant.minimum_driver_family, + }, + "device_code": device_code, "source_assets": [ { "name": args.backend_archive.name, @@ -408,6 +423,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--runtime-archive", required=True, type=Path) parser.add_argument("--runtime-sha256", required=True) parser.add_argument("--core-dll", required=True, type=Path) + parser.add_argument("--cuobjdump", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path) return parser.parse_args() @@ -415,7 +431,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: try: output = package(parse_args()) - except (PackagingError, OSError, zipfile.BadZipFile) as error: + except (CudaContractError, PackagingError, OSError, zipfile.BadZipFile) as error: print(f"ERROR: {error}") return 1 print(output) diff --git a/tools/tests/test_cuda_pack_contract.py b/tools/tests/test_cuda_pack_contract.py new file mode 100644 index 0000000..4aa7917 --- /dev/null +++ b/tools/tests/test_cuda_pack_contract.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys +import unittest +from unittest import mock + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import cuda_pack_contract as subject # noqa: E402 + + +def manifest(version: str) -> dict[str, object]: + variant = subject.CUDA_VARIANTS[version] + return { + "cuda_version": version, + "cuda_major": variant.cuda_major, + "compatibility": { + "minimum_compute_capability": variant.minimum_compute_capability, + "minimum_driver_family": variant.minimum_driver_family, + }, + "device_code": { + "ptx_architectures": sorted(variant.ptx_architectures), + "sass_architectures": sorted(variant.sass_architectures), + }, + } + + +class CudaPackContractTest(unittest.TestCase): + def test_parses_architectures_with_family_suffixes(self) -> None: + output = "\n".join( + ( + "ELF file 1: kernels.sm_86.cubin", + "ELF file 2: kernels.sm_120a.cubin", + "ELF file 3: duplicate.sm_86.cubin", + ) + ) + self.assertEqual( + subject.parse_listed_architectures(output), + frozenset({"86", "120a"}), + ) + + def test_inspects_exact_cuda_13_device_code(self) -> None: + results = [ + subprocess.CompletedProcess([], 0, "cuobjdump release 13.3, V13.3.29", ""), + subprocess.CompletedProcess( + [], + 0, + "\n".join( + f"ELF file: ggml.sm_{arch}.cubin" + for arch in ("86", "89", "120a", "121a") + ), + "", + ), + subprocess.CompletedProcess( + [], + 0, + "\n".join( + f"PTX file: ggml.sm_{arch}.ptx" + for arch in ("75", "80", "90") + ), + "", + ), + ] + with ( + mock.patch.object(subject.subprocess, "run", side_effect=results), + mock.patch.object( + subject, "file_sha256", return_value=subject.CUOBJDUMP_SHA256 + ), + ): + result = subject.inspect_device_code( + Path("cuobjdump.exe"), + Path("ggml-cuda-13.dll"), + subject.CUDA_VARIANTS["13.3"], + ) + self.assertEqual(result["ptx_architectures"], ["75", "80", "90"]) + self.assertEqual( + result["sass_architectures"], ["120a", "121a", "86", "89"] + ) + self.assertEqual( + result["inspector"]["sha256"], subject.CUOBJDUMP_SHA256 + ) + + def test_missing_fatbin_target_fails_closed(self) -> None: + results = [ + subprocess.CompletedProcess([], 0, "cuobjdump release 13.3, V13.3.29", ""), + subprocess.CompletedProcess([], 0, "ELF file: ggml.sm_86.cubin", ""), + subprocess.CompletedProcess( + [], 0, "PTX file: ggml.sm_75.ptx", "" + ), + ] + with ( + mock.patch.object(subject.subprocess, "run", side_effect=results), + mock.patch.object( + subject, "file_sha256", return_value=subject.CUOBJDUMP_SHA256 + ), + ): + with self.assertRaisesRegex( + subject.CudaContractError, "SASS architectures differ" + ): + subject.inspect_device_code( + Path("cuobjdump.exe"), + Path("ggml-cuda-13.dll"), + subject.CUDA_VARIANTS["13.3"], + ) + + def test_selector_covers_driver_and_architecture_boundaries(self) -> None: + manifests = [manifest("12.4"), manifest("13.3")] + cases = ( + (49, 610, None), + (50, 524, None), + (50, 525, 12), + (70, 610, 12), + (75, 579, 12), + (75, 580, 13), + (120, 610, 13), + ) + for capability, driver, expected in cases: + with self.subTest(capability=capability, driver=driver): + selected = subject.select_cuda_pack( + manifests, + compute_capability=capability, + driver_family=driver, + ) + actual = None if selected is None else selected["cuda_major"] + self.assertEqual(actual, expected) + + def test_selector_rejects_tampered_architecture_metadata(self) -> None: + tampered = manifest("13.3") + tampered["device_code"]["ptx_architectures"] = ["75"] + with self.assertRaisesRegex(subject.CudaContractError, "PTX metadata"): + subject.select_cuda_pack( + [tampered], compute_capability=75, driver_family=580 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_package_upstream_cuda.py b/tools/tests/test_package_upstream_cuda.py index afedbe0..ee39b4e 100644 --- a/tools/tests/test_package_upstream_cuda.py +++ b/tools/tests/test_package_upstream_cuda.py @@ -105,6 +105,7 @@ def test_package_rejects_asset_from_a_different_tag(self) -> None: runtime_archive=Path("cudart-llama-bin-win-cuda-13.3-x64.zip"), runtime_sha256="0" * 64, core_dll=Path("ggml-base.dll"), + cuobjdump=Path("cuobjdump.exe"), output_dir=Path("output"), ) with self.assertRaisesRegex(subject.PackagingError, "version mismatch"): @@ -156,6 +157,7 @@ def test_packages_only_variant_specific_runtime_files(self) -> None: runtime_archive=runtime_archive, runtime_sha256=digest(runtime_archive), core_dll=core, + cuobjdump=Path("cuobjdump.exe"), output_dir=root / "output", ) inspections = [ @@ -165,7 +167,21 @@ def test_packages_only_variant_specific_runtime_files(self) -> None: core_info, core_info, ] - with mock.patch.object(subject, "inspect_pe", side_effect=inspections): + device_code = { + "inspector": { + "name": "NVIDIA cuobjdump", + "sha256": "b6f56c1eb5edd046949f9c947e730a1bf0ed5beff6fc20f8ccafd8a1f5d2eff1", + "version": "cuobjdump release 13.3, V13.3.29", + }, + "ptx_architectures": ["75", "80", "90"], + "sass_architectures": ["86", "89", "120a", "121a"], + } + with ( + mock.patch.object(subject, "inspect_pe", side_effect=inspections), + mock.patch.object( + subject, "inspect_device_code", return_value=device_code + ), + ): output = subject.package(args) self.assertTrue(output.is_file()) @@ -181,6 +197,16 @@ def test_packages_only_variant_specific_runtime_files(self) -> None: "cublasLt64_13.dll", }, ) + with subject.tarfile.open(output, "r:gz") as archive: + metadata = subject.json.loads( + archive.extractfile("cuda-pack.json").read() + ) + self.assertEqual(metadata["contract_version"], 2) + self.assertEqual(metadata["device_code"], device_code) + self.assertEqual( + metadata["compatibility"], + {"minimum_compute_capability": 75, "minimum_driver_family": 580}, + ) def test_archive_is_reproducible_across_output_names(self) -> None: with tempfile.TemporaryDirectory() as directory: diff --git a/tools/tests/test_verify_cuda_pack.py b/tools/tests/test_verify_cuda_pack.py new file mode 100644 index 0000000..f1b8db8 --- /dev/null +++ b/tools/tests/test_verify_cuda_pack.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import hashlib +import io +import json +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest + + +TOOLS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(TOOLS)) + +import cuda_pack_contract # noqa: E402 +import verify_cuda_pack as subject # noqa: E402 + + +def add_bytes(archive: tarfile.TarFile, name: str, contents: bytes) -> None: + info = tarfile.TarInfo(name) + info.size = len(contents) + archive.addfile(info, io.BytesIO(contents)) + + +def write_test_pack( + path: Path, + version: str, + *, + corrupt_hash: bool = False, + extra_name: str | None = None, +) -> None: + variant = cuda_pack_contract.CUDA_VARIANTS[version] + major = str(variant.cuda_major) + payload = { + f"ggml-cuda-{major}.dll": b"backend", + f"cudart64_{major}.dll": b"cudart", + f"cublas64_{major}.dll": b"cublas", + f"cublasLt64_{major}.dll": b"cublas-lt", + } + files = [] + for name, contents in sorted(payload.items()): + digest = hashlib.sha256(contents).hexdigest() + if corrupt_hash and name.startswith("ggml-cuda"): + digest = "0" * 64 + files.append({"name": name, "sha256": digest, "size": len(contents)}) + manifest = { + "contract_version": 2, + "llama_cpp_tag": "b-test", + "llama_cpp_commit": "1" * 40, + "platform": "windows", + "arch": "x64", + "backend": "cuda", + "cuda_version": version, + "cuda_major": variant.cuda_major, + "backend_library": f"ggml-cuda-{major}.dll", + "compatibility": { + "minimum_compute_capability": variant.minimum_compute_capability, + "minimum_driver_family": variant.minimum_driver_family, + }, + "device_code": { + "inspector": { + "name": "NVIDIA cuobjdump", + "sha256": cuda_pack_contract.CUOBJDUMP_SHA256, + "version": "cuobjdump release 13.3, V13.3.29", + }, + "ptx_architectures": sorted(variant.ptx_architectures), + "sass_architectures": sorted(variant.sass_architectures), + }, + "source_assets": [ + { + "name": f"llama-b-test-bin-win-cuda-{version}-x64.zip", + "sha256": "3" * 64, + "url": "https://github.com/ggml-org/llama.cpp/releases/" + f"download/b-test/llama-b-test-bin-win-cuda-{version}-x64.zip", + }, + { + "name": f"cudart-llama-bin-win-cuda-{version}-x64.zip", + "sha256": "4" * 64, + "url": "https://github.com/ggml-org/llama.cpp/releases/" + f"download/b-test/cudart-llama-bin-win-cuda-{version}-x64.zip", + }, + ], + "files": files, + } + with tarfile.open(path, "w:gz") as archive: + for name, contents in payload.items(): + add_bytes(archive, name, contents) + add_bytes( + archive, + "cuda-pack.json", + (json.dumps(manifest) + "\n").encode(), + ) + if extra_name is not None: + add_bytes(archive, extra_name, b"extra") + + +class VerifyCudaPackTest(unittest.TestCase): + def test_verifies_payload_hashes_and_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + pack = Path(directory) / "cuda13.tar.gz" + write_test_pack(pack, "13.3") + manifest = subject.verify_pack( + pack, expected_tag="b-test", expected_commit="1" * 40 + ) + self.assertEqual(manifest["cuda_major"], 13) + + def test_rejects_corrupt_payload_hash(self) -> None: + with tempfile.TemporaryDirectory() as directory: + pack = Path(directory) / "cuda12.tar.gz" + write_test_pack(pack, "12.4", corrupt_hash=True) + with self.assertRaisesRegex(subject.VerificationError, "SHA-256"): + subject.verify_pack( + pack, expected_tag="b-test", expected_commit="1" * 40 + ) + + def test_rejects_non_top_level_member(self) -> None: + with tempfile.TemporaryDirectory() as directory: + pack = Path(directory) / "cuda12.tar.gz" + write_test_pack(pack, "12.4", extra_name="../escape.dll") + with self.assertRaisesRegex(subject.VerificationError, "top-level"): + subject.verify_pack( + pack, expected_tag="b-test", expected_commit="1" * 40 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_cuda_pack.py b/tools/verify_cuda_pack.py new file mode 100644 index 0000000..819b5b9 --- /dev/null +++ b/tools/verify_cuda_pack.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Independently verify optional Windows CUDA pack archives without a GPU.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import tarfile +from typing import Any, BinaryIO, Mapping + +from cuda_pack_contract import ( + CUDA_VARIANTS, + CUOBJDUMP_SHA256, + CUOBJDUMP_VERSION, + CudaContractError, + select_cuda_pack, + validate_variant_metadata, +) + + +class VerificationError(RuntimeError): + """Raised when a CUDA pack archive violates its contract.""" + + +def stream_sha256(source: BinaryIO) -> str: + digest = hashlib.sha256() + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _read_member(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes: + source = archive.extractfile(member) + if source is None: + raise VerificationError(f"Unable to read pack member: {member.name}") + with source: + return source.read() + + +def verify_pack( + path: Path, + *, + expected_tag: str, + expected_commit: str, +) -> dict[str, Any]: + """Verify one pack and return its validated manifest.""" + + with tarfile.open(path, "r:gz") as archive: + members = archive.getmembers() + names = [member.name for member in members] + if len(names) != len(set(names)): + raise VerificationError(f"Pack contains duplicate members: {path.name}") + for member in members: + pure = PurePosixPath(member.name) + if ( + not member.isfile() + or pure.is_absolute() + or len(pure.parts) != 1 + or pure.name in {"", ".", ".."} + ): + raise VerificationError( + f"Pack member is not a regular top-level file: {member.name}" + ) + by_name = {member.name: member for member in members} + metadata_member = by_name.get("cuda-pack.json") + if metadata_member is None: + raise VerificationError("Pack is missing cuda-pack.json") + if metadata_member.size > 1024 * 1024: + raise VerificationError("Pack manifest exceeds the 1 MiB safety limit") + try: + manifest = json.loads(_read_member(archive, metadata_member)) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise VerificationError("Pack manifest is not valid UTF-8 JSON") from error + if not isinstance(manifest, dict): + raise VerificationError("Pack manifest must be a JSON object") + + if manifest.get("contract_version") != 2: + raise VerificationError("Pack contract version must be 2") + if manifest.get("llama_cpp_tag") != expected_tag: + raise VerificationError("Pack tag does not match the requested upstream tag") + if manifest.get("llama_cpp_commit") != expected_commit: + raise VerificationError("Pack commit does not match the requested upstream commit") + if manifest.get("platform") != "windows" or manifest.get("arch") != "x64": + raise VerificationError("Pack platform must be Windows x64") + + try: + variant = validate_variant_metadata(manifest) + except CudaContractError as error: + raise VerificationError(str(error)) from error + major = str(variant.cuda_major) + expected_backend = f"ggml-cuda-{major}.dll" + expected_payload = { + expected_backend, + f"cudart64_{major}.dll", + f"cublas64_{major}.dll", + f"cublasLt64_{major}.dll", + } + if manifest.get("backend_library") != expected_backend: + raise VerificationError("Pack backend filename is invalid") + if set(by_name) != expected_payload | {"cuda-pack.json"}: + raise VerificationError("Pack contains missing or unexpected payload files") + + file_entries = manifest.get("files") + if not isinstance(file_entries, list): + raise VerificationError("Pack manifest files must be an array") + entry_map: dict[str, Mapping[str, Any]] = {} + for entry in file_entries: + if not isinstance(entry, Mapping) or not isinstance(entry.get("name"), str): + raise VerificationError("Pack manifest contains an invalid file entry") + name = entry["name"] + if name in entry_map: + raise VerificationError(f"Duplicate manifest file entry: {name}") + entry_map[name] = entry + if set(entry_map) != expected_payload: + raise VerificationError("Manifest payload list differs from archive payload") + for name in sorted(expected_payload): + member = by_name[name] + entry = entry_map[name] + if entry.get("size") != member.size: + raise VerificationError(f"Manifest size mismatch for {name}") + source = archive.extractfile(member) + if source is None: + raise VerificationError(f"Unable to hash pack member: {name}") + with source: + actual = stream_sha256(source) + if entry.get("sha256") != actual: + raise VerificationError(f"Manifest SHA-256 mismatch for {name}") + + inspector = manifest.get("device_code", {}).get("inspector", {}) + if ( + inspector.get("name") != "NVIDIA cuobjdump" + or inspector.get("sha256") != CUOBJDUMP_SHA256 + or CUOBJDUMP_VERSION not in str(inspector.get("version", "")) + ): + raise VerificationError("Pack has incomplete fatbin inspector provenance") + + source_assets = manifest.get("source_assets", []) + if not isinstance(source_assets, list) or len(source_assets) != 2: + raise VerificationError("Pack must record exactly two source assets") + expected_sources = { + f"llama-{expected_tag}-bin-win-cuda-{variant.cuda_version}-x64.zip", + f"cudart-llama-bin-win-cuda-{variant.cuda_version}-x64.zip", + } + source_names: set[str] = set() + for entry in source_assets: + if not isinstance(entry, Mapping) or not isinstance(entry.get("name"), str): + raise VerificationError("Pack contains an invalid source asset entry") + name = entry["name"] + source_names.add(name) + if re.fullmatch(r"[0-9a-f]{64}", str(entry.get("sha256", ""))) is None: + raise VerificationError(f"Pack source asset has no SHA-256: {name}") + expected_url = ( + "https://github.com/ggml-org/llama.cpp/releases/" + f"download/{expected_tag}/{name}" + ) + if entry.get("url") != expected_url: + raise VerificationError(f"Pack source asset URL is invalid: {name}") + if source_names != expected_sources: + raise VerificationError("Pack source assets differ from exact upstream variant") + return manifest + + +def verify_selection_policy(manifests: list[Mapping[str, Any]]) -> None: + """Exercise important driver/architecture boundaries from pack metadata.""" + + versions = [manifest.get("cuda_version") for manifest in manifests] + if len(versions) != 2 or set(versions) != set(CUDA_VARIANTS): + raise VerificationError("Selection verification requires CUDA 12.4 and 13.3") + cases = ( + (49, 610, None), + (50, 524, None), + (50, 525, 12), + (70, 610, 12), + (75, 579, 12), + (75, 580, 13), + (120, 610, 13), + ) + for compute_capability, driver_family, expected_major in cases: + try: + selected = select_cuda_pack( + manifests, + compute_capability=compute_capability, + driver_family=driver_family, + ) + except CudaContractError as error: + raise VerificationError(str(error)) from error + actual_major = None if selected is None else selected.get("cuda_major") + if actual_major != expected_major: + raise VerificationError( + "CUDA selection policy mismatch for " + f"CC {compute_capability}, driver {driver_family}: " + f"expected {expected_major}, got {actual_major}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pack", action="append", required=True, type=Path) + parser.add_argument("--expected-tag", required=True) + parser.add_argument("--expected-commit", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + manifests = [ + verify_pack( + path, + expected_tag=args.expected_tag, + expected_commit=args.expected_commit, + ) + for path in args.pack + ] + verify_selection_policy(manifests) + except (OSError, tarfile.TarError, VerificationError) as error: + print(f"ERROR: {error}") + return 1 + print( + json.dumps( + { + "packs": [ + { + "cuda_version": manifest["cuda_version"], + "compatibility": manifest["compatibility"], + "device_code": { + "ptx_architectures": manifest["device_code"][ + "ptx_architectures" + ], + "sass_architectures": manifest["device_code"][ + "sass_architectures" + ], + }, + } + for manifest in manifests + ], + "selection_policy": "verified", + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7141e126b656357dece05d770e0f50a953dc3cf1 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 17 Aug 2026 13:22:28 -0400 Subject: [PATCH 4/4] ci: publish verified Windows CUDA sidecars --- .github/workflows/native_release.yml | 305 ++++++++++++++++------ AGENTS.md | 4 + scripts/generate_assets_manifest.sh | 11 + scripts/verify_release_provenance.py | 48 ++++ tools/cuda_pack_contract.py | 4 + tools/package_upstream_cuda.py | 18 +- tools/tests/test_cuda_pack_contract.py | 1 + tools/tests/test_package_upstream_cuda.py | 19 +- tools/tests/test_verify_cuda_pack.py | 67 ++++- tools/verify_cuda_pack.py | 29 +- 10 files changed, 417 insertions(+), 89 deletions(-) diff --git a/.github/workflows/native_release.yml b/.github/workflows/native_release.yml index fe06dc7..bd65f43 100644 --- a/.github/workflows/native_release.yml +++ b/.github/workflows/native_release.yml @@ -737,7 +737,6 @@ jobs: env: CMAKE_C_COMPILER_LAUNCHER: sccache CMAKE_CXX_COMPILER_LAUNCHER: sccache - CMAKE_CUDA_COMPILER_LAUNCHER: "" strategy: fail-fast: false matrix: @@ -747,25 +746,14 @@ jobs: runs_on: windows-latest backend: vulkan include_core: true - needs_cuda: false needs_openblas: false needs_vulkan: true backend_glob: "*ggml-vulkan*.dll" - - arch: x64 - vcpkg_triplet: x64-windows - runs_on: windows-2022 - backend: cuda - include_core: false - needs_cuda: true - needs_openblas: false - needs_vulkan: false - backend_glob: "*ggml-cuda*.dll" - arch: x64 vcpkg_triplet: x64-windows runs_on: windows-latest backend: blas include_core: false - needs_cuda: false needs_openblas: true needs_vulkan: false backend_glob: "*ggml-blas*.dll" @@ -774,7 +762,6 @@ jobs: runs_on: windows-11-arm backend: vulkan include_core: true - needs_cuda: false needs_openblas: false needs_vulkan: true backend_glob: "*ggml-vulkan*.dll" @@ -783,7 +770,6 @@ jobs: runs_on: windows-11-arm backend: blas include_core: false - needs_cuda: false needs_openblas: true needs_vulkan: false backend_glob: "*ggml-blas*.dll" @@ -813,23 +799,6 @@ jobs: - name: Install Ninja if: ${{ matrix.arch == 'x64' }} run: choco install ninja -y - - name: Install CUDA Toolkit - if: ${{ matrix.needs_cuda }} - run: | - $cudaRoot = Join-Path $env:ProgramFiles "NVIDIA GPU Computing Toolkit\CUDA\v12.8" - python tools/install_cuda_redist.py ` - --version "${env:CUDA_REDIST_VERSION}" ` - --platform windows-x86_64 ` - --destination "$cudaRoot" - Add-Content $env:GITHUB_PATH (Join-Path $cudaRoot "bin") - Add-Content $env:GITHUB_PATH (Join-Path $cudaRoot "nvvm\bin") - Add-Content $env:GITHUB_ENV "CUDA_PATH=$cudaRoot" - Add-Content $env:GITHUB_ENV "CUDAToolkit_ROOT=$cudaRoot" - $nvccVersion = & (Join-Path $cudaRoot "bin\nvcc.exe") --version - $nvccVersion - if (($nvccVersion -join "`n") -notmatch 'release 12\.8') { - throw "Expected CUDA ${env:CUDA_REDIST_VERSION} nvcc" - } - name: Restore vcpkg cache if: ${{ matrix.needs_openblas }} id: vcpkg-cache @@ -902,41 +871,6 @@ jobs: if (-not (Get-ChildItem $out -Filter "${{ matrix.backend_glob }}" -ErrorAction SilentlyContinue | Select-Object -First 1)) { throw "Missing backend DLL '${{ matrix.backend_glob }}' in $out" } - if ("${{ matrix.backend }}" -eq "cuda") { - if (-not (Get-ChildItem $out -Filter "cudart64_*.dll" -ErrorAction SilentlyContinue | Select-Object -First 1)) { - throw "Missing CUDA runtime dependency (cudart64_*.dll) in $out" - } - if (-not (Get-ChildItem $out -Filter "cublas64_*.dll" -ErrorAction SilentlyContinue | Select-Object -First 1)) { - throw "Missing CUDA runtime dependency (cublas64_*.dll) in $out" - } - if (-not (Get-ChildItem $out -Filter "cublasLt64_*.dll" -ErrorAction SilentlyContinue | Select-Object -First 1)) { - throw "Missing CUDA runtime dependency (cublasLt64_*.dll) in $out" - } - $cache = "build/wx64/CMakeCache.txt" - if (-not (Test-Path $cache)) { - throw "Missing CMake cache at $cache" - } - $cudaArchFiles = Get-ChildItem "build/wx64" -Recurse -File -Include CMakeCache.txt,*.ninja,*.vcxproj - if (-not ($cudaArchFiles | Select-String -Pattern "120a-real|compute_120a|sm_120a" -Quiet)) { - throw "Expected CUDA Blackwell architecture marker in windows CUDA build" - } - $cudaBin = Join-Path $env:CUDA_PATH "bin" - $cudaDlls = @() - $cudaDlls += Get-ChildItem $out -Filter "cudart64_*.dll" - $cudaDlls += Get-ChildItem $out -Filter "cublas64_*.dll" - $cudaDlls += Get-ChildItem $out -Filter "cublasLt64_*.dll" - foreach ($dll in $cudaDlls) { - $source = Join-Path $cudaBin $dll.Name - if (-not (Test-Path $source)) { - throw "Packaged CUDA DLL $($dll.Name) is not present in CUDA_PATH" - } - $packagedHash = (Get-FileHash $dll.FullName -Algorithm SHA256).Hash - $sourceHash = (Get-FileHash $source -Algorithm SHA256).Hash - if ($packagedHash -ne $sourceHash) { - throw "Packaged CUDA DLL $($dll.Name) does not match CUDA_PATH" - } - } - } if ("${{ matrix.backend }}" -eq "blas") { if (-not (Get-ChildItem $out -Filter "openblas*.dll" -ErrorAction SilentlyContinue | Select-Object -First 1)) { throw "Missing BLAS runtime dependency (openblas*.dll) in $out" @@ -970,11 +904,6 @@ jobs: $files = @( Get-ChildItem $out -Filter "${{ matrix.backend_glob }}" -ErrorAction Stop ) - if ("${{ matrix.backend }}" -eq "cuda") { - $files += Get-ChildItem $out -Filter "cudart64_*.dll" -ErrorAction SilentlyContinue - $files += Get-ChildItem $out -Filter "cublas64_*.dll" -ErrorAction SilentlyContinue - $files += Get-ChildItem $out -Filter "cublasLt64_*.dll" -ErrorAction SilentlyContinue - } if ("${{ matrix.backend }}" -eq "blas") { $files += Get-ChildItem $out -Filter "openblas*.dll" -ErrorAction SilentlyContinue } @@ -1002,6 +931,220 @@ jobs: C:\vcpkg\installed key: vcpkg-${{ runner.os }}-${{ matrix.vcpkg_triplet }}-${{ env.VCPKG_CACHE_VERSION }} + package-windows-cuda: + needs: [resolve-tag, build-windows] + runs-on: windows-latest + permissions: + contents: read + env: + GH_TOKEN: ${{ github.token }} + LLAMA_CPP_TAG: ${{ needs.resolve-tag.outputs.llama_cpp_ref }} + LLAMA_CPP_COMMIT: ${{ needs.resolve-tag.outputs.llama_cpp_commit }} + NATIVE_RELEASE_TAG: ${{ needs.resolve-tag.outputs.release_tag }} + CUDA_CUOBJDUMP_VERSION: '13.3.29' + CUDA_CUOBJDUMP_ARCHIVE_SHA256: '50c8ab72fdfec7e5958fbd61988719460093d7ac86e2893377e77c8d908a2350' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify packaging unit contracts + run: python -m unittest discover -s tools/tests -v + + - name: Download same-run Windows core + uses: actions/download-artifact@v4 + with: + name: native_windows_x64_vulkan + path: ${{ runner.temp }}/native-core + + - name: Resolve exact same-run core libraries + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $coreDir = Join-Path $env:RUNNER_TEMP 'native-core' + $base = @(Get-ChildItem $coreDir -File -Filter '*ggml-base*.dll') + if ($base.Count -ne 1) { + throw "Expected exactly one same-run ggml-base DLL, found $($base.Count)" + } + $ggml = @(Get-ChildItem $coreDir -File -Filter 'ggml.dll') + if ($ggml.Count -ne 1) { + throw "Expected exactly one same-run ggml.dll, found $($ggml.Count)" + } + "CUDA_PREBUILT_CORE_DIR=$coreDir" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CUDA_PREBUILT_CORE_DLL=$($base[0].FullName)" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Install exact GPU-less fatbin inspector + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $archiveName = "cuda_cuobjdump-windows-x86_64-$env:CUDA_CUOBJDUMP_VERSION-archive.zip" + $archive = Join-Path $env:RUNNER_TEMP $archiveName + $url = "https://developer.download.nvidia.com/compute/cuda/redist/cuda_cuobjdump/windows-x86_64/$archiveName" + Invoke-WebRequest -Uri $url -OutFile $archive + $actual = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:CUDA_CUOBJDUMP_ARCHIVE_SHA256) { + throw "NVIDIA cuobjdump archive digest mismatch: expected $env:CUDA_CUOBJDUMP_ARCHIVE_SHA256, got $actual" + } + $destination = Join-Path $env:RUNNER_TEMP 'cuda-cuobjdump' + Expand-Archive -Path $archive -DestinationPath $destination + $tools = @(Get-ChildItem $destination -Recurse -File -Filter 'cuobjdump.exe') + if ($tools.Count -ne 1) { + throw "Expected exactly one cuobjdump.exe, found $($tools.Count)" + } + & $tools[0].FullName --version + if ($LASTEXITCODE -ne 0) { + throw 'Pinned cuobjdump executable failed its version probe' + } + "CUDA_CUOBJDUMP=$($tools[0].FullName)" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Resolve and download exact upstream CUDA assets + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $downloadDir = Join-Path $env:RUNNER_TEMP 'cuda-prebuilt-downloads' + New-Item -ItemType Directory -Force -Path $downloadDir | Out-Null + + $upstreamRelease = gh api "repos/ggml-org/llama.cpp/releases/tags/$env:LLAMA_CPP_TAG" | + ConvertFrom-Json + if ($upstreamRelease.target_commitish -ne $env:LLAMA_CPP_COMMIT) { + throw "Upstream release target $($upstreamRelease.target_commitish) does not match resolved commit $env:LLAMA_CPP_COMMIT" + } + + $assetNames = @( + "llama-$env:LLAMA_CPP_TAG-bin-win-cuda-12.4-x64.zip", + 'cudart-llama-bin-win-cuda-12.4-x64.zip', + "llama-$env:LLAMA_CPP_TAG-bin-win-cuda-13.3-x64.zip", + 'cudart-llama-bin-win-cuda-13.3-x64.zip' + ) + $metadata = @{} + foreach ($assetName in $assetNames) { + $asset = $upstreamRelease.assets | + Where-Object name -EQ $assetName | + Select-Object -First 1 + if (-not $asset) { + throw "Required upstream release asset is missing: $assetName" + } + if ($asset.digest -notmatch '^sha256:[0-9a-f]{64}$') { + throw "Upstream release asset has no usable SHA-256 digest: $assetName" + } + $metadata[$assetName] = $asset.digest.Substring(7) + gh release download $env:LLAMA_CPP_TAG ` + --repo ggml-org/llama.cpp ` + --pattern $assetName ` + --dir $downloadDir + if ($LASTEXITCODE -ne 0) { + throw "Failed to download upstream release asset $assetName" + } + $path = Join-Path $downloadDir $assetName + $actual = (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $metadata[$assetName]) { + throw "Downloaded asset digest mismatch for $assetName" + } + } + + $metadata | ConvertTo-Json | Set-Content ` + (Join-Path $downloadDir 'release-assets.json') + "CUDA_PREBUILT_DOWNLOAD_DIR=$downloadDir" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Package CUDA 12.4 and CUDA 13.3 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $outputDir = Join-Path $env:RUNNER_TEMP 'cuda-packs' + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + $metadata = Get-Content ` + (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR 'release-assets.json') | + ConvertFrom-Json -AsHashtable + $timer = [Diagnostics.Stopwatch]::StartNew() + + foreach ($version in @('12.4', '13.3')) { + $backendName = "llama-$env:LLAMA_CPP_TAG-bin-win-cuda-$version-x64.zip" + $runtimeName = "cudart-llama-bin-win-cuda-$version-x64.zip" + python tools/package_upstream_cuda.py ` + --tag $env:LLAMA_CPP_TAG ` + --native-release-tag $env:NATIVE_RELEASE_TAG ` + --llama-commit $env:LLAMA_CPP_COMMIT ` + --cuda-version $version ` + --backend-archive (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR $backendName) ` + --backend-sha256 $metadata[$backendName] ` + --runtime-archive (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR $runtimeName) ` + --runtime-sha256 $metadata[$runtimeName] ` + --core-dll $env:CUDA_PREBUILT_CORE_DLL ` + --cuobjdump $env:CUDA_CUOBJDUMP ` + --output-dir $outputDir + if ($LASTEXITCODE -ne 0) { + throw "CUDA $version packaging failed" + } + } + $timer.Stop() + "CUDA_PREBUILT_OUTPUT_DIR=$outputDir" | Out-File ` + -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CUDA pack preparation: $([math]::Round($timer.Elapsed.TotalSeconds, 1)) seconds" | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append + + - name: Independently verify finished CUDA packs + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + python tools/verify_cuda_pack.py ` + --pack (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` + "llamadart-native-windows-x64-cuda12-$env:NATIVE_RELEASE_TAG.tar.gz") ` + --pack (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` + "llamadart-native-windows-x64-cuda13-$env:NATIVE_RELEASE_TAG.tar.gz") ` + --expected-native-tag $env:NATIVE_RELEASE_TAG ` + --expected-tag $env:LLAMA_CPP_TAG ` + --expected-commit $env:LLAMA_CPP_COMMIT ` + --core-dll $env:CUDA_PREBUILT_CORE_DLL + if ($LASTEXITCODE -ne 0) { + throw 'Finished CUDA pack verification failed' + } + + - name: Smoke renamed CUDA backends through ggml loader + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($major in @('12', '13')) { + $smokeDir = Join-Path $env:RUNNER_TEMP "cuda-smoke-$major" + New-Item -ItemType Directory -Force -Path $smokeDir | Out-Null + $pack = Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` + "llamadart-native-windows-x64-cuda$major-$env:NATIVE_RELEASE_TAG.tar.gz" + tar -xzf $pack -C $smokeDir + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract CUDA $major pack" + } + Copy-Item $env:CUDA_PREBUILT_CORE_DLL ` + (Join-Path $smokeDir 'ggml-base.dll') -Force + foreach ($name in @('ggml.dll', 'vcomp140.dll')) { + $source = Join-Path $env:CUDA_PREBUILT_CORE_DIR $name + if (Test-Path $source) { + Copy-Item $source $smokeDir + } + } + $smokeArgs = @( + 'tools/smoke_windows_cuda_pack.py', + '--directory', $smokeDir, + '--backend', "ggml-cuda-$major.dll" + ) + if ($major -eq '12') { + $smokeArgs += '--allow-missing-nvidia-driver' + } + python @smokeArgs + if ($LASTEXITCODE -ne 0) { + throw "CUDA $major loader smoke failed" + } + } + + - name: Upload verified CUDA release sidecars + uses: actions/upload-artifact@v4 + with: + name: windows_cuda_packs + path: ${{ runner.temp }}/cuda-packs/*.tar.gz + compression-level: 0 + windows-cuda-prebuilt-experiment: needs: resolve-tag if: >- @@ -1014,6 +1157,7 @@ jobs: GH_TOKEN: ${{ github.token }} LLAMA_CPP_TAG: ${{ needs.resolve-tag.outputs.llama_cpp_ref }} LLAMA_CPP_COMMIT: ${{ needs.resolve-tag.outputs.llama_cpp_commit }} + NATIVE_RELEASE_TAG: ${{ needs.resolve-tag.outputs.release_tag }} CUDA_CUOBJDUMP_VERSION: '13.3.29' CUDA_CUOBJDUMP_ARCHIVE_SHA256: '50c8ab72fdfec7e5958fbd61988719460093d7ac86e2893377e77c8d908a2350' steps: @@ -1154,6 +1298,7 @@ jobs: $runtimeName = "cudart-llama-bin-win-cuda-$version-x64.zip" python tools/package_upstream_cuda.py ` --tag $env:LLAMA_CPP_TAG ` + --native-release-tag $env:NATIVE_RELEASE_TAG ` --llama-commit $env:LLAMA_CPP_COMMIT ` --cuda-version $version ` --backend-archive (Join-Path $env:CUDA_PREBUILT_DOWNLOAD_DIR $backendName) ` @@ -1179,11 +1324,13 @@ jobs: $ErrorActionPreference = 'Stop' python tools/verify_cuda_pack.py ` --pack (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` - "llamadart-native-windows-x64-cuda12-$env:LLAMA_CPP_TAG.tar.gz") ` + "llamadart-native-windows-x64-cuda12-$env:NATIVE_RELEASE_TAG.tar.gz") ` --pack (Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` - "llamadart-native-windows-x64-cuda13-$env:LLAMA_CPP_TAG.tar.gz") ` + "llamadart-native-windows-x64-cuda13-$env:NATIVE_RELEASE_TAG.tar.gz") ` + --expected-native-tag $env:NATIVE_RELEASE_TAG ` --expected-tag $env:LLAMA_CPP_TAG ` - --expected-commit $env:LLAMA_CPP_COMMIT + --expected-commit $env:LLAMA_CPP_COMMIT ` + --core-dll (Join-Path $env:CUDA_PREBUILT_CORE_DIR 'ggml-base.dll') if ($LASTEXITCODE -ne 0) { throw 'Finished CUDA pack verification failed' } @@ -1196,7 +1343,7 @@ jobs: $smokeDir = Join-Path $env:RUNNER_TEMP "cuda-smoke-$major" New-Item -ItemType Directory -Force -Path $smokeDir | Out-Null $pack = Join-Path $env:CUDA_PREBUILT_OUTPUT_DIR ` - "llamadart-native-windows-x64-cuda$major-$env:LLAMA_CPP_TAG.tar.gz" + "llamadart-native-windows-x64-cuda$major-$env:NATIVE_RELEASE_TAG.tar.gz" tar -xzf $pack -C $smokeDir if ($LASTEXITCODE -ne 0) { throw "Failed to extract CUDA $major pack" @@ -1242,7 +1389,7 @@ jobs: retention-days: 7 package-and-release: - needs: [resolve-tag, build-android, build-apple, build-linux, build-linux-hip, build-windows] + needs: [resolve-tag, build-android, build-apple, build-linux, build-linux-hip, build-windows, package-windows-cuda] runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -1294,6 +1441,12 @@ jobs: mkdir -p release_assets mkdir -p bundles + cuda_pack_dir="artifacts/windows_cuda_packs" + test -d "$cuda_pack_dir" || { echo "Missing verified Windows CUDA sidecars" >&2; exit 1; } + cuda_pack_count="$(find "$cuda_pack_dir" -maxdepth 1 -type f -name '*.tar.gz' | wc -l | tr -d ' ')" + test "$cuda_pack_count" = "2" || { echo "Expected two Windows CUDA sidecars, found $cuda_pack_count" >&2; exit 1; } + cp "$cuda_pack_dir"/*.tar.gz release_assets/ + for dir in artifacts/native_*; do [ -d "$dir" ] || continue name="$(basename "$dir")" diff --git a/AGENTS.md b/AGENTS.md index 8132dbb..9728f4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,10 @@ Optional Linux container build: - Manual native build + release publish. - `.github/workflows/auto_native_release.yml` - Scheduled/manual dispatcher when upstream `llama.cpp` tag advances. +- Windows x64 CUDA release backends come from exact-tag upstream CUDA 12/13 + assets, are verified against the same-run `ggml-base.dll`, and publish as + separate sidecars. Do not restore a source-built Windows CUDA matrix lane + without evidence that the sidecar contract cannot satisfy the release. ## Change Boundaries diff --git a/scripts/generate_assets_manifest.sh b/scripts/generate_assets_manifest.sh index a6734ce..02a30b7 100755 --- a/scripts/generate_assets_manifest.sh +++ b/scripts/generate_assets_manifest.sh @@ -54,6 +54,10 @@ infer_meta() { # New naming convention: --. case "$stem" in + llamadart-native-windows-x64-cuda12-*) + platform="windows"; arch="x64"; backend="cuda"; module="backend-cuda12"; libid="ggml-cuda-12" ;; + llamadart-native-windows-x64-cuda13-*) + platform="windows"; arch="x64"; backend="cuda"; module="backend-cuda13"; libid="ggml-cuda-13" ;; llamadart-native-apple-xcframework-*) platform="apple"; arch="universal"; backend="core"; module="spm-xcframework"; libid="llamadart-native-apple-xcframework" ;; *-windows-x64) @@ -116,6 +120,13 @@ infer_meta() { local id_no_lib id_no_lib="${libid#lib}" + case "$module" in + backend-cuda12|backend-cuda13) + echo "$platform|$arch|$backend|$module" + return + ;; + esac + case "$id_no_lib" in llamadart-native-apple-xcframework) backend="core" diff --git a/scripts/verify_release_provenance.py b/scripts/verify_release_provenance.py index a7f0bb3..3d8cf49 100755 --- a/scripts/verify_release_provenance.py +++ b/scripts/verify_release_provenance.py @@ -110,6 +110,35 @@ def verify_workflow_contract(errors: list[str]) -> None: errors, ) + windows_build = workflow.split(" build-windows:", 1)[1].split( + " package-windows-cuda:", 1 + )[0] + cuda_packaging = workflow.split(" package-windows-cuda:", 1)[1].split( + " windows-cuda-prebuilt-experiment:", 1 + )[0] + require( + "backend: cuda" not in windows_build + and "Install CUDA Toolkit" not in windows_build, + "Windows release builds must not compile the replaced CUDA backend lane", + errors, + ) + require( + "needs: [resolve-tag, build-windows]" in cuda_packaging + and "name: native_windows_x64_vulkan" in cuda_packaging + and "--native-release-tag $env:NATIVE_RELEASE_TAG" in cuda_packaging + and "--core-dll $env:CUDA_PREBUILT_CORE_DLL" in cuda_packaging + and "name: windows_cuda_packs" in cuda_packaging, + "production CUDA sidecars must be verified against and follow the same-run Windows core", + errors, + ) + require( + "package-windows-cuda]" in workflow + and 'cuda_pack_dir="artifacts/windows_cuda_packs"' in workflow + and 'test "$cuda_pack_count" = "2"' in workflow, + "release packaging must require and publish both verified Windows CUDA sidecars", + errors, + ) + def verify_manifest_contract(errors: list[str]) -> None: with tempfile.TemporaryDirectory() as directory: @@ -117,6 +146,9 @@ def verify_manifest_contract(errors: list[str]) -> None: assets = root / "assets" assets.mkdir() (assets / "libllamadart-linux-x64.so").write_bytes(b"native-test") + (assets / "llamadart-native-windows-x64-cuda13-native-test.tar.gz").write_bytes( + b"cuda-test" + ) output_json = root / "assets.json" output_checksums = root / "SHA256SUMS" env = os.environ.copy() @@ -152,6 +184,22 @@ def verify_manifest_contract(errors: list[str]) -> None: "manifest generation must continue to emit asset checksums", errors, ) + cuda_artifacts = [ + artifact + for artifact in manifest.get("artifacts", []) + if artifact.get("file") + == "llamadart-native-windows-x64-cuda13-native-test.tar.gz" + ] + require( + len(cuda_artifacts) == 1 + and cuda_artifacts[0].get("module") == "backend-cuda13" + and cuda_artifacts[0].get("platform") == "windows" + and cuda_artifacts[0].get("arch") == "x64" + and cuda_artifacts[0].get("backend") == "cuda" + and cuda_artifacts[0].get("size") == len(b"cuda-test"), + "assets.json must classify Windows CUDA sidecars as versioned CUDA backends", + errors, + ) def main() -> int: diff --git a/tools/cuda_pack_contract.py b/tools/cuda_pack_contract.py index b366538..3cf0622 100644 --- a/tools/cuda_pack_contract.py +++ b/tools/cuda_pack_contract.py @@ -25,6 +25,7 @@ class CudaVariant: cuda_major: int minimum_compute_capability: int minimum_driver_family: int + minimum_driver_api: int ptx_architectures: frozenset[str] sass_architectures: frozenset[str] @@ -38,6 +39,7 @@ class CudaVariant: cuda_major=12, minimum_compute_capability=50, minimum_driver_family=525, + minimum_driver_api=12000, ptx_architectures=frozenset({"50", "61", "70", "75", "80", "90"}), sass_architectures=frozenset({"86", "89"}), ), @@ -46,6 +48,7 @@ class CudaVariant: cuda_major=13, minimum_compute_capability=75, minimum_driver_family=580, + minimum_driver_api=13000, ptx_architectures=frozenset({"75", "80", "90"}), sass_architectures=frozenset({"86", "89", "120a", "121a"}), ), @@ -145,6 +148,7 @@ def validate_variant_metadata(manifest: Mapping[str, Any]) -> CudaVariant: expected_compatibility = { "minimum_compute_capability": variant.minimum_compute_capability, "minimum_driver_family": variant.minimum_driver_family, + "minimum_driver_api": variant.minimum_driver_api, } if compatibility != expected_compatibility: raise CudaContractError( diff --git a/tools/package_upstream_cuda.py b/tools/package_upstream_cuda.py index 18cae82..cbecc1c 100644 --- a/tools/package_upstream_cuda.py +++ b/tools/package_upstream_cuda.py @@ -307,6 +307,12 @@ def write_deterministic_tar_gz(source_dir: Path, output: Path) -> None: def package(args: argparse.Namespace) -> Path: if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", args.tag) is None: raise PackagingError(f"Invalid llama.cpp tag: {args.tag}") + if re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._-]*", args.native_release_tag + ) is None: + raise PackagingError( + f"Invalid llamadart-native release tag: {args.native_release_tag}" + ) if re.fullmatch(r"[0-9a-fA-F]{40}", args.llama_commit) is None: raise PackagingError("llama.cpp commit must be a full 40-character SHA") variant = CUDA_VARIANTS.get(args.cuda_version) @@ -366,7 +372,8 @@ def package(args: argparse.Namespace) -> Path: ) metadata = { - "contract_version": 2, + "contract_version": 3, + "native_release_tag": args.native_release_tag, "llama_cpp_tag": args.tag, "llama_cpp_commit": args.llama_commit, "platform": "windows", @@ -375,9 +382,14 @@ def package(args: argparse.Namespace) -> Path: "cuda_version": args.cuda_version, "cuda_major": int(cuda_major), "backend_library": backend_name, + "core_compatibility": { + "library": "ggml-base.dll", + "sha256": sha256(args.core_dll), + }, "compatibility": { "minimum_compute_capability": variant.minimum_compute_capability, "minimum_driver_family": variant.minimum_driver_family, + "minimum_driver_api": variant.minimum_driver_api, }, "device_code": device_code, "source_assets": [ @@ -407,7 +419,8 @@ def package(args: argparse.Namespace) -> Path: ) output = args.output_dir / ( - f"llamadart-native-windows-x64-cuda{cuda_major}-{args.tag}.tar.gz" + "llamadart-native-windows-x64-" + f"cuda{cuda_major}-{args.native_release_tag}.tar.gz" ) write_deterministic_tar_gz(staging, output) return output @@ -416,6 +429,7 @@ def package(args: argparse.Namespace) -> Path: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--tag", required=True) + parser.add_argument("--native-release-tag", required=True) parser.add_argument("--llama-commit", required=True) parser.add_argument("--cuda-version", required=True) parser.add_argument("--backend-archive", required=True, type=Path) diff --git a/tools/tests/test_cuda_pack_contract.py b/tools/tests/test_cuda_pack_contract.py index 4aa7917..9dc7111 100644 --- a/tools/tests/test_cuda_pack_contract.py +++ b/tools/tests/test_cuda_pack_contract.py @@ -23,6 +23,7 @@ def manifest(version: str) -> dict[str, object]: "compatibility": { "minimum_compute_capability": variant.minimum_compute_capability, "minimum_driver_family": variant.minimum_driver_family, + "minimum_driver_api": variant.minimum_driver_api, }, "device_code": { "ptx_architectures": sorted(variant.ptx_architectures), diff --git a/tools/tests/test_package_upstream_cuda.py b/tools/tests/test_package_upstream_cuda.py index ee39b4e..d21e581 100644 --- a/tools/tests/test_package_upstream_cuda.py +++ b/tools/tests/test_package_upstream_cuda.py @@ -98,6 +98,7 @@ def test_dependency_closure_allows_nvidia_driver(self) -> None: def test_package_rejects_asset_from_a_different_tag(self) -> None: args = argparse.Namespace( tag="b-new", + native_release_tag="native-test", llama_commit="1" * 40, cuda_version="13.3", backend_archive=Path("llama-b-old-bin-win-cuda-13.3-x64.zip"), @@ -150,6 +151,7 @@ def test_packages_only_variant_specific_runtime_files(self) -> None: ) args = argparse.Namespace( tag="b-test", + native_release_tag="native-test", llama_commit="1" * 40, cuda_version="13.3", backend_archive=backend_archive, @@ -201,11 +203,24 @@ def test_packages_only_variant_specific_runtime_files(self) -> None: metadata = subject.json.loads( archive.extractfile("cuda-pack.json").read() ) - self.assertEqual(metadata["contract_version"], 2) + self.assertEqual( + output.name, + "llamadart-native-windows-x64-cuda13-native-test.tar.gz", + ) + self.assertEqual(metadata["contract_version"], 3) + self.assertEqual(metadata["native_release_tag"], "native-test") + self.assertEqual( + metadata["core_compatibility"], + {"library": "ggml-base.dll", "sha256": digest(core)}, + ) self.assertEqual(metadata["device_code"], device_code) self.assertEqual( metadata["compatibility"], - {"minimum_compute_capability": 75, "minimum_driver_family": 580}, + { + "minimum_compute_capability": 75, + "minimum_driver_family": 580, + "minimum_driver_api": 13000, + }, ) def test_archive_is_reproducible_across_output_names(self) -> None: diff --git a/tools/tests/test_verify_cuda_pack.py b/tools/tests/test_verify_cuda_pack.py index f1b8db8..dfa22ac 100644 --- a/tools/tests/test_verify_cuda_pack.py +++ b/tools/tests/test_verify_cuda_pack.py @@ -29,6 +29,8 @@ def write_test_pack( path: Path, version: str, *, + native_release_tag: str = "native-test", + core_sha256: str = "2" * 64, corrupt_hash: bool = False, extra_name: str | None = None, ) -> None: @@ -47,7 +49,8 @@ def write_test_pack( digest = "0" * 64 files.append({"name": name, "sha256": digest, "size": len(contents)}) manifest = { - "contract_version": 2, + "contract_version": 3, + "native_release_tag": native_release_tag, "llama_cpp_tag": "b-test", "llama_cpp_commit": "1" * 40, "platform": "windows", @@ -56,9 +59,14 @@ def write_test_pack( "cuda_version": version, "cuda_major": variant.cuda_major, "backend_library": f"ggml-cuda-{major}.dll", + "core_compatibility": { + "library": "ggml-base.dll", + "sha256": core_sha256, + }, "compatibility": { "minimum_compute_capability": variant.minimum_compute_capability, "minimum_driver_family": variant.minimum_driver_family, + "minimum_driver_api": variant.minimum_driver_api, }, "device_code": { "inspector": { @@ -100,29 +108,74 @@ def write_test_pack( class VerifyCudaPackTest(unittest.TestCase): def test_verifies_payload_hashes_and_metadata(self) -> None: with tempfile.TemporaryDirectory() as directory: - pack = Path(directory) / "cuda13.tar.gz" + pack = Path(directory) / ( + "llamadart-native-windows-x64-cuda13-native-test.tar.gz" + ) write_test_pack(pack, "13.3") manifest = subject.verify_pack( - pack, expected_tag="b-test", expected_commit="1" * 40 + pack, + expected_native_tag="native-test", + expected_tag="b-test", + expected_commit="1" * 40, + expected_core_sha256="2" * 64, ) self.assertEqual(manifest["cuda_major"], 13) def test_rejects_corrupt_payload_hash(self) -> None: with tempfile.TemporaryDirectory() as directory: - pack = Path(directory) / "cuda12.tar.gz" + pack = Path(directory) / ( + "llamadart-native-windows-x64-cuda12-native-test.tar.gz" + ) write_test_pack(pack, "12.4", corrupt_hash=True) with self.assertRaisesRegex(subject.VerificationError, "SHA-256"): subject.verify_pack( - pack, expected_tag="b-test", expected_commit="1" * 40 + pack, + expected_native_tag="native-test", + expected_tag="b-test", + expected_commit="1" * 40, + expected_core_sha256="2" * 64, ) def test_rejects_non_top_level_member(self) -> None: with tempfile.TemporaryDirectory() as directory: - pack = Path(directory) / "cuda12.tar.gz" + pack = Path(directory) / ( + "llamadart-native-windows-x64-cuda12-native-test.tar.gz" + ) write_test_pack(pack, "12.4", extra_name="../escape.dll") with self.assertRaisesRegex(subject.VerificationError, "top-level"): subject.verify_pack( - pack, expected_tag="b-test", expected_commit="1" * 40 + pack, + expected_native_tag="native-test", + expected_tag="b-test", + expected_commit="1" * 40, + expected_core_sha256="2" * 64, + ) + + def test_rejects_native_release_or_core_version_skew(self) -> None: + with tempfile.TemporaryDirectory() as directory: + pack = Path(directory) / ( + "llamadart-native-windows-x64-cuda13-native-test.tar.gz" + ) + write_test_pack(pack, "13.3") + with self.assertRaisesRegex( + subject.VerificationError, "native release tag" + ): + subject.verify_pack( + pack, + expected_native_tag="native-other", + expected_tag="b-test", + expected_commit="1" * 40, + expected_core_sha256="2" * 64, + ) + with self.assertRaisesRegex( + subject.VerificationError, "core compatibility" + ): + subject.verify_pack( + pack, + expected_native_tag="native-test", + expected_tag="b-test", + expected_commit="1" * 40, + expected_core_sha256="9" * 64, ) diff --git a/tools/verify_cuda_pack.py b/tools/verify_cuda_pack.py index 819b5b9..f3792ab 100644 --- a/tools/verify_cuda_pack.py +++ b/tools/verify_cuda_pack.py @@ -43,8 +43,10 @@ def _read_member(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes: def verify_pack( path: Path, *, + expected_native_tag: str, expected_tag: str, expected_commit: str, + expected_core_sha256: str, ) -> dict[str, Any]: """Verify one pack and return its validated manifest.""" @@ -77,8 +79,12 @@ def verify_pack( if not isinstance(manifest, dict): raise VerificationError("Pack manifest must be a JSON object") - if manifest.get("contract_version") != 2: - raise VerificationError("Pack contract version must be 2") + if manifest.get("contract_version") != 3: + raise VerificationError("Pack contract version must be 3") + if manifest.get("native_release_tag") != expected_native_tag: + raise VerificationError( + "Pack native release tag does not match the requested release" + ) if manifest.get("llama_cpp_tag") != expected_tag: raise VerificationError("Pack tag does not match the requested upstream tag") if manifest.get("llama_cpp_commit") != expected_commit: @@ -92,6 +98,14 @@ def verify_pack( raise VerificationError(str(error)) from error major = str(variant.cuda_major) expected_backend = f"ggml-cuda-{major}.dll" + expected_archive = ( + "llamadart-native-windows-x64-" + f"cuda{major}-{expected_native_tag}.tar.gz" + ) + if path.name != expected_archive: + raise VerificationError( + f"Pack filename must be {expected_archive}, got {path.name}" + ) expected_payload = { expected_backend, f"cudart64_{major}.dll", @@ -100,6 +114,11 @@ def verify_pack( } if manifest.get("backend_library") != expected_backend: raise VerificationError("Pack backend filename is invalid") + if manifest.get("core_compatibility") != { + "library": "ggml-base.dll", + "sha256": expected_core_sha256, + }: + raise VerificationError("Pack core compatibility digest differs") if set(by_name) != expected_payload | {"cuda-pack.json"}: raise VerificationError("Pack contains missing or unexpected payload files") @@ -199,19 +218,25 @@ def verify_selection_policy(manifests: list[Mapping[str, Any]]) -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--pack", action="append", required=True, type=Path) + parser.add_argument("--expected-native-tag", required=True) parser.add_argument("--expected-tag", required=True) parser.add_argument("--expected-commit", required=True) + parser.add_argument("--core-dll", required=True, type=Path) return parser.parse_args() def main() -> int: args = parse_args() try: + with args.core_dll.open("rb") as core_source: + expected_core_sha256 = stream_sha256(core_source) manifests = [ verify_pack( path, + expected_native_tag=args.expected_native_tag, expected_tag=args.expected_tag, expected_commit=args.expected_commit, + expected_core_sha256=expected_core_sha256, ) for path in args.pack ]