Hermes v0.17.0 GPU Power Spike: Debugging a fontations + temporal_rs Render Loop
Hermes v0.17.0 GPU Power Spike: Debugging a fontations + temporal_rs Render Loop
After upgrading to Hermes v0.17.0, the Mac's idle power draw jumped from a normal 3-5W to 19W and the fans spun up. Using powermetrics to quantify, ps to localize the process, and sample to grab the call stack, I traced it all the way to Chromium's Rust font engine fontations and the Rust implementation of the Temporal API, temporal_rs, repeatedly formatting time strings and validating glyphs every frame, pinning the GPU at full load. This post records the full investigation.
Background
| Item | Info |
|---|---|
| Hermes version | v0.17.0 (built Jun 27 10:52:43 2026) |
| Electron version | 40.10.2 |
| OS | macOS 26.2 (25C56) |
| Chip | Apple M4 (Mac mini) |
| Displays | 2 screens: 4K main (3840x2160) + portrait (1800x3200) |
| Symptom | Abnormal power draw (19W vs a normal 3-5W idle), noticeable fan noise |
| Issue | NousResearch/hermes-agent#53902 |
Investigation
1. System-level power diagnosis — GPU is maxed out
# Power measurement (needs sudo)
sudo powermetrics --samplers cpu_power,gpu_power -n 1 -i 3000The GPU was the biggest consumer:
| Metric | Value | Normal idle | Delta |
|---|---|---|---|
| GPU power | 13.5W | <1W | 13x |
| GPU activity | 96.5% (1.578GHz max freq) | <30% | |
| CPU power | 5.7W | 2-3W | |
| Combined | 19.2W | 3-5W | ~4x |
2. Find the high-CPU processes
# macOS BSD ps syntax (not GNU!)
ps -Arc -o pid,pcpu,pmem,etime,comm -r | head -25Three high-CPU processes:
PID %CPU COMM
166 62.6 WindowServer
719 45.6 Hermes Helper (Renderer) ← Electron renderer
709 33.6 Hermes Helper (GPU) ← Electron GPU processHermes's Renderer + GPU processes are the direct drivers of the GPU load; WindowServer is dragged along by them.
3. Stack sampling — locks onto fontations + temporal_rs
# Sample the Hermes Renderer process (3s)
sudo sample <Renderer_PID> 3 -mayDieTwo consecutive samples (~2300 samples) showed the main thread spending >90% of its time spinning in two Rust crates:
temporal_rs_OwnedRelativeTo_empty (433 samples)
└─ temporal_rs_PlainTime_second (Temporal API — ZonedDateTime / PlainTime)
└─ fontations_ffi$font_ref_is_valid (Chromium Rust font engine — glyph validation/rasterization)
└─ temporal_rs_ZonedDateTime_offset_nanoseconds
└─ temporal_rs_ZonedDateTime_calendar (recursive 3+ levels)Meaning: the JavaScript Temporal API repeatedly formats time strings -> Fontations repeatedly validates/rasterizes glyphs for those strings -> every animation frame repeats it. The GPU process was likewise dominated by fontations (2454 samples), confirming the font rendering pipeline is the bottleneck.
4. Ruling out CSS animations
The source has 4 infinite CSS animations (rotating session-row border, pulse hint, streaming code-block glow, pet-egg hatch). Disabling all infinite animations in the bundled CSS and restarting still left the GPU at 88% -> CSS animations aren't the cause.
5. Ruling out the PetSprite RAF loop
The PetSprite component has a permanent requestAnimationFrame loop. But ruled out: it has an early-out optimization (only redraws on visible frame changes, ~5Hz not 60Hz), it draws to a canvas and doesn't touch fontations or temporal_rs, and the machine didn't have a pet enabled.
6. Investigating background-throttling config
electron/main.cjs lines 212-214:
// Unconditionally disable all background throttling
app.commandLine.appendSwitch('disable-renderer-backgrounding')
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
app.commandLine.appendSwitch('disable-background-timer-throttling')These switches are for streaming (keep rendering LLM replies when the window loses focus), but they apply unconditionally to every renderer process and every timer/RAF callback, so even minimized windows run their render loops at a full 60fps.
7. Likely trigger — MoA PR #53855
The only recent desktop code change was feat(moa): render reference-model blocks in TUI and desktop (#53855). The temporal_rs_ZonedDateTime call pattern (repeatedly formatting timestamps) matches the real-time timestamp rendering of MoA reference-model response blocks.
Kill + respawn verification
Killing the Hermes Helper process let Electron auto-restart, and the new process fell back into the same fontations + temporal_rs loop within 8 seconds:
| Test | GPU power | GPU activity |
|---|---|---|
| Before kill | 13.5W | 96.5% |
| After kill + respawn | 13.0W | 98.3% |
| After full app restart | 16.3W | 99.8% |
Conclusion: a deterministic v0.17.0 bug, not an occasional stall or state corruption.
Suggested fix directions
Option A: conditional background throttling (recommended)
Make disable-background-timer-throttling apply only during active agent-turn streaming:
// Don't add the switch unconditionally
// app.commandLine.appendSwitch('disable-background-timer-throttling')
// Control it dynamically at the window level instead
win.webContents.setBackgroundThrottling(false) // when streaming starts
win.webContents.setBackgroundThrottling(true) // when streaming endsOption B: add a visibilitychange guard to the render loop
// If the component triggering Temporal formatting has a timer:
useEffect(() => {
const tick = () => { if (!document.hidden) updateTimestamp() }
// ...
}, [])Option C: cache Temporal formatting results
If the same timestamp gets formatted every frame, cache the string result so Fontations doesn't re-rasterize identical text.
Option D: audit the MoA rendering logic
Review the PR #53855 diff to see whether a setInterval or requestAnimationFrame was added to the MoA reference-model block timestamp rendering.
Investigation tool cheat sheet
| Tool | Command | Use |
|---|---|---|
| powermetrics | sudo powermetrics --samplers cpu_power,gpu_power -n 1 -i 3000 | Measure actual CPU/GPU/ANE power |
| ps (macOS) | ps -Arc -o pid,pcpu,pmem,etime,comm -r | head -25 | Sort by CPU (note: not GNU syntax) |
| sample | sudo sample <PID> 3 -mayDie | Sample a process call stack (3s) |
| pmset | pmset -g batt / pmset -g therm | Battery status, thermal pressure |
| system_profiler | system_profiler SPDisplaysDataType | Display config |
The macOS ps syntax trap
# ✅ macOS BSD ps — sort by CPU with -r
ps -Arc -o pid,pcpu,pmem,etime,comm -r | head -25
# ❌ GNU syntax errors on macOS
ps aux --sort=-%cpu # illegal optionTakeaways
- The first step in a macOS power investigation is powermetrics — far more accurate than Activity Monitor, it shows the wattage and activity of the GPU/CPU/ANE separately.
sampleis a great tool for locating hot functions — lighter than Instruments, 3 seconds is enough to see which function a process is stuck in.- Electron
--disable-background-timer-throttlingis a double-edged sword — it keeps background streaming rendering alive, but it also lets every animation/timer run forever. - Fontations is Chromium's new Rust font engine — the default on Apple Silicon from Electron 40+, behaving differently from the old FreeType.
temporal_rsis the Rust implementation of the Temporal API — compiled into the Electron Framework as a Stage 3 proposal.- Investigation order: powermetrics (quantify) -> ps (find the process) -> sample (read the stack) -> source analysis (locate the component) -> elimination testing (rule out hypotheses).
References
- GitHub Issue: NousResearch/hermes-agent#53902
- MoA PR: #53855
- Electron CLI switches docs: Electron CLI switches
