Edge and On-device AI

Power and thermal limits

A device can run fast in bursts and slower forever, because heat and battery are the real budget — so measure sustained throughput and joules per inference, not peak speed.

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. Why this exists
  4. What this means for your model
  5. The battery is the second budget
  6. Where you have already felt it
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

A device can go fast for a short time, or slower for a long time. Heat and battery decide which, and no model can argue with either.

The analogy you have already lived

You can sprint. You cannot sprint for an hour.

After a few hundred metres your body forces you to slow down. Not because you decided to. Heat has to go somewhere, and it is not leaving your skin fast enough.

Your phone does exactly this. It runs fast for the first thirty seconds, gets hot, and quietly slows itself down. It never tells you. The video starts stuttering.

Why this exists

Every calculation turns a little electricity into a little heat. Millions of calculations a second turn a lot of electricity into a lot of heat.

A data centre solves this with fans, air conditioning and space. A phone has none of these. It is a sealed slab in your hand, and its only cooling is the air around it and your palm.

So the chip has a rule built into it: when I get too hot, I slow down. That is called thermal throttling, and it is a safety feature, not a bug.

What this means for your model

The speed you measured for thirty seconds is the burst speed. It is not the speed your users get.

  inferences per second
     |
 100 |████████
     |        ██████
  70 |              ████████████████████████████   <- the number that matters
     |
     +---------------------------------------------> time
      0s      30s     1min                 10min
        burst        cooling down        sustained

The honest number is the one on the right. If a benchmark ran for ten seconds, it measured the wrong thing.

The battery is the second budget

A battery holds a fixed amount of energy. Every answer your model gives spends a little of it.

The right measure is energy per answer, not speed. Take a model twice as fast that draws three times the power. For anything running all day, it is the worse choice.

Three facts that decide most designs:

Dedicated chips are far cheaper. A phone's AI accelerator does the same work for a small fraction of the energy. The main processor is far hungrier.

Sending data is expensive. Turning on the mobile radio costs more energy than most models cost to run locally. And the radio stays powered up for seconds after you finish, waiting for more. Many small uploads are the worst possible pattern.

Not running is free. Checking one frame in ten instead of every frame cuts the energy by ten. That single decision beats most optimisation work.

Where you have already felt it

  • Your phone gets warm during a long video call, and the video quality drops.
  • A phone recording 4K video shows a warning and stops.
  • A laptop's fans get loud, and then everything feels slower.
  • A game runs beautifully for five minutes and then does not.

Remember this

  • Devices run fast in bursts and slower forever. Measure the second one.
  • The real budget is energy per answer, not speed.
  • Running less often saves more than almost any code change.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install torch onnxruntime numpy

Two scripts. The first measures something real. The second measures nothing at all, and says so loudly.

Script 1: does your device hold its speed?

This runs one model flat out for a minute and reports throughput in five-second windows. It takes a minute, and that is the point.

sustained.py
import time, numpy as np, torch, torch.nn as nn, onnxruntime as ort

torch.manual_seed(0)
net = nn.Sequential(
    nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1),
    nn.Flatten(), nn.Linear(128, 10)).eval()
torch.onnx.export(net, torch.zeros(1, 3, 128, 128), "hot.onnx",
                  input_names=["image"], output_names=["scores"], opset_version=13)

so = ort.SessionOptions(); so.intra_op_num_threads = 4      # load every core, as a real app would
sess = ort.InferenceSession("hot.onnx", so, providers=["CPUExecutionProvider"])
frame = np.random.default_rng(0).standard_normal((1, 3, 128, 128), dtype=np.float32)
for _ in range(20):
    sess.run(None, {"image": frame})

WINDOW, TOTAL = 5.0, 60.0
print("window       inferences/second   share of the first window")
start = time.perf_counter(); rates = []
while time.perf_counter() - start < TOTAL:
    w0 = time.perf_counter(); n = 0
    while time.perf_counter() - w0 < WINDOW:
        sess.run(None, {"image": frame}); n += 1
    rates.append(n / (time.perf_counter() - w0))
    print("%3.0f-%3.0f s    %15.1f   %22.1f%%"
          % (w0 - start, time.perf_counter() - start, rates[-1], 100 * rates[-1] / rates[0]))

print()
print("first window %.1f/s, last window %.1f/s, sustained rate is %.1f%% of the burst rate"
      % (rates[0], rates[-1], 100 * rates[-1] / rates[0]))
Output
window       inferences/second   share of the first window
  0-  5 s             1703.4                    100.0%
  5- 10 s             1671.2                     98.1%
 10- 15 s             1689.1                     99.2%
 15- 20 s             1699.8                     99.8%
 20- 25 s             1607.0                     94.3%
 25- 30 s             1628.4                     95.6%
 30- 35 s             1701.5                     99.9%
 35- 40 s             1674.0                     98.3%
 40- 45 s             1639.9                     96.3%
 45- 50 s             1679.8                     98.6%
 50- 55 s             1689.5                     99.2%
 55- 60 s             1679.9                     98.6%

first window 1703.4/s, last window 1679.9/s, sustained rate is 98.6% of the burst rate

Nothing throttled. That is the honest result on a desktop with a fan and a mains supply. The wobble between 94% and 100% is other processes taking cores, not heat.

The absolute rate is a measurement on one machine, so your inferences-per-second will be a different number. The share of the first window is the column to read, because that one is a ratio and it means the same thing everywhere.

Which is exactly why this result is worth printing. A benchmark on a cooled desktop cannot show you thermal throttling, and most benchmarks are run on cooled desktops. Run this same script on a phone, on a Raspberry Pi without a heatsink, or on a laptop on battery, and the last column falls. On a fanless device it can fall a long way.

Two things to change when you run it there. Raise TOTAL to 600 seconds, because thermal effects on a phone take minutes rather than seconds. And on a Raspberry Pi, check the firmware's own verdict afterwards:

bash
vcgencmd get_throttled

throttled=0x0 means the run was clean. Any other value means the firmware intervened, and your numbers describe an interrupted run.

Script 2: the energy arithmetic

You cannot measure watts from Python. Nothing in the standard tooling reports the power a phone's chip is drawing. So this script is honest about what it is: arithmetic on constants you supply.

energy.py
"""This script measures NOTHING. It does arithmetic on numbers you supply.
Replace every constant with your own measurements before believing any output."""

# --- your measurements go here -------------------------------------------
CPU_MS, CPU_WATTS = 25.0, 2.5      # one inference on a phone CPU, and the power draw while busy
NPU_MS, NPU_WATTS = 4.0, 0.4       # the same model on the phone's dedicated accelerator
RADIO_WATTS = 1.5                  # cellular radio while transmitting
RADIO_TX_S, RADIO_TAIL_S = 0.4, 5.0   # time to send one image, and the radio's high-power tail after
BATTERY_MAH, BATTERY_V = 4500, 3.85
# -------------------------------------------------------------------------

battery_j = BATTERY_MAH / 1000 * BATTERY_V * 3600
print("a %d mAh, %.2f V battery holds %.0f joules\n" % (BATTERY_MAH, BATTERY_V, battery_j))

print("path                 J per inference   inferences per full battery")
rows = [("on-device, CPU", CPU_MS / 1000 * CPU_WATTS, CPU_WATTS),
        ("on-device, NPU", NPU_MS / 1000 * NPU_WATTS, NPU_WATTS),
        ("send to a server", RADIO_WATTS * (RADIO_TX_S + RADIO_TAIL_S), RADIO_WATTS)]
for name, j, watts in rows:
    print("%-20s %15.4f   %26.0f" % (name, j, battery_j / j))

print("\nbattery used per hour, at different rates")
print("frames/second     CPU        NPU     server")
for fps in [30, 5, 1, 0.1]:
    pct = [100 * j * fps * 3600 / battery_j for _, j, _ in rows]
    print("%12.1f   %6.1f%%   %6.1f%%   %8.1f%%" % (fps, pct[0], pct[1], pct[2]))

print("\nduty cycling: run the model on 1 frame in N, at 30 frames per second")
print("  N    effective fps   battery per hour")
for n in [1, 2, 10, 30]:
    fps = 30 / n
    print("%3d   %13.1f   %14.1f%%" % (n, fps, 100 * rows[0][1] * fps * 3600 / battery_j))
Output
a 4500 mAh, 3.85 V battery holds 62370 joules

path                 J per inference   inferences per full battery
on-device, CPU                0.0625                       997920
on-device, NPU                0.0016                     38981250
send to a server              8.1000                         7700

battery used per hour, at different rates
frames/second     CPU        NPU     server
        30.0     10.8%      0.3%     1402.6%
         5.0      1.8%      0.0%      233.8%
         1.0      0.4%      0.0%       46.8%
         0.1      0.0%      0.0%        4.7%

duty cycling: run the model on 1 frame in N, at 30 frames per second
  N    effective fps   battery per hour
  1            30.0             10.8%
  2            15.0              5.4%
 10             3.0              1.1%
 30             1.0              0.4%

Three conclusions from that table

The accelerator is 39 times cheaper per inference, and it is not because it is faster. It is 6.25 times faster and draws 6.25 times less power, and those two multiply together. This is why Core ML, NNAPI and the vendor delegates exist, and why getting your model onto the accelerator matters far more than shaving a layer off it.

Sending the image to a server costs 8.1 joules — 130 times the local CPU cost. Almost all of it is the tail: RADIO_TAIL_S = 5.0. The radio stays in a high-power state for seconds after a transmission, waiting for more traffic. One small upload therefore costs as much as five seconds of radio-on time.

Read the server column at 30 frames per second: 1402.6% of the battery per hour. That is not a slow design, it is an impossible one — you would need fourteen fully charged batteries to survive an hour. Uploading every frame is not on the menu.

Duty cycling beats everything. Running on one frame in ten drops the cost from 10.8% to 1.1% of the battery per hour, and it took no engineering at all. Before you quantise, prune or distil, ask how often the model genuinely needs to run.

Every number above depends on constants I supplied. They are plausible orders of magnitude, and they are not measurements of your device. Change them and the conclusions can change.

How to get real power numbers

There is no portable API. The routes that work:

  • A USB power meter in line with a Raspberry Pi. Around 500 rupees, and it measures the whole board honestly, including the SD card and any peripherals.
  • Android Battery Historian, or dumpsys batterystats, for per-app energy attribution over long runs.
  • Xcode's Energy Log instrument on iOS.
  • A phone left unplugged, running your app for an hour, with the battery percentage noted before and after. Crude, and better than nothing, and much better than a guess.

Common mistakes

Benchmarking for ten seconds. You measured the burst. Users get the sustained rate.

Benchmarking on mains power. Phones and laptops run at higher clocks when plugged in. Measure on battery.

Benchmarking a cold device. The first run after the device has been idle is the best it will ever be. Warm it, then measure.

Optimising latency when energy is the constraint. A model twice as fast that uses the wide vector units and drives the clock up can spend more joules per answer. Optimise the quantity that is actually binding.

Polling instead of waiting on an event. A model that wakes every 100 ms to check whether anything happened keeps the chip out of its deep sleep states permanently. Wake on the sensor interrupt instead.

Ignoring the radio tail. Batch network traffic. One transfer of ten items costs far less than ten transfers of one.

Try it yourself

Run sustained.py on the warmest device you own, with TOTAL = 600. Then run it again with the device in a fridge, or with a fan pointed at it, and compare the last window. The difference between those two runs is your thermal headroom, and it is a number nobody can tell you without measuring your hardware.

What to learn next

Researcher — Mathematics and papers.

Where the energy goes

Dynamic power in CMOS is

$$ P_{\text{dyn}} = \alpha C V^2 f $$

  • $\alpha$ — activity factor, the fraction of transistors switching per cycle.
  • $C$ — total switched capacitance.
  • $V$ — supply voltage.
  • $f$ — clock frequency.

Static leakage $P_{\text{leak}} = V I_{\text{leak}}$ adds to this, and grows superlinearly with temperature — which is what makes thermal runaway a real design concern rather than a theoretical one.

The quadratic in $V$ is the whole basis of dynamic voltage and frequency scaling. Sustaining a higher $f$ requires a higher $V$, so power grows roughly as $f^3$ while work grows linearly in $f$. Energy per operation therefore rises with frequency, and the cheapest way to do a fixed amount of work is slowly.

This produces the race-to-idle versus crawl question. Race-to-idle wins when static power and fixed platform overhead dominate, because finishing early lets the whole system enter a deep sleep state. Crawling wins when dynamic power dominates and there is no deep state to reach. For inference on a phone, race-to-idle usually wins, because the display and memory subsystem impose a large fixed cost while anything is awake at all.

Thermal dynamics

A first-order lumped model:

$$ C_{\text{th}} \frac{dT}{dt} = P(t) - \frac{T - T_{\text{amb}}}{R_{\text{th}}} $$

  • $C_{\text{th}}$ — thermal capacitance in J/K, the heat the package absorbs per degree.
  • $R_{\text{th}}$ — thermal resistance to ambient in K/W.
  • $T_{\text{amb}}$ — ambient temperature.

The steady-state rise is $\Delta T = P R_{\text{th}}$, reached with time constant $\tau = R_{\text{th}} C_{\text{th}}$. For a phone $\tau$ is on the order of minutes; for a bare Raspberry Pi, tens of seconds; for a microcontroller, effectively instant.

Two consequences that decide benchmark design. A benchmark shorter than $\tau$ measures the transient and reports a number the device cannot hold. And ambient temperature enters linearly — the same phone in a 20 °C room and a 40 °C room has substantially different sustained throughput, which is not a minor consideration for deployments in India, the Gulf or an unventilated equipment cabinet.

Sustained performance is bounded by $P_{\max} = (T_{\text{limit}} - T_{\text{amb}}) / R_{\text{th}}$ regardless of what the silicon could do.

The energy hierarchy, again

From Horowitz (2014), at 45 nm, the numbers that determine where optimisation effort belongs:

OperationEnergyRelative to an int add
int8 add0.03 pJ0.3x
int32 add0.1 pJ1x
fp32 add0.9 pJ9x
fp32 multiply3.7 pJ37x
SRAM read, 8 KB5 pJ50x
DRAM read640 pJ6400x

A DRAM access costs more than 170 fp32 multiplies. This single ratio explains the shape of every efficient accelerator: they are built around keeping data resident and reusing it, not around doing arithmetic faster.

It also reframes quantisation. The arithmetic saving from int8 over fp32 is roughly 30x per multiply, but the memory saving is 4x on a term that is three orders of magnitude larger. Quantisation is a memory-traffic optimisation that happens to also reduce arithmetic energy, and this is why it helps even on hardware with no integer speed-up.

The radio tail

Balasubramanian et al. (2009) measured the energy cost of cellular transfers directly and found the dominant term to be the RRC state machine's tail, not the transmission. After a transfer, 3G held a high-power state for 5–12 seconds; LTE's tail is shorter but present, and 5G NR adds its own inactivity timers.

The energy for $n$ transfers separated by more than the tail is $n \cdot P_{\text{radio}} (t_{\text{tx}} + t_{\text{tail}})$, whereas $n$ coalesced transfers cost $P_{\text{radio}} (n\, t_{\text{tx}} + t_{\text{tail}})$. For small payloads the difference approaches a factor of $n$.

This is why "send it to the server" is an energy decision and not only a latency one, and it is the quantitative form of the argument made in why run a model on the device.

Reporting standards

The literature is poor here. A checklist that makes an edge benchmark reproducible:

  1. Sustained throughput over a period exceeding the thermal time constant, plus the burst figure separately.
  2. Ambient temperature and cooling arrangement.
  3. Power source: battery or mains, and the battery's state of charge.
  4. Frequency governor and, where available, the throttling state before and after.
  5. Joules per inference, with the measurement method stated — board-level, package-level or estimated.
  6. Device model and firmware version, since throttling ladders change between firmware revisions.

MLPerf Tiny (Banbury et al., 2021) includes an energy measurement track with a specified harness, and it is the only widely used benchmark in this space that treats energy as a first-class result rather than a footnote.

Reading

  • Horowitz, Computing's Energy Problem, ISSCC 2014.
  • Balasubramanian, Balasubramanian and Venkataramani, Energy consumption in mobile phones: a measurement study and implications for network applications, IMC 2009.
  • Banbury et al., MLPerf Tiny Benchmark, NeurIPS Datasets and Benchmarks 2021 — arxiv.org/abs/2106.07597
  • Chen, Emer and Sze, Eyeriss: An Energy-Efficient Reconfigurable Accelerator for Deep Convolutional Neural Networks, ISSCC 2016 — dataflow design driven entirely by the memory hierarchy above.
  • Yang, Chen and Sze, Designing Energy-Efficient Convolutional Neural Networks using Energy-Aware Pruning, CVPR 2017 — pruning by measured energy rather than by parameter count, and the two orderings differ.

What to learn next