vkML 0.1.0

Shape & indexing

9 functions — 9 documented.

cat

cat(tensors: collections.abc.Sequence[Tensor], axis: int = 0) → Tensor
CPUVulkan

Join tensors along an existing axis.

The output index is split into (outer, along, inner) around the joined axis: inner is the product of the extents after it, outer everything before, so an element's position along the axis is (i / inner) % extent. Positions below the first operand's extent come from it; the rest from the second with the offset subtracted.

Each source index is rebuilt with that source's own axis extent. Reusing the output's extent would read past the end of the shorter operand — a trap documented in both kernels, and the reason they compute it the same way.

That property is also what brought cat's push-constant block inside the 128-byte guarantee. The operands' extents were 32 bytes restating what the output's extents plus the joined axis already said, so the shader now reconstructs them and the block dropped from 144 to 112 bytes.

Parameters

tensors (Sequence[Tensor])
Tensors to join. All must agree on every axis except the joined one.
axis (int)
Axis to join along.

Returns

A new tensor whose extent along axis is the sum of the inputs'.

⚠ Warning

The axis packed into the push constants is the index into the padded extent array — to_gpu_operand right-pads to rank 4, so a rank-2 tensor's axis 0 lives at index 2. Packing the tensor-space axis instead would address the wrong component for every operand of rank below 4, and would do it silently.

Example

>>> a = vkml.tensor(np.array([[1.0, 2.0]], dtype=np.float32))
>>> b = vkml.tensor(np.array([[3.0, 4.0]], dtype=np.float32))
>>> vkml.cat([a, b], 0).numpy()
array([[1., 2.],
       [3., 4.]], dtype=float32)

From the header

[[nodiscard]] Tensor cat(std::span<const Tensor> tensors, int axis = 0);include/vkml/api/ops.h:354

Joins tensors along axis. Every input must share the same rank, dtype, device and extents on every axis except axis.

The graph node is binary, so a list of N is folded left into N-1 nodes. That copies O(N^2) bytes for equal-sized inputs where O(N) is achievable with an n-ary node. Deferred deliberately: N is 2 for every use in scope (residual joins, UNet skips), and an n-ary node needs per-source extents in push constants for a case that does not yet exist.

In the CPU kernel

Joins two operands along one axis.

The linear index is split into (outer, along, inner) around the concatenated axis: inner is the product of the extents after it, outer everything before, so an element's position along the axis is (i / inner) % extent. Positions below the first operand's extent come from it, the rest from the second with the offset subtracted.

Each source index is rebuilt using the source's own axis extent, which is what makes this correct when the two differ -- reusing the output's extent would read past the end of the shorter operand.

Byte-wise rather than typed: concatenation moves elements without interpreting them, so one implementation serves every dtype.

src/backend/cpu/kernels_movement.cpp:275

Implementation

Declared ininclude/vkml/api/ops.h:354
Graph nodeOpKind::Cat
CPU kernelsrc/backend/cpu/kernels_movement.cpp:275
Vulkan shadershaders/cat.comp (86 lines · 2 specialisation constants)
Gradient ruleautograd.cpp:372
DecisionsADR 0009
Historycommits touching the CPU kernel
Tests (≥7)test_layout_and_scale.py test_ops_vs_torch.py test_vulkan_kernels.py

See also index_select, where

tril

tril(input: Tensor, diagonal: int = 0) → Tensor
CPUVulkan

Zero everything above the k-th diagonal.

Keeps the lower triangle. k=0 is the main diagonal, positive k keeps more above it, negative less.

Parameters

input (Tensor)
Rank 2 or higher; the last two axes are the matrix.
k (int = 0) optional
Diagonal offset.

Returns

A tensor of the same shape and dtype.

Example

>>> x = vkml.tensor(np.ones((3, 3), dtype=np.float32))
>>> vkml.tril(x, 0).numpy()
array([[1., 0., 0.],
       [1., 1., 0.],
       [1., 1., 1.]], dtype=float32)

From the header

[[nodiscard]] Tensor tril(const Tensor& a, int64_t diagonal = 0);include/vkml/api/ops.h:374

Mirror of triu, keeping the lower triangle.

Implementation

Declared ininclude/vkml/api/ops.h:374
Graph nodeOpKind::Tril
CPU kernelsrc/backend/cpu/kernels_elementwise.cpp:368
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient ruleautograd.cpp:393
Historycommits touching the CPU kernel
Tests (≥3)test_ops_vs_torch.py test_vulkan_kernels.py

See also triu, masked_fill

triu

triu(input: Tensor, diagonal: int = 0) → Tensor
CPUVulkan

Zero everything below the k-th diagonal.

Keeps the upper triangle. Combined with masked_fill this is how causal attention masks are built — nn.MultiheadAttention's is_causal path uses exactly that pair.

Parameters

input (Tensor)
Rank 2 or higher; the last two axes are the matrix.
k (int = 0) optional
Diagonal offset.

Returns

A tensor of the same shape and dtype.

Example

>>> x = vkml.tensor(np.ones((3, 3), dtype=np.float32))
>>> vkml.triu(x, 1).numpy()
array([[0., 1., 1.],
       [0., 0., 1.],
       [0., 0., 0.]], dtype=float32)

From the header

[[nodiscard]] Tensor triu(const Tensor& a, int64_t diagonal = 0);include/vkml/api/ops.h:371

Zeroes everything below the diagonal-th diagonal of the last two axes, keeping the upper triangle. diagonal = 0 keeps the main diagonal; positive values move the boundary toward the upper-right. Rank must be at least 2; leading axes are batched over.

Implementation

Declared ininclude/vkml/api/ops.h:371
Graph nodeOpKind::Triu
CPU kernelsrc/backend/cpu/kernels_elementwise.cpp:366
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient ruleautograd.cpp:389
Historycommits touching the CPU kernel
Tests (≥4)test_ops_vs_torch.py test_vulkan_kernels.py

See also tril, masked_fill, where

masked_fill

masked_fill(input: Tensor, mask: Tensor, value: float) → Tensor
CPUVulkan

Replace elements where a bool mask is true with a constant.

The fill value travels as a push constant, so no tensor is allocated for it — which is the difference from where, where both branches are tensors that already exist.

Parameters

input (Tensor)
Any shape, float dtype.
mask (Tensor)
A bool tensor, broadcastable against input.
value (float)
What to write where the mask is true.

Returns

A tensor of the same shape and dtype as input.

Example

>>> x = vkml.tensor(np.array([1.0, 2.0, 3.0], dtype=np.float32))
>>> m = vkml.greater(x, vkml.tensor(np.array([1.5, 1.5, 1.5], dtype=np.float32)))
>>> vkml.masked_fill(x, m, 0.0).numpy()
array([1., 0., 0.], dtype=float32)

From the header

[[nodiscard]] Tensor masked_fill(const Tensor& a, const Tensor& mask, double value);include/vkml/api/ops.h:365

Replaces every element where mask is true with value, following torch.masked_fill. mask must be Bool and broadcastable to a's shape.

Composed from where rather than given its own kernel: the replacement is a rank-0 tensor broadcast with stride 0, so the "fused" version would save one graph node and four bytes. See the commit that added it.

Implementation

Declared ininclude/vkml/api/ops.h:365
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 where, triu, clamp

index_select

index_select(input: Tensor, axis: int, index: Tensor) → Tensor
CPUVulkan

Gather slices along one axis by index.

The index tensor must be rank 1 and int64. The output takes the index's length along the selected axis and keeps every other axis unchanged.

Parameters

input (Tensor)
Source tensor.
axis (int)
Axis to index along.
index (Tensor)
Rank-1 int64 indices.

Returns

A new tensor with index.numel() entries along axis.

ⓘ Note

Its gradient is a scatter_add back into a zero tensor, which is why the two are implemented as a pair and why scatter_add's determinism matters for training rather than only for inference.

Example

>>> x = vkml.tensor(np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=np.float32))
>>> idx = vkml.tensor(np.array([2, 0], dtype=np.int64))
>>> vkml.index_select(x, 0, idx).numpy()
array([[5., 6.],
       [1., 2.]], dtype=float32)

From the header

[[nodiscard]] Tensor index_select(const Tensor& a, int axis, const Tensor& index);include/vkml/api/ops.h:299

Gathers along axis at the positions named by index, following torch.index_select. index must be I64 and rank 1; the result takes its extent on axis from the index length, and every other extent from a.

This is Embedding's forward pass.

In the CPU kernel

Gathers rows named by an index vector. Embedding's forward pass.

src/backend/cpu/kernels_movement.cpp:339

Implementation

Declared ininclude/vkml/api/ops.h:299
Graph nodeOpKind::IndexSelect
CPU kernelsrc/backend/cpu/kernels_movement.cpp:339
Vulkan shadershaders/index_select.comp (50 lines · 1 specialisation constants)
Gradient ruleautograd.cpp:354
Historycommits touching the CPU kernel
Tests (≥8)test_layout_and_scale.py test_ops_vs_torch.py test_vulkan_kernels.py

See also scatter_add, cat

scatter_add

scatter_add(src: Tensor, axis: int, index: Tensor, dim_size: int) → Tensor
CPUVulkan

Accumulate rows of a source into a zero tensor at given indices.

The inverse of index_select, and the operation its gradient needs. Several source rows may target the same destination, so the contributions must be added, not written — which is what makes this irreducible to the element-wise and reduction operators.

Both backends walk the source in ascending linear order, so for any destination the contributions arrive in ascending index order. That fixed order is what makes the result bit-reproducible, and the two backends agree exactly rather than merely within a tolerance.

Parameters

src (Tensor)
Source rows.
axis (int)
Axis to scatter along.
index (Tensor)
Rank-1 int64 indices, one per source row.
dim_size (int)
Extent of the output along axis.

Returns

A new tensor with dim_size entries along axis.

⚠ Warning

The GPU has no global float atomicAdd on this hardware, and the ordering above is the only way to have determinism at all without one. The consequence is a scan: the kernel is O(dim_size × index_len) rather than O(index_len). Replacing it with a sort-based segmented reduction is tracked as future work, and any replacement has to preserve the ascending order or it trades determinism for speed.

Example

>>> src = vkml.tensor(np.array([[1.0], [2.0], [3.0]], dtype=np.float32))
>>> idx = vkml.tensor(np.array([0, 0, 1], dtype=np.int64))
>>> vkml.scatter_add(src, 0, idx, 2).numpy()
array([[3.],
       [3.]], dtype=float32)

From the header

[[nodiscard]] Tensor scatter_add(const Tensor& src, int axis, const Tensor& index, int64_t dim_size);include/vkml/api/ops.h:315

Adjoint of index_select: accumulates each slice of src into the row of a zero-filled result named by index, following torch.index_add. dim_size is the extent of the result on axis.

One of the four operations docs/ARCHITECTURE.md records as genuinely needing its own kernel rather than composing from forward ops -- repeated indices mean several source slices land on one destination, which no elementwise or reduction op expresses.

DETERMINISM. The target GPU has no global float atomicAdd (shaderBufferFloat32AtomicAdd = false, measured), so the usual atomic scatter is unavailable -- and unwanted, since its order varies run to run. Both backends instead fold contributions in ascending index order, which makes the result bit-reproducible and identical between them.

In the CPU kernel

Adjoint of index_select: accumulate each source slice into the row it came from. Repeated indices mean several sources land on one destination, which is what makes this irreducible to the elementwise and reduction ops.

Walks the SOURCE in ascending linear order, so for any destination the contributions arrive in ascending index order. That fixed order is what makes the result bit-reproducible, and it is the same order the Vulkan kernel uses -- deliberately, so the two agree exactly rather than merely within a tolerance. Fixing it costs nothing here and is the only way to have it at all on the GPU, which has no global float atomicAdd.

src/backend/cpu/kernels_movement.cpp:381

Implementation

Declared ininclude/vkml/api/ops.h:315
Graph nodeOpKind::ScatterAdd
CPU kernelsrc/backend/cpu/kernels_movement.cpp:381
Vulkan shadershaders/scatter_add.comp (71 lines · 1 specialisation constants)
Gradient ruleautograd.cpp:361
Historycommits touching the CPU kernel
Tests (≥7)test_autograd_vs_torch.py test_layout_and_scale.py test_ops_vs_torch.py test_vulkan_kernels.py

See also index_select

im2col

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

Expand sliding windows into columns, the lowering behind conv2d.

(N, C, H, W) becomes (N, C·kh·kw, L), where L is the number of window positions. Every element of every window is written out, so a convolution becomes a single matrix multiply.

Parameters

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

Returns

(N, C·kh·kw, L).

⚠ Warning

The expansion is materialised in memory. A 3×3 kernel makes the intermediate roughly nine times the input, and that memory is the price conv2d pays for reaching the tuned GEMM path. Implicit GEMM — folding the addressing into the GEMM's operand load so the expansion is never written — is the standard fix and is not implemented.

Example

>>> x = vkml.tensor(np.arange(16, dtype=np.float32).reshape(1, 1, 4, 4))
>>> vkml.im2col(x, [2, 2], [2, 2], [0, 0]).shape
(1, 4, 4)

From the header

[[nodiscard]] Tensor im2col(const Tensor& input, std::array<int, 2> kernel, 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:180

Extracts sliding local blocks, following torch.nn.functional.unfold.

(N, C, H, W) becomes (N, C * kernel_h * kernel_w, L), where L is the number of window positions. Positions falling outside the padded image contribute zero. This is the first half of convolution-as-GEMM: the second half is an ordinary matmul against the flattened weights.

Implementation

Declared ininclude/vkml/api/ops.h:180
CPU kernelsrc/backend/cpu/kernels_movement.cpp:682
Vulkan shadershaders/im2col.comp (87 lines · 7 specialisation constants)
Gradient rulenone — backward through it raises
DecisionsADR 0010 ADR 0011
Historycommits touching the CPU kernel
Tests (≥12)test_layout_and_scale.py test_ops_vs_torch.py test_vulkan_kernels.py

See also col2im, conv2d, matmul

col2im

col2im(cols: Tensor, image: Sequence[int], kernel: Sequence[int], stride: Sequence[int] = [1, 1], padding: Sequence[int] = [0, 0], dilation: Sequence[int] = [1, 1]) → Tensor
CPUVulkan

Fold columns back into an image, accumulating overlaps.

The adjoint of im2col, and the operation conv2d's gradient with respect to its input needs. Where windows overlap, the contributions are summed — which is what makes it the adjoint rather than merely the inverse.

Parameters

cols (Tensor)
(N, C·kh·kw, L).
image (Sequence[int])
Spatial size (H, W) to fold back into.
kernel (Sequence[int])
Window size in (H, W).
stride (Sequence[int] = [1, 1]) optional
Step.
padding (Sequence[int] = [0, 0]) optional
Padding used by the forward im2col.
dilation (Sequence[int] = [1, 1]) optional
Spacing between window elements.

Returns

(N, C, H, W).

ⓘ Note

Because overlaps accumulate, this is one of the few operators where the two backends agree within a tolerance rather than bit-exactly — the tolerance table lists it alongside the transcendentals for that reason.

From the header

[[nodiscard]] Tensor col2im(const Tensor& cols, std::array<int, 2> image, std::array<int, 2> kernel, 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:191

Adjoint of im2col, following torch.nn.functional.fold: sums every window contribution back into the image position it came from.

Overlapping windows mean one image position receives several contributions, which is why this cannot be expressed as a gather and is one of the few operations needing its own kernel. image gives the spatial extent to reconstruct, which the column tensor does not determine.

Implementation

Declared ininclude/vkml/api/ops.h:191
CPU kernelsrc/backend/cpu/kernels_movement.cpp:690
Vulkan shadershaders/col2im.comp (109 lines · 9 specialisation constants)
Gradient rulenone — backward through it raises
DecisionsADR 0011
Historycommits touching the CPU kernel
Tests (≥9)test_autograd_vs_torch.py test_layout_and_scale.py test_ops_vs_torch.py test_vulkan_kernels.py

See also im2col, conv2d, scatter_add

detach

detach(tensor: Tensor) → Tensor
CPUVulkan

A tensor sharing the same values but outside the autograd graph.

Returns a tensor with requires_grad false and no recorded history, so a gradient cannot flow back through it. Used to freeze part of a model, or to stop a running statistic from being differentiated.

Parameters

input (Tensor)
The tensor to detach.

Returns

A tensor with the same values and no graph history.

⚠ Warning

detach currently forces realization. A lazily built graph runs at the point of the call rather than staying deferred, which costs a submission wherever it appears in a hot loop. Making it lazy is tracked as an architectural change, not a local fix — it needs the graph to represent 'same values, no history' as a node rather than as an evaluated result.

Example

>>> x = vkml.tensor(np.array([1.0], dtype=np.float32), requires_grad=True)
>>> vkml.detach(x).requires_grad
False

From the header

[[nodiscard]] Tensor detach(const Tensor& t);include/vkml/autograd/autograd.h:45

A tensor sharing the same data but with no gradient history.

Implementation

Declared ininclude/vkml/autograd/autograd.h:45
CPU kernelcomposed from other operators
Vulkan shadercomposed, or dispatched through a shared kernel
Gradient rulenone — backward through it raises
Testsnone found by name

See also backward, realize

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