Error analysis, condition numbers, tolerance budgets
Overview
Section titled “Overview”| Module | M09.3 · build · Python · Pass 6 · 3 to 4 h |
| You build | python/tinyllm/num/tolerance.py: unit_roundoff, gamma, sum_error_bound, dot_error_bound, matmul_error_bound, assert_close_bounded, bound_ratio, cond, relative_condition, fd_error_model, optimal_fd_step |
| Contract | course/contracts/py/tinyllm/num/tolerance.pyi |
| Tests | course/tests/M09.3/ (what they check: section 4) |
| Needs | M03.5 (svd: cond takes its singular values) · M01.1 (central_diff: the tests measure a real difference against your step) · reading: M09.1 the unit roundoff, M09.2 summation order, S-M09a |
| Used by | L8.5’s output_error_bound budgets quantization error with matmul_error_bound(W, x^T, dtype, dA=abs(W - dequantize(q))) (at most scale/2) · optional L9.1 checks its standalone C matmul against fixture values within matmul_error_bound · your own differential tests from rung R5 on |
| Milestone | MS-P6 (Pass 6 gate: every math module of the pass checks green) |
| Optional depth | Higham, Accuracy and Stability of Numerical Algorithms (SIAM, 2nd ed.), ch. 2 to 4 and 7; Trefethen and Bau, Numerical Linear Algebra, lectures 12 to 15; Higham and Mary, “A New Approach to Probabilistic Rounding Error Analysis” (SIAM J. Sci. Comput., 2019) |
Key Takeaways
Section titled “Key Takeaways”- One model of rounding, with , chained through operations, gives , and with it a bound on a dot product’s error that holds for every input and every summation order (
test_dot_bound_holds_and_is_not_loose,test_matmul_bound_covers_any_order). - The bound scales with , not with : when terms cancel, the relative error of the result can be huge while the algorithm is perfectly fine (
test_matmul_bound_covers_any_order). - The condition number measures the problem, not the algorithm: bounds how much a relative change in moves the solution of , and the bound is reached (
test_cond_bounds_the_amplification). - A tolerance budget adds what you introduce on purpose (quantization moves each weight by up to half a step) to what rounding adds; a test that only allows rounding fails a correct quantized kernel (
test_matmul_bound_budgets_a_perturbation). - Finite differences trade truncation (falls with ) against rounding (grows as ); the optimal step is forward and central (
test_fd_model_and_optimal_step,test_optimal_step_on_real_differences).
How to work this chapter
Section titled “How to work this chapter”ol start M09.3 # stubs tolerance.py into your repool tests M09.3 # read the test catalog first: rung R0, you write no tests hereol check M09.3 # exit code is the verdictol check M09.3 --ref-deps # only if you skipped M03.5 or M01.1ol diff M09.3 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Part 8 and Part 9 compare numbers that are not equal and must not be. Your C matmul (L9.1) sums in tiles, numpy sums pairwise in blocks, and the two agree only to rounding; your int8 and int4 kernels (L9.5) start from weights that were moved on purpose (L8.5). A tolerance chosen by eye fails one of two ways. Too tight, and a correct kernel fails on a long row (the error of a dot product grows with its length). Too loose, and a kernel that drops one term of 257 passes. So far the course tests have used the frozen tests/_lib/close.py, whose rule is a statistical rule of thumb. Before you write kernels whose tests you own, you derive the rigorous version: where the error comes from, how big it can be for a given input, and how much of it is the problem’s fault rather than the algorithm’s.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| significand bits including the implicit 1: 53 (f64), 24 (f32), 11 (f16), 8 (bf16), 4 (e4m3), 3 (e5m2) | integer | |
| unit roundoff: the largest relative error of one rounding | float | |
| the floating-point result of an expression | float | |
| the relative error of one rounding, | float | |
| the accumulated relative error of roundings, for | float | |
| the vectors of a dot product | float[k] | |
| elementwise absolute value | float[k] | |
| matrices, is , is | float[m, k], float[k, n] | |
the largest and smallest singular values (M03.5) | float | |
| the 2-norm condition number | float | |
| the relative condition number of a scalar function | float | |
| a finite-difference step | float |
2.1 The rounding model
Section titled “2.1 The rounding model”IEEE arithmetic rounds the exact result of each operation to the nearest float (M09.1), so for
is half the gap between 1 and the next float: the gap is (machine epsilon, np.finfo(np.float32).eps ), and rounding to the nearest float errs by at most half of it, for float32. Counting the implicit bit matters: bf16 stores 7 fraction bits but keeps 8 significant bits, so its is .
2.2 Products of and
Section titled “2.2 Products of (1+δ)(1 + \delta)(1+δ) and γk\gamma_kγk”A value that passes through roundings carries a factor . If ,
(Induction: , and .) To first order ; the denominator makes the bound rigorous and says it is void once , which for bf16 happens at .
Now a dot product summed left to right: , . The term goes through one multiplication and additions, for through one multiplication and additions; every term goes through at most roundings, so
The same count holds for any order of the additions (pairwise, blocked, tiled): each term meets at most additions. That is why one bound covers numpy’s BLAS, your tiled C kernel (L9.1), and a plain loop. A sum without products (, exact multiplications) needs : one term alone is exact.
2.3 Why and not
Section titled “2.3 Why ∣x∣⋅∣y∣\lvert x\rvert \cdot \lvert y\rvert∣x∣⋅∣y∣ and not ∣x⋅y∣\lvert x \cdot y\rvert∣x⋅y∣”The roundings are relative to the partial sums, and the partial sums can be large even when the result is small. in float32 computes , then : the exact result is lost completely, a 100% relative error, and that is correct float32 arithmetic. The bound allows it. A bound proportional to would call a correct kernel broken. For a matrix product the elementwise bound is .
2.4 Tolerance budgets
Section titled “2.4 Tolerance budgets”A test compares an implementation against a reference. Every source of difference must be in the allowance, and nothing else:
With quantization to a grid of step (L8.5), each weight moves by at most . With it is the plain rounding bound optional L9.1 checks its standalone C matmul against. bound_ratio reports : at most 1 passes, and how close to 1 it is tells you whether the test can still catch a bug. assert_close_bounded multiplies the dot bound by a slack (default 4) because the “expected” side is itself computed in floating point (in float64, or in float32 by a different order).
The worst case is rarely reached: rounding errors have random signs and partially cancel, so the typical error grows like , not (Higham and Mary 2019). The frozen tests/_lib/close.py that grades your modules uses that statistical rule: tolerances times . It is tighter and almost always right; the bound here is looser and always right. Use the bound when a false failure would be expensive to debug.
2.5 Condition numbers
Section titled “2.5 Condition numbers”Error analysis separates two questions. The backward error of an algorithm asks: for which nearby input is my computed output the exact answer? The condition number of the problem asks: how much does the exact answer move when the input moves? The forward error is at most their product.
For a scalar function, a relative change in changes by , a relative change of
has : it halves relative errors. near has , which is at : the cancellation of S-M09a is ill-conditioning, a property of subtraction near equal numbers, not of any algorithm.
For a linear system and a perturbation : , so , and . Multiplying,
in the 2-norm, where and . Equality holds when lies along the top left singular vector and along the bottom one. Eigenvalues are not a substitute: has both eigenvalues 1 and . A matrix whose is below is singular to working precision, and cond returns rather than a meaningless .
2.6 The optimal finite-difference step
Section titled “2.6 The optimal finite-difference step”M01.1’s forward difference has truncation error (Taylor) and rounding error up to (two evaluations, each off by , divided by ). The total
with and . The central difference has truncation and rounding :
In float64 with unit scales that is about and , and the best achievable errors are about and : the central difference wins by three orders of magnitude, which is why the frozen gradcheck uses it.
3. Worked example by hand
Section titled “3. Worked example by hand”A dot product that loses two terms. , , all float32. Recursive summation: rounds to 1 (the gap at 1 is , and is less than half of it), and so does the next addition. The computed result is 1; the exact result is ; the error is .
The bound: , , , so the allowance is . The error is about 0.11 of the allowance: inside, as it must be.
An ill-conditioned system. is symmetric, so its singular values are its eigenvalues, : about and . So . With the solution is . Move to , a relative change of ; the solution becomes , a relative change of . The amplification is , below 40002 as the theory says. No algorithm can do better than this problem allows.
These are the first cases in section 4: test_hand_example and test_hand_example_condition.
4. The interface
Section titled “4. The interface”def unit_roundoff(dtype: str) -> float # "f32" -> 2^-24def gamma(k: int, dtype: str) -> float # k u / (1 - k u)def sum_error_bound(k: int, dtype: str, abs_sum) -> NDArray # gamma_(k-1) * sum|x|def dot_error_bound(k: int, dtype: str, abs_dot) -> NDArray # gamma_k * |x|.|y|def matmul_error_bound(A, B, dtype: str, dA=0.0) -> NDArray # dA|B| + gamma_k (|A| + dA)|B|def assert_close_bounded(actual, expected, k: int, dtype: str, abs_dot, slack: float = 4.0) -> Nonedef bound_ratio(actual, expected, bound) -> float # max |error| / bounddef cond(A) -> float # sigma_max / sigma_min via M03.5def relative_condition(f, df, x: float) -> float # |x f'(x) / f(x)|def fd_error_model(h, order, dtype, f_scale=1.0, deriv_scale=1.0) -> floatdef optimal_fd_step(order, dtype, f_scale=1.0, deriv_scale=1.0) -> floatdtype is one of "f64", "f32", "f16", "bf16", "e4m3", "e5m2" or the numpy names. These helpers decide only this module’s verdict: course tests elsewhere assert through the frozen tests/_lib/close.py (D35), and you use these in your own tests.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example | unit | section 3’s dot product, , , the bound | you and the test agree on the definitions |
test_hand_example_condition | unit | section 3’s system: , amplification 28284 | the meaning of a condition number |
test_unit_roundoff_table | unit | for six formats and the numpy names; | every bound scales with it |
test_gamma_edges | boundary | , exact formula, void at | bf16 sums of 256 terms have no bound |
test_dot_bound_holds_and_is_not_loose | property | 1000 float32 dots hold the bound; median bound/error under 100 | a tolerance that never false-fails and still catches |
test_sum_bound_holds | property | float16 running sums within ; is exact | the count of roundings |
test_bounds_are_elementwise_arrays | unit | array in, float64 array out | kernel tests pass whole matrices |
test_matmul_bound_covers_any_order | property | BLAS, long sequential sums, and cancellation stay inside | L9.1 tiles in its own order |
test_matmul_bound_budgets_a_perturbation | property | quantized weights pass with , fail without | L8.5’s quantization budget |
test_assert_close_bounded_passes_and_fails | unit | a correct dot passes, one dropped term fails, slack scales | the helper is a verdict |
test_assert_close_bounded_special_values | boundary | NaN, infinities, zero bounds, shape mismatch | overflowed kernels compare sanely |
test_bound_ratio | unit | the worst element, , | optional C parity reports one number per operation |
test_cond_matches_numpy | golden | LAPACK on square, tall, wide, Hilbert, non-normal | an independent implementation agrees |
test_cond_edges | boundary | identity, scale invariance, orthogonal, singular gives | no meaningless |
test_cond_bounds_the_amplification | property | amplification , reached at the singular directions | the theorem of section 2.5 |
test_relative_condition | unit | , , near 1, zeros | cancellation is conditioning |
test_fd_model_and_optimal_step | unit | both models, both optimal steps, minimality | gradcheck steps |
test_optimal_step_on_real_differences | property | M01.1’s central difference is best near | the model predicts real behavior |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. using for , or forgetting the implicit bit | every tolerance off by 2 | test_unit_roundoff_table (mutants s01, s03) |
| 2. without the denominator | a bound that stays finite where it is void | test_gamma_edges (mutant s02) |
| 3. the wrong count of roundings, or for | a correct kernel fails on long or cancelling rows | test_dot_bound_holds_and_is_not_loose (mutant s04), test_matmul_bound_covers_any_order (mutant s20) |
| 4. a budget without the deliberate perturbation | a correct int4 kernel fails its test | test_matmul_bound_budgets_a_perturbation (mutants s06, s07) |
| 5. ignoring the slack on the expected side | flaky failures at long | test_assert_close_bounded_passes_and_fails (mutant s09) |
| 6. treating a numerically singular matrix as finite | reported as a number | test_cond_edges (mutant s11) |
| 7. eigenvalues instead of singular values | non-normal matrices look well conditioned | test_cond_matches_numpy (mutant s12) |
| 8. the absolute condition | a scale-dependent number | test_relative_condition (mutant s14) |
| 9. for the central difference | a step 100 times too small, ten times the error | test_fd_model_and_optimal_step (mutant s16) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M03.5 | svd gives the singular values cond divides |
| Back | M01.1 | central_diff is the difference whose step section 2.6 optimizes |
| Back | M09.1 | and the formats |
| Forward | L8.5 | output_error_bound(w, q, x): matmul_error_bound(W, x^T, "f32", dA=abs(W - dequantize(q))), the quantization step (at most scale/2) plus the f32 rounding |
| Forward | L9.1 | standalone C matmul checked against fixture values within matmul_error_bound |
| Forward | L9.1 | the tiled matmul’s own differential test, if you write it with these bounds |
If you skip this module, L8.5 still needs its error budget; optional L9.1 can be studied later.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
assert_close_bounded | PyTorch torch.testing.assert_close | per-dtype default tolerances, NaN and device handling (a fixed table, not a bound) | torch/testing/_comparison.py |
dot_error_bound | probabilistic error analysis | bounds that hold with probability | Higham and Mary (2019) |
cond | numpy.linalg.cond, LAPACK xGECON | estimates in from an LU factorization instead of an SVD | LAPACK dgecon.f |
optimal_fd_step | JAX and PyTorch gradcheck | central differences at a fixed in float64 | torch/autograd/gradcheck.py |