ADR-377: Vectors a Program Holds - simd::Lanes[T] at the Build’s Width, simd::Fixed[T, N] Where an Algorithm Demands a Width

Status: Accepted provisionally (owner’s direction, October 20, 2026: vector types are wanted; this record decides them for a prototype, and is confirmed or changed from its measurement) Date: October 20, 2026 Answers: ADR-371 D4’s questions 1 to 3 (#755): the type, its width, its operations and the mask Related: ADR-371 (portable lanes are std’s, an instruction of one architecture a safe function of its backend), ADR-375 (a search over a whole buffer), ADR-320 D7 (a read checked as the cut it reads), ADR-296 D6 (no vector in the core)

1. Context

The fastest 1BRC program written by a person parses the temperatures of two rows in one 256-bit vector: a load, a shuffle that puts each row’s digits in place, a multiply that adds neighbouring pairs, a reduction. Nikaia’s find_all already hides a vector search inside std; a program that wants its own vector code has nothing to write it with.

Other languages:

Two forms recur: one at the target’s width for loops whose result does not depend on it, one at a fixed width for algorithms defined over a width.

2. Decision

D1 — simd::Lanes[T] holds as many lanes as the build’s widest register.

T is one of u8, i8, u16, i16, u32, i32, u64, i64. Lanes[T].count is a constant of the build: the width of the CPU level’s widest integer vector register divided by T’s size (16 bytes at x86-64 and on aarch64, 32 at x86-64-v3). The owner rejected a fixed width as the main form (#755): it gives up the target’s best.

D2 — simd::Fixed[T, N] holds N lanes on every target.

N is a literal power of two from 2 to 64; another is NK1297. A target narrower than N lanes runs it in several registers, or lane by lane. It is for algorithms defined over a width: a row parsed in 16 bytes, a 16-entry table, a hash over blocks.

D3 — Only what keeps the output the same at every width is offered on Lanes.

On both: a load from a Bytes (load(b, at), checked as the cut b[at..<at + count], so a proof drops it), splat(x), the lane-wise wrapping_add, wrapping_sub, wrapping_mul, saturating_add, saturating_sub, min, max, & | ^ !, << and >> by a constant, the comparisons eq ne lt le gt ge answering a Mask, select(mask, a, b), and the reductions wrapping_sum, min_lane, max_lane.

On Fixed only, because their result depends on the width: lane(i) (checked as an index), Fixed.from([…]), shuffle(of: Fixed[u8, N]) with constant indices (an index outside the lanes is NK1298), dot_pairs(w), which multiplies lane by lane and adds each neighbouring pair into a lane of twice the width (Fixed[u8, N] by Fixed[i8, N] gives Fixed[i16, N / 2], Fixed[i16, N] by Fixed[i16, N] gives Fixed[i32, N / 2]), and widen/narrow_saturating between lane widths.

D4 — + - * are not lane operations.

A lane’s + would either check every lane, which costs a compare and a branch the vector code exists to avoid, or wrap, which + never does elsewhere in Nikaia. The program names what it means: wrapping_add or saturating_add. + - * on lanes is NK1296, whose help names both.

D5 — A mask is read as positions, never as a number.

Mask[T] answers first() -> i64?, positions() -> Seq[i64] (rising), count(), any(), all(), and combines with & | !. A bits() -> u64 would break on a target with more than 64 lanes (SVE). Each position is at least 0 and below the lane count, and a proof may use it.

D6 — Lowering is the backend’s choice, the meaning is not.

The prototype lowers to fixed-size arrays the backend vectorises, and to core::arch intrinsics behind the CPU level where it does not; either way no unsafe is written in the program. A Fixed that outruns the target’s register is split.

D7 — What the prototype settled.

Settled by the supervising session from the prototype (#775).

D8 — The array lowering gives vector instructions only for whole vectors.

LLVM forms a vector from the array lowering where whole vectors are loaded or stored; lanes built from scalars and read back as scalars stay scalar code. In 1BRC a two-row temperature parse with Fixed[u64, 2] (beowolx’s technique) cost 118.1 instructions a row at x86-64-v3 against 99.4 for the scalar parse; the same parse written with core::arch intrinsics reached 97.8, so even ideal lowering would save 1.6. The prototype therefore keeps the array lowering and adds no unsafe crate for Fixed; one is decided when a program measures a gain from it. A lane shift by a run-time count is not needed: select between v >> 8 and v is one instruction.

From #775 step 6’s measurement.

3. Rejected alternatives

4. What this does not decide

A shuffle by indices known only at run time (a table chosen by the input’s layout); Floating-point lanes; a store into a buffer; gathers and scatters; the CPU level query and the first x86:: instructions (#755, questions 4 and 5); crc32c and prefetch (questions 6 and 7). Whether the provisional status is lifted is decided from the prototype’s measurement on 1BRC.

This site is open source. Improve this page.