Polynomial approximation and range reduction: expf in C (optional)
Overview
Section titled “Overview”| Module | M09.6 · side · C · Pass 6 · 2 to 3 h |
| You build | c/src/numerics/expf.c: tl_expf and tl_exp_f32 (and the helper pow2i) |
| Contract | course/contracts/c/include/tinyllm/numerics.h (the M09.6 section) · rules: c/ABI.md |
| Tests | course/tests/M09.6/: test_expf.c (C, under ASan and UBSan) (what they check: section 4) |
| Needs | rt.02 runtime support, M02.1 Taylor series and range reduction (exp_range_reduced, the Python twin) (or --ref-deps). Reading: M00.4 Horner’s rule, M09.1 IEEE 754 |
| Used by | L9.2 softmax · L9.3 FlashAttention · L9.4 paged attention · L9.6 SiLU, each calling tl_expf per element |
| Milestone | MS-L9, the optional standalone C module group |
| Optional depth | Muller, Elementary Functions: Algorithms and Implementation (3rd ed.), ch. 2 and 11; Cody and Waite, Software Manual for the Elementary Functions (1980); Trefethen, Approximation Theory and Approximation Practice, ch. 10 |
Key Takeaways
Section titled “Key Takeaways”- Range reduction turns an infinite domain into a tiny one exactly: with and , and costs nothing because it is an exponent field (
reduction_boundaries). - The reduction must not round away : is subtracted in two parts (Cody and Waite), a 9-bit high part whose product with is exact and a low part for the rest (
million_samples_within_2_ulp). - On a Taylor polynomial of degree 7 is accurate to about , a tenth of a float32 ulp, so the result is within 1.21 ulp everywhere; degree 6 is not enough (
million_samples_within_2_ulp,sweep_of_minus_one_to_one). - runs from to , past both ends of the float exponent range, so is applied in two halves; the edges overflow to and underflow to exactly where the contract says (
overflow_edge,underflow_edge). - The C kernel is your M02.1 algorithm in float32, within 2 ulp of
exp_range_reducedon 50000 inputs (test_within_2_ulp_of_exp_range_reduced).
How to work this chapter
Section titled “How to work this chapter”ol start M09.6 # stubs expf.c into your repool tests M09.6 # read the test catalog first: rung R0, you write no tests hereol check M09.6 # exit code is the verdictol check M09.6 --ref-deps # only if you skipped rt.02 or M02.1ol diff M09.6 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Every softmax in your model exponentiates its logits: the sampler (L8.1) once per token, attention once per query and key. In Part 9 those loops move into C (L9.2 online softmax, L9.3 FlashAttention, and SiLU in L9.6, which is ), and each of them calls an exponential per element. The C library’s expf is correct, but it is opaque, its speed and accuracy differ between macOS and Linux (which breaks bitwise reproducibility between the two CI runners), and it cannot be inlined into a vector loop. You already derived the algorithm in M02.1 (exp_range_reduced, in float64). This module ports it to float32 C with an accuracy contract in ulps, and the edges (overflow at 88.72, underflow below , , NaN) that a float64 prototype never meets.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| the input | float | |
| the integer nearest (ties to even) | int, in range | |
| the reduced argument | float, | |
| the polynomial approximating | float | |
| split: , | float constants | |
| the remainder of the degree-7 Taylor polynomial | ||
| the spacing of float32 values at |
2.1 Range reduction
Section titled “2.1 Range reduction”is defined everywhere but a polynomial is accurate only near its center. The identity moves any input into a small interval: write , so
Choosing puts in . Multiplying by is free in binary floating point: (for ) is the float with exponent field and mantissa 0, and multiplying by it changes only the exponent of the result, so it is exact whenever the result stays normal.
2.2 Computing without losing it
Section titled “2.2 Computing rrr without losing it”subtracts two nearly equal numbers when is large: at , and . In float32, has an error of about , which is relative error in and therefore in the result: a hundred ulps. Cody and Waite’s fix: split with having few significant bits. has 9 significant bits and has 8, so fits in 17 bits and is exact in float32; is then exact too (the two are within a factor of 2 of each other, Sterbenz’s lemma). The remaining is tiny, so its rounding error is tiny:
2.3 The polynomial
Section titled “2.3 The polynomial”Taylor’s theorem (M02.1) for at 0 with degree : with . On and relative to :
| Degree | Relative remainder bound | In float32 ulps of the result |
|---|---|---|
| 6 | about 3: too many | |
| 7 | about 0.1: rounding dominates |
Evaluate it with Horner’s rule (M00.4): , seven multiply-adds from the innermost coefficient out. Each step rounds once, and the sum of those roundings plus the reduction is what the tests measure: 1.21 ulp at worst for the reference. (A minimax polynomial, fitted to minimize the worst error on the interval instead of matching derivatives at 0, gets the same accuracy at degree 5. That is what production libraries ship; see Going further.)
2.4 Scaling by at the edges
Section titled “2.4 Scaling by 2k2^k2k at the edges”The largest float is just below , so overflows for ; the largest float below that is 0x1.62e42ep+6 , where . One past the largest exponent: is not a float. Below the result is subnormal, and below it rounds to 0, where reaches . Both ends fall outside , so apply as with (C rounds toward zero) and , both in : the first product is exact and the second rounds once, even into the subnormal range. The specials come first: NaN returns NaN (converting NaN to int is undefined behavior in C, and UBSan stops the test), returns , (and ) returns .
3. Worked example by hand
Section titled “3. Worked example by hand”.
| Step | Computation | Value |
|---|---|---|
| , nearest integer | 1 | |
| (exact) | 0.306640625 | |
| 0.30685282 | ||
| Horner | ; ; ; ; ; ; ; | |
| scale | , : | 2.7182817 |
and the float32 nearest to it is 2.7182817: the kernel is exact here. With : , , , result 0.36787945. With : , , , result 22026.465. These are the first assertions of hand_example.
4. The interface
Section titled “4. The interface”/* tinyllm/numerics.h, M09.6 section */float tl_expf(float x); /* within 2 ulp where e^x is normal; > 88.7228317 -> +inf; -inf -> +0; NaN -> NaN */void tl_exp_f32(const float *x, float *y, int64_t n); /* elementwise; y == x allowed */Write pow2i(k) (the float from its bit pattern, for ) as a static helper. Keep each multiply-add of the Horner loop a separate statement or keep #pragma STDC FP_CONTRACT OFF, so the compiler does not fuse them (a fused multiply-add is more accurate, but differs between compilers and machines).
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
hand_example | unit, smoke | section 3: give the bits above; | you and the test agree on the algorithm |
million_samples_within_2_ulp | differential | seeded within 2 ulp of exp in double | the softmax’s accuracy budget (M09.3) |
sweep_of_minus_one_to_one | property | every 512th float of (4 million inputs) within 2 ulp | small logits after max subtraction |
reduction_boundaries | boundary | 64 floats each side of for every : the largest | the worst case of the polynomial |
overflow_edge | boundary | finite and within 2 ulp; the next float, FLT_MAX, and give | needs the split scale |
underflow_edge | boundary | within 2 ulp; below the normal range FLT_MIN and within one subnormal step (or 0); | masked attention scores are |
nan_in_nan_out | boundary | NaN stays NaN, with no undefined behavior | a NaN logit stays visible |
array_matches_scalar_and_aliases | unit | the array form gives the scalar’s bits, writes exactly , works in place | the softmax row in place |
hand_example | unit, smoke | the standalone C implementation evaluates within of | checks the worked reduction |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. stopping the Taylor polynomial at | about 3 ulp near | million_samples_within_2_ulp (mutant s01) |
2. reducing with one product, x - k * 0.69314718f | tens of ulps for large | million_samples_within_2_ulp (mutant s02) |
3. by truncation, (int)(x / ln 2) | up to , where degree 7 is 20 ulp off | million_samples_within_2_ulp (mutant s03) |
| 4. built in one piece | (exponent field 255) and garbage below | overflow_edge, underflow_edge (mutant s04) |
5. the overflow test written x >= 88.7228317f | the largest finite result becomes | overflow_edge (mutant s05) |
6. no NaN check before (int) | undefined behavior: UBSan aborts | nan_in_nan_out (mutant s06) |
| 7. no underflow cutoff | is undefined; large negative builds a garbage exponent | underflow_edge (mutant s07) |
| 8. the array loop off by one | the last element unwritten, or one past the end overwritten | array_matches_scalar_and_aliases (mutants s08, s09) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | rt.02 | the error slot of every stub, and the status and error support used by tl_exp_f32 |
| Back | M02.1 | exp_range_reduced is this algorithm in float64; the differential test compares the two |
| Back | M00.4 | Horner’s rule for the polynomial (reading) |
| Back | M09.1 | exponent fields, ulps, subnormals (reading) |
| Forward | L9.2 | the 3-pass and online softmax kernels exponentiate each logit minus the row maximum |
| Forward | L9.3 | FlashAttention’s running softmax rescales with |
| Forward | L9.4 | paged decode attention’s softmax over the keys of each block table |
| Forward | L9.6 | SiLU in tl_silu_mul_f32 |
If you skip this module, ol check L9.2 stops with BLOCKED ... needs M09.6: build it, or pass --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
| degree-7 Taylor | SLEEF expf, Cephes expf | a degree-5 minimax polynomial (Remez algorithm) with the same accuracy and two fewer multiplies | SLEEF src/libm/sleefsp.c (xexpf); Cephes expf.c |
| scalar loop | SLEEF and XNNPACK vector exp | 8 or 16 lanes per instruction; the reduction rounds with the “add ” trick instead of rintf | XNNPACK src/f32-vscaleexpminusmax/ |
| ulp | CORE-MATH expf | correctly rounded for every input, with a proof | the CORE-MATH project (Inria) |
tl_expf in softmax | FlashAttention-3 | exp2 with the factor folded into the score scale, so the reduction is a plain integer split | Shah et al., FlashAttention-3 (2024), section 3 |