Skip to content

Math

Basalt implements its own f32 transcendentals. Sin, Cos, Tan, Atan, Atan2, Exp, Log and Pow are written from scratch, with no dependency on the C runtime’s math library. Each one exists twice under the same name: a scalar version that takes one value, and a batch version that takes a span.

The accuracy of every function is a measured number, not a claim. Each unary function was swept over all 232 possible f32 inputs, and the worst error found is the published contract.

The scalar and batch forms are overloads, so you pick by how much data you have:

f32 angle = Sin(t); // one value
Span<const f32> angles = /* 10 000 angles */;
Span<f32> results = arena.Push<f32>(angles.size);
Sin(angles, results); // the same function, eight lanes at a time

This exists because the two cases have genuinely different best answers, not to be tidy. A single Sin call has no internal parallelism — there is nothing in one sine to vectorise. Ten thousand sines have a great deal, because eight independent inputs fit in one AVX2 register. The only way to reach that is to hand over the whole group.

Scalar Sin(f32) Batch Sin(Span, Span)
Accuracy ≤ 0.5009 ULP ≤ 4 ULP contracted
Width 1 value 8 values per step, on AVX2
Speed vs the C runtime comparable 10.5×–27.4× on MSVC, 7.8×–17.4× on clang
Falls back to the scalar tier, when AVX2 is absent

Pow is the exception on speed: 4.6× on MSVC and 2.6× on clang.

ULP — unit in the last place — is the distance between one f32 and the next representable one. It is the natural unit for floating-point error, because absolute error is meaningless without knowing the magnitude: an error of 0.001 is catastrophic near zero and invisible near a million.

An error of 0.5 ULP means correctly rounded: the answer is the closest f32 to the true mathematical result. No function can do better.

Function Max ULP Swept exhaustively Domain
Sin 0.5009 yes |x| ≤ 1e5
Cos 0.5009 yes |x| ≤ 1e5
Tan 0.7998 yes |x| ≤ 1e5
Atan 0.8521 yes all f32
Atan2 0.5000 sampled all pairs
Exp 0.5016 yes all f32
Log 0.8177 yes x > 0
Pow 0.7111 sampled all pairs
Function Max ULP Swept exhaustively Domain
Sin 1.5598 yes |x| ≤ 1e5
Cos 1.5617 yes |x| ≤ 1e5
Tan 3.3177 yes |x| ≤ 1e5
Atan 1.4679 yes all f32
Atan2 1.7596 sampled all pairs
Exp 1.9536 yes all f32
Log 1.7265 yes x > 0
Pow 0.9688 sampled all pairs

The two binary functions are sampled rather than swept, because 264 input pairs cannot be enumerated. That limit is stated rather than hidden.

Every figure is identical on MSVC 19.43 and clang 21.1.8 — same worst-case error, at the same input bit pattern, on both compilers.

Seven functions are not approximations at all. Sqrt, Abs, Floor, Ceil, Truncate, Round and CopySign each compile to a single hardware instruction, correctly rounded as IEEE-754 requires.

They have no ULP figure because they have no error, and they are bit-identical on every conforming machine. They are also inline, so they cost nothing to call.

Each function states the range it is contracted over.

Function Contract Outside the contract
Sin Cos Tan |radians| ≤ 1e5 Asserts in debug; quiet NaN in release
Atan, Atan2 all inputs
Exp all inputs +inf on overflow, +0 on underflow
Log x > 0 Log(±0) is -inf; Log(x<0) is NaN
Pow all pairs The full C99 special-value table

The trig limit of 1e5 radians is about 15 915 full turns. A caller with a larger angle wraps it first.

Two decisions inside that table are worth understanding:

  • The limit is the range that was measured, not the range the algorithm survives. The reduction stays sound much further, but letting the scalar version answer beyond where the batch version is gated would make the result depend on which overload you happened to call.
  • A violation returns NaN rather than a best-effort number. Past roughly 4.3e9 the reduction saturates and the polynomial overflows to inf — a value outside sine’s range that propagates silently through clamps and comparisons. NaN is louder. A domain violation is a programming error, so debug builds assert, and release builds refuse to invent a plausible answer.

Special values follow C99 exactly and are verified bit-for-bit, including signed zeros: Sin(-0.0f) is -0.0f.

Why these functions are compiled, not header-inline

Section titled “Why these functions are compiled, not header-inline”

Every other numeric helper in Basalt is inline. The eight transcendentals are not: they live in a .cpp and you link them.

The reason is that a client build can silently destroy them. Compiling with /fp:fast — or -ffast-math — permits the compiler to re-associate floating-point arithmetic. The polynomial kernels depend on exact operation order to keep their error terms small. Measured: under /fp:fast, Exp and Pow degrade from under 1 ULP to about 41 000 ULP.

No portable pragma reliably defends a header against the flags of the translation unit that includes it. Compiling the kernels once, in a unit whose own flags are controlled, is the only mechanism that holds. This is the same reason the system libm ships as a binary rather than a header.

The batch tier uses AVX2 where the processor has it. The decision is made once at run time, by querying CPU features — you do not compile a separate build for it, and you do not need to check anything at the call site.

When AVX2 is absent, the batch functions fall back to the validated scalar tier. So correctness never depends on the instruction set — only speed does.

if (IsBatchMathAccelerated())
{
// the 8-wide kernels are in use
}

Two instruction-set levels are not optional, and they are not the same kind of claim. SSE2 is what the x86-64 ABI itself guarantees, and the transcendentals need nothing beyond it. SSE4.1 is a requirement Basalt imposes on top of that, because the exact family compiles to roundss, an instruction SSE4.1 introduced. It is not probed for at run time — it is a build prerequisite.

Angles are in radians everywhere, with DegreesToRadians and RadiansToDegrees at the boundary where humans write numbers.

BitsFromFloat and FloatFromBits reinterpret an f32 as a u32 and back, without the undefined behaviour of a pointer cast. They are the basis of the classification helpers and are useful for exact comparison in tests.

Accuracy, domain and special-value behaviour for each function are documented in basalt/core/Math.h.

Name Value
PI, TAU, HALF_PI, QUARTER_PI, INVERSE_PI Circle constants. TAU is a full turn.
EULER, LN_2, SQRT_2 Common irrationals.
F32_MAX, F32_MIN, F32_EPSILON Range and precision limits.
Signature Purpose
Sqrt(v) Square root.
Abs(v) Absolute value.
Floor(v) / Ceil(v) / Truncate(v) / Round(v) Rounding, four ways.
CopySign(magnitude, sign) The magnitude of one value with the sign of another.
Signature Purpose
IsNaN(v) / IsInfinite(v) / IsFinite(v) Classification.
IsNegative(v) Sign-bit test. Distinguishes -0.0f from +0.0f.
QuietNaN() / Infinity() Produce the special values.
BitsFromFloat(v) / FloatFromBits(bits) Reinterpret between f32 and u32.
DegreesToRadians(d) / RadiansToDegrees(r) Angle conversion. constexpr.
Signature Domain
Sin(radians) / Cos(radians) / Tan(radians) |radians| ≤ 1e5
Atan(v) all f32
Atan2(y, x) all pairs
Exp(v) all f32
Log(v) v > 0
Pow(base, exponent) all pairs
Signature Purpose
Sin(in, out) / Cos(in, out) / Tan(in, out) 8-wide trig over spans.
Atan(in, out) 8-wide arctangent.
Atan2(y, x, out) Two input spans, one output.
Exp(in, out) / Log(in, out) 8-wide exponential and logarithm.
Pow(bases, exponents, out) Two input spans, one output.
IsBatchMathAccelerated() Whether the wide kernels are active on this machine.