Skip to content

[python] Split Python packages and add standalone AOT runtime - #9272

Open
derek-gerstmann wants to merge 39 commits into
mainfrom
dg/split-py-rt
Open

[python] Split Python packages and add standalone AOT runtime#9272
derek-gerstmann wants to merge 39 commits into
mainfrom
dg/split-py-rt

Conversation

@derek-gerstmann

@derek-gerstmann derek-gerstmann commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Rework Halide's Python distribution into three coordinated packages, add a standalone runtime API for calling precompiled AOT kernels without the compiler, and harden the shared Python binding/marshalling layer.

The full pip install halide experience remains intact: package managers install the matching binary and runtime distributions automatically. Deployment environments that only execute precompiled pipelines can install halide-runtime by itself and avoid libHalide and LLVM entirely.

Package layout

Distribution Contents Wheel shape
halide-bin libHalide, autoschedulers, generator tools, headers, and CMake package files Python-independent platform wheel, built once per platform
halide-runtime halide.runtime, its lightweight Buffer, and the Halide runtime needed to invoke AOT kernels Small CPython platform wheel with no libHalide dependency
halide The compiler-facing pybind11 extension, Python helpers, and image I/O CPython wheel depending on matching halide-bin and halide-runtime versions

This avoids rebuilding and bundling the LLVM-linked compiler once per CPython ABI. The repository root is now a uv workspace; source builds target the individual distribution directories rather than treating the root as the halide distribution. The Python bindings are organized as workspace packages with shared versioning and reproducible, packaged build dependencies.

Standalone halide.runtime

halide.runtime can load and call a precompiled Halide AOT shared library without importing the compiler extension:

import halide.runtime as hlr

kernel = hlr.load("my_filter.so", name="my_filter")
kernel(input_array, output_array)

The module provides:

  • load(path, name=None), accepting normal Python path-like objects and resolving <name>_argv and <name>_metadata from a shared library.
  • Callable Kernel objects with metadata-driven scalar and buffer argument marshalling plus name, target, and argument introspection.
  • A lightweight halide.runtime.Buffer with zero-copy Python buffer/NumPy views, shape transformations, element access, and device operations.
  • Zero-copy interop between compiler halide.Buffer, runtime halide.runtime.Buffer, and generated Python extensions through versioned typed capsules with explicit ownership.
  • Per-thread runtime error capture and output copy-back failures reported as Python exceptions instead of aborting the interpreter.
  • Lazy compiler imports so import halide.runtime never loads libHalide; runtime-only installations produce a clear error if compiler APIs are requested.

Shared marshalling and binding correctness

Move the generated-extension buffer marshalling code into src/PythonExtensionRuntime.template.cpp, which is both embedded into generated Python extensions and reused by the standalone runtime module. This removes duplicated conversion logic and keeps generated extensions self-contained.

The shared and compiler binding paths are also hardened to:

  • Preserve exceptions raised by capsule providers and retain capsules for the lifetime of borrowed pointers.
  • Preserve Python buffer-exporter errors, including read-only output diagnostics.
  • Reject non-native-endian multi-byte buffers, non-integral element strides, out-of-range dimensions/strides, and inconsistent format/item-size metadata rather than silently misaddressing or corrupting data.
  • Propagate warnings promoted to exceptions instead of returning with a pending Python error.
  • Catch only pybind11 conversion failures during manual overload dispatch so genuine Halide errors are not replaced with generic ValueErrors.
  • Use pybind11's std::filesystem::path support instead of a custom raw-Python path caster.

Build, packaging, and CI

  • Add dedicated CMake targets/install components for the compiler and runtime Python modules.
  • Install the shared runtime template next to the Halide runtime headers for out-of-tree builds.
  • Build the bundled runtime for the native target and keep the runtime binding independent of libHalide.
  • Update wheel build, repair, publication, dependency discovery, and Windows shared-package handling for all three distributions.
  • Keep project-wide version bumping and disabled-Python-binding configurations working.
  • Remove obsolete archive packaging scripts and modernize the Python workspace/pre-commit configuration.

Tests and documentation

Coverage includes:

  • Loading and invoking real AOT kernels without importing the compiler.
  • Every supported scalar calling-convention type, multidimensional buffers, tuple outputs, keyword arguments, and compile-time GeneratorParam variants.
  • CPU and available Metal/OpenCL/CUDA/Vulkan execution paths.
  • Compiler/runtime/generated-extension Buffer interop and ownership behavior.
  • Capsule exception propagation, warnings-as-errors, endian/stride validation, read-only buffers, path-like inputs, and Halide error preservation.
  • A binary dependency check proving the runtime module does not link libHalide.
  • Updated package documentation, Python API documentation, and a standalone runtime tutorial covering AOT generation, shared-library linking, loading, and execution.

Co-authored by @alexreinking

Checklist

  • Tests added or updated
  • Documentation updated
  • Python bindings updated
  • Benchmarks included (not performance-oriented)
  • Commits include AI attribution where applicable

@derek-gerstmann derek-gerstmann added enhancement New user-visible features or improvements to existing features. release_notes For changes that may warrant a note in README for official releases. python Issues related to Halide/Python interop labels Aug 3, 2026
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.01%. Comparing base (5c21c82) to head (ad25832).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9272      +/-   ##
==========================================
- Coverage   70.08%   70.01%   -0.08%     
==========================================
  Files         260      260              
  Lines       79287    79229      -58     
  Branches    19327    19327              
==========================================
- Hits        55569    55470      -99     
- Misses      17923    17929       +6     
- Partials     5795     5830      +35     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

alexreinking and others added 6 commits August 21, 2026 15:05
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-<platform>, 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 <noreply@anthropic.com>
…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
    `<name>_argv`/`<name>_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 <noreply@anthropic.com>
@alexreinking alexreinking changed the title [python] Add a separate runtime module that doesn't require the compiler [python] Split Python packages and add standalone AOT runtime Aug 22, 2026

@alexreinking alexreinking left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This took some work, but with everything in place, we were able to pay off some longstanding tech debt.

@derek-gerstmann

Copy link
Copy Markdown
Contributor Author

@alexreinking Amazing! Thanks for all your help on this!

@alexreinking

Copy link
Copy Markdown
Member

Looks like I broke Python stubs. Will fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New user-visible features or improvements to existing features. python Issues related to Halide/Python interop release_notes For changes that may warrant a note in README for official releases.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants