SIMD Programming
Codon's simd module provides explicit vector operations through the Vec[T, N]
type. Using SIMD
(single instruction, multiple data), a vector operation applies the same
computation to several values at once. Each value occupies a lane, whose type
and width are specified by T and N.
The module supports arithmetic, bitwise operations, comparisons, mathematical functions, and reductions, along with masks for selecting values and controlling memory access. It is useful in performance-critical loops that process contiguous data and require explicit control over vectorization.
Vector types¶
simd.Vec[T, N] represents an LLVM vector <N x T>, where:
Tis anInt/UInttype,float16,bfloat16,float32,float, orfloat128.Nis a positive integer literal that fits in 32 bits.
For example, Vec[float32, 8] contains eight 32-bit floating-point lanes, while
Vec[u8, 16] contains sixteen unsigned byte lanes. In either case, len(v)
returns the number of lanes, N.
Individual lanes can be accessed with literal or runtime integer indices, with
negative indices counting from the end. Out-of-range literal indices are rejected
at compile time; out-of-range runtime indices raise IndexError in both numeric
modes. The v.extract(index) method provides an explicit lane read with runtime
bounds checking.
Vectors are immutable. To replace a lane, use v.insert(index, value), which
returns a new vector containing the replacement.
Creating vectors¶
Broadcasting a scalar¶
Passing a scalar to the constructor broadcasts it to every lane:
from simd import Vec
# 8-lane vector of all ones
v = Vec[float, 8](1.0)
# 4-lane vector of all -3
w = Vec[int, 4](-3)
Loading from pointers and arrays¶
The Vec constructor can load consecutive elements from a pointer, including the
underlying buffer of a NumPy array. The following function adds two arrays in
groups of eight elements, then processes any remaining elements with a scalar
loop. It assumes that the arrays are contiguous and have matching lengths.
import numpy as np
from simd import Vec
def add_arrays(a: np.ndarray[float32, 1],
b: np.ndarray[float32, 1],
out: np.ndarray[float32, 1]):
W: Literal[int] = 8 # vector width
i = 0
while i + W <= len(a):
va = Vec[float32, W](a.data + i) # load 8 elements from a[i..i+7]
vb = Vec[float32, W](b.data + i) # load 8 elements from b[i..i+7]
vc = va + vb # SIMD add
vc.store(out.data, offset=i) # store back
i += W
# handle remaining tail elements (scalar)
while i < len(a):
out[i] = a[i] + b[i]
i += 1
Vec[T, N](ptr) reads N scalar elements using T's memory layout. Unaligned
addresses are supported, but the caller must ensure that all N elements are
readable: raw pointer accesses are not bounds-checked.
Loads and stores through Ptr[Vec[T, N]] also support unaligned addresses, but
use LLVM's vector allocation stride, which can include padding. Non-power-of-two
lane counts can differ from scalar-array storage even for ordinary element widths:
for example, Vec[int, 3] can occupy 32 bytes rather than 24. Layout can also
differ for types such as u1 and UInt[24]. Use Vec[T, N].load(ptr, offset) and
v.store(ptr, offset) when accessing scalar-array storage.
Vectors can also be constructed from lists:
data = [1.0, 2.0, 3.0, 4.0]
v = Vec[float, 4](data) # load from data[0..3]
The list's element type must match T. To change types, convert the elements
explicitly or use the vector's cast() method. Constructors that read from lists,
strings, or byte buffers check the requested range and raise IndexError if the
offset is negative or fewer than N elements remain.
When the caller has already established these bounds, the constructor accepts
check=False to omit validation; see Controlling runtime checks.
Explicit loads and stores¶
Vec[T, N].load(ptr, offset=0) and v.store(ptr, offset=0) read and write scalar
arrays of T, including types with unusual integer widths. Offsets are measured
in elements.
Both operations default to alignment=1, which permits unaligned access. A
larger literal alignment, such as alignment=16, promises that the address
after applying the offset is aligned to that many bytes. The value must be a
power of two from 1 through 2**31. Because this promise is not checked at
runtime, specify a larger alignment only when both the allocation and the offset
guarantee it.
A Vec[u1, N] mask restricts memory access to active lanes, allowing a partial
vector to be processed without accessing elements beyond the end of a buffer.
Inactive load lanes receive zero unless a scalar or vector passthrough value is
provided; inactive store lanes leave memory unchanged.
data = [10.0, 20.0, 30.0]
output = [0.0] * len(data)
active = Vec[int, 4]([0, 1, 2, 3]) < len(data)
values = Vec[float, 4].load(data._ptr, mask=active, passthrough=0.0)
(values + 1.0).store(output._ptr, mask=active)
assert output == [11.0, 21.0, 31.0]
Only active lanes require valid storage, so an all-false mask may be used with a
null pointer. Without a mask, the caller must provide storage for all N lanes.
Indexed gather and scatter¶
For noncontiguous access, Vec[T, N].gather(ptr, indices) loads elements at the
offsets specified by a Vec[int, N], while v.scatter(ptr, indices) writes lanes
to those offsets. Both operations accept mask and alignment arguments, and
gather also accepts passthrough for inactive lanes.
data = [10.0, 20.0, 30.0, 40.0]
output = [0.0] * len(data)
indices = Vec[int, 4]([3, 0, 2, 1])
values = Vec[float, 4].gather(data._ptr, indices)
values.scatter(output._ptr, indices)
assert output == data
Indices are scalar element offsets relative to the supplied pointer and are not bounds-checked. Every active address must refer to valid storage and satisfy the specified alignment. Active scatter destinations must also be distinct, because the API does not define an order for conflicting writes.
The no-argument form, v.scatter(), returns the lanes as a list rather than
writing to memory.
Loading from strings and bytes¶
For byte-sized element types (i8, u8, and byte), the constructor accepts
bytes or a string's UTF-8 encoding. Both the offset and the vector width count
bytes, so they do not necessarily correspond to Unicode character positions.
ASCII strings can be loaded directly; non-ASCII strings are encoded first. The
result retains the requested signed or unsigned element type.
# Load 16 bytes from a string
s = "ABCDEFGHIJKLMNOPQRST"
v = Vec[u8, 16](s)
# Skip first 4 bytes
v2 = Vec[u8, 16](s, 4)
SIMD arithmetic¶
Arithmetic operates lane by lane: integer and floating-point vectors support
+, -, and *, floating-point vectors support true division with /, and
integer vectors support // and %.
A scalar operand on either side of an operator is broadcast to all lanes. Thus,
2 * v, 10 - v, and 12 // v preserve operand order and follow the same lane
type and numeric-mode rules as the corresponding vector-vector operations.
Integer division follows the selected numeric mode, as it does for scalar integers:
- With
-numerics=py,//rounds down and%produces a remainder with the divisor's sign. A zero divisor in any lane raisesZeroDivisionError. Both operators raiseOverflowErrorfor the minimum representable signed integer paired with a divisor of-1. - With
-numerics=c, division truncates toward zero and the remainder has the dividend's sign. Divisors must be nonzero, and the minimum representable signed integer must not be divided by-1; violating either precondition has undefined behavior.
Other integer arithmetic wraps at the lane width. Floating-point equality uses
ordered comparisons (NaN == NaN is false), while inequality uses unordered
comparisons (NaN != NaN is true). Unary negation preserves IEEE signed-zero
behavior.
For floating-point multiply-accumulate operations, va.fma(vb, acc) computes
va * vb + acc with a single rounding step per lane. The following dot product
accumulates eight products at a time, then uses sum() to reduce the accumulator
to a scalar before processing the remaining elements:
import numpy as np
from simd import Vec
def dot(a: Ptr[float32], b: Ptr[float32], n: int) -> float32:
W: Literal[int] = 8
i = 0
acc = Vec[float32, W](0.0f32)
while i + W <= n:
va = Vec[float32, W](a + i)
vb = Vec[float32, W](b + i)
acc = va.fma(vb, acc)
i += W
# horizontal reduce SIMD accumulator
total = acc.sum()
# handle tail scalars
while i < n:
total += a[i] * b[i]
i += 1
return total
Floating-point math¶
Floating-point vectors provide sqrt(), exp(), exp2(), log(), log2(),
log10(), sin(), and cos() methods. The rounding methods floor(), ceil(),
trunc(), and round() preserve the floating-point lane type, with round()
using ties-to-even rounding.
The exp(), exp2(), log2(), log10(), floor(), ceil(), trunc(), and
round() methods, along with fma(), support float16, bfloat16, float32,
and float. Their float128 specializations are rejected at compile time
because portable backend and runtime support is unavailable. The unary methods
listed in this paragraph evaluate float16 and bfloat16 lanes through float32
and convert back. The sqrt(), log(), sin(), and cos() methods instead use
intrinsics of the lane type; their lowering is backend-dependent.
Classification methods—isnan(), isinf(), isfinite(), and signbit()—support
all floating-point lane types and return Vec[u1, N] masks. signbit() tests the
sign bit, including that of negative zero and NaNs.
The lane-wise methods v.min(other) and v.max(other) accept either a scalar or
a vector. For floating-point lanes, they propagate NaNs and order negative zero
below positive zero. Both lane-wise and reducing min/max operations reject
float128, whose LLVM lowering is not reliably supported by the current backend.
Sum and product reductions and classification methods do support float128.
Masks, comparisons, and branchless selection¶
Comparing two vectors, or a vector and a scalar, produces a mask vector with
one u1 lane per comparison result:
v: Vec[float, 8] = ...
mask_negative = v < Vec[float, 8](0.0) # Vec[u1, 8]
Use v.mask(other, mask) to select values lane by lane: a true mask lane selects
the corresponding value from v, and a false lane selects from other. This
expresses conditional updates as data selection without branches, which is useful
for thresholding, clamping, and selectively updating lanes. For example, a ReLU
operation retains positive values and replaces the remaining lanes with zero:
def relu_vec(v: Vec[float, 8]) -> Vec[float, 8]:
zero = Vec[float, 8](0.0)
m = v > zero # positive lanes
return v.mask(zero, m) # if m: v else zero
Inspecting masks¶
To inspect a mask as a whole, use any() or all(), which return scalar
booleans, or count(), which returns the number of nonzero lanes as an int.
These methods also accept numeric vectors, treating every nonzero value,
including NaN, as true. Use them explicitly instead of converting an entire
mask to bool.
mask = Vec[int, 4]([0, 5, 0, -2]) != 0
assert mask.any() and not mask.all()
assert mask.count() == 2
bits = mask.to_bits()
assert bits == UInt[4](0b1010)
assert Vec[u1, 4].from_bits(bits).scatter() == mask.scatter()
For a compact bit representation, to_bits() returns a UInt[N] with lane zero
in the least significant bit, independent of machine endianness.
Vec[u1, N].from_bits() reverses this mapping; neither operation is limited to
64 lanes.
Reductions and horizontal operations¶
Reductions combine all lanes into a scalar of the lane type. The methods
sum(), prod(), min(), and max() compute the sum, product, minimum, and
maximum, respectively. For min() and max(), omitting the argument requests a
reduction; supplying an argument requests a lane-wise operation.
Integer reductions wrap at the lane width, while floating-point min/max reductions follow the same NaN and signed-zero rules as their lane-wise counterparts.
v = Vec[float32, 8](...)
s = v.sum() # returns float32
product = v.prod()
smallest = v.min()
largest = v.max()
These operations use LLVM's vector reduction intrinsics, avoiding the need to convert the vector to a list before combining its elements. They can also finish an accumulation performed across multiple loop iterations, as in the following L2 norm calculation:
def l2_norm(xs: Ptr[float32], n: int) -> float32:
W: Literal[int] = 8
i = 0
acc = Vec[float32, W](0.0f32)
while i + W <= n:
v = Vec[float32, W](xs + i)
acc = acc + v * v
i += W
total = acc.sum()
while i < n:
total += xs[i] * xs[i]
i += 1
return np.sqrt(total)
Rearranging lanes¶
shuffle(indices) constructs a vector by selecting lanes from the input. The
indices may be a nonempty tuple of int values or a Vec[int, M]; either form
produces a Vec[T, M], allowing the output width to differ from the input width.
Each index must be in [0, N). Unlike ordinary lane indexing, shuffle indices do
not accept negative values; invalid indices raise IndexError in both numeric
modes.
The two-vector form, shuffle(indices, other), selects from the concatenated
lanes of both vectors: indices in [0, N) refer to the first vector, and those
in [N, 2*N) refer to other. Both vectors must have the same type and width.
For the common case of alternating lanes from two vectors, interleave(other)
returns a Vec[T, 2*N] directly.
left = Vec[int, 4]([10, 11, 12, 13])
right = Vec[int, 4]([20, 21, 22, 23])
assert left.shuffle((3, 0)).scatter() == [13, 10]
assert left.shuffle((0, 4, 1, 5), right).scatter() == [10, 20, 11, 21]
assert left.interleave(right).scatter() == [10, 20, 11, 21, 12, 22, 13, 23]
Integer-specific operations¶
Integer vectors support bitwise operators (&, |, ^, and ~), bit shifts
(<< and >>), min/max operations, and overflow-aware addition and subtraction.
Comparisons, right shifts, min/max operations, and overflow flags respect the
signedness of the lane type. Absolute value leaves unsigned lanes unchanged;
for signed lanes, the absolute value of the minimum representable value wraps
to that same value.
Binary bitwise operators accept a scalar or vector on either side. Bit shifts
accept either a vector of per-lane counts or a scalar count applied to every
lane; reflected shifts such as 1 << counts broadcast the left operand. Each
count must be between zero and the lane bit width minus one. Python numeric mode
checks this range and raises ValueError for invalid counts, while C numeric
mode requires the caller to satisfy the precondition.
The lsh(k) and rsh(k) methods instead shift whole lanes, filling vacated
lanes with zero. Their argument must be a literal between zero and N, inclusive:
zero leaves the vector unchanged, and N produces an all-zero vector.
Floating-point vectors also support &, |, and ^, which operate on the lanes'
bit representations without numerically converting them to integers.
Saturating addition¶
Overflow-aware arithmetic returns both the result and a mask identifying lanes
that overflowed. For example, va.add(vb, overflow=True) returns
(result, overflow_mask), allowing the result to be replaced selectively with a
limit value.
The following function uses this pattern to add unsigned byte values with saturation at 255, as needed when combining image buffers:
def saturating_add_u8(a: Ptr[u8], b: Ptr[u8],
out: Ptr[u8], n: int):
W: Literal[int] = 16
i = 0
max_val = Vec[u8, W](255u8)
while i + W <= n:
va = Vec[u8, W](a + i)
vb = Vec[u8, W](b + i)
(sum_vec, overflow) = va.add(vb, overflow=True)
# where overflow[i] == 1, clamp to 255
clamped = max_val.mask(sum_vec, overflow)
clamped.store(out, offset=i)
i += W
while i < n:
s = int(a[i]) + int(b[i])
out[i] = u8(255 if s > 255 else s)
i += 1
Here, max_val.mask(sum_vec, overflow) selects 255 for lanes that overflowed
and retains the computed sum for all other lanes.
Controlling runtime checks¶
Checked operations accept check: Literal[bool] = True. Passing check=False
removes their runtime validation at compile time, which can avoid repeated checks
inside a loop whose bounds or operands are already known to be valid. The flag
must be a literal, not a runtime boolean.
The following interfaces expose this control:
| Operation | Unchecked form | Caller precondition |
|---|---|---|
| List, byte, or string load | Vec[T, N](data, offset=0, check=False) |
Nonnegative offset with at least N elements remaining; string offsets count UTF-8 bytes |
| Lane extraction | v.extract(index, check=False) |
-N <= index < N |
| Lane replacement | v.insert(index, value, check=False) |
-N <= index < N |
| Lane shuffle | v.shuffle(indices, other=None, check=False) |
Every selector is in [0, N), or [0, 2*N) when other is supplied |
| Bit shifts | v.lshift(counts, check=False), v.rshift(counts, check=False) |
Each count is nonnegative and less than the lane bit width |
| Integer division/remainder | v.floordiv(divisors, check=False), v.mod(divisors, check=False) |
No zero divisor; no signed minimum-value / -1 pair |
The named integer methods accept either a scalar or a vector operand. lshift()
and rshift() shift bits within lanes, unlike lsh() and rsh(), which move
whole lanes. Operators such as v[index], v << counts, v // divisors, and
v % divisors retain their existing default checks.
data = [4, 8, 12, 16]
vector = Vec[int, 4](data, check=False)
assert vector.extract(-1, check=False) == 16
counts = Vec[int, 4]([0, 1, 2, 3])
assert vector.rshift(counts, check=False).scatter() == [4, 4, 3, 2]
assert vector.floordiv(3, check=False).scatter() == [1, 2, 4, 5]
Only validation is removed. Negative lane indices still count from the end,
string loads still use UTF-8, and division/remainder retain the selected numeric
mode's rounding and sign rules. check=True preserves existing numeric-mode
behavior: integer division and shift checks apply in Python mode, while C mode
already requires valid operands. Buffer and lane checks apply in both modes
unless explicitly disabled.
Unchecked inputs must satisfy the preconditions even when the underlying storage is larger than the logical buffer. Violating them can cause invalid memory access or undefined behavior instead of an exception. Static element-type, vector-width, mask-type, and alignment validation cannot be disabled with this flag.
Raw-pointer load, store, gather, and scatter already rely on caller-provided
bounds and do not accept a check argument. Masked-off lanes remain unaccessed;
their masks are memory-access semantics, not validation to be switched off.
Debugging¶
Converting to a list¶
Call v.scatter() without arguments to obtain a list of lane values for
inspection:
v = Vec[int, 4](...)
lst = v.scatter() # List[int] of length 4
print(lst) # e.g. [1, 1, 1, 1]
Printing vectors¶
Vectors can also be printed directly, with their lane values enclosed in angle brackets:
v = Vec[int, 4](1)
print(v) # e.g. "<1,1,1,1>"