Halide Bindings for Python#

Halide provides Python bindings for most of its public API. Python 3.10 (or higher) is required. The Python bindings are supported on 64-bit Linux, OSX, and Windows systems.

In addition to the ability to write just-in-time Halide code using Python, you can write Generators using the Python bindings, which can simplify build-system integration (since no C++ metacompilation step is required).

You can also use existing Halide Generators (written in either C++ or Python) to produce Python extensions that can be used within Python code.

Acquiring the Python bindings#

As of Halide 19.0.0, we provide binary wheels on PyPI which include the Python bindings and the C++/CMake package for native development. Full releases may be installed with pip like so:

$ pip install halide

Every commit to main is published to a private PyPI index as a development version and these may be installed with a few extra flags:

$ pip install halide --pre --extra-index-url https://pypi.halide-lang.org/simple

Currently, we provide wheels for: Windows x86-64, macOS x86-64, macOS arm64, and Linux x86-64. The Linux wheels are built for manylinux_2_28, which makes them broadly compatible (Debian 10, Ubuntu 18.10, Fedora 29).

Building the Python bindings#

If pip isn’t enough for your purposes, or you are developing Halide directly, you have two options for building and using the Python bindings. Note that the bindings require Halide to be built with RTTI and exceptions enabled, which in turn requires LLVM to be built with RTTI, but this is not the default for LLVM.

Before configuring with CMake, you should ensure you have prerequisite packages installed in your local Python environment. The best way to get set up is to use a virtual environment with uv:

$ uv sync --no-install-project

If you don’t have LLVM installed already, you can try using the same ones the buildbots use by adding --group ci-llvm-<VERSION> to the uv sync command, where <VERSION> is the LLVM major version number (e.g. 23) or main.

If you install ci-llvm-*, you can set Halide_LLVM_ROOT=$(halide-llvm --prefix) in your environment.

Ensure you have flatbuffers and wabt installed, too. (The wheel build does not use vcpkg for manylinux compatibility reasons, so these must be available as system packages or installed from source.)

Using wheel infrastructure#

When using uv, this entire workflow can be run via:

$ uv pip install . --no-build-isolation

Using CMake directly#

Assuming dependencies are available, you can build the Python bindings directly with CMake:

$ cmake -G Ninja -S . -B build -DCMAKE_BUILD_TYPE=Release
$ cmake --build build
$ cmake --install build --prefix .local

Now you can set the PYTHONPATH variable to point to the freshly built Python package:

$ export PYTHONPATH="$PWD/.local/lib/python3/site-packages"

Documentation and Examples#

As mentioned elsewhere, the Python API attempts to mimic the C++ Halide API as directly as possible; there isn’t separate Python-specific documentation for the API at this time.

For now, examine the code for the example applications in the test/apps/ and tutorial/ subdirectories.

The tests run as part of the standard CTest infrastructure and are labeled with the python label. You can run the Python tests specifically by running:

$ ctest -L python

from the Halide build directory.

Differences from C++ API#

The Python bindings attempt to mimic the Halide C++ API as closely as possible, with some differences where the C++ idiom is either inappropriate or impossible:

  • Most APIs that take a variadic argument list of ints in C++ take an explicit list in Python. For instance, the usual version of the Buffer ctor in C++ offers both variadic and list versions:

    Buffer<>(Type t, int extent_dim_0, int extent_dim_1, ...., extent_dim_N, string name = "");
    Buffer<>(Type t, vector<int> extents, string name = "");
    

    In Python, only the second variant is provided.

  • Func and Buffer access is done using [] rather than ()

    • For zero-dimensional Func and Buffer, you must explicitly specify [()] – that is, use an empty tuple as the index – because [] is not syntactically acceptable in Python.

  • Some classes in the Halide API aren’t provided because standard Python idioms are a better fit:

    • Halide::Tuple doesn’t exist in the Python bindings; an ordinary Python tuple of Halide::Expr is used instead.

    • Halide::Realization doesn’t exist in the Python bindings; an ordinary Python tuple of Halide::Buffer is used instead.

  • static and instance method overloads with the same name in the same class aren’t allowed, so some convenience methods are missing from Halide::Var

  • Templated types (notably Halide::Buffer<> and Halide::Param<>) aren’t provided, for obvious reasons; only the equivalents of Halide::Buffer<void> and Halide::Param<void> are supported.

  • The functions in Halide::ConciseCasts are present in the toplevel Halide module in Python, rather than a submodule: e.g., use halide.i8_sat(), not halide.ConciseCasts.i8_sat().

  • Only things in the Halide namespace are supported; classes and methods that involve using the Halide::Internal namespace are not provided.

  • No mechanism is provided for overriding any runtime functions from Python for JIT-compiled code. (Runtime functions for AOT-compiled code can be overridden by building and linking a custom runtime, but not currently via any runtime API, e.g. halide_set_custom_print() does not exist.)

  • No mechanism is provided for supporting Func::define_extern.

  • Buffer::for_each_value() isn’t supported yet.

  • Func::in becomes Func.in_ because in is a Python keyword.

  • Func::async becomes Func.async_ because async is a Python keyword.

  • The not keyword cannot be used to negate boolean Halide expressions. Instead, the logical_not function can be used and is equivalent to using operator! in C++.

  • There is no way to override the logical and/or operators in Python to work with Expr: you must use the bitwise | and & instead. (Note that incorrectly using and/or just short-circuits weirdly, rather than failing with some helpful error; this is an issue that we have not yet found any way to improve, unfortunately.)

  • Some error messages need to be made more informative.

  • Some exceptions are the “incorrect” type (compared to C++ expectations).

  • Many hooks to override runtime functions (e.g. Func::set_error_handler) aren’t yet implemented.

  • The following parts of the Halide public API are currently missing entirely from the Python bindings (but are all likely to be supported at some point in the future):

    • DeviceInterface

    • evaluate()

Example of Simple Usage#

Here is a basic example of using Halide to produce a procedural image.

# By convention, we import halide as 'hl' for terseness
import halide as hl

# Some constants
edge = 512
k = 20.0 / float(edge)

# Simple formula
x, y, c = hl.Var("x"), hl.Var("y"), hl.Var("c")
f = hl.Func("f")
e = hl.sin(x * ((c + 1) / 3.0) * k) * hl.cos(y * ((c + 1) / 3.0) * k)
f[x, y, c] = hl.cast(hl.UInt(8), e * 255.0)
f.vectorize(x, 8).parallel(y)

# Realize into a Buffer.
buf = f.realize([edge, edge, 3])

# Do something with the image. We'll just save it to a PNG.
from halide import imageio

imageio.imwrite("/tmp/example.png", buf)

It’s worth noting in the example above that the Halide Buffer object supports the Python Buffer Protocol (https://www.python.org/dev/peps/pep-3118) and thus is converted to and from other compatible objects (e.g., NumPy’s ndarray), at essentially zero cost, with storage being shared. Thus, we can usually pass it directly to existing Python APIs (like imsave()) that expect ‘image-like’ objects without any explicit conversion necessary.

Halide Generators In Python#

In Halide, a “Generator” is a unit of encapsulation for Halide code. It is a self-contained piece of code that can:

  • Produce a chunk of Halide IR (in the form of an hl.Pipeline) that is appropriate for compilation (via either JIT or AOT)

  • Expose itself to the build system in a discoverable way

  • Fully describe itself for the build system with metadata for (at least) the type and number of inputs and outputs expected

  • Allow for build-time customization of coder-specified parameters in a way that doesn’t require editing of source code

Originally, Halide only supported writing Generators in C++. In this document, we’ll use the term “C++ Generator” to mean “Generator written in C++ using the classic API”, the term “Python Generator” to mean “Generator written in Halide’s Python bindings”, and just plain “Generator” when the discussion is relatively neutral with respect to the implementation language/API.

Writing a Generator in Python#

A Python Generator is a class that:

  • has the @hl.generator decorator applied to it

  • declares zero or more member fields that are initialized with values of hl.InputBuffer or hl.InputScalar, which specify the expected input(s) of the resulting Pipeline.

  • declares one or more member fields that are initialized with values of hl.OutputBuffer or hl.OutputScalar, which specify the expected output(s) of the resulting Pipeline.

  • declares zero or more member fields that are initialized with values of hl.GeneratorParam, which can be used to pass arbitrary information from the build system to the Generator. A GeneratorParam can carry a value of type bool, int, float, str, or hl.Type.

  • declares a generate() method that fill in the Halide IR needed to define all the Outputs

  • optionally declares a configure() method to dynamically add Inputs or Outputs to the pipeline, based on (e.g.) the values of GeneratorParam values or other external inputs

Let’s look at a fairly simple example:

TODO: this example is pretty contrived; is there an equally simple Generator to use here that would demonstrate the basics?

import halide as hl

x = hl.Var("x")
y = hl.Var("y")

_operators = {
    "xor": lambda a, b: a ^ b,
    "and": lambda a, b: a & b,
    "or": lambda a, b: a | b,
}


# Apply a mask value to a 2D image using a logical operator that is selected at compile-time.
@hl.generator(name="logical_op_generator")
class LogicalOpGenerator:
    op = hl.GeneratorParam("xor")

    input = hl.InputBuffer(hl.UInt(8), 2)
    mask = hl.InputScalar(hl.UInt(8))

    output = hl.OutputBuffer(hl.UInt(8), 2)

    def generate(g):
        # Algorithm
        operator = _operators[g.op]
        g.output[x, y] = operator(g.input[x, y], g.mask)

        # Schedule
        v = g.natural_vector_size(hl.UInt(8))
        g.output.vectorize(x, v)


if __name__ == "__main__":
    hl.main()

If you’ve worked with Halide Generators written in C++, the “shape” of this will likely look familiar. (If not, no worries; you shouldn’t need any knowledge of C++ Generators for the following to make sense.)

Let’s take the details here one at a time.

hl.generator("name")#

This decorator adds appropriate “glue” machinery to the class to enforce various invariants. It also serves as the declares a “registered name” for the Generator, which is a unique name that the build system will use to identify the Generator. If you omit the name, it defaults to module.classname; if module is __main__ then we omit it and just use the plain classname. Note that the registered name need not match the classname. (Inside Halide, we use the convention of CamelCase for class names and snake_case for registered names, but you can use whatever convention you like.)

hl.GeneratorParam#

Each GeneratorParam is an arbitrary key-value pair that can be used to provide configurable options at compile time. You provide the name and a default value. The default value can be overridden by the build machinery, which will replace the value (based on user specified text).

Note that the type of the default value is used to define the expected type of the GeneratorParam, and trying to set it to an incompatible value will throw an exception. The types that are acceptable to use in a GeneratorParam are:

  • Python’s bool, int, float, or str

  • Halide’s hl.Type

  • …that’s all

Note that the value of a GeneratorParam is read-only from the point of view of the Generator; they are set at Generator construction time and attempting to change their value will throw an exception.

hl.InputBuffer, hl.InputScalar#

These declare the inputs to the hl.Pipeline that the Generator will produce. An hl.InputScalar is, essentially, a “factory” that produces an hl.Param in the existing Python API, while an hl.InputBuffer is a factory for hl.ImageParam.

From the Generator author’s perspective, a field initialized with InputScalar is a Param – not kinda-like-one, not a magic wrapper that forwards everything; it is literally just hl.Param. Similarly, an InputBuffer produces ImageParam, and an InputFunc is a wrapper around Func. You won’t be able to assign a new value to the member field for Inputs – as with GeneratorParams, they are “read-only” to the Generator – but you will be able to set constraints on them.

Note that in addition to specifying a concrete type and dimensionality for the inputs, these factory classes support the ability to specify either (or both) None, which means the type/dimensionality will be provided by GeneratorParams in the build system.

hl.OutputBuffer, hl.OutputScalar#

These declare the output(s) of the Pipeline that the Generator will produce. An hl.OutputBuffer is, essentially, a “factory” that produces an hl.Func in the existing Python API. (hl.OutputScalar is just an hl.OutputBuffer that always has zero dimensions.)

From the Generator author’s perspective, a field declared with OutputBuffer is a Func – not kinda-like-one, not a magic wrapper that forwards everything; it is literally just hl.Func (with type-and-dimensionality set to match, see recent PR https://github.com/halide/Halide/pull/6734) . You won’t be able to assign a new value to the member field for Inputs – as with GeneratorParams, they are “read-only” to the Generator – but you will be able to set constraints on them.

Note that in addition to specifying a concrete type and dimensionality for the inputs, these factory classes support the ability to specify either (or both) as None, which means the type/dimensionality will be provided by GeneratorParams in the build system.

Names#

Note that all the GeneratorParams, Inputs, and Outputs have names that are implicitly filled in based on the field name of their initial assignment; unlike in C++ Generators, there isn’t a way to “override” this name (i.e., the name in the IR will always exactly match the Python field name). Names have the same constraints as for C++ Generators (essentially, a C identifier, but without an initial underscore, and without any double underscore anywhere).

generate() method#

This will be called by the Generator machinery to build the Pipeline. As with C++ Generators, the only required task is to ensure that all Output fields are fully defined, in a way that matches the type-and-dimension constraints specified.

It is required that the generate() method be defined by the Generator.

(Note that, by convention, Halide Generators use g instead of self in their generate() method to make the expression language terser; this is not in any way required, but is recommended to improve readability.)

Types for Inputs and Outputs#

For all the Input and Output fields of Generators, you can specify native Python types (instead of hl.Type) for certain cases that are unambiguous. At present, we allow bool as an alias for hl.Bool(), int as an alias for hl.Int(32), and float as an alias for hl.Float(32).

Using a Generator for JIT compilation#

You can use the compile_to_callable() method to JIT-compile a Generator into a hl.Callable, which is (essentially) just a dynamically-created function.

import LogicalOpGenerator
from halide import imageio
import numpy as np

# Instantiate a Generator -- we can only set the GeneratorParams
# by passing in a dict to the Generator's constructor
or_op_generator = LogicalOpGenerator({"op": "or"})

# Now compile the Generator into a Callable
or_filter = or_op_generator.compile_to_callable()

# Read in some file for input
input_buf = imageio.imread("/path/to/some/file.png")
assert input_buf.ndim == 2
assert input_buf.dtype == np.uint8

# create a Buffer-compatible object for the output; we'll use np.array
output_buf = np.empty(input_buf.shape, dtype=input_buf.dtype)

# Note, Python code throws exception for error conditions rather than returning an int
or_filter(input_buf, 0x7F, output_buf)

# Note also that we can use named arguments for any/all, in the Python manner:
or_filter(mask=0x7F, input=input_buf, output=output_buf)

imageio.imwrite("/tmp/or.png", output_buf)

By default, a Generator will produce code targeted at Target("host") (or the value of the HL_JIT_TARGET environment variable, if set); you can override this behavior selectively by activating a GeneratorContext when the Generator is created:

import LogicalOpGenerator

# Compile with debugging enabled
t = hl.Target("host-debug")
with hl.GeneratorContext(t):
    or_op_generator = LogicalOpGenerator({"op": "or"})
    or_filter = or_op_generator.compile_to_callable()

Using a Generator for AOT compilation#

If you are using CMake, the simplest thing is to use add_halide_library and add_halide_python_extension_library():

# Build a Halide library as you usually would, but be sure to include `PYTHON_EXTENSION`
add_halide_library(xor_filter
                   FROM logical_op_generator
                   PARAMS op=xor
                   PYTHON_EXTENSION output_path_var
                   [ FEATURES ... ]
                   [ PARAMS ... ])

# Now wrap the generated code with a Python extension.
# (Note that module name defaults to match the target name; we only
# need to specify MODULE_NAME if we need a name that may differ)
add_halide_python_extension_library(my_extension
                                    MODULE_NAME my_module
                                    HALIDE_LIBRARIES xor_filter)

(Note that this rule works for both C++ and Python Generators.)

This compiles the Generator code in logical_op_generator.py with the registered name logical_op_generator to produce the target xor_filter, and then wraps the compiled output with a Python extension. The result will be a shared library of the form <target>.<soabi>.so, where <soabi> describes the specific Python version and platform (e.g., cpython-310-darwin for Python 3.10 on OSX.)

Note that you can combine multiple Halide libraries into a single Python module; this is convenient for packaging, but also because all the libraries in a single extension module share the same Halide runtime (and thus, the same caches, thread pools, etc.).

add_halide_library(xor_filter ...)
add_halide_library(and_filter ...)
add_halide_library(or_filter ...)

add_halide_python_extension_library(my_extension
                                    MODULE_NAME my_module
                                    HALIDE_LIBRARIES xor_filter and_filter or_filter)

Note that you must take care to ensure that all of the add_halide_library targets specified use the same Halide runtime; it may be necessary to use add_halide_runtime to define an explicit runtime that is shared by all the targets:

add_halide_runtime(my_runtime)

add_halide_library(xor_filter USE_RUNTIME my_runtime ...)
add_halide_library(and_filter USE_RUNTIME my_runtime ...)
add_halide_library(or_filter USE_RUNTIME my_runtime ...)

add_halide_python_extension_library(my_extension
                                    MODULE_NAME my_module
                                    HALIDE_LIBRARIES xor_filter and_filter or_filter)

If you’re not using CMake, you can “drive” a Generator directly from your build system via command-line flags. The most common, minimal set looks something like this:

python3 /path/to/my/generator.py -g <registered-name> \
                                 -o <output-dir> \
                                 target=<halide-target-string> \
                                 [generator-param=value ...]

The argument to -g is the name supplied to the @hl.generator decorator. The argument to -o is a directory to use for the output files; by default, we’ll produce a static library containing the object code, and a C++ header file with a forward declaration. target specifies a Halide Target string describing the OS, architecture, features, etc. that should be used for compilation. Any other arguments to the command line that don’t begin with - are presumed to name GeneratorParam values to set.

There are other flags and options too, of course; use python3 /path/to/my/generator.py -help to see a list with explanations.

(Unfortunately, there isn’t (yet) a way to produce a Python Extension just by running a Generator; the logic for add_halide_python_extension_library is currently all in the CMake helper files.)

Calling Generator-Produced code from Python#

As long as the shared library is in PYTHONPATH, it can be imported and used directly. For the example above:

from my_module import xor_filter
from halide import imageio
import numpy as np

# Read in some file for input
input_buf = imageio.imread("/path/to/some/file.png")
assert input_buf.ndim == 2
assert input_buf.dtype == np.uint8

# create a Buffer-compatible object for the output; we'll use np.array
output_buf = np.empty(input_buf.shape, dtype=input_buf.dtype)

# Note, Python code throws exception for error conditions rather than returning an int
xor_filter(input_buf, 0xFF, output_buf)

# Note also that we can use named arguments for any/all, in the Python manner:
# xor_filter(input=input_buf, mask=0xff, output=output_buf)

imageio.imwrite("/tmp/xored.png", output_buf)

Above, we’re using common Python utilities (numpy) to construct the input/output buffers we want to pass to Halide.

Note: Getting the memory order correct can be a little confusing for numpy. By default, numpy uses “C-style” row-major order, which sounds like the right option for Halide; however, this nomenclature assumes the matrix-math convention of ordering axes as [rows, cols], whereas Halide (and imaging code in general) generally assumes [x, y] (i.e., [cols, rows]). Thus, what you usually want in Halide is column-major ordering. This means numpy arrays, by default, come with the wrong memory layout for Halide. But if you construct the numpy arrays yourself (like above), you can 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.

Keeping Up To Date#

If you use the Halide Bindings for Python inside Google, you are strongly encouraged to subscribe to announcements for new releases of Halide, as it is likely that enhancements and tweaks to our Python support will be made in future releases.

License#

The Python bindings use the same MIT license as Halide.

Python bindings provided by Connelly Barnes (2012-2013), Fred Rotbart (2014), Rodrigo Benenson (2015) and the Halide open-source community.