-
-
Six sensor payloads, one multispectral QR. Decoding it is the perception step the agent repeats — caught by the joule.
-
Live measured run: the tablet reading its own battery mid-benchmark — 179 J at 16 W, no lab gear
-
3 verdicts, 1 optimization: FLOPs said −50%, physics predicted −75%, the battery measured −56%. Both proxies missed. Measurement didn't
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

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.

Log in or sign up for Devpost to join the conversation.