Skip to content

Allow Basix functions to be called from Numba kernels - #1096

Open
garth-wells wants to merge 2 commits into
mainfrom
garth/numba-element-kernels
Open

garth-wells wants to merge 2 commits into
mainfrom
garth/numba-element-kernels

Conversation

@garth-wells

Copy link
Copy Markdown
Member

Tabulation and the push-forward/pull-back maps are exposed as native functions that a Numba kernel compiled in nopython mode can call directly, so that loops over large numbers of points run at native speed instead of paying a Python call per point.

Until now basix.numba_helpers only provided DOF transformations, reimplemented in Python. Anything else had to be reimplemented the same way, which is what #201 asks to avoid.

Interface

from basix.numba_helpers import ElementHandle, tabulate_fn

element = basix.create_element(basix.ElementFamily.P, basix.CellType.triangle, 2)
handle = ElementHandle(element)
tabulate = tabulate_fn(element.dtype)

@numba.njit(cache=True, nogil=True)
def kernel(address, X, basis):
    if tabulate(address, 0, X.ctypes.data, X.shape[0], basis.ctypes.data) != 0:
        raise RuntimeError("Basix tabulation failed.")
  • The entry points use no Python API and let no C++ exception escape into JIT-compiled code, reporting failure through their return value instead. Kernels calling them can release the GIL.
  • They are registered with LLVM by symbol name and wrapped as numba.types.ExternalFunction, so kernels that call them are still cacheable. Binding the raw address through ctypes instead makes Numba refuse to cache the kernel (Cannot cache compiled function ... uses dynamic globals).
  • ElementHandle holds the address of the C++ element together with a reference to the element, so the element cannot be collected while a kernel is using it.
  • The map entry point writes into a buffer supplied by the caller and performs no allocation, unlike push_forward/pull_back. It derives the reference and physical value sizes from the element and the geometric dimension, which differ when gdim != tdim — a contravariant Piola mapped element on a triangle in 3D has reference value size 2 and physical value size 3.

FiniteElement.tabulate_shape is added to the Python interface, since the size of the tabulation buffer cannot otherwise be computed from Python.

Demo

demo/python/demo_numba_kernels.py assembles the interpolation matrix from a degree 2 Lagrange space to a set of scattered points, which is the use case raised in #201. One kernel pulls each point back to the reference cell, tabulates, and scatters the basis values into a matrix row. On 200,000 points:

Numba kernel: 0.13 us/point
Python loop:  8.71 us/point
Speedup:      67.8x

Its demo test is guarded with importorskip, since Numba is not installed on every platform Basix supports.

Testing

43 cases in test/test_numba.py: tabulation against FiniteElement.tabulate for float32 and float64, derivative orders 0 to 2 and five elements; maps against push_forward/pull_back including three cases where gdim != tdim; a cache test that promotes NumbaWarning to an error; handle lifetime and dtype rejection; and an error path using a custom L2 Piola element, where an unsupported map is reported rather than letting the C++ exception escape.

Closes #201.

🤖 Generated with Claude Code

garth-wells and others added 2 commits October 17, 2026 19:08
Tabulation and the push-forward/pull-back maps are exposed as native
functions that a Numba kernel compiled in nopython mode can call
directly, so that loops over large numbers of points run at native
speed rather than paying a Python call per point.

The entry points use no Python API and let no C++ exception escape into
JIT-compiled code, reporting failure through their return value instead.
They are registered with LLVM by symbol name, so kernels that call them
can still be cached by Numba. The map entry point writes into a buffer
supplied by the caller and performs no allocation, and derives the
reference and physical value sizes from the element and the geometric
dimension, which differ when gdim is not equal to tdim.

An element is passed to a kernel as the address of the underlying C++
object. ElementHandle holds that address together with a reference to
the element, so that the element cannot be collected while a kernel is
using it.

FiniteElement.tabulate_shape is added to the Python interface, since the
size of the tabulation buffer cannot otherwise be computed from Python.

Fixes #201.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The demo runs in a subprocess, so importing Numba in the test process
only loaded its shared libraries into the process that valgrind traces.
glibc's loader leaks a small allocation per dlopen, which the valgrind
job reports as definitely lost and fails on.

Check for Numba with importlib.util.find_spec, which finds the module
without executing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# ==================================
#
# Tabulating an element one point at a time from Python is dominated by
# the cost of the Python call, not by the mathematics. A loop that

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.

not by the mathematics - not by the calculations performed within the call.

# cells are affine, so the pull-back is the inverse of the Jacobian
# ``J = [v1 - v0, v2 - v0]`` applied to ``x - v0``.
#
# Because the tabulation function does not touch the Python interpreter,

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 is a bit roundabout and I'm not sure about the GIL because this doesn't appear to be threaded?

I would propose "the numba code directly calls the Basix C++ function without passing back through the Python interpreter, leading to no Python function call overhead."

- ``out`` is the address of the output buffer, shape
``(num_jacobians, num_points, vs)``.

The value size ``vs`` is the reference value size on the reference

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 comment is 'standard knowledge'.

Comment on lines +90 to +91
# application would locate each point in the mesh with a bounding box
# tree; here we pick a cell and a position within it, so that the cell

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.

Not just a bounding box tree, it would also have to check for containment within the cell by using a pull back or closest point projection.

Comment on lines +268 to +273
J = np.zeros((num_mapped_cells, 2, 2))
for c in range(num_mapped_cells):
v0, v1, v2 = points[cells[c]]
J[c] = np.column_stack([v1 - v0, v2 - v0])
detJ = np.array([np.linalg.det(J[c]) for c in range(num_mapped_cells)])
K = np.array([np.linalg.inv(J[c]) for c in range(num_mapped_cells)])

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.

Shouldn't this rather use the basis functions to compute the jacobian, so this generalizes to higher order or non-affien geometries?

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provide interface to run Basix functions inside Numba-compiled kernels

3 participants