Tensor + ops library shared by sibling projects (brogameagent, brodiffusion, …). One flat namespace brotensor::. A single unified Tensor type carries a runtime Device tag; ops dispatch to the registered backend at runtime. Three backends: CPU (always built), CUDA, Metal (latter two optional).
include/brotensor/
tensor.h Tensor (unified host+device) + Dtype + Device + factories,
migration (to/clone), mutators, host accessors, half/bf16
↔ fp32 bit conversion helpers
ops.h Umbrella header — #includes every per-category header in
ops/. Each ops/<category>.h declares its slice of the
public surface (ls ops/ is the table of contents)
ops/ activation, attention, codec, concat, conv, conv1d,
delta_rule, diffusion, elementwise, embedding,
flash_attention, image, linear, lora, loss, lstm, norm,
optim, pooling, quant, reduction, resize, rope, sampling,
spatial, spectral, stylegan
runtime.h init() / shutdown() / default-device policy /
compute_dtype() / DeviceScope / sync / device mem info+trim
safetensors.h safetensors reader + writer — File/TensorView + upload* +
write_file. Tensor-container format; output type is Tensor
gguf.h GGUF reader — mmap'd File + TensorInfo + metadata +
shape_to_2d + upload_raw. Carries F32/F16/BF16 + every
legacy/K-quant block type; only Q4_K/Q6_K/Q8_0 have ops
cuda_graph.h CUDA graph capture/replay (CudaGraph, CudaGraphCapture) —
CUDA-only, gate on BROTENSOR_HAS_CUDA
metal_interop.h Public Metal custom-kernel surface (Obj-C++ / .mm only)
detail/op_table.h X-macro: the single canonical op list
detail/dispatch.h OpsVTable / AllocVTable + register_backend + dispatch()
detail/string_hash.h Heterogeneous string hash for the loaders' name indices
detail/cpu/ CPU-internal helpers shared across CPU TUs
(fft_core.h, thread_pool.h)
src/
tensor.cpp Tensor impl — alloc/clone/to/resize/zero via AllocVTable
dispatch.cpp Backend registry + per-operand device resolution
init.cpp Runtime: init(), default device, DeviceScope, sync
ops.cpp One thin wrapper per op — resolve device, forward to vtable
safetensors.cpp safetensors mmap reader + JSON header parser + writer
gguf.cpp GGUF mmap reader + header/metadata parser + upload_raw
cpu/ *.cpp — scalar FP32 backend (always compiled). Implements
essentially the whole FP32 fwd+bwd surface (audio, vision,
diffusion samplers, flash attention, …); leaves FP16/BF16/
INT8/GGUF-quant slots null
cuda/ *.cu — CUDA backend (gated on BROTENSOR_WITH_CUDA);
detail/ holds CUDA-internal helpers (cuda_check.h, …)
metal/ *.mm — Metal backend (gated on BROTENSOR_WITH_METAL)
# CPU-only
cmake -S . -B build && cmake --build build --config Release
# CPU + CUDA
cmake -S . -B build -DBROTENSOR_WITH_CUDA=ON && cmake --build build --config Release
# CPU + Metal (Apple only)
cmake -S . -B build -DBROTENSOR_WITH_METAL=ON && cmake --build build --config ReleaseBROTENSOR_WITH_CUDA and BROTENSOR_WITH_METAL are independent at the CMake level — nothing rejects enabling both. They stay exclusive in practice because their toolchains (nvcc vs. the Apple toolchain) don't coexist on one host. CPU has no opt-out.
Each backend compiles as its own static library and registers itself into the dispatcher. The consumed brotensor::brotensor interface target whole-archives the backend libs so their self-registration TUs survive the link.
Defines: BROTENSOR_HAS_CUDA / BROTENSOR_HAS_METAL set per-backend, BROTENSOR_HAS_GPU is the umbrella. Gate any GPU-conditional code on BROTENSOR_HAS_GPU unless you specifically need backend identity.
ctest --test-dir build -C ReleaseTests live under tests/, enabled by BROTENSOR_TESTS=ON (default ON when standalone). Most are CPU↔GPU parity tests; they skip cleanly when the GPU backend isn't available.
- One unified
Tensor, runtime device tag. A singlebrotensor::Tensorholds storage on any backend; theDevice devicefield (enum class Device { CPU, CUDA, Metal }) says where. There is no separateGpuTensortype — oneTensorcovers host and device storage alike. Storage is a single opaquevoid* dataallocated through the backend'sAllocVTable. - Dispatch is runtime, per-operand. Each public op in
ops.his a thin wrapper insrc/ops.cpp. The wrapper callsdetail::dispatch(...), which resolves the op's device from the first committed operand (data != nullptr), verifies every other committed operand agrees (throws on mismatch), and returns that backend'sOpsVTable. An uncommitted output (data == nullptr) is a wildcard — skipped by the check, then pinned to the resolved device viaadopt_outputbefore the backend impl allocates it. A null vtable slot means the backend doesn't implement that op; the wrapper throws "not implemented on ". - The op list is one X-macro.
detail/op_table.h'sBROTENSOR_FOR_EACH_OPis the single source of truth. It expands into theOpsVTablestruct, thesrc/ops.cppwrappers, and each backend's registration table — so the public surface and every backend stay in sync by construction. - Op signatures mirror across CPU and GPU. The vtable slot signature is the public signature. Same argument order, same shape contracts, same accumulation semantics for backward (caller zeros dW/dB; op accumulates). When adding a CPU op that already has a GPU counterpart, port the contract verbatim and document any FP32-only restriction.
- Porting a kernel means porting its arithmetic, not just its signature. A CPU op can match the GPU slot's arguments, shapes and accumulation semantics exactly and still be wrong, because the numerics differ. Reductions are where this bites: compute a variance as the sum of squared deviations from the mean (two passes), never as
E[x^2] - E[x]^2. The one-pass form cancels catastrophically as soon as a row's mean dwarfs its spread — both terms land on the same large value, their FP32 difference is rounding noise, and a negative variance makesrstdNaN. A CLIP text row sitting near 395 is enough to trigger it.tests/test_layernorm_stability.cpppins this down for layernorm; the same rule holds for group_norm, batch_norm and the fused resblock norms. If a comment says a kernel "ports" its counterpart in another backend, the formula has to match too. - CPU is FP32-only, but covers the whole FP32 surface. The CPU backend implements essentially every op's FP32 forward and backward — the dense/attention/loss/optim core, the audio family, the vision primitives, the diffusion samplers, flash attention. It is not a thin subset; it's the simple, correct reference. What it doesn't do: FP16 / BF16 / INT8-W8A16 / GGUF-quant paths — those exist on the GPU because they pay for themselves there. Don't add them to the CPU side; the CPU backend leaves those vtable slots null and the dispatcher throws "not implemented on CPU".
- GPU dtype dispatch is on
Tensor::dtype. Ops select FP32 vs FP16 vs BF16 (vs INT8 for W8A16) internally; the public surface takes a singleTensor&per arg.DtypeisFP32 / FP16 / BF16 / INT8 / INT32 / F64plus the GGUF block-quant carriers (Q4_0 … Q8_K). FP32/FP16/BF16 are the arithmetic dtypes (BF16 GPU-only; FP16/BF16 areuint16_tbit patterns on the host). INT8/INT32 are storage carriers for quantised weights and index/offset buffers; the GGUF quant dtypes are non-element-addressable block carriers consumed only by the GGUF dequant / fused-matmul ops — no general arithmetic op dispatches on any of them. Element/block sizing goes throughdtype_size_bytes/dtype_block_size/dtype_block_bytes/dtype_storage_bytes/dtype_is_quant(quant dtypes return 0 fromdtype_size_bytes— usedtype_storage_bytes). - Backend-resident storage stays opaque. GPU
.cu/.mmfiles include<brotensor/tensor.h>and treatTensor::dataas a raw device pointer (CUDA) or resolve it to itsMTLBufferviametal_interop.h(Metal). Usefrom_host/to/copy_to_hostfor host transfers. - Backend registration. CPU self-registers from a static-init object (
src/cpu/register.cpp), so CPU tensors work without a priorinit()call. CUDA / Metal are probed and registered bybrotensor::init(). - Default device.
default_device()picks the best available (CUDA > Metal > CPU). Override globally withset_default_device(), per-scope withDeviceScope, or via theBROTENSOR_DEFAULT_DEVICEenv var (cpu/cuda/metal).zeros/empty/from_hostland on the default;*_onvariants pin to an explicit device. - Streams / async. CUDA hot ops launch on the current stream. Metal batches command buffers and submits asynchronously (
submit/flush— seemetal_interop.h).sync(Device)/sync_all()drain pending work; call before reading GPU results back to host. - Error checks. Backend impls throw
std::runtime_errorwith a"brotensor: <op>: <reason>"message. Wrap CUDA calls withBROTENSOR_CUDA_CHECK(expr). Negative dimensions are rejected at allocation;resize()on a non-owningview()throws rather than silently severing the view.
- Adding a new op: declare it in the matching
include/brotensor/ops/<category>.h(and rely onops.hre-including it — don't add declarations toops.hdirectly); add one row toBROTENSOR_FOR_EACH_OPindetail/op_table.h; implement it insrc/cpu/,src/cuda/,src/metal/; register the slot in each backend's registration file (src/cpu/register.cpp,src/cuda/register*.cu,src/metal/register*.mm); list any new source file under that backend's target inCMakeLists.txt. Match the shape contract across all three (a backend may register a null slot if it genuinely can't support the op — the dispatcher throws on null lookups). FP32 ops should land on CPU too (it's the parity reference); FP16/BF16/INT8/GGUF-quant variants are GPU-only. - Adding a new dtype path: extend
Dtype, updatedtype_size_bytes(anddtype_block_size/dtype_block_bytesfor a block-quant carrier), and add the path inside the relevant GPU op kernel — don't add per-dtype public entry points. - ABI: the sibling projects vendor brotensor via
add_subdirectoryand build it from source, so a change here reaches every consumer as soon as they update their checkout. Don't break the public ABI casually.