Inspiration

FLOPs are the lingua franca of AI efficiency and a bad proxy for what mobile users actually pay: energy. Horowitz (ISSCC 2014) showed a DRAM access costs orders of magnitude more than an arithmetic op; the NEXUS/TOML model (Syed, FLAIRS 2026) grounds efficiency in transistor operations instead. We put proxies on trial on real Arm silicon, with the device's own battery as judge — and the verdict surprised even the physics.

What it does

Joulehound runs a local agentic workload — decode a 6-layer multispectral QR carrying sensor readings, act on it with an on-device LLM — in two configs on an Arm Android device, while sampling the device's own power telemetry and integrating to joules. No external meter, no lab gear. Each run is scored three ways: a FLOPs proxy, a physics-grounded NEXUS prediction (Horowitz per-op energies loaded through nexus-ml-metrics' public coefficient API), and measured battery energy.

  • baseline — re-decodes the QR and calls the brain every step
  • optimized — pollard-governed: decode once and cache, gate redundant calls, early-stop

The QR is a stand-in. Any on-device agentic loop with a repeated perception step — OCR, sensor parsing, image preprocessing — has the same redundancy pathology; the meter and methodology transfer unchanged.

The result

FLOPs vs physics vs battery — measured on a Galaxy Tab S9+

On a Galaxy Tab S9+ (5 reps per config after 3 discarded warm-ups, screen off, unplugged, airplane mode):

FLOPs claimed −50%. The physics model predicted −75%. The battery said −56%. Both proxies missed, in opposite directions.

Metric baseline optimized change
FLOPs proxy 2.00 M 1.00 M −50%
NEXUS-predicted energy (relative; α uncalibrated) 1.00 0.25 −75%
Measured battery energy 27.67 J 12.22 J −56%
Dynamic energy (1.343 W idle floor removed) 24.7 J 10.7 J −57%

Measured run statistics — Galaxy Tab S9+, N=5 per config after 3 discarded warm-ups; screen off, unplugged, airplane mode; ~1 Hz battery-API meter:

config mean energy avg power duration CoV
baseline 27.673 J ~11.1 W 2.23 s 4.1%
optimized 12.215 J ~10.0 W 1.14 s 2.5%

Full per-rep CSVs ship in the repo (results_steady.csv, results_ramp.csv). FLOPs is blind to everything silicon does. The transistor-operation model priced our image traffic at DRAM rates — but the 720 KB workload image fits in the Snapdragon's cache, so the "expensive" redundant decodes were served from SRAM. That is an experimental demonstration of exactly why the NEXUS β table ships uncalibrated: physics beats FLOPs directionally; measurement beats both. Subtracting the measured 1.34 W idle floor, the dynamic energy the optimization controls fell −57%.

Bonus finding: in a run without warm-ups, the identical baseline code cost 12.7 J/rep on efficiency cores and ~30 J/rep after the DVFS governor migrated it to performance cores — a 2.4× energy swing at constant FLOPs. We publish that run as data (results_ramp.csv), not as an outtake.

How we built it

multispecqr's threshold decoder (6 payload layers, ~720 KB image — seconds of deterministic, cache-interesting compute) + a llama.cpp brain (Qwen 2.5 Instruct Q4_K_M, compiled with NEON on-device) governed by pollard budgets. The meter speaks the official Android battery API through Termux:API at ~1 Hz — because recent One UI SELinux-blocks battery sysfs entirely — with slow-meter correctness built in: both edges of every rep sampled synchronously, the t=0 reading taken before the clock starts so meter IPC latency is never billed to the workload, vendor unit chaos (µA/mA, mV/V, sign) normalized and covered by 7 unit tests. bench.py refuses to run if a decode round-trip fails, so the numbers can never describe a workload that quietly stopped being real. CI executes the full pipeline on every push.

Challenges we ran into

The device fought us with six distinct monsters in one night: One UI denying battery sysfs (→ Android-API meter); Termux's Python 3.14 sitting past the wheel horizon — nexus-ml-metrics declaring a pandas dependency it never imports, and multispecqr 0.4.1 being pure python wearing a <3.13 metadata cap that made pip silently substitute a skeleton alpha; no prebuilt OpenCV existing anywhere for Termux (→ a 20-line cv2 shim that routes multispecqr's decoder to its own pyzbar fallback, verified byte-identical); Termux's zbar package linking libdbus without declaring it; and llama-cpp-python's loader predating PEP 738's sys.platform == "android", throwing "Unsupported platform" on a platform it was literally running on (one-line patch). Then the data itself taught us: our first clean run had 43% CoV because the DVFS governor changed personalities mid-experiment — which became the warm-up protocol and a publishable finding. Every workaround ships in termux-setup.sh; several are upstream bug reports in waiting.

Accomplishments we're proud of

A three-way proxy-vs-physics-vs-battery result on consumer Arm hardware with zero external equipment and a $0 toolchain, reproducible by anyone (git clone → bash termux-setup.sh → two bench commands), with the prediction made before the measurement and the misses explained rather than hidden.

What we learned

Mobile energy is decided by things FLOPs cannot see: memory locality (our cache-resident image broke the DRAM-priced prediction), DVFS core choice (2.4× at constant FLOPs), and system floors. Physics-grounded models are the right direction — and they need per-silicon calibration, which is precisely the NEXUS paper's thesis.

What's next

Calibrate α and a cache-aware β split for specific Arm cores; upstream the Horowitz table to nexus-ml-metrics; file the five upstream bugs; extend the meter to per-cluster energy on big.LITTLE.

Built with

multispecqr (6-layer QR encode/decode — the agent's eyes) · pollard (budgeted runtime governing the optimized loop) · nexus-ml-metrics (ECU/MCER/DDEV + the coefficient API our Horowitz β table loads through) · llama.cpp via llama-cpp-python (on-device LLM brain, NEON build) · Qwen 2.5 3B Instruct GGUF · Termux + Termux:API (the $0 toolchain and the battery pipe) · pyzbar / zbar (per-layer QR decode behind the cv2 shim) · qrcode · matplotlib (the chart).

Setup

Laptop (no hardware needed): clone, pip install -r requirements.txt, run both configs with --meter fake, then make_chart.py. Device: any Arm Android device (tested on a Samsung Galaxy Tab S9+, Snapdragon 8 Gen 2). Install Termux and Termux:API from F-Droid, clone, run bash termux-setup.sh, then both configs with --warmup 3 --meter termuxapi. Measure unplugged, in airplane mode, screen off.

Built With

Share this project:

Updates

Submission history