Skip to content

Clock-keeper spin thread (on by default) costs more power for no throughput gain on NPU decode #1330

Description

Describe the bug

geniex_core starts a clock-keeper spin thread before every decode loop (ThreadPool::startClockKeeper, third-party/geniex-qairt/core/src/threadpool.cpp:108, called from llm_model.cpp:959), on by default (clock_keeper_threads_ = 1, llm_model.h:207). The
body is a bare while (!spinners_stop_) { yield } — it waits on nothing, the point being to keep the DVFS governor's measured load high so the cluster doesn't downclock while the dispatch thread waits on the NPU.

On Snapdragon X2 Plus with QAIRT NPU decode it costs real power and returns nothing:

Rail keeper=0 keeper=1 delta
System Power 21.70 W 24.59 W +2.89 W (+13.3%)
CPU Cluster 1 4.22 W 6.59 W +2.37 W (+56.0%)
SoC Power 9.78 W 12.20 W +2.42 W (+24.7%)
NSP (NPU) 1.935 W 1.945 W +0.010 W (+0.5%)

82% of the extra power lands on the CPU cluster — the spinner. Throughput is unchanged: 30.183 ± 0.160 tok/s (keeper=0) vs 30.417 ± 0.194 (keeper=1) — +0.77%, ranges overlap, not significant at n=6; TTFT 0.1 s both arms. Energy: system +12.6%, CPU cluster +55.1%, NPU −0.2%.

To Reproduce

  1. Load a QAIRT NPU model, generate a fixed token count (we used qualcomm/Qwen3-VL-4B-Instruct W4A16, -c npu, 1200 tokens × 3 rounds ≈ 150 s decode per arm).
  2. Repeat with GENIEX_CLOCK_KEEPER_THREADS=0 and =1. Read once in LLMModel::initialize() (llm_model.cpp:420), so set it before the model loads.
  3. Log per-rail power during each arm (HWiNFO shared memory, QC Perf group, 500 ms) plus a 30 s idle baseline either side.
  4. Compare mean power over the inference window only, plus tok/s and TTFT.

For a clean signal: interleave the arms (0 → 1 → 1 → 0) so thermal drift cancels; slice to the actual inference window; match rails by name, not index (HWiNFO reindexes across restarts and silently reads the wrong sensor); watch the NPU rail as a control — if it moves, the arms didn't do the same work.

Expected behavior

Either the spinner earns its power, or it shouldn't be the default. Suggest defaulting clock_keeper_threads_ to 0 (llm_model.h:207), keeping GENIEX_CLOCK_KEEPER_THREADS as an opt-in for platforms where the governor really does downclock. Here that is −11.8% system power, −35.9% CPU cluster, −11.2% energy at unchanged throughput. Alternatively, detect at startup whether spinning actually raises the operating point and skip it when it doesn't.

Desktop (please complete the following information):

  • OS: Windows 11 build 28000 (ARM64)
  • Device: Snapdragon X2 Plus (XG301062), Oryon, 6 cores, 4032 MHz
  • GenieX CLI: v0.3.16 (default still = 1 as of v0.3.19-rc.1)
  • QAIRT Runtime: v2.45.0.260326
  • Backend / model: qairt, -c npu, qualcomm/Qwen3-VL-4B-Instruct W4A16
  • Power tooling: HWiNFO ARM64 v8.47-5971 shared memory, QC Perf group

Metadata

Metadata

Labels

🐞 bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions