8. Data types, storage, and the raw tensor layer (internal)¶
This section documents the substrate under the public Tensor facade: the
dtype system (src/dtype.zig), refcounted storage (src/storage.zig), and
the raw tensor value (src/tensor.zig). None of this is a stable public
API. The module root deliberately does not export the raw tensor type — a
comptime guard makes that a compile error (§8.6) — and the sanctioned
in-tree names for it are fucina.internal.RawTensor and, for
microbenchmarks only, bench_raw.RawTensor (§2). It is documented here
because it is load-bearing for everything else: the dtype policy in §8.3
explains every output dtype in §4, the buffer refcount explains the memory
model in §6 and MEMORY-MODEL.md, and anyone extending the
library (new ops, backend kernels, model loaders) works directly against
these types. Expect this surface to change without compatibility notice.
8.1 The DType enum (src/dtype.zig)¶
pub const DType = enum {
bool, u8, u16, i8, i16, i32, i64, f16, bf16, f32, f64,
q1_0, q2_0, q4_0, q4_1, q5_0, q5_1, q8_0, q8_1,
q2_k, q3_k, q4_k, q5_k, q6_k, q8_k,
iq1_s, iq1_m, iq2_xxs, iq2_xs, iq2_s, iq3_xxs, iq3_s, iq4_nl, iq4_xs,
tq1_0, tq2_0, mxfp4, nvfp4,
};
pub const DTypeKind = enum { scalar, block_quantized };
DType is the logical format tag carried by every buffer and tensor type. It
is re-exported at the public root as fucina.DType. Every dtype falls into
one of two kinds (kind(dtype), isScalar, isBlockQuantized):
- Scalar dtypes store one storage element per logical tensor element.
- Block-quantized dtypes store one packed block struct per
blockSizelogical elements, always along the last logical axis.
Scalar dtypes:
DType |
Scalar(dtype) |
Size | Notes |
|---|---|---|---|
.bool |
bool |
1 B | zero() is false, one() is true |
.u8 |
u8 |
1 B | |
.u16 |
u16 |
2 B | token-id workhorse |
.i8 |
i8 |
1 B | |
.i16 |
i16 |
2 B | |
.i32 |
i32 |
4 B | |
.i64 |
i64 |
8 B | |
.f16 |
f16 |
2 B | IEEE binary16, native Zig float |
.bf16 |
u16 |
2 B | raw bfloat16 bits, not a float type; one(.bf16) == 0x3f80 |
.f32 |
f32 |
4 B | the only differentiable public dtype |
.f64 |
f64 |
8 B |
Block-quantized dtypes (GGML-compatible wire formats; the packed extern
struct layouts are byte-exact against ggml and pinned by comptime size
asserts in src/dtype.zig). Encoding/decoding semantics per format are §10;
this table is the storage geometry:
DType |
Block struct | Elems/block (blockSize) |
Bytes/block (blockByteSize) |
|---|---|---|---|
.q1_0 |
BlockQ1_0 |
128 | 18 |
.q2_0 |
BlockQ2_0 |
128 | 34 |
.q4_0 |
BlockQ4_0 |
32 | 18 |
.q4_1 |
BlockQ4_1 |
32 | 20 |
.q5_0 |
BlockQ5_0 |
32 | 22 |
.q5_1 |
BlockQ5_1 |
32 | 24 |
.q8_0 |
BlockQ8_0 |
32 | 34 |
.q8_1 |
BlockQ8_1 |
32 | 36 |
.q2_k |
BlockQ2_K |
256 | 84 |
.q3_k |
BlockQ3_K |
256 | 110 |
.q4_k |
BlockQ4_K |
256 | 144 |
.q5_k |
BlockQ5_K |
256 | 176 |
.q6_k |
BlockQ6_K |
256 | 210 |
.q8_k |
BlockQ8_K |
256 | 292 |
.iq1_s |
BlockIQ1_S |
256 | 50 |
.iq1_m |
BlockIQ1_M |
256 | 56 |
.iq2_xxs |
BlockIQ2_XXS |
256 | 66 |
.iq2_xs |
BlockIQ2_XS |
256 | 74 |
.iq2_s |
BlockIQ2_S |
256 | 82 |
.iq3_xxs |
BlockIQ3_XXS |
256 | 98 |
.iq3_s |
BlockIQ3_S |
256 | 110 |
.iq4_nl |
BlockIQ4_NL |
32 | 18 |
.iq4_xs |
BlockIQ4_XS |
256 | 136 |
.tq1_0 |
BlockTQ1_0 |
256 | 54 |
.tq2_0 |
BlockTQ2_0 |
256 | 66 |
.mxfp4 |
BlockMXFP4 |
32 | 17 |
.nvfp4 |
BlockNVFP4 |
64 (16-elem subblocks) | 36 |
The block structs and the size constants (q1_0_block_size, q2_0_block_size,
q4_0_block_size, q4_1_block_size, q5_0_block_size, q5_1_block_size,
q8_0_block_size, q8_1_block_size, qk_k_block_size = 256,
k_scale_size = 12, iq4_nl_block_size, mxfp4_block_size,
nvfp4_block_size, nvfp4_subblock_size, iq3s_n_scale) are pub in
src/dtype.zig; the block structs are also re-exported at the public root
(fucina.BlockQ4_K, fucina.BlockTQ2_0, ...) because loaders and format
code legitimately handle raw blocks.
A block-quantized tensor of shape [..., n] requires n to be a nonzero
multiple of blockSize(dtype) and stores
prefix_product * n / blockSize(dtype) block structs
(storageElementCount, §8.5.7). Both blockSize and blockByteSize are
comptime functions; calling blockSize on a scalar dtype is a compile error.
8.2 Storage mapping and dtype predicates (src/dtype.zig)¶
pub fn Scalar(comptime dtype: DType) type // scalar dtypes only
pub fn Storage(comptime dtype: DType) type // any dtype
pub fn Accumulator(comptime dtype: DType) type // scalar dtypes only
Scalar(dtype)is the per-logical-element type. It is a compile error for block-quantized dtypes ("block-quantized dtypes do not have one scalar storage element per logical tensor element"). NoteScalar(.bf16) == u16: bf16 is stored and passed as raw bits everywhere.Storage(dtype)is the per-storage-element type:Scalar(dtype)for scalar dtypes, the block struct for block-quantized dtypes. Buffers and raw tensors are sized inStorage(dtype)units.Accumulator(dtype)is the reduction accumulator type:f32for.f16/.bf16/.f32,f64for.f64,u64for.bool/.u8/.u16,i64for the signed integers. Compile error for block dtypes.
Classification predicates (all comptime, all pub):
| Function | True for |
|---|---|
kind(dtype) |
returns .scalar or .block_quantized |
isScalar / isBlockQuantized |
kind shorthands |
isFloat |
.f16, .bf16, .f32, .f64 |
isInteger |
.u8, .u16, .i8, .i16, .i32, .i64 |
isSignedInteger / isUnsignedInteger |
the obvious subsets |
supportsGrad |
== isFloat (only float tensors can carry gradients; in practice only .f32 does, §5) |
supportsIntMath |
== isInteger (wrapping integer pointwise math and i64-accumulated reductions; .bool reduces but has no pointwise math) |
supportsForwardFloatMath |
== isFloat (forward-only math on the typed facade, §3) |
supportsToFloat |
floats plus every block-quantized dtype (dequantizable) |
supportsQuantizedMatmulRhs |
every block dtype except .q8_1 and .q8_k (those two are activation-side dot-product formats, §10) |
supportsQuantizedGetRows |
== isBlockQuantized (embedding-row gather) |
logicalDType |
blocks map to .f32, scalars map to themselves |
Scalar constant/conversion helpers: zero(dtype), one(dtype),
name(dtype) (the tag name), toF32/toF64/fromF32/fromF64 (float
dtypes only; compile error otherwise), and
castFloat(source_dtype, target_dtype, value), which routes through f64
when the target is .f64 and through f32 otherwise.
castScalar(source_dtype, target_dtype, value) is the general scalar cast
across the non-block dtypes: float↔float delegates to castFloat, and the
int/bool legs follow the semantics quoted in §3.8's to conversion table
(integer↔integer wraps two's-complement, float→integer truncates toward
zero and saturates with NaN → 0, anything→bool is != 0, bool→number is
0/1). isTruthy(dtype, value) is mask truthiness: != 0, with bf16 read
through the value bridge so -0.0 stays falsy and NaN is truthy.
toAccumulator/fromAccumulator convert between Scalar(dtype) and
Accumulator(dtype) (bool maps to 0/1). bf16ToF32(bits: u16) f32 and
f32ToBf16(value: f32) u16 implement the bf16 bridge: round-to-nearest-even
on narrowing, with ggml-compatible NaN quieting (a NaN payload never
truncates to infinity; src/dtype_tests.zig pins this).
8.3 Float compute/output dtype policy (src/dtype.zig)¶
pub const FloatOp = enum { pointwise, reduction, matmul };
pub fn computeDType(comptime op: FloatOp, comptime input_dtype: DType) DType
pub fn outputDType(comptime op: FloatOp, comptime input_dtype: DType) DType
Forward float math has one explicit, comptime-resolved policy. computeDType
names the arithmetic/accumulation dtype, outputDType the result storage
dtype. For block-quantized dtypes both functions return the input unchanged;
integers and .bool follow the integer rows in the table:
| Op family | Input | Computes in | Returns |
|---|---|---|---|
| pointwise | .f16 |
f16 |
.f16 |
| pointwise | .bf16 |
f32 |
.bf16 |
| pointwise | .f32 |
f32 |
.f32 |
| pointwise | .f64 |
f64 |
.f64 |
| pointwise | integers | input dtype (wrapping) | input dtype |
| reduction | .f16, .bf16, .f32 |
f32 |
.f32 |
| reduction | .f64 |
f64 |
.f64 |
| reduction | integers, .bool |
i64 (wrapping) |
.i64 |
| dot/matmul | .f16, .bf16, .f32 |
f32 (accumulate) |
input dtype |
| dot/matmul | .f64 |
f64 |
.f64 |
Three rules fall out of the table:
- bf16 computes through f32 always — it is stored as raw
u16bits, so even pointwise ops widen viabf16ToF32, compute inf32, and narrow back with round-to-nearest-even on store (except reductions, which returnf32outright). - Reductions on 16-bit floats return f32. Summing
f16/bf16into a 16-bit result would lose the accumulator's precision, so the widened result dtype is kept. - Explicit casts are required for anything else. No op silently promotes
across operand dtypes: mixed-dtype pointwise math on the typed facade is a
compile error (
"typed pointwise requires matching dtypes; cast explicitly"insrc/ag/tensor.zig). Casting is an explicit op —to(ctx, target_dtype)on the public facade (§3),castTypedonExecContext(§6).
The policy is visible directly in public result types:
test "float dtype policy: f16 reduction returns f32" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const Half = fucina.Tensor(.{ .dtype = .f16, .tags = .{ .row, .col } });
var x = try Half.fromSlice(&ctx, .{ 2, 2 }, &.{ 1.5, 2.5, 3.0, 4.0 });
defer x.deinit();
// Pointwise keeps the input dtype: f16 + f16 -> f16.
var y = try x.add(&ctx, &x);
defer y.deinit();
comptime std.debug.assert(@TypeOf(y).dtype == .f16);
// Reductions on f16 accumulate in f32 and *return* f32.
var s = try x.sum(&ctx, .col);
defer s.deinit();
comptime std.debug.assert(@TypeOf(s).dtype == .f32);
try std.testing.expectEqualSlices(f32, &.{ 4.0, 7.0 }, try s.dataConst());
// Any other output dtype requires an explicit cast.
var wide = try x.to(&ctx, .f32);
defer wide.deinit();
comptime std.debug.assert(@TypeOf(wide).dtype == .f32);
}
compiled & run in CI ✓
8.4 Refcounted storage: BufferOf(dtype) (src/storage.zig)¶
pub fn BufferOf(comptime buffer_dtype: DType) type {
return struct {
allocator: Allocator,
data: []Elem, // Elem == dtype.Storage(buffer_dtype)
refs: std.atomic.Value(u32),
release_ctx: ?*anyopaque = null,
release_fn: ?*const fn (*anyopaque, *Self) void = null,
pending_work: std.atomic.Value(?*accelerator.Work) = .init(null),
pending_use: std.atomic.Value(?*accelerator.Work) = .init(null),
accelerator_resource: std.atomic.Value(?*accelerator.Resource) = .init(null),
pub const dtype = buffer_dtype;
pub const Element = Elem;
...
};
}
pub const Buffer = BufferOf(.f32);
A buffer is a heap-allocated header (allocator.create(Self)) plus a typed
data slice, shared by pointer and lifetime-managed by an atomic refcount.
Every raw tensor holds exactly one reference to exactly one buffer; views
share the buffer by taking additional references.
Constructors (all return !*Self with refs == 1):
| Constructor | Data ownership | At refs == 0 |
|---|---|---|
create(allocator, len) |
owned, uninitialized len elements |
destroy(): free data + header |
createWithRelease(allocator, len, release_ctx, release_fn) |
owned | release_fn(release_ctx, self) |
fromSlice(allocator, values) |
owned copy of values |
destroy() |
fromBorrowedSlice(allocator, values) |
aliases caller memory | destroy header only; caller keeps the bytes |
fromBorrowedSliceWithRelease(allocator, values, release_fn) |
aliases | release_fn(self, self) — full cleanup duty |
fromBorrowedSliceWithReleaseCtx(allocator, values, release_ctx, release_fn) |
aliases | release_fn(release_ctx, self) — full cleanup duty |
Refcount operations:
retain()—fetchAdd(1, .monotonic). Safe from any thread.release()—fetchSub(1, .acq_rel); debug-asserts against over-release. When the count reaches zero it invokesrelease_fnif set, otherwisedestroy(). Exactly one caller observes zero, so the hook fires exactly once (src/storage_tests.zigpins this).isUnique()— acquire-load snapshotrefs == 1. Snapshot only: it is meaningful only when the caller already has exclusive access to the tensor handle pointing at this buffer (the basis ofcanTakeInPlace, §8.5.5).resetRefs()— stores 1; valid only under exclusive ownership. Used by theExecContextbuffer pool when recycling a cached buffer (§6).destroy()— unconditionally frees data + header, bypassing the refcount. Only for owners that know no references remain (pool teardown).waitReady()/discardPending()— complete already-submitted GPU output work, respectively making host bytes visible or skipping an unused D2H.waitReadyis safe under CONCURRENT callers (parallel chunk copies land N readers on one buffer): a single claimant dereferences and releases the Work; everyone else spins until the slot clears, which happens only after the host copy is visible (8-thread regression insrc/storage_tests.zig).setPendingUse()/waitUnused()/waitMutable()— track the latest submitted GPU reader of this allocation. Const host reads may overlap a device read; mutable access waits so post-call input mutation cannot race Metal zero-copy reads or CUDA async upload. Provider queue order lets the latest token subsume earlier readers. Final release always completes both output and reader work before storage can be recycled.acceleratorResource— provider mapping metadata tied to this allocation's lifetime (Metal's pooled page-wrapper cache; CUDA host page registration). It survives ordinary pool release/reacquire and is destroyed with the backing allocation.
Release-hook contract. For the borrowed-with-release variants the hook
runs once, at the final release(), and takes full cleanup responsibility:
it must dispose of the external data by whatever means created it and
free the header with buffer.destroyHeader() (which also releases any
accelerator resource). Capture the external slice, call destroyHeader()
first, then free/unmap the bytes: provider teardown may still need the live
address to unregister it. The two in-tree
production uses:
- GPU device-resident bytes —
src/llm/weights.zigwraps managed device allocations frominternal.gpu.allocResidentBytesso the final buffer release (countingcloneView'd weights that share it) frees them viainternal.gpu.freeResidentBytes(§8.6, §9). - pooled slabs —
src/exec/buffer_pool.ziguses theCtxvariant so a typed buffer's release returns its byte slab to the pool free list instead of freeing it (§6).
Note the tree does not use release hooks for mmap'd GGUF bytes: that
lifetime is holder-managed — gguf.File.deinit munmaps, or ownership moves
via takeMapping to a MappedRegion the holder must keep alive while
anything borrows tensor data from it (§12). The hook mechanism remains the
right tool when user code wants refcount-driven cleanup of an external
mapping, as below:
fn wrapMappedWeights(alloc: std.mem.Allocator, mapped: []f32) !fucina.internal.RawTensor {
const RawTensor = fucina.internal.RawTensor;
const Buffer = std.meta.Child(@FieldType(RawTensor, "buffer")); // BufferOf(.f32)
const hook = struct {
fn releaseMapped(_: *anyopaque, buffer: *Buffer) void {
// Full cleanup responsibility: the external bytes AND the header.
const bytes = std.mem.sliceAsBytes(buffer.data);
buffer.destroyHeader();
std.posix.munmap(@alignCast(bytes));
}
};
const buffer = try Buffer.fromBorrowedSliceWithRelease(alloc, mapped, hook.releaseMapped);
return RawTensor.fromOwnedBuffer(buffer, &.{ 4, 8 }) catch |err| {
buffer.release(); // still owns the one reference on failure
return err;
};
// Later: the final tensor deinit drops refs to 0 and fires the hook once.
}
The buffer type is not separately exported; internal code names it through
the tensor's field type, as above (the src/llm/weights.zig idiom).
Thread-safety. retain/release are atomic and may race freely; the
data slice is not synchronized — concurrent reads are fine, and writers need
external coordination (the runtime's parallel kernels partition disjoint
ranges, §9). isUnique and resetRefs are only correct under exclusive
access as described above.
8.5 The raw tensor: TensorOf(dtype) (src/tensor.zig)¶
pub const max_rank = 8;
pub const TensorError = error{
ShapeMismatch, InvalidShape, InvalidDataLength, IndexOutOfBounds, UnsupportedView,
EmptySelection, DivisionByZero,
};
pub const Shape = struct {
len: u8,
dims: [max_rank]usize,
pub fn init(values: []const usize) !Shape // rejects rank 0/>8 and zero dims
pub fn initStrides(values: []const usize) !Shape // zeros allowed (broadcast strides)
pub fn slice(self: *const Shape) []const usize
pub fn at(self: *const Shape, i: usize) usize
};
pub fn TensorOf(comptime tensor_dtype: DType) type {
return struct {
buffer: *BufferOf(tensor_dtype),
shape: Shape,
strides: Shape,
offset: usize = 0,
pub const dtype = tensor_dtype; // and: pub const Element = Storage(dtype)
...
};
}
pub const Tensor = TensorOf(.f32); // == fucina.internal.RawTensor
A raw tensor is a plain value: a buffer pointer plus inline shape/stride
metadata (two fixed [8]usize arrays — no allocation per view) and a start
offset, all measured in storage elements (Storage(dtype) units — for
block-quantized dtypes, strides count blocks). Rank is runtime (1 to
max_rank = 8; there is no rank-0 shape, which is why the facade's
scalar-tag tensor is a rank-1 {1} raw tensor). Copying the struct does
not retain the buffer; every legitimately owned tensor value carries
exactly one buffer reference, and deinit() releases it and poisons the
struct (self.* = undefined — not idempotent).
The raw tensor appears inside public signatures (ctx.fromSlice returns
one; Tensor(spec).variable(&ctx, raw) consumes one), but the type itself is
only nameable as fucina.internal.RawTensor /
fucina.internal.tensor_mod.TensorOf(dtype). For convenience tensor.zig
re-exports DType, Scalar, and Storage from dtype.zig (and
storage.zig re-exports DType), so tensor_mod alone is enough for most
raw-layer work.
8.5.1 Construction and ownership¶
| Constructor | Dtypes | Semantics |
|---|---|---|
zeros(allocator, shape) / ones(allocator, shape) |
scalar only (compile error otherwise) | fresh owned buffer, filled |
fromSlice(allocator, shape, values: []const Scalar) |
scalar only | owned copy; InvalidDataLength unless values.len == elementCount(shape) |
fromBorrowedSlice(allocator, shape, values: []Scalar) |
scalar only | aliases caller memory (borrowed buffer; caller keeps ownership of the bytes and must outlive the tensor) |
fromStorageSlice(allocator, shape, values: []const Element) |
any | owned copy in storage elements; for block dtypes values.len must equal storageElementCount |
fromBorrowedStorageSlice(allocator, shape, values: []Element) |
any | borrowed, in storage elements |
fromOwnedBuffer(buffer, shape) |
any | consumes one reference to buffer; the caller must not release that reference after success — deinit does. Accepts oversized buffers (data.len >= storageElementCount), which is how pooled buffers are wrapped; InvalidDataLength if too small. On error the reference stays with the caller |
scalar(allocator, value) |
scalar only | shape {1} |
clone(allocator) |
any | materializing deep copy into a fresh contiguous buffer (see 8.5.4) |
All errors are TensorError members, plus error.Overflow from checked
element-count multiplication and error.OutOfMemory from every allocating
constructor (only fromOwnedBuffer is allocation-free). The allocator
passed at construction is stored in the buffer and used for its teardown.
8.5.2 Geometry queries¶
rank(), len() (logical element count), storageLen() (storage element
count; len()/blockSize per trailing axis for block dtypes), rows() /
cols() (rank-2 only, else InvalidShape), isScalar() (len() == 1),
and isContiguous() — true when strides are exactly the row-major strides
of the shape (a broadcast {1} scalar with stride 0 is not contiguous).
8.5.3 Views¶
All view constructors retain() the buffer and return a new tensor value
that must be deinited independently; shape/stride metadata is copied
inline, and offset is preserved (or extended). Writes through any view are
visible through every alias of the same buffer.
pub fn cloneView(self: *const Self) !Self
pub fn reshape(self: *const Self, new_shape: []const usize) !Self
pub fn viewWithStrides(self: *const Self, shape: []const usize, strides: []const usize) !Self
pub fn viewWithStridesOffset(self: *const Self, shape: []const usize, strides: []const usize, offset_delta: usize) !Self
pub fn broadcastTo(self: *const Self, target_shape: []const usize) !Self
pub fn broadcastToRank(self: *const Self, comptime target_rank: usize, target_shape: [target_rank]usize) !Self
cloneView— identical view, one more reference. This is how weight tensors are shared across module structs.reshape— requires contiguity (UnsupportedViewotherwise) and a matching element count (InvalidShape); the result is a retained view over the same storage, never a copy. Non-contiguous tensors must be materialized first (clone, orExecContext.materialize*/ the tagged layer'scontiguousForReshapeOf, §6/§7).viewWithStrides/viewWithStridesOffset— arbitrary strided (sub)views, checked:strides.lenmust matchshape.len(InvalidShape) and the maximal reachable indexoffset + offset_delta + Σ (dim-1)·stridemust lie inside the buffer (InvalidDataLength).offset_deltaadvances the view's start; this is the raw narrowing primitive (there is no dedicatednarrowon the raw type —ExecContext.narrowAxisRank/narrowAxisRankTypedin §6 and the facade'snarrowin §4 are built on it).broadcastTo/broadcastToRank— zero-stride broadcast views, right-aligned like NumPy: the source rank must not exceed the target rank (ShapeMismatch), new leading axes get stride 0, matching axes keep their stride, size-1 axes get stride 0, and any other mismatch isShapeMismatch.broadcastTodispatches over runtime target rank 1–8 (InvalidShapebeyond);broadcastToRanktakes the rank at comptime.
Block-quantized restriction: for block dtypes, reshape and the
viewWithStrides* family accept only the identity view (same shape, same
strides, offset_delta == 0) and otherwise return UnsupportedView; blocks
are indivisible, so only whole-tensor aliasing is a view. Broadcasting
follows the generic path but is only meaningful on non-trailing axes.
fn rawViewTour(alloc: std.mem.Allocator, blocks: []const fucina.BlockQ8_0) !void {
const RawTensor = fucina.internal.RawTensor; // TensorOf(.f32)
var x = try RawTensor.fromSlice(alloc, &.{ 2, 3 }, &.{ 1, 2, 3, 4, 5, 6 });
defer x.deinit();
// Transposed view: same buffer, swapped strides, not contiguous.
var t = try x.viewWithStrides(&.{ 3, 2 }, &.{ 1, 3 });
defer t.deinit();
std.debug.assert(t.buffer == x.buffer);
std.debug.assert(!t.isContiguous());
// t.data() would panic here; the checked accessor reports an error.
try std.testing.expectError(error.UnsupportedView, t.dataChecked());
// clone materializes any view into fresh contiguous storage.
var m = try t.clone(alloc);
defer m.deinit();
std.debug.assert(m.isContiguous() and m.canTakeInPlace());
// Zero-stride broadcast view; reshape of a contiguous tensor is a view.
var b = try x.broadcastTo(&.{ 4, 2, 3 });
defer b.deinit();
var flat = try x.reshape(&.{6});
defer flat.deinit();
// Non-f32 raw tensors: fucina.internal.tensor_mod.TensorOf(dtype).
var ids = try fucina.internal.tensor_mod.TensorOf(.u16)
.fromSlice(alloc, &.{ 2, 3 }, &.{ 1, 2, 3, 4, 5, 6 });
defer ids.deinit();
// Block-quantized raw tensor: last axis in logical elements, storage in
// blocks (here 4*64/32 == 8 BlockQ8_0 storage elements).
var w = try fucina.internal.tensor_mod.TensorOf(.q8_0)
.fromStorageSlice(alloc, &.{ 4, 64 }, blocks);
defer w.deinit();
}
8.5.4 Data access and materialization¶
pub fn data(self: *Self) []Elem // PANICS on non-contiguous
pub fn dataConst(self: *const Self) []const Elem // PANICS on non-contiguous
pub fn dataChecked(self: *Self) ![]Elem // error.UnsupportedView instead
pub fn dataConstChecked(self: *const Self) ![]const Elem
pub fn item(self: *const Self) Elem // scalar dtypes; asserts len() == 1
pub fn copyTo(self: *const Self, dst: []Elem) !void
pub fn clone(self: *const Self, allocator: Allocator) !Self
data/dataConst return buffer.data[offset .. offset + storageLen()] and
panic on non-contiguous tensors ("Tensor.data requires a contiguous
tensor; materialize or use dataChecked") — they are for hot paths that have
already established contiguity. dataChecked/dataConstChecked are the
recoverable variants (UnsupportedView). item() debug-asserts a
single-element tensor and reads through dataConst (so it also requires
contiguity — a zero-stride broadcast scalar panics).
copyTo(dst) writes the logical contents into a caller slice of exactly
storageLen() elements (InvalidDataLength otherwise): a straight memcpy
when contiguous, an odometer copy for non-contiguous scalar tensors (the
maximal row-major-contiguous axis suffix moves as whole memcpy runs, a
strided innermost axis as a stride-increment loop — never a per-element
division; dim-1 axes are absorbed, so a spuriously non-contiguous singleton
view still copies as one memcpy), and UnsupportedView for non-contiguous
block tensors. copyRangeTo(dst, linear_start, count) is the range form
(scalar dtypes): disjoint ranges of the row-major linearization may be
copied concurrently, which is how the exec runtime parallelizes large
materializations (§6). clone(allocator) is the materialization path: it
allocates a fresh contiguous buffer and copyTos into it — the result is
always contiguous with offset == 0, regardless of the source view.
8.5.5 In-place helpers and canTakeInPlace¶
Scalar-dtype-only mutators (compile error for block dtypes):
addInPlace(other) (ShapeMismatch unless shapes match; both operands go
through data()/dataConst() and hence panic when non-contiguous),
scaleInPlace(scalar_value), and fill(value).
// Safe only when the caller owns exclusive access to this Tensor value.
// The refcount proves no other retained Tensor aliases the buffer now; it
// is not a lock against another thread retaining the same Tensor later.
pub fn canTakeInPlace(self: *const Self) bool // offset == 0 and isContiguous() and buffer.isUnique()
canTakeInPlace is the ownership optimization used by consuming ops to
steal an operand's buffer and write the result in place instead of
allocating. The exact contract: it returns true only for a full-buffer
(offset == 0), contiguous, uniquely-referenced tensor, and the answer is
trustworthy only while the caller has exclusive access to the tensor
handle — the refcount check proves no other view aliases the buffer at
that instant; it is not synchronization, and another thread that could
still retain/read the same handle invalidates the optimization by contract,
not by any runtime check.
8.5.6 Fixed-rank views¶
pub fn RankedTensorOf(comptime tensor_dtype: DType, comptime rank: usize) type {
return struct {
tensor: *const TensorOf(tensor_dtype),
shape: [rank]usize,
strides: [rank]usize,
pub fn dim(self: @This(), comptime axis: usize) usize
pub fn len(self: @This()) usize
pub fn isContiguous(self: @This()) bool
};
}
pub fn RankedTensor(comptime rank: usize) type // f32 alias
pub fn rankView(self: *const Self, comptime rank_value: usize) !RankedTensorOf(dtype, rank_value)
rankView copies the runtime shape/strides into comptime-sized arrays
(InvalidShape when the tensor's rank differs). The result borrows the
tensor — no retain; it must not outlive it. This is the bridge from
runtime-rank tensors to rank-specialized kernels: with [rank]usize in
hand, loops unroll at comptime (inline while/inline for), which is how
the exec layer's *Rank entry points (§6) and backend kernels (§9) are
written.
8.5.7 Shape arithmetic (free functions)¶
pub helpers in src/tensor.zig, shared by the exec and tagged layers:
requireSameShape(a, b)/requireSameShapeOf(dtype, a, b)—ShapeMismatchunless shapes are equal.elementCount(shape)/elementCountArray(rank, shape)— logical element count;InvalidShapefor rank 0/>8 or zero dims; overflow-checked.storageElementCount(dtype, shape)/storageElementCountArray(...)— storage element count; for block dtypes the last axis must be a nonzero multiple ofblockSize(dtype)(InvalidShapeotherwise).elementCountArrayAssumeValid(rank, shape)— unchecked product for already-validated shapes.
Thread-safety. Raw tensors have no interior locking. The only atomic state is the buffer refcount; concurrent readers of one buffer are safe, concurrent writers (or a writer racing readers) need external coordination. The runtime never mutates shared storage concurrently except by partitioning disjoint ranges across the worker team (§9).
8.6 The fucina.internal escape hatch (src/fucina.zig)¶
The public root deliberately does not export the raw tensor type, and an
anti-regression guard makes reintroducing it a compile error on any build
that analyzes the module root (every test, example, and tool — not just
zig build test):
comptime {
if (@hasDecl(@This(), "RawTensor")) @compileError(
"fucina.RawTensor must not be exported at the public root; raw tensors are internal. " ++
"Use fucina.internal.RawTensor (in-tree raw naming) or bench_raw.RawTensor (microbench).",
);
}
The rationale is API shape, not capability: the no-grad Tensor facade has
negligible forward overhead, so model and example code carries
fucina.Tensor(spec) end-to-end, and a public raw type would split the
ecosystem into two tensor vocabularies. Code that genuinely needs the raw
layer names it through fucina.internal; raw-kernel microbenchmarks use the
separate bench_raw module (src/bench_raw.zig), which the guard does not
affect (it inspects only the root's own decls).
pub const internal = struct {
pub const backend_mod = backend; // src/backend.zig
pub const tensor_mod = tensor; // src/tensor.zig
pub const thread_mod = thread; // src/thread.zig
pub const gpu = struct { ... }; // GPU hooks, see below
pub const RawTensor = tensor.Tensor; // TensorOf(.f32)
};
backend_mod,tensor_mod,thread_mod— the internal surface for sibling modules (notablyfucina_llm, §13) that need exact core type identity without importing a second copy of the backend/exec files: aTensorOf(.q4_k)from a re-importedtensor.zigwould be a distinct, incompatible type.tensor_modgives typed raw tensors (TensorOf(dtype),RankedTensorOf, the shape helpers);thread_modthe thread-pool primitives (Pool,WaitGroup,Mutex, ..., §9);backend_modthe kernel entry points and packed-RHS types (§9).RawTensor— the canonical internal name for the raw no-grad f32 tensor. Intended users: runtime/backend internals, raw-kernel benchmarks, serialization/format byte work, and tests targeting raw runtime behavior.gpu— hooks for model loaders and benchmark instrumentation, deliberately kept off the public root: users keep ordinary eagerTensorvalues; residency and tracing are backend-owned details (§9).
| Hook | Type / signature | Purpose |
|---|---|---|
enabled |
bool (comptime) |
true on GPU builds (-Dgpu=metal or -Dgpu=cuda, §2): GPU GEMM offload is compiled in |
has_quant_gemm |
bool (comptime) |
provider implements dequant-in-kernel quantized GEMM (dense + grouped MoE). Loaders that reshape CPU representations for the GPU quant path key on this, not on enabled — a provider can be enabled while its quantized arms are still CPU-only |
has_q5_k_quant |
bool (comptime) |
provider additionally implements Q5_K dense/grouped quantized kernels (CUDA only at present, §9.9.2) |
has_tq2_0_quant |
bool (comptime) |
provider additionally implements the TQ2_0 dequant-in-kernel GEMM (Metal only at present, §9.9.1); the .tq2_0 GPU offload arms and the ternary/PTQTP loader layouts key on it |
allocResidentBytes |
fn (len: usize) ?[]u8 |
device-owned bytes for GPU-build loaders; null when unavailable (no device context, len == 0, or too large) |
freeResidentBytes |
fn (bytes: []const u8) void |
release bytes returned by allocResidentBytes; safe no-op when the device context is gone or the slice is foreign |
traceEnabled |
fn () bool |
opt-in dispatch tracing, enabled by FUCINA_GPU_TRACE=1 |
traceReset |
fn () void |
reset trace counters (call before a warm measurement window); no-op when tracing is off |
traceDump |
fn () void |
print the accumulated dispatch/time breakdown to stderr; no-op when tracing is off |
The hooks resolve at comptime through src/backend/gpu.zig to the active
provider (src/backend/metal.zig or src/backend/cuda.zig; -Dgpu=none
resolves to metal.zig with enabled == false). Call sites gate
shim-touching calls on comptime gpu.enabled so CPU-only builds
comptime-elide every provider reference; traceReset/traceDump may be
called unconditionally on GPU builds since they no-op when tracing is off.
fn residentScratch(len: usize) !void {
const gpu = fucina.internal.gpu;
if (comptime gpu.enabled) {
// Device-owned bytes for GPU-build loaders; null when unavailable.
const bytes = gpu.allocResidentBytes(len) orelse return error.OutOfMemory;
defer gpu.freeResidentBytes(bytes);
// Dispatch tracing (FUCINA_GPU_TRACE=1); reset/dump no-op when off.
gpu.traceReset();
if (gpu.traceEnabled()) gpu.traceDump();
}
}
For inspection (as opposed to construction), the public facade already
crosses the boundary: every public tensor exposes asRawTensor(), returning
*const raw tensor whose metadata can be read without owning anything:
test "asRawTensor exposes raw shape/stride metadata" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var x = try fucina.Tensor(.{ .rows, .cols }).fromSlice(&ctx, .{ 2, 3 }, &.{ 1, 2, 3, 4, 5, 6 });
defer x.deinit();
const raw = x.asRawTensor(); // *const fucina.internal.RawTensor
try std.testing.expectEqualSlices(usize, &.{ 2, 3 }, raw.shape.slice());
try std.testing.expectEqualSlices(usize, &.{ 3, 1 }, raw.strides.slice());
try std.testing.expectEqual(@as(usize, 0), raw.offset);
try std.testing.expect(raw.isContiguous());
// Scalar-tag tensors are rank-1 shape {1} at the raw layer (no rank 0).
var total = try x.sumAll(&ctx);
defer total.deinit();
try std.testing.expectEqualSlices(usize, &.{1}, total.asRawTensor().shape.slice());
}
compiled & run in CI ✓