From 67a63939a2fb1dc464dc16b042c00839dcf26c4a Mon Sep 17 00:00:00 2001 From: Alex Reinking Date: Thu, 23 Jul 2026 01:55:53 -0400 Subject: [PATCH 1/5] Split pip packaging into halide + halide-bin libHalide, the autoschedulers, and the generator tools were bundled into every per-Python-version wheel, so cibuildwheel rebuilt the entire LLVM-linked library from scratch once per CPython ABI per platform (~20 full builds today). Split the binary components into a new halide-bin wheel (py3-none-, built once per platform) that halide now depends on and links against via find_package(Halide), so halide's own per-version build is just the pybind11 extension. A plain `pip install .` is unaffected: the split only activates when HALIDE_SPLIT_BUILD=1 is set, which only CI does. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/pip.yml | 125 ++++++++++++++++-- packaging/pip-bin/pyproject.toml | 69 ++++++++++ packaging/pip-bin/src/halide_bin/__init__.py | 5 + packaging/pip/CMakeLists.txt | 15 +-- packaging/pip/TrampolineConfig.cmake.in | 2 +- packaging/pip/_dynamic_metadata.py | 71 ++++++++++ pyproject.toml | 29 +++- python_bindings/packaging/CMakeLists.txt | 8 ++ python_bindings/packaging/pip/CMakeLists.txt | 19 +++ .../packaging/pip/TrampolineConfig.cmake.in | 5 + python_bindings/src/halide/__init__.py | 21 ++- 11 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 packaging/pip-bin/pyproject.toml create mode 100644 packaging/pip-bin/src/halide_bin/__init__.py create mode 100644 packaging/pip/_dynamic_metadata.py create mode 100644 python_bindings/packaging/pip/CMakeLists.txt create mode 100644 python_bindings/packaging/pip/TrampolineConfig.cmake.in diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml index f118350f6953..de7fa6cb3bda 100644 --- a/.github/workflows/pip.yml +++ b/.github/workflows/pip.yml @@ -20,8 +20,8 @@ permissions: contents: read # to fetch code (actions/checkout) jobs: - build-wheels: - name: Build Halide wheels for ${{ matrix.platform_tag }} + build-halide-bin: + name: Build halide-bin for ${{ matrix.platform_tag }} runs-on: ${{ matrix.os }} strategy: @@ -64,16 +64,18 @@ jobs: echo "Halide_LLVM_ROOT=$(halide-llvm --prefix)" >> "$GITHUB_ENV" ######################################################################## - # Wheels + # Wheel (no CPython ABI to matrix over -- halide-bin is a plain + # py3-none- wheel, so we only ever need to invoke + # cibuildwheel against a single, arbitrary interpreter to drive the + # underlying CMake build once per platform). ######################################################################## - #- uses: mxschmitt/action-tmate@v3 - - - name: Build wheels + - name: Build wheel uses: pypa/cibuildwheel@v4.1.0 + with: + package-dir: packaging/pip-bin env: - CIBW_BUILD: "cp3*-${{ matrix.platform_tag }}" - CIBW_SKIP: "cp3{5,6,7,8,9}* cp314t-*" + CIBW_BUILD: "cp312-${{ matrix.platform_tag }}" CIBW_BEFORE_ALL_LINUX: > /opt/python/cp312-cp312/bin/pip install cmake ninja "halide-llvm==${{ env.LLVM_VERSION }}" @@ -130,17 +132,114 @@ jobs: CIBW_ENVIRONMENT_LINUX: > Halide_LLVM_ROOT=/project/opt/llvm CMAKE_PREFIX_PATH=/project/opt - SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE='{local_scheme="no-local-version"}' + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE_BIN='{local_scheme="no-local-version"}' CIBW_ENVIRONMENT_MACOS: > CMAKE_PREFIX_PATH='${{ github.workspace }}/opt' Python_ROOT_DIR='' - SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE='{local_scheme="no-local-version"}' + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE_BIN='{local_scheme="no-local-version"}' CIBW_ENVIRONMENT_WINDOWS: > CMAKE_GENERATOR=Ninja CMAKE_PREFIX_PATH='${{ github.workspace }}\opt' - SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE='{local_scheme="no-local-version"}' + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE_BIN='{local_scheme="no-local-version"}' CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: delvewheel repair --ignore-existing -w {dest_dir} {wheel} - CIBW_BEFORE_TEST_LINUX: pip install cmake ninja + + - uses: actions/upload-artifact@v7 + with: + name: wheels-bin-${{ matrix.platform_tag }} + path: ./wheelhouse/*.whl + + build-wheels: + name: Build Halide wheels for ${{ matrix.platform_tag }} + needs: build-halide-bin + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform_tag: manylinux_x86_64 + - os: windows-latest + platform_tag: win_amd64 + - os: macos-15-intel + platform_tag: macosx_x86_64 + - os: macos-15 + platform_tag: macosx_arm64 + + env: + MACOSX_DEPLOYMENT_TARGET: 11 + HALIDE_SPLIT_BUILD: "1" + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: ilammy/msvc-dev-cmd@v1 + - uses: lukka/get-cmake@v4.4.0 + with: + cmakeVersion: "~3.28.0" + + ######################################################################## + # halide-bin + # + # Building the Python bindings no longer needs LLVM/FlatBuffers/WABT at + # all -- it links against an already-installed halide-bin via + # find_package(Halide). Fetch the wheel this platform's + # build-halide-bin job just produced, and unpack a copy of it so + # CMAKE_PREFIX_PATH can point straight at a real directory (as opposed + # to a still-zipped wheel). + ######################################################################## + + - uses: actions/download-artifact@v8 + with: + name: wheels-bin-${{ matrix.platform_tag }} + path: dist-bin + + - name: Unpack halide-bin for the build + shell: bash + run: python -m zipfile -e dist-bin/*.whl opt/halide-bin + + ######################################################################## + # Wheels + ######################################################################## + + #- uses: mxschmitt/action-tmate@v3 + + - name: Build wheels + uses: pypa/cibuildwheel@v4.1.0 + env: + CIBW_BUILD: "cp3*-${{ matrix.platform_tag }}" + CIBW_SKIP: "cp3{5,6,7,8,9}* cp314t-*" + CIBW_ENVIRONMENT_LINUX: > + CMAKE_PREFIX_PATH=/project/opt/halide-bin/halide_bin/data + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE='{local_scheme="no-local-version"}' + CIBW_ENVIRONMENT_MACOS: > + CMAKE_PREFIX_PATH='${{ github.workspace }}/opt/halide-bin/halide_bin/data' + Python_ROOT_DIR='' + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE='{local_scheme="no-local-version"}' + CIBW_ENVIRONMENT_WINDOWS: > + CMAKE_GENERATOR=Ninja + CMAKE_PREFIX_PATH='${{ github.workspace }}\opt\halide-bin\halide_bin\data' + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE='{local_scheme="no-local-version"}' + # halide_.pyd/.so now depends on a Halide.dll/libHalide supplied by the + # already-installed halide-bin package, not one bundled in this wheel -- + # `--exclude` stops delvewheel from vendoring a second copy of it, while + # `--add-path` lets its dependency scan actually resolve it. + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > + delvewheel repair --ignore-existing --exclude Halide.dll + --add-path '${{ github.workspace }}\opt\halide-bin\halide_bin\data\bin' + -w {dest_dir} {wheel} + # halide-bin isn't published yet during this CI run, so resolve it + # from the wheel we just downloaded instead of the package index. + # Linux additionally needs cmake/ninja installed into the (otherwise + # bare) per-wheel manylinux test container -- macOS/Windows already + # have them on PATH via the get-cmake action above. + CIBW_BEFORE_TEST_LINUX: > + pip install cmake ninja && + pip install --no-index --find-links {project}/dist-bin halide-bin + CIBW_BEFORE_TEST: pip install --no-index --find-links {project}/dist-bin halide-bin CIBW_TEST_COMMAND: > cmake -G Ninja -S {project}/python_bindings/apps -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build && @@ -158,7 +257,7 @@ jobs: publish: name: Publish on PyPI - needs: build-wheels + needs: [build-halide-bin, build-wheels] runs-on: ubuntu-latest permissions: id-token: write diff --git a/packaging/pip-bin/pyproject.toml b/packaging/pip-bin/pyproject.toml new file mode 100644 index 000000000000..4918318eaf0e --- /dev/null +++ b/packaging/pip-bin/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["scikit-build-core~=0.11.0", "setuptools-scm>=8.3.1"] +build-backend = "scikit_build_core.build" + +[project] +name = "halide-bin" +authors = [ + { name = "The Halide team", email = "halide-dev@lists.csail.mit.edu" }, +] +maintainers = [{ name = "Alex Reinking", email = "areinking@adobe.com" }] +description = "Python-independent binary components for Halide (libHalide, autoschedulers, generator tools, headers, CMake package files)." +license = { file = "../../LICENSE.txt" } +requires-python = ">=3.10" +dynamic = ['version'] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: C++", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Code Generators", + "Topic :: Software Development :: Compilers", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://halide-lang.org" +Documentation = "https://halide-lang.org/docs" +Issues = "https://github.com/halide/Halide/issues" +Repository = "https://github.com/halide/Halide.git" + +[tool.scikit-build] +minimum-version = "build-system.requires" +cmake.version = ">=3.28" +ninja.version = ">=1.11,!=1.13.0" +cmake.source-dir = "../.." +wheel.packages = ["src/halide_bin"] +wheel.install-dir = "halide_bin/data" +# No Python extension modules in this package -- python-version-agnostic tag. +wheel.py-api = "py3" +metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.scikit-build.cmake.define] +CMAKE_DISABLE_FIND_PACKAGE_JPEG = true +CMAKE_DISABLE_FIND_PACKAGE_PNG = true +FETCHCONTENT_FULLY_DISCONNECTED = true +Halide_ENABLE_EXCEPTIONS = true +Halide_ENABLE_RTTI = true +Halide_WASM_BACKEND = "wabt" +WITH_PYTHON_BINDINGS = false +WITH_TESTS = false +WITH_TUTORIALS = false + +## +# Don't version libHalide.so/dylib -- wheels are zip files that do +# not understand symbolic links. Including version information here +# causes the final wheel to have three copies of our library. Not good. +CMAKE_PLATFORM_NO_VERSIONED_SONAME = true + +[[tool.scikit-build.overrides]] +if.platform-system = "^win32" +inherit.cmake.define = "append" +cmake.define.Halide_WASM_BACKEND = "OFF" + +[tool.setuptools_scm] +root = "../.." diff --git a/packaging/pip-bin/src/halide_bin/__init__.py b/packaging/pip-bin/src/halide_bin/__init__.py new file mode 100644 index 000000000000..a2d204ffd307 --- /dev/null +++ b/packaging/pip-bin/src/halide_bin/__init__.py @@ -0,0 +1,5 @@ +import os + + +def install_dir(): + return os.path.join(os.path.dirname(__file__), "data") diff --git a/packaging/pip/CMakeLists.txt b/packaging/pip/CMakeLists.txt index d7d443be4e0f..a22c3c85af32 100644 --- a/packaging/pip/CMakeLists.txt +++ b/packaging/pip/CMakeLists.txt @@ -26,7 +26,7 @@ install( # bin/, which CMake does not understand. These users can add %VIRTUAL_ENV% # to their CMAKE_PREFIX_PATH. DESTINATION "${SKBUILD_DATA_DIR}/share/cmake/Halide" - COMPONENT Halide_Python + COMPONENT Halide_Development ) # Same thing for HalideHelpers @@ -35,16 +35,5 @@ install( "${CMAKE_CURRENT_BINARY_DIR}/HalideHelpersConfig.cmake" "${CMAKE_CURRENT_BINARY_DIR}/../HalideHelpersConfigVersion.cmake" DESTINATION "${SKBUILD_DATA_DIR}/share/cmake/HalideHelpers" - COMPONENT Halide_Python + COMPONENT Halide_Development ) - -## -# Set up RPATH for the Python bindings plugin. - -if (WITH_PYTHON_BINDINGS) - _Halide_compute_rpath( - TARGETS Halide_Python - ORIGIN_DIR "${Halide_INSTALL_PYTHONDIR}" - LIB_DIR "${CMAKE_INSTALL_LIBDIR}" - ) -endif () diff --git a/packaging/pip/TrampolineConfig.cmake.in b/packaging/pip/TrampolineConfig.cmake.in index 918328354248..1d7469b25c6d 100644 --- a/packaging/pip/TrampolineConfig.cmake.in +++ b/packaging/pip/TrampolineConfig.cmake.in @@ -3,4 +3,4 @@ cmake_minimum_required(VERSION 3.28) include(CMakeFindDependencyMacro) find_dependency(Python 3 COMPONENTS Interpreter Development.Module) -include("${Python_SITEARCH}/halide/@INSTALL_DIR@/@PACKAGE@Config.cmake") +include("${Python_SITEARCH}/halide_bin/data/@INSTALL_DIR@/@PACKAGE@Config.cmake") diff --git a/packaging/pip/_dynamic_metadata.py b/packaging/pip/_dynamic_metadata.py new file mode 100644 index 000000000000..35378ce1c70c --- /dev/null +++ b/packaging/pip/_dynamic_metadata.py @@ -0,0 +1,71 @@ +""" +Dynamic metadata provider for the `halide` pip package. + +Computes the version via setuptools_scm -- identically to +scikit_build_core's builtin `scikit_build_core.metadata.setuptools_scm` +provider -- and reuses that exact value to pin the `halide-bin` runtime +dependency when building in split mode (HALIDE_SPLIT_BUILD=1 -- see +.github/workflows/pip.yml), so the `==` pin and the actual `halide-bin` +version can never silently diverge. Outside of split mode (e.g. a plain +`pip install .`), `dependencies` is identical to the static list this +project shipped before the split. +""" + +from __future__ import annotations + +import os + +__all__ = ["dynamic_metadata", "get_requires_for_dynamic_metadata"] + + +def __dir__() -> list[str]: + return __all__ + + +_STATIC_DEPENDENCIES = [ + "imageio>=2", + "pillow; platform_machine == 'armv8l' or platform_machine == 'armv7l'", + "numpy>=1.26", +] + + +def _compute_version() -> str: + from setuptools_scm import Configuration, _get_version + + config = Configuration.from_file("pyproject.toml") + try: + version = _get_version(config, force_write_version_files=True) + except TypeError: # setuptools_scm < 8 + version = _get_version(config) + + if version is None: + msg = f"setuptools-scm was unable to detect version for {config.absolute_root}." + raise ValueError(msg) + + return version + + +def dynamic_metadata( + field: str, + settings: dict[str, object] | None = None, +) -> str | list[str]: + if settings: + msg = "No inline configuration is supported" + raise ValueError(msg) + + if field == "version": + return _compute_version() + + if field == "dependencies": + if not os.environ.get("HALIDE_SPLIT_BUILD"): + return list(_STATIC_DEPENDENCIES) + return [*_STATIC_DEPENDENCIES, f"halide-bin=={_compute_version()}"] + + msg = f"Only 'version' and 'dependencies' fields are supported, not {field!r}" + raise ValueError(msg) + + +def get_requires_for_dynamic_metadata( + _settings: dict[str, object] | None = None, +) -> list[str]: + return ["setuptools-scm"] diff --git a/pyproject.toml b/pyproject.toml index 987e651a4829..7a4a86239470 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,12 +16,7 @@ description = "Halide is a programming language designed to make it easier to wr license = { file = "LICENSE.txt" } readme = "./packaging/pip/README.md" requires-python = ">=3.10" -dependencies = [ - "imageio>=2", - "pillow; platform_machine == 'armv8l' or platform_machine == 'armv7l'", - "numpy>=1.26", -] -dynamic = ['version'] +dynamic = ['version', 'dependencies'] keywords = [ "array", "compiler", @@ -106,6 +101,8 @@ Issues = "https://github.com/halide/Halide/issues" Repository = "https://github.com/halide/Halide.git" [tool.scikit-build] +# Required for in-tree dynamic-metadata plugins (packaging/pip/_dynamic_metadata.py). +experimental = true cmake.version = ">=3.28" ninja.version = ">=1.11,!=1.13.0" wheel.install-dir = "halide" @@ -117,7 +114,14 @@ sdist.exclude = [ "tutorial/", "dependencies/update-*.sh", ] -metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.scikit-build.metadata.version] +provider = "_dynamic_metadata" +provider-path = "packaging/pip" + +[tool.scikit-build.metadata.dependencies] +provider = "_dynamic_metadata" +provider-path = "packaging/pip" [tool.scikit-build.cmake.define] CMAKE_DISABLE_FIND_PACKAGE_JPEG = true @@ -142,6 +146,17 @@ if.platform-system = "^win32" inherit.cmake.define = "append" cmake.define.Halide_WASM_BACKEND = "OFF" +## +# Split-package build: build only the Python bindings, against an +# already-installed `halide-bin` (found via CMAKE_PREFIX_PATH, which the +# caller -- e.g. .github/workflows/pip.yml -- must set explicitly). Only +# official release CI sets HALIDE_SPLIT_BUILD; a plain `pip install .` +# from a source checkout is unaffected and keeps building everything from +# scratch, as before. +[[tool.scikit-build.overrides]] +if.env.HALIDE_SPLIT_BUILD = true +cmake.source-dir = "python_bindings" + [tool.setuptools_scm] # Needs to exist for scikit-build-core to use setuptools_scm diff --git a/python_bindings/packaging/CMakeLists.txt b/python_bindings/packaging/CMakeLists.txt index dee493fb4fd2..328084de1f14 100644 --- a/python_bindings/packaging/CMakeLists.txt +++ b/python_bindings/packaging/CMakeLists.txt @@ -88,3 +88,11 @@ if (WITH_PYTHON_BINDINGS OR WITH_PYTHON_STUBS) COMPONENT Halide_Python ) endif () + +## +# Pip overrides +## + +if (SKBUILD) + add_subdirectory(pip) +endif () diff --git a/python_bindings/packaging/pip/CMakeLists.txt b/python_bindings/packaging/pip/CMakeLists.txt new file mode 100644 index 000000000000..990039edf953 --- /dev/null +++ b/python_bindings/packaging/pip/CMakeLists.txt @@ -0,0 +1,19 @@ +## +# Create a trampoline to the real Halide_PythonConfig.cmake. +# +# Same trick as packaging/pip/CMakeLists.txt in the root project: the wheel +# directory gets grafted to one of an unpredictable set of paths determined +# by sysconfig, so this trampoline finds platlib via sysconfig before jumping +# to the real Halide_PythonConfig.cmake inside the halide package. + +configure_file(TrampolineConfig.cmake.in "Halide_PythonConfig.cmake" @ONLY) + +install( + FILES + "${CMAKE_CURRENT_BINARY_DIR}/Halide_PythonConfig.cmake" + # It's better to duplicate the version file than to trampoline to it, as + # this would require calling find_package(Python) in the version file. + "${CMAKE_CURRENT_BINARY_DIR}/../Halide_PythonConfigVersion.cmake" + DESTINATION "${SKBUILD_DATA_DIR}/share/cmake/Halide_Python" + COMPONENT Halide_Python +) diff --git a/python_bindings/packaging/pip/TrampolineConfig.cmake.in b/python_bindings/packaging/pip/TrampolineConfig.cmake.in new file mode 100644 index 000000000000..45d40bad64a5 --- /dev/null +++ b/python_bindings/packaging/pip/TrampolineConfig.cmake.in @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.28) +include(CMakeFindDependencyMacro) +find_dependency(Python 3 COMPONENTS Interpreter Development.Module) + +include("${Python_SITEARCH}/halide/@Halide_Python_INSTALL_CMAKEDIR@/Halide_PythonConfig.cmake") diff --git a/python_bindings/src/halide/__init__.py b/python_bindings/src/halide/__init__.py index b0438e6bff68..580263c20306 100644 --- a/python_bindings/src/halide/__init__.py +++ b/python_bindings/src/halide/__init__.py @@ -1,3 +1,18 @@ +def _halide_install_dir(): + # `halide-bin` is only present when this package was built in split mode + # (see pyproject.toml's HALIDE_SPLIT_BUILD override); a plain, monolithic + # build has no such dependency and bundles everything under this package's + # own directory instead, exactly as before the split. + try: + import halide_bin + except ImportError: + import os + + return os.path.dirname(__file__) + else: + return halide_bin.install_dir() + + def _preload_bundled_halide_library(): # Force-load our own copy of the Halide runtime library by absolute path before # importing halide_, so that halide_'s implicit load of the same library (by @@ -10,7 +25,7 @@ def _preload_bundled_halide_library(): from pathlib import Path - root = Path(__file__).parent + root = Path(_halide_install_dir()) bin_dir = root / "bin" if hasattr(os, "add_dll_directory") and bin_dir.is_dir(): @@ -38,9 +53,7 @@ def _preload_bundled_halide_library(): def install_dir(): - import os - - return os.path.dirname(__file__) + return _halide_install_dir() from ._generator_helpers import ( # noqa: E402, F401 From 3682482fe3f10873c568681dea2970a73418ffa2 Mon Sep 17 00:00:00 2001 From: Derek Gerstmann Date: Mon, 3 Aug 2026 15:20:22 -0700 Subject: [PATCH 2/5] Add standalone halide.runtime module for calling AOT kernels without libHalide Introduce `halide.runtime`, a small Python extension that can load and call precompiled Halide AOT kernels without depending on libHalide (the compiler) or LLVM. This lets you compile pipelines on a build machine with the full toolchain and run them on deployment machines that have neither. Shared marshalling core ----------------------- The buffer-protocol <-> halide_buffer_t marshalling (`unpack_buffer` and `PyHalideBuffer`) previously lived only as string literals inside PythonExtensionGen.cpp. Lift it into a single source of truth, src/PythonExtensionRuntime.template.cpp, embedded into libHalide via binary2cpp (added to C_TEMPLATE_FILES in both src/CMakeLists.txt and the Makefile) and also compiled directly into the runtime module. `unpack_buffer` is now `inline` so it can be emitted into every generated .py.cpp (including the multi-library OMIT_MODULE_DEFINITION case) without violating the ODR. PythonExtensionGen.cpp shrinks by ~160 lines and its three copies of the conversion logic are unified. The runtime module (python_bindings/src/halide/runtime/) ------------------------------------------------------- * PyRuntime.cpp: a pybind11 extension linking only Halide::Runtime (headers) plus a compiled runtime via add_halide_runtime -- never libHalide. - `load(path, name=None)`: dlopen/LoadLibrary an artifact, dlsym its `_argv`/`_metadata`, and return a callable `Kernel`. - `Kernel`: `__call__` marshals buffer-protocol objects (NumPy) and scalars of every type into the argv array driven by halide_filter_metadata_t; exposes `name`, `target`, `argument_names`, and `arguments` (per-argument name/kind/type/dimensions introspection). - `Buffer`: wraps a buffer-protocol object as a halide_buffer_t, exposing the duck-typed `_get_raw_halide_buffer_t` protocol (shared with halide.Buffer and generated extensions) plus a zero-copy NumPy round-trip. - Installs a non-aborting error handler both in its own runtime and, via dlsym, in each loaded kernel's runtime, so a runtime error (e.g. a missing GPU driver) raises a Python exception instead of aborting the interpreter. Lazy compiler import -------------------- Rewrite halide/__init__.py to defer loading the compiler extension (halide_) and the generator helpers until a compiler attribute is first accessed (PEP 562 module __getattr__/__dir__). `import halide.runtime` therefore never pulls in libHalide, even when the compiler is present. A runtime-only install raises a clear ImportError, guiding users to the full `halide` package, when the compiler is accessed. Packaging --------- * Install the runtime module (component Halide_PythonRuntime) and split the Python-source install so that component is self-contained. * Install PythonExtensionRuntime.template.cpp next to HalideRuntime.h so the runtime module can be built out-of-tree (the CMake now finds the template in-tree or via the installed Halide::Runtime include dirs). * Add packaging/pip-runtime: a libHalide-free `halide-runtime` wheel that builds only the runtime target and installs only its component (numpy dependency, no halide-bin). * Add a build-runtime-wheels job to .github/workflows/pip.yml (split-built against halide-bin; a bare-environment `import halide.runtime` is itself the no-libHalide check) and publish it alongside the existing wheels. Tests (python_bindings/test/runtime/) ------------------------------------- * load_aot.py: load a real generated kernel, call it, and exercise Buffer interop, asserting the compiler was never imported. * call_convention.py: drive a kernel entirely from `kernel.arguments` covering every scalar type, a 2-D buffer, and a Tuple output; a second build with the enum GeneratorParam `combine=xor` demonstrates that a compile-time GeneratorParam changes behavior without changing the runtime calling convention. * gpu.py: Metal/OpenCL/CUDA/Vulkan coverage (CUDA gated on LLVM's NVPTX backend, Metal on Apple), running the backends with a live device and skipping the rest. * A CMake check asserting the runtime module has no libHalide dependency. Docs and tutorial ----------------- * doc/Python.md: a new "Calling AOT Code Without the Compiler (halide.runtime)" section covering producing a loadable kernel, load()/Kernel/arguments, and the Buffer type. * python_bindings/tutorial/lesson_15_runtime.py: a self-contained lesson that AOT-compiles a pipeline, links it into a loadable shared library (force_load on macOS, --whole-archive on Linux, link.exe /DLL with a .def on Windows), and then loads and runs it with only halide.runtime. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pip.yml | 87 ++- Makefile | 3 +- doc/Python.md | 122 ++++ packaging/CMakeLists.txt | 10 + packaging/pip-runtime/README.md | 24 + packaging/pip-runtime/pyproject.toml | 92 +++ python_bindings/packaging/CMakeLists.txt | 30 + python_bindings/src/halide/CMakeLists.txt | 3 + python_bindings/src/halide/__init__.py | 125 +++- .../src/halide/runtime/CMakeLists.txt | 63 ++ .../src/halide/runtime/PyRuntime.cpp | 604 ++++++++++++++++++ .../src/halide/runtime/__init__.py | 15 + python_bindings/test/CMakeLists.txt | 1 + python_bindings/test/runtime/CMakeLists.txt | 125 ++++ .../test/runtime/aot_module_stub.c | 3 + .../test/runtime/call_convention.py | 122 ++++ .../test/runtime/callconv_generator.cpp | 76 +++ .../test/runtime/check_no_libhalide.cmake | 43 ++ python_bindings/test/runtime/gpu.py | 63 ++ .../test/runtime/gpu_generator.cpp | 28 + python_bindings/test/runtime/load_aot.py | 64 ++ .../test/runtime/runtimeadd_generator.cpp | 23 + python_bindings/tutorial/CMakeLists.txt | 1 + python_bindings/tutorial/lesson_15_runtime.py | 180 ++++++ src/CMakeLists.txt | 2 +- src/PythonExtensionGen.cpp | 190 +----- src/PythonExtensionRuntime.template.cpp | 186 ++++++ 27 files changed, 2081 insertions(+), 204 deletions(-) create mode 100644 packaging/pip-runtime/README.md create mode 100644 packaging/pip-runtime/pyproject.toml create mode 100644 python_bindings/src/halide/runtime/CMakeLists.txt create mode 100644 python_bindings/src/halide/runtime/PyRuntime.cpp create mode 100644 python_bindings/src/halide/runtime/__init__.py create mode 100644 python_bindings/test/runtime/CMakeLists.txt create mode 100644 python_bindings/test/runtime/aot_module_stub.c create mode 100644 python_bindings/test/runtime/call_convention.py create mode 100644 python_bindings/test/runtime/callconv_generator.cpp create mode 100644 python_bindings/test/runtime/check_no_libhalide.cmake create mode 100644 python_bindings/test/runtime/gpu.py create mode 100644 python_bindings/test/runtime/gpu_generator.cpp create mode 100644 python_bindings/test/runtime/load_aot.py create mode 100644 python_bindings/test/runtime/runtimeadd_generator.cpp create mode 100644 python_bindings/tutorial/lesson_15_runtime.py create mode 100644 src/PythonExtensionRuntime.template.cpp diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml index de7fa6cb3bda..d57a47f25005 100644 --- a/.github/workflows/pip.yml +++ b/.github/workflows/pip.yml @@ -255,9 +255,94 @@ jobs: name: wheels-${{ matrix.platform_tag }} path: ./wheelhouse/*.whl + build-runtime-wheels: + name: Build halide-runtime wheels for ${{ matrix.platform_tag }} + needs: build-halide-bin + + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform_tag: manylinux_x86_64 + - os: windows-latest + platform_tag: win_amd64 + - os: macos-15-intel + platform_tag: macosx_x86_64 + - os: macos-15 + platform_tag: macosx_arm64 + + env: + MACOSX_DEPLOYMENT_TARGET: 11 + HALIDE_SPLIT_BUILD: "1" + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: ilammy/msvc-dev-cmd@v1 + - uses: lukka/get-cmake@v4.4.0 + with: + cmakeVersion: "~3.28.0" + + ######################################################################## + # halide-bin + # + # The runtime module is compiled against an already-installed halide-bin + # (for the runtime headers, the shared marshalling source, and GenRT to + # generate its bundled Halide runtime), exactly like build-wheels above. + # Unlike that job, the resulting extension links no libHalide and the wheel + # has no halide-bin dependency. + ######################################################################## + + - uses: actions/download-artifact@v8 + with: + name: wheels-bin-${{ matrix.platform_tag }} + path: dist-bin + + - name: Unpack halide-bin for the build + shell: bash + run: python -m zipfile -e dist-bin/*.whl opt/halide-bin + + ######################################################################## + # Wheels + ######################################################################## + + - name: Build wheels + uses: pypa/cibuildwheel@v4.1.0 + with: + package-dir: packaging/pip-runtime + env: + CIBW_BUILD: "cp3*-${{ matrix.platform_tag }}" + CIBW_SKIP: "cp3{5,6,7,8,9}* cp314t-*" + CIBW_ENVIRONMENT_LINUX: > + CMAKE_PREFIX_PATH=/project/opt/halide-bin/halide_bin/data + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE_RUNTIME='{local_scheme="no-local-version"}' + CIBW_ENVIRONMENT_MACOS: > + CMAKE_PREFIX_PATH='${{ github.workspace }}/opt/halide-bin/halide_bin/data' + Python_ROOT_DIR='' + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE_RUNTIME='{local_scheme="no-local-version"}' + CIBW_ENVIRONMENT_WINDOWS: > + CMAKE_GENERATOR=Ninja + CMAKE_PREFIX_PATH='${{ github.workspace }}\opt\halide-bin\halide_bin\data' + SETUPTOOLS_SCM_OVERRIDES_FOR_HALIDE_RUNTIME='{local_scheme="no-local-version"}' + # The runtime wheel links no libHalide, so its test environment needs + # nothing installed: a successful `import halide.runtime` in a bare + # environment is itself proof that the module is libHalide-free. + CIBW_TEST_COMMAND: > + python -c "import halide.runtime as r; assert r.load and r.Kernel and r.Buffer; print('halide.runtime OK')" + + - uses: actions/upload-artifact@v7 + with: + name: wheels-runtime-${{ matrix.platform_tag }} + path: ./wheelhouse/*.whl + publish: name: Publish on PyPI - needs: [build-halide-bin, build-wheels] + needs: [build-halide-bin, build-wheels, build-runtime-wheels] runs-on: ubuntu-latest permissions: id-token: write diff --git a/Makefile b/Makefile index e26176ecb854..d0218e16eb6f 100644 --- a/Makefile +++ b/Makefile @@ -632,7 +632,8 @@ SOURCE_FILES = \ C_TEMPLATE_FILES = \ CodeGen_C_prologue \ - CodeGen_C_vectors + CodeGen_C_vectors \ + PythonExtensionRuntime HTML_TEMPLATE_FILES = \ StmtToHTML_dependencies.html \ diff --git a/doc/Python.md b/doc/Python.md index cd22bd61e3b7..9f76bae6f439 100644 --- a/doc/Python.md +++ b/doc/Python.md @@ -22,6 +22,10 @@ - [Using a Generator for JIT compilation](#using-a-generator-for-jit-compilation) - [Using a Generator for AOT compilation](#using-a-generator-for-aot-compilation) - [Calling Generator-Produced code from Python](#calling-generator-produced-code-from-python) + - [Calling AOT Code Without the Compiler (`halide.runtime`)](#calling-aot-code-without-the-compiler-halideruntime) + - [Producing a loadable kernel](#producing-a-loadable-kernel) + - [Loading and calling a kernel](#loading-and-calling-a-kernel) + - [The `halide.runtime.Buffer` type](#the-halideruntimebuffer-type) - [Advanced Generator-Related Topics](#advanced-generator-related-topics) - [Generator Aliases](#generator-aliases) - [Dynamic Inputs and Outputs](#dynamic-inputs-and-outputs) @@ -621,6 +625,124 @@ pass `order='F'` to make numpy use the Halide-compatible memory layout. If you're passing in an array constructed somewhere else, the easiest thing to do is to `.transpose()` it before passing it to your Halide code. +### Calling AOT Code Without the Compiler (`halide.runtime`) + +The approach above imports a Python extension that was produced at build time by +`add_halide_python_extension_library`. Sometimes you instead want to load a +precompiled Halide kernel _dynamically_, at runtime, from an ordinary shared +library -- and to do so in an environment that does not have the Halide compiler +(or `libHalide`) installed at all. This is what the `halide.runtime` module is +for: it is a small, standalone package that can load and call AOT-compiled Halide +kernels without depending on `libHalide`. + +This is primarily useful for deployment. You can compile your pipelines on a +build machine that has the full Halide toolchain, then ship only the resulting +kernels plus this tiny runtime, and run them on machines that have neither the +compiler nor LLVM installed. + +`halide.runtime` is always included in the full `halide` package, but it is also +published as a separate, `libHalide`-free wheel for exactly this deployment case: + +```shell +pip install halide-runtime +``` + +Importing `halide.runtime` never loads `libHalide`; in the full package, the +compiler is loaded only if and when you first access a compiler symbol such as +`hl.Func`. (In a runtime-only install there is no compiler at all, so accessing +one raises an `ImportError` explaining that only the runtime is present and +pointing you at the full `halide` package.) + +#### Producing a loadable kernel + +`halide.runtime` loads a shared library that exports a Halide filter's +`_argv` and `_metadata` symbols -- the ordinary product of AOT +compilation. Note that this is _not_ the same artifact as the Python extension +produced by `add_halide_python_extension_library`, which deliberately hides every +symbol except its `PyInit_` entry point. Instead, link the AOT library into a +plain shared module that keeps those symbols visible: + +```cmake +add_halide_library(my_kernel FROM my_generator GENERATOR my_kernel) + +# Wrap the static AOT library in a shared module, keeping the filter's +# _argv/_metadata symbols exported so they can be resolved at load time. +# (A MODULE library needs at least one source of its own; an empty stub is fine.) +add_library(my_kernel_module MODULE stub.c) +target_link_libraries(my_kernel_module PRIVATE + "$") +set_target_properties(my_kernel_module PROPERTIES + PREFIX "" OUTPUT_NAME my_kernel + C_VISIBILITY_PRESET default + CXX_VISIBILITY_PRESET default) +``` + +Because `add_halide_library` bundles a Halide runtime into the library by +default, the resulting shared module is self-contained and, like `halide.runtime` +itself, has no dependency on `libHalide`. + +#### Loading and calling a kernel + +Once you have such a library, loading and calling it looks much like using the +compiled extension above: + +```python +import numpy as np +import halide.runtime as hlr + +# dlopen the shared library and locate the Halide filter inside it. The filter +# name defaults to the library's file name; pass name=... if it differs. +kernel = hlr.load("/path/to/my_kernel.so", name="my_kernel") + +print(kernel.name) # "my_kernel" +print(kernel.target) # the Target string it was compiled for +print(kernel.argument_names) # e.g. ['input', 'offset', 'output'] + +# `kernel.arguments` gives the full calling convention: one dict per argument, +# in argv order, with its name, kind ('input_scalar', 'input_buffer', or +# 'output_buffer'), element type (e.g. 'uint8'), and dimensions (0 for scalars). +for arg in kernel.arguments: + print(arg) # {'name': 'input', 'kind': 'input_buffer', 'type': 'uint8', 'dimensions': 2} + +input_buf = imageio.imread("/path/to/some/file.png") +output_buf = np.empty(input_buf.shape, dtype=input_buf.dtype) + +# Arguments -- inputs, scalars, and the output buffer(s) -- are passed +# positionally in the order given by kernel.argument_names, or by keyword, +# in the Python manner: +kernel(input_buf, np.int32(5), output_buf) +# kernel(input=input_buf, offset=5, output=output_buf) +``` + +As with the compiled extension, Halide does not allocate outputs for you: you +must pass in a correctly-sized output buffer, and error conditions raise a Python +exception rather than returning an int. + +#### The `halide.runtime.Buffer` type + +Any object that supports the Python buffer protocol (such as a numpy array) may +be passed directly to a kernel. For finer control, `halide.runtime.Buffer` wraps +such an object as a Halide runtime buffer, without copying: + +```python +buf = hlr.Buffer(np.empty((480, 640), dtype=np.uint8)) +buf.dimensions # 2 +buf.type # "uint8" +buf.shape # [480, 640] + +view = np.asarray(buf) # a zero-copy view of the same memory +``` + +A `Buffer` exposes the same `_get_raw_halide_buffer_t` protocol that `halide.Buffer` +and the extensions produced by `add_halide_python_extension_library` use, so the +very same object can be handed either to a kernel loaded via `halide.runtime.load` +or to a function in a generated extension module. + +The same memory-order caveats described in the previous section apply here: +numpy's default row-major layout corresponds to Halide's axes in reverse order, +so construct your arrays with `order='F'` (or `.transpose()` them) when the axis +order matters. + ### Advanced Generator-Related Topics #### Generator Aliases diff --git a/packaging/CMakeLists.txt b/packaging/CMakeLists.txt index 1d0481584396..14585bc365b5 100644 --- a/packaging/CMakeLists.txt +++ b/packaging/CMakeLists.txt @@ -82,6 +82,16 @@ install( FILE_SET HEADERS COMPONENT Halide_Development ) +# The shared buffer-marshalling core, #included both by generated Python +# extensions (embedded via binary2cpp) and compiled into the standalone +# halide.runtime module. Install it next to HalideRuntime.h so an out-of-tree +# build of the runtime module finds it via Halide::Runtime's include dirs. +install( + FILES "${Halide_SOURCE_DIR}/src/PythonExtensionRuntime.template.cpp" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}" + COMPONENT Halide_Development +) + ## # Halide tools ## diff --git a/packaging/pip-runtime/README.md b/packaging/pip-runtime/README.md new file mode 100644 index 000000000000..ceaa7453da63 --- /dev/null +++ b/packaging/pip-runtime/README.md @@ -0,0 +1,24 @@ +# halide-runtime + +A tiny, standalone runtime for calling **precompiled Halide AOT kernels** from +Python, with **no dependency on libHalide** (no compiler, no LLVM). + +```python +import numpy as np +import halide.runtime as hlr + +kernel = hlr.load("mykernel.so") # dlopen a precompiled Halide artifact +out = np.empty_like(inp) +kernel(inp, out) # call it with NumPy arrays +``` + +This package provides only `halide.runtime` — `hlr.load(...)`, the resulting +`Kernel` objects, and a lightweight `hlr.Buffer` wrapper. It is a strict subset +of the full [`halide`](https://pypi.org/project/halide/) package; install this +one for small deployment environments that run precompiled pipelines but do not +need the Halide compiler. Accessing the compiler API (e.g. `halide.Func`) from a +runtime-only install raises a clear `ImportError` directing you to the full +`halide` package. + +See the Halide Python docs: + diff --git a/packaging/pip-runtime/pyproject.toml b/packaging/pip-runtime/pyproject.toml new file mode 100644 index 000000000000..1858ae252a01 --- /dev/null +++ b/packaging/pip-runtime/pyproject.toml @@ -0,0 +1,92 @@ +[build-system] +requires = [ + "pybind11>=2.11.1", + "scikit-build-core~=0.11.0", + "setuptools-scm>=8.3.1", +] +build-backend = "scikit_build_core.build" + +[project] +name = "halide-runtime" +authors = [ + { name = "The Halide team", email = "halide-dev@lists.csail.mit.edu" }, +] +maintainers = [{ name = "Alex Reinking", email = "areinking@adobe.com" }] +description = "Standalone Halide runtime: load and call precompiled Halide AOT kernels without depending on libHalide (no compiler, no LLVM)." +license = { file = "../../LICENSE.txt" } +readme = "README.md" +requires-python = ">=3.10" +dynamic = ['version'] +dependencies = ["numpy>=1.26"] +keywords = ["array", "image processing", "runtime", "aot", "halide"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Natural Language :: English", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: C++", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Multimedia :: Graphics", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Image Processing", +] + +[project.urls] +Homepage = "https://halide-lang.org" +Documentation = "https://github.com/halide/Halide/blob/main/doc/Python.md" +Issues = "https://github.com/halide/Halide/issues" +Repository = "https://github.com/halide/Halide.git" + +[tool.scikit-build] +minimum-version = "build-system.requires" +experimental = true +cmake.version = ">=3.28" +ninja.version = ">=1.11,!=1.13.0" +cmake.source-dir = "../.." + +# Build and package ONLY the standalone runtime extension: build just its target +# (so libHalide's compiler bindings are never compiled) and install just its +# component (the _runtime extension plus the small pure-Python surface: the lazy +# halide/__init__.py and halide/runtime/__init__.py). The resulting wheel links +# no libHalide and has no `halide-bin` dependency. +build.targets = ["Halide_PythonRuntime"] +install.components = ["Halide_PythonRuntime"] +wheel.install-dir = "halide" +sdist.include = ["dependencies/"] + +metadata.version.provider = "scikit_build_core.metadata.setuptools_scm" + +[tool.scikit-build.cmake.define] +CMAKE_DISABLE_FIND_PACKAGE_JPEG = true +CMAKE_DISABLE_FIND_PACKAGE_PNG = true +FETCHCONTENT_FULLY_DISCONNECTED = true +Halide_ENABLE_EXCEPTIONS = true +Halide_ENABLE_RTTI = true +Halide_INSTALL_PYTHONDIR = "." +Halide_WASM_BACKEND = "wabt" +WITH_PYTHON_BINDINGS = true +WITH_TESTS = false +WITH_TUTORIALS = false +CMAKE_PLATFORM_NO_VERSIONED_SONAME = true + +[[tool.scikit-build.overrides]] +if.platform-system = "^win32" +inherit.cmake.define = "append" +cmake.define.Halide_WASM_BACKEND = "OFF" + +## +# Split-package build: build only the Python bindings tree against an +# already-installed `halide-bin` (found via CMAKE_PREFIX_PATH, which the caller +# must set), so the runtime extension is compiled without building libHalide. +# The extension still links no libHalide at runtime. +[[tool.scikit-build.overrides]] +if.env.HALIDE_SPLIT_BUILD = true +cmake.source-dir = "../../python_bindings" + +[tool.setuptools_scm] +root = "../.." diff --git a/python_bindings/packaging/CMakeLists.txt b/python_bindings/packaging/CMakeLists.txt index 328084de1f14..f715b3b0fab8 100644 --- a/python_bindings/packaging/CMakeLists.txt +++ b/python_bindings/packaging/CMakeLists.txt @@ -6,6 +6,25 @@ set(Halide_INSTALL_PYTHONDIR "${CMAKE_INSTALL_LIBDIR}/python3/site-packages/hali ) if (WITH_PYTHON_BINDINGS) + # The runtime-only Python surface -- the lazy top-level __init__ (which does + # not import the compiler until a compiler attribute is used) and the + # halide.runtime subpackage. These go in the Halide_PythonRuntime component + # so a libHalide-free halide-runtime wheel can select just this component and + # still be a complete, importable package. + install( + FILES "${Halide_Python_SOURCE_DIR}/src/halide/__init__.py" + DESTINATION "${Halide_INSTALL_PYTHONDIR}" + COMPONENT Halide_PythonRuntime + ) + install( + DIRECTORY "${Halide_Python_SOURCE_DIR}/src/halide/runtime/" + DESTINATION "${Halide_INSTALL_PYTHONDIR}/runtime" + COMPONENT Halide_PythonRuntime + FILES_MATCHING + PATTERN "*.py" + ) + + # The remaining (compiler-side) Python sources. install( DIRECTORY "${Halide_Python_SOURCE_DIR}/src/halide/" DESTINATION "${Halide_INSTALL_PYTHONDIR}" @@ -13,6 +32,8 @@ if (WITH_PYTHON_BINDINGS) FILES_MATCHING PATTERN "*.py" PATTERN "halide_" EXCLUDE + PATTERN "runtime" EXCLUDE + PATTERN "__init__.py" EXCLUDE ) install( @@ -21,6 +42,15 @@ if (WITH_PYTHON_BINDINGS) LIBRARY DESTINATION "${Halide_INSTALL_PYTHONDIR}" COMPONENT Halide_Python ) + # The standalone halide.runtime module. It links no libHalide, so it needs + # no RPATH patching. It gets its own install component so a libHalide-free + # runtime-only wheel can select just this (plus the *.py below); the full + # package installs every component and so includes it too. + install( + TARGETS Halide_PythonRuntime + LIBRARY DESTINATION "${Halide_INSTALL_PYTHONDIR}/runtime" COMPONENT Halide_PythonRuntime + ) + get_property(halide_is_imported TARGET Halide::Halide PROPERTY IMPORTED) get_property(halide_type TARGET Halide::Halide PROPERTY TYPE) diff --git a/python_bindings/src/halide/CMakeLists.txt b/python_bindings/src/halide/CMakeLists.txt index 138625bbf149..e3a7c6819f30 100644 --- a/python_bindings/src/halide/CMakeLists.txt +++ b/python_bindings/src/halide/CMakeLists.txt @@ -87,3 +87,6 @@ add_custom_command( add_custom_target(Halide_Python_sources DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/${stamp_file}") add_dependencies(Halide_Python Halide_Python_sources) + +# The standalone `halide.runtime` submodule (links only Halide::Runtime). +add_subdirectory(runtime) diff --git a/python_bindings/src/halide/__init__.py b/python_bindings/src/halide/__init__.py index 580263c20306..3878b548826a 100644 --- a/python_bindings/src/halide/__init__.py +++ b/python_bindings/src/halide/__init__.py @@ -43,34 +43,109 @@ def _preload_bundled_halide_library(): return -_preload_bundled_halide_library() -del _preload_bundled_halide_library +def install_dir(): + return _halide_install_dir() -from .halide_ import * # noqa: E402, F403 -# noinspection PyUnresolvedReferences, PyProtectedMember -from .halide_ import _, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9 # noqa: E402, F401 +# --------------------------------------------------------------------------- +# Lazy loading of the compiler extension. +# +# `import halide.runtime` must not pull in libHalide, but importing any submodule +# runs this parent package's __init__ first. So instead of eagerly importing the +# compiler extension (halide_) here, we defer it until a compiler attribute is +# actually accessed on the `halide` module (PEP 562 module __getattr__). A +# runtime-only deployment can therefore `import halide.runtime` with no compiler +# and no libHalide present. +# --------------------------------------------------------------------------- +# The implicit-argument placeholders, which `from .halide_ import *` would skip +# because they begin with an underscore. +_PLACEHOLDER_ARG_NAMES = ("_", "_0", "_1", "_2", "_3", "_4", "_5", "_6", "_7", "_8", "_9") -def install_dir(): - return _halide_install_dir() +_GENERATOR_HELPER_NAMES = ( + "_create_python_generator", + "_generatorcontext_enter", + "_generatorcontext_exit", + "_get_python_generator_names", + "active_generator_context", + "alias", + "funcs", + "generator", + "main", + "Generator", + "GeneratorParam", + "InputBuffer", + "InputScalar", + "OutputBuffer", + "OutputScalar", + "vars", +) +_compiler_loaded = False +_compiler_loading = False -from ._generator_helpers import ( # noqa: E402, F401 - _create_python_generator, - _generatorcontext_enter, - _generatorcontext_exit, - _get_python_generator_names, - active_generator_context, - alias, - funcs, - generator, - main, - Generator, - GeneratorParam, - InputBuffer, - InputScalar, - OutputBuffer, - OutputScalar, - vars, -) + +def _load_compiler(): + """Import the Halide compiler extension and hoist its names into this module. + + Idempotent, and safe to call repeatedly. Raises ImportError if the compiler + extension is unavailable (e.g. a libHalide-free, runtime-only install); the + "loaded" flag is only latched on success so that later accesses re-raise that + same clear error rather than a confusing AttributeError.""" + global _compiler_loaded, _compiler_loading + if _compiler_loaded or _compiler_loading: + # `_compiler_loading` guards re-entrant access (e.g. from the generator + # helpers imported below) while names are still being populated. + return + _compiler_loading = True + try: + _preload_bundled_halide_library() + + try: + from . import halide_ + except ImportError as e: + raise ImportError( + "The Halide compiler is not available in this installation. This " + "looks like a runtime-only install, which provides `halide.runtime` " + "(for calling precompiled AOT kernels) without libHalide. Install " + "the full `halide` package to use the compiler/JIT API." + ) from e + + g = globals() + _populate_from_compiler(g, halide_) + _compiler_loaded = True + finally: + _compiler_loading = False + + +def _populate_from_compiler(g, halide_): + # `from .halide_ import *` semantics: all public (non-underscore) names ... + for name in dir(halide_): + if not name.startswith("_"): + g.setdefault(name, getattr(halide_, name)) + # ... plus the implicit-argument placeholders imported explicitly. + for name in _PLACEHOLDER_ARG_NAMES: + g[name] = getattr(halide_, name) + + from . import _generator_helpers + + for name in _GENERATOR_HELPER_NAMES: + g[name] = getattr(_generator_helpers, name) + + +def __getattr__(name): + # PEP 562: invoked only for attributes not already found in globals(), so once + # _load_compiler() has hoisted the compiler names this is no longer hit for them. + if name == "__all__": + _load_compiler() + return sorted(n for n in globals() if not n.startswith("_")) + _load_compiler() + try: + return globals()[name] + except KeyError: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + + +def __dir__(): + _load_compiler() + return sorted(globals()) diff --git a/python_bindings/src/halide/runtime/CMakeLists.txt b/python_bindings/src/halide/runtime/CMakeLists.txt new file mode 100644 index 000000000000..aaaef5d3bc00 --- /dev/null +++ b/python_bindings/src/halide/runtime/CMakeLists.txt @@ -0,0 +1,63 @@ +## +# The `halide.runtime` extension module. +# +# This links ONLY the header-only Halide runtime (Halide::Runtime), never +# libHalide, so it can be imported standalone to call precompiled AOT kernels. +## + +pybind11_add_module(Halide_PythonRuntime) +add_library(Halide::PythonRuntime ALIAS Halide_PythonRuntime) + +set_target_properties( + Halide_PythonRuntime + PROPERTIES OUTPUT_NAME _runtime EXPORT_NAME PythonRuntime +) + +target_sources(Halide_PythonRuntime PRIVATE PyRuntime.cpp) + +# PyRuntime.cpp #includes the shared marshalling core (it depends only on +# + HalideRuntime.h). In an in-tree build it lives in the Halide +# source tree; when building against an installed Halide it is installed next to +# HalideRuntime.h and found via Halide::Runtime's include directories (so no +# extra include path is needed in that case). +if (DEFINED Halide_SOURCE_DIR AND EXISTS "${Halide_SOURCE_DIR}/src/PythonExtensionRuntime.template.cpp") + target_include_directories(Halide_PythonRuntime PRIVATE "${Halide_SOURCE_DIR}/src") +endif () + +# Runtime only: link a compiled Halide runtime (halide_copy_to_host, +# halide_device_free, error/print handlers, ...) plus the header-only runtime +# interface -- but NOT libHalide/the compiler. +add_halide_runtime(Halide_PythonRuntime_rt) +target_link_libraries(Halide_PythonRuntime PRIVATE Halide::Runtime Halide_PythonRuntime_rt) + +# Place the module at halide/runtime/ *inside the compiler package's tree* so it +# is importable as `halide.runtime`. The parent (Halide_Python) builds into +# `/$/halide`; this subdirectory's binary dir is one level +# deeper, so `../$/halide/runtime` co-locates the two. +set_target_properties( + Halide_PythonRuntime + PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/../$/halide/runtime" +) + +if (Halide_ASAN_ENABLED) + set_target_properties(Halide_PythonRuntime PROPERTIES CMAKE_SHARED_LINKER_FLAGS -shared-libasan) +endif () + +# Copy the Python source for the subpackage next to the extension. +set(python_sources __init__.py) +list(TRANSFORM python_sources + PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/" + OUTPUT_VARIABLE python_sources_source_dir) + +set(stamp_file "$/Halide_PythonRuntime_sources.stamp") +add_custom_command( + OUTPUT "${stamp_file}" + COMMAND "${CMAKE_COMMAND}" -E make_directory $ + COMMAND "${CMAKE_COMMAND}" -E copy -t $ ${python_sources_source_dir} + COMMAND "${CMAKE_COMMAND}" -E touch "${stamp_file}" + DEPENDS ${python_sources_source_dir} + VERBATIM +) + +add_custom_target(Halide_PythonRuntime_sources DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/${stamp_file}") +add_dependencies(Halide_PythonRuntime Halide_PythonRuntime_sources) diff --git a/python_bindings/src/halide/runtime/PyRuntime.cpp b/python_bindings/src/halide/runtime/PyRuntime.cpp new file mode 100644 index 000000000000..f43c313bb76a --- /dev/null +++ b/python_bindings/src/halide/runtime/PyRuntime.cpp @@ -0,0 +1,604 @@ +// The `halide.runtime` extension module. +// +// This module deliberately depends ONLY on the header-only Halide runtime +// (HalideRuntime.h / HalideBuffer.h) and pybind11 -- it must NOT depend on +// libHalide. It provides just enough to load a precompiled AOT Halide kernel +// (a shared object exporting `_argv` and `_metadata`) and call it +// with buffer-protocol objects (e.g. NumPy arrays) and Python scalars. +// +// It shares the buffer-protocol <-> halide_buffer_t marshalling core with the +// Python extensions emitted by PythonExtensionGen, via the single source of +// truth in src/PythonExtensionRuntime.template.cpp (included below). Interop +// with `halide.Buffer` (from the compiler module) and with objects produced by +// generated extensions flows through the duck-typed `_get_raw_halide_buffer_t` +// protocol, so no libHalide types cross the boundary. + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "HalideRuntime.h" + +#ifdef _WIN32 +#include +#else +#include +#endif + +// Pull in the shared marshalling core: Halide::PythonRuntime::unpack_buffer() +// and PyHalideBuffer<>. `unpack_buffer` is `inline` and PyHalideBuffer lives in +// an anonymous namespace, so including this translation unit here (rather than +// compiling it separately) is well-formed and keeps a single implementation. +#include "PythonExtensionRuntime.template.cpp" + +namespace py = pybind11; + +namespace { + +// --------------------------------------------------------------------------- +// Cross-platform dynamic library handling +// --------------------------------------------------------------------------- + +#ifdef _WIN32 +using LibHandle = HMODULE; +LibHandle open_library(const std::string &path) { + return LoadLibraryA(path.c_str()); +} +void *find_symbol(LibHandle handle, const std::string &name) { + return reinterpret_cast(GetProcAddress(handle, name.c_str())); +} +void close_library(LibHandle handle) { + FreeLibrary(handle); +} +std::string library_error() { + return "LoadLibrary/GetProcAddress failed (error " + std::to_string(GetLastError()) + ")"; +} +#else +using LibHandle = void *; +LibHandle open_library(const std::string &path) { + return dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); +} +void *find_symbol(LibHandle handle, const std::string &name) { + return dlsym(handle, name.c_str()); +} +void close_library(LibHandle handle) { + dlclose(handle); +} +std::string library_error() { + const char *e = dlerror(); + return e ? std::string(e) : std::string("unknown dynamic-linker error"); +} +#endif + +// --------------------------------------------------------------------------- +// Error handling +// +// By default the Halide runtime prints an error to stderr and aborts the +// process. That is unacceptable in a library: a bad argument or a missing GPU +// driver would take down the interpreter. We install a handler that instead +// records the message so Kernel::call can raise it as a Python exception. Each +// AOT artifact carries its own copy of the runtime, so the handler must be +// installed both into this module's runtime and, at load time, into the +// runtime bundled in each loaded kernel. +// --------------------------------------------------------------------------- + +std::mutex &error_mutex() { + static std::mutex m; + return m; +} + +std::string &last_error() { + static std::string s; + return s; +} + +extern "C" void runtime_error_handler(void * /*user_context*/, const char *msg) { + std::lock_guard lock(error_mutex()); + last_error() = msg ? msg : ""; +} + +std::string take_last_error() { + std::lock_guard lock(error_mutex()); + std::string s = last_error(); + last_error().clear(); + return s; +} + +using SetErrorHandlerFn = void (*)(void (*)(void *, const char *)); + +// Best-effort default filter name from a library path: strip the directory, +// any file extension, and a leading "lib". e.g. "/x/libfoo.so" -> "foo". +std::string default_filter_name(const std::string &path) { + size_t slash = path.find_last_of("/\\"); + std::string base = (slash == std::string::npos) ? path : path.substr(slash + 1); + size_t dot = base.find('.'); + if (dot != std::string::npos) { + base = base.substr(0, dot); + } + if (base.rfind("lib", 0) == 0 && base.size() > 3) { + base = base.substr(3); + } + return base; +} + +using ArgvCallFn = int (*)(void **); +using MetadataFn = const halide_filter_metadata_t *(*)(); + +// --------------------------------------------------------------------------- +// A single loaded AOT filter. +// --------------------------------------------------------------------------- + +std::string type_to_string(halide_type_t t); // defined below + +// The three halide_argument_kind_t values, as readable strings. +std::string kind_to_string(int kind) { + switch (kind) { + case halide_argument_kind_input_scalar: + return "input_scalar"; + case halide_argument_kind_input_buffer: + return "input_buffer"; + case halide_argument_kind_output_buffer: + return "output_buffer"; + default: + return "unknown"; + } +} + +class Kernel { +public: + Kernel(LibHandle handle, ArgvCallFn argv_fn, const halide_filter_metadata_t *md) + : handle_(handle), argv_fn_(argv_fn), md_(md) { + } + + ~Kernel() { + if (handle_) { + close_library(handle_); + } + } + + Kernel(const Kernel &) = delete; + Kernel &operator=(const Kernel &) = delete; + + std::string name() const { + return md_->name ? md_->name : ""; + } + + std::string target() const { + return md_->target ? md_->target : ""; + } + + // The argument names, in argv order. + std::vector argument_names() const { + std::vector names; + names.reserve(md_->num_arguments); + for (int i = 0; i < md_->num_arguments; i++) { + names.emplace_back(md_->arguments[i].name); + } + return names; + } + + // Full per-argument metadata, in argv order: a dict per argument describing + // the calling convention -- its name, kind (input_scalar / input_buffer / + // output_buffer), element type (e.g. "uint8"), and dimensions (0 for scalars). + py::list arguments() const { + py::list result; + for (int i = 0; i < md_->num_arguments; i++) { + const halide_filter_argument_t &a = md_->arguments[i]; + py::dict d; + d["name"] = std::string(a.name); + d["kind"] = kind_to_string(a.kind); + d["type"] = type_to_string(a.type); + d["dimensions"] = a.dimensions; + result.append(std::move(d)); + } + return result; + } + + // Per-argument scratch storage that must outlive the argv_fn call. + struct ArgSlot { + halide_scalar_value_t scalar{}; + halide_buffer_t buffer{}; + std::vector dims; + Py_buffer py_buf{}; + bool py_buf_valid = false; + bool needs_device_free = false; + }; + + void call(const py::args &args, const py::kwargs &kwargs) { + const int argc = md_->num_arguments; + + std::vector slots(argc); + std::vector argv(argc, nullptr); + + // Release any Python buffers / device allocations on scope exit. + struct Cleanup { + std::vector &slots; + ~Cleanup() { + for (auto &s : slots) { + if (s.needs_device_free) { + halide_device_free(nullptr, &s.buffer); + } + if (s.py_buf_valid) { + PyBuffer_Release(&s.py_buf); + } + } + } + } cleanup{slots}; + + // Map user arguments (positional + keyword) onto metadata slots, + // auto-filling the implicit __user_context argument if present. + std::vector values(argc); + std::vector filled(argc, false); + + size_t next_positional = 0; + for (int i = 0; i < argc; i++) { + if (is_user_context(md_->arguments[i])) { + filled[i] = true; // handled specially below; consumes no user arg + } + } + + for (auto item : args) { + // Advance to the next slot the user is expected to fill. + while (next_positional < (size_t)argc && filled[next_positional]) { + next_positional++; + } + if (next_positional >= (size_t)argc) { + throw std::runtime_error("Too many positional arguments for kernel '" + name() + "'."); + } + values[next_positional] = item; + filled[next_positional] = true; + next_positional++; + } + + for (auto kw : kwargs) { + const std::string key = py::cast(kw.first); + int slot = -1; + for (int i = 0; i < argc; i++) { + if (!is_user_context(md_->arguments[i]) && key == md_->arguments[i].name) { + slot = i; + break; + } + } + if (slot < 0) { + throw std::runtime_error("Unknown argument '" + key + "' for kernel '" + name() + "'."); + } + if (values[slot]) { + throw std::runtime_error("Argument '" + key + "' specified more than once."); + } + values[slot] = kw.second; + filled[slot] = true; + } + + for (int i = 0; i < argc; i++) { + const halide_filter_argument_t &a = md_->arguments[i]; + + ArgSlot &slot = slots[i]; + + if (is_user_context(a)) { + slot.scalar.u.u64 = 0; // null user context + argv[i] = &slot.scalar; + continue; + } + + if (!values[i]) { + throw std::runtime_error("Missing argument '" + std::string(a.name) + + "' for kernel '" + name() + "'."); + } + + if (a.kind == halide_argument_kind_input_scalar) { + store_scalar(a, values[i], &slot.scalar); + argv[i] = &slot.scalar; + } else { + // Input or output buffer. + const bool writable = (a.kind == halide_argument_kind_output_buffer); + halide_buffer_t *raw = unpack_buffer_arg(a, values[i], writable, slot); + if (a.kind == halide_argument_kind_input_buffer) { + raw->set_host_dirty(); + } + argv[i] = raw; + } + } + + take_last_error(); // clear any stale message + + int result; + { + py::gil_scoped_release release; + result = argv_fn_(argv.data()); + } + if (result != 0) { + const std::string msg = take_last_error(); + throw std::runtime_error( + msg.empty() ? ("Halide kernel '" + name() + "' returned error " + std::to_string(result)) + : ("Halide kernel '" + name() + "': " + msg)); + } + + // Flush any device-side outputs back to host (host-only buffer protocol). + for (int i = 0; i < argc; i++) { + const halide_filter_argument_t &a = md_->arguments[i]; + if (a.kind == halide_argument_kind_output_buffer) { + auto *buf = static_cast(argv[i]); + if (buf->device_dirty()) { + (void)halide_copy_to_host(nullptr, buf); + } + } + } + } + +private: + static bool is_user_context(const halide_filter_argument_t &a) { + return a.kind == halide_argument_kind_input_scalar && + std::strcmp(a.name, "__user_context") == 0; + } + + // Convert one buffer-protocol / Halide-buffer argument into a halide_buffer_t. + // Uses the shared `_get_raw_halide_buffer_t` fast path first, then falls back + // to the shared unpack_buffer() implementation. + halide_buffer_t *unpack_buffer_arg(const halide_filter_argument_t &a, + py::handle value, + bool writable, + ArgSlot &slot) { + PyObject *obj = value.ptr(); + + // Fast path: an object that already exposes a raw halide_buffer_t + // (halide.Buffer, halide.runtime.Buffer, or another generated result). + if (PyObject_HasAttrString(obj, "_get_raw_halide_buffer_t")) { + PyObject *raw = PyObject_CallMethod(obj, "_get_raw_halide_buffer_t", nullptr); + if (raw && PyLong_Check(raw)) { + auto ptr = (uintptr_t)PyLong_AsUnsignedLongLong(raw); + Py_DECREF(raw); + if (ptr) { + return reinterpret_cast(ptr); + } + } else { + Py_XDECREF(raw); + PyErr_Clear(); + } + } + + // General path: buffer-protocol object (e.g. NumPy array). + slot.dims.resize(a.dimensions > 0 ? a.dimensions : 1); + bool ok = Halide::PythonRuntime::unpack_buffer( + obj, writable ? PyBUF_WRITABLE : 0, a.name, a.dimensions, + slot.py_buf, slot.dims.data(), slot.buffer, slot.py_buf_valid, + slot.needs_device_free); + if (!ok) { + throw py::error_already_set(); + } + return &slot.buffer; + } + + void store_scalar(const halide_filter_argument_t &a, py::handle value, + halide_scalar_value_t *out) { + const halide_type_t t = a.type; + +#define HALIDE_RUNTIME_SCALAR_CASE(CODE, BITS, CTYPE, FIELD) \ + if (t.code == (CODE) && t.bits == (BITS)) { \ + out->u.FIELD = py::cast(value); \ + return; \ + } + + HALIDE_RUNTIME_SCALAR_CASE(halide_type_float, 32, float, f32) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_float, 64, double, f64) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_int, 8, int8_t, i8) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_int, 16, int16_t, i16) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_int, 32, int32_t, i32) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_int, 64, int64_t, i64) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_uint, 1, bool, b) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_uint, 8, uint8_t, u8) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_uint, 16, uint16_t, u16) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_uint, 32, uint32_t, u32) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_uint, 64, uint64_t, u64) + HALIDE_RUNTIME_SCALAR_CASE(halide_type_handle, 64, uint64_t, u64) + +#undef HALIDE_RUNTIME_SCALAR_CASE + + throw std::runtime_error("Unsupported scalar type for argument '" + + std::string(a.name) + "'."); + } + + LibHandle handle_; + ArgvCallFn argv_fn_; + const halide_filter_metadata_t *md_; +}; + +std::string type_to_string(halide_type_t t) { + if (t.code == halide_type_uint && t.bits == 1) { + return "bool"; + } + const char *base; + switch (t.code) { + case halide_type_int: + base = "int"; + break; + case halide_type_uint: + base = "uint"; + break; + case halide_type_float: + base = "float"; + break; + default: + base = "handle"; + break; + } + return std::string(base) + std::to_string(t.bits); +} + +// A thin wrapper around a buffer-protocol object (e.g. a NumPy array) that +// exposes it as a halide_buffer_t. This is the interop bridge: it speaks the +// same `_get_raw_halide_buffer_t` protocol as halide.Buffer and the generated +// AOT extensions, so one object can be passed to both a load()'ed Kernel and a +// generated-extension function. It does not own or copy the underlying memory; +// the wrapped object is kept alive for the Buffer's lifetime. +class Buffer { +public: + explicit Buffer(py::object source) + : source_(std::move(source)) { + PyObject *obj = source_.ptr(); + + // Determine ndim so we can size the Halide dimension array. + Py_buffer probe; + if (PyObject_GetBuffer(obj, &probe, PyBUF_FORMAT | PyBUF_STRIDES) < 0) { + throw py::error_already_set(); + } + const int ndim = probe.ndim; + PyBuffer_Release(&probe); + + dims_.resize(ndim > 0 ? ndim : 1); + + // Prefer a writable view (so the buffer can be used as an output), but + // fall back to read-only for immutable inputs. + bool ok = Halide::PythonRuntime::unpack_buffer( + obj, PyBUF_WRITABLE, "buffer", 0, view_, dims_.data(), buf_, + view_valid_, needs_device_free_); + if (!ok) { + PyErr_Clear(); + ok = Halide::PythonRuntime::unpack_buffer( + obj, 0, "buffer", 0, view_, dims_.data(), buf_, + view_valid_, needs_device_free_); + } + if (!ok) { + throw py::error_already_set(); + } + } + + ~Buffer() { + if (needs_device_free_) { + halide_device_free(nullptr, &buf_); + } + if (view_valid_) { + PyBuffer_Release(&view_); + } + } + + Buffer(const Buffer &) = delete; + Buffer &operator=(const Buffer &) = delete; + + uintptr_t raw_halide_buffer_t() { + return reinterpret_cast(&buf_); + } + + int dimensions() const { + return buf_.dimensions; + } + + std::string type() const { + return type_to_string(buf_.type); + } + + // Shape in NumPy (row-major) axis order. + std::vector shape() const { + std::vector s(view_.ndim); + for (int i = 0; i < view_.ndim; i++) { + s[i] = (int)view_.shape[i]; + } + return s; + } + + // Re-export the underlying memory via the buffer protocol, in NumPy axis + // order, so `numpy.asarray(buffer)` is a zero-copy view of the same data. + py::buffer_info buffer_info() { + std::vector shape(view_.shape, view_.shape + view_.ndim); + std::vector strides(view_.strides, view_.strides + view_.ndim); + return py::buffer_info( + view_.buf, view_.itemsize, + std::string(view_.format ? view_.format : "B"), + view_.ndim, shape, strides, view_.readonly != 0); + } + +private: + py::object source_; + Py_buffer view_{}; + bool view_valid_ = false; + bool needs_device_free_ = false; + std::vector dims_; + halide_buffer_t buf_{}; +}; + +std::shared_ptr load(const std::string &path, py::object name_obj) { + LibHandle handle = open_library(path); + if (!handle) { + throw std::runtime_error("Could not load '" + path + "': " + library_error()); + } + + // Redirect the kernel runtime's errors to our handler so a runtime failure + // (e.g. a missing GPU driver) raises a Python exception instead of aborting. + if (void *set_handler = find_symbol(handle, "halide_set_error_handler")) { + reinterpret_cast(set_handler)(&runtime_error_handler); + } + + std::vector candidates; + if (!name_obj.is_none()) { + candidates.push_back(py::cast(name_obj)); + } else { + candidates.push_back(default_filter_name(path)); + } + + for (const std::string &name : candidates) { + auto argv_fn = reinterpret_cast(find_symbol(handle, name + "_argv")); + auto meta_fn = reinterpret_cast(find_symbol(handle, name + "_metadata")); + if (argv_fn && meta_fn) { + const halide_filter_metadata_t *md = meta_fn(); + if (!md) { + continue; + } + if (md->version != halide_filter_metadata_t::VERSION) { + close_library(handle); + throw std::runtime_error("Kernel '" + name + "' has metadata version " + + std::to_string(md->version) + ", expected " + + std::to_string(halide_filter_metadata_t::VERSION)); + } + return std::make_shared(handle, argv_fn, md); + } + } + + close_library(handle); + std::string tried = candidates.empty() ? "" : candidates.front(); + throw std::runtime_error( + "Could not find Halide filter symbols '" + tried + "_argv' / '" + tried + + "_metadata' in '" + path + "'. Pass name=... if the function name differs from the file name."); +} + +} // namespace + +PYBIND11_MODULE(_runtime, m) { + m.doc() = "Standalone Halide runtime: load and call precompiled AOT kernels " + "without depending on libHalide."; + + // Make our own runtime's errors non-fatal too (e.g. a failed copy-to-host). + halide_set_error_handler(&runtime_error_handler); + + py::class_(m, "Buffer", py::buffer_protocol()) + .def(py::init(), py::arg("source"), + "Wrap a buffer-protocol object (e.g. a NumPy array) as a Halide " + "runtime buffer, without copying.") + .def_buffer(&Buffer::buffer_info) + .def("_get_raw_halide_buffer_t", &Buffer::raw_halide_buffer_t) + .def_property_readonly("dimensions", &Buffer::dimensions) + .def_property_readonly("type", &Buffer::type) + .def_property_readonly("shape", &Buffer::shape) + .def("__repr__", [](const Buffer &b) { + return ""; + }); + + py::class_>(m, "Kernel") + .def("__call__", &Kernel::call) + .def_property_readonly("name", &Kernel::name) + .def_property_readonly("target", &Kernel::target) + .def_property_readonly("argument_names", &Kernel::argument_names) + .def_property_readonly("arguments", &Kernel::arguments) + .def("__repr__", [](const Kernel &k) { + return ""; + }); + + m.def("load", &load, py::arg("path"), py::arg("name") = py::none(), + "Load a precompiled Halide AOT kernel from a shared library.\n\n" + "`path` is the shared object to dlopen; `name` is the Halide function\n" + "name (defaults to the library's file name). Returns a callable Kernel."); +} diff --git a/python_bindings/src/halide/runtime/__init__.py b/python_bindings/src/halide/runtime/__init__.py new file mode 100644 index 000000000000..03a4185e9f71 --- /dev/null +++ b/python_bindings/src/halide/runtime/__init__.py @@ -0,0 +1,15 @@ +"""Standalone Halide runtime. + +Load and call precompiled Halide AOT kernels without depending on libHalide:: + + import halide.runtime as hlr + kernel = hlr.load("path/to/kernel.so") + kernel(input_array, output_array) + +This subpackage links only the header-only Halide runtime, so it can be used in +deployment environments that do not have the Halide compiler installed. +""" + +from ._runtime import Buffer, Kernel, load # noqa: F401 + +__all__ = ["Buffer", "Kernel", "load"] diff --git a/python_bindings/test/CMakeLists.txt b/python_bindings/test/CMakeLists.txt index 786664317f17..35e801a78715 100644 --- a/python_bindings/test/CMakeLists.txt +++ b/python_bindings/test/CMakeLists.txt @@ -10,3 +10,4 @@ endif () add_subdirectory(correctness) add_subdirectory(generators) +add_subdirectory(runtime) diff --git a/python_bindings/test/runtime/CMakeLists.txt b/python_bindings/test/runtime/CMakeLists.txt new file mode 100644 index 000000000000..fdcd337ca5c9 --- /dev/null +++ b/python_bindings/test/runtime/CMakeLists.txt @@ -0,0 +1,125 @@ +## +# Tests for the standalone `halide.runtime` module. +## + +# Register a Halide generator (once). Separated from kernel building so a single +# generator can be compiled for several targets (e.g. the GPU backends below). +function(add_runtime_test_generator GEN SRC) + add_halide_generator(${GEN}_gen SOURCES "${SRC}") +endfunction() + +# Build an AOT kernel and wrap it into a loadable shared module named ".so". +# Unlike a Python extension library (which hides all but PyInit_), we keep the +# filter's `_argv` / `_metadata` symbols exported so the runtime loader +# can dlsym them; WHOLE_ARCHIVE pulls in the filter objects even though the stub +# references nothing. The loader defaults the filter name to the file stem, so we +# name the module and the Halide function identically. +function(add_runtime_test_kernel MOD) + cmake_parse_arguments(ARG "" "GENERATOR" "FEATURES;PARAMS" ${ARGN}) + if (NOT ARG_GENERATOR) + set(ARG_GENERATOR ${MOD}) + endif () + + add_halide_library(${MOD}_aot + FROM ${ARG_GENERATOR}_gen + GENERATOR ${ARG_GENERATOR} + FUNCTION_NAME ${MOD} + FEATURES ${ARG_FEATURES} + PARAMS ${ARG_PARAMS} + ) + + add_library(${MOD}_module MODULE aot_module_stub.c) + target_link_libraries(${MOD}_module PRIVATE "$") + set_target_properties( + ${MOD}_module + PROPERTIES + PREFIX "" + OUTPUT_NAME ${MOD} + C_VISIBILITY_PRESET default + CXX_VISIBILITY_PRESET default + ) +endfunction() + +# A tiny kernel: load, call, and Buffer interop. +add_runtime_test_generator(runtimeadd runtimeadd_generator.cpp) +add_runtime_test_kernel(runtimeadd) +add_python_test( + FILE load_aot.py + LABEL python_runtime + TEST_ARGS "$" +) + +# The full scalar/buffer calling convention: every scalar type, a 2-D buffer, +# and multiple outputs including a Tuple output. We compile the generator twice +# with different values of its enum GeneratorParam `combine` (a compile-time +# choice, baked into each kernel) to show that it selects behavior without +# changing the runtime calling convention. +add_runtime_test_generator(callconv callconv_generator.cpp) +add_runtime_test_kernel(callconv) # combine=add (default) +add_runtime_test_kernel(callconv_xor GENERATOR callconv PARAMS combine=xor) +add_python_test( + FILE call_convention.py + LABEL python_runtime + TEST_ARGS "$" "$" +) + +## +# GPU coverage. +# +# We compile the same GPU-scheduled kernel for each backend that can be built on +# this platform, then run one test that tries each and skips a backend at run +# time if its device/driver is missing (as on a headless CI runner). +# +# * Metal -- Apple only (its runtime links Apple frameworks). +# * OpenCL -- OpenCL C is emitted and compiled by the driver at run time; +# the runtime is dlopen-based, so it builds everywhere. +# * Vulkan -- SPIR-V is emitted directly (no LLVM GPU target); vk_int8 is +# required because the kernel uses 8-bit buffers. +# * CUDA -- needs LLVM's NVPTX backend to emit PTX. +## + +add_runtime_test_generator(gpuadd gpu_generator.cpp) + +set(gpu_modules "") + +if (APPLE) + add_runtime_test_kernel(gpuadd_metal GENERATOR gpuadd FEATURES metal) + list(APPEND gpu_modules gpuadd_metal) +endif () + +add_runtime_test_kernel(gpuadd_opencl GENERATOR gpuadd FEATURES opencl) +list(APPEND gpu_modules gpuadd_opencl) + +add_runtime_test_kernel(gpuadd_vulkan GENERATOR gpuadd FEATURES vulkan vk_int8) +list(APPEND gpu_modules gpuadd_vulkan) + +if ("NVPTX" IN_LIST Halide_LLVM_COMPONENTS) + add_runtime_test_kernel(gpuadd_cuda GENERATOR gpuadd FEATURES cuda) + list(APPEND gpu_modules gpuadd_cuda) +else () + message(STATUS "halide.runtime GPU test: skipping CUDA (LLVM has no NVPTX backend)") +endif () + +set(gpu_module_files "") +foreach (mod IN LISTS gpu_modules) + list(APPEND gpu_module_files "$") +endforeach () + +add_python_test( + FILE gpu.py + LABEL python_runtime + TEST_ARGS ${gpu_module_files} +) + +# The whole point of the runtime module: it must not depend on libHalide. +# (Symbol-visibility trick from the export-single-symbol test; POSIX only.) +if (NOT MSVC AND NOT WIN32) + add_test( + NAME python_runtime_no_libhalide + COMMAND + "${CMAKE_COMMAND}" + "-DMODULE=$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/check_no_libhalide.cmake" + ) + set_tests_properties(python_runtime_no_libhalide PROPERTIES LABELS "python;python_runtime") +endif () diff --git a/python_bindings/test/runtime/aot_module_stub.c b/python_bindings/test/runtime/aot_module_stub.c new file mode 100644 index 000000000000..7c29acced3e2 --- /dev/null +++ b/python_bindings/test/runtime/aot_module_stub.c @@ -0,0 +1,3 @@ +/* Empty stub: the runtimeadd_module shared library is built entirely from the + * WHOLE_ARCHIVE'd AOT static library. A CMake MODULE library still needs at + * least one source file of its own, which this provides. */ diff --git a/python_bindings/test/runtime/call_convention.py b/python_bindings/test/runtime/call_convention.py new file mode 100644 index 000000000000..cc8b1bc67c92 --- /dev/null +++ b/python_bindings/test/runtime/call_convention.py @@ -0,0 +1,122 @@ +"""Exercise the full AOT calling convention through halide.runtime. + +This drives the `callconv` kernel entirely from its introspected metadata +(`kernel.arguments`): input scalars of every type, a 2-D input buffer, and three +outputs of differing element types -- including a Tuple output that lowers to +several output buffers. Because everything is computed elementwise, the numpy +reference is independent of Halide's axis ordering. +""" + +import sys + +import numpy as np + +import halide.runtime as hlr + +# Halide type string (as reported by kernel.arguments) -> numpy dtype. +_DTYPE = { + "bool": np.uint8, + "int8": np.int8, + "int16": np.int16, + "int32": np.int32, + "int64": np.int64, + "uint8": np.uint8, + "uint16": np.uint16, + "uint32": np.uint32, + "uint64": np.uint64, + "float32": np.float32, + "float64": np.float64, +} + +# Deliberately spans signed/unsigned, narrow/wide, and float types. Values are +# small enough that the int64 accumulator never overflows. +SCALARS = { + "s_bool": True, + "s_i8": -5, + "s_i16": 300, + "s_i32": -70000, + "s_i64": 5_000_000_000, + "s_u8": 7, + "s_u16": 40000, + "s_u32": 3_000_000_000, + "s_u64": 10_000_000_000, + "s_f32": 1.5, # exactly representable in float32 + "s_f64": 2.25, +} + + +def run(kernel, shape, input_buf): + """Call `kernel` with a value for every argument, driven by its metadata.""" + args = {a["name"]: a for a in kernel.arguments} + call_args = {} + for name, a in args.items(): + if a["kind"] == "input_buffer": + call_args[name] = input_buf + elif a["kind"] == "output_buffer": + call_args[name] = np.zeros(shape, dtype=_DTYPE[a["type"]]) + else: # input_scalar + call_args[name] = SCALARS[name] + # Note: "packed.0"/"packed.1" are not valid Python identifiers, but keyword + # expansion of a dict accepts arbitrary string keys. + kernel(**call_args) + return call_args + + +def main(): + # Two kernels compiled from the same generator with different values of its + # enum GeneratorParam `combine`: the default (add) and a `combine=xor` build. + add_path, xor_path = sys.argv[1], sys.argv[2] + kernel = hlr.load(add_path, name="callconv") + + # Introspection describes the calling convention: name, kind, type, dims. + args = {a["name"]: a for a in kernel.arguments} + assert args["input"]["kind"] == "input_buffer" + assert args["input"]["type"] == "uint8" and args["input"]["dimensions"] == 2 + assert args["s_bool"]["kind"] == "input_scalar" + assert args["s_u64"]["type"] == "uint64" and args["s_u64"]["dimensions"] == 0 + assert args["total"]["kind"] == "output_buffer" and args["total"]["type"] == "int64" + assert args["scaled"]["type"] == "float64" + # The Tuple output shows up as two separate output buffers. + assert "packed.0" in args and "packed.1" in args + assert args["packed.0"]["type"] == "uint8" and args["packed.1"]["type"] == "int32" + + shape = (3, 4) + input_buf = np.arange(12, dtype=np.uint8).reshape(shape) + + call_args = run(kernel, shape, input_buf) + + in64 = input_buf.astype(np.int64) + scalar_sum = ( + 1 # s_bool + - 5 + 300 - 70000 + 5_000_000_000 # signed + + 7 + 40000 + 3_000_000_000 + 10_000_000_000 # unsigned + ) + expected_total = in64 + scalar_sum + np.testing.assert_array_equal(call_args["total"], expected_total) + + expected_scaled = np.float32(1.5).astype(np.float64) * in64.astype(np.float64) + 2.25 + np.testing.assert_array_equal(call_args["scaled"], expected_scaled) + + # Default `combine=add`: packed.0 = input + s_u8. + np.testing.assert_array_equal(call_args["packed.0"], (input_buf + 7).astype(np.uint8)) + np.testing.assert_array_equal(call_args["packed.1"], expected_total.astype(np.int32)) + + # The enum GeneratorParam is a compile-time choice: the `combine=xor` build is + # a different kernel with the *same* calling convention but different behavior. + xor_kernel = hlr.load(xor_path, name="callconv_xor") + assert [a["name"] for a in xor_kernel.arguments] == list(args), ( + "the enum GeneratorParam must not change the runtime calling convention" + ) + xor_args = run(xor_kernel, shape, input_buf) + np.testing.assert_array_equal(xor_args["packed.0"], (input_buf ^ 7).astype(np.uint8)) + # Everything not selected by the enum is unchanged. + np.testing.assert_array_equal(xor_args["total"], expected_total) + + # No compiler was needed for any of this. + assert "halide.halide_" not in sys.modules + + print("Success!") + + +if __name__ == "__main__": + main() diff --git a/python_bindings/test/runtime/callconv_generator.cpp b/python_bindings/test/runtime/callconv_generator.cpp new file mode 100644 index 000000000000..9cd0c9c2a56c --- /dev/null +++ b/python_bindings/test/runtime/callconv_generator.cpp @@ -0,0 +1,76 @@ +#include "Halide.h" + +using namespace Halide; + +// Exercises the full AOT scalar/buffer calling convention as seen by +// halide.runtime: a 2-D input buffer, input scalars of every supported width and +// signedness, and three outputs of different element types -- including a Tuple +// output, which lowers to several output buffers (a "structured" output). +class CallConv : public Generator { +public: + // A compile-time enum GeneratorParam: it selects how `packed.0` is computed + // and is baked into the generated code (it is NOT a runtime argument). It is + // set at build time, e.g. `add_halide_library(... PARAMS combine=xor)`. + enum class Combine { Add, + Sub, + Xor }; + GeneratorParam combine{ + "combine", + Combine::Add, + {{"add", Combine::Add}, {"sub", Combine::Sub}, {"xor", Combine::Xor}}}; + + Input> input{"input"}; + + Input s_bool{"s_bool"}; + Input s_i8{"s_i8"}; + Input s_i16{"s_i16"}; + Input s_i32{"s_i32"}; + Input s_i64{"s_i64"}; + Input s_u8{"s_u8"}; + Input s_u16{"s_u16"}; + Input s_u32{"s_u32"}; + Input s_u64{"s_u64"}; + Input s_f32{"s_f32"}; + Input s_f64{"s_f64"}; + + // Distinct output element types. + Output> total{"total"}; + Output> scaled{"scaled"}; + // A Tuple output: lowers to two output buffers named "packed.0"/"packed.1". + Output packed{"packed", {UInt(8), Int(32)}, 2}; + + Var x, y; + + void generate() { + Expr in = cast(input(x, y)); + Expr sum = in + + select(s_bool, cast(1), cast(0)) + + cast(s_i8) + cast(s_i16) + + cast(s_i32) + s_i64 + + cast(s_u8) + cast(s_u16) + + cast(s_u32) + cast(s_u64); + + // The enum GeneratorParam picks the operation at compile time. + Expr combined; + switch (combine) { + case Combine::Add: + combined = input(x, y) + s_u8; + break; + case Combine::Sub: + combined = input(x, y) - s_u8; + break; + case Combine::Xor: + combined = input(x, y) ^ s_u8; + break; + } + + total(x, y) = sum; + scaled(x, y) = cast(s_f32) * cast(input(x, y)) + s_f64; + packed(x, y) = Tuple(cast(combined), cast(sum)); + } + + void schedule() { + } +}; + +HALIDE_REGISTER_GENERATOR(CallConv, callconv) diff --git a/python_bindings/test/runtime/check_no_libhalide.cmake b/python_bindings/test/runtime/check_no_libhalide.cmake new file mode 100644 index 000000000000..79d91dfc1825 --- /dev/null +++ b/python_bindings/test/runtime/check_no_libhalide.cmake @@ -0,0 +1,43 @@ +# Verify that the given shared MODULE does not depend on libHalide (the +# compiler). The standalone runtime must link only the header-only runtime +# interface plus a compiled Halide runtime, never libHalide. +# +# Invoked as: cmake -DMODULE= -P check_no_libhalide.cmake + +if (NOT MODULE) + message(FATAL_ERROR "MODULE must be set") +endif () + +if (APPLE) + execute_process( + COMMAND otool -L "${MODULE}" + OUTPUT_VARIABLE deps + RESULT_VARIABLE rc + ) +else () + find_program(OBJDUMP objdump) + if (OBJDUMP) + execute_process( + COMMAND "${OBJDUMP}" -p "${MODULE}" + OUTPUT_VARIABLE deps + RESULT_VARIABLE rc + ) + else () + execute_process( + COMMAND ldd "${MODULE}" + OUTPUT_VARIABLE deps + RESULT_VARIABLE rc + ) + endif () +endif () + +if (NOT rc EQUAL 0) + message(FATAL_ERROR "Failed to inspect dependencies of ${MODULE}") +endif () + +if (deps MATCHES "libHalide") + message(FATAL_ERROR + "Runtime module ${MODULE} unexpectedly depends on libHalide:\n${deps}") +endif () + +message(STATUS "OK: ${MODULE} has no libHalide dependency") diff --git a/python_bindings/test/runtime/gpu.py b/python_bindings/test/runtime/gpu.py new file mode 100644 index 000000000000..23ce3e3dcca1 --- /dev/null +++ b/python_bindings/test/runtime/gpu.py @@ -0,0 +1,63 @@ +"""Check that halide.runtime can load and run kernels compiled for GPU targets. + +Each backend (Metal, CUDA, OpenCL, Vulkan) is compiled into its own loadable +module; the AOT artifact bundles its own device-capable Halide runtime, so the +standalone (CPU-only) halide.runtime module can drive it: input host buffers are +copied to the device, the kernel runs, and outputs are copied back to host. + +The module paths that were actually built on this platform are passed as +arguments. A backend whose device/driver is missing at run time (common on CI) +is skipped individually; if no backend has a usable device, the whole test skips. +""" + +import os +import sys + +import numpy as np + +import halide.runtime as hlr + +_GPU_FEATURES = ("cuda", "opencl", "metal", "vulkan", "webgpu") + + +def backend_of(target): + return next((f for f in _GPU_FEATURES if f in target), None) + + +def main(): + module_paths = sys.argv[1:] + + inp = np.arange(64, dtype=np.uint8).reshape(8, 8) + expected = (inp.astype(np.int64) + 3).astype(np.uint8) + + ran = [] + for path in module_paths: + # The module file is named ".so"; the loader defaults the filter + # name to that stem, which matches the Halide function name. + kernel = hlr.load(path) + backend = backend_of(kernel.target) or os.path.basename(path) + + out = np.zeros_like(inp) + try: + kernel(inp, np.int32(3), out) + except Exception as e: + # No usable device on this machine (e.g. a headless CI runner). + print(f"[skip] {backend}: device unavailable: {e}") + continue + + np.testing.assert_array_equal(out, expected) + print(f"ran on {backend}: OK") + ran.append(backend) + + assert "halide.halide_" not in sys.modules + + if not ran: + built = ", ".join(backend_of(hlr.load(p).target) or p for p in module_paths) + print(f"[SKIP] no usable GPU device (built backends: {built or 'none'})") + return + + print("Success! GPU backends verified:", ", ".join(ran)) + + +if __name__ == "__main__": + main() diff --git a/python_bindings/test/runtime/gpu_generator.cpp b/python_bindings/test/runtime/gpu_generator.cpp new file mode 100644 index 000000000000..1a35139e1f7c --- /dev/null +++ b/python_bindings/test/runtime/gpu_generator.cpp @@ -0,0 +1,28 @@ +#include "Halide.h" + +using namespace Halide; + +// A simple elementwise kernel with a GPU schedule, used to check that +// halide.runtime can load and run a kernel compiled for a GPU target (the AOT +// artifact bundles its own device-capable Halide runtime). Falls back to a CPU +// schedule when compiled without a GPU feature. +class GpuAdd : public Generator { +public: + Input> input{"input"}; + Input offset{"offset"}; + Output> output{"output"}; + + Var x, y, xo, yo, xi, yi; + + void generate() { + output(x, y) = cast(input(x, y) + offset); + } + + void schedule() { + if (get_target().has_gpu_feature()) { + output.gpu_tile(x, y, xo, yo, xi, yi, 8, 8); + } + } +}; + +HALIDE_REGISTER_GENERATOR(GpuAdd, gpuadd) diff --git a/python_bindings/test/runtime/load_aot.py b/python_bindings/test/runtime/load_aot.py new file mode 100644 index 000000000000..1b778123fa81 --- /dev/null +++ b/python_bindings/test/runtime/load_aot.py @@ -0,0 +1,64 @@ +"""Test the standalone `halide.runtime` loader against a precompiled AOT kernel. + +The key property under test is that `halide.runtime` can load and call a +precompiled Halide kernel WITHOUT importing the Halide compiler package (and thus +without libHalide): `import halide.runtime` must not pull in `halide.halide_`. +""" + +import sys + +import numpy as np + +import halide.runtime as hlr + + +def main(): + aot_path = sys.argv[1] + + # Importing the runtime must not have loaded the compiler extension. + assert "halide.halide_" not in sys.modules, ( + "importing halide.runtime pulled in the halide compiler extension" + ) + + kernel = hlr.load(aot_path, name="runtimeadd") + assert kernel.name == "runtimeadd", kernel.name + assert kernel.argument_names == ["input", "offset", "output"], kernel.argument_names + + rng = np.arange(0, 256, dtype=np.uint8) + out = np.zeros_like(rng) + + # positional + kernel(rng, np.int32(5), out) + expected = ((rng.astype(np.int64) + 5) % 256).astype(np.uint8) + assert np.array_equal(out, expected), (out, expected) + + # keyword + mixed + out2 = np.zeros_like(rng) + kernel(rng, output=out2, offset=200) + expected2 = ((rng.astype(np.int64) + 200) % 256).astype(np.uint8) + assert np.array_equal(out2, expected2), (out2, expected2) + + # halide.runtime.Buffer interop: introspection + zero-copy NumPy round-trip. + grid = np.arange(6, dtype=np.uint8).reshape(2, 3) + buf = hlr.Buffer(grid) + assert buf.dimensions == 2 and buf.type == "uint8" and buf.shape == [2, 3] + assert isinstance(buf._get_raw_halide_buffer_t(), int) and buf._get_raw_halide_buffer_t() != 0 + view = np.asarray(buf) + assert np.array_equal(view, grid) + view[0, 0] = 42 + assert grid[0, 0] == 42, "Buffer must be a zero-copy view of its source" + + # Pass Buffer objects to the kernel (via the _get_raw_halide_buffer_t bridge). + out3 = np.zeros_like(rng) + kernel(hlr.Buffer(rng), np.int32(7), hlr.Buffer(out3)) + expected3 = ((rng.astype(np.int64) + 7) % 256).astype(np.uint8) + assert np.array_equal(out3, expected3), (out3, expected3) + + # Still no compiler after all of this. + assert "halide.halide_" not in sys.modules + + print("Success!") + + +if __name__ == "__main__": + main() diff --git a/python_bindings/test/runtime/runtimeadd_generator.cpp b/python_bindings/test/runtime/runtimeadd_generator.cpp new file mode 100644 index 000000000000..a4c84e7cdb65 --- /dev/null +++ b/python_bindings/test/runtime/runtimeadd_generator.cpp @@ -0,0 +1,23 @@ +#include "Halide.h" + +using namespace Halide; + +// A deliberately tiny AOT kernel used to exercise the standalone `halide.runtime` +// loader: one input buffer, one scalar, one output buffer. +class RuntimeAddGenerator : public Generator { +public: + Input> input{"input"}; + Input offset{"offset"}; + Output> output{"output"}; + + Var x; + + void generate() { + output(x) = cast(input(x) + offset); + } + + void schedule() { + } +}; + +HALIDE_REGISTER_GENERATOR(RuntimeAddGenerator, runtimeadd) diff --git a/python_bindings/tutorial/CMakeLists.txt b/python_bindings/tutorial/CMakeLists.txt index a5da602365fa..612f3e35ec48 100644 --- a/python_bindings/tutorial/CMakeLists.txt +++ b/python_bindings/tutorial/CMakeLists.txt @@ -20,6 +20,7 @@ set(tests lesson_12_using_the_gpu.py lesson_13_tuples.py lesson_14_types.py + lesson_15_runtime.py # keep-sorted end ) diff --git a/python_bindings/tutorial/lesson_15_runtime.py b/python_bindings/tutorial/lesson_15_runtime.py new file mode 100644 index 000000000000..a303e9bdadd8 --- /dev/null +++ b/python_bindings/tutorial/lesson_15_runtime.py @@ -0,0 +1,180 @@ +#!/usr/bin/python3 + +# Halide tutorial lesson 15. + +# This lesson demonstrates how to load and call a precompiled, ahead-of-time +# (AOT) compiled Halide pipeline at runtime using the standalone "halide.runtime" +# module. + +# This lesson can be built by invoking the command: +# make test_tutorial_lesson_15_runtime +# in a shell with the current directory at python_bindings/ + +# Lesson 10 showed how to AOT-compile a pipeline and call it through a generated +# Python extension. This lesson shows another way to run AOT code: loading a +# precompiled shared library dynamically and calling it, using only +# "halide.runtime". Unlike "import halide", halide.runtime does not depend on +# libHalide (the compiler) at all -- it is a small package whose whole job is to +# run precompiled kernels. + +# This is useful for deployment: you can compile your pipelines on a build +# machine that has the full Halide toolchain, then ship just the compiled kernels +# and this tiny runtime, and run them on machines that have neither the compiler +# nor LLVM installed. + +# There are two distinct phases below: +# 1. Build time (needs the Halide compiler): compile a pipeline to a shared +# library. In a real project this happens in your build system. +# 2. Deploy time (needs only halide.runtime): load that shared library and +# call it. This is the part you would actually ship. + +import os +import platform +import shutil +import subprocess +import tempfile + +import numpy as np + +# halide.runtime is all you need to *call* precompiled kernels. Importing it does +# not load libHalide. (We import the full "halide" package only for the +# build-time compilation step below.) +import halide.runtime as hlr + + +def compile_pipeline(directory): + # Build time: AOT-compile a simple pipeline to a loadable shared library. + # Returns the path to the shared library, or None if this environment cannot + # build one (e.g. there is no C compiler on the PATH). + import halide as hl + + # A simple one-stage pipeline that brightens an image, just like lesson 10. + x, y = hl.Var("x"), hl.Var("y") + input = hl.ImageParam(hl.UInt(8), 2, "input") + offset = hl.Param(hl.Int(32), name="offset") + brighter = hl.Func("brighter") + brighter[x, y] = hl.cast(hl.UInt(8), input[x, y] + offset) + brighter.vectorize(x, 16).parallel(y) + + # Compile to a static library. Unlike compile_to(object), a static library + # bundles a copy of the Halide runtime, so the shared library we link below + # is self-contained and, like halide.runtime itself, needs no libHalide. + # + # We compile for this machine (get_host_target) so we can run it right away. + archive = os.path.join(directory, "brighter.a") + brighter.compile_to( + {hl.OutputFileType.static_library: archive}, + [input, offset], + "brighter", + hl.get_host_target(), + ) + + # halide.runtime loads a *shared* library, so link the static library into + # one, taking care to keep the filter's symbols visible. In a real project + # your build system does this for you; see doc/Python.md for a CMake recipe. + return link_shared_library(archive, os.path.join(directory, "brighter"), "brighter") + + +def link_shared_library(archive, stem, name): + # Link the static library `archive` into a shared library that exports the + # filter's `_argv` / `_metadata` entry points, so halide.runtime + # can resolve them. Returns the shared library path, or None if this + # environment has no suitable linker. + system = platform.system() + + if system == "Windows": + # Use the MSVC linker (link.exe) if the Visual Studio toolchain is on the + # PATH (e.g. from a Developer Command Prompt). We wrap the static library + # into a DLL, listing the entry points to export in a .def file. /NOENTRY + # is fine here: the DLL shares the host process's already-initialized + # dynamic CRT, so it needs no startup code of its own. + if shutil.which("link") is None: + return None + library = stem + ".dll" + def_path = stem + ".def" + with open(def_path, "w") as f: + f.write("EXPORTS\n") + f.write(f" {name}_argv\n") + f.write(f" {name}_metadata\n") + args = ["link", "/nologo", "/DLL", "/NOENTRY", + archive, f"/DEF:{def_path}", f"/OUT:{library}"] + elif system == "Darwin": + library = stem + ".dylib" + cc = os.environ.get("CC", "cc") + args = [cc, "-shared", "-o", library, "-Wl,-force_load," + archive] + else: + library = stem + ".so" + cc = os.environ.get("CC", "cc") + args = [cc, "-shared", "-o", library, + "-Wl,--whole-archive", archive, "-Wl,--no-whole-archive", + "-lpthread", "-ldl"] + + try: + subprocess.run(args, check=True) + except (OSError, subprocess.CalledProcessError): + return None + return library + + +def main(): + with tempfile.TemporaryDirectory() as directory: + library = compile_pipeline(directory) + if library is None: + # We couldn't produce a shared library to load in this environment, + # and the interesting part of the lesson needs one. + print("[SKIP] could not build a shared library to load") + return 0 + + # ------------------------------------------------------------------ + # Deploy time: everything below uses only halide.runtime, not + # libHalide. This is the code you would actually ship to run + # precompiled kernels. + # ------------------------------------------------------------------ + + # Load the precompiled kernel. The filter name defaults to the library's + # file name; we pass it explicitly here for clarity. + kernel = hlr.load(library, name="brighter") + + # A loaded Kernel knows what it was compiled for and how it must be + # called. `kernel.arguments` describes the calling convention: one entry + # per argument, with its name, kind (input_scalar / input_buffer / + # output_buffer), element type, and dimensions. + print("Loaded kernel:", kernel.name) + print("Compiled for target:", kernel.target) + for arg in kernel.arguments: + print(" argument:", arg) + + # Let's make some input data to test with. Note that, as in lesson 10, + # when a numpy array is passed to Halide code its axes are reversed; + # since this pipeline is elementwise that doesn't affect the result. + input = np.empty((640, 480), dtype=np.uint8) + for y in range(480): + for x in range(640): + input[x, y] = (x ^ (y + 1)) & 0xFF + + # Halide does not allocate outputs for us, so we provide a buffer for the + # result. + output = np.empty((640, 480), dtype=np.uint8) + + offset = 5 + + # Call it. Arguments may be passed by position, in the order given by + # kernel.argument_names ... + kernel(input, offset, output) + # ... or by name, in the Python manner: + # kernel(input=input, offset=offset, output=output) + # + # (Errors raise a Python exception rather than returning an int.) + + # Now let's check the filter performed as advertised: it was supposed to + # add the offset to every input pixel, with uint8 wraparound. + expected = (input.astype(np.int32) + offset).astype(np.uint8) + assert np.array_equal(output, expected), "kernel produced the wrong result" + + # Everything worked! + print("Success!") + return 0 + + +if __name__ == "__main__": + main() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1e2229eca2c7..504a603b671d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -424,7 +424,7 @@ target_sources( # keep-sorted end ) -set(C_TEMPLATE_FILES CodeGen_C_prologue CodeGen_C_vectors) +set(C_TEMPLATE_FILES CodeGen_C_prologue CodeGen_C_vectors PythonExtensionRuntime) set(HTML_TEMPLATE_FILES StmtToHTML_dependencies.html StmtToHTML.js StmtToHTML.css) diff --git a/src/PythonExtensionGen.cpp b/src/PythonExtensionGen.cpp index 50b5f3b0ba12..9231b34d4ea7 100644 --- a/src/PythonExtensionGen.cpp +++ b/src/PythonExtensionGen.cpp @@ -6,6 +6,13 @@ #include "PythonExtensionGen.h" #include "Util.h" +// The buffer-protocol <-> halide_buffer_t marshalling helpers +// (Halide::PythonRuntime::unpack_buffer and PyHalideBuffer) live in a single +// source of truth, src/PythonExtensionRuntime.template.cpp, which is embedded +// here via binary2cpp and also compiled directly into the standalone +// `halide.runtime` Python module. +extern "C" unsigned char halide_c_template_PythonExtensionRuntime[]; + namespace Halide { namespace Internal { @@ -162,92 +169,6 @@ void _module_halide_print(void *user_context, const char *msg) { } // namespace -namespace Halide::PythonRuntime { - -bool unpack_buffer(PyObject *py_obj, - int py_getbuffer_flags, - const char *name, - int dimensions, - Py_buffer &py_buf, - halide_dimension_t *halide_dim, - halide_buffer_t &halide_buf, - bool &py_buf_valid, - bool &needs_device_free) { - py_buf_valid = false; - needs_device_free = false; - - memset(&py_buf, 0, sizeof(py_buf)); - if (PyObject_GetBuffer(py_obj, &py_buf, PyBUF_FORMAT | PyBUF_STRIDED_RO | py_getbuffer_flags) < 0) { - PyErr_Format(PyExc_ValueError, "Invalid argument %s: Expected %d dimensions, got %d", name, dimensions, py_buf.ndim); - return false; - } - py_buf_valid = true; - - if (dimensions && py_buf.ndim != dimensions) { - PyErr_Format(PyExc_ValueError, "Invalid argument %s: Expected %d dimensions, got %d", name, dimensions, py_buf.ndim); - return false; - } - // Always reverse axes. - // TODO(jiawen): Can probably consolidate this with similar code in PyCallable.cpp and - // pybufferinfo_to_halidebuffer() in PyBuffer.h. - for (int i = 0; i < py_buf.ndim; ++i) { - const int j = py_buf.ndim - 1 - i; // numpy axis j maps to Halide dim i - halide_dim[i].min = 0; - halide_dim[i].stride = (int)(py_buf.strides[j] / py_buf.itemsize); // Python strides are in bytes - halide_dim[i].extent = (int)py_buf.shape[j]; - halide_dim[i].flags = 0; - if (py_buf.suboffsets && py_buf.suboffsets[j] >= 0) { - // Halide doesn't support arrays of pointers. But we should never see this - // anyway, since we did not specify PyBUF_INDIRECT. - PyErr_Format(PyExc_ValueError, "Invalid buffer: suboffsets not supported"); - return false; - } - } - - halide_buf = {}; - needs_device_free = true; - if (!py_buf.format) { - halide_buf.type.code = halide_type_uint; - halide_buf.type.bits = 8; - } else { - /* Convert struct type code. See - * https://docs.python.org/2/library/struct.html#module-struct */ - char *p = py_buf.format; - while (strchr("@<>!=", *p)) { - p++; // ignore little/bit endian (and alignment) - } - if (*p == 'f' || *p == 'd' || *p == 'e') { - // 'f', 'd', and 'e' are float, double, and half, respectively. - halide_buf.type.code = halide_type_float; - } else if (*p >= 'a' && *p <= 'z') { - // lowercase is signed int. - halide_buf.type.code = halide_type_int; - } else { - // uppercase is unsigned int. - halide_buf.type.code = halide_type_uint; - } - const char *type_codes = "bBhHiIlLqQfde"; // integers and floats - if (*p == '?') { - // Special-case bool, so that it is a distinct type vs uint8_t - // (even though the memory layout is identical) - halide_buf.type.bits = 1; - } else if (strchr(type_codes, *p)) { - halide_buf.type.bits = (uint8_t)py_buf.itemsize * 8; - } else { - // We don't handle 's' and 'p' (char[]) and 'P' (void*) - PyErr_Format(PyExc_ValueError, "Invalid data type for %s: %s", name, py_buf.format); - return false; - } - } - halide_buf.dimensions = py_buf.ndim; - halide_buf.dim = halide_dim; - halide_buf.host = (uint8_t *)py_buf.buf; - - return true; -} - -} // namespace Halide::PythonRuntime - extern "C" { HALIDE_EXPORT_SYMBOL PyObject *_HALIDE_EXPAND_AND_CONCAT(PyInit_, HALIDE_PYTHON_EXTENSION_MODULE_NAME)() { @@ -289,96 +210,13 @@ void PythonExtensionGen::compile(const Module &module) { extern_decl_gen.compile(module); } - dest << normalize_line_endings(R"INLINE_CODE( -namespace Halide::PythonRuntime { -extern bool unpack_buffer(PyObject *py_obj, - int py_getbuffer_flags, - const char *name, - int dimensions, - Py_buffer &py_buf, - halide_dimension_t *halide_dim, - halide_buffer_t &halide_buf, - bool &py_buf_valid, - bool &needs_device_free); -} // namespace Halide::PythonRuntime - -namespace { - -template -struct PyHalideBuffer { - // Must allocate at least 1, even if d=0 - static constexpr int dims_to_allocate = (dimensions < 1) ? 1 : dimensions; - static constexpr const char* get_raw_halide_runtime_buffer_fn = "_get_raw_halide_buffer_t"; - - Py_buffer py_buf; - halide_buffer_t* halide_buf = nullptr; - bool py_buf_needs_release = false; - bool needs_device_free = false; - - bool unpack_from_halide_buffer(PyObject *py_obj) { - if (!PyObject_HasAttrString(py_obj, get_raw_halide_runtime_buffer_fn)) { - return false; - } - - PyObject *py_raw_buffer = PyObject_CallMethod(py_obj, get_raw_halide_runtime_buffer_fn, NULL); - if (!py_raw_buffer) { - PyErr_Clear(); - return false; - } - - if (!PyLong_Check(py_raw_buffer)) { - Py_DECREF(py_raw_buffer); - return false; - } - - uintptr_t py_raw_buffer_ptr = (uintptr_t)PyLong_AsUnsignedLongLong(py_raw_buffer); - Py_DECREF(py_raw_buffer); - - if (py_raw_buffer_ptr == 0) { - return false; - } - - halide_buf = reinterpret_cast(py_raw_buffer_ptr); - return true; - } - - bool unpack(PyObject *py_obj, int py_getbuffer_flags, const char *name) { - if (unpack_from_halide_buffer(py_obj)) { - return true; - } - if (Halide::PythonRuntime::unpack_buffer( - py_obj, py_getbuffer_flags, name, dimensions, py_buf, - unpacked_dim, unpacked_buf, py_buf_needs_release, - needs_device_free)) { - halide_buf = &unpacked_buf; - return true; - } - return false; - } - - ~PyHalideBuffer() { - if (needs_device_free) { - halide_device_free(nullptr, halide_buf); - } - if (py_buf_needs_release) { - PyBuffer_Release(&py_buf); - } - } - - PyHalideBuffer() = default; - PyHalideBuffer(const PyHalideBuffer &other) = delete; - PyHalideBuffer &operator=(const PyHalideBuffer &other) = delete; - PyHalideBuffer(PyHalideBuffer &&other) = delete; - PyHalideBuffer &operator=(PyHalideBuffer &&other) = delete; - -private: - halide_dimension_t unpacked_dim[dims_to_allocate]; - halide_buffer_t unpacked_buf; -}; - -} // namespace - -)INLINE_CODE"); + // Emit the buffer marshalling helpers (Halide::PythonRuntime::unpack_buffer + // and the PyHalideBuffer wrapper) from the shared source of truth. This is + // the same implementation compiled into the standalone `halide.runtime` + // module; see src/PythonExtensionRuntime.template.cpp. + dest << "\n" + << normalize_line_endings((const char *)halide_c_template_PythonExtensionRuntime) + << "\n"; for (const auto &f : module.functions()) { if (f.linkage == LinkageType::ExternalPlusMetadata) { diff --git a/src/PythonExtensionRuntime.template.cpp b/src/PythonExtensionRuntime.template.cpp new file mode 100644 index 000000000000..0ed301f05b07 --- /dev/null +++ b/src/PythonExtensionRuntime.template.cpp @@ -0,0 +1,186 @@ +/* This file is the single source of truth for the buffer-protocol <-> + * halide_buffer_t marshalling used to invoke AOT-compiled Halide pipelines from + * Python. It is used two ways: + * + * 1. It is embedded (via binary2cpp, as halide_c_template_PythonExtensionRuntime) + * into libHalide and emitted verbatim into each generated ".py.cpp" Python + * extension by PythonExtensionGen, so those extensions remain self-contained + * and depend on nothing beyond and HalideRuntime.h. + * + * 2. It is compiled directly into the standalone `halide.runtime` module, which + * can load and call precompiled AOT kernels without depending on libHalide. + * + * Because it is emitted into generated code that already includes , + * , and "HalideRuntime.h", the includes below are include-guard no-ops + * in that context; they are present so this file is a valid translation unit on + * its own. `unpack_buffer` is `inline` so that emitting its definition into every + * generated ".py.cpp" (including the multi-library case) does not violate the ODR. + */ +#include + +#include +#include + +#include "HalideRuntime.h" + +namespace Halide::PythonRuntime { + +inline bool unpack_buffer(PyObject *py_obj, + int py_getbuffer_flags, + const char *name, + int dimensions, + Py_buffer &py_buf, + halide_dimension_t *halide_dim, + halide_buffer_t &halide_buf, + bool &py_buf_valid, + bool &needs_device_free) { + py_buf_valid = false; + needs_device_free = false; + + memset(&py_buf, 0, sizeof(py_buf)); + if (PyObject_GetBuffer(py_obj, &py_buf, PyBUF_FORMAT | PyBUF_STRIDED_RO | py_getbuffer_flags) < 0) { + PyErr_Format(PyExc_ValueError, "Invalid argument %s: Expected %d dimensions, got %d", name, dimensions, py_buf.ndim); + return false; + } + py_buf_valid = true; + + if (dimensions && py_buf.ndim != dimensions) { + PyErr_Format(PyExc_ValueError, "Invalid argument %s: Expected %d dimensions, got %d", name, dimensions, py_buf.ndim); + return false; + } + // Always reverse axes. + // TODO(jiawen): Can probably consolidate this with similar code in PyCallable.cpp and + // pybufferinfo_to_halidebuffer() in PyBuffer.h. + for (int i = 0; i < py_buf.ndim; ++i) { + const int j = py_buf.ndim - 1 - i; // numpy axis j maps to Halide dim i + halide_dim[i].min = 0; + halide_dim[i].stride = (int)(py_buf.strides[j] / py_buf.itemsize); // Python strides are in bytes + halide_dim[i].extent = (int)py_buf.shape[j]; + halide_dim[i].flags = 0; + if (py_buf.suboffsets && py_buf.suboffsets[j] >= 0) { + // Halide doesn't support arrays of pointers. But we should never see this + // anyway, since we did not specify PyBUF_INDIRECT. + PyErr_Format(PyExc_ValueError, "Invalid buffer: suboffsets not supported"); + return false; + } + } + + halide_buf = {}; + needs_device_free = true; + if (!py_buf.format) { + halide_buf.type.code = halide_type_uint; + halide_buf.type.bits = 8; + } else { + /* Convert struct type code. See + * https://docs.python.org/2/library/struct.html#module-struct */ + char *p = py_buf.format; + while (strchr("@<>!=", *p)) { + p++; // ignore little/bit endian (and alignment) + } + if (*p == 'f' || *p == 'd' || *p == 'e') { + // 'f', 'd', and 'e' are float, double, and half, respectively. + halide_buf.type.code = halide_type_float; + } else if (*p >= 'a' && *p <= 'z') { + // lowercase is signed int. + halide_buf.type.code = halide_type_int; + } else { + // uppercase is unsigned int. + halide_buf.type.code = halide_type_uint; + } + const char *type_codes = "bBhHiIlLqQfde"; // integers and floats + if (*p == '?') { + // Special-case bool, so that it is a distinct type vs uint8_t + // (even though the memory layout is identical) + halide_buf.type.bits = 1; + } else if (strchr(type_codes, *p)) { + halide_buf.type.bits = (uint8_t)py_buf.itemsize * 8; + } else { + // We don't handle 's' and 'p' (char[]) and 'P' (void*) + PyErr_Format(PyExc_ValueError, "Invalid data type for %s: %s", name, py_buf.format); + return false; + } + } + halide_buf.dimensions = py_buf.ndim; + halide_buf.dim = halide_dim; + halide_buf.host = (uint8_t *)py_buf.buf; + + return true; +} + +} // namespace Halide::PythonRuntime + +namespace { + +template +struct PyHalideBuffer { + // Must allocate at least 1, even if d=0 + static constexpr int dims_to_allocate = (dimensions < 1) ? 1 : dimensions; + static constexpr const char *get_raw_halide_runtime_buffer_fn = "_get_raw_halide_buffer_t"; + + Py_buffer py_buf; + halide_buffer_t *halide_buf = nullptr; + bool py_buf_needs_release = false; + bool needs_device_free = false; + + bool unpack_from_halide_buffer(PyObject *py_obj) { + if (!PyObject_HasAttrString(py_obj, get_raw_halide_runtime_buffer_fn)) { + return false; + } + + PyObject *py_raw_buffer = PyObject_CallMethod(py_obj, get_raw_halide_runtime_buffer_fn, NULL); + if (!py_raw_buffer) { + PyErr_Clear(); + return false; + } + + if (!PyLong_Check(py_raw_buffer)) { + Py_DECREF(py_raw_buffer); + return false; + } + + uintptr_t py_raw_buffer_ptr = (uintptr_t)PyLong_AsUnsignedLongLong(py_raw_buffer); + Py_DECREF(py_raw_buffer); + + if (py_raw_buffer_ptr == 0) { + return false; + } + + halide_buf = reinterpret_cast(py_raw_buffer_ptr); + return true; + } + + bool unpack(PyObject *py_obj, int py_getbuffer_flags, const char *name) { + if (unpack_from_halide_buffer(py_obj)) { + return true; + } + if (Halide::PythonRuntime::unpack_buffer( + py_obj, py_getbuffer_flags, name, dimensions, py_buf, + unpacked_dim, unpacked_buf, py_buf_needs_release, + needs_device_free)) { + halide_buf = &unpacked_buf; + return true; + } + return false; + } + + ~PyHalideBuffer() { + if (needs_device_free) { + halide_device_free(nullptr, halide_buf); + } + if (py_buf_needs_release) { + PyBuffer_Release(&py_buf); + } + } + + PyHalideBuffer() = default; + PyHalideBuffer(const PyHalideBuffer &other) = delete; + PyHalideBuffer &operator=(const PyHalideBuffer &other) = delete; + PyHalideBuffer(PyHalideBuffer &&other) = delete; + PyHalideBuffer &operator=(PyHalideBuffer &&other) = delete; + +private: + halide_dimension_t unpacked_dim[dims_to_allocate]; + halide_buffer_t unpacked_buf; +}; + +} // namespace From 63b39a3d8ee43d1c91a6de5e8202ce9c57d9a829 Mon Sep 17 00:00:00 2001 From: "halide-ci[bot]" <266445882+halide-ci[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:36:54 +0000 Subject: [PATCH 3/5] Apply pre-commit auto-fixes --- doc/Python.md | 44 ++++++++++--------- packaging/pip-runtime/README.md | 4 +- python_bindings/src/halide/__init__.py | 14 +++++- .../src/halide/runtime/CMakeLists.txt | 27 +++++++----- .../src/halide/runtime/PyRuntime.cpp | 5 +-- python_bindings/test/runtime/CMakeLists.txt | 18 +++----- .../test/runtime/call_convention.py | 28 +++++++++--- .../test/runtime/check_no_libhalide.cmake | 21 ++------- python_bindings/test/runtime/load_aot.py | 5 ++- python_bindings/tutorial/lesson_15_runtime.py | 25 ++++++++--- 10 files changed, 112 insertions(+), 79 deletions(-) diff --git a/doc/Python.md b/doc/Python.md index 9f76bae6f439..5f69f6a67461 100644 --- a/doc/Python.md +++ b/doc/Python.md @@ -632,8 +632,8 @@ The approach above imports a Python extension that was produced at build time by precompiled Halide kernel _dynamically_, at runtime, from an ordinary shared library -- and to do so in an environment that does not have the Halide compiler (or `libHalide`) installed at all. This is what the `halide.runtime` module is -for: it is a small, standalone package that can load and call AOT-compiled Halide -kernels without depending on `libHalide`. +for: it is a small, standalone package that can load and call AOT-compiled +Halide kernels without depending on `libHalide`. This is primarily useful for deployment. You can compile your pipelines on a build machine that has the full Halide toolchain, then ship only the resulting @@ -641,7 +641,8 @@ kernels plus this tiny runtime, and run them on machines that have neither the compiler nor LLVM installed. `halide.runtime` is always included in the full `halide` package, but it is also -published as a separate, `libHalide`-free wheel for exactly this deployment case: +published as a separate, `libHalide`-free wheel for exactly this deployment +case: ```shell pip install halide-runtime @@ -658,9 +659,9 @@ pointing you at the full `halide` package.) `halide.runtime` loads a shared library that exports a Halide filter's `_argv` and `_metadata` symbols -- the ordinary product of AOT compilation. Note that this is _not_ the same artifact as the Python extension -produced by `add_halide_python_extension_library`, which deliberately hides every -symbol except its `PyInit_` entry point. Instead, link the AOT library into a -plain shared module that keeps those symbols visible: +produced by `add_halide_python_extension_library`, which deliberately hides +every symbol except its `PyInit_` entry point. Instead, link the AOT library +into a plain shared module that keeps those symbols visible: ```cmake add_halide_library(my_kernel FROM my_generator GENERATOR my_kernel) @@ -678,8 +679,8 @@ set_target_properties(my_kernel_module PROPERTIES ``` Because `add_halide_library` bundles a Halide runtime into the library by -default, the resulting shared module is self-contained and, like `halide.runtime` -itself, has no dependency on `libHalide`. +default, the resulting shared module is self-contained and, like +`halide.runtime` itself, has no dependency on `libHalide`. #### Loading and calling a kernel @@ -694,15 +695,17 @@ import halide.runtime as hlr # name defaults to the library's file name; pass name=... if it differs. kernel = hlr.load("/path/to/my_kernel.so", name="my_kernel") -print(kernel.name) # "my_kernel" -print(kernel.target) # the Target string it was compiled for +print(kernel.name) # "my_kernel" +print(kernel.target) # the Target string it was compiled for print(kernel.argument_names) # e.g. ['input', 'offset', 'output'] # `kernel.arguments` gives the full calling convention: one dict per argument, # in argv order, with its name, kind ('input_scalar', 'input_buffer', or # 'output_buffer'), element type (e.g. 'uint8'), and dimensions (0 for scalars). for arg in kernel.arguments: - print(arg) # {'name': 'input', 'kind': 'input_buffer', 'type': 'uint8', 'dimensions': 2} + print( + arg + ) # {'name': 'input', 'kind': 'input_buffer', 'type': 'uint8', 'dimensions': 2} input_buf = imageio.imread("/path/to/some/file.png") output_buf = np.empty(input_buf.shape, dtype=input_buf.dtype) @@ -715,8 +718,8 @@ kernel(input_buf, np.int32(5), output_buf) ``` As with the compiled extension, Halide does not allocate outputs for you: you -must pass in a correctly-sized output buffer, and error conditions raise a Python -exception rather than returning an int. +must pass in a correctly-sized output buffer, and error conditions raise a +Python exception rather than returning an int. #### The `halide.runtime.Buffer` type @@ -726,17 +729,18 @@ such an object as a Halide runtime buffer, without copying: ```python buf = hlr.Buffer(np.empty((480, 640), dtype=np.uint8)) -buf.dimensions # 2 -buf.type # "uint8" -buf.shape # [480, 640] +buf.dimensions # 2 +buf.type # "uint8" +buf.shape # [480, 640] view = np.asarray(buf) # a zero-copy view of the same memory ``` -A `Buffer` exposes the same `_get_raw_halide_buffer_t` protocol that `halide.Buffer` -and the extensions produced by `add_halide_python_extension_library` use, so the -very same object can be handed either to a kernel loaded via `halide.runtime.load` -or to a function in a generated extension module. +A `Buffer` exposes the same `_get_raw_halide_buffer_t` protocol that +`halide.Buffer` and the extensions produced by +`add_halide_python_extension_library` use, so the very same object can be handed +either to a kernel loaded via `halide.runtime.load` or to a function in a +generated extension module. The same memory-order caveats described in the previous section apply here: numpy's default row-major layout corresponds to Halide's axes in reverse order, diff --git a/packaging/pip-runtime/README.md b/packaging/pip-runtime/README.md index ceaa7453da63..29ef62d30b7c 100644 --- a/packaging/pip-runtime/README.md +++ b/packaging/pip-runtime/README.md @@ -7,9 +7,9 @@ Python, with **no dependency on libHalide** (no compiler, no LLVM). import numpy as np import halide.runtime as hlr -kernel = hlr.load("mykernel.so") # dlopen a precompiled Halide artifact +kernel = hlr.load("mykernel.so") # dlopen a precompiled Halide artifact out = np.empty_like(inp) -kernel(inp, out) # call it with NumPy arrays +kernel(inp, out) # call it with NumPy arrays ``` This package provides only `halide.runtime` — `hlr.load(...)`, the resulting diff --git a/python_bindings/src/halide/__init__.py b/python_bindings/src/halide/__init__.py index 3878b548826a..c9ae1430dc61 100644 --- a/python_bindings/src/halide/__init__.py +++ b/python_bindings/src/halide/__init__.py @@ -60,7 +60,19 @@ def install_dir(): # The implicit-argument placeholders, which `from .halide_ import *` would skip # because they begin with an underscore. -_PLACEHOLDER_ARG_NAMES = ("_", "_0", "_1", "_2", "_3", "_4", "_5", "_6", "_7", "_8", "_9") +_PLACEHOLDER_ARG_NAMES = ( + "_", + "_0", + "_1", + "_2", + "_3", + "_4", + "_5", + "_6", + "_7", + "_8", + "_9", +) _GENERATOR_HELPER_NAMES = ( "_create_python_generator", diff --git a/python_bindings/src/halide/runtime/CMakeLists.txt b/python_bindings/src/halide/runtime/CMakeLists.txt index aaaef5d3bc00..f7eb15abc20d 100644 --- a/python_bindings/src/halide/runtime/CMakeLists.txt +++ b/python_bindings/src/halide/runtime/CMakeLists.txt @@ -8,9 +8,10 @@ pybind11_add_module(Halide_PythonRuntime) add_library(Halide::PythonRuntime ALIAS Halide_PythonRuntime) -set_target_properties( - Halide_PythonRuntime - PROPERTIES OUTPUT_NAME _runtime EXPORT_NAME PythonRuntime +set_target_properties(Halide_PythonRuntime + PROPERTIES + OUTPUT_NAME _runtime + EXPORT_NAME PythonRuntime ) target_sources(Halide_PythonRuntime PRIVATE PyRuntime.cpp) @@ -20,7 +21,10 @@ target_sources(Halide_PythonRuntime PRIVATE PyRuntime.cpp) # source tree; when building against an installed Halide it is installed next to # HalideRuntime.h and found via Halide::Runtime's include directories (so no # extra include path is needed in that case). -if (DEFINED Halide_SOURCE_DIR AND EXISTS "${Halide_SOURCE_DIR}/src/PythonExtensionRuntime.template.cpp") +if ( + DEFINED Halide_SOURCE_DIR + AND EXISTS "${Halide_SOURCE_DIR}/src/PythonExtensionRuntime.template.cpp" +) target_include_directories(Halide_PythonRuntime PRIVATE "${Halide_SOURCE_DIR}/src") endif () @@ -34,9 +38,9 @@ target_link_libraries(Halide_PythonRuntime PRIVATE Halide::Runtime Halide_Python # is importable as `halide.runtime`. The parent (Halide_Python) builds into # `/$/halide`; this subdirectory's binary dir is one level # deeper, so `../$/halide/runtime` co-locates the two. -set_target_properties( - Halide_PythonRuntime - PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/../$/halide/runtime" +set_target_properties(Halide_PythonRuntime + PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/../$/halide/runtime" ) if (Halide_ASAN_ENABLED) @@ -46,14 +50,17 @@ endif () # Copy the Python source for the subpackage next to the extension. set(python_sources __init__.py) list(TRANSFORM python_sources - PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/" - OUTPUT_VARIABLE python_sources_source_dir) + PREPEND "${CMAKE_CURRENT_SOURCE_DIR}/" + OUTPUT_VARIABLE python_sources_source_dir +) set(stamp_file "$/Halide_PythonRuntime_sources.stamp") add_custom_command( OUTPUT "${stamp_file}" COMMAND "${CMAKE_COMMAND}" -E make_directory $ - COMMAND "${CMAKE_COMMAND}" -E copy -t $ ${python_sources_source_dir} + COMMAND + "${CMAKE_COMMAND}" -E copy -t $ + ${python_sources_source_dir} COMMAND "${CMAKE_COMMAND}" -E touch "${stamp_file}" DEPENDS ${python_sources_source_dir} VERBATIM diff --git a/python_bindings/src/halide/runtime/PyRuntime.cpp b/python_bindings/src/halide/runtime/PyRuntime.cpp index f43c313bb76a..19ab865d3bbe 100644 --- a/python_bindings/src/halide/runtime/PyRuntime.cpp +++ b/python_bindings/src/halide/runtime/PyRuntime.cpp @@ -315,8 +315,7 @@ class Kernel { if (result != 0) { const std::string msg = take_last_error(); throw std::runtime_error( - msg.empty() ? ("Halide kernel '" + name() + "' returned error " + std::to_string(result)) - : ("Halide kernel '" + name() + "': " + msg)); + msg.empty() ? ("Halide kernel '" + name() + "' returned error " + std::to_string(result)) : ("Halide kernel '" + name() + "': " + msg)); } // Flush any device-side outputs back to host (host-only buffer protocol). @@ -380,7 +379,7 @@ class Kernel { #define HALIDE_RUNTIME_SCALAR_CASE(CODE, BITS, CTYPE, FIELD) \ if (t.code == (CODE) && t.bits == (BITS)) { \ - out->u.FIELD = py::cast(value); \ + out->u.FIELD = py::cast(value); \ return; \ } diff --git a/python_bindings/test/runtime/CMakeLists.txt b/python_bindings/test/runtime/CMakeLists.txt index fdcd337ca5c9..ac607e9ddc08 100644 --- a/python_bindings/test/runtime/CMakeLists.txt +++ b/python_bindings/test/runtime/CMakeLists.txt @@ -20,7 +20,8 @@ function(add_runtime_test_kernel MOD) set(ARG_GENERATOR ${MOD}) endif () - add_halide_library(${MOD}_aot + add_halide_library( + ${MOD}_aot FROM ${ARG_GENERATOR}_gen GENERATOR ${ARG_GENERATOR} FUNCTION_NAME ${MOD} @@ -30,8 +31,7 @@ function(add_runtime_test_kernel MOD) add_library(${MOD}_module MODULE aot_module_stub.c) target_link_libraries(${MOD}_module PRIVATE "$") - set_target_properties( - ${MOD}_module + set_target_properties(${MOD}_module PROPERTIES PREFIX "" OUTPUT_NAME ${MOD} @@ -43,11 +43,7 @@ endfunction() # A tiny kernel: load, call, and Buffer interop. add_runtime_test_generator(runtimeadd runtimeadd_generator.cpp) add_runtime_test_kernel(runtimeadd) -add_python_test( - FILE load_aot.py - LABEL python_runtime - TEST_ARGS "$" -) +add_python_test(FILE load_aot.py LABEL python_runtime TEST_ARGS "$") # The full scalar/buffer calling convention: every scalar type, a 2-D buffer, # and multiple outputs including a Tuple output. We compile the generator twice @@ -105,11 +101,7 @@ foreach (mod IN LISTS gpu_modules) list(APPEND gpu_module_files "$") endforeach () -add_python_test( - FILE gpu.py - LABEL python_runtime - TEST_ARGS ${gpu_module_files} -) +add_python_test(FILE gpu.py LABEL python_runtime TEST_ARGS ${gpu_module_files}) # The whole point of the runtime module: it must not depend on libHalide. # (Symbol-visibility trick from the export-single-symbol test; POSIX only.) diff --git a/python_bindings/test/runtime/call_convention.py b/python_bindings/test/runtime/call_convention.py index cc8b1bc67c92..635f716f6987 100644 --- a/python_bindings/test/runtime/call_convention.py +++ b/python_bindings/test/runtime/call_convention.py @@ -40,7 +40,7 @@ "s_u16": 40000, "s_u32": 3_000_000_000, "s_u64": 10_000_000_000, - "s_f32": 1.5, # exactly representable in float32 + "s_f32": 1.5, # exactly representable in float32 "s_f64": 2.25, } @@ -88,18 +88,30 @@ def main(): in64 = input_buf.astype(np.int64) scalar_sum = ( 1 # s_bool - - 5 + 300 - 70000 + 5_000_000_000 # signed - + 7 + 40000 + 3_000_000_000 + 10_000_000_000 # unsigned + - 5 + + 300 + - 70000 + + 5_000_000_000 # signed + + 7 + + 40000 + + 3_000_000_000 + + 10_000_000_000 # unsigned ) expected_total = in64 + scalar_sum np.testing.assert_array_equal(call_args["total"], expected_total) - expected_scaled = np.float32(1.5).astype(np.float64) * in64.astype(np.float64) + 2.25 + expected_scaled = ( + np.float32(1.5).astype(np.float64) * in64.astype(np.float64) + 2.25 + ) np.testing.assert_array_equal(call_args["scaled"], expected_scaled) # Default `combine=add`: packed.0 = input + s_u8. - np.testing.assert_array_equal(call_args["packed.0"], (input_buf + 7).astype(np.uint8)) - np.testing.assert_array_equal(call_args["packed.1"], expected_total.astype(np.int32)) + np.testing.assert_array_equal( + call_args["packed.0"], (input_buf + 7).astype(np.uint8) + ) + np.testing.assert_array_equal( + call_args["packed.1"], expected_total.astype(np.int32) + ) # The enum GeneratorParam is a compile-time choice: the `combine=xor` build is # a different kernel with the *same* calling convention but different behavior. @@ -108,7 +120,9 @@ def main(): "the enum GeneratorParam must not change the runtime calling convention" ) xor_args = run(xor_kernel, shape, input_buf) - np.testing.assert_array_equal(xor_args["packed.0"], (input_buf ^ 7).astype(np.uint8)) + np.testing.assert_array_equal( + xor_args["packed.0"], (input_buf ^ 7).astype(np.uint8) + ) # Everything not selected by the enum is unchanged. np.testing.assert_array_equal(xor_args["total"], expected_total) diff --git a/python_bindings/test/runtime/check_no_libhalide.cmake b/python_bindings/test/runtime/check_no_libhalide.cmake index 79d91dfc1825..da48400e2b35 100644 --- a/python_bindings/test/runtime/check_no_libhalide.cmake +++ b/python_bindings/test/runtime/check_no_libhalide.cmake @@ -9,25 +9,13 @@ if (NOT MODULE) endif () if (APPLE) - execute_process( - COMMAND otool -L "${MODULE}" - OUTPUT_VARIABLE deps - RESULT_VARIABLE rc - ) + execute_process(COMMAND otool -L "${MODULE}" OUTPUT_VARIABLE deps RESULT_VARIABLE rc) else () find_program(OBJDUMP objdump) if (OBJDUMP) - execute_process( - COMMAND "${OBJDUMP}" -p "${MODULE}" - OUTPUT_VARIABLE deps - RESULT_VARIABLE rc - ) + execute_process(COMMAND "${OBJDUMP}" -p "${MODULE}" OUTPUT_VARIABLE deps RESULT_VARIABLE rc) else () - execute_process( - COMMAND ldd "${MODULE}" - OUTPUT_VARIABLE deps - RESULT_VARIABLE rc - ) + execute_process(COMMAND ldd "${MODULE}" OUTPUT_VARIABLE deps RESULT_VARIABLE rc) endif () endif () @@ -36,8 +24,7 @@ if (NOT rc EQUAL 0) endif () if (deps MATCHES "libHalide") - message(FATAL_ERROR - "Runtime module ${MODULE} unexpectedly depends on libHalide:\n${deps}") + message(FATAL_ERROR "Runtime module ${MODULE} unexpectedly depends on libHalide:\n${deps}") endif () message(STATUS "OK: ${MODULE} has no libHalide dependency") diff --git a/python_bindings/test/runtime/load_aot.py b/python_bindings/test/runtime/load_aot.py index 1b778123fa81..0a1afa71ca89 100644 --- a/python_bindings/test/runtime/load_aot.py +++ b/python_bindings/test/runtime/load_aot.py @@ -42,7 +42,10 @@ def main(): grid = np.arange(6, dtype=np.uint8).reshape(2, 3) buf = hlr.Buffer(grid) assert buf.dimensions == 2 and buf.type == "uint8" and buf.shape == [2, 3] - assert isinstance(buf._get_raw_halide_buffer_t(), int) and buf._get_raw_halide_buffer_t() != 0 + assert ( + isinstance(buf._get_raw_halide_buffer_t(), int) + and buf._get_raw_halide_buffer_t() != 0 + ) view = np.asarray(buf) assert np.array_equal(view, grid) view[0, 0] = 42 diff --git a/python_bindings/tutorial/lesson_15_runtime.py b/python_bindings/tutorial/lesson_15_runtime.py index a303e9bdadd8..e034c02fa0a1 100644 --- a/python_bindings/tutorial/lesson_15_runtime.py +++ b/python_bindings/tutorial/lesson_15_runtime.py @@ -96,8 +96,15 @@ def link_shared_library(archive, stem, name): f.write("EXPORTS\n") f.write(f" {name}_argv\n") f.write(f" {name}_metadata\n") - args = ["link", "/nologo", "/DLL", "/NOENTRY", - archive, f"/DEF:{def_path}", f"/OUT:{library}"] + args = [ + "link", + "/nologo", + "/DLL", + "/NOENTRY", + archive, + f"/DEF:{def_path}", + f"/OUT:{library}", + ] elif system == "Darwin": library = stem + ".dylib" cc = os.environ.get("CC", "cc") @@ -105,9 +112,17 @@ def link_shared_library(archive, stem, name): else: library = stem + ".so" cc = os.environ.get("CC", "cc") - args = [cc, "-shared", "-o", library, - "-Wl,--whole-archive", archive, "-Wl,--no-whole-archive", - "-lpthread", "-ldl"] + args = [ + cc, + "-shared", + "-o", + library, + "-Wl,--whole-archive", + archive, + "-Wl,--no-whole-archive", + "-lpthread", + "-ldl", + ] try: subprocess.run(args, check=True) From 377778e2746ac1bf231090c98f4e66ffd1703ee2 Mon Sep 17 00:00:00 2001 From: Derek Gerstmann Date: Mon, 3 Aug 2026 16:06:23 -0700 Subject: [PATCH 4/5] Fix ruff checks. Remove # noqa: F401 --- python_bindings/src/halide/runtime/__init__.py | 2 +- python_bindings/tutorial/lesson_15_runtime.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 python_bindings/tutorial/lesson_15_runtime.py diff --git a/python_bindings/src/halide/runtime/__init__.py b/python_bindings/src/halide/runtime/__init__.py index 03a4185e9f71..af5c26e5d2ab 100644 --- a/python_bindings/src/halide/runtime/__init__.py +++ b/python_bindings/src/halide/runtime/__init__.py @@ -10,6 +10,6 @@ deployment environments that do not have the Halide compiler installed. """ -from ._runtime import Buffer, Kernel, load # noqa: F401 +from ._runtime import Buffer, Kernel, load __all__ = ["Buffer", "Kernel", "load"] diff --git a/python_bindings/tutorial/lesson_15_runtime.py b/python_bindings/tutorial/lesson_15_runtime.py old mode 100644 new mode 100755 From a0dece9450ae5b4bb36033ac60a54b263ffe2d0e Mon Sep 17 00:00:00 2001 From: Derek Gerstmann Date: Tue, 4 Aug 2026 10:18:14 -0700 Subject: [PATCH 5/5] Fix clang-tidy findings in the halide.runtime sources Running clang-tidy 21 (WarningsAsErrors: '*') over the new runtime code surfaced a handful of findings; fix them: * PyRuntime.cpp: use std::scoped_lock instead of std::lock_guard (modernize-use-scoped-lock); take the optional `name` argument to load() by const reference (performance-unnecessary-value-param); and mark the deliberate #include of the shared .cpp marshalling core NOLINT (bugprone-suspicious-include). * PythonExtensionRuntime.template.cpp: use nullptr instead of NULL (modernize-use-nullptr). This code is also emitted into generated Python extensions, so the improvement carries over there. * callconv_generator.cpp (test): switch on the enum value rather than the GeneratorParam wrapper so the switch is exhaustive (bugprone-switch-missing-default-case). The libHalide and python-bindings sources that the CI clang-tidy job checks (WITH_TESTS=OFF) are now clean. Verified the runtime, generated-extension, and call-convention tests still pass. Co-Authored-By: Claude Opus 4.8 --- python_bindings/src/halide/runtime/PyRuntime.cpp | 7 ++++--- python_bindings/test/runtime/callconv_generator.cpp | 3 ++- src/PythonExtensionRuntime.template.cpp | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/python_bindings/src/halide/runtime/PyRuntime.cpp b/python_bindings/src/halide/runtime/PyRuntime.cpp index 19ab865d3bbe..0a0e8c2892f0 100644 --- a/python_bindings/src/halide/runtime/PyRuntime.cpp +++ b/python_bindings/src/halide/runtime/PyRuntime.cpp @@ -35,6 +35,7 @@ // and PyHalideBuffer<>. `unpack_buffer` is `inline` and PyHalideBuffer lives in // an anonymous namespace, so including this translation unit here (rather than // compiling it separately) is well-formed and keeps a single implementation. +// NOLINTNEXTLINE(bugprone-suspicious-include): intentionally shared source. #include "PythonExtensionRuntime.template.cpp" namespace py = pybind11; @@ -99,12 +100,12 @@ std::string &last_error() { } extern "C" void runtime_error_handler(void * /*user_context*/, const char *msg) { - std::lock_guard lock(error_mutex()); + std::scoped_lock lock(error_mutex()); last_error() = msg ? msg : ""; } std::string take_last_error() { - std::lock_guard lock(error_mutex()); + std::scoped_lock lock(error_mutex()); std::string s = last_error(); last_error().clear(); return s; @@ -520,7 +521,7 @@ class Buffer { halide_buffer_t buf_{}; }; -std::shared_ptr load(const std::string &path, py::object name_obj) { +std::shared_ptr load(const std::string &path, const py::object &name_obj) { LibHandle handle = open_library(path); if (!handle) { throw std::runtime_error("Could not load '" + path + "': " + library_error()); diff --git a/python_bindings/test/runtime/callconv_generator.cpp b/python_bindings/test/runtime/callconv_generator.cpp index 9cd0c9c2a56c..a0bc1f5240e6 100644 --- a/python_bindings/test/runtime/callconv_generator.cpp +++ b/python_bindings/test/runtime/callconv_generator.cpp @@ -52,7 +52,8 @@ class CallConv : public Generator { // The enum GeneratorParam picks the operation at compile time. Expr combined; - switch (combine) { + const Combine op = combine; + switch (op) { case Combine::Add: combined = input(x, y) + s_u8; break; diff --git a/src/PythonExtensionRuntime.template.cpp b/src/PythonExtensionRuntime.template.cpp index 0ed301f05b07..ff9e6c0c1165 100644 --- a/src/PythonExtensionRuntime.template.cpp +++ b/src/PythonExtensionRuntime.template.cpp @@ -127,7 +127,7 @@ struct PyHalideBuffer { return false; } - PyObject *py_raw_buffer = PyObject_CallMethod(py_obj, get_raw_halide_runtime_buffer_fn, NULL); + PyObject *py_raw_buffer = PyObject_CallMethod(py_obj, get_raw_halide_runtime_buffer_fn, nullptr); if (!py_raw_buffer) { PyErr_Clear(); return false;