Devices and the async model
The same program from parts 1-2 runs unchanged on a GPU. Data placement is the only new ingredient. This part adds device discovery and .to(device), then explains the execution model behind every ops:: call you have made so far.
The program
- C++
- C
- Python
- Kotlin
- Go
- Rust
#include <cstdio>
#include <ClikaRT/clika_rt.h>
using ClikaRT::DataType;
using ClikaRT::Device;
using ClikaRT::device::ComputeAPI;
namespace device = ClikaRT::device;
using ClikaRT::Tensor;
namespace ops = ClikaRT::ops;
// The best device this machine has, probed at run time. An unavailable
// backend is a fact, not an error: the predicate answers false.
namespace {
Device pick_device() {
if (device::is_cuda_available()) return Device::cuda();
if (device::is_vulkan_available()) return Device::vulkan();
if (device::is_metal_available()) return Device::metal();
return Device::cpu();
}
} // namespace
int main() {
// What is this machine carrying? enumerate_devices lists the concrete
// handles a backend exposes; get_device_properties describes one.
for (ComputeAPI api : {ComputeAPI::CPU, ComputeAPI::CUDA,
ComputeAPI::Vulkan, ComputeAPI::Metal}) {
for (Device dev : device::enumerate_devices(api)) {
const device::DeviceProperties p = device::get_device_properties(dev);
std::printf("%-7s %d: %s\n",
device::compute_api_name(api), dev.index, p.name.c_str());
}
}
const Device dev = pick_device();
std::printf("running on %s\n", device::compute_api_name(dev.api));
// .to(device) moves data; ops run where their inputs live.
const Tensor a = Tensor::ones({512, 512}, DataType::Float32).to(dev);
// This call DISPATCHES the matmul and returns. The kernel runs in its
// own time; nothing here waits for it.
const Tensor c = ops::matmul(a, a);
// A host read is where the wait lands: it synchronizes first, so you
// never observe unfinished bytes. Every element is 512 (= K).
std::printf("every element = %.0f\n", ops::amax(c).item<float>());
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);
}
/* The best device this machine has: device_gpu probes the loaded backends
* and FALLS BACK to the CPU when none is available; never an error. */
static const char* api_name(clika_rt_device d) {
if (api->device_is_cuda(d)) return "CUDA";
if (api->device_is_vulkan(d)) return "Vulkan";
if (api->device_is_metal(d)) return "Metal";
return "CPU";
}
int main(void) {
bootstrap();
const clika_rt_stream_or_device dflt = {CLIKA_RT_STREAM_OR_DEVICE_DEFAULT};
const clika_rt_device dev = api->device_gpu(0);
printf("running on %s\n", api_name(dev));
/* to_device moves data; ops run where their inputs live. */
const int64_t shape[2] = {512, 512};
clika_rt_tensor* ones = NULL;
check(api->tensor_ones(shape, 2, CLIKA_RT_DATA_TYPE_FLOAT32, dflt, dflt, &ones), "ones");
clika_rt_tensor* a = NULL;
check(api->tensor_to_device(ones, dev, &a), "to_device");
api->tensor_release(ones);
/* This call DISPATCHES the matmul and returns. The kernel runs in its
* own time; nothing here waits for it. matmul consumes BOTH tensor
* operands; the retain funds the second reference to a. */
api->tensor_retain(a);
clika_rt_tensor* c = NULL;
check(api->op_matmul(a, a, NULL, 0, CLIKA_RT_ACTIVATION_IDENTITY, 0, 0, 0.0, 0.0, &c), "matmul");
/* A host read is where the wait lands: it synchronizes first, so you
* never observe unfinished bytes. Every element is 512 (= K). */
clika_rt_tensor* m = NULL;
check(api->op_amax(c, NULL, 0, 0, &m), "amax");
clika_rt_tensor* mh = NULL;
check(api->tensor_to_device(m, api->device_cpu(0), &mh), "to_cpu");
const void* p = NULL;
check(api->tensor_const_data_ptr(mh, &p), "const_data_ptr");
printf("every element = %.0f\n", *(const float*)p);
api->tensor_release(mh);
api->tensor_release(m);
return 0;
}
import clika_runtime as crt
def main() -> None:
# The best device this machine has: Device.gpu() probes the available
# accelerators and FALLS BACK to the CPU when none is present; it never
# fails, so the same script runs everywhere.
dev = crt.Device.gpu()
print(f"running on {dev!r}")
# Factories take the device directly; ops run where their inputs live.
a = crt.ones(512, 512, device=dev)
# This call DISPATCHES the matmul and returns. The kernel runs in its
# own time; nothing here waits for it.
c = a @ a
# A host read is where the wait lands: it synchronizes first, so you
# never observe unfinished bytes. Every element is 512 (= K).
print(f"every element = {c.amax().item():.0f}")
if __name__ == "__main__":
main()
import io.clika.runtime.ClikaRt
import io.clika.runtime.ClikaRtGen
import io.clika.runtime.Device
import io.clika.runtime.F
import io.clika.runtime.Tensors
import io.clika.runtime.item
import io.clika.runtime.matmul
fun main() {
ClikaRt.load()
// The best device this machine has: the generated deviceGpu probe
// returns a packed device value, an accelerator when one is loaded,
// and FALLS BACK to the CPU when none is available; it never fails.
val packed = ClikaRtGen.deviceGpu(0)
val dev = Device(ClikaRtGen.deviceApiOf(packed), ClikaRtGen.deviceIndexOf(packed))
println("running on $dev")
// Placement happens at the factory; ops run where their inputs live.
val a = Tensors.ones(longArrayOf(512, 512), device = dev)
// This call DISPATCHES the matmul and returns. The kernel runs in its
// own time; nothing here waits for it. Operator extensions leave their
// operands live.
val c = a matmul a
// A host read is where the wait lands: item() synchronizes first, so
// you never observe unfinished bytes. Every element is 512 (= K).
val m = F.amax(c)
println("every element = %.0f".format(m.item()))
listOf(a, c, m).forEach { it.release() }
}
//go:build ignore
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 best device this machine has: DeviceGpu probes the loaded backends
// and FALLS BACK to the CPU when none is available.
func apiName(api *clikart.Api, d clikart.Device) string {
switch {
case api.DeviceIsCuda(d):
return "CUDA"
case api.DeviceIsVulkan(d):
return "Vulkan"
case api.DeviceIsMetal(d):
return "Metal"
}
return "CPU"
}
func main() {
api := must(clikart.Load("libClikaRT.so"))
dev := api.DeviceGpu(0)
fmt.Printf("running on %s\n", apiName(api, dev))
// The placement slot puts the tensor on the device directly.
a := must(api.TensorOnes([]int64{512, 512}, clikart.Float32,
clikart.StreamOrDevice{Kind: clikart.StreamOrDeviceDevice, Device: dev},
clikart.StreamOrDevice{}))
// This call DISPATCHES the matmul and returns. The kernel runs in its
// own time; nothing here waits for it.
c := must(clikart.Matmul(a, a))
// A host read is where the wait lands: it synchronizes first, so you
// never observe unfinished bytes. Every element is 512 (= K).
m := must(clikart.Amax(c, nil, false))
host := must(api.TensorToDevice(m, api.DeviceCpu(0)))
fmt.Printf("every element = %.0f\n", *(*float32)(must(api.TensorConstDataPtr(host))))
}
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<()> {
let api = Api::load("libClikaRT.so")?;
// The best device this machine has: device_gpu probes the loaded
// backends and FALLS BACK to the CPU when none is available. The
// device predicates name what it resolved to.
let dev = api.device_gpu(0);
let kind = if api.device_is_cuda(dev) { "cuda" }
else if api.device_is_vulkan(dev) { "vulkan" }
else if api.device_is_metal(dev) { "metal" }
else { "cpu" };
println!("running on {kind}");
// f_tensor_to_device moves data (borrowing its source); ops run where
// their inputs live.
let ones = api.tensor_full(&[512, 512], 1.0, F32)?;
let a = api.f_tensor_to_device(&ones, dev)?;
// This call DISPATCHES the matmul and returns. The kernel runs in its
// own time; nothing here waits for it. f_matmul consumes both tensor
// args; the shallow_clones keep `a` live.
let c = a.shallow_clone().f_matmul(a.shallow_clone(), api.absent(), false,
sys::clika_rt_activation_CLIKA_RT_ACTIVATION_IDENTITY, false, false, 0.0, 0.0)?;
// A host read is where the wait lands: it synchronizes first, so you
// never observe unfinished bytes. Every element is 512 (= K).
let m = c.f_amax(&[], false)?;
let mh = api.f_tensor_to_device(&m, api.device_cpu(0))?;
let p = api.f_tensor_const_data_ptr(&mh)?;
println!("every element = {:.0}", unsafe { *(p as *const f32) });
Ok(())
}
On a machine with an NVIDIA GPU:
CPU 0: AMD
CUDA 0: NVIDIA RTX PRO 6000 Blackwell Workstation Edition
CUDA 1: NVIDIA RTX PRO 6000 Blackwell Workstation Edition
Vulkan 0: NVIDIA RTX PRO 6000 Blackwell Workstation Edition
Vulkan 1: NVIDIA RTX PRO 6000 Blackwell Workstation Edition
running on CUDA
every element = 512
The same binary on a CPU-only machine lists only the CPU and runs there; no rebuild, no configuration.
Devices and backends
A Device is a backend API plus a zero-based index. Device::cuda(1) is the second CUDA GPU, and Device::cpu() is the default everything starts on. Every distribution has the CPU backend compiled in; CUDA, Vulkan and Metal are shared libraries the runtime loads on demand, the first time something asks. is_backend_available(api) (and the shorthands is_cuda_available() and friends) answers whether that load works on this machine; enumerate_devices(api) returns the concrete handles, and an empty list is a valid answer, not an error. This is why the same binary runs everywhere. Absent hardware costs you a branch, not a build configuration.
.to(device) returns the tensor moved (a no-op copy if it is already there), and operators run on the device their inputs live on; there is no global "current device" to set.
Dispatch is not execution
ClikaRT is asynchronous by nature. An ops:: call dispatches work and returns; the kernel runs and the result becomes ready in its own time. On the default CPU stream ops happen to run inline, which is why parts 1-2 never confronted this. On an accelerator, or a worker stream made with Stream::create, the dispatch returns first and the compute overlaps with your code. t.synchronize() blocks until t's pending work is done; t.on_complete(callback) is the push-style equivalent, firing when the result is ready.
Reading across the async boundary
Two rules cover every host read:
- Reads wait for you.
item<T>(),item_as_vec<T>()andconst_data_ptr()synchronize before handing back bytes. A read issued right after dispatching heavy work blocks until the result is real. The wait moves into the read; it never disappears. You can never observe garbage through the public read surface. - Pointers are device pointers.
const_data_ptr()addresses the buffer on the tensor's own device. On a CUDA tensor that is CUDA memory; callt.to(Device::cpu())first to read it on the host. (item/item_as_vecdo the host transfer for you.)
So the failure mode is never corruption, it is a surprise stall, a "cheap" read that waited for a matmul. When latency matters, choose where the wait lands: an explicit synchronize(), an on_complete callback, or a read whose cost you have accepted. The async example project measures all of this with timers.
Next: part 4. It loads weights from disk, computes, and reads results back.