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
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))trueScoping 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)trueIt 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.
| Rewritten | Left alone |
|---|---|
| unqualified calls to a name from Available functions with a matching number of positional arguments | qualified 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
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.
Two common flags silently disable this entirely, because each stops the loop from vectorising at all:
--check-bounds=yes— disables@inbounds. This is whatPkg.test()passes by default, soSIMDMathruns 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 calltrueFloat32 runs 4 lanes at a time (_simd_*_f4), Float64 runs 2 (_simd_*_d2).
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 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 |
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_f4and 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 globalsigngam, 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).
| func | Float32 SM/Base | Float32 SM/vForce | Float64 SM/Base | Float64 SM/vForce |
|---|---|---|---|---|
sinpi / cospi | 26x | 0.61x | 7.5x | 0.45x |
tanpi | 22x | 0.78x | 5.9x | 0.57x |
atan(y,x) | 10.7x | 0.73x | 3.9x | 0.62x |
atan | 10.2x | 0.69x | 3.4x | 0.53x |
asin / acos | 8.6x | 0.65x | 3.1–3.7x | 0.51–0.63x |
sin / cos | 8.2x | 0.57x | 2.5x | 0.51x |
rem | 6.9x | 0.97x | 3.1x | 0.99x |
expm1 | 6.7x | 0.71x | 3.4x | 0.58x |
pow | 6.4x | 0.78x | 2.6x | 0.66x |
tan | 6.4x | 0.64x | 4.0x | 0.52x |
tanh | 5.7x | 0.78x | 3.2x | 0.57x |
atanh | 5.2x | 0.57x | 2.1x | 0.54x |
exp2 / exp / exp10 | 4.2–5.0x | 0.57x | 1.5x | 0.68x |
log / log2 / log10 / log1p | 3.4–4.5x | 0.55–0.67x | 1.8–2.2x | 0.59–0.65x |
sinh | 4.4x | 0.66x | 1.6x | 0.57x |
acosh | 4.1x | 0.56x | 1.1x | 0.52x |
asinh | 2.5x | 0.65x | 1.1x | 0.60x |
cbrt | 2.1x | 0.73x | 1.3x | 0.66x |
hypot | 1.2x | — | 4.5x | — |
cosh | 1.1x | 0.63x | 1.5x | 0.55x |
nextafter | — | 0.18x | — | 0.33x |
Reading this:
SM/vForceis 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.nextafteris the extreme case at 0.18x — always preferAppleAccelerate.nextafter!.- The
Float32trigonometric functions are where this shines againstBase, especiallysinpi/cospi/tanpi, whereBaseis unusually slow. coshandhypotbarely beatBaseinFloat32becauseBaseimplements them natively there — those loops vectorise with no library call at all, so there is little left to win.hypotandexp10have no vForce equivalent, soSIMDMathis 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.SIMDMathScalar 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))
trueWhen to use this — and when not to
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
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.
--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_d2Available 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 globalsigngam, so it cannot be declaredmemory(none).
AppleAccelerate.SIMDMath.@simdmath — Macro
SIMDMath.@simdmath exprRewrite 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
3This 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 becomespow(x, y)– except whenyis 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.