include/brotensor/tensor.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <cstddef> | ||
| 4 | #include <cstdint> | ||
| 5 | #include <vector> | ||
| 6 | |||
| 7 | namespace brotensor { | ||
| 8 | |||
| 9 | // ─── Dtype ───────────────────────────────────────────────────────────────── | ||
| 10 | // | ||
| 11 | // brotensor's tensor type carries a dtype tag so ops can pick the right | ||
| 12 | // kernel without a parallel tensor type per precision. Storage stays as a | ||
| 13 | // single raw `void*` (`data`); typed access is via the host_f32 / host_fp16 | ||
| 14 | // accessors. GPU backends reinterpret the same allocation for FP16 / INT8. | ||
| 15 | // | ||
| 16 | // Element sizes are fixed: FP32 = 4 bytes, FP16 = 2 bytes, BF16 = 2 bytes, | ||
| 17 | // INT8 = 1 byte, INT32 = 4 bytes. Allocation, clone, zero, and resize all use | ||
| 18 | // dtype-aware byte counts. BF16 (IEEE 754 bfloat16 — the high 16 bits of an | ||
| 19 | // FP32) is an arithmetic dtype carried only by the GPU backends; like FP16 it | ||
| 20 | // is stored as a uint16_t bit pattern on the host. Arithmetic ops dispatch on | ||
| 21 | // FP32/FP16/BF16. INT8 is currently only carried by weight-only quantised ops | ||
| 22 | // (W8A16 matmul/conv2d). INT32 is likewise a pure storage carrier — used for | ||
| 23 | // device-resident index/offset buffers (e.g. per-head offset tables for | ||
| 24 | // softmax_xent_fused_batched); no arithmetic op dispatches on it. | ||
| 25 | enum class Dtype : int { | ||
| 26 | FP32 = 0, | ||
| 27 | FP16 = 1, | ||
| 28 | INT8 = 2, | ||
| 29 | INT32 = 3, | ||
| 30 | BF16 = 4, | ||
| 31 | F64 = 5, | ||
| 32 | // GGUF legacy quants — 32-element blocks, opaque storage carriers only. | ||
| 33 | Q4_0 = 10, | ||
| 34 | Q4_1 = 11, | ||
| 35 | Q5_0 = 12, | ||
| 36 | Q5_1 = 13, | ||
| 37 | Q8_0 = 14, | ||
| 38 | Q8_1 = 15, | ||
| 39 | // GGUF K-quants — 256-element superblocks, opaque storage carriers only. | ||
| 40 | Q2_K = 20, | ||
| 41 | Q3_K = 21, | ||
| 42 | Q4_K = 22, | ||
| 43 | Q5_K = 23, | ||
| 44 | Q6_K = 24, | ||
| 45 | Q8_K = 25, | ||
| 46 | }; | ||
| 47 | |||
| 48 | // Bytes per scalar element. Returns 0 for quant dtypes (they aren't | ||
| 49 | // element-addressable — use dtype_storage_bytes() instead). | ||
| 50 | int dtype_size_bytes(Dtype); | ||
| 51 | |||
| 52 | // Elements per block. 1 for non-quant types; 32 for the legacy GGUF quants | ||
| 53 | // (Q4_0..Q8_1); 256 for the K-quants. | ||
| 54 | int dtype_block_size(Dtype); | ||
| 55 | |||
| 56 | // Bytes per block. Equals dtype_size_bytes(d) for non-quant dtypes; for quant | ||
| 57 | // dtypes it's the on-disk block size (e.g. Q4_K = 144). | ||
| 58 | int dtype_block_bytes(Dtype); | ||
| 59 | |||
| 60 | // Byte count for a tensor of `numel` elements stored as `d`. For non-quant | ||
| 61 | // types it's numel * dtype_size_bytes(d). For quant types `numel` must be a | ||
| 62 | // multiple of dtype_block_size(d) (throws std::runtime_error otherwise) and | ||
| 63 | // the result is (numel / block_size) * block_bytes. | ||
| 64 | std::size_t dtype_storage_bytes(Dtype d, std::int64_t numel); | ||
| 65 | |||
| 66 | // True iff `d` is a quant block carrier (Q*_*). | ||
| 67 | bool dtype_is_quant(Dtype); | ||
| 68 | |||
| 69 | // ─── Device ──────────────────────────────────────────────────────────────── | ||
| 70 | // | ||
| 71 | // Runtime backend tag carried on every Tensor. CPU is always available; | ||
| 72 | // CUDA / Metal are registered at runtime by `brotensor::init()` if the | ||
| 73 | // corresponding backend was compiled into this binary. Multi-GPU within a | ||
| 74 | // single backend is deliberately out of scope for now (no Device::CUDA(idx)). | ||
| 75 | enum class Device { CPU, CUDA, Metal }; | ||
| 76 | |||
| 77 | const char* device_name(Device); | ||
| 78 | |||
| 79 | // ─── Tensor ──────────────────────────────────────────────────────────────── | ||
| 80 | // | ||
| 81 | // Unified tensor: a row-major (rows, cols) buffer tagged with both a Dtype | ||
| 82 | // and a Device. Storage is a single opaque `void*` allocated through the | ||
| 83 | // backend's alloc vtable (see detail/dispatch.h); the destructor frees via | ||
| 84 | // the same vtable. Rank is fixed at 2 (matrix) or 1 (vector — cols == 1). | ||
| 85 | // | ||
| 86 | // Copyable and movable: the copy ctor / copy assignment perform a | ||
| 87 | // device-aware deep copy (identical to clone()); move transfers ownership | ||
| 88 | // of the underlying buffer. Copying a GPU-resident tensor therefore | ||
| 89 | // allocates and copies on-device — pass by reference on hot paths and use | ||
| 90 | // clone() where the copy should be explicit. The CPU backend allocates | ||
| 91 | // plain host memory through the same vtable interface so the storage layout | ||
| 92 | // is uniform across devices; for CPU tensors the typed host accessors | ||
| 93 | // (host_f32, host_fp16, at, to_host_vector) give ergonomic access without a | ||
| 94 | // device sync. | ||
| 95 | struct Tensor { | ||
| 96 | 79811 | void* data = nullptr; | |
| 97 | 79811 | int rows = 0; // rank-1 tensors: rows = N, cols = 1 | |
| 98 | 79811 | int cols = 0; | |
| 99 | 79811 | Dtype dtype = Dtype::FP32; | |
| 100 | 79811 | Device device = Device::CPU; | |
| 101 | |||
| 102 | 239433 | Tensor() = default; | |
| 103 | ~Tensor(); | ||
| 104 | |||
| 105 | // Copyable + movable. The copy ctor / copy assignment perform a | ||
| 106 | // device-aware deep copy — identical to clone() — so a Tensor can be | ||
| 107 | // used with value semantics (caches, std::vector storage, by-value | ||
| 108 | // params). clone() remains for call sites that want the copy to be | ||
| 109 | // explicit. Copying a GPU-resident tensor allocates + copies on-device. | ||
| 110 | Tensor(const Tensor&); | ||
| 111 | Tensor& operator=(const Tensor&); | ||
| 112 | Tensor(Tensor&&) noexcept; | ||
| 113 | Tensor& operator=(Tensor&&) noexcept; | ||
| 114 | |||
| 115 | // ─── Factories ───────────────────────────────────────────────────────── | ||
| 116 | // | ||
| 117 | // zeros / empty allocate on the current default device (see runtime.h — | ||
| 118 | // controlled by set_default_device() / DeviceScope, or the | ||
| 119 | // BROTENSOR_DEFAULT_DEVICE env var). `zeros` memset-zeros the buffer | ||
| 120 | // via the backend's memset_zero hook; `empty` leaves contents undefined. | ||
| 121 | static Tensor zeros(int r, int c, Dtype dt = Dtype::FP32); | ||
| 122 | static Tensor empty(int r, int c, Dtype dt = Dtype::FP32); | ||
| 123 | |||
| 124 | // Explicit-device variants — bypass the thread-local default. Useful for | ||
| 125 | // tests, multi-device pipelines, and any code that wants to pin storage | ||
| 126 | // to a specific backend regardless of caller policy. | ||
| 127 | static Tensor zeros_on(Device, int r, int c, Dtype dt = Dtype::FP32); | ||
| 128 | static Tensor empty_on(Device, int r, int c, Dtype dt = Dtype::FP32); | ||
| 129 | |||
| 130 | // Host (CPU) FP32 factories. Always allocate zero-filled storage pinned | ||
| 131 | // to Device::CPU regardless of the current default device — a parameter- | ||
| 132 | // bearing layer builds its weights on the host, then migrates the whole | ||
| 133 | // layer with to(Device). `mat` is a (rows, cols) matrix; `vec` is a | ||
| 134 | // rank-1 (n, 1) column vector. | ||
| 135 | 5786 | static Tensor mat(int r, int c) { return zeros_on(Device::CPU, r, c); } | |
| 136 | 1073 | static Tensor vec(int n) { return zeros_on(Device::CPU, n, 1); } | |
| 137 | |||
| 138 | // Host bootstrap. Allocates on the current default device and uploads | ||
| 139 | // `r * c` floats (FP32) or uint16_t bit patterns (FP16) from `src`. | ||
| 140 | // For non-CPU defaults this performs a host→device copy via the | ||
| 141 | // backend's memcpy_h2d hook; for the CPU default it's a plain memcpy. | ||
| 142 | static Tensor from_host(const float* src, int r, int c); | ||
| 143 | static Tensor from_host_fp16(const uint16_t* src, int r, int c); | ||
| 144 | static Tensor from_host_bf16(const uint16_t* src, int r, int c); | ||
| 145 | // INT8 weights (W8A16): `r * c` int8_t values, e.g. the output of | ||
| 146 | // quantize_int8_per_row_host paired with FP32 per-row dequant scales. | ||
| 147 | static Tensor from_host_int8(const int8_t* src, int r, int c); | ||
| 148 | |||
| 149 | // Variant that pins to a specific device, bypassing the default. | ||
| 150 | static Tensor from_host_on(Device, const float* src, int r, int c); | ||
| 151 | static Tensor from_host_fp16_on(Device, const uint16_t* src, int r, int c); | ||
| 152 | static Tensor from_host_bf16_on(Device, const uint16_t* src, int r, int c); | ||
| 153 | static Tensor from_host_int8_on(Device, const int8_t* src, int r, int c); | ||
| 154 | |||
| 155 | // Non-owning view over an existing backend-resident pointer. The | ||
| 156 | // returned tensor's destructor will NOT free `data`. Caller is | ||
| 157 | // responsible for lifetime. Mirrors the legacy GpuTensor::view pattern. | ||
| 158 | static Tensor view(Device, void* data, int rows, int cols, Dtype = Dtype::FP32); | ||
| 159 | |||
| 160 | // Dtype-agnostic host bootstrap: allocates on `target` and copies | ||
| 161 | // `nbytes` raw bytes from `src` — a plain memcpy for Device::CPU, a | ||
| 162 | // single memcpy_h2d otherwise. Unlike from_host*_on, this works for any | ||
| 163 | // Dtype including the opaque GGUF block-quant carriers, since it copies | ||
| 164 | // bytes() rather than interpreting elements. `nbytes` must equal the | ||
| 165 | // resulting tensor's bytes() (i.e. dtype_storage_bytes(dt, r*c)). | ||
| 166 | static Tensor from_raw_bytes_on(Device target, const void* src, | ||
| 167 | int r, int c, Dtype dt, | ||
| 168 | std::size_t nbytes); | ||
| 169 | |||
| 170 | // ─── Migration ───────────────────────────────────────────────────────── | ||
| 171 | |||
| 172 | // Returns a fresh tensor on `target` with the same shape/dtype/contents | ||
| 173 | // as `*this`. No-op clone() if already on the target device. The source | ||
| 174 | // tensor is unchanged. Uses the backend pair's memcpy_h2d / memcpy_d2h / | ||
| 175 | // memcpy_d2d hooks as appropriate. | ||
| 176 | Tensor to(Device target) const; | ||
| 177 | |||
| 178 | // Device-preserving deep copy. | ||
| 179 | Tensor clone() const; | ||
| 180 | |||
| 181 | // ─── Mutators ────────────────────────────────────────────────────────── | ||
| 182 | |||
| 183 | // memset-zero the buffer over bytes(). Dispatches through the backend's | ||
| 184 | // memset_zero hook. | ||
| 185 | void zero(); | ||
| 186 | |||
| 187 | // Reshapes to (r, c, dt); leaves contents undefined (call zero() | ||
| 188 | // afterwards if needed). Device is preserved. Storage is kept whenever | ||
| 189 | // the requested shape fits the existing allocation (capacity = the | ||
| 190 | // high-water mark of this tensor's past sizes), so a scratch buffer | ||
| 191 | // cycling through shapes stabilises at its largest size instead of | ||
| 192 | // reallocating every call — which also keeps its device pointer stable, | ||
| 193 | // a requirement for CUDA-graph-captured op sequences. Reallocates only | ||
| 194 | // when growing past capacity. A no-op if the shape and dtype already | ||
| 195 | // match. Throws std::runtime_error on a negative dimension, or if called | ||
| 196 | // on a non-owning view (a tensor from view()) — reshaping a view would | ||
| 197 | // silently sever it, so allocate a fresh tensor instead. | ||
| 198 | void resize(int r, int c, Dtype dt = Dtype::FP32); | ||
| 199 | |||
| 200 | // ─── Accessors ───────────────────────────────────────────────────────── | ||
| 201 | |||
| 202 | 46298138 | int size() const { return rows * cols; } | |
| 203 | std::size_t bytes() const; | ||
| 204 | 1 | bool is_host() const { return device == Device::CPU; } | |
| 205 |
2/2✓ Branch 0 taken 4 times.
✓ Branch 1 taken 4423 times.
|
4427 | bool empty() const { return data == nullptr || size() == 0; } |
| 206 | |||
| 207 | // Host-side typed accessors. Throw std::runtime_error if device != CPU. | ||
| 208 | // `host_f32` additionally throws if dtype != FP32; `host_fp16` if | ||
| 209 | // dtype != FP16. `host_raw` is dtype-agnostic. | ||
| 210 | float* host_f32_mut(); | ||
| 211 | const float* host_f32() const; | ||
| 212 | uint16_t* host_fp16_mut(); | ||
| 213 | const uint16_t* host_fp16() const; | ||
| 214 | uint16_t* host_bf16_mut(); | ||
| 215 | const uint16_t* host_bf16() const; | ||
| 216 | void* host_raw_mut(); | ||
| 217 | const void* host_raw() const; | ||
| 218 | |||
| 219 | // Element access helpers (host-only, FP32-only — convenience for tests). | ||
| 220 | // Throw if device != CPU or dtype != FP32 or indices out of range. | ||
| 221 | float& at(int r, int c); | ||
| 222 | float at(int r, int c) const; | ||
| 223 | |||
| 224 | // Host (CPU) FP32 convenience accessors. Thin aliases over the typed | ||
| 225 | // host accessors above — they throw via the same checks if device != CPU | ||
| 226 | // or dtype != FP32. `ptr` is the raw row-major base pointer; operator() | ||
| 227 | // is bounds-checked (r, c) access; operator[] is flat element access. | ||
| 228 | 42535008 | float* ptr() { return host_f32_mut(); } | |
| 229 | const float* ptr() const { return host_f32(); } | ||
| 230 | 7985 | float& operator()(int r, int c) { return at(r, c); } | |
| 231 | 1024 | float operator()(int r, int c) const { return at(r, c); } | |
| 232 | 3007993 | float& operator[](int i) { return host_f32_mut()[i]; } | |
| 233 | 8447822 | float operator[](int i) const { return host_f32()[i]; } | |
| 234 | |||
| 235 | // ─── Host roundtrip helpers ──────────────────────────────────────────── | ||
| 236 | // | ||
| 237 | // `to_host_vector*` downloads (if on a GPU backend) and returns a | ||
| 238 | // std::vector containing the buffer's contents in the matching scalar | ||
| 239 | // type. The copy_to_host variants write into a caller-supplied buffer | ||
| 240 | // of at least size() elements. | ||
| 241 | std::vector<float> to_host_vector() const; // FP32 only | ||
| 242 | std::vector<uint16_t> to_host_vector_fp16() const; // FP16 only | ||
| 243 | std::vector<uint16_t> to_host_vector_bf16() const; // BF16 only | ||
| 244 | void copy_to_host(float* dst) const; // FP32 only | ||
| 245 | void copy_to_host_fp16(uint16_t* dst) const; // FP16 only | ||
| 246 | void copy_to_host_bf16(uint16_t* dst) const; // BF16 only | ||
| 247 | |||
| 248 | private: | ||
| 249 | 79811 | bool owns_ = false; | |
| 250 | // Bytes actually allocated behind `data` when owns_ is true — resize() | ||
| 251 | // keeps the existing storage whenever the requested size fits, so the | ||
| 252 | // capacity is the high-water mark of past sizes. 0 for views, released, | ||
| 253 | // and default-constructed tensors. | ||
| 254 | 79811 | std::size_t cap_bytes_ = 0; | |
| 255 | void release_(); | ||
| 256 | }; | ||
| 257 | |||
| 258 | // ─── FP16 / BF16 ↔ FP32 host-side conversion helpers ─────────────────────── | ||
| 259 | // | ||
| 260 | // Pure-CPU conversion. `fp16` is IEEE 754 binary16; `bf16` is bfloat16 — the | ||
| 261 | // high 16 bits of an FP32 with round-to-nearest-even. Useful for tests and | ||
| 262 | // small preprocessing where a GPU roundtrip would be wasteful. Not intended | ||
| 263 | // for hot loops. | ||
| 264 | uint16_t fp32_to_fp16_bits(float v); | ||
| 265 | float fp16_bits_to_fp32(uint16_t bits); | ||
| 266 | uint16_t fp32_to_bf16_bits(float v); | ||
| 267 | float bf16_bits_to_fp32(uint16_t bits); | ||
| 268 | |||
| 269 | } // namespace brotensor | ||
| 270 |