Skip to content

Polynomial approximation and range reduction: expf in C (optional)

ModuleM09.6 · side · C · Pass 6 · 2 to 3 h
You buildc/src/numerics/expf.c: tl_expf and tl_exp_f32 (and the helper pow2i)
Contractcourse/contracts/c/include/tinyllm/numerics.h (the M09.6 section) · rules: c/ABI.md
Testscourse/tests/M09.6/: test_expf.c (C, under ASan and UBSan) (what they check: section 4)
Needsrt.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 byL9.2 softmax · L9.3 FlashAttention · L9.4 paged attention · L9.6 SiLU, each calling tl_expf per element
MilestoneMS-L9, the optional standalone C module group
Optional depthMuller, 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
  • Range reduction turns an infinite domain into a tiny one exactly: ex=2kere^x = 2^k e^r with k=round(x/ln⁡2)k = \mathrm{round}(x/\ln 2) and ∣r∣≤ln⁡(2)/2|r| \le \ln(2)/2, and 2k2^k costs nothing because it is an exponent field (reduction_boundaries).
  • The reduction must not round away rr: kln⁡2k \ln 2 is subtracted in two parts (Cody and Waite), a 9-bit high part whose product with kk is exact and a low part for the rest (million_samples_within_2_ulp).
  • On ∣r∣≤0.347|r| \le 0.347 a Taylor polynomial of degree 7 is accurate to about 10−810^{-8}, 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).
  • kk runs from −150-150 to 128128, past both ends of the float exponent range, so 2k2^k is applied in two halves; the edges overflow to +∞+\infty and underflow to 00 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_reduced on 50000 inputs (test_within_2_ulp_of_exp_range_reduced).
Terminal window
ol start M09.6 # stubs expf.c into your repo
ol tests M09.6 # read the test catalog first: rung R0, you write no tests here
ol check M09.6 # exit code is the verdict
ol check M09.6 --ref-deps # only if you skipped rt.02 or M02.1
ol diff M09.6 # after passing: your code against the reference

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 x/(1+e−x)x/(1 + e^{-x})), 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 −87.34-87.34, ±∞\pm\infty, NaN) that a float64 prototype never meets.

SymbolMeaningType / shape
xxthe inputfloat
kkthe integer nearest x/ln⁡2x / \ln 2 (ties to even)int, −150≤k≤128-150 \le k \le 128 in range
rrthe reduced argument x−kln⁡2x - k \ln 2float, ∣r∣≤ln⁡(2)/2≈0.3466\lvert r \rvert \le \ln(2)/2 \approx 0.3466
p(r)p(r)the polynomial approximating ere^rfloat
Lhi,LloL_{hi}, L_{lo}ln⁡2\ln 2 split: Lhi=355/512=0.693359375L_{hi} = 355/512 = 0.693359375, Llo=ln⁡2−Lhi≈−2.1219444×10−4L_{lo} = \ln 2 - L_{hi} \approx -2.1219444 \times 10^{-4}float constants
R7(r)R_7(r)the remainder of the degree-7 Taylor polynomial
ulp(y)\mathrm{ulp}(y)the spacing of float32 values at yy

exe^x is defined everywhere but a polynomial is accurate only near its center. The identity ea+b=eaebe^{a + b} = e^a e^b moves any input into a small interval: write x=kln⁡2+rx = k \ln 2 + r, so

ex=ekln⁡2er=2ker.e^x = e^{k \ln 2} e^r = 2^k e^r.

Choosing k=round(x/ln⁡2)k = \mathrm{round}(x / \ln 2) puts rr in [−ln⁡(2)/2,ln⁡(2)/2][-\ln(2)/2, \ln(2)/2]. Multiplying by 2k2^k is free in binary floating point: 2k2^k (for −126≤k≤127-126 \le k \le 127) is the float with exponent field k+127k + 127 and mantissa 0, and multiplying by it changes only the exponent of the result, so it is exact whenever the result stays normal.

r=x−kln⁡2r = x - k \ln 2 subtracts two nearly equal numbers when xx is large: at x=88x = 88, k=127k = 127 and kln⁡2≈88.03k \ln 2 \approx 88.03. In float32, k⋅fl(ln⁡2)k \cdot \mathrm{fl}(\ln 2) has an error of about 127×2−25≈4×10−6127 \times 2^{-25} \approx 4 \times 10^{-6}, which is relative error 10−410^{-4} in r≈−0.03r \approx -0.03 and therefore in the result: a hundred ulps. Cody and Waite’s fix: split ln⁡2=Lhi+Llo\ln 2 = L_{hi} + L_{lo} with LhiL_{hi} having few significant bits. Lhi=0.693359375=355/512L_{hi} = 0.693359375 = 355/512 has 9 significant bits and ∣k∣≤150|k| \le 150 has 8, so kLhik L_{hi} fits in 17 bits and is exact in float32; x−kLhix - k L_{hi} is then exact too (the two are within a factor of 2 of each other, Sterbenz’s lemma). The remaining kLlok L_{lo} is tiny, so its rounding error is tiny:

r=(x−kLhi)−kLlo.r = (x - k L_{hi}) - k L_{lo}.

Taylor’s theorem (M02.1) for ere^r at 0 with degree nn: er=∑j=0nrj/j!+Rn(r)e^r = \sum_{j=0}^{n} r^j/j! + R_n(r) with ∣Rn(r)∣≤e∣r∣∣r∣n+1/(n+1)!|R_n(r)| \le e^{|r|} |r|^{n+1}/(n+1)!. On ∣r∣≤0.3466|r| \le 0.3466 and relative to er≥e−0.3466e^r \ge e^{-0.3466}:

Degree nnRelative remainder bound e2∣r∣∣r∣n+1/(n+1)!e^{2\lvert r \rvert}\lvert r\rvert^{n+1}/(n+1)!In float32 ulps of the result
62.4×10−72.4 \times 10^{-7}about 3: too many
71.1×10−81.1 \times 10^{-8}about 0.1: rounding dominates

Evaluate it with Horner’s rule (M00.4): p=1+r(1+r(12+r(16+⋯+r15040)))p = 1 + r(1 + r(\tfrac12 + r(\tfrac16 + \dots + r \tfrac{1}{5040}))), 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.)

The largest float is just below 21282^{128}, so exe^x overflows for x>ln⁡(FLT_MAX)=88.7228391x > \ln(\mathrm{FLT\_MAX}) = 88.7228391; the largest float below that is 0x1.62e42ep+6 =88.7228317= 88.7228317, where k=128k = 128. One past the largest exponent: 21282^{128} is not a float. Below x=ln⁡(2−126)=−87.3365x = \ln(2^{-126}) = -87.3365 the result is subnormal, and below ln⁡(2−150)=−103.972\ln(2^{-150}) = -103.972 it rounds to 0, where kk reaches −150-150. Both ends fall outside [−126,127][-126, 127], so apply 2k2^k as 2k1⋅2k22^{k_1} \cdot 2^{k_2} with k1=k/2k_1 = k/2 (C rounds toward zero) and k2=k−k1k_2 = k - k_1, both in [−75,64][-75, 64]: 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), x>88.7228317x > 88.7228317 returns +∞+\infty, x<−103.97208x < -103.97208 (and −∞-\infty) returns +0+0.

x=1x = 1.

StepComputationValue
kk1×1.442695=1.4426951 \times 1.442695 = 1.442695, nearest integer1
x−kLhix - k L_{hi}1−0.6933593751 - 0.693359375 (exact)0.306640625
rr0.306640625−1×(−2.1219444×10−4)0.306640625 - 1 \times (-2.1219444 \times 10^{-4})0.30685282
Horner15040=0.00019841\tfrac{1}{5040} = 0.00019841; ×r+1720=0.00144977\times r + \tfrac{1}{720} = 0.00144977; ×r+1120=0.00877820\times r + \tfrac{1}{120} = 0.00877820; ×r+124=0.04436028\times r + \tfrac1{24} = 0.04436028; ×r+16=0.18027875\times r + \tfrac16 = 0.18027875; ×r+12=0.55531910\times r + \tfrac12 = 0.55531910; ×r+1=1.17040120\times r + 1 = 1.17040120; ×r+1\times r + 1p=1.3591409p = 1.3591409
scalek1=0k_1 = 0, k2=1k_2 = 1: p⋅20⋅21p \cdot 2^0 \cdot 2^12.7182817

e=2.718281828…e = 2.718281828\ldots and the float32 nearest to it is 2.7182817: the kernel is exact here. With x=−1x = -1: k=−1k = -1, r=−0.30685282r = -0.30685282, p=0.7357589p = 0.7357589, result 0.36787945. With x=10x = 10: k=14k = 14, r=0.29593948r = 0.29593948, p=1.3443887p = 1.3443887, result 22026.465. These are the first assertions of hand_example.

/* 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 2k2^k from its bit pattern, for −126≤k≤127-126 \le k \le 127) 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).

TestKINDChecksWhy it matters downstream
hand_exampleunit, smokesection 3: e1,e−1,e10e^1, e^{-1}, e^{10} give the bits above; e±0=1e^{\pm 0} = 1you and the test agree on the algorithm
million_samples_within_2_ulpdifferential10610^6 seeded x∈[−87.34,88.72]x \in [-87.34, 88.72] within 2 ulp of exp in doublethe softmax’s accuracy budget (M09.3)
sweep_of_minus_one_to_onepropertyevery 512th float of [−1,1][-1, 1] (4 million inputs) within 2 ulpsmall logits after max subtraction
reduction_boundariesboundary64 floats each side of (j+12)ln⁡2(j + \tfrac12)\ln 2 for every jj: the largest ∣r∣\lvert r \rvertthe worst case of the polynomial
overflow_edgeboundarye88.7228317e^{88.7228317} finite and within 2 ulp; the next float, FLT_MAX, and +∞+\infty give +∞+\inftyk=128k = 128 needs the split scale
underflow_edgeboundary−87.3-87.3 within 2 ulp; below the normal range 0≤y≤0 \le y \le FLT_MIN and within one subnormal step (or 0); e−∞=+0e^{-\infty} = +0masked attention scores are −∞-\infty
nan_in_nan_outboundaryNaN stays NaN, with no undefined behaviora NaN logit stays visible
array_matches_scalar_and_aliasesunitthe array form gives the scalar’s bits, writes exactly nn, works in placethe softmax row in place
hand_exampleunit, smokethe standalone C implementation evaluates x=1x = 1 within 10−810^{-8} of eechecks the worked reduction
PitfallSymptomCaught by
1. stopping the Taylor polynomial at r6r^6about 3 ulp near ∣r∣=0.35\lvert r \rvert = 0.35million_samples_within_2_ulp (mutant s01)
2. reducing with one product, x - k * 0.69314718ftens of ulps for large ∣x∣\lvert x \rvertmillion_samples_within_2_ulp (mutant s02)
3. kk by truncation, (int)(x / ln 2)∣r∣\lvert r \rvert up to ln⁡2\ln 2, where degree 7 is 20 ulp offmillion_samples_within_2_ulp (mutant s03)
4. 2k2^k built in one piecee88.72=∞e^{88.72} = \infty (exponent field 255) and garbage below −87.3-87.3overflow_edge, underflow_edge (mutant s04)
5. the overflow test written x >= 88.7228317fthe largest finite result becomes ∞\inftyoverflow_edge (mutant s05)
6. no NaN check before (int)undefined behavior: UBSan abortsnan_in_nan_out (mutant s06)
7. no underflow cutoff(int)(−∞)(int)(-\infty) is undefined; large negative kk builds a garbage exponentunderflow_edge (mutant s07)
8. the array loop off by onethe last element unwritten, or one past the end overwrittenarray_matches_scalar_and_aliases (mutants s08, s09)
DirectionModuleHow it uses this
Backrt.02the error slot of every stub, and the status and error support used by tl_exp_f32
BackM02.1exp_range_reduced is this algorithm in float64; the differential test compares the two
BackM00.4Horner’s rule for the polynomial (reading)
BackM09.1exponent fields, ulps, subnormals (reading)
ForwardL9.2the 3-pass and online softmax kernels exponentiate each logit minus the row maximum
ForwardL9.3FlashAttention’s running softmax rescales with emold−mnewe^{m_{old} - m_{new}}
ForwardL9.4paged decode attention’s softmax over the keys of each block table
ForwardL9.6SiLU x/(1+e−x)x / (1 + e^{-x}) 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.

Your pieceProduction equivalentWhat it addsWhere to look
degree-7 TaylorSLEEF expf, Cephes expfa degree-5 minimax polynomial (Remez algorithm) with the same accuracy and two fewer multipliesSLEEF src/libm/sleefsp.c (xexpf); Cephes expf.c
scalar loopSLEEF and XNNPACK vector exp8 or 16 lanes per instruction; the reduction rounds with the “add 1.5×2231.5 \times 2^{23}” trick instead of rintfXNNPACK src/f32-vscaleexpminusmax/
≤2\le 2 ulpCORE-MATH expfcorrectly rounded for every input, with a proofthe CORE-MATH project (Inria)
tl_expf in softmaxFlashAttention-3exp2 with the log⁡2e\log_2 e factor folded into the score scale, so the reduction is a plain integer splitShah et al., FlashAttention-3 (2024), section 3