Skip to content

Commit 045e829

Browse files
wlejonclaude
andcommitted
docs: correct backend coverage, document 9 missing ops and 2 headers
The docs had drifted from the code in three ways. Metal coverage was described as it stood several commits ago. a04cf93 and 5070c51 filled the slots the tables still marked CPU+CUDA-only: threshold_u8, rows_count_above, copy_d2d_strided, max_pool2d/adaptive_avg_pool2d, gather_rows/scatter_rows/scatter_rows_add, top_k_rows, conv_transpose1d/2d. Metal now leaves 6 of 260 slots null (three host-scalar loss/RNG ops, xavier_init, and the CUDA-only fused filtered_lrelu pair); CUDA leaves one, filtered_lrelu_backward, which is the composite everywhere. State the counts rather than a vague "a few inference-only ops". 82ffc5c also gave relu/tanh/ sigmoid and masked_mean_pool FP16/BF16 paths the FP16 column still read as "—". Nine ops in the X-macro appeared nowhere in op-coverage.md: flash_attention_gqa (bef5ca7), matmul_abt, softmax_rows, patch_unpack, pixel_shuffle_upsample_2x, sample_logits_into, and the axpby / add_scalar / add_channel_bias in-place family. Also fix downsample_avg_2x, attributed to resize.h when it is declared in pooling.h, and the linear_batched_int8w_fp16 row label, whose real symbol is linear_forward_batched_int8w_fp16. api.md claimed five public headers and documented six; cuda_graph.h was absent entirely, which matters because resize()'s pointer-stability guarantee exists to serve graph capture. Two outright errors: safetensors upload() is dtype-preserving, not "as FP32", and the GGUF reader carries 16 types, not the 3 listed — carrier support is broader than fused-op support, so separate the two claims. Add the runtime functions the table never picked up (shutdown, device_mem_info/trim, device_product_name), the dtype_* helper family, from_raw_bytes_on, zero(), and upload_as. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 82ffc5c commit 045e829

5 files changed

Lines changed: 97 additions & 48 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,24 @@ include/brotensor/
1414
public surface (ls ops/ is the table of contents)
1515
ops/ activation, attention, codec, concat, conv, conv1d,
1616
delta_rule, diffusion, elementwise, embedding,
17-
flash_attention, image, linear, loss, norm, optim, pooling,
18-
quant, reduction, resize, rope, sampling, spatial, spectral
19-
runtime.h init() / default-device policy / compute_dtype() /
20-
DeviceScope / sync
17+
flash_attention, image, linear, lora, loss, lstm, norm,
18+
optim, pooling, quant, reduction, resize, rope, sampling,
19+
spatial, spectral, stylegan
20+
runtime.h init() / shutdown() / default-device policy /
21+
compute_dtype() / DeviceScope / sync / device mem info+trim
2122
safetensors.h safetensors reader + writer — File/TensorView + upload* +
2223
write_file. Tensor-container format; output type is Tensor
2324
gguf.h GGUF reader — mmap'd File + TensorInfo + metadata +
24-
shape_to_2d + upload_raw; F32/F16 and Q4_K/Q6_K/Q8_0 carriers
25+
shape_to_2d + upload_raw. Carries F32/F16/BF16 + every
26+
legacy/K-quant block type; only Q4_K/Q6_K/Q8_0 have ops
27+
cuda_graph.h CUDA graph capture/replay (CudaGraph, CudaGraphCapture) —
28+
CUDA-only, gate on BROTENSOR_HAS_CUDA
2529
metal_interop.h Public Metal custom-kernel surface (Obj-C++ / .mm only)
2630
detail/op_table.h X-macro: the single canonical op list
2731
detail/dispatch.h OpsVTable / AllocVTable + register_backend + dispatch()
28-
detail/cpu/ CPU-internal helpers shared across CPU TUs (e.g. fft_core.h)
32+
detail/string_hash.h Heterogeneous string hash for the loaders' name indices
33+
detail/cpu/ CPU-internal helpers shared across CPU TUs
34+
(fft_core.h, thread_pool.h)
2935
3036
src/
3137
tensor.cpp Tensor impl — alloc/clone/to/resize/zero via AllocVTable
@@ -38,7 +44,8 @@ src/
3844
essentially the whole FP32 fwd+bwd surface (audio, vision,
3945
diffusion samplers, flash attention, …); leaves FP16/BF16/
4046
INT8/GGUF-quant slots null
41-
cuda/ *.cu — CUDA backend (gated on BROTENSOR_WITH_CUDA)
47+
cuda/ *.cu — CUDA backend (gated on BROTENSOR_WITH_CUDA);
48+
detail/ holds CUDA-internal helpers (cuda_check.h, …)
4249
metal/ *.mm — Metal backend (gated on BROTENSOR_WITH_METAL)
4350
```
4451

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ brotensor is the shared tensor layer for a family of sibling projects (`brodiffu
1717
## What's inside
1818
1919
- **A forward + backward op surface** covering the dense / attention / normalization / convolution / loss / optimizer core, plus dedicated families for:
20-
- **LLM inference** — RoPE (incl. M-RoPE), RMSNorm, SwiGLU, KV-cache append, causal flash-decode with GQA, Gated DeltaNet linear attention, GGUF fused quant matmul
20+
- **LLM inference** — RoPE (incl. M-RoPE), RMSNorm, SwiGLU, KV-cache append, causal flash-decode with GQA, GQA prefill attention (causal *or* bidirectional, for LLM2Vec-style encoders), Gated DeltaNet linear attention, GGUF fused quant matmul
2121
- **Diffusion inference** — conv2d, GroupNorm, cross-attention, fused ResBlock, AdaLN modulate, fused DDIM / Euler / DPM++ 2M sampler steps (SD 1.5, SDXL, DiT)
2222
- **Audio (TTS / STT / codecs)** — FFT/STFT spectral core, 1D convolution (incl. transposed + streaming causal), vocoder activations, VQ/FSQ codec quantization, resampling, autoregressive logit sampling
2323
- **Vision** — SAM/ViTDet decomposed-rel-pos attention, window partition, Qwen-VL spatial merge, deformable conv2d, interp2d, image preprocessing
2424
- **Training building blocks** — flash attention with backward, LSTM with full BPTT, LoRA adapters, StyleGAN3 generator primitives (modulated conv, upfirdn2d, filtered lrelu), SGD/Adam
2525
- **Precision & quantization** — the CPU backend is the complete FP32 reference; the GPU backends add FP16/BF16 paths, INT8 weight-only matmul/conv (W8A16), and GGUF block-quant kernels (Q4_K / Q6_K / Q8_0)
2626
- **Model loading** — mmap'd zero-copy readers for **safetensors** (also writes) and **GGUF**
27+
- **CUDA graph capture** (`<brotensor/cuda_graph.h>`) — capture a fixed-shape step once and replay it with a single launch, amortising per-kernel launch overhead in tight decode loops. `Tensor::resize` keeps device pointers stable across shape cycles so captured buffers stay valid
2728
2829
See [docs/op-coverage.md](docs/op-coverage.md) for the full per-op coverage tables.
2930

docs/api.md

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
# API reference
22

3-
The public surface lives in five headers:
3+
The portable public surface lives in five headers:
44

55
| Header | Contents |
66
|---|---|
7-
| `<brotensor/tensor.h>` | `Tensor`, `Dtype`, `Device`, factories, migration, host accessors, bit-conversion helpers |
8-
| `<brotensor/runtime.h>` | `init()`, device policy, `compute_dtype()`, `sync` |
7+
| `<brotensor/tensor.h>` | `Tensor`, `Dtype`, `Device`, factories, migration, host accessors, dtype + bit-conversion helpers |
8+
| `<brotensor/runtime.h>` | `init()` / `shutdown()`, device policy, `compute_dtype()`, device memory, `sync` |
99
| `<brotensor/ops.h>` | The device-neutral op surface — an umbrella over the per-category headers in `<brotensor/ops/>` (see [op-coverage.md](op-coverage.md)) |
1010
| `<brotensor/safetensors.h>` | safetensors reader + writer |
1111
| `<brotensor/gguf.h>` | GGUF reader |
1212

13-
`<brotensor/metal_interop.h>` additionally exposes the Metal custom-kernel surface (Obj-C++ / `.mm` consumers only).
13+
Two further headers are backend-specific and compile only in a build that enables that backend:
14+
15+
| Header | Contents |
16+
|---|---|
17+
| `<brotensor/cuda_graph.h>` | CUDA graph capture / replay (`CudaGraph`, `CudaGraphCapture`) — CUDA-only; gate on `BROTENSOR_HAS_CUDA` |
18+
| `<brotensor/metal_interop.h>` | Metal custom-kernel surface — Obj-C++ / `.mm` consumers only |
1419

1520
All preconditions and dispatch failures throw `std::runtime_error` with a `"brotensor: <op>: <reason>"` message.
1621

@@ -33,6 +38,7 @@ enum class Dtype { FP32, FP16, INT8, INT32, BF16, F64,
3338
| `Tensor::zeros_on(dev, r, c, dt)` / `Tensor::empty_on(dev, r, c, dt)` | Same, pinned to an explicit device. |
3439
| `Tensor::from_host(ptr, r, c)` (+ `_fp16` / `_bf16` / `_int8` variants) | Copy a host buffer to a new tensor on the default device. FP16/BF16 take `uint16_t` bit patterns, INT8 takes `int8_t`. |
3540
| `Tensor::from_host_on(dev, ptr, r, c)` (+ `_fp16_on` / `_bf16_on` / `_int8_on`) | Same, pinned to an explicit device. |
41+
| `Tensor::from_raw_bytes_on(dev, src, r, c, dt, nbytes)` | Dtype-agnostic byte-level bootstrap. Copies raw bytes rather than interpreting elements, so unlike `from_host*` it works for **any** dtype including the opaque GGUF block-quant carriers. `nbytes` must equal `dtype_storage_bytes(dt, r*c)`. |
3642
| `Tensor::mat(r, c)` / `Tensor::vec(n)` | Zero-filled FP32 **host (CPU)** tensors — build parameters on the host, then migrate with `to()`. |
3743
| `Tensor::view(dev, ptr, r, c, dt)` | Non-owning view over an existing backend-resident pointer. `resize()` on a view throws. |
3844
@@ -44,12 +50,28 @@ enum class Dtype { FP32, FP16, INT8, INT32, BF16, F64,
4450
| `t.clone()` | Device-preserving deep copy. |
4551
| `t.to_host_vector()` (+ `_fp16` / `_bf16`) | Read back to a `std::vector` (`float` / `uint16_t` bits). |
4652
| `t.copy_to_host(dst)` (+ `_fp16` / `_bf16`) | Read back into a caller-owned buffer. |
47-
| `t.resize(r, c, dt)` | Reallocate in place; contents **undefined** afterwards. Throws on a non-owning view. |
53+
| `t.zero()` | memset the buffer to zero over `bytes()`. |
54+
| `t.resize(r, c, dt)` | Reshape in place; contents **undefined** afterwards (call `zero()` if needed), device preserved. Throws on a negative dimension or a non-owning view. |
55+
56+
`resize()` reuses storage whenever the requested shape fits the existing allocation — capacity is the high-water mark of the tensor's past sizes — and reallocates only when growing past it. A no-op when the shape and dtype already match. So a scratch buffer cycling through shapes stabilises at its largest size instead of reallocating every call, **and its device pointer stays stable** — which is what makes a tensor reusable across a CUDA-graph-captured op sequence.
4857
4958
Call `sync(device)` / `sync_all()` before reading GPU results back to the host — GPU ops are asynchronous.
5059
5160
CPU-resident tensors additionally expose direct host accessors (`host_f32_mut()`, `at()`, `operator[]`, …) — see `tensor.h`.
5261
62+
### Dtype helpers
63+
64+
Free functions for sizing a buffer without special-casing the quant carriers:
65+
66+
| Helper | Meaning |
67+
|---|---|
68+
| `dtype_size_bytes(dt)` | Bytes per element. **Returns 0 for the block-quant dtypes** — they aren't element-addressable. |
69+
| `dtype_block_size(dt)` | Elements per block (32 for the legacy quants, 256 for the K-quants, 1 otherwise). |
70+
| `dtype_block_bytes(dt)` | Encoded bytes per block. |
71+
| `dtype_storage_bytes(dt, n)` | Bytes needed for `n` elements. **Use this for buffer sizes** — it's correct for quant and non-quant dtypes alike. |
72+
| `dtype_is_quant(dt)` | Whether `dt` is a GGUF block-quant carrier. |
73+
| `device_name(dev)` | The backend kind as a string (`"cpu"` / `"cuda"` / `"metal"`). |
74+
5375
### Bit-conversion helpers
5476
5577
`fp32_to_fp16_bits` / `fp16_bits_to_fp32` / `fp32_to_bf16_bits` / `bf16_bits_to_fp32` — pure-CPU scalar conversions between FP32 and half/bfloat bit patterns, for tests and small host-side preprocessing.
@@ -59,32 +81,43 @@ CPU-resident tensors additionally expose direct host accessors (`host_f32_mut()`
5981
| Function | Meaning |
6082
|---|---|
6183
| `init()` | Idempotent. Probes and registers the CUDA / Metal backends. CPU is always registered (static-init), so CPU-only code works without calling it. |
84+
| `shutdown()` | Joins the CPU backend's worker threads. Idempotent, and safe even if `init()` was never called. See the note below — **call it before returning from `main()`**. |
6285
| `default_device()` | Where no-suffix factories allocate. Best available: CUDA > Metal > CPU. |
6386
| `set_default_device(dev)` | Global override. Also overridable per-process via the `BROTENSOR_DEFAULT_DEVICE` env var (`cpu` / `cuda` / `metal`). |
6487
| `DeviceScope scope(dev)` | RAII per-scope default-device override. |
6588
| `compute_dtype()` | The dtype a model loader should upload weights at for the current default device: FP32 on CPU, FP16 on a GPU. |
6689
| `available_devices()` / `is_available(dev)` | Backends registered in this binary at runtime. |
6790
| `sync(dev)` / `sync_all()` | Drain pending backend work (no-op on CPU). |
91+
| `device_mem_info(dev, free, total)` | Device-wide free/total bytes. Returns `false` (outputs untouched) when the backend can't report; CPU always returns `false`. |
92+
| `device_mem_trim(dev, keep_bytes = 0)` | Return the allocator's cached-but-unused memory to the driver, keeping at most `keep_bytes`. Syncs the device first so stream-ordered frees are reclaimable. |
93+
| `device_product_name(dev)` | The card's human-readable name (e.g. `"NVIDIA GeForce RTX 4090"`) — distinct from `device_name()`, which is the backend kind. `""` if unavailable. |
94+
95+
**Shutdown.** The CPU backend's worker threads otherwise live until the thread pool's Meyers-singleton destructor runs during static destruction, by which point the OS has already suspended every other thread. A worker suspended mid-op while holding a global lock (e.g. the Debug CRT's iterator-checking mutex) can deadlock the main thread's own exit-time destructors on that same lock. `shutdown()` makes the teardown deterministic instead.
96+
97+
**Trimming.** `device_mem_trim` is worth calling between pipeline phases with very different scratch shapes: cached blocks count against device residency, and on Windows (WDDM) sustained near-full commit makes the OS demote large resident allocations to shared memory — silently turning weight reads into PCIe traffic.
6898
6999
## safetensors (`<brotensor/safetensors.h>`)
70100
71101
mmap'd zero-copy reader plus a writer. Namespace `brotensor::safetensors`.
72102
73103
- `File` — opens and mmaps a `.safetensors` file, parses the JSON header, exposes tensors by name as `TensorView`s (name, dtype, shape, raw byte span).
74-
- Upload helpers (view → device `Tensor`):
75-
- `upload(view, rows, cols, dst)` — as FP32;
76-
- `upload_fp16(view, rows, cols, dst)` — as FP16;
77-
- `upload_compute(view, rows, cols, dst)` — at `compute_dtype()` for the current default device;
78-
- `upload_compute_checked(...)` — same, with shape validation.
79-
- `write_file(path, entries)` — write a `.safetensors` file from host data.
104+
- Upload helpers (view → device `Tensor`). All require an F32 / F16 / BF16 source view; brotensor is 2D-only, so the caller flattens higher-rank weights to the `(rows, cols)` layout the consuming op expects:
105+
- `upload(view, rows, cols, dst)` — **dtype-preserving**: `dst` gets the brotensor dtype matching the view, zero conversion (a BF16 view yields a BF16 tensor, an F16 view an FP16 tensor);
106+
- `upload_fp16(view, rows, cols, dst)` — always FP16, converting host-side from F32 if needed;
107+
- `upload_as(view, rows, cols, want, dst)` — at an **explicit** arithmetic dtype, converting host-side. Lets one module pick a compute dtype different from the global one — e.g. Flux runs BF16 on a pipeline whose dtype is FP16, because its activations overflow FP16;
108+
- `upload_compute(view, rows, cols, dst)` — at `compute_dtype()` for the current default device, so one checkpoint serves either backend (BF16 widens to FP32 on CPU, narrows to FP16 on a GPU);
109+
- `upload_compute_checked(view, rows, cols, dst, name)` — same, but first validates the view's dtype and element count, throwing tagged with the caller-supplied `name` and the safetensors key.
110+
- `write_file(path, entries)` — write a `.safetensors` file from host data. Each `WriteEntry` carries name / dtype / shape / host pointer / byte count; dtype defaults to `F16`.
80111
- Supported on-disk dtypes: F32, F16, BF16, I32, I64, U8, BOOL.
81112
82113
## GGUF (`<brotensor/gguf.h>`)
83114
84115
mmap'd reader for GGUF model files. Namespace `brotensor::gguf`.
85116
86-
- `File` — opens and mmaps a `.gguf` file, parses header + metadata, exposes tensors as `TensorInfo` (name, GGUF type, shape, raw data span).
87-
- Metadata: `find_meta(key)` / `get_meta(key)` / `metadata()`.
88-
- `shape_to_2d(shape)` — collapse a GGUF n-d shape to brotensor's `(rows, cols)`.
89-
- `upload_raw(info, rows, cols, dst)` — upload a tensor's raw bytes to the device at its carrier dtype.
90-
- Supported carriers: F32, F16, and the block-quant types Q4_K / Q6_K / Q8_0 — consumed directly by the fused dequant/matmul ops (see [op-coverage.md](op-coverage.md)) without dequantizing on the host.
117+
- `File` — opens and mmaps a `.gguf` file, parses header + metadata, exposes tensors as `TensorInfo` (name, GGUF type, mapped brotensor `dtype`, `dtype_supported`, shape, `numel`, raw data span). Also `find_tensor()` / `get_tensor()` / `tensors()`, `version()`, `alignment()`, `tensor_count()`.
118+
- Metadata: `find_meta(key)` / `get_meta(key)` / `metadata()`, returning GGUF `Value` / `ValueType`.
119+
- `shape_to_2d(shape)` — collapse a GGUF n-d shape to brotensor's `(rows, cols)`. GGUF shapes are innermost-first, so `cols = shape[0]` and `rows = product(shape[1..])`; a 1-D shape gives `(shape[0], 1)`. Throws on an empty shape.
120+
- `upload_raw(info, rows, cols, dst)` — upload a tensor's raw bytes to the device at its carrier dtype, no host-side dequantization. Throws if `info.dtype_supported` is false; for a quant carrier, `cols` must be a multiple of `dtype_block_size(dtype)`.
121+
- **Carriers the reader maps:** F32, F16, BF16, the legacy blocks Q4_0 / Q4_1 / Q5_0 / Q5_1 / Q8_0 / Q8_1, and the K-quant superblocks Q2_K / Q3_K / Q4_K / Q5_K / Q6_K / Q8_K.
122+
123+
Note that carrier support is broader than *op* support: the reader will load any of the above, but only **Q4_K / Q6_K / Q8_0** are consumed by the fused dequant / matmul kernels (see [op-coverage.md](op-coverage.md)). Loading a Q5_K tensor succeeds; calling a matmul on it throws.

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ The vtable slot signature *is* the public signature: same argument order, same s
5151

5252
- **CPU** — scalar FP32, always compiled. It implements essentially the entire FP32 surface, forward *and* backward — including the diffusion samplers, flash attention, the audio family, and the vision primitives. It is the simple, correct, autovectorize-friendly reference that the parity tests measure the GPU backends against. By design it leaves the FP16 / BF16 / INT8-W8A16 / GGUF-quant slots null.
5353
- **CUDA** (`BROTENSOR_WITH_CUDA=ON`) — mirrors the FP32 surface and adds the FP16/BF16 precision paths, batched-inference variants, W8A16 WMMA kernels, GGUF block-quant kernels, and fused inference kernels.
54-
- **Metal** (`BROTENSOR_WITH_METAL=ON`) — same role as CUDA on Apple GPUs. A few inference-only ops are CPU+CUDA with the Metal slot left null (noted in the [coverage tables](op-coverage.md)).
54+
- **Metal** (`BROTENSOR_WITH_METAL=ON`) — same role as CUDA on Apple GPUs, and at near-total parity with it: of the 260 slots Metal leaves six null (three host-scalar loss/RNG ops, `xavier_init`, and the CUDA-only fused `filtered_lrelu` pair). See the [coverage tables](op-coverage.md#backend-coverage).
5555

56-
A handful of "ops" are not vtable entries at all but device-agnostic compositions of public ops — LoRA (`ops/lora.h`, header-only) and the `filtered_lrelu` composite fallback — so they run on any backend automatically.
56+
A handful of "ops" are not vtable entries at all but device-agnostic compositions of public ops — LoRA (`ops/lora.h`, header-only) and the `filtered_lrelu` composite — so they run on any backend automatically.
5757

5858
## Default device and scopes
5959

0 commit comments

Comments
 (0)