Skip to content

[python] Add a separate runtime module that doesn't require the compiler - #9272

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

[python] Add a separate runtime module that doesn't require the compiler#9272
derek-gerstmann wants to merge 7 commits into
mainfrom
dg/split-py-rt

Conversation

@derek-gerstmann

Copy link
Copy Markdown
Contributor

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
      <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

Checklist

  • Tests added or updated (not required for docs, CI config, or typo fixes)
  • Documentation updated (if public API changed)
  • Python bindings updated (if public API changed)
  • Benchmarks are included here if the change is intended to affect performance.
  • Commits include AI attribution where applicable (see Code of Conduct)

alexreinking and others added 2 commits July 23, 2026 01:55
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>
@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.19%. Comparing base (734f678) to head (a0dece9).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9272      +/-   ##
==========================================
- Coverage   70.22%   70.19%   -0.04%     
==========================================
  Files         257      257              
  Lines       79105    79046      -59     
  Branches    18954    18954              
==========================================
- Hits        55552    55484      -68     
- Misses      17888    17897       +9     
  Partials     5665     5665              

☔ 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.

Derek Gerstmann and others added 2 commits August 4, 2026 10:18
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 <noreply@anthropic.com>
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