Files
rerun-io--rerun/scripts/ci/setup_software_rasterizer.py
2026-07-13 13:05:14 +08:00

326 lines
12 KiB
Python

"""
Sets up software rasterizers for CI.
Borrows heavily from wgpu's CI setup.
See https://github.com/gfx-rs/wgpu/blob/a8a91737b2d2f378976e292074c75817593a0224/.github/workflows/ci.yml#L10
In fact we're the exact same Mesa builds that wgpu produces,
see https://github.com/gfx-rs/ci-build
Using a software rasterizer avoids GPU-related flakiness on CI runners which we hit quite often when
running many tests in parallel - we got spurious timeouts and failure to find graphics devices,
the cause of these issues is unknown. See https://github.com/rerun-io/rerun/issues/11359.
All platforms use lavapipe (Mesa's software Vulkan implementation).
For macOS, we use a custom static build from https://github.com/rerun-io/lavapipe-build.
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import sys
import time
from pathlib import Path
from shutil import copytree
# Sourced from https://archive.mesa3d.org/. Bumping this requires
# updating the mesa build in https://github.com/gfx-rs/ci-build and creating a new release.
MESA_VERSION = "25.2.7"
# Corresponds to https://github.com/gfx-rs/ci-build/releases
CI_BINARY_BUILD = "build26"
# Lavapipe build for macOS from https://github.com/rerun-io/lavapipe-build
LAVAPIPE_MACOS_VERSION = "v0.4.0"
LAVAPIPE_MACOS_URL = (
f"https://github.com/rerun-io/lavapipe-build/releases/download/{LAVAPIPE_MACOS_VERSION}/lavapipe-macos-arm64.tar.gz"
)
CARGO_TARGET_DIR = Path(os.environ.get("CARGO_TARGET_DIR", "target"))
def run(
args: list[str],
*,
env: dict[str, str] | None = None,
timeout: int | None = None,
cwd: str | None = None,
retries: int = 0,
) -> subprocess.CompletedProcess[str]:
for attempt in range(retries + 1):
result = subprocess.run(args, env=env, cwd=cwd, timeout=timeout, check=False, capture_output=True, text=True)
if result.returncode == 0:
return result
if attempt < retries:
print(
f"Command {args[0]} failed with exit-code {result.returncode}. Retrying in 5 seconds ({attempt + 1}/{retries})…"
)
time.sleep(5)
assert result.returncode == 0, (
f"{subprocess.list2cmdline(args)} failed with exit-code {result.returncode}. Output:\n{result.stdout}\n{result.stderr}"
)
return result
def set_environment_variables(variables: dict[str, str]) -> None:
"""
Sets environment variables in the GITHUB_ENV file.
If `GITHUB_ENV` is not set (i.e. when running locally), prints the variables to stdout.
"""
for key, value in variables.items():
os.environ[key] = value
# Set in GITHUB_ENV file.
github_env = os.environ.get("GITHUB_ENV")
if github_env is None:
print(f"GITHUB_ENV is not set. The following environment variables need to be set:\n{variables}")
else:
print(f"Setting environment variables in {github_env}:\n{variables}")
# Write to GITHUB_ENV file.
with open(github_env, "a", encoding="utf-8") as f:
for key, value in variables.items():
f.write(f"{key}={value}\n")
def setup_lavapipe_for_linux() -> dict[str, str]:
"""Sets up lavapipe mesa driver for Linux (x64)."""
# Download mesa
run(
[
"curl",
"-L",
"--retry",
"5",
f"https://github.com/gfx-rs/ci-build/releases/download/{CI_BINARY_BUILD}/mesa-{MESA_VERSION}-linux-x86_64.tar.xz",
"-o",
"mesa.tar.xz",
],
retries=3,
)
# Create mesa directory and extract
os.makedirs("mesa", exist_ok=True)
run(["tar", "xpf", "mesa.tar.xz", "-C", "mesa"])
# The ICD provided by the mesa build is hardcoded to the build environment.
# We write out our own ICD file to point to the mesa vulkan
icd_json = f"""{{
"ICD": {{
"api_version": "1.1.255",
"library_path": "{os.getcwd()}/mesa/lib/x86_64-linux-gnu/libvulkan_lvp.so"
}},
"file_format_version": "1.0.0"
}}"""
icd_json_path = Path("icd.json")
with open(icd_json_path, "w", encoding="utf-8") as f:
f.write(icd_json)
# Update environment variables
env_vars = {
"VK_DRIVER_FILES": f"{os.getcwd()}/{icd_json_path}",
"LD_LIBRARY_PATH": f"{os.getcwd()}/mesa/lib/x86_64-linux-gnu/:{os.environ.get('LD_LIBRARY_PATH', '')}",
}
set_environment_variables(env_vars)
# On CI we run with elevated privileges, therefore VK_DRIVER_FILES is ignored.
# See: https://github.com/KhronosGroup/Vulkan-Loader/blob/sdk-1.3.261/docs/LoaderInterfaceArchitecture.md#elevated-privilege-caveats
# (curiously, when installing the Vulkan SDK via apt, it seems to work fine).
# Therefore, we copy the icd file into one of the standard search paths.
target_path = Path("~/.config/vulkan/icd.d").expanduser()
print(f"Copying icd file to {target_path}")
target_path.mkdir(parents=True, exist_ok=True)
shutil.copy(icd_json_path, target_path)
return env_vars
def setup_lavapipe_for_windows() -> dict[str, str]:
"""Sets up lavapipe mesa driver for Windows (x64)."""
# Download mesa
run(
[
"curl.exe",
"-L",
"--retry",
"5",
f"https://github.com/pal1000/mesa-dist-win/releases/download/{MESA_VERSION}/mesa3d-{MESA_VERSION}-release-msvc.7z",
"-o",
"mesa.7z",
],
retries=3,
)
# Extract needed files
run([
"7z.exe",
"e",
"mesa.7z",
"-aoa",
"-omesa",
"x64/vulkan_lvp.dll",
"x64/lvp_icd.x86_64.json",
])
# Copy files to target directory.
copytree("mesa", CARGO_TARGET_DIR / "debug", dirs_exist_ok=True)
copytree("mesa", CARGO_TARGET_DIR / "debug" / "deps", dirs_exist_ok=True)
# Print icd file that should be used.
icd_json_path = Path(os.path.join(os.getcwd(), "mesa", "lvp_icd.x86_64.json")).resolve()
print(f"Using ICD file at '{icd_json_path}':")
with open(icd_json_path, encoding="utf-8") as f:
print(f.read())
icd_json_path_str = icd_json_path.as_posix().replace("/", "\\")
# Set environment variables, make sure to use windows path style.
vulkan_runtime_path = f"{os.environ['VULKAN_SDK']}/runtime/x64"
env_vars = {
"VK_DRIVER_FILES": icd_json_path_str,
# Vulkan runtime install should do this, but the CI action we're using right now for instance doesn't,
# causing `vulkaninfo` to fail since it can't find the vulkan loader.
"PATH": f"{os.environ.get('PATH', '')};{vulkan_runtime_path}",
}
set_environment_variables(env_vars)
# For debugging: List files in Vulkan runtime path.
if False:
print(f"\nListing files in Vulkan runtime path '{vulkan_runtime_path}':")
try:
files = os.listdir(vulkan_runtime_path)
for file in files:
print(f" {file}")
except Exception as e:
print(f"Error listing Vulkan runtime directory: {e}")
# On CI we run with elevated privileges, therefore VK_DRIVER_FILES is ignored.
# See: https://github.com/KhronosGroup/Vulkan-Loader/blob/vulkan-sdk-1.4.304/docs/LoaderInterfaceArchitecture.md#elevated-privilege-caveats
# Therefore, we have to set one of the registry keys that is checked to find the driver.
# See: https://vulkan.lunarg.com/doc/view/1.4.304.1/windows/LoaderDriverInterface.html#driver-discovery-on-windows
# Write registry keys to configure Vulkan drivers
import winreg
key_path = "SOFTWARE\\Khronos\\Vulkan\\Drivers"
# mypy apparently doesn't have stubs for wingreg
key = winreg.CreateKeyEx(winreg.HKEY_LOCAL_MACHINE, key_path) # type: ignore[attr-defined]
winreg.SetValueEx(key, icd_json_path_str, 0, winreg.REG_DWORD, 0) # type: ignore[attr-defined]
winreg.CloseKey(key) # type: ignore[attr-defined]
return env_vars
def setup_lavapipe_for_macos() -> dict[str, str]:
"""Sets up lavapipe mesa driver for macOS (arm64)."""
print("Setting up lavapipe for macOS…")
# Download lavapipe build (self-contained with bundled dependencies)
run([
"curl",
"-L",
"--retry",
"5",
LAVAPIPE_MACOS_URL,
"-o",
"lavapipe-macos-arm64.tar.gz",
])
# Extract
run(["tar", "xzf", "lavapipe-macos-arm64.tar.gz"])
lavapipe_dir = Path(os.getcwd()) / "lavapipe-macos-arm64"
icd_json_path = lavapipe_dir / "lvp_icd.aarch64.json"
# The Vulkan SDK's loader may be incompatible with lavapipe. Replace it with
# Homebrew's vulkan-loader which is built against the same Vulkan headers as Mesa.
print("Installing Homebrew vulkan-loader for compatibility with lavapipe…")
run(["/opt/homebrew/bin/brew", "install", "vulkan-loader"])
# Replace the Vulkan SDK's loader with the Homebrew one in all search paths.
# DYLD_LIBRARY_PATH alone is unreliable on macOS due to SIP stripping it.
run(["sudo", "mkdir", "-p", "/usr/local/lib"])
for lib_name in ["libvulkan.dylib", "libvulkan.1.dylib"]:
homebrew_path = f"/opt/homebrew/lib/{lib_name}"
if Path(homebrew_path).exists():
run(["sudo", "ln", "-sf", homebrew_path, f"/usr/local/lib/{lib_name}"])
# Also overwrite in the Vulkan SDK lib directory
vulkan_sdk_path = os.environ.get("VULKAN_SDK")
if vulkan_sdk_path:
sdk_lib = f"{vulkan_sdk_path}/lib/{lib_name}"
if Path(sdk_lib).exists() or Path(f"{vulkan_sdk_path}/lib").is_dir():
print(f"Replacing {sdk_lib} with symlink to {homebrew_path}")
run(["sudo", "ln", "-sf", homebrew_path, sdk_lib])
# Set environment variables
env_vars = {
"VK_DRIVER_FILES": str(icd_json_path.resolve()),
# Clear VK_LAYER_PATH to prevent validation layer crashes with lavapipe.
"VK_LAYER_PATH": "",
}
set_environment_variables(env_vars)
return env_vars
def vulkan_info(extra_env_vars: dict[str, str]) -> None:
"""Run vulkaninfo to verify the Vulkan setup."""
vulkan_sdk_path = os.environ["VULKAN_SDK"]
env = os.environ.copy()
env["VK_LOADER_DEBUG"] = "all" # Enable verbose logging of vulkan loader for debugging.
for key, value in extra_env_vars.items():
env[key] = value
if os.name == "nt":
vulkaninfo_path = f"{vulkan_sdk_path}/bin/vulkaninfoSDK.exe"
else:
vulkaninfo_path = f"{vulkan_sdk_path}/bin/vulkaninfo"
if not Path(vulkaninfo_path).exists():
print(f"ERROR: vulkaninfo not found at {vulkaninfo_path}")
print("The Vulkan SDK should be installed with vulkaninfo utility.")
sys.exit(1)
print(f"\nRunning vulkaninfo to verify setup (from {vulkaninfo_path})…\n")
print(run([vulkaninfo_path, "--summary"], env=env).stdout)
def check_for_vulkan_sdk() -> None:
vulkan_sdk_path = os.environ.get("VULKAN_SDK")
if vulkan_sdk_path is None:
print(
"ERROR: VULKAN_SDK is not set. The sdk needs to be installed prior including runtime & vulkaninfo utility.",
)
sys.exit(1)
def main() -> None:
# We only use Vulkan software rasterizers right now.
check_for_vulkan_sdk()
if os.name == "nt" and platform.machine() == "AMD64":
# Note that we could also use WARP, the DX12 software rasterizer.
# (wgpu tests with both llvmpip and WARP)
# But practically speaking we prefer Vulkan anyways on Windows today and as such this is
# both less variation and closer to what Rerun uses when running on a "real" machine.
env_vars = setup_lavapipe_for_windows()
vulkan_info(env_vars)
elif os.name == "posix" and sys.platform != "darwin" and platform.machine() == "x86_64":
env_vars = setup_lavapipe_for_linux()
vulkan_info(env_vars)
elif os.name == "posix" and sys.platform == "darwin":
env_vars = setup_lavapipe_for_macos()
vulkan_info(env_vars)
else:
raise ValueError(f"Unsupported OS / architecture: {os.name} / {platform.machine()}")
if __name__ == "__main__":
main()