Skip to content

Math patching semantics

While a FlopCountingContext is active, math module functions that require counting (sqrt, log2, pow, ...) are temporarily replaced by counting equivalents. This page documents the exact contract of that patching.

Nothing is patched at import time

Merely importing counted_float leaves the process's math module completely untouched. The math module is only patched while at least one FlopCountingContext is active: patches are applied when the first context enters and removed when the last context exits, so nested contexts behave correctly.

The snapshot/restore contract

The patching contract mirrors unittest.mock.patch / pytest monkeypatch conventions:

  • at first context entry, the current math functions are snapshotted (whatever they are, including other packages' patches) and the counting replacements delegate through them;
  • at last context exit, that snapshot is restored, unconditionally — so math.* ends up exactly as it was when the first context entered.

The snapshot is (re)captured at patch time, not at import time: another package may have applied its own math patches after counted_float was imported, and the library delegates through — and later restores — whatever is current, rather than silently wiping those patches.

Composing with third-party patches

Well-nested (LIFO) third-party patching composes correctly: a patch applied before the first context entered is delegated through while counting and still in place after the last context exits.

Mis-nested patching is unsupported: a patch applied inside an active counting context but not removed before the last context exits is discarded — the library simply restores its snapshot.

What the replacements count

The counting replacements only count operations touching CountedFloat values; on plain floats they delegate straight through with no counting (see the counting model). Two functions carry extra classification logic. As everywhere in the counting model, any constant (non-CountedFloat) operand folds by value — it enables strength reduction and never adds an I2F conversion:

  • math.pow(x, y) classifies like x ** y: constant exponents/bases strength-reduce (x**2 → MUL, x**0.5 → SQRT, x**-1 → DIV, small int exponents → their multiply chain, base 2/10 → EXP2/EXP10), other cases count POW.
  • math.log(x, base) classifies per log variant: base omitted → LOG; constant base 2 / 10 → LOG2 / LOG10 (a compiled port calls log2/log10 directly); other constant base → LOG + MUL (a port computes log(x) * C with C = 1/log(base) folded at compile time — and C being a constant, the identity folds apply to the multiply itself: exactly 1.0, e.g. base math.e, drops it, exactly -1.0 counts MINUS); CountedFloat base → genuinely runtime, a port computes log(x)/log(base): LOG per counted operand + DIV.

The full per-operation counting rules are in the FLOP types reference.

Coverage of the math module

Every commonly used math function, and how it participates in counting:

Coverage Functions
Instrumented (patched, counts its FlopType) sqrt, cbrt, exp, exp2, expm1, log, log2, log10, log1p, pow, sin, cos, tan, asin, acos, atan, atan2, sinh, cosh, tanh, asinh, acosh, atanh, hypot (+ HYPOT_XARG per coordinate beyond the second), dist (+ DIST_XARG likewise), fmod, remainder, gamma, lgamma, erf, erfc, fabs, copysign, fmax (Python 3.15+; COMP — the compare-and-select a port emits), fmin (Python 3.15+; COMP likewise), isnan (COMP — the self-compare a port emits), isinf (ABS + COMP), isfinite (ABS + COMP), isnormal (Python 3.15+; ABS + 2 COMP — the FP-canonical DBL_MIN ≤ |x| ≤ DBL_MAX; the Python spelling's short-circuit on zeros, subnormals and NaN is a stated gap), issubnormal (Python 3.15+; ABS + 2 COMP — likewise, for 0 < |x| < DBL_MIN), signbit (Python 3.15+; COMP — the float-domain exit alone: the copysign its faithful float spelling needs is machinery of the spelling, not work the operation does), isclose (SUB + 3 ABS + MUL + 3 COMP — the transcription of its documented formula; the guards, short-circuit savings and the implementation's respelling are a stated gap), fma (Python 3.13+), sumprod (Python 3.12+; + SUMPROD_XELEM per element beyond the second — counted inputs are unboxed so the extended-precision algorithm runs)
Instrumented, counted as a decomposition (patched, counts the flops a compiled port would execute) degrees / radians → MUL; prod → (n−1) MUL, n when start is counted or differs from 1; fsum → (n−1) ADD; 1-argument hypot → ABS; 1-D dist → SUB + ABS
Counted via dunder (no patch needed — do not expect these in the patch list) math.floor / math.ceil / math.trunc → F2I through __floor__/__ceil__/__trunc__; the builtins abs() → ABS and round() → RND/F2I likewise count through their dunders
Not instrumented (returns a plain, uncounted float) exactly the float-representation helpers — frexp, ldexp, modf, nextafter, ulp

The not-instrumented set breaks contagion: the plain-float result silently stops all downstream counting, so convert back with CountedFloat(...) if a result of these feeds counted computation.

While a context is open, the reduction patches (fsum, prod, sumprod, dist) materialize their iterable inputs to inspect the operands after computing the result — a space-behavior divergence from the streaming stdlib versions; see Known limitations.

The float-classification calls (isnan, isinf, isfinite, isnormal, issubnormal, signbit, isclose) return a bool, so contagion does not apply to them — but they count all the same: a compiled port emits real compare machinery for each, and each is priced as the FP-canonical form of the question it asks. For isnan that form is an operator spelling a reader could have written (x != x) and the counts coincide; where the form is a range test the price is fixed per call while the Python spelling short-circuits, so isnormal and issubnormal charge more than the hand-written chain does on zeros, subnormals and NaN — a stated gap, listed with them in the decomposed operations. The one comparison left uncounted is truthiness (bool(x), if x:), a labeled exception — the interpreter inserts it implicitly, with no opt-out — documented with the COMP type.

Rather than checking this table against your code by hand, you can have a counting context report the uncounted calls as it meets them — the not-instrumented set, each reported once per call site. See watching what gets counted.

math.fma(x, y, z) exists only from Python 3.13 on, and is patched exactly where it exists — on older interpreters there is no such function to call, and a multiply-add written as a*b + c counts MUL + ADD there as everywhere else. It is the one place a fused multiply-add is observable at the Python level, which is why it is also the only place one is counted; see FLOP types for its counting rules and Known limitations for what operator-level fusion still costs.

Which functions are patched is also the boundary of what gets counted — see Known limitations.