Skip to content

Commit 9acb814

Browse files
committed
feat: add chunking
Signed-off-by: Michael Pollind <mpollind@gmail.com>
1 parent 7b45f74 commit 9acb814

6 files changed

Lines changed: 604 additions & 85 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
matrix:
1515
os: [ubuntu-latest, windows-latest, macos-latest]
16-
zig-version: [0.15.1, master]
16+
zig-version: [master]
1717
steps:
1818
- name: Checkout
1919
uses: actions/checkout@v4

README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44

55
A high-performance linear algebra library for Zig, providing vector, matrix, quaternion, and geometry operations.
66

7+
## Features
8+
9+
- **Vectors** (`zml.vec`) — norm, dot, cross, normalize, reflect, distance, angle, swizzle, fused `sin_cos`, and more.
10+
- **Matrices** (`zml.Mat`) — generic column-major `Mat(T, cols, rows)` with multiply, transforms, etc.
11+
- **Quaternions** (`zml.quat`) — rotation, slerp/nlerp, axis-angle and Euler conversions.
12+
- **Geometry** (`zml.geom`) — AABB, sphere, plane, capsule, OBB, ray, frustum, overlap/containment tests.
13+
- Extras: `zml.scalar` (clamp/lerp/smoothstep), `zml.color`, `zml.packing`, `zml.random`.
14+
- Built on Zig's native `@Vector` SIMD types — **but functions also accept plain arrays** (see below).
15+
716
## Installation
817

918
```zig
@@ -21,3 +30,72 @@ const zml = b.dependency("zml", .{
2130
exe.root_module.addImport("zml", zml.module("zml"));
2231
```
2332

33+
## Usage
34+
35+
```zig
36+
const zml = @import("zml");
37+
38+
const a = zml.Vec3f32{ 1, 2, 3 }; // @Vector(3, f32)
39+
const b = zml.Vec3f32{ 4, 5, 6 };
40+
41+
const d = zml.vec.dot(a, b); // f32 = 32
42+
const c = zml.vec.cross(a, b); // @Vector(3, f32)
43+
const n = zml.vec.normalize(a); // @Vector(3, f32)
44+
const r = zml.vec.sin_cos(a); // .{ .sin_out, .cos_out }
45+
```
46+
47+
## `@Vector` and array inputs
48+
49+
Every length-generic vector function accepts **both** a native `@Vector(N, T)` and a plain
50+
`[N]T` array, and the return **preserves the input's container kind** (array in → array out,
51+
vector in → vector out):
52+
53+
```zig
54+
const arr = [3]f32{ 1, 2, 3 };
55+
const d = zml.vec.dot(arr, arr); // f32
56+
const nz = zml.vec.normalize(arr); // [3]f32
57+
```
58+
59+
Arrays are meant for arbitrarily long data. Rather than coercing a large array into one wide
60+
`@Vector(N, T)` — which makes LLVM emit a single enormous SIMD instruction and bloats the binary
61+
— array inputs are processed in **chunks sized to the CPU's native SIMD width**
62+
(`std.simd.suggestVectorLengthForCpu`) via a compact loop. `@Vector` inputs keep the single-op
63+
path (you chose that width explicitly).
64+
65+
For example, `norm` over a `[245]f32` compiles to **324 bytes** via the chunked path, versus
66+
**2,529 bytes** for the equivalent `@Vector(245, f32)` op (`-OReleaseSmall`) — and it is faster
67+
too (see below).
68+
69+
## Benchmarks
70+
71+
Run the benchmark suite (uses [zBench](https://github.com/hendriknielaender/zBench)):
72+
73+
```sh
74+
zig build bench -Doptimize=ReleaseFast
75+
```
76+
77+
Representative results (`ReleaseFast`; absolute numbers are machine-dependent — the point is the
78+
array-vs-`@Vector` ratio at large `N`):
79+
80+
| Operation (N = 245) | array (chunked) | `@Vector(N)` (single op) | speedup |
81+
| -------------------------- | --------------: | -----------------------: | ------: |
82+
| `norm` | 37 ns | 235 ns | 6.3× |
83+
| `dot` | 40 ns | 313 ns | 7.8× |
84+
| `normalize` | 57 ns | 261 ns | 4.6× |
85+
86+
| Sin/Cos over 256 elements | time/run |
87+
| -------------------------- | ---------: |
88+
| scalar `std.math` loop | 695 ns |
89+
| `sin_cos` (`@Vector`) | 106 ns |
90+
| `sin_cos` (array, chunked) | 139 ns |
91+
92+
For the length-generic reductions/maps, the chunked array path is both **smaller and faster**
93+
than one wide `@Vector` op at large `N`: the wide op forces LLVM into a slow, bloated instruction
94+
sequence, while the native-width loop stays compact and vectorized. `sin_cos` is pure
95+
element-wise, so the `@Vector` form already vectorizes cleanly and the two are comparable.
96+
97+
## Testing
98+
99+
```sh
100+
zig build test
101+
```

bench/bench.zig

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,105 @@ fn benchmark_sin_cos_system(comptime size: usize) type {
7070
};
7171
}
7272

73+
// Fill a length-`size` array with distinct positive values (kept away from zero
74+
// so reductions/normalize stay well-conditioned).
75+
fn ramp(comptime size: usize) [size]f32 {
76+
var val: [size]f32 = undefined;
77+
for (0..size) |k| val[k] = @as(f32, @floatFromInt(k % 251)) * 0.03 + 1.0;
78+
return val;
79+
}
80+
81+
// `sin_cos` on a plain array: processed in native-width SIMD chunks (simd.zig).
82+
fn bench_sin_cos_array(comptime size: usize) type {
83+
return struct {
84+
angles: [size]f32,
85+
fn init() @This() {
86+
return .{ .angles = ramp(size) };
87+
}
88+
pub fn run(self: *@This(), _: std.mem.Allocator) void {
89+
std.mem.doNotOptimizeAway(@call(.never_inline, zml.vec.sin_cos, .{self.angles}));
90+
}
91+
};
92+
}
93+
94+
// The remaining factories each come in an `_array` (chunked) and a `_vector`
95+
// (single wide @Vector op) flavor over the SAME data, so a run shows the
96+
// chunked path's cost against the wide-vector path it replaces for arrays.
97+
98+
fn bench_norm_array(comptime size: usize) type {
99+
return struct {
100+
data: [size]f32,
101+
fn init() @This() {
102+
return .{ .data = ramp(size) };
103+
}
104+
pub fn run(self: *@This(), _: std.mem.Allocator) void {
105+
std.mem.doNotOptimizeAway(@call(.never_inline, zml.vec.norm_adv, .{ self.data, 32 }));
106+
}
107+
};
108+
}
109+
110+
fn bench_norm_vector(comptime size: usize) type {
111+
return struct {
112+
data: @Vector(size, f32),
113+
fn init() @This() {
114+
return .{ .data = ramp(size) };
115+
}
116+
pub fn run(self: *@This(), _: std.mem.Allocator) void {
117+
std.mem.doNotOptimizeAway(@call(.never_inline, zml.vec.norm_adv, .{ self.data, 32 }));
118+
}
119+
};
120+
}
121+
122+
fn bench_dot_array(comptime size: usize) type {
123+
return struct {
124+
a: [size]f32,
125+
b: [size]f32,
126+
fn init() @This() {
127+
return .{ .a = ramp(size), .b = ramp(size) };
128+
}
129+
pub fn run(self: *@This(), _: std.mem.Allocator) void {
130+
std.mem.doNotOptimizeAway(@call(.never_inline, zml.vec.dot, .{ self.a, self.b }));
131+
}
132+
};
133+
}
134+
135+
fn bench_dot_vector(comptime size: usize) type {
136+
return struct {
137+
a: @Vector(size, f32),
138+
b: @Vector(size, f32),
139+
fn init() @This() {
140+
return .{ .a = ramp(size), .b = ramp(size) };
141+
}
142+
pub fn run(self: *@This(), _: std.mem.Allocator) void {
143+
std.mem.doNotOptimizeAway(@call(.never_inline, zml.vec.dot, .{ self.a, self.b }));
144+
}
145+
};
146+
}
147+
148+
fn bench_normalize_array(comptime size: usize) type {
149+
return struct {
150+
data: [size]f32,
151+
fn init() @This() {
152+
return .{ .data = ramp(size) };
153+
}
154+
pub fn run(self: *@This(), _: std.mem.Allocator) void {
155+
std.mem.doNotOptimizeAway(@call(.never_inline, zml.vec.normalize, .{self.data}));
156+
}
157+
};
158+
}
159+
160+
fn bench_normalize_vector(comptime size: usize) type {
161+
return struct {
162+
data: @Vector(size, f32),
163+
fn init() @This() {
164+
return .{ .data = ramp(size) };
165+
}
166+
pub fn run(self: *@This(), _: std.mem.Allocator) void {
167+
std.mem.doNotOptimizeAway(@call(.never_inline, zml.vec.normalize, .{self.data}));
168+
}
169+
};
170+
}
171+
73172
pub fn main() !void {
74173
const io = std.Io.Threaded.global_single_threaded.io();
75174
const stdout: std.Io.File = .stdout();
@@ -89,6 +188,29 @@ pub fn main() !void {
89188
try bench.addParam("Sin/Cos vectorized", &bench_sin_cos_fused(256).init(), .{
90189
.iterations = 256,
91190
});
191+
try bench.addParam("Sin/Cos array (chunked)", &bench_sin_cos_array(256).init(), .{
192+
.iterations = 256,
193+
});
194+
195+
// Chunked array path vs single wide-@Vector op, at the motivating length 245.
196+
try bench.addParam("norm [245] array (chunked)", &bench_norm_array(245).init(), .{
197+
.iterations = 4096,
198+
});
199+
try bench.addParam("norm @Vector(245)", &bench_norm_vector(245).init(), .{
200+
.iterations = 4096,
201+
});
202+
try bench.addParam("dot [245] array (chunked)", &bench_dot_array(245).init(), .{
203+
.iterations = 4096,
204+
});
205+
try bench.addParam("dot @Vector(245)", &bench_dot_vector(245).init(), .{
206+
.iterations = 4096,
207+
});
208+
try bench.addParam("normalize [245] array (chunked)", &bench_normalize_array(245).init(), .{
209+
.iterations = 4096,
210+
});
211+
try bench.addParam("normalize @Vector(245)", &bench_normalize_vector(245).init(), .{
212+
.iterations = 4096,
213+
});
92214

93215
try bench.run(io, stdout);
94216
}

src/root.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub const Mat3f64 = Mat(f64, 3, 3);
2424

2525
pub const geom = @import("geometry.zig");
2626
pub const meta = @import("meta.zig");
27+
pub const simd = @import("simd.zig");
2728
pub const scalar = @import("scalar.zig");
2829
pub const packing = @import("packing.zig");
2930
pub const color = @import("color.zig");
@@ -35,6 +36,7 @@ test {
3536
_ = @import("quat.zig");
3637
_ = @import("geometry.zig");
3738
_ = @import("meta.zig");
39+
_ = @import("simd.zig");
3840
_ = @import("scalar.zig");
3941
_ = @import("packing.zig");
4042
_ = @import("color.zig");

0 commit comments

Comments
 (0)