Wrap existing memory without copying
Your data already sits in memory that some other part of the program owns: a decoder's output buffer, an arena, a mapped file, another library's array. Tensor::from_blob wraps that memory as a tensor with no copy; the tensor's data pointer is your pointer. What needs deciding is ownership, and the API makes the two contracts explicit:
- No deleter passed: borrowed. You keep ownership. The buffer must stay alive, and its layout unchanged, for as long as the tensor or any view of it is in use. Writes through the buffer are visible through the tensor and the other way around; it is the same memory.
- Deleter passed: adopted. The tensor takes ownership and calls
deleter(data)once, when the last reference drops.
The C samples abbreviate the api-table bootstrap that tutorial part 1 shows in full. The Python variant borrows a numpy array's memory; the Kotlin arm borrows a direct java.nio.ByteBuffer, the one JVM buffer with a stable native address (a heap buffer is refused with a readable error).
Borrow a buffer and compute on it
- 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() {
std::vector<float> buf(8, 1.0F); // memory the application owns
const Tensor view = Tensor::from_blob(buf.data(), {8}, DataType::Float32);
std::printf("shares memory: %s\n",
view.const_data_ptr() == buf.data() ? "yes (no copy)" : "no");
std::printf("sum = %g\n", ops::sum(view).item<float>());
buf[0] = 100.0F; // write through the buffer...
std::printf("sum after buf[0] = 100: %g\n", ops::sum(view).item<float>());
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). */
extern const clika_rt_api* api;
extern void check(clika_rt_error* e, const char* where);
static float read_sum(clika_rt_tensor* view) {
api->tensor_retain(view);
clika_rt_tensor* s = NULL;
check(api->op_sum(view, NULL, 0, 0, CLIKA_RT_DATA_TYPE_UNDEFINED, &s), "sum");
const void* p = NULL;
check(api->tensor_const_data_ptr(s, &p), "data_ptr");
const float value = *(const float*)p;
api->tensor_release(s);
return value;
}
int main(void) {
float buf[8]; /* memory the application owns */
for (int i = 0; i < 8; ++i) buf[i] = 1.0F;
/* No borrow surface here: from_blob is the C++ tier's contract. This
* surface COPIES the bytes in (tensor_from_data); the tensor owns its
* own memory from then on. */
const clika_rt_stream_or_device dflt = {CLIKA_RT_STREAM_OR_DEVICE_DEFAULT};
clika_rt_tensor* t = NULL;
check(api->tensor_from_data(buf, (const int64_t[]){8}, 1,
CLIKA_RT_DATA_TYPE_FLOAT32, dflt, dflt, &t),
"from_data");
const void* p = NULL;
check(api->tensor_const_data_ptr(t, &p), "data_ptr");
printf("shares memory: %s\n", p == buf ? "yes" : "no (copied in)");
printf("sum = %g\n", read_sum(t));
buf[0] = 100.0F; /* the copy does NOT alias the source... */
printf("sum after buf[0] = 100: %g\n", read_sum(t));
api->tensor_release(t);
return 0;
}
import numpy as np
import clika_runtime as crt
def main() -> None:
# Borrow: from_numpy wraps the array's own memory, no copy. The array
# and the tensor see the same bytes, mutation aliases both ways, and
# the tensor keeps the array alive. The array must be writable and
# contiguous; the refusals name the fix.
buf = np.zeros(4, dtype=np.float32)
view = crt.from_numpy(buf)
buf[0] = 100.0
print(f"sum after buf[0] = 100: {view.sum().item():g}")
# The other direction: numpy() on a CPU tensor is a zero-copy view of
# the tensor's memory; a device tensor asks you to move it first
# (t.to('cpu').numpy()).
t = crt.ones(2, 2)
arr = t.numpy()
print(f"shared bytes: {arr.sum():g}")
# A non-contiguous array does not borrow; the error names the fix.
try:
crt.from_numpy(np.zeros((4, 4), dtype=np.float32)[:, ::2])
except TypeError as e:
print(f"non-contiguous refused: {e}")
if __name__ == "__main__":
main()
crt.tensor(array) stays the copying entry when an independent tensor is
wanted.
import io.clika.runtime.ClikaRtGen
import java.nio.ByteBuffer
import java.nio.ByteOrder
fun main() {
ClikaRtGen.load()
// Memory the application owns: a DIRECT buffer, whose native address is
// stable for as long as the buffer object lives.
val buf = ByteBuffer.allocateDirect(8 * Float.SIZE_BYTES).order(ByteOrder.nativeOrder())
for (i in 0 until 8) buf.putFloat(i * Float.SIZE_BYTES, 1f)
// No deleter: borrowed. The buffer must outlive every view of it.
val view = ClikaRtGen.tensorFromBlobContiguous(buf, longArrayOf(8),
ClikaRtGen.DATA_TYPE_FLOAT32,
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L,
null,
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L)
fun sum(): Float {
ClikaRtGen.tensorRetain(view) // sum consumes its operand
val s = ClikaRtGen.opSum(view, longArrayOf(), false,
ClikaRtGen.DATA_TYPE_UNDEFINED)
val value = ClikaRtGen.tensorItemF32(s)
ClikaRtGen.tensorRelease(s)
return value
}
println("sum = ${sum()}")
buf.putFloat(0, 100f) // write through the buffer...
println("sum after buf[0] = 100: ${sum()}") // ...and the view sees it
ClikaRtGen.tensorRelease(view)
}
package main
import (
"fmt"
"log"
"runtime"
"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"))
buf := make([]float32, 8) // memory the application owns
for i := range buf {
buf[i] = 1
}
var pin runtime.Pinner // the GC must not move borrowed memory
pin.Pin(&buf[0])
defer pin.Unpin()
// No deleter: borrowed. The buffer must outlive every view of it.
view := must(api.TensorFromBlob(unsafe.Pointer(&buf[0]), []int64{8}, clikart.Float32))
sum := func() float32 {
s := must(clikart.Sum(view, nil, false))
return *(*float32)(must(api.TensorConstDataPtr(s)))
}
fmt.Printf("sum = %g\n", sum())
buf[0] = 100 // write through the buffer...
fmt.Printf("sum after buf[0] = 100: %g\n", sum())
}
use clika_rt::{sys, Api, Tensor};
const DTYPE_UNSET: sys::clika_rt_data_type = sys::clika_rt_data_type_CLIKA_RT_DATA_TYPE_UNDEFINED;
fn sum(api: &Api, view: &Tensor<'_>) -> clika_rt::Result<f32> {
api.f_tensor_item_f32(&api.f_op_sum(view.shallow_clone(), &[], false, DTYPE_UNSET)?)
}
fn main() -> clika_rt::Result<()> {
let api = Api::load("libClikaRT.so")?;
let mut buf = [1.0f32; 8]; // memory the application owns
let base = buf.as_ptr() as *const std::ffi::c_void;
// The borrow form ties the view's lifetime to the slice's: no copy, and
// the slice is unreachable until the view drops.
let view = api.f_tensor_from_blob(&mut buf, &[8])?;
println!("shares memory: {}",
if api.f_tensor_const_data_ptr(&view)? == base { "yes (no copy)" } else { "no" });
println!("sum = {}", sum(&api, &view)?);
// Writes through the buffer are visible through a view over it; the
// borrow ends before buf mutates, and a fresh view reads the new bytes.
drop(view);
buf[0] = 100.0;
let view = api.f_tensor_from_blob(&mut buf, &[8])?;
println!("sum after buf[0] = 100: {}", sum(&api, &view)?);
Ok(())
}
shares memory: yes (no copy)
sum = 8
sum after buf[0] = 100: 107
The borrow contract in one sentence: the runtime never reuses or overwrites borrowed memory, and in exchange you guarantee it outlives every tensor that sees it. A vector that reallocates (or a stack buffer that goes out of scope) under a live view is the bug this contract exists to name.
Hand ownership over with a deleter
When the producer wants to fire and forget, pass a deleter. The tensor (and every tensor computed from it) keeps the buffer alive; the deleter runs exactly once, when the last reference drops, and it runs in your runtime: an exception it throws never crosses the library boundary. It also frees the runtime to reuse the buffer as scratch, which the borrow contract forbids.
- C++
- C
- Python
- Kotlin
- Go
- Rust
#include <cstdio>
#include <cstdlib>
#include "ClikaRT/clika_rt.h"
using ClikaRT::DataType;
using ClikaRT::Tensor;
namespace ops = ClikaRT::ops;
int main() {
float* buf = static_cast<float*>(std::malloc(8 * sizeof(float)));
for (int i = 0; i < 8; ++i) buf[i] = static_cast<float>(i);
{
const Tensor adopted = Tensor::from_blob(
buf, {8}, DataType::Float32, {},
[](void* p) { std::printf("deleter: buffer released\n"); std::free(p); });
std::printf("mean = %g\n", ops::mean(adopted).item<float>());
std::printf("leaving the tensor's scope...\n");
}
std::printf("scope closed\n");
return 0;
}
The api table carries the handover: tensor_from_blob_contiguous takes the buffer, its shape and dtype, and the deleter as the C callback triple (the function pointer, a userdata pointer passed to it first, and a finalizer the runtime calls exactly once when the registration dies); a NULL deleter borrows instead. The Go, Rust and Kotlin tabs show the member through their bindings. The copying tensor_from_data remains the form for bytes the runtime should own from the start.
Handing ownership over with a deleter is part of the C++ API today; the
C++ tab shows it. The python borrow keeps the ARRAY as the owner:
crt.from_numpy(array) holds the array alive for the tensor's lifetime,
so no deleter changes hands.
import io.clika.runtime.ClikaRtGen
import io.clika.runtime.VoidCallback
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
fun main() {
ClikaRtGen.load()
// A DIRECT buffer: the one JVM buffer with a stable native address.
val buf = ByteBuffer.allocateDirect(8 * Float.SIZE_BYTES).order(ByteOrder.nativeOrder())
for (i in 0 until 8) buf.putFloat(i * Float.SIZE_BYTES, i.toFloat())
// Deleter passed: adopted. It runs once, when the last reference drops,
// on whichever thread that happens; the latch lets main see it.
val released = CountDownLatch(1)
val adopted = ClikaRtGen.tensorFromBlobContiguous(buf, longArrayOf(8),
ClikaRtGen.DATA_TYPE_FLOAT32,
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L,
VoidCallback {
println("deleter: buffer released")
released.countDown()
},
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L)
ClikaRtGen.tensorRetain(adopted) // mean consumes its operand
val m = ClikaRtGen.opMean(adopted, longArrayOf(), false,
ClikaRtGen.DATA_TYPE_UNDEFINED)
println("mean = ${ClikaRtGen.tensorItemF32(m)}")
println("releasing the last reference...")
ClikaRtGen.tensorRelease(m)
ClikaRtGen.tensorRelease(adopted)
released.await()
println("scope closed")
}
package main
import (
"fmt"
"log"
"runtime"
"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"))
buf := make([]float32, 8)
for i := range buf {
buf[i] = float32(i)
}
var pin runtime.Pinner
pin.Pin(&buf[0])
// Deleter passed: adopted. It runs once, when the last reference drops.
adopted := must(api.TensorFromBlobOwned(unsafe.Pointer(&buf[0]), []int64{8},
clikart.Float32, func() {
fmt.Println("deleter: buffer released")
pin.Unpin()
}))
m := must(clikart.Mean(adopted, nil, false))
fmt.Printf("mean = %g\n", *(*float32)(must(api.TensorConstDataPtr(m))))
fmt.Println("releasing the last reference...")
m.Release()
adopted.Release()
fmt.Println("scope closed")
}
use clika_rt::{sys, Api};
const DTYPE_UNSET: sys::clika_rt_data_type = sys::clika_rt_data_type_CLIKA_RT_DATA_TYPE_UNDEFINED;
fn main() -> clika_rt::Result<()> {
let api = Api::load("libClikaRT.so")?;
let buf: Vec<f32> = (0..8).map(|i| i as f32).collect();
// The adopt form takes ownership of the allocation (no copy); the release
// hook runs once, when the last reference goes away, and the Vec is
// dropped right after it.
let adopted = api.f_tensor_from_blob_owned(buf, &[8], || {
println!("deleter: buffer released");
})?;
// The reduction reads through a second handle, so `adopted` stays the
// last reference and its drop below is what releases the buffer.
let m = api.f_op_mean(adopted.shallow_clone(), &[], false, DTYPE_UNSET)?;
println!("mean = {}", api.f_tensor_item_f32(&m)?);
println!("releasing the last reference...");
drop(adopted);
println!("scope closed");
Ok(())
}
mean = 3.5
leaving the tensor's scope...
deleter: buffer released
scope closed
The deleter must not throw (a throw is swallowed). Adoption is the right contract at module boundaries: the producer allocates, the consumer wraps and forgets the allocation ever existed.
Wrap non-contiguous memory with strides
The strided overload views memory that is not laid out contiguously, without rearranging a byte. Strides are in elements, one per dimension. A worked case: cropping a region of interest out of a pitched image buffer, the layout every camera API and GPU readback hands you (rows padded to a pitch wider than the image).
- C++
- C
- Python
- Kotlin
- Go
- Rust
#include <cstdint>
#include <cstdio>
#include <vector>
#include "ClikaRT/clika_rt.h"
using ClikaRT::DataType;
using ClikaRT::Tensor;
namespace ops = ClikaRT::ops;
int main() {
// A 4x6 single-channel image, row-major. buf[r][c] = r*10 + c.
constexpr std::int64_t kPitch = 6;
std::vector<float> buf(4 * kPitch);
for (std::int64_t r = 0; r < 4; ++r)
for (std::int64_t c = 0; c < kPitch; ++c) buf[r * kPitch + c] = (float)(r * 10 + c);
// The 2x3 region starting at row 1, column 2: shape {2, 3}, and the
// ORIGINAL row pitch as the row stride. No pixel is copied.
const Tensor roi = Tensor::from_blob(buf.data() + 1 * kPitch + 2,
{2, 3}, {kPitch, 1}, DataType::Float32);
std::printf("roi = %s\n", roi.to_string().c_str());
std::printf("sum = %g (12+13+14+22+23+24 = 108)\n", ops::sum(roi).item<float>());
return 0;
}
The api table carries tensor_from_blob_strided: the element strides ride beside the shape, one per dimension, and the deleter slot follows the contiguous form's rules (NULL borrows). The Go, Rust and Kotlin tabs show the member through their bindings; copying the region's bytes with tensor_from_data remains the alternative when the source will not outlive the view.
Wrapping strided memory with explicit strides is part of the C++ API
today; the C++ tab shows it. crt.from_numpy takes a C-contiguous
array; a strided source enters through np.ascontiguousarray (the
refusal names it).
import io.clika.runtime.ClikaRtGen
import java.nio.ByteBuffer
import java.nio.ByteOrder
fun main() {
ClikaRtGen.load()
// A 4x6 single-channel image, row-major, in a DIRECT buffer.
// buf[r][c] = r*10 + c.
val pitch = 6
val buf = ByteBuffer.allocateDirect(4 * pitch * Float.SIZE_BYTES).order(ByteOrder.nativeOrder())
for (r in 0 until 4) {
for (c in 0 until pitch) {
buf.putFloat((r * pitch + c) * Float.SIZE_BYTES, (r * 10 + c).toFloat())
}
}
// The 2x3 region starting at row 1, column 2: a slice positioned at that
// element, shape {2, 3}, and the ORIGINAL row pitch as the row stride.
// No pixel is copied; the slice shares the image's memory.
buf.position((1 * pitch + 2) * Float.SIZE_BYTES)
val region = buf.slice().order(ByteOrder.nativeOrder())
val roi = ClikaRtGen.tensorFromBlobStrided(region, longArrayOf(2, 3),
longArrayOf(pitch.toLong(), 1), ClikaRtGen.DATA_TYPE_FLOAT32,
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L,
null,
ClikaRtGen.STREAM_OR_DEVICE_DEFAULT, 0, 0, 0L)
println("roi = ${ClikaRtGen.tensorToString(roi)}")
val s = ClikaRtGen.opSum(roi, longArrayOf(), false,
ClikaRtGen.DATA_TYPE_UNDEFINED) // consumes roi
println("sum = ${ClikaRtGen.tensorItemF32(s)} (12+13+14+22+23+24 = 108)")
ClikaRtGen.tensorRelease(s)
}
package main
import (
"fmt"
"log"
"runtime"
"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"))
// A 4x6 single-channel image, row-major. buf[r][c] = r*10 + c.
const pitch = 6
buf := make([]float32, 4*pitch)
for r := 0; r < 4; r++ {
for c := 0; c < pitch; c++ {
buf[r*pitch+c] = float32(r*10 + c)
}
}
var pin runtime.Pinner
pin.Pin(&buf[0])
defer pin.Unpin()
// The 2x3 region starting at row 1, column 2: shape {2, 3}, and the
// ORIGINAL row pitch as the row stride. No pixel is copied.
roi := must(api.TensorFromBlobStrided(unsafe.Pointer(&buf[1*pitch+2]),
[]int64{2, 3}, []int64{pitch, 1}, clikart.Float32))
fmt.Printf("roi = %s\n", roi)
s := must(clikart.Sum(roi, nil, false))
fmt.Printf("sum = %g (12+13+14+22+23+24 = 108)\n",
*(*float32)(must(api.TensorConstDataPtr(s))))
}
use clika_rt::{sys, Api};
const DTYPE_UNSET: sys::clika_rt_data_type = sys::clika_rt_data_type_CLIKA_RT_DATA_TYPE_UNDEFINED;
fn main() -> clika_rt::Result<()> {
let api = Api::load("libClikaRT.so")?;
// A 4x6 single-channel image, row-major. buf[r][c] = r*10 + c.
const PITCH: usize = 6;
let mut buf = [0.0f32; 4 * PITCH];
for r in 0..4 {
for c in 0..PITCH {
buf[r * PITCH + c] = (r * 10 + c) as f32;
}
}
// The 2x3 region starting at row 1, column 2: shape {2, 3}, and the
// ORIGINAL row pitch as the row stride. No pixel is copied.
let roi = api.f_tensor_from_blob_strided(&mut buf[PITCH + 2..], &[2, 3],
&[PITCH as i64, 1])?;
println!("roi = {}", api.tensor_to_string(&roi)?);
println!("sum = {} (12+13+14+22+23+24 = 108)",
api.f_tensor_item_f32(&api.f_op_sum(roi, &[], false, DTYPE_UNSET)?)?);
Ok(())
}
roi = Tensor(shape=[2, 3], dtype=Float32, device=CPU, numel=6, data=[12, 13, 14, 22, 23, 24])
sum = 108 (12+13+14+22+23+24 = 108)
The same shape covers any pitched or tiled layout: a submatrix of a row-major matrix, a plane in a planar image, a batch entry inside a larger allocation. ops::contiguous materializes an owned compact copy when a consumer needs one.
Device moves and pinned memory
.to(device) on a wrapped tensor behaves like on any other: on a discrete accelerator the move is a real transfer to device memory (the wrap saved the host-side copy, not the transfer), while unified-memory hardware moves for free. Two related notes. from_data is the copying cousin: it copies your bytes into an owned tensor so the source's lifetime stops mattering; take it when the buffer is short-lived and the tensor is not. And from_blob's pinned_for parameter is tag-only: it asserts pages you already page-locked for a device, letting transfers take the pinned path; it cannot pin memory for you.
Give the pool's idle reserve back
The runtime side of the memory story: the pool keeps memory it handed out and got back (MemoryStats::cached_bytes), so the next allocation is cheap. After a model unloads, or before a second model must fit beside the first, that idle reserve is memory the device (on a shared-memory part, the host) cannot use for anything else. device::release_cached_memory(device) returns it to the driver or the OS: deferred reservations drain, parked buffers retire and empty blocks release, the same three steps the runtime takes before it reports out-of-memory. Memory still in use, or whose last use has not completed on the device, is never touched; a later call can release more once that work retires. Automatic placement calls it on a failed accelerator before the CPU fallback loads.
#include <cstdio>
#include <ClikaRT/clika_rt.h>
using ClikaRT::DataType;
using ClikaRT::Device;
using ClikaRT::Tensor;
static void report(const char* when) {
const ClikaRT::device::MemoryStats s =
ClikaRT::device::memory_stats(Device::cpu());
std::printf("%-14s active %8.1f MiB, cached %8.1f MiB\n", when,
s.active_bytes / 1048576.0, s.cached_bytes / 1048576.0);
}
int main() {
{
// 256 MiB of Float32 work: the pool reserves real memory for it.
Tensor big = Tensor::zeros({64, 1024, 1024}, DataType::Float32);
big.synchronize();
report("in use:");
}
// The tensor is gone, but the pool keeps its bytes idle for reuse.
report("dropped:");
// Hand the idle reserve back to the system. Memory still in use, or
// whose last use has not completed, is never touched.
ClikaRT::device::release_cached_memory(Device::cpu());
report("released:");
return 0;
}
in use: active 256.0 MiB, cached 0.0 MiB
dropped: active 0.0 MiB, cached 256.0 MiB
released: active 0.0 MiB, cached 0.0 MiB
The C table carries the same member as release_cached_memory, and the Go, Kotlin and Rust bindings expose it through their generated tiers.
The bundle's compute example covers device movement and this wrap in its 03_data_movement and 06_zero_copy chapters; the custom-operator guide uses from_blob to hand a hand-written kernel's output back to the runtime.