Power Measurement#
heliaPROFILER can capture current, voltage, and energy alongside PMU data, using a GPIO-gated clean window: the firmware runs a dedicated, uninstrumented inference loop and asserts a sync GPIO high for exactly that window, while a Joulescope integrates charge/energy on-device and streams it to the host. This page walks through wiring, the minimal config to get a first reading, and every knob you're likely to need afterward — from simplest to most advanced.
What you need#
- A Joulescope JS110, JS220, or JS320, wired in series between your bench
supply (or wall adapter) and the EVB's power input. The
pyjoulescope-driverdistribution ships as a core dependency ofhelia-profiler— no extra install. - One GPIO wire from the board to the Joulescope's digital input
INPUT0— this is the minimum wiring for a valid capture (see Wiring reference). - A J-Link probe for flashing, connected as usual.
- Optionally, two more GPIO wires for the lock-step handshake (recommended/auto-enabled on Apollo5-family boards).
Don't power the EVB from two sources
The Joulescope must be the board's only power source while capturing. If a target USB cable, J-Link debug USB, or coin cell also feeds power, current readings will be wrong (sometimes negative). Disconnect every other supply during a capture.
Bench power wiring#
The Joulescope sits in series between your bench supply and the EVB's power input — separate from the GPIO gate wiring described later on this page:
+--------- USB to host (Joulescope) -----+
| |
Power supply --[+]---+--> Joulescope IN+ Joulescope OUT+ ---+--> EVB VBAT
[-]------> Joulescope IN- Joulescope OUT- ---+--> EVB GND
|
+-- USB to host (J-Link) -----> EVB J-Link ------+
- Disconnect any USB power source from the EVB.
- Wire Joulescope
IN±to your bench supply or wall adapter, and JoulescopeOUT±to the EVB'sVBAT/GND. Match polarity. - Connect J-Link USB for flashing, and the Joulescope to the host via its own USB.
- Wire the sync GPIO from Wiring reference.
- For JS320 digital I/O, connect
Vrefto the target MCU I/O rail on the EVB side of Joulescope passthrough (1.8 V for the registered Apollo510 wiring), and connect digital ground to EVB ground. Do not reference the upstream supply when it differs from the MCU GPIO voltage.
When JS320 Vref comes from the target-side I/O rail, normal capture keeps
passthrough enabled through flash, reset, handshake, and measurement. Cycling
passthrough would remove both target power and the digital logic reference, so
it is reserved for explicit recovery. GPIO observations made during flash/reset
are discarded; only fresh stable samples after a short reset grace period
participate in the READY/GO/GATE protocol.
Choosing a Joulescope#
power.driver |
Instrument | Status |
|---|---|---|
joulescope (default) |
Auto-detect JS110, JS220, or JS320 | Stable |
ondevice |
Apollo SoC internal power monitoring | Not yet implemented — see Troubleshooting |
| Aspect | JS110 | JS220 | JS320 | |---|---|---| | Current range | nA → 3 A (auto-ranging) | nA → 10 A (auto-ranging) | | Sample rate | 250 kSPS | up to 2 MSPS | up to 2 MSPS | | Voltage range | 0–15 V | 0–15 V | 0–15 V | | Lock-step GO command | Per-output value | GPO bitmap set/clear | GPO bitmap set/clear |
Both expose the same JoulescopeDriver interface inside heliaPROFILER.
Auto-detect (joulescope) is fine unless you have both connected and want
to pin one explicitly with power.serial or --power-serial.
Quick start#
On an apollo510_evb, the board's sync/state/go GPIO pins are already
registered in heliaPROFILER's board registry, so a minimal config is enough:
That's it — power.driver defaults to joulescope (auto-detects JS110, JS220,
or JS320), power.mode defaults to external, and the sync/state/go GPIO
pins default to the board's registered wiring (GPIO 29 / 36 / 14 on
apollo510_evb). Results land in summary.json's power section and the
terminal summary; see Verifying a capture for what a
healthy run looks like.
How the measurement works#
- heliaPROFILER flashes firmware that includes a dedicated clean inference loop — no per-layer PMU instrumentation, just warmed-up, back-to-back inferences.
- The firmware asserts the sync GPIO high for exactly the duration of that loop and low otherwise.
- The Joulescope samples current/voltage at ~2 MSPS internally and streams
statistics packets to the host at
power.stats_rate_hz(default 1000 Hz) — each packet already contains an on-device-integrated charge/energy total for that slice. - The host watches the Joulescope's
INPUT0(the sync GPIO) to find the rising and falling edges of the gate, then sums the stats packets that fall inside it to get gated energy, charge, and a spike-robust current/power distribution (median, p95, p99). - Energy-per-inference = gated energy ÷ the firmware-reported clean
inference count (
HPX_CLEAN_INFER_COUNT).
summary.json records power.measurement_scope: "gpio_gated_clean_window"
for this path, plus health signals described in
Verifying a capture.
Wiring reference#
The minimum wiring is one wire: the board's sync/gate GPIO into the
Joulescope's INPUT0. Optionally, a 3-wire lock-step handshake adds a
state/error line (device → host, INPUT1) and a GO line (host → device,
Joulescope OUTPUT0).
| Signal | Direction | Config field | Joulescope side | Default input/output index |
|---|---|---|---|---|
| Sync / gate | device → host | power.sync_gpio_pin |
INPUT0 |
power.sync_input_index = 0 |
| State / error (lock-step only) | device → host | power.state_gpio_pin |
INPUT1 |
power.state_input_index = 1 |
| GO (lock-step only) | host → device | power.go_gpio_pin |
OUTPUT0 |
power.go_output_index = 0 |
Board-registered defaults#
Some boards already have wiring registered, so you don't need to set these pins yourself:
| Board | sync_gpio_pin |
state_gpio_pin |
go_gpio_pin |
|---|---|---|---|
apollo510_evb |
29 | 36 | 14 |
apollo510b_evb |
29 | 36 | 14 |
apollo330mP_evb has no registered GPIO wiring yet, so you must set the
pins explicitly. The shipped configs/mlperf_tiny/*_ap330*.yaml examples use
the validated J8 header pins:
power:
sync_gpio_pin: 5 # J8 GP5 — sync/gate
state_gpio_pin: 6 # J8 GP6 — state
go_gpio_pin: 7 # J8 GP7 — go
Verified EVB-to-Joulescope wiring#
Use the following direct connections for the validated benches. All signal
grounds must share the EVB/Joulescope reference, and power.io_voltage must
match the EVB GPIO rail.
| EVB / instrument | Gate: device → monitor | State: device → monitor | GO: monitor → device |
|---|---|---|---|
| Apollo510 EVB + JS320 | GPIO 29 → INPUT0 |
GPIO 36 → INPUT1 |
OUTPUT0 → GPIO 14 |
| Apollo330 Plus EVB + JS110 | J8 GP5 → INPUT0 |
J8 GP6 → INPUT1 |
OUTPUT0 → J8 GP7 |
For Apollo330 Plus, 5:6:7 are Apollo device GPIO pin numbers, not
Joulescope channel numbers. The matching JS110 channels are always
INPUT0:INPUT1:OUTPUT0 (0:1:0 internally). With both instruments attached,
pin the JS110 explicitly and provide the device-pin mapping to validation:
hpx validate --suite smoke --boards apollo330mP_evb \
--jlink-serials apollo330mP_evb=<AP330_JLINK> \
--power-serials apollo330mP_evb=004204 \
--power-gpios apollo330mP_evb=5:6:7 --power on
io_voltage#
power.io_voltage (default 1.8) tells the Joulescope's GPI reference what
voltage represents a logic-high on the gate/state lines. It must match the
board's GPIO I/O rail — a mismatch reads a gate that never appears to go
high (or reads noise as always-high).
Lock-step (3-wire handshake)#
power.lockstep (default None, i.e. auto) adds a GO/state handshake: the
firmware parks in a wait state until the host confirms its GPIO poller is
armed and asserts GO, so reset latency and host scheduling jitter can never
race the start of the gated window.
- Auto-enables when both
state_gpio_pinandgo_gpio_pinare wired (> 0) and the target SoC family's default power reset policy needs it to stay race-free — currently true for all Apollo5-family SoCs (including Apollo330P), because their default reset strategy chains two sequential J-Link operations (debug_reset+swpoi_reset), leaving a window where an unsynchronized gate can rise and fall before the host poller starts watching. - An explicit
true/falsealways wins over the auto behavior. - Setting
lockstep: truerequires bothstate_gpio_pin > 0andgo_gpio_pin > 0— heliaPROFILER raises a config error otherwise.
Capture modes#
| Mode | Wiring | When to use |
|---|---|---|
| Gated capture | Gate only (INPUT0) |
Initial board bring-up or a bench without state/GO wiring. The host can miss a short window after a slow reset. |
| Lock-step capture (preferred) | Gate + state + GO (INPUT0, INPUT1, OUTPUT0) |
Production measurements. Firmware waits at READY; the host arms the GPI poller and asserts GO before inference begins. |
Apollo5-family boards auto-select lock-step when all three board GPIOs are
configured. Set power.lockstep: false only while bringing up incomplete
wiring; do not use it as the normal measurement mode.
Relay and passthrough behavior#
JS220 and JS320 use s/i/range/mode (off / auto) to open or close the
target-power relay; JS110 uses its family-specific range selector. hpx
power-on and the profiler's preflight passthrough set the relay to auto.
Releasing the host's passthrough handle does not turn target power off:
the relay remains latched until a power-cycle or explicit relay-off command.
power:
lockstep: true # force on, e.g. for a custom board with the wiring
# or: lockstep: false to force off (e.g. bring-up without the extra wires)
Dedicated power firmware#
PMU capture needs a host transport (rtt, uart, swo, or usb_cdc) to get
per-layer counters off the target. That same transport, if still initialized
during the power capture window, contaminates the current reading with
power draw that has nothing to do with the model:
| Transport left active during capture | Contamination source |
|---|---|
| UART | UART peripherals stay clocked and powered |
| SWO | debug power domain stays powered |
| USB CDC | USB PHY stays powered for enumeration (largest effect) |
Tearing the transport down at runtime right before the window only partially helps — pad/pinmux configuration residue still shifts the current draw.
To eliminate this, heliaPROFILER renders the same firmware template a second
time with power_only=true into src/main_power.cc. This build has no
transport at all: the system debug transport is NSX_DEBUG_NONE, hpx_printf
compiles to a no-op, and there is no RTT/UART/USB/SWO code in the binary. It
does model init, warmup, a GPIO 3-wire lockstep sync, and the gated clean
inference window, then parks. Both executables — hpx_profiler (PMU capture)
and hpx_profiler_power (power capture) — build from one NSX/CMake project.
During the power stage, hpx flashes hpx_profiler_power (via the
NSX-generated per-target J-Link flash script) right before arming the gated
capture, then runs the existing race-free arm → reset → READY → GO
lock-step flow against it.
With the dedicated binary, all four transports converge on effectively the same power number — measured on the same Apollo510 EVB/model:
| Transport used for the PMU phase | Current (relative to RTT baseline) | Energy/inference (relative to RTT baseline) |
|---|---|---|
| RTT | 1.00× (baseline) | 1.00× (baseline) |
| UART | ~1.00× | ~0.99× |
| SWO | ~1.00× | ~0.99× |
| USB CDC | ~1.00× | ~1.00× |
All four are within 0.3% of each other. This is controlled by
power.firmware (default dedicated); summary.json's
power.power_firmware field records which mode produced the result.
Escape hatch: shared firmware#
or --power-firmware shared. This reverts to the pre-existing behavior of
measuring current on the already-flashed transport binary — useful for
bring-up or when no probe is free to reflash — but it carries the
transport-dependent contamination described above. Prefer dedicated (the
default) for any number you intend to report or compare across runs.
Window sizing and duration#
profiling.window_mode (default auto) sizes the clean/gated window at
runtime: the firmware targets profiling.window_target_ms of wall-time,
clamped to [window_min, window_max], and reports back exactly how many
clean inferences it ran. window_mode: fixed instead runs exactly
profiling.iterations clean inferences, no matter how long that takes.
Ordinary (non-power) runs target window_target_ms: 1000 (1 s) by default.
When power.enabled: true, heliaPROFILER automatically raises the effective
target to at least 5000 ms (max(profiling.window_target_ms, 5000)), because
host-side GPIO polling and Joulescope packet alignment need more time to
settle than a plain PMU capture does.
External captures enforce the window contract before reporting
energy-per-inference. The measured gate must be at least one second and agree
with clean_infer_count * clean_infer_avg_us within the larger of two stats
packets, half an inference, or 1% cross-binary timing drift. Short GPIO pulses
are ignored as glitches; a capture with no qualifying window fails rather than
publishing a plausible but invalid power number. The accepted ratio and any
ignored pulse count are recorded in summary.json.
Very short inferences#
For models whose single inference takes only a couple of milliseconds, even a multi-second window is dominated by GPIO-edge/gate-boundary timing jitter as a fraction of the total. Widening the window further reduces that jitter:
profiling:
# This model's inference is extremely short, so the default auto-sized
# clean/power window contains relatively few milliseconds of gated signal
# per gate edge. Push the gated window out to several seconds (thousands
# of inferences) so gate-boundary jitter becomes negligible as a fraction
# of the measured total.
window_target_ms: 8000
window_max: 10000
power.duration_s (default None) is the host-side safety bound for the
whole capture, separate from the firmware-side window. Left unset,
heliaPROFILER auto-tunes it from PMU-phase timing (boot settle + estimated
firmware runtime + margin); an explicit value always wins and disables that
auto-tuning.
Reset strategies#
power.reset_strategy (default auto) controls how the target is reset
before power capture:
| Value | Meaning |
|---|---|
auto |
Board/SoC family default (recommended for almost everyone) |
power_cycle |
Cycle Joulescope current passthrough off/on |
none |
Don't reset — assumes firmware is already running |
debug_reset |
J-Link debug reset only |
swpoi_reset |
Software point-of-interest reset only |
debug_reset+swpoi_reset |
Both, sequentially (Apollo5 family default) |
Explicit values are bring-up/experiment tools — auto already picks the
board/SoC-appropriate strategy (Apollo5-family boards default to
debug_reset+swpoi_reset, which is also why lock-step auto-enables on those
boards; see Lock-step).
Advanced power floors#
These knobs deliberately lower the measured power floor. Use them only when you understand the tradeoff:
profiling.extreme_mode(defaultfalse) — powers down the shared SSRAM (3 MB) and collapses MRAM to a single bank (NVM0 only). Only safe when the model's weights and arena are entirely TCM-resident — code keeps running from MRAM, so transports andhpx_printfremain available, but any SRAM/MRAM-resident data access will fault or read garbage.profiling.force_shared_sram(defaultfalse) — a diagnostic that unconditionally powers and retains the full shared SSRAM array at boot (mirroring AutoDeploy'sns_power_config(bNeedSharedSRAM=true)), even when the model runs entirely from TCM. Use it to measure SSRAM's static/ retention contribution to the power floor.- Crypto/OTP/radio shutdown — the dedicated power binary automatically shuts down the crypto and OTP subsystems (and the radio subsystem, where the HAL exposes it) on AP5-family SoCs. This is capability-gated and needs no configuration.
Verifying a capture#
The terminal prints a compact power table at the end of a run:
Power
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Metric ┃ Value ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ Avg current │ 12.345 mA │
│ Avg power │ 22.222 mW │
│ Peak current │ 14.567 mA │
│ Energy │ 666.630 µJ │
└────────────────┴──────────────┘
(Synthetic placeholder values — actual figures depend on your board, model, and clock configuration.)
summary.json's power section carries both the measured numbers and
health signals you should check before trusting a result:
{
"power": {
"measurement_scope": "gpio_gated_clean_window",
"avg_current_a": 0.012345,
"avg_power_w": 0.022222,
"median_current_a": 0.012300,
"p95_current_a": 0.012900,
"p99_current_a": 0.013100,
"energy_per_inference_j": 0.00001305,
"inferences_per_joule": 76628.4,
"gated_window_count": 1,
"gated_window_duration_ratio": 0.998,
"gated_vs_whole_current_ok": true,
"power_firmware": "dedicated"
}
}
(All numeric values above are synthetic placeholders — actual figures depend on your board, model, and clock configuration.)
gated_window_duration_ratio— measured gate duration ÷ expected duration (clean_infer_count × clean_infer_avg_us). Healthy captures land around 0.99–1.01. Far from 1.0 means the gate/handshake didn't line up with the actual inference loop.gated_vs_whole_current_ok—falsemeans the gated (inference) average current was not higher than the whole-capture average, which usually signals a gate/timing problem — but can be a legitimate reading for very light or bursty models where out-of-gate protocol phases (boot, handshake) draw more current than the light gated steady-state.power.sync.ready_observed—trueonce the host observed the firmware's lock-stepREADYhandshake.false/absent with lock-step enabled points at a wiring or GO-line problem.gated_window_duration_suspect— set when the duration check above fails tolerance, or when the device-reported clean-window timing itself looks corrupted (an inference reporting zero time).
detailed/power_summary.csv (with output.detailed: true) breaks all of
this down per gated window, plus a whole_capture_window reference row for
comparison.
Diagnostics for bring-up#
profiling.clean_window_trace(defaultfalse) — makes the firmware emit anHPX_CLEAN_ITER=<n>line over the transport every clean-window iteration, proving the device is genuinely looping inferences for the whole gated window rather than stalling. Perturbs the measurement (extra transport traffic inside the gate) — leave it off for real runs.profiling.clean_window_probe: busy_loop(default:infer) — replaces the clean window's inference loop with a calibrated CPU spin. Useful during bring-up to distinguish "the gate semantics are wrong" from "the model's inference behavior is wrong," independent of actual model execution.
Power config reference#
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable power capture |
driver |
string | joulescope |
joulescope (auto-detects JS110, JS220, or JS320), or ondevice (see note below) |
mode |
string | external |
external (Joulescope inline) or internal (on-device) |
duration_s |
int | null | null |
Host-side safety bound; null auto-tunes from PMU-phase timing |
io_voltage |
float | 1.8 |
Joulescope GPI reference voltage — must match the board's I/O rail |
sync_gpio_pin |
int | board default (10 generic; 29 on apollo510_evb/apollo510b_evb) |
Gate GPIO the firmware toggles around the clean window |
sync_input_index |
int | 0 |
Joulescope digital INPUTn wired to the sync GPIO |
lockstep |
bool | null | null (auto) |
Force the 3-wire handshake on/off; null auto-enables per board/SoC (see Lock-step) |
state_gpio_pin |
int | board default (0 generic; 36 on apollo510_evb/apollo510b_evb) |
State/error GPIO (device → host); 0 disables the wire |
go_gpio_pin |
int | board default (0 generic; 14 on apollo510_evb/apollo510b_evb) |
GO GPIO (host → device); 0 disables the wire |
state_input_index |
int | 1 |
Joulescope INPUTn wired to the state GPIO |
go_output_index |
int | 0 |
Joulescope OUTPUTn wired to the GO line |
stats_rate_hz |
int | 1000 |
Host stats-packet cadence for gated capture |
firmware |
string | dedicated |
dedicated (transport-free binary, see Dedicated power firmware) or shared |
reset_strategy |
string | auto |
See Reset strategies |
serial |
string | null | null |
Joulescope serial number to disambiguate multiple connected devices |
Related profiling and target fields#
| Field | Type | Default | Description |
|---|---|---|---|
profiling.window_mode |
string | auto |
auto sizes the clean window at runtime; fixed runs exactly iterations |
profiling.window_target_ms |
int | 1000 |
Target wall-time for the clean window (auto-raised to ≥ 5000 when power is enabled) |
profiling.window_min / window_max |
int | 10 / 500000 |
Clamp bounds for the auto-sized clean-window iteration count |
profiling.extreme_mode |
bool | false |
See Advanced power floors |
profiling.force_shared_sram |
bool | false |
See Advanced power floors |
profiling.clean_window_trace |
bool | false |
See Diagnostics for bring-up |
profiling.clean_window_probe |
string | infer |
infer or busy_loop; see Diagnostics for bring-up |
target.ensure_board_powered |
bool | false |
Pre-run Joulescope current passthrough so the board powers up before flashing; always on when power.enabled: true |
hpx power-on#
Opens the Joulescope and enables current passthrough so the target board stays powered, holding the connection open until Ctrl-C. Useful when you want the board powered for manual debugging (JLinkExe, a serial console, etc.) without running a profiling session.
When multiple Joulescopes are attached, --power-serial is required. For
example, use hpx power-on --driver joulescope --power-serial 25QG for
the JS320 bench.
Troubleshooting#
joulescope: device not found
Joulescope USB driver not installed, or device claimed by another
process. On Linux, confirm the udev rule for the device is installed
and replug it (see Installation).
pyjoulescope_driver ships as a core dependency of helia-profiler,
so no extra install is needed.
Current reads negative or implausibly high
Another power source is also feeding the EVB. Disconnect target USB, debug USB power, or coin cell during the capture window.
No GPIO gate rising/falling edge detected
Check the sync GPIO wiring and power.sync_gpio_pin /
power.sync_input_index against the board's INPUTn mapping.
Confirm the firmware reached the power window wait state, and — if
using a reset strategy that reflashes/resets twice — verify lock-step
is enabled so reset latency can't race the gate.
Wrong io_voltage or wrong input index
A GPI configured for the wrong voltage threshold, or wired to the wrong
Joulescope INPUTn, reads a gate that's always low (or always
"high" from noise). Double check power.io_voltage matches the
board's I/O rail and that sync_input_index/state_input_index match
the physical wiring.
ready_observed: false or gated_window_duration_ratio far from 1.0
The lock-step handshake or gate timing didn't line up. Check the GO/
state wiring, confirm power.lockstep reflects your actual wiring, and
verify the selected reset strategy relaunches the firmware cleanly
before capture.
\"gated avg current <= whole-capture avg\" warning
Usually a gate/timing problem, but can be a legitimate reading for a
very light or bursty model where out-of-gate protocol phases (boot
handshake, etc.) draw more current than the gated steady-state. Compare
against the whole_capture_window row in detailed/power_summary.csv.
driver: ondevice raises PowerError: not yet implemented
The on-device driver is present in power.driver's choices for forward
compatibility, but its capture path is currently a stub and always
raises. Use power.driver: joulescope (the default) for any real
measurement today.
TOPS-per-Watt missing from summary
Only emitted for heliaAOT runs with power enabled. heliaRT/TFLM don't expose the MAC count needed for the TOPS calculation.
Power numbers differ between transports
This should not happen with the default power.firmware: dedicated —
all transports converge to within ~0.3% on Apollo510 EVB testing. If
you still see transport-dependent drift, check whether
power.firmware: shared is set (explicitly or via --power-firmware
shared); shared measures the transport-carrying binary directly and
is expected to show the contamination described in
Dedicated power firmware. Switch back to
dedicated for comparable numbers.