sysl

simd

One kernel compiled for more than one register width — a lane count that is a value parameter, and the four things writing a constraint solver lane-wise turned up.

Box2D’s contact_solver.c writes the same solver four times — AVX2, NEON, SSE2 and a scalar fallback, selected by #if — and over half of that 2120-line file is those four copies. This program asks whether sysl can write it once.

The axis: one body, more than one register width. Every other program in the set is written for one machine shape. This is the one where the width is the variable, and the interesting part is that the mechanism is not a vector feature at all.

The lane count is an ordinary value parameter

A <N>T is N lanes of T. What makes one kernel serve several widths is that N can come from a value parameter, exactly as [N]T reads an array’s length:

solve[const W: usize](vn: <W>f32, mass: <W>f32, bias: <W>f32) -> <W>f32
    val impulse = (bias - vn) * mass

    // The impulse a contact applies can only push, never pull — so a negative one is clamped away.
    return (impulse < 0.0).select(0.0, impulse)
end solve

Nothing in that body names a width. W is read off the argument, the mask impulse < 0.0 is a <W>bool because the vector it compares is a <W>f32, and select chooses at whatever width the mask has. Called with four lanes and with eight, it is two bodies holding different instructions from one piece of source — fmul <4 x float> in one and fmul <8 x float> in the other.

The clamp is why select is a method rather than an if. Some lanes want it and others do not, and a branch takes one path for the whole register. Both sides are computed and the mask picks per lane. That a mask is an ordinary value — it binds, combines with &, and reduces with any and all — is what keeps the solver’s convergence test to one word instead of a loop with an early exit.

What it found

A vector may not cross to C, and the refusal had to be built. The scope said it was refused and nothing implemented it, so an extern taking a <4>f32 emitted the declaration and made a call whose convention nothing had checked. Which register a vector arrives in differs by target and by which extensions the other side was compiled for — so the failure mode is a call that resolves and corrupts its arguments, not one that fails to link, which is precisely what a boundary check exists to prevent. The shape that does cross is the one C’s own SIMD-taking functions take: a pointer to the lanes. The FFI reference carries the rule.

v += 1.0 was refused while v = v + 1.0 beside it worked. The compound form did not broadcast its scalar, so two spellings defined to reach the same instruction disagreed about it. That is the kind of gap a program finds and a suite does not: a suite tests the form it was written for, and a program writes whichever one reads better.

A vector’s lanes could not reach memory, and that was the main finding. There was no way to move a <W>f32 into a [W]f32 or a []f32, and no way to build one from a run of a slice — so a kernel could compute a batch of results and had nowhere to put them. At a parameterised width it could not be written at all, since a lane index must be a constant and a [const W] body has no constant to write. It was reported rather than worked around, because the workaround would have been per-width and would have been copied.

xs.load(i) and xs.store(i, v) are what closed it, and this is the loop the finding said could not be written:

batch[const W: usize](vn: []const f32, mass: []const f32, bias: []const f32, out: []f32, relax: <W>f32)
    var i: usize = 0

    while i + W <= vn.len
        val v: <W>f32 = vn.load(i)
        val m: <W>f32 = mass.load(i)
        val b: <W>f32 = bias.load(i)

        out.store(i, solve(v, m, b) * relax)
        i += W

    while i < vn.len
        out[i] = scalar_solve(vn[i], mass[i], bias[i]) * relax[0]
        i += 1
end batch

The tail is the caller’s and it is written out rather than hidden, because every kernel over real data has one: an array whose length is not a multiple of the register’s width ends with fewer elements left than there are lanes, and a load of that run traps rather than reading past the end. A masked load would answer it in one instruction and sysl has none; a scalar loop answers it in three lines, which is what C’s SIMD code writes anyway.

relax is a vector rather than an f32, and it is a choice rather than a workaround. The relaxation factor of a Gauss-Seidel step is a constant broadcast across the lanes, and every real SIMD solver passes its constants as vectors for that reason — so the parameter says what the kernel does, and W falls out of it.

It was also, when this program was written, the only way W could enter. Both halves of that have since moved: a load takes its width from what receives the value, and an operand is such a place now, so xs.load(i) * by reads its lane count off by; and a written type argument at a call is what a kernel with no vector parameter uses, so add[8](a, b, out) says the width where the reader is standing. This program’s findings are what closed both.

There is still no shuffle, and the gather is where that shows. A real solver reads its bodies through an index table — body 17 in lane 0, body 3 in lane 1 — and moving eight scattered velocities into lane order costs a scratch array and a pass of scalar stores:

gather(xs: []const f32, at: []const usize) -> <8>f32
    var scratch: [8]f32

    for i in 0..<at.len
        scratch[i] = xs[at[i]]

    return scratch.load(0)
end gather

That is better than the eight written-out lane writes it replaces, and it is still not one instruction. The store is what makes the loop possible: a lane index has to be a constant, because a vector has no address to bounds-check against and a computed lane would be LLVM’s poison rather than a trap — while an array index is checked and may be computed. Putting the lanes somewhere a subscript works is how “check every lane” gets to be an ordinary for.

It is also the honest limit on the claim this program set out to test. Box2D’s four copies differ in the gather-and-transpose half and agree about the arithmetic, so write it once is a true statement about the solver and a weaker one about the loading.


Source · Back to the guide programs, or on to the vectors reference.

Search

Esc
to navigate to open Esc to close