| name | fkl-language-bindings |
| description | Wrap FKL from another language (Python, Rust, Julia...) using an AST/graph approach and JIT compilation. Covers why NVRTC fails, the Pointer Array ABI (void** for aligned parameters and I/O), generating C++ source strings for IOps and static DPP execution, zero-copy interop, and cross-platform shared library generation (.so/.dll). Use when building or extending a language binding for FKL. |
Building language bindings for FKL
Proven architecture: dynamic front-ends (like fkl-python) compose an Abstract Syntax Tree (AST) of operations, serialize their parameter structs into aligned memory, and generate a single fused C++ translation unit at runtime.
Why the obvious approaches fail
- pybind11/SWIG over a fixed API: impossible — there is no fixed API. The kernel is GENERATED by the C++ type system from the user's op sequence; every distinct chain is a new template instantiation.
- NVRTC: dead end. NVRTC compiles DEVICE code only, but FKL's fusion machinery (
build(), BackFuser, Executor, grid computation) is HOST code. You cannot fuse with NVRTC alone.
- Pre-compiled "bag of kernels": defeats the whole point — the combinatorial space of chains is unbounded.
The AST/Graph Architecture
Instead of binding C++ functions, the host language represents Operations and DPPs as graph nodes. Each node stores the literal C++ type string (e.g., "fk::Add<float>") and the host language allocates its parameter struct.
The host language (Python, Rust, etc.) is responsible for writing the C++ source strings for steps 1, 2, 3, and 4 below, concatenating them to generate the final .cu file:
extern "C" void fkl_entry(const void** params_ptrs, void* stream)
{
auto in_ptr = *reinterpret_cast<const fk::RawPtr<fk::ND::_2D, float>*>(params_ptrs[0]);
auto p1 = *reinterpret_cast<const float*>(params_ptrs[1]);
auto p2 = *reinterpret_cast<const fk::Rect*>(params_ptrs[2]);
auto out_ptr = *reinterpret_cast<const fk::RawPtr<fk::ND::_2D, float>*>(params_ptrs[3]);
auto in_op = fk::PerThreadRead<fk::ND::_2D, float>::build(in_ptr);
auto op1 = fk::Add<float>::build(p1);
auto op2 = fk::Crop<>::build(p2);
auto out_op = fk::PerThreadWrite<fk::ND::_2D, float>::build(out_ptr);
fk::Stream s(reinterpret_cast<cudaStream_t>(stream));
fk::executeOperations<fk::TransformDPP<...>>(s, in_op, op1, op2, out_op);
Key decisions:
- Types compile, values don't. The cache key contains: op tokens (names + template args), dtypes, layout (2D vs planes), batch size, OS, compiler, and FKL versions. Runtime values are passed through the pointer array at every call. Changing a value NEVER recompiles; changing the chain shape compiles exactly once.
- Generate what a C++ user would write. The binding emits the exact
::build(params) calls and static executeOperations sequences a human would write.
- clang first. clang compiles host+device CUDA in one invocation and is Apache-2.0; fall back to nvcc. Keep any required shims (texture header stubs, fatbinary flags) inside the binding.
The ABI (Pointer Array Architecture)
Keep fkl_entry dumb, stable, and immune to memory alignment undefined behavior:
const void** params_ptrs: All runtime values, inputs, and outputs passed as an array of pointers.
- The host language allocates device pointers/shapes as
fk::RawPtr structs alongside standard structs (like floats or Rects) using its native FFI tools, ensuring OS-level memory alignment.
- It then passes an array of pointers to these structs (
void**).
- The generated C++ casts each pointer to its exact type. This prevents C++ strict-aliasing and struct-packing alignment crashes while supporting graphs with any number of inputs and outputs without changing the FFI signature.
void* stream: external CUDA stream. If null, use a TU-local static Stream; if provided, run async and DON'T sync — the caller owns ordering (torch/cupy semantics).
Zero-copy interop
- Inputs: accept anything exposing a device pointer + shape + dtype (
__cuda_array_interface__ in Python). Allocate a RawPtr struct on the host containing its pointer and strides, and pass its address in the params_ptrs array. Require C-contiguous or honor strides via the pitch argument.
- Outputs: allocate with the CUDA driver API and expose BOTH the array interface and DLPack so frameworks adopt the memory without copying. On DLPack export, transfer ownership to the consumer's deleter and disarm your own destructor (double-free guard).
Mapping the fusion techniques
| technique | binding surface |
|---|
| VF | ordered list of op descriptors between read and write |
| BVF | geometry descriptors right after the read; emit ReadBack builds |
| HF | LISTS of parameters: list of rects -> std::array<Rect,N>; N is part of the type |
| DHF | list of chains + plane->sequence map; generate a SequenceSelector struct |
| CircularTensor | stateful handle (create/update/snapshot/destroy in the same library) |
Performance budget (measured reference points)
- steady-state fused launch: ~19-70 us/call (one FFI call, no sync path)
- cold compile: 1-3 s per signature, once, then disk-cached forever
- fused 6-op chain at 1080p: ~5x faster than 6 separate kernel launches
Testing a binding
- Validate against CPU references computed in the host language, not against FKL itself.
- Always test BOTH compilers (clang + nvcc), wiping the cache between.
- Test cross-platform generation (
.dll on Windows, .so on Linux).
- Test that parameter value changes hit the cache (same library path) and that chain topology changes miss it.