Tensors and operators
Part 1 made one tensor; this part covers the compute vocabulary you will use everywhere: building tensors, transforming them with ops::, and reading values back. Same project as part 1; only main.cpp changes.
The program
- C++
- C
- Python
- Kotlin
- Go
- Rust
#include <cstdio>
#include <vector>
#include <ClikaRT/clika_rt.h>
using ClikaRT::DataType;
using ClikaRT::Tensor;
namespace ops = ClikaRT::ops;
int main() {
// Factories build tensors from a shape and a dtype.
const Tensor threes = Tensor::full({2, 3}, 3.0, DataType::Float32);
// from_data copies host bytes into a tensor of the given shape + dtype.
const float host[6] = {0, 1, 2, 3, 4, 5};
const Tensor x = Tensor::from_data(host, {2, 3}, DataType::Float32);
// ops:: free functions return their result directly. Elementwise math
// broadcasts, and a scalar binds wherever a tensor does.
const Tensor y = ops::add(ops::mul(x, 2.0), threes); // y = 2x + 3
// Shape ops are views: the same bytes behind a new layout, no copy.
const Tensor yt = ops::permute(y, {1, 0}); // 2x3 -> 3x2
const Tensor flat = ops::reshape(y, {6});
// Tensor carries operator and method sugar over the same ops, so a
// chain reads like the math it computes.
const Tensor m = (y - 3.0).abs().max();
// Reading back: to_string() for a summary, item<T>() for the single
// element of a one-element tensor, item_as_vec<T>() for a 0-D/1-D tensor.
std::printf("y = %s\n", y.to_string().c_str());
std::printf("y^T = %s\n", yt.to_string().c_str());
std::printf("max|y - 3| = %.0f\n", m.item<float>());
const std::vector<float> v = flat.item_as_vec<float>();
std::printf("flat = [");
for (std::size_t i = 0; i < v.size(); ++i) std::printf("%s%.0f", i ? ", " : "", v[i]);
std::printf("]\n");
return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>
#include "clika_rt/clika_rt_core.h"
/* The api-table bootstrap shared by every C sample: dlopen the runtime,
* handshake at the header's ABI version and api-table layout fingerprint,
* exit loudly on any runtime error. */
static const clika_rt_api* api;
static void bootstrap(void) {
void* so = dlopen("libClikaRT.so", RTLD_NOW | RTLD_GLOBAL);
if (so == NULL) { fprintf(stderr, "dlopen: %s\n", dlerror()); exit(1); }
const clika_rt_api* (*get_api)(uint32_t) =
(const clika_rt_api* (*)(uint32_t))dlsym(so, "clika_rt_get_api");
api = get_api != NULL ? get_api(CLIKA_RT_ABI_VERSION) : NULL;
if (api == NULL) { fprintf(stderr, "clika_rt_get_api: handshake failed\n"); exit(1); }
if (strcmp(api->layout_fingerprint, CLIKA_RT_ABI_LAYOUT_FINGERPRINT) != 0) {
fprintf(stderr, "clika_rt_get_api: the runtime's api-table layout does not match "
"this header; rebuild against the runtime's header\n");
exit(1);
}
}
static void check(clika_rt_error* e, const char* where) {
if (e == NULL) return;
char msg[512];
size_t len = sizeof msg;
if (api->error_message(e, msg, &len) != CLIKA_RT_STATUS_OK) msg[0] = '\0';
fprintf(stderr, "%s failed: %s\n", where, msg);
api->error_free(e);
exit(1);
}
int main(void) {
bootstrap();
const clika_rt_stream_or_device dflt = {CLIKA_RT_STREAM_OR_DEVICE_DEFAULT};
/* Factories build tensors from a shape and a dtype. */
const int64_t shape[2] = {2, 3};
clika_rt_tensor* threes = NULL;
check(api->tensor_full(shape, 2, 3.0, CLIKA_RT_DATA_TYPE_FLOAT32,
dflt, dflt, &threes), "tensor_full");
/* from_data copies host bytes into a tensor of the given shape + dtype. */
const float host[6] = {0, 1, 2, 3, 4, 5};
clika_rt_tensor* x = NULL;
check(api->tensor_from_data(host, shape, 2, CLIKA_RT_DATA_TYPE_FLOAT32,
dflt, dflt, &x), "tensor_from_data");
/* Ops return their result directly and CONSUME their tensor operand (the
* donation law); scalar and borrowed right-hand sides stay live. */
clika_rt_scalar_or_tensor two = {.kind = CLIKA_RT_SCALAR_OR_TENSOR_DOUBLE, .double_value = 2.0};
clika_rt_tensor* x2 = NULL;
check(api->op_mul_tensor(x, two, CLIKA_RT_ACTIVATION_IDENTITY, &x2), "op_mul");
clika_rt_scalar_or_tensor rhs = {.kind = CLIKA_RT_SCALAR_OR_TENSOR_TENSOR, .tensor = threes}; /* threes: borrowed */
clika_rt_tensor* y = NULL; /* y = 2x + 3 */
check(api->op_add_tensor(x2, rhs, 1.0, CLIKA_RT_ACTIVATION_IDENTITY, &y), "op_add");
/* Shape ops are views: the same bytes behind a new layout, no copy.
* y is reused below, so each donation gets its own retained reference. */
const int64_t perm[2] = {1, 0};
clika_rt_tensor* yt = NULL;
api->tensor_retain(y);
check(api->op_permute(y, perm, 2, &yt), "op_permute"); /* 2x3 -> 3x2 */
const clika_rt_index_bound six = {.kind = CLIKA_RT_INDEX_BOUND_INT, .int_value = 6};
clika_rt_tensor* flat = NULL;
api->tensor_retain(y);
check(api->op_reshape(y, &six, 1, &flat), "op_reshape");
/* The chain the C++ sugar spells as (y - 3.0).abs().max(). */
clika_rt_scalar_or_tensor three = {.kind = CLIKA_RT_SCALAR_OR_TENSOR_DOUBLE, .double_value = 3.0};
clika_rt_tensor* d = NULL;
api->tensor_retain(y);
check(api->op_sub_tensor(y, three, 1.0, CLIKA_RT_ACTIVATION_IDENTITY, &d), "op_sub");
clika_rt_tensor* absd = NULL;
check(api->op_abs(d, &absd), "op_abs");
clika_rt_tensor* m = NULL;
check(api->op_amax(absd, NULL, 0, 0, &m), "op_amax");
/* Reading back: tensor_to_string for a summary, const_data_ptr for
* values (a host read synchronizes first). */
char buf[512];
size_t blen = sizeof buf;
api->tensor_to_string(y, buf, &blen);
printf("y = %s\n", buf);
blen = sizeof buf;
api->tensor_to_string(yt, buf, &blen);
printf("y^T = %s\n", buf);
const void* pm = NULL;
check(api->tensor_const_data_ptr(m, &pm), "const_data_ptr");
printf("max|y - 3| = %.0f\n", *(const float*)pm);
const void* pf = NULL;
check(api->tensor_const_data_ptr(flat, &pf), "const_data_ptr");
printf("flat = [");
for (int i = 0; i < 6; ++i) printf("%s%.0f", i ? ", " : "", ((const float*)pf)[i]);
printf("]\n");
api->tensor_release(m);
api->tensor_release(flat);
api->tensor_release(yt);
api->tensor_release(y);
api->tensor_release(threes);
return 0;
}
import clika_runtime as crt
def main() -> None:
# Factories build tensors directly; dtypes are attributes on the package.
threes = crt.full((2, 3), 3.0)
x = crt.arange(6, dtype=crt.float32).reshape(2, 3)
# Elementwise math reads as operators; scalars broadcast.
y = 2 * x + threes # y = 2x + 3
# Shape methods are views: the same bytes behind a new layout, no copy.
yt = y.permute(1, 0) # 2x3 -> 3x2
flat = y.reshape(-1)
# Method chains: |y - 3| reduced to its global maximum. amax is the
# global reduce; max(dim) is the dim-wise form returning values and
# indices.
m = (y - 3).abs().amax()
# Reading back: repr(t) is the summary, item() reads a scalar, and
# numpy() on a CPU tensor is a zero-copy view when an array is wanted.
print(f"y = {y}")
print(f"y^T = {yt}")
print(f"max|y - 3| = {m.item():.0f}")
v = flat.numpy()
print("flat = [" + ", ".join(f"{e:.0f}" for e in v) + "]")
if __name__ == "__main__":
main()
import io.clika.runtime.ClikaRt
import io.clika.runtime.F
import io.clika.runtime.Tensors
import io.clika.runtime.minus
import io.clika.runtime.plus
import io.clika.runtime.summary
import io.clika.runtime.times
fun main() {
ClikaRt.load()
// Factories build tensors from data or a shape; float32 is the default
// dtype, and Tensors.of copies host values in.
val threes = Tensors.full(longArrayOf(2, 3), 3.0)
val x = Tensors.of(floatArrayOf(0f, 1f, 2f, 3f, 4f, 5f), longArrayOf(2, 3))
// Operator extensions carry the math. Every operator returns a fresh
// tensor and leaves its operands live; release the tensors you name.
val scaled = x * 2.0
val y = scaled + threes // y = 2x + 3
// The typed Kotlin tier carries no view methods (reshape, permute) yet;
// shapes are fixed at the factory. Reductions ride F: y - 3 is 2x here
// (y is at least 3), so its global max is max|y - 3| = 10.
val d = y - 3.0
val m = F.amax(d)
// Reading back: summary() renders shape, dtype, device and the values.
println("y = ${y.summary()}")
println("max|y - 3| = ${m.summary()}")
listOf(x, threes, scaled, y, d, m).forEach { it.release() }
}
//go:build ignore
package main
import (
"fmt"
"log"
"strings"
"unsafe"
"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
}
func main() {
api := must(clikart.Load("libClikaRT.so"))
// Factories build tensors from a shape and a dtype.
threes := must(api.TensorFull([]int64{2, 3}, 3.0, clikart.Float32))
// TensorOf copies host values into a tensor of the matching dtype.
x := must(clikart.TensorOf(api, []float32{0, 1, 2, 3, 4, 5}, []int64{2, 3}))
// Ops return the value directly and are non-consuming (the retain rides
// inside every wrapper); an operand slot takes a scalar or a tensor.
x2 := must(x.MulTensor(clikart.ScalarOrTensor{
Kind: clikart.ScalarOrTensorDouble, DoubleValue: 2.0,
}, clikart.ActivationIdentity))
y := must(x2.AddTensor(clikart.ScalarOrTensor{
Kind: clikart.ScalarOrTensorTensor, Tensor: threes,
}, 1.0, clikart.ActivationIdentity)) // y = 2x + 3
// Shape ops are views: the same bytes behind a new layout, no copy.
yt := must(y.Permute([]int64{1, 0})) // 2x3 -> 3x2
flat := must(y.Reshape([]clikart.IndexBound{
{Kind: clikart.IndexBoundInt, IntValue: 6},
}))
// The chain the C++ sugar spells as (y - 3.0).abs().max().
d := must(y.SubTensor(clikart.ScalarOrTensor{
Kind: clikart.ScalarOrTensorDouble, DoubleValue: 3.0,
}, 1.0, clikart.ActivationIdentity))
m := must(clikart.Amax(must(d.Abs()), nil, false))
// Reading back: String() is the summary; a data pointer read hands back
// the tensor's own buffer (a host read synchronizes first).
fmt.Printf("y = %s\n", y)
fmt.Printf("y^T = %s\n", yt)
fmt.Printf("max|y - 3| = %.0f\n", *(*float32)(must(api.TensorConstDataPtr(m))))
v := unsafe.Slice((*float32)(must(api.TensorConstDataPtr(flat))), 6)
out := make([]string, len(v))
for i, e := range v {
out[i] = fmt.Sprintf("%.0f", e)
}
fmt.Printf("flat = [%s]\n", strings.Join(out, ", "))
}
use clika_rt::{sys, Api};
const F32: sys::clika_rt_data_type = sys::clika_rt_data_type_CLIKA_RT_DATA_TYPE_FLOAT32;
fn main() -> clika_rt::Result<()> {
// Load the runtime and handshake at the crate's ABI version.
let api = Api::load("libClikaRT.so")?;
// Factories: tensor_from_slice copies host values in (the dtype rides
// the element type); tensor_full builds from a shape, a value and a dtype.
let host: [f32; 6] = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
let x = api.tensor_from_slice(&host, &[2, 3])?;
let threes = api.tensor_full(&[2, 3], 3.0, F32)?;
// Operators read as math and never invalidate their operands. Explicit
// calls (the `f_*` Result forms) consume their tensor args instead;
// a kept handle is a `shallow_clone()`.
let y = &x * 2.0 + &threes; // y = 2x + 3
// Shape ops are views: the same bytes behind a new layout, no copy.
let yt = y.shallow_clone().f_permute(&[1, 0])?; // 2x3 -> 3x2
// reshape's dims ride index bounds (a sized dim, or one a tensor provides).
let six = sys::clika_rt_index_bound {
kind: sys::clika_rt_index_bound_kind_CLIKA_RT_INDEX_BOUND_INT as i32,
int_value: 6,
tensor: std::ptr::null_mut(),
};
let flat = y.shallow_clone().f_reshape(&[six])?;
// The chain the C++ sugar spells as (y - 3.0).abs().max().
let m = (&y - 3.0).f_abs()?.f_amax(&[], false)?;
// Reading back: tensor_to_string for a summary, the const data pointer
// for raw values (m is a CPU tensor here).
println!("y = {}", api.tensor_to_string(&y)?);
println!("y^T = {}", api.tensor_to_string(&yt)?);
println!("flat = {}", api.tensor_to_string(&flat)?);
let p = api.f_tensor_const_data_ptr(&m)?;
println!("max|y - 3| = {:.0}", unsafe { *(p as *const f32) });
Ok(())
}
y = Tensor(shape=[2, 3], dtype=Float32, device=CPU, numel=6, data=[3, 5, 7, 9, 11, 13])
y^T = Tensor(shape=[3, 2], dtype=Float32, device=CPU, numel=6, data=[3, 9, 5, 11, 7, 13])
max|y - 3| = 10
flat = [3, 5, 7, 9, 11, 13]
Dtypes
DataType names the element format. The everyday set is Float32, Float16, BFloat16, Float64, the signed and unsigned integer widths (Int8 ... Int64, UInt8 ... UInt64) and Bool; beyond it are the sub-byte integers (Int4, Int2) and the narrow float families (FP8, FP6, FP4) that quantized models use. ClikaRT::data_type_name(t.dtype()) prints one; t.to(DataType::Float16) casts. Factories take the dtype explicitly. Nothing defaults behind your back.
Copies are handles
A Tensor copy is a cheap reference to the same underlying data, not a deep copy. Writes through one copy are visible through the others, and the data stays alive as long as any copy does. For independent data, build a fresh tensor (a factory or from_data, which copies the source bytes and does not retain the pointer).
The ops:: library
Every operator is a free function in ClikaRT::ops, taking tensors and returning a tensor: elementwise math, reductions, matrix products, convolutions, attention, indexing. This is the operator set a model needs. Shape ops (reshape, permute, narrow) return views, metadata over the source's bytes, no copy. For the common ones, Tensor adds sugar. Arithmetic operators (y - 3.0) and chainable methods (.abs(), .relu(), .max(), .matmul(...)) forward to the same ops:: functions with the same error contract as part 1. The API reference documents every operator.
Reading values back
Three host-side reads, in increasing weight. to_string() is an infallible summary (shape, dtype, device, first values) for logging. item<T>() reads the single element of a one-element tensor, typically a reduction result. item_as_vec<T>() reads all elements of a 0-D or 1-D tensor, contiguous and dtype-matched. A wrong T or a wrong element count raises ClikaRT::Error. Every one of these reads is also a synchronization point; part 3 explains what that means.
Next: part 3, devices, and the asynchrony you have been using without noticing.