whestbench.
Reference

Flopscope Primer

Flopscope is a numpy-compatible array library that tracks FLOPs analytically, enabling fair FLOP budgets across different machines.

Flopscope is a numpy-compatible array library that tracks FLOPs analytically rather than timing them on hardware. Every arithmetic operation on a fnp.ndarray increments a FLOP counter instead of (or in addition to) performing the computation. This is how WhestBench enforces fair FLOP budgets across different machines.

Source: github.com/AIcrowd/flopscope

BudgetContext

All estimator predictions run inside a BudgetContext. When the budget is exhausted, a BudgetExhaustedError is raised and your predictions are zeroed out.

import flopscope as flops
import flopscope.numpy as fnp

with flops.BudgetContext(flop_budget=1_000_000) as ctx:
    x = fnp.ones(100)
    y = x @ fnp.eye(100)  # matmul: 100 * 100 * 100 = 1M FLOPs
    # BudgetExhaustedError raised here if budget exceeded

You don't need to create BudgetContext yourself — the framework does it before calling your predict() method. The budget argument tells you how many FLOPs you have.

BudgetContext also supports wall_time_limit_s when you want a cooperative wall-clock limit in addition to the FLOP cap:

with flops.BudgetContext(flop_budget=1_000_000, wall_time_limit_s=2.0) as ctx:
    ...

The timer starts when the context is entered and is checked before and after each counted flopscope/NumPy call. If it is exceeded, flopscope raises TimeExhaustedError.

Operation FLOP Costs

CategoryOperationsCost
Free (0 FLOPs)fnp.array, fnp.zeros, fnp.ones, fnp.eye, fnp.asarray, fnp.reshape, .T, indexing, fnp.stack, fnp.concatenate, .copy(), .astype()0
Pointwise (1 FLOP/element)+, -, *, /, fnp.exp, fnp.sqrt, fnp.abs, fnp.maximum, fnp.where, fnp.log, comparisonsN elements
Reductions (input size)fnp.sum, fnp.mean, fnp.var, fnp.max, fnp.min, fnp.all, fnp.anyN elements
Matmul@, fnp.matmulM * N * K for (M,N) @ (N,K)

Key insight: Matmul dominates. A single (100, 100) @ (100, 100) costs 1M FLOPs. A pointwise exp on 100 elements costs 100 FLOPs.

Array Creation

import flopscope as flops
import flopscope.numpy as fnp

x = fnp.zeros(100)                          # 1D zeros
X = fnp.zeros((64, 100), dtype=fnp.float32)  # 2D zeros, explicit dtype
I = fnp.eye(100, dtype=fnp.float32)          # identity matrix
a = fnp.array([1.0, 2.0, 3.0])             # from list
b = fnp.asarray(numpy_array)                # convert from numpy (free)

All array creation is free (0 FLOPs).

Random Number Generation

import flopscope as flops
import flopscope.numpy as fnp

rng = fnp.random.default_rng(42)            # seeded RNG
x = rng.standard_normal((1000, 64))        # Gaussian samples
x = x.astype(fnp.float32)                   # cast to float32 (free)

Random generation itself is free. FLOPs are counted when you operate on the arrays.

Budget Inspection

Use budget.summary() for the current explicit context and fnp.budget_summary() for the accumulated session/global view:

with flops.BudgetContext(flop_budget=10_000_000) as ctx:
    # ... your computations ...
    print(ctx.summary())        # current context only
    print(fnp.budget_summary())  # process/session-wide summary
    print(ctx.flops_used)       # integer FLOP count

Both summaries also include four timing fields that satisfy a strict decomposition identity, wall_time_s = flopscope_backend_time_s + flopscope_overhead_time_s + residual_wall_time_s:

  • wall_time_s: total elapsed time in the context
  • flopscope_backend_time_s: time spent inside counted flopscope numpy kernels
  • flopscope_overhead_time_s: time spent inside flopscope's own dispatch (wrapper preambles, FLOP bookkeeping, namespace push/pop)
  • residual_wall_time_s: everything else - participant Python, GC, uninstrumented numpy

This decomposition lets you see whether time is going to numpy compute, framework dispatch, or your own Python.

WhestBench-specific limits

Flopscope's BudgetContext measures wall_time_s, flopscope_backend_time_s, flopscope_overhead_time_s, and residual_wall_time_s. It also accepts wall_time_limit_s, which it checks while counted flopscope operations run.

WhestBench exposes some of those concepts as run-level CLI knobs:

  • --wall-time-limit: passed through to the estimator's BudgetContext
  • --residual-wall-time-limit: enforced by WhestBench after predict() returns, using the reported residual_wall_time_s. Because residual_wall_time_s no longer includes flopscope's own dispatch time, this gate measures only your Python work — not the framework's bookkeeping tax.

So if you see time_exhausted, that came from Flopscope's wall_time_limit_s. If you see residual_wall_time_exhausted, that came from WhestBench scoring logic comparing Flopscope's measured residual_wall_time_s with the configured --residual-wall-time-limit.

Residual wall time: gated, not priced

Residual wall time is the part of predict() that flopscope does not meter. There are two ways to stop that being a free lunch, and WhestBench supports both — the difference is one number, λ:

RegimeλWhat happens to residual secondsC_m
Gated (default)0Capped by --residual-wall-time-limit (default 0.4 s). Crossing the cap fails the MLP.C_m = F_m
Priced> 0Converted to FLOPs and added to the bill. Wall time is spendable, at a price.C_m = F_m + λ · R_m

The default is gated, so C_m = F_m — the FLOP budget means exactly what it says, and the two resources do not trade against each other. This is the current round's design. The priced regime is the earlier one, at λ = 1e11 FLOP-equivalents per second.

The general formula covers both, since λ = 0 collapses it:

C_m = F_m + λ · R_m
  • F_m = analytical FLOPs counted by flopscope (flops_used)
  • R_m = residual wall time — the third bucket of the time decomposition. Specifically, residual_wall_time_s = wall_time_s − flopscope_backend_time_s − flopscope_overhead_time_s. This is participant Python (loops, control flow), GC pauses, and uninstrumented numpy. It explicitly excludes flopscope's own dispatch overhead (the second bucket).
  • λ = the residual price, in FLOP-equivalents per second. Defaults to 0 (whestbench.budget.DEFAULT_LAMBDA_FLOPS_PER_SECOND); set per-run with whest run --lambda-flops-per-second.

The combined C_m is capped at B_m = flop_budget. If C_m > B_m, the MLP is marked combined_budget_exhausted and the prediction is replaced with zeros. Under the default gated regime C_m = F_m, so that post-hoc check is a backstop on the same FLOP count that budget_exhausted already guards — blowing the residual cap surfaces separately, as residual_wall_time_exhausted.

Why does Phase 2 remove λ and hard-cap residual wall time instead?

Because λ priced something Phase 2 no longer permits.

The competition asks how accurate an estimate a solution can produce for a fixed amount of computation. That question is only meaningful if computation is counted the same way for every participant, and flopscope is what keeps that count.

Phase 1 permitted unmetered computation, so it priced it. A submission could call out to any other library, numerical backend, programming language, executable, or saved file of the participant's choice. That was real work the meter could not see, so it was charged against the budget through the residual-wall-time conversion. λ was that price.

Phase 2 withdraws the permission. All computation must be performed through flopscope, and a solution's own Python exists to decide which flopscope operations to call. A submission may use only the Python interpreter provided by the grader, the flopscope client API, and pure-Python standard-library modules for control flow and bookkeeping. This design encourages a focus on estimation algorithms rather than on optimizing numeric primitives — or on optimizing against the meter.

Once unmetered computation is prohibited rather than priced, there is nothing left for λ to charge for. Residual wall time stops being a channel for doing work and becomes plumbing — unpacking mlp, control flow around your fnp calls, assembling the returned array. Plumbing needs a bound, not a price. So it gets a hard 400 ms per MLP, and C_m = F_m.

The following are prohibited in Phase 2. This list is not exhaustive: each entry describes a way of performing computation that flopscope does not count, and any other mechanism having that effect is equally prohibited.

  • bundled or vendored numpy, scipy, or any BLAS or LAPACK implementation;
  • compiled kernels of any kind, however delivered, including wheels, shared objects, static binaries, and code generated at runtime;
  • ctypes, cffi, or any other foreign-function mechanism;
  • asyncio, threads, subprocesses, multiprocessing, and threading runtimes;
  • computation of any kind performed while a flopscope operation is in flight; and
  • modifying, monkeypatching, or otherwise interfering with the flopscope client, its transport, or its accounting.

Data files remain permitted, including weights, lookup tables and precomputed artifacts. Doing heavy work offline and shipping the result is the intended path, not a loophole.

There is a second benefit. Pricing coupled a score to wall-clock time, so the same submission could score differently depending on how fast the grader's machine happened to be that day. With λ = 0, C_m is a pure analytical quantity — identical on your laptop and on the grader.

The competition rules are authoritative on what is permitted; this note explains why the harness behaves as it does.

Reproducing an earlier round

Nothing here is hard-coded to a round. To re-score against the priced regime, ask for its rate and turn the gate off — it did not exist then, and leaving it armed would fail MLPs against a rule they were never scored under:

whest run --estimator estimator.py \
  --lambda-flops-per-second 1e11 \
  --no-residual-wall-time-limit \
  --flop-budget 272000000000 \
  --wall-time-limit 60

All four matter. --wall-time-limit is easy to forget because it is not part of the pricing change, but Phase 1 graded predict() at 60 s and the current default is 120 s: a submission whose predict() took between 60 s and 120 s was time_exhausted in the graded round — zeroed, no compute discount — and would otherwise score normally here, silently and in the participant's favour. Pass --dataset with that round's revision too, so the MLP shape and seeds match; the flags above only restore the scoring rules.

whestbench.budget.PHASE1_LAMBDA_FLOPS_PER_SECOND is that rate, kept as a named constant for the same purpose. run_config.lambda_flops_per_second and run_config.residual_wall_time_limit_s in every report record which regime produced it.

Common Gotchas

numpy arrays still count FLOPs. Since fnp.ndarray is backed by numpy, a raw numpy array passed to flopscope operations will still be tracked. Convert explicitly with fnp.array() or fnp.asarray() — but those conversions are themselves subject to the numeric-dtype rule below, so fnp.array([1.0, None]) and fnp.array(['a', 'b']) raise UnsupportedDtypeError rather than producing an object or string array. Build numeric data with fnp.array(..., dtype=fnp.float64) from values that are already numeric scalars, and keep ragged or mixed data in a Python list of numeric arrays instead of one array. Converting with plain numpy first is not a remedy: the grader sandbox ships no numpy.

Pythonic operators are tracked. x @ w counts the same FLOPs as fnp.matmul(x, w). Use whichever reads better.

dtype decides both cost and admission. dtype scales FLOP cost: the charged cost is flop_cost * dtype_rate * complex_factor * weight, and float64 carries a rate of 2.0, so a float64 operation costs twice the same operation in float32. dtype also decides admission — flopscope accepts only numeric dtypes (dtype.kind in "biufc": bool, signed and unsigned integer, float, complex). Anything else raises UnsupportedDtypeError (importable from flopscope.errors) wherever it reaches a registered operation, whether as an operand, an explicit dtype=, a fill value or distribution parameter, or an out= destination. The one carve-out is a dtype NumPy materialises with zero itemsize, such as an empty structured spec ('V0'); 'U0' and 'S0' are not exempt, because NumPy promotes them to 'U1'/'S1' on allocation.

Testing

Use flopscope's testing utilities:

import flopscope as flops
import flopscope.numpy as fnp

fnp.testing.assert_allclose(actual, expected, atol=1e-6)
fnp.testing.assert_array_equal(actual, expected)

These work like numpy's testing functions but on flopscope arrays.

On this page