vkML 0.1.0

Linear algebra & NN

10 functions — 10 documented.

matmul

matmul(a: Tensor, b: Tensor) → Tensor
CPUVulkan

Matrix product of two tensors.

The behaviour depends on the dimensionality of the arguments:

Six pipelines sit behind this one operatorgemv, gemm_naive, gemm_tiled, gemm_db, gemm_reg and gemm_split_k_reduce — and it is the only operator in vkML that creates more than one. Which runs is decided per dispatch, in this order:

The register block is BM=32, BN=32, RM=2, RN=2 with BK=32. BK is 32 rather than 16 for two measured reasons: it halves the K-tile count, which costs one fewer carry-stack level and therefore fewer registers; and it makes each block a 32-element sequential sum, exactly matching kPairwiseBlock in src/backend/cpu/reduce.h, so the two backends fold K with the same structure.

Parameters

input (Tensor)
The left operand.
other (Tensor)
The right operand. Its second-to-last axis must match input's last axis.

Returns

The product, with batch axes broadcast.

ⓘ Note

Accumulation is always float32, including for float16 inputs. vkML does not offer float16 accumulation at any tile size — it is a common way to buy throughput and it is incompatible with the numerical contract.

⚠ Warning

The fallback is decided on what the device permits, not what you asked for. An explicit VKML_GEMM_KERNEL request for a kernel the device cannot run is still overridden, because the alternative is a DeviceError the caller can do nothing about. It is logged once per device, not per dispatch.

★ Tip

Split-K is bit-identical to the unsplit kernel, and that is a proof rather than a measurement: the chunk is always a power-of-two number of K-tiles, so no fold inside a partition crosses a boundary whose tile index has q low zero bits, which makes every partial exactly a subtree of the unsplit carry stack. GEMV is bit-identical to the tiled kernel by the same argument applied across lanes instead of across workgroups.

Example

>>> a = vkml.tensor(np.random.rand(64, 128).astype(np.float32))
>>> b = vkml.tensor(np.random.rand(128, 32).astype(np.float32))
>>> vkml.matmul(a, b).shape
(64, 32)

From the header

[[nodiscard]] Tensor matmul(const Tensor& a, const Tensor& b);include/vkml/api/ops.h:416

Matrix multiply, following torch.matmul for ranks 1-4: leading axes are treated as batch and broadcast against each other.

Implementation

Declared ininclude/vkml/api/ops.h:416
Graph nodeOpKind::Matmul
CPU kernelsrc/backend/cpu/kernels_matmul.cpp:89
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient ruleautograd.cpp:548
DecisionsADR 0005 ADR 0009 ADR 0010 ADR 0011
Benchmarkedmatmul
Historycommits touching the CPU kernel
Tests (≥27)test_autograd_vs_torch.py test_device_limits.py test_f16.py test_invariants.py test_ops_vs_torch.py test_vulkan_kernels.py

See also conv2d, im2col

softmax

softmax(input: Tensor, dim: int = -1) → Tensor
CPUVulkan

Apply the softmax function along one axis.

Computes exp(xᵢ − max(x)) / Σ exp(xⱼ − max(x)) along dim.

One workgroup per row, three passes inside it: the maximum over the axis, the sum of exp(x − max), then the normalised write. All three share a single workgroup, so the two reductions synchronise with barrier() rather than needing separate dispatches and a global barrier — which is the whole reason softmax is one kernel and not three. The shader carries seven barriers, more than any other in the project.

The maximum is subtracted before exponentiating so a large logit cannot overflow: exp reaches +inf above about 88.72 in float32.

Parameters

input (Tensor)
Any shape, float dtype.
dim (int = -1) optional
Axis to normalise over. Negative counts from the end.

Returns

A tensor of the same shape whose values along dim sum to 1.

ⓘ Note

The sum pass folds pairwise, matching the reduction family, so a long axis keeps the O(log n) error bound rather than accumulating sequentially.

Example

>>> x = vkml.tensor(np.array([[1.0, 2.0, 3.0]], dtype=np.float32))
>>> vkml.softmax(x, -1).numpy()
array([[0.09003057, 0.24472848, 0.66524094]], dtype=float32)

Implementation

Declared ininclude/vkml/api/ops.h:411
Graph nodeOpKind::Softmax
CPU kernelsrc/backend/cpu/kernels_reduce.cpp:256
Vulkan shadershaders/softmax.comp (153 lines · 4 specialisation constants)
Gradient ruleautograd.cpp:528
DecisionsADR 0009 ADR 0013
Benchmarkedsoftmax
Historycommits touching the CPU kernel
Tests (≥11)test_autograd_vs_torch.py test_device_limits.py test_f16.py test_operand_packing.py test_ops_vs_torch.py test_vulkan_kernels.py

See also log_softmax, cross_entropy, sum

log_softmax

log_softmax(input: Tensor, dim: int = -1) → Tensor
CPUVulkan

The logarithm of softmax, computed without forming the softmax.

x − max(x) − log(Σ exp(x − max(x))), sharing the softmax kernel and its three-pass structure — the difference is only what the final pass writes.

Computing it this way is not an optimisation. log(softmax(x)) underflows: a probability reaches exactly 0 in float32 once the logit gap passes about 90, and log(0) is -inf, which then poisons every gradient in the batch.

Parameters

input (Tensor)
Any shape, float dtype.
dim (int = -1) optional
Axis to normalise over.

Returns

A tensor of the same shape.

Example

>>> x = vkml.tensor(np.array([[1.0, 2.0, 3.0]], dtype=np.float32))
>>> vkml.log_softmax(x, -1).numpy()
array([[-2.407606  , -1.4076059 , -0.40760595]], dtype=float32)

Implementation

Declared ininclude/vkml/api/ops.h:412
Graph nodeOpKind::LogSoftmax
CPU kernelsrc/backend/cpu/kernels_reduce.cpp:258
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient ruleautograd.cpp:538
Benchmarkedlog_softmax
Historycommits touching the CPU kernel
Tests (≥6)test_autograd_vs_torch.py test_f16.py test_nn_vs_torch.py test_ops_vs_torch.py

See also softmax, cross_entropy, kl_div

layer_norm

layer_norm(input: Tensor, normalized_axes: int = 1, eps: float = 1e-05) → Tensor
CPUVulkan

Normalise over the trailing axes to zero mean and unit variance.

Takes a count of trailing axes rather than a shape: normalized_axes=1 normalises over the last axis, 2 over the last two. No weight or bias — vkML's layer_norm is the normalisation alone, and vkml.nn.LayerNorm applies the affine transform on top of it.

Parameters

input (Tensor)
Any shape, float dtype.
normalized_axes (int = 1) optional
How many trailing axes to normalise over.
eps (float = 1e-5) optional
Added to the variance before the square root.

Returns

A tensor of the same shape and dtype.

Example

>>> x = vkml.tensor(np.array([[1.0, 2.0, 3.0]], dtype=np.float32))
>>> vkml.layer_norm(x, 1, 1e-5).numpy()
array([[-1.2247356,  0.       ,  1.2247356]], dtype=float32)

From the header

[[nodiscard]] Tensor layer_norm(const Tensor& a, int normalized_axes = 1, double eps = 1e-5);include/vkml/api/ops.h:332

Standardises over the last normalized_axes axes: subtract the mean, divide by the standard deviation. No affine term -- nn.LayerNorm applies its own weight and bias with mul/add, which the autograd handles without a special case.

Composed from mean/sub/square/rsqrt rather than fused. That is the two-pass algorithm, which is the numerically sound one: computing the variance as E[x^2] - E[x]^2 in one pass cancels catastrophically when the mean is large relative to the spread. A fused kernel would save bandwidth, not accuracy, and is deferred until a profile says it matters.

normalized_axes counts trailing axes rather than naming a shape, which is a deliberate divergence from torch's normalized_shape. It carries the same information for every legal call and needs no shape validation.

Implementation

Declared ininclude/vkml/api/ops.h:332
CPU kernelcomposed from other operators
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient rulenone — backward through it raises
Tests (≥5)test_ops_vs_torch.py test_vulkan_kernels.py

See also rms_norm, batch_norm, rsqrt

rms_norm

rms_norm(input: Tensor, normalized_axes: int = 1, eps: float = 1e-05) → Tensor
CPUVulkan

Normalise over the trailing axes by root-mean-square, without centring.

x / sqrt(mean(x²) + eps). Unlike layer_norm it does not subtract the mean, which makes it cheaper — one reduction instead of two — and is why transformer implementations increasingly prefer it.

Parameters

input (Tensor)
Any shape, float dtype.
normalized_axes (int = 1) optional
How many trailing axes to normalise over.
eps (float = 1e-5) optional
Added to the mean square before the root.

Returns

A tensor of the same shape and dtype.

Example

>>> x = vkml.tensor(np.array([[1.0, 2.0, 3.0]], dtype=np.float32))
>>> vkml.rms_norm(x, 1, 1e-5).numpy()
array([[0.46290955, 0.9258191 , 1.3887286 ]], dtype=float32)

From the header

[[nodiscard]] Tensor rms_norm(const Tensor& a, int normalized_axes = 1, double eps = 1e-5);include/vkml/api/ops.h:344

As layer_norm but without centring: divides by the root mean square. Used by Llama-family models, where dropping the mean subtraction is most of the speedup and costs nothing measurable in quality.

DEFAULT eps DIVERGES FROM TORCH, deliberately. torch.nn.functional.rms_norm defaults eps to finfo(dtype).eps (1.19e-7 for f32), which is small enough to be a no-op except for an exactly-zero input. vkml uses 1e-5, matching layer_norm here and what Llama-family implementations actually ship. The two are otherwise identical: pass eps explicitly and the results agree to 7e-7, which is how the parity tests compare them.

Implementation

Declared ininclude/vkml/api/ops.h:344
CPU kernelcomposed from other operators
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient rulenone — backward through it raises
Tests (≥3)test_ops_vs_torch.py

See also layer_norm, rsqrt

batch_norm

batch_norm(input: Tensor, mean: Tensor, variance: Tensor, weight: Tensor = Tensor(<undefined>), bias: Tensor = Tensor(<undefined>), eps: float = 1e-05) → Tensor
CPUVulkan

Normalise each channel by supplied statistics, then scale and shift.

(x − mean) / sqrt(variance + eps) · weight + bias, with the statistics passed in rather than computed. That is deliberate: this operator does not decide between batch statistics and running statistics, so it has no train/eval mode and no hidden state. vkml.nn.BatchNorm2d owns that decision and the running buffers, and calls this.

Parameters

input (Tensor)
(N, C, …) — channels on axis 1.
mean (Tensor)
Per-channel mean, (C,).
variance (Tensor)
Per-channel variance, (C,).
weight (Tensor = undefined) optional
Per-channel scale, (C,). Omit for none.
bias (Tensor = undefined) optional
Per-channel shift, (C,). Omit for none.
eps (float = 1e-5) optional
Added to the variance before the root.

Returns

A tensor of the same shape as input.

From the header

[[nodiscard]] Tensor batch_norm(const Tensor& input, const Tensor& mean, const Tensor& variance, const Tensor& weight = Tensor{}, const Tensor& bias = Tensor{}, double eps = 1e-5);include/vkml/api/ops.h:264

Applies a batch normalisation given the statistics to use: (input - mean) / sqrt(variance + eps) * weight + bias.

mean and variance are rank-1 with one entry per channel, where the channel is axis 1. weight and bias are optional and likewise per-channel.

TAKES THE STATISTICS; IT DOES NOT CHOOSE OR UPDATE THEM. Whether to use the batch's own statistics or the running estimate is a property of training mode, and updating the running estimate is a mutation across calls -- both belong to the nn module, which owns that state, exactly as the optimisers own theirs. Keeping this function pure is what makes it directly comparable against torch.nn.functional.batch_norm.

A CALLER COMPUTING BATCH STATISTICS MUST USE THE BIASED VARIANCE (divide by N, not N-1) here, while updating a running estimate with the UNBIASED one. That is torch's behaviour, verified, and the asymmetry is deliberate on their part: the biased estimate is the right normaliser for the batch in hand, and the unbiased one the right estimator of the population. Using one for both makes evaluation drift away from training as the running estimate converges to the wrong value -- invisible in a single-step comparison.

Implementation

Declared ininclude/vkml/api/ops.h:264
CPU kernelcomposed from other operators
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient rulenone — backward through it raises
Tests (≥4)test_ops_vs_torch.py

See also layer_norm, rms_norm

dropout

dropout(input: Tensor, p: float, seed: int, offset: int = 0, training: bool = True) → Tensor
CPUVulkan

Randomly zero elements with probability p, scaling the rest.

Inverted dropout: survivors are scaled by 1/(1−p) during training so the expected value is unchanged and inference needs no compensation.

The mask comes from the same counter-based Philox generator as rand, which is why the signature takes (seed, offset) rather than reading hidden state. The mask is a pure function of those, so it is reproducible across runs and identical on both backends — and you must advance offset between steps or every step drops the same elements.

Parameters

input (Tensor)
Any shape, float dtype.
p (float)
Probability of zeroing an element. 0.0 is the identity.
seed (int)
Identifies the random stream.
offset (int = 0) optional
Position in the stream. Advance between steps.
training (bool = True) optional
When False, returns the input unchanged.

Returns

A tensor of the same shape and dtype.

⚠ Warning

There is no global RNG state. Passing the same (seed, offset) on every step produces the same mask every step, which trains a model with a fixed set of dead units rather than with dropout.

From the header

[[nodiscard]] Tensor dropout(const Tensor& input, double p, uint64_t seed, uint64_t offset = 0, bool training = true);include/vkml/api/ops.h:241

Zeroes each element independently with probability p and scales the rest by 1 / (1 - p), following torch.nn.functional.dropout.

The scaling happens at training time -- "inverted dropout" -- so that evaluation is the identity and needs no compensating factor. training = false returns the input unchanged, which is why the flag is a parameter rather than something the caller branches on.

The mask comes from rand(seed, offset), so the caller owns reproducibility: the same seed and offset give the same mask. A training loop must advance the offset between steps, or every step drops the same elements.

Implementation

Declared ininclude/vkml/api/ops.h:241
CPU kernelcomposed from other operators
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient rulenone — backward through it raises
Tests (≥5)test_ops_vs_torch.py test_vulkan_kernels.py

See also rand

conv2d

conv2d(input: Tensor, weight: Tensor, bias: Tensor = Tensor(<undefined>), stride: Sequence[int] = [1, 1], padding: Sequence[int] = [0, 0], dilation: Sequence[int] = [1, 1]) → Tensor
CPUVulkan

Apply a 2-D convolution over a batch of images.

Lowered to im2col followed by matmul, not implemented as a direct convolution. src/api/ops.cpp builds it in four steps:

The consequence is that every GEMM improvement reaches convolution for free — and so does every GEMM limitation.

Parameters

input (Tensor)
(N, C_in, H, W).
weight (Tensor)
(C_out, C_in, kh, kw).
bias (Tensor = undefined) optional
(C_out,). Omit for none.
stride (Sequence[int] = [1, 1]) optional
Step in (H, W).
padding (Sequence[int] = [0, 0]) optional
Zero padding on both sides of (H, W).
dilation (Sequence[int] = [1, 1]) optional
Spacing between kernel elements.

Returns

(N, C_out, H_out, W_out).

ⓘ Note

The im2col expansion is materialised in memory: a 3×3 kernel makes the intermediate roughly nine times the input. Implicit GEMM — folding the im2col addressing into the GEMM's operand load so the expansion is never written — is the standard fix and is recorded in the extensibility roadmap, not implemented.

⚠ Warning

Grouped and depthwise convolution are not supported. The weight's input channels must equal the input's, and ops.cpp rejects anything else by name rather than computing something wrong.

Example

>>> x = vkml.tensor(np.random.rand(8, 3, 32, 32).astype(np.float32))
>>> w = vkml.tensor(np.random.rand(16, 3, 3, 3).astype(np.float32))
>>> vkml.conv2d(x, w, stride=[1, 1], padding=[1, 1]).shape
(8, 16, 32, 32)

From the header

[[nodiscard]] Tensor conv2d(const Tensor& input, const Tensor& weight, const Tensor& bias = Tensor{}, std::array<int, 2> stride = {1, 1}, std::array<int, 2> padding = {0, 0}, std::array<int, 2> dilation = {1, 1});include/vkml/api/ops.h:210

2D convolution, following torch.nn.functional.conv2d.

input is (N, C_in, H, W), weight is (C_out, C_in, kernel_h, kernel_w), and bias is (C_out) or undefined.

Implemented as convolution-as-GEMM: im2col to lay each window out as a column, then one matmul against the flattened weights. That reuses the tuned matmul instead of needing a direct convolution kernel, and its gradient falls out of the gradients for im2col, matmul and reshape -- there is no conv-specific backward rule anywhere.

GROUPS ARE NOT SUPPORTED. Grouped and depthwise convolution need either a batched matmul over the group axis or a separate kernel, and nothing in the current roadmap uses them. Passing a weight whose input-channel extent disagrees with the input's is rejected rather than silently misinterpreted.

Implementation

Declared ininclude/vkml/api/ops.h:210
CPU kernelcomposed from other operators
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient rulenone — backward through it raises
Tests (≥6)test_invariants.py test_ops_vs_torch.py test_vulkan_kernels.py

See also im2col, col2im, matmul, max_pool2d

max_pool2d

max_pool2d(input: Tensor, kernel: Sequence[int], stride: Sequence[int] = [0, 0], padding: Sequence[int] = [0, 0], dilation: Sequence[int] = [1, 1]) → Tensor
CPUVulkan

Maximum over each sliding window, per channel.

Accepts a non-contiguous input. The kernel addresses planes through the same Operand stride machinery every other kernel uses, rather than assuming a packed layout — so a transposed or sliced tensor pools correctly without a materialising copy first. A CONTIGUOUS specialisation constant lets the packed case skip the stride arithmetic entirely.

Parameters

input (Tensor)
(N, C, H, W).
kernel (Sequence[int])
Window size in (H, W).
stride (Sequence[int] = [0, 0]) optional
Step. Defaults to the kernel size.
padding (Sequence[int] = [0, 0]) optional
Padding on both sides of (H, W).
dilation (Sequence[int] = [1, 1]) optional
Spacing between window elements.

Returns

(N, C, H_out, W_out).

ⓘ Note

max_pool2d_backward still requires a contiguous input — the forward pass was extended and the backward one was not.

Example

>>> x = vkml.tensor(np.arange(16, dtype=np.float32).reshape(1, 1, 4, 4))
>>> vkml.max_pool2d(x, [2, 2], [2, 2]).numpy()
array([[[[ 5.,  7.],
         [13., 15.]]]], dtype=float32)

From the header

[[nodiscard]] Tensor max_pool2d(const Tensor& input, std::array<int, 2> kernel, std::array<int, 2> stride = {0, 0}, std::array<int, 2> padding = {0, 0}, std::array<int, 2> dilation = {1, 1});include/vkml/api/ops.h:278

2D max pooling, following torch.nn.functional.max_pool2d.

PADS WITH -INFINITY, not zero. A padded window must not report 0 as its maximum when every real element is negative, which is why this needs its own kernel rather than composing as max over im2col -- im2col pads with zero (verified against torch; avg_pool2d composes precisely because zero padding is what it wants).

Its gradient goes to exactly one position per window -- the first maximum in row-major order within the window, matching torch -- not split among ties.

Implementation

Declared ininclude/vkml/api/ops.h:278
Graph nodeOpKind::MaxPool2d
CPU kernelsrc/backend/cpu/kernels_movement.cpp:698
Vulkan shadershaders/max_pool2d.comp (181 lines · 11 specialisation constants)
Gradient ruleautograd.cpp:313
DecisionsADR 0006 ADR 0008 ADR 0011
Historycommits touching the CPU kernel
Tests (≥16)test_backend_parity.py test_invariants.py test_layout_and_scale.py test_ops_vs_torch.py test_vulkan_kernels.py

See also avg_pool2d, conv2d, amax

avg_pool2d

avg_pool2d(input: Tensor, kernel: Sequence[int], stride: Sequence[int] = [0, 0], padding: Sequence[int] = [0, 0]) → Tensor
CPUVulkan

Mean over each sliding window, per channel.

Parameters

input (Tensor)
(N, C, H, W).
kernel (Sequence[int])
Window size in (H, W).
stride (Sequence[int] = [0, 0]) optional
Step. Defaults to the kernel size.
padding (Sequence[int] = [0, 0]) optional
Padding on both sides of (H, W).

Returns

(N, C, H_out, W_out).

ⓘ Note

This is the operator that exposed the reduction dispatch ceiling: it issues one workgroup per output row, so 32 images of 3×64×64 reach 98,304 rows against the 65,535 Vulkan guarantees — an ordinary batch, where the elementwise path needs 16.7 million elements to get there.

Example

>>> x = vkml.tensor(np.arange(16, dtype=np.float32).reshape(1, 1, 4, 4))
>>> vkml.avg_pool2d(x, [2, 2], [2, 2]).numpy()
array([[[[ 2.5,  4.5],
         [10.5, 12.5]]]], dtype=float32)

From the header

[[nodiscard]] Tensor avg_pool2d(const Tensor& input, std::array<int, 2> kernel, std::array<int, 2> stride = {0, 0}, std::array<int, 2> padding = {0, 0});include/vkml/api/ops.h:290

2D average pooling, following torch.nn.functional.avg_pool2d with count_include_pad=True: padded positions contribute zero and are counted in the divisor.

Composed from im2col and a mean, because zero padding is exactly the semantics wanted here. Dilation is not accepted -- torch's avg_pool2d has no such parameter, and offering one would invent a behaviour to match.

Implementation

Declared ininclude/vkml/api/ops.h:290
CPU kernelcomposed from other operators
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient rulenone — backward through it raises
Tests (≥4)test_device_limits.py test_ops_vs_torch.py test_vulkan_kernels.py

See also max_pool2d, mean, conv2d

vkML — Vulkan-first machine learning in C++20. Apache-2.0. Signatures on this page are generated from the installed module.