Write a custom operator
The built-in ops:: library does not have to be the end of the line. nn::Module is both the parameter container (PyTorch-style dotted names, so HuggingFace checkpoints bind by name) and the custom-op entry point: a leaf module that overrides output_shapes() and compute() runs its own kernel through the runtime, eager and tracing alike. This guide builds one of each. The C samples abbreviate the api-table bootstrap that tutorial part 1 shows in full.
Compose built-ins into a module
A composite module subclasses nn::Module, registers its children in the constructor, and defines its own forward composing ops:: and child modules. Registration is what buys the dotted names: the block below enumerates up.weight and down.weight, which is exactly how a checkpoint refers to them.
- C++
- C
- Python
- Kotlin
- Go
- Rust
#include <cstdint>
#include <cstdio>
#include <memory>
#include "ClikaRT/clika_rt.h"
using ClikaRT::DataType;
using ClikaRT::nn::Linear;
using ClikaRT::NamedTensors;
using ClikaRT::Tensor;
namespace nn = ClikaRT::nn;
namespace ops = ClikaRT::ops;
// A residual MLP block: y = down(relu(up(x))) + x.
class MlpBlock final : public nn::Module {
public:
static std::shared_ptr<MlpBlock> make(std::int64_t dim, std::int64_t hidden) {
std::shared_ptr<MlpBlock> m(new MlpBlock());
m->up_ = Linear::make(dim, hidden);
m->down_ = Linear::make(hidden, dim);
m->register_module("up", m->up_);
m->register_module("down", m->down_);
return m;
}
Tensor forward(const Tensor& x) const {
return ops::add(down_->forward(ops::relu(up_->forward(x))), x);
}
private:
MlpBlock() = default;
std::shared_ptr<Linear> up_;
std::shared_ptr<Linear> down_;
};
int main() {
const std::shared_ptr<MlpBlock> block = MlpBlock::make(4, 8);
std::printf("parameters (dotted, checkpoint-shaped):\n");
for (const auto& [name, t] : block->named_parameters())
std::printf(" %-12s fake=%d\n", name.c_str(), (int)t.is_fake());
// Bind weights by name, the way a checkpoint would.
NamedTensors ckpt;
ckpt.set("up.weight", Tensor::full({8, 4}, 0.1, DataType::Float32));
ckpt.set("down.weight", Tensor::full({4, 8}, 0.1, DataType::Float32));
block->load_state_dict(ckpt);
const Tensor x = Tensor::ones({1, 4}, DataType::Float32);
std::printf("y = %s\n", block->forward(x).to_string().c_str());
return 0;
}
#include <stdio.h>
#include "clika_rt/clika_rt_core.h"
/* api, check(): the bootstrap from tutorial part 1 (dlopen + clika_rt_get_api).
* The C surface has no subclassing; a composite block is a plain struct whose
* forward chains the ops, and the dotted names live in how you bind. */
extern const clika_rt_api* api;
extern void check(clika_rt_error* e, const char* where);
typedef struct {
clika_rt_tensor* up_weight; /* "up.weight" [hidden, dim] */
clika_rt_tensor* down_weight; /* "down.weight" [dim, hidden] */
} mlp_block;
/* A residual MLP block: y = down(relu(up(x))) + x. The relu rides the first
* linear as its activation epilogue. */
static clika_rt_tensor* mlp_forward(const mlp_block* m, clika_rt_tensor* x) {
/* op_linear CONSUMES x and the weight (the donation law). The retain on
* x funds that consume; the residual add below only BORROWS x, riding
* the caller's own reference. Each weight is retained so the block
* keeps owning it across calls. */
api->tensor_retain(x);
api->tensor_retain(m->up_weight);
clika_rt_tensor* h = NULL;
check(api->op_linear(x, m->up_weight, NULL, 1, CLIKA_RT_ACTIVATION_RELU,
0.0, 0.0, &h), "up");
api->tensor_retain(m->down_weight);
clika_rt_tensor* d = NULL;
check(api->op_linear(h, m->down_weight, NULL, 0, CLIKA_RT_ACTIVATION_IDENTITY,
0.0, 0.0, &d), "down");
clika_rt_scalar_or_tensor residual = {.kind = CLIKA_RT_SCALAR_OR_TENSOR_TENSOR,
.tensor = x}; /* borrowed for the call */
clika_rt_tensor* y = NULL;
check(api->op_add_tensor(d, residual, 1.0, CLIKA_RT_ACTIVATION_IDENTITY, &y), "add");
return y;
}
int main(void) {
const clika_rt_stream_or_device dflt = {CLIKA_RT_STREAM_OR_DEVICE_DEFAULT};
mlp_block block = {NULL, NULL};
check(api->tensor_full((const int64_t[]){8, 4}, 2, 0.1,
CLIKA_RT_DATA_TYPE_FLOAT32, dflt, dflt, &block.up_weight),
"full");
check(api->tensor_full((const int64_t[]){4, 8}, 2, 0.1,
CLIKA_RT_DATA_TYPE_FLOAT32, dflt, dflt, &block.down_weight),
"full");
clika_rt_tensor* x = NULL;
check(api->tensor_ones((const int64_t[]){1, 4}, 2, CLIKA_RT_DATA_TYPE_FLOAT32,
dflt, dflt, &x), "ones");
clika_rt_tensor* y = mlp_forward(&block, x);
char buf[256];
size_t blen = sizeof buf;
api->tensor_to_string(y, buf, &blen);
printf("y = %s\n", buf);
api->tensor_release(y);
api->tensor_release(x);
api->tensor_release(block.down_weight);
api->tensor_release(block.up_weight);
return 0;
}
import clika_runtime as crt
import clika_runtime.nn as nn
class MlpBlock(nn.Module):
"""A residual MLP block: y = down(relu(up(x))) + x. A model is a Module
subclass: assigning a module REGISTERS it: load_state_dict,
named_parameters and repr all see `up` and `down` automatically. The
first linear fuses its ReLU as an activation epilogue; bias defaults to
on, so these layers opt out explicitly."""
def __init__(self, dim: int, hidden: int) -> None:
super().__init__()
self.up = nn.Linear(dim, hidden, bias=False,
activation=crt.Activation.Relu)
self.down = nn.Linear(hidden, dim, bias=False)
def forward(self, x: crt.Tensor) -> crt.Tensor:
return self.down(self.up(x)) + x
def main() -> None:
block = MlpBlock(4, 8)
print(block) # the module tree, registered names included
print("parameters (dotted, checkpoint-shaped):")
for name, t in block.named_parameters():
print(f" {name}")
# Weights arrive as a plain dict; dotted keys route to the registered
# submodules.
block.load_state_dict({
"up.weight": crt.full((8, 4), 0.1),
"down.weight": crt.full((4, 8), 0.1),
})
y = block(crt.ones(1, 4)) # calling the module runs forward
print(f"y = {y}")
if __name__ == "__main__":
main()
import io.clika.runtime.ClikaRt
import io.clika.runtime.ClikaRtGen
import io.clika.runtime.Linear
import io.clika.runtime.Tensor
import io.clika.runtime.Tensors
import io.clika.runtime.plus
import io.clika.runtime.summary
// A residual MLP block: y = down(relu(up(x))) + x. The first linear fuses
// its ReLU as an activation epilogue. The typed tier has no registration
// or dotted-name enumeration yet: a block is a plain class over [Linear],
// and the checkpoint-shaped names live in how you bind the weights.
class MlpBlock(up: Tensor, down: Tensor) : AutoCloseable {
val up = Linear(up, activation = ClikaRtGen.ACTIVATION_RELU)
val down = Linear(down)
fun forward(x: Tensor): Tensor {
val h = up(x) // modules are non-consuming; x stays valid
val d = down(h)
h.release()
val y = d + x // operators are non-consuming too
d.release()
return y
}
override fun close() { up.close(); down.close() }
}
fun main() {
ClikaRt.load() // binds the typed tier AND the generated table
// Bind weights the way a checkpoint names them: "up.weight" [hidden,
// dim], "down.weight" [dim, hidden].
val block = MlpBlock(
Tensors.full(longArrayOf(8, 4), 0.1),
Tensors.full(longArrayOf(4, 8), 0.1))
val x = Tensors.ones(longArrayOf(1, 4))
println("y = ${block.forward(x).summary()}")
block.close()
}
package main
import (
"fmt"
"log"
"github.com/Clika/clika_runtime/bindings/go/clikart"
)
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
// A residual MLP block: y = down(relu(up(x))) + x, as a plain struct whose
// forward chains the ops; the relu rides the first linear as its epilogue.
type MlpBlock struct {
UpWeight *clikart.Tensor // "up.weight" [hidden, dim]
DownWeight *clikart.Tensor // "down.weight" [dim, hidden]
}
func (m *MlpBlock) Forward(x *clikart.Tensor) (*clikart.Tensor, error) {
relu := clikart.ActivationRelu
h, err := clikart.Linear(x, m.UpWeight, nil, &relu)
if err != nil {
return nil, err
}
d, err := clikart.Linear(h, m.DownWeight, nil, nil)
if err != nil {
return nil, err
}
return d.AddTensor(clikart.ScalarOrTensor{
Kind: clikart.ScalarOrTensorTensor, Tensor: x,
}, 1.0, clikart.ActivationIdentity)
}
func main() {
api := must(clikart.Load("libClikaRT.so"))
block := &MlpBlock{
UpWeight: must(api.TensorFull([]int64{8, 4}, 0.1, clikart.Float32)),
DownWeight: must(api.TensorFull([]int64{4, 8}, 0.1, clikart.Float32)),
}
x := must(api.TensorOnes([]int64{1, 4}, clikart.Float32,
clikart.StreamOrDevice{}, clikart.StreamOrDevice{}))
fmt.Printf("y = %s\n", must(block.Forward(x)))
}
use clika_rt::{sys, Api, Tensor};
const F32: sys::clika_rt_data_type = sys::clika_rt_data_type_CLIKA_RT_DATA_TYPE_FLOAT32;
const RELU: sys::clika_rt_activation = sys::clika_rt_activation_CLIKA_RT_ACTIVATION_RELU;
const IDENTITY: sys::clika_rt_activation = sys::clika_rt_activation_CLIKA_RT_ACTIVATION_IDENTITY;
fn default_placement() -> sys::clika_rt_stream_or_device {
sys::clika_rt_stream_or_device {
kind: sys::clika_rt_stream_or_device_kind_CLIKA_RT_STREAM_OR_DEVICE_DEFAULT as i32,
device: sys::clika_rt_device { api: 0, index: 0 },
stream: std::ptr::null_mut(),
}
}
// A residual MLP block: y = down(relu(up(x))) + x, as a plain struct whose
// forward chains the ops; the relu rides the first linear as its epilogue.
struct MlpBlock<'a> {
up_weight: Tensor<'a>, // "up.weight" [hidden, dim]
down_weight: Tensor<'a>, // "down.weight" [dim, hidden]
}
impl<'a> MlpBlock<'a> {
fn forward(&self, api: &'a Api, x: &Tensor<'a>) -> clika_rt::Result<Tensor<'a>> {
// f_op_linear consumes its operands; the clones fund the block's
// next call.
let h = api.f_op_linear(x.shallow_clone(), self.up_weight.shallow_clone(),
api.absent(), true, RELU, 0.0, 0.0)?;
let d = api.f_op_linear(h, self.down_weight.shallow_clone(),
api.absent(), false, IDENTITY, 0.0, 0.0)?;
Ok(&d + x) // the operator tier never invalidates its operands
}
}
fn main() -> clika_rt::Result<()> {
let api = Api::load("libClikaRT.so")?;
let block = MlpBlock {
up_weight: api.tensor_full(&[8, 4], 0.1, F32)?,
down_weight: api.tensor_full(&[4, 8], 0.1, F32)?,
};
let x = api.f_tensor_ones(&[1, 4], F32, default_placement(), default_placement())?;
println!("y = {}", api.tensor_to_string(&block.forward(&api, &x)?)?);
Ok(())
}
parameters (dotted, checkpoint-shaped):
up.weight fake=1
down.weight fake=1
y = Tensor(shape=[1, 4], dtype=Float32, device=CPU, numel=4, data=[1.32, 1.32, 1.32, 1.32])
The math checks out by hand: up maps ones to 0.4 per unit, relu passes it, down sums 8 of them times 0.1 to 0.32, and the residual adds the input back, 1.32. Linear::make declares storage-free slots, so the parameters read as fake until load_state_dict binds them; block->to(device) moves the whole tree.
Run your own kernel through the runtime
A leaf custom op overrides two virtuals. output_shapes() is the shape rule: it reads the inputs' metadata (FakeTensor: symbolic dims, dtype, placement) and returns the outputs' metadata. compute() is the kernel, with one accessor pair per residence: for a plain host loop over CPU-resident tensors, inputs read through const_data_ptr() and the pre-allocated outputs write through mutable_data_ptr() (both wait for the data and refuse a device tensor); a kernel on the op's device reads device_const_data_ptr() and writes device_mutable_data_ptr(), with no host wait and the pointer ordered on the op's stream. An exception thrown inside compute() surfaces as a typed error in the caller's runtime instead of crossing the library boundary. forward hands both to dispatch(), which runs the op through the runtime: eager mode calls compute() now, a tracing scope records the op from the shape rule alone. A leaf that dispatches must be owned by a shared_ptr.
- C++
- C
- Python
- Kotlin
- Go
- Rust
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <vector>
#include "ClikaRT/clika_rt.h"
using ClikaRT::DataType;
using ClikaRT::FakeTensor;
using ClikaRT::Tensor;
namespace nn = ClikaRT::nn;
// Elementwise soft clip: y = x / (1 + |x|).
class SoftClip final : public nn::Module {
public:
static std::shared_ptr<SoftClip> make() {
return std::shared_ptr<SoftClip>(new SoftClip());
}
Tensor forward(const Tensor& x) const { return dispatch({&x, 1})[0]; }
// Shape rule: one output, same shape and dtype as the input.
std::vector<FakeTensor> output_shapes(
ClikaRT::Span<const FakeTensor> inputs) const override {
return {FakeTensor(inputs[0].shape(), inputs[0].dtype(), inputs[0].stream())};
}
// Kernel: runs on the op's stream; here the CPU path, a plain loop over
// host pointers. A device kernel (a CUDA arm, say) reads
// `device_const_data_ptr()`, writes `device_mutable_data_ptr()`, and
// launches on `this->stream().native_handle()`; the host accessors below
// wait for the data and refuse a device tensor.
void compute(ClikaRT::Span<const Tensor> inputs,
ClikaRT::Span<Tensor> outputs) const override {
const Tensor& in = inputs[0];
const float* x = static_cast<const float*>(in.const_data_ptr());
float* y = static_cast<float*>(outputs[0].mutable_data_ptr());
for (std::int64_t i = 0; i < in.numel(); ++i)
y[i] = x[i] / (1.0F + std::fabs(x[i]));
}
private:
SoftClip() = default;
};
int main() {
const std::shared_ptr<SoftClip> clip = SoftClip::make();
std::vector<float> v = {-9.0F, -1.0F, 0.0F, 1.0F, 9.0F};
const Tensor x = Tensor::from_data(v.data(), {5}, DataType::Float32);
std::printf("y = %s\n", clip->forward(x).to_string().c_str());
return 0;
}
#include <stdio.h>
#include "clika_rt/clika_rt_core.h"
/* api, check(): the bootstrap from tutorial part 1. The shape-rule and
* compute overrides are the C++ tier's custom-op entry (the C++ tab); at
* this surface a custom op composes built-in table members. */
extern const clika_rt_api* api;
extern void check(clika_rt_error* e, const char* where);
/* Elementwise soft clip: y = x / (1 + |x|). Consumes x. */
static clika_rt_tensor* softclip(clika_rt_tensor* x) {
api->tensor_retain(x); /* x feeds the abs AND the division */
clika_rt_tensor* absx = NULL;
check(api->op_abs(x, &absx), "abs");
const clika_rt_scalar_or_tensor one = {.kind = CLIKA_RT_SCALAR_OR_TENSOR_DOUBLE,
.double_value = 1.0};
clika_rt_tensor* denom = NULL;
check(api->op_add_tensor(absx, one, 1.0, CLIKA_RT_ACTIVATION_IDENTITY, &denom),
"add");
/* The embedded tensor slot is BORROWED for the call; release after. */
const clika_rt_scalar_or_tensor d = {.kind = CLIKA_RT_SCALAR_OR_TENSOR_TENSOR,
.tensor = denom};
clika_rt_tensor* y = NULL;
check(api->op_div_tensor(x, d, CLIKA_RT_ROUNDING_MODE_NONE,
CLIKA_RT_ACTIVATION_IDENTITY, &y), "div");
api->tensor_release(denom);
return y;
}
int main(void) {
const clika_rt_stream_or_device dflt = {CLIKA_RT_STREAM_OR_DEVICE_DEFAULT};
const float v[5] = {-9.0F, -1.0F, 0.0F, 1.0F, 9.0F};
clika_rt_tensor* x = NULL;
check(api->tensor_from_data(v, (const int64_t[]){5}, 1,
CLIKA_RT_DATA_TYPE_FLOAT32, dflt, dflt, &x), "from_data");
clika_rt_tensor* y = softclip(x);
char buf[256];
size_t blen = sizeof buf;
api->tensor_to_string(y, buf, &blen);
printf("y = %s\n", buf);
api->tensor_release(y);
return 0;
}
import clika_runtime as crt
import clika_runtime.nn as nn
class SoftClip(nn.Module):
"""Elementwise soft clip: y = x / (1 + |x|), composed from the built-in
operations; a custom module needs nothing beyond a forward. Writing a
custom KERNEL (your own shape rule and compute) is done through the C++
API; the C++ tab walks it."""
def forward(self, x: crt.Tensor) -> crt.Tensor:
return x / (1 + x.abs())
def main() -> None:
clip = SoftClip()
x = crt.tensor([-9.0, -1.0, 0.0, 1.0, 9.0])
print(f"y = {clip(x)}")
if __name__ == "__main__":
main()
import io.clika.runtime.ClikaRtGen
import java.nio.ByteBuffer
import java.nio.ByteOrder
// Elementwise soft clip: y = x / (1 + |x|), composed from built-in table
// members at the generated tier (the typed tier carries no abs yet).
// Writing a custom KERNEL (your own shape rule and compute) is done
// through the C++ API; the C++ tab walks it.
fun softclip(x: Long): Long {
ClikaRtGen.tensorRetain(x) // x feeds the abs AND the division
val absx = ClikaRtGen.opAbs(x)
val denom = ClikaRtGen.opAddTensor(absx,
ClikaRtGen.SCALAR_OR_TENSOR_DOUBLE, 1.0, 0L, 0L, 1.0,
ClikaRtGen.ACTIVATION_IDENTITY)
// The embedded tensor slot is borrowed for the call; release after.
val y = ClikaRtGen.opDivTensor(x,
ClikaRtGen.SCALAR_OR_TENSOR_TENSOR, 0.0, 0L, denom,
ClikaRtGen.ROUNDING_MODE_NONE, ClikaRtGen.ACTIVATION_IDENTITY)
ClikaRtGen.tensorRelease(denom)
return y
}
fun main() {
ClikaRtGen.load()
val bytes = ByteArray(5 * 4)
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder())
floatArrayOf(-9f, -1f, 0f, 1f, 9f).forEachIndexed { i, v -> bb.putFloat(i * 4, v) }
val x = ClikaRtGen.tensorFromData(bytes, longArrayOf(5),
ClikaRtGen.DATA_TYPE_FLOAT32,
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L,
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L)
val y = softclip(x)
println("y = ${ClikaRtGen.tensorToString(y)}")
ClikaRtGen.tensorRelease(y)
}
package main
import (
"fmt"
"log"
"github.com/Clika/clika_runtime/bindings/go/clikart"
)
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
// The shape-rule and compute overrides are the C++ tier's custom-op entry
// (the C++ tab); at this surface a custom op composes built-in table members.
// Elementwise soft clip: y = x / (1 + |x|). Consumes x.
func softclip(api *clikart.Api, x *clikart.Tensor) *clikart.Tensor {
defer x.Release() // the wrappers are non-consuming: x is ours to release
absx := must(api.OpAbs(x)) // x feeds the abs AND the division
defer absx.Release()
one := clikart.ScalarOrTensor{Kind: clikart.ScalarOrTensorDouble, DoubleValue: 1}
denom := must(api.OpAddTensor(absx, one, 1.0, clikart.ActivationIdentity))
defer denom.Release() // the embedded tensor slot is borrowed for the call
d := clikart.ScalarOrTensor{Kind: clikart.ScalarOrTensorTensor, Tensor: denom}
return must(api.OpDivTensor(x, d, clikart.RoundingModeNone, clikart.ActivationIdentity))
}
func main() {
api := must(clikart.Load("libClikaRT.so"))
x := must(clikart.TensorOf(api, []float32{-9, -1, 0, 1, 9}, []int64{5}))
y := softclip(api, x)
fmt.Printf("y = %s\n", y)
y.Release()
}
use clika_rt::{sys, Api, IntoOperand, Tensor};
const IDENTITY: sys::clika_rt_activation = sys::clika_rt_activation_CLIKA_RT_ACTIVATION_IDENTITY;
const ROUNDING_NONE: sys::clika_rt_rounding_mode =
sys::clika_rt_rounding_mode_CLIKA_RT_ROUNDING_MODE_NONE;
// The shape-rule and compute overrides are the C++ tier's custom-op entry
// (the C++ tab); at this surface a custom op composes built-in table members.
// Elementwise soft clip: y = x / (1 + |x|). Consumes x.
fn softclip<'a>(api: &'a Api, x: Tensor<'a>) -> clika_rt::Result<Tensor<'a>> {
let absx = api.f_op_abs(x.shallow_clone())?; // x feeds the abs AND the division
let denom = api.f_op_add_tensor(absx, 1.0.into_operand(), 1.0, IDENTITY)?;
// The embedded tensor slot is BORROWED for the call; denom drops after.
api.f_op_div_tensor(x, (&denom).into_operand(), ROUNDING_NONE, IDENTITY)
}
fn main() -> clika_rt::Result<()> {
let api = Api::load("libClikaRT.so")?;
let v: [f32; 5] = [-9.0, -1.0, 0.0, 1.0, 9.0];
let x = api.tensor_from_slice(&v, &[5])?;
let y = softclip(&api, x)?;
println!("y = {}", api.tensor_to_string(&y)?);
Ok(())
}
y = Tensor(shape=[5], dtype=Float32, device=CPU, numel=5, data=[-0.9, -0.5, 0, 0.5, 0.9])
The runtime allocated the output, placed it beside the input, and ran the kernel; the op composes with everything else (ops:: calls before and after, on_complete, the scopes) because it went through dispatch like a built-in.
Launching on an accelerator
compute() runs on whatever device its tensors live on. Inside it, this->stream() is the op's stream: stream().device() says where you are, and stream().native_handle() is the backend's queue as an opaque pointer, cudaStream_t on CUDA (nullptr on the CPU backend, which has no device queue). The launch pattern, from the nn/module.h contract:
Stream s = this->stream(); // the op's stream
auto cu = static_cast<cudaStream_t>(s.native_handle()); // the CUDA queue
const float* q = static_cast<const float*>(inputs[0].device_const_data_ptr());
float* o = static_cast<float*>(outputs[0].device_mutable_data_ptr());
my_kernel<<<grid, block, smem, cu>>>(q, ..., o); // your kernel
The device accessors return the device address with no host wait, ordered on the op's stream, so the launch above is valid as written; the host accessors (const_data_ptr(), mutable_data_ptr()) are for CPU-resident tensors and would refuse here.
Size the launch from get_device_properties(stream().device()), and take stream-ordered scratch from stream().allocate(shape, dtype), which recycles safely on that stream only. The handle is owned by the runtime: never destroy it, and keep the Stream alive while using it. The bundle's flash_attention example is the complete worked case, a hand-written CUDA attention kernel dispatched through this exact interface.
A rule of thumb for choosing the shape: compose built-ins when the math decomposes into ops:: (the runtime already fuses and places them); write a leaf when you have a kernel the library does not, and keep its output_shapes honest, because tracing trusts it without running compute.