The tensor library#
Train anywhere. Get the same weights.
vision.tensor has tensors on the GPU, reverse-mode autodiff, transformer layers, optimisers,
int8 inference and tools to audit a training run. It trains a GPT from scratch.
import numpy as np
import vision.tensor as vt
from vision.tensor.nn import GPT
from vision.tensor.optim import AdamW
dev = vt.device("intel") # "nvidia", "cpu" (llvmpipe), "numpy", "numpy64"
model = GPT(dev, vocab=65, block_size=128, dim=192, layers=4, heads=6)
opt = AdamW(model.parameters(), lr=2e-3)
loss = model.loss(x, y) # x, y: numpy integer arrays [batch, 128]
loss.backward()
opt.step()
print(vt.fingerprint(model.parameters())) # the same signature on any GPU
Tensors and autodiff#
x = vt.tensor(dev, np.ones((2, 3), np.float32), requires_grad=True)
y = (x @ x.T).sum()
y.backward()
print(x.grad.numpy())
Tensors support +, -, *, /, @, reshape, permute, transpose, .T, sum and
mean, plus the functions softmax, cross_entropy, rms_norm, gelu, embedding and
matmul. Data goes in with vt.tensor(dev, array) and comes out with t.numpy().
Every gradient is computed in a fixed order, without atomics, so training is reproducible bit by bit.
Modes#
Mode |
Guarantee |
Runs on |
Mini-GPT training (RTX 4050) |
|---|---|---|---|
|
bit-identical on every device, including the CPU and numpy |
everything |
69 k tokens/s |
|
bit-identical between GPUs that fuse multiply-add (tested: NVIDIA = Intel) |
GPUs; refuses devices without fused FMA, such as llvmpipe |
98 k tokens/s |
|
deterministic only on the same kind of GPU |
everything |
104 k tokens/s |
dev = vt.device("nvidia", mode="gpu-exact")
If in doubt, use the default. gpu-exact refuses a device that cannot give the same bits, instead
of silently giving different ones.
Number formats#
Every matrix product has a number format, chosen per call, per layer or per device:
y = vt.matmul(x, w, format="mx16")
model = GPT(dev, vocab=65, block_size=128, format="mx8") # every Linear layer
dev.format = "mx16" # the default for 2-D products on dev
The MX formats quantise both operands and compute the product exactly, rounding once at the end, so the
result is the same bits on every GPU and on the numpy device, whatever hardware computes it.
Format |
Mantissa |
Max error (normal data, relative to the largest output) |
Product, RTX 4050 |
Mini-GPT, 600 steps |
|---|---|---|---|---|
|
24 bits |
5.5e-7 |
1.2 TFLOPS |
val loss 1.7856, 69 k tokens/s |
|
14 bits |
1.0e-4 |
2.2–2.8 TOPS |
val loss 1.7855, 84 k tokens/s |
|
7 bits |
1.2e-2 |
4.2–6.1 TOPS |
val loss 1.7920, 113 k tokens/s |
mx16 trains like float32 here; mx8 is faster and slightly less accurate. mx16r is a variant of
mx16, a little less accurate on uneven rows (Qwen2.5: max logit error 0.32 against 0.09, the same text).
On GPUs with tensor cores that Vision can use (e.g. the RTX 4050), mx16r and mx8 run on them, with the
same bits as on any other device: a prompt of 512 tokens goes from 3028 to 5122 tokens/s. Training the
mini-GPT for 300 steps in mx16r gives the same validation loss as mx16 (2.0999) at 110 k tokens/s
against 93 k, and the weights trained on the RTX 4050’s tensor cores have the same SHA-256 as those trained
on the Intel UHD. The MX formats take 2-D
operands (the Linear layers); batched products, such as those of attention, stay in float32 when the
format comes from the device, and raise an error when it is asked for explicitly. With mx16, K is at
most 16 320 per product.
Language models#
vision.tensor.llm runs pretrained models of the Llama family (Qwen2 / Qwen2.5, Llama, Mistral, SmolLM2;
Decoder.load(folder, dev)) from their .safetensors files or from a .gguf file of llama.cpp (read with numpy only; bf16 becomes float32 exactly), with rotary embedding, grouped-query
attention, a key/value cache and greedy decoding. In the auditable mode the logits have the same bits
on every device, so the generated text is the same.
from vision.tensor.llm import Qwen2
model = Qwen2.load("examples/data/qwen2.5-0.5b", vt.device("intel"), format="mx16")
tokens = model.generate(ids, 64)
On Qwen2.5-0.5B-Instruct (494 M parameters):
Result |
|
|---|---|
Logits against PyTorch in float64 |
max error 6.3e-5 in float32 (PyTorch float32: 4.0e-5); the same next token at every position |
The same logits (SHA-256) |
float32 on NVIDIA, Intel and llvmpipe; |
SmolLM2-360M (Llama architecture) |
float32 within 1.0e-4 of PyTorch float64, the same next token everywhere, the same logits on NVIDIA and Intel |
Generation, RTX 4050 |
about 105 tokens/s in |
Generation, Intel UHD |
41 tokens/s in |
Constant weights are quantised once (vt.quantized(w, "mx16")). The generation is optimised for speed
without changing a bit of the logits: they are the same with or without the cache, and the same as the
model computed one product at a time.
GGUF files are read with numpy only (vision.tensor.gguf): F32, F16, BF16, Q8_0, Q4_0, Q4_1, Q5_0, Q5_1, Q4_K,
Q5_K and Q6_K are dequantised to float32 with the same bits as llama.cpp’s own gguf-py (checked for all but
Q5_K), the Q/K permutation of Llama conversions is undone, and the configuration comes from the metadata. The
official Q8_0 and Q4_K_M files of Qwen2.5-0.5B and the Q8_0 of SmolLM2-360M give the same logits on NVIDIA and
Intel and the same text as their safetensors.
generate_batch(prompts, steps) serves several sequences at once: every step their new tokens go through the
linear layers together, each sequence with its own cache, and in the MX formats every sequence gets exactly the
tokens it gets alone. On the RTX 4050, 16 sequences of Qwen2.5-0.5B give 535 tokens/s in total in mx8 (133 for
one) and 407 in mx16 (109 for one); on the Intel UHD, 93 in mx8 (55 for one).
Against llama.cpp (build b11887, llama-bench -p 512 -n 128), the same Qwen2.5-0.5B Q8_0 file, tokens/s:
RTX 4050: prompt 512 |
RTX 4050: generation 128 |
Intel UHD: prompt 512 |
Intel UHD: generation 128 |
|
|---|---|---|---|---|
llama.cpp CUDA |
18 795 |
266 |
— |
— |
llama.cpp Vulkan |
13 277 |
251 |
1 211 |
56.2 |
Vision |
7 145 |
139 |
885 |
56.4 |
Vision |
5 992 |
111 |
388 |
40.0 |
Vision is at about half of llama.cpp on the NVIDIA GPU and level with it on the Intel one for generation; it is the one of the three whose logits and text are the same on every GPU.
Besides greedy decoding, generate(..., temperature=0.8, top_p=0.9, seed=1) draws the tokens from a seed,
also the same on every machine. On Qwen2.5 the same seed gives the same poem on
the RTX 4050 and on the Intel UHD, with or without the cache.
The LLM example answers a prompt and writes a receipt binding the model (SHA-256 of its files), the
input, the format and the code to the tokens and to the signature of the logits; --verify runs it again
on another device:
python examples/llm.py "What is the capital of France?" --device nvidia --receipt answer.json
python examples/llm.py --verify answer.json --device intel # MATCH: the same tokens and logits
int8 inference#
The int8 matrix product is exact, so it gives the same result on every vendor. It runs at 3.3–4.7 TOPS on the RTX 4050.
from vision.tensor.quant import quantize_model
quantize_model(model) # replaces every Linear with an int8 QuantLinear, in place
On the mini-GPT, validation loss is 1.5216 in int8 against 1.5209 in float32, with the same logits signature on NVIDIA, Intel and llvmpipe.
Auditing a training run#
Signature on the GPU. SHA-256 runs on the GPU; only the digests go back to the host. GPU and CPU (
hashlib) give the same hash.Chained log. Every step records the hash of the batch, the hash of the weights and the loss (exact bits). Every record carries the hash of the previous one.
The batch of step t is a pure function of (seed, t), so any step can be redone without running the previous ones, on any number of GPUs.
Checkpoints with the hash of the full state, and an auditor that redoes chosen or random stretches of the run on another device and compares bit by bit.
On the mini-GPT, the log with a signature per step costs 11%.
Heterogeneous training#
DataParallel splits micro-batches between devices of different vendors. Neither the number nor the
vendor of the GPUs changes the weights: NVIDIA and Intel training together give the same SHA-256
as NVIDIA alone. cross_check recomputes one micro-batch on two devices and compares, which
detects a faulty GPU or silent data corruption.
Performance#
Same RTX 4050, same model, batch 32 × 128 tokens:
Training tokens/s |
FP32 matmul |
|
|---|---|---|
Vision, auditable |
69 k |
0.7–1.4 TFLOPS |
Vision, gpu-exact |
98 k |
1.8–2.1 TFLOPS |
Vision, fast |
104 k |
0.9–2.8 TFLOPS |
PyTorch + CUDA (FP32, eager) |
185 k |
cuBLAS 5.7–6.0 TFLOPS |
PyTorch + CUDA (TF32) |
262 k |
cuBLAS 6.3–9.9 TFLOPS |
PyTorch FP32 is still 1.9× faster than gpu-exact. Tensor cores, fused attention and mixed
precision are not implemented yet.