SIMD Math in @simd Loops

AppleAccelerate.SIMDMath exposes scalar math functions that LLVM replaces with Apple's SIMD math routines when they appear inside a loop that vectorises.

This is the non-array counterpart to Array Operations. Where AppleAccelerate.log!(out, X) needs a whole array in and a whole array out, SIMDMath.log(x) is an ordinary scalar call you can put in the middle of a loop.

When to use it

Important

SIMDMath is not a faster replacement for the array API. On an M-series machine, vForce's vv* routines (AppleAccelerate.log! and friends) are about 2x faster per element than these SIMD routines at every array size measured (N = 16 through 500,000). There is no small-N crossover, and vForce wins even when it needs extra temporary arrays and extra passes over memory.

If your data is a dense Array and you can call AppleAccelerate.log!, do that.

SIMDMath is for the cases where the array API does not apply:

  • strided or otherwise non-contiguous access;
  • values computed on the fly rather than read out of an array;
  • loops with control flow, or over iterables that are not dense Arrays;
  • hot paths where you cannot allocate and cannot preallocate a temporary.

In those situations the realistic alternative is a scalar Base loop, and SIMDMath is roughly 2–4x faster for Float32 and 1.2–2x for Float64.

using AppleAccelerate
using AppleAccelerate.SIMDMath: log

# weights applied on the fly: vForce would need a temporary for log.(X) first
function weighted_logsum(X, W)
    u = zero(eltype(X))
    @simd for i in eachindex(X, W)
        @inbounds u += W[i] * log(X[i])
    end
    u
end

X = collect(1.0:1000.0)
W = fill(0.5, 1000)
weighted_logsum(X, W) ≈ sum(W .* Base.log.(X))
true

Scoping it to one loop: @simdmath

using AppleAccelerate.SIMDMath: log replaces log for the whole enclosing module. Since these functions only have Float32/Float64 methods, every other log(2) or rem(i, n) in that module then throws a MethodError. SIMDMath.@simdmath confines the substitution to one expression instead, and imports nothing:

using AppleAccelerate
using AppleAccelerate.SIMDMath: @simdmath

function weighted_logsum(X, W)
    u = zero(eltype(X))
    @simdmath @simd for i in eachindex(X, W)
        @inbounds u += W[i] * log(X[i])^W[i]     # _simd_log_d2, _simd_pow_d2
    end
    u
end

X = rand(1000) .+ 1; W = rand(1000)
weighted_logsum(X, W) ≈ sum(W .* log.(X) .^ W)
true

It can go outside or inside @simd. It does not add @simd or @inbounds for you, and the loop still has to vectorise for any of this to matter.

RewrittenLeft alone
unqualified calls to a name from Available functions with a matching number of positional argumentsqualified calls — Base.log(x)
x^y → pow(x, y)x^2 and other literal integer exponents, which Julia already lowers to multiplications
broadcasts — log.(x), x .^ y
calls with keyword or splatted arguments
the signature of a method defined inside the expression, and anything quoted
every function SIMDMath does not provide (sqrt, erf, your own, …)

A rewritten call uses the SIMD routine only when its arguments are all Float32 or all Float64; for anything else it falls back to the Base function. So index arithmetic like rem(i, 4), integer powers x^n, and complex or BigFloat values keep working inside the block. (nextafter and remainder have no Base counterpart to fall back to.)

The rewrite is purely syntactic: a local variable or argument that is named log and then called is rewritten too. The accuracy caveats below apply unchanged.

Accuracy

Warning

These routines trade accuracy for speed and are less accurate than Base, whose math functions are correctly rounded (≤ 0.5 ULP). Worst case measured against Base is about 3 ULP.

Accuracy is also not consistent across a single call site: if the loop vectorises you get the SIMD routine, and if it does not — an unvectorisable loop, or the scalar remainder of one that did — you get the correctly-rounded libm call instead. Two runs over arrays of different length can therefore give slightly different answers. Do not use SIMDMath where bitwise reproducibility matters.

Confirming you got the vectorised form

A call only becomes a SIMD call if the surrounding loop actually vectorises, which in practice means @simd and usually @inbounds. Nothing warns you if it does not — the code stays correct and simply runs at scalar speed.

Note

Two common flags silently disable this entirely, because each stops the loop from vectorising at all:

  • --check-bounds=yes — disables @inbounds. This is what Pkg.test() passes by default, so SIMDMath runs at scalar speed inside a test suite.
  • --code-coverage — the counters coverage inserts into the loop body block vectorisation.

Both leave results correct, just scalar. If you are benchmarking SIMDMath, make sure neither is on.

To check, look for the symbol in the generated code:

using InteractiveUtils
asm = sprint(code_native, weighted_logsum, (Vector{Float64}, Vector{Float64}))  # what `@code_native` prints
occursin("_simd_log_d2", asm)   # the scalar `log` became a 2-lane SIMD call
true

Float32 runs 4 lanes at a time (_simd_*_f4), Float64 runs 2 (_simd_*_d2).

Strided loops

A stride that is a compile-time constant (for i in 1:3:length(X)) vectorises (for Float64 on Apple silicon, only from Julia 1.13; older LLVMs judge the 2-lane gather unprofitable and keep the call scalar). A stride only known at run time (for i in 1:stride:length(X)) does not — the loop vectoriser gives up on the unknown-stride gather and the call stays scalar.

Available functions

All are defined for Float32 and Float64 only.

One argumentacos, acosh, asin, asinh, atan, atanh, cbrt, cos, cosh, cospi, exp, exp10, exp2, expm1, log, log10, log1p, log2, sin, sinh, sinpi, tan, tanh, tanpi
Two argumentsatan(y, x), hypot, nextafter, pow, rem, remainder

nextafter, pow and remainder have no Base counterpart with matching semantics, so they are named after their C equivalents. Use pow(x, y) rather than x^y, or let @simdmath rewrite x^y for you.

That is every one- and two-argument routine <simd/math.h> offers that is worth calling. copysign, min/max (C's fmin/fmax) and fdim have no _simd_* entry point at all — the header implements them inline, and LLVM already vectorises the Base versions natively.

Deliberately absent:

  • sqrt, floor, ceil, round, trunc, fma, abs — LLVM already lowers these to native instructions, so a library call would be slower.
  • erf, erfc, tgamma — <simd/math.h> declares _simd_erf_f4 and friends, but they measure at 0.97–0.99x of a plain scalar libm loop: they are scalar loops behind a vector signature and buy nothing.
  • lgamma — writes the global signgam, so it cannot be declared side-effect-free.

Measured speedups

out[i] = f(x[i]) over N = 100,000 on an M-series machine. SM/Base is SIMDMath against a scalar Base loop (higher is better); SM/vForce is against AppleAccelerate.f! (below 1.0 means vForce wins).

funcFloat32 SM/BaseFloat32 SM/vForceFloat64 SM/BaseFloat64 SM/vForce
sinpi / cospi26x0.61x7.5x0.45x
tanpi22x0.78x5.9x0.57x
atan(y,x)10.7x0.73x3.9x0.62x
atan10.2x0.69x3.4x0.53x
asin / acos8.6x0.65x3.1–3.7x0.51–0.63x
sin / cos8.2x0.57x2.5x0.51x
rem6.9x0.97x3.1x0.99x
expm16.7x0.71x3.4x0.58x
pow6.4x0.78x2.6x0.66x
tan6.4x0.64x4.0x0.52x
tanh5.7x0.78x3.2x0.57x
atanh5.2x0.57x2.1x0.54x
exp2 / exp / exp104.2–5.0x0.57x1.5x0.68x
log / log2 / log10 / log1p3.4–4.5x0.55–0.67x1.8–2.2x0.59–0.65x
sinh4.4x0.66x1.6x0.57x
acosh4.1x0.56x1.1x0.52x
asinh2.5x0.65x1.1x0.60x
cbrt2.1x0.73x1.3x0.66x
hypot1.2x—4.5x—
cosh1.1x0.63x1.5x0.55x
nextafter—0.18x—0.33x

Reading this:

  • SM/vForce is below 1.0 in every single row. vForce wins across the board, which is why the guidance above is to prefer it whenever it applies. nextafter is the extreme case at 0.18x — always prefer AppleAccelerate.nextafter!.
  • The Float32 trigonometric functions are where this shines against Base, especially sinpi/cospi/tanpi, where Base is unusually slow.
  • cosh and hypot barely beat Base in Float32 because Base implements them natively there — those loops vectorise with no library call at all, so there is little left to win.
  • hypot and exp10 have no vForce equivalent, so SIMDMath is the only accelerated option for them.

How it works

LLVM's loop vectoriser can replace a scalar call inside a vectorising loop with a call to an equivalent SIMD routine, if it is told which routine to use. clang spells this -fveclib=Accelerate. The underlying mechanism is the LLVM vector function ABI (VFABI), which is driven entirely by IR metadata, so Julia can reach it through llvmcall — no compiler flag and no patched Julia required.

Each function is emitted as a scalar call to the real libm symbol (logf), carrying a "vector-function-abi-variant" attribute naming the SIMD replacement:

_ZGV_LLVM_N4v_logf(_simd_log_f4)

which reads as "_LLVM_ ISA, Not masked, 4 lanes, one vector operand, mapping scalar logf onto vector _simd_log_f4". The vector routine must also be declared in the module and anchored in @llvm.compiler.used, or it gets stripped before the vectoriser runs and the mapping is silently dropped.

The routines come from libsystem_m's <simd/math.h> rather than Accelerate's own vfp.h. Both are public Apple API, but vfp.h is Float32-only and covers fewer functions, whereas <simd/math.h> covers both widths — and since Float64 is Julia's default, using one source for both keeps accuracy consistent across types.

AppleAccelerate.SIMDMath — Module
AppleAccelerate.SIMDMath

Scalar math functions that LLVM replaces with Apple's SIMD math routines when they appear inside a vectorised loop.

Unlike the array functions in AppleAccelerate proper (which wrap vForce and need a whole array to work on), these are scalar functions called from inside a @simd loop:

julia> using AppleAccelerate.SIMDMath: log

julia> function weighted_logsum(X, W)  # no temporary for log.(X), unlike the array API
           u = zero(eltype(X))
           @simd for i in eachindex(X, W)
               @inbounds u += W[i] * log(X[i])
           end
           u
       end;

julia> X = collect(1.0:1000.0); W = fill(0.5, 1000);

julia> weighted_logsum(X, W) ≈ sum(W .* Base.log.(X))
true

When to use this — and when not to

Important

This is not a faster replacement for the array API. vForce's vv* routines (AppleAccelerate.log! and friends) are about 2x faster per element than these SIMD routines at every array size measured, and win even when they need extra temporaries and extra passes over memory. If your data is a dense array, use those instead.

SIMDMath is for the cases where vForce does not apply:

  • strided or otherwise non-contiguous access;
  • values computed on the fly rather than read from an array;
  • loops with control flow, or over iterables that are not dense Arrays;
  • hot paths where you cannot allocate and cannot preallocate a temporary.

There, the realistic alternative is a scalar Base loop, against which these run roughly 2–4x faster for Float32 and 1.2–2x for Float64.

Accuracy

Warning

These routines trade accuracy for speed and are less accurate than the equivalent Base functions, which are correctly rounded (≤ 0.5 ULP). Measured worst case is roughly 1–3 ULP depending on the function; see test/simdmath_tests.jl for the pinned per-function bounds.

Accuracy is also not consistent across the same call site. If the loop vectorises you get the SIMD routine; if it does not (an unvectorisable loop, or the scalar remainder of one that did) you get the correctly-rounded libm call instead. Do not use these where reproducibility across loop shapes matters.

Vectorising

A call only becomes a SIMD call if the surrounding loop actually vectorises, which in practice means @simd (and usually @inbounds). Outside a vectorised loop these are ordinary — correct, but no faster than Base, and sometimes slower. Float32 runs 4 lanes at a time, Float64 runs 2.

Note

--check-bounds=yes (which Pkg.test() passes by default) and --code-coverage each stop the loop vectorising at all, silently reducing these to scalar libm calls. Results stay correct; the speedup disappears.

To confirm you are getting the vectorised form, look for the symbol:

using InteractiveUtils
@code_native weighted_logsum(X, W)   # expect a call to _simd_log_d2

Available functions

One argument: acos, acosh, asin, asinh, atan, atanh, cbrt, cos, cosh, cospi, exp, exp10, exp2, expm1, log, log10, log1p, log2, sin, sinh, sinpi, tan, tanh, tanpi.

Two arguments: atan(y, x), hypot, nextafter, pow, rem, remainder.

All are defined for Float32 and Float64 only. nextafter, pow and remainder have no Base counterpart with matching semantics and are named after their C equivalents; use pow(x, y) rather than x^y (or let @simdmath rewrite x^y for you).

These are all the one- and two-argument routines <simd/math.h> provides that are worth calling. Names such as copysign, min/max (fmin/fmax) and fdim have no _simd_* entry point at all – the header implements them inline, and LLVM already vectorises the Base versions natively.

Deliberately absent:

  • sqrt, floor, ceil, round, trunc, fma, abs — LLVM already lowers these to native instructions, so a library call would be slower.
  • erf, erfc, tgamma — <simd/math.h> declares these, but they measure at 0.97–0.99x of a scalar libm loop: they are scalar loops behind a vector signature and buy nothing.
  • lgamma — writes the global signgam, so it cannot be declared memory(none).
source
AppleAccelerate.SIMDMath.@simdmath — Macro
SIMDMath.@simdmath expr

Rewrite the math calls inside expr to their SIMDMath equivalents, without importing anything into the enclosing module:

julia> using AppleAccelerate.SIMDMath: @simdmath

julia> function weighted_logsum(X, W)
           u = zero(eltype(X))
           @simdmath @simd for i in eachindex(X, W)
               @inbounds u += W[i] * log(X[i])^W[i]
           end
           u
       end;

julia> X = [1.5, 2.5, 3.5, 4.5]; W = [0.5, 1.0, 1.5, 2.0];

julia> weighted_logsum(X, W) ≈ sum(W .* log.(X) .^ W)   # `log` and `^` are SIMDMath's here
true

julia> @simdmath rem(7, 4)                              # not Float32/Float64: falls back to Base
3

This is the alternative to using AppleAccelerate.SIMDMath: log, which replaces log everywhere in the module rather than in one loop. @simdmath goes outside or inside @simd; it does not add @simd or @inbounds for you, and the loop still has to vectorise for any of this to matter (see SIMDMath).

What is rewritten:

  • unqualified calls to a name in the "Available functions" list of SIMDMath, with a matching number of positional arguments;
  • x^y, which becomes pow(x, y) – except when y is a literal integer (x^2), which Julia already lowers to multiplications.

What is left alone: qualified calls (Base.log(x)), broadcasts (log.(x), x .^ y), calls with keyword or splatted arguments, the signature of a method defined inside expr, anything inside a quoted expression, and every function SIMDMath does not provide.

A rewritten call only uses the SIMD routine when all its arguments are Float32 or all are Float64. For any other argument types it falls back to the Base function, so index arithmetic such as rem(i, 4) and integer powers x^n keep working unchanged. (nextafter and remainder have no Base counterpart to fall back to.)

The rewrite is purely syntactic: a local variable or argument that happens to be called log and is then called will be rewritten too.

The accuracy caveats of SIMDMath apply unchanged – these routines are less accurate than Base.

source