Counting FLOPs¶
The CountedFloat class¶
In order to instrument all floating point operations with counting
functionality, the CountedFloat class was implemented, which is a drop-in
replacement for the built-in float type. The CountedFloat class is a
subclass of float and is "contagious", meaning that it will automatically
ensure results of math operations where at least one operand is a
CountedFloat will also be a CountedFloat. This way we ensure flop counting
is a 'closed system'.
On top of this, math module functions that require counting (sqrt, log2,
pow, ...) are also instrumented: while a FlopCountingContext is active
(see below), they are temporarily replaced by counting equivalents. Outside
such a context — including at plain import time — the math module is left
completely untouched (see Math patching semantics for the
exact contract).
Example:
from counted_float import CountedFloat
cf = CountedFloat(1.3)
f = 2.8
result = cf + f # result = CountedFloat(4.1)
is_float_1 = isinstance(cf, float) # True
is_float_2 = isinstance(result, float) # True
The counting model: what gets counted and why¶
The counting model is a contract with two sides:
- Your side: wrap every runtime input of the algorithm you want to
measure in
CountedFloatat its boundary. Contagion does the rest — everything derived from those inputs stays counted automatically. - The library's side: count every FLOP that a compiled (C/Rust/...) port of your algorithm would execute on data derived from those inputs.
From this contract follows a clean rule for everything else: constants are
free. Any plain float encountered mid-computation is, by the contract, not
an input — so it must be a constant of the algorithm (a literal, a
coefficient, a tolerance), and operations purely among constants are work a
compiled port would fold at compile time or precompute. This is why e.g.
math.sqrt(3) counts nothing: the port ships sqrt(3) as a precomputed
constant.
The library detects constants through two mechanisms, applying the same rule:
- Unwrapped values (plain floats): constants by the wrapping contract, as above.
intoperands: evidence of a hardcoded constant — ints don't fall out of floating-point computations, so an int operand almost certainly appears literally in your source. This enables counting the strength reductions a compiled port would apply:x**2counts MUL (i.e.x*x),2**xcounts EXP2,math.log(x, 10)counts LOG10 — whilex**2.0, with a float that could be a runtime value, conservatively counts a generic POW.
The flip side: an unwrapped runtime input is invisible to the counter — that is a wrapping error at your algorithm's boundary, not something the library can detect. When in doubt, wrap.
One more consequence of the context-scoped math patching: math.* calls
participate in counting (and in contagion) only while a FlopCountingContext
is active. Operator-based contagion (+, *, **, ...) works everywhere,
but counts are meant to be read through a context — so the practical rule is
simply: run your measured algorithm inside one.
Not everything is counted — see Known limitations for
what falls outside the counting model (e.g. numpy operations).
FLOP counting context managers¶
Once we use the CountedFloat class, we can use the available context
managers to count the number of flops performed by CountedFloat objects.
Example 1: basic usage
from counted_float import CountedFloat, FlopCountingContext
cf1 = CountedFloat(1.73)
cf2 = CountedFloat(2.94)
with FlopCountingContext() as ctx:
_ = cf1 * cf2
_ = cf1 + cf2
counts = ctx.flop_counts() # {FlopType.MUL: 1, FlopType.ADD: 1}
counts.total_count() # 2
Example 2: math module functions
import math
from counted_float import CountedFloat, FlopCountingContext
cf1 = CountedFloat(0.81)
with FlopCountingContext() as ctx:
s = math.sqrt(cf1) # s = CountedFloat(0.9)
counts = ctx.flop_counts() # {FlopType.SQRT: 1}
Note that math.* functions are only instrumented inside the context:
outside it, math.sqrt(cf1) returns a plain float and counts nothing.
Example 3: pause counting 1
from counted_float import CountedFloat, FlopCountingContext
cf1 = CountedFloat(1.73)
cf2 = CountedFloat(2.94)
with FlopCountingContext() as ctx:
_ = cf1 * cf2
ctx.pause()
_ = cf1 + cf2 # will be executed but not counted
ctx.resume()
_ = cf1 - cf2
counts = ctx.flop_counts() # {FlopType.MUL: 1, FlopType.SUB: 1}
counts.total_count() # 2
Example 4: pause counting 2
from counted_float import CountedFloat, FlopCountingContext, PauseFlopCounting
cf1 = CountedFloat(1.73)
cf2 = CountedFloat(2.94)
with FlopCountingContext() as ctx:
_ = cf1 * cf2
with PauseFlopCounting():
_ = cf1 + cf2 # will be executed but not counted
_ = cf1 - cf2
counts = ctx.flop_counts() # {FlopType.MUL: 1, FlopType.SUB: 1}
counts.total_count() # 2