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
- 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).
- 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.
- Log per-rail power during each arm (HWiNFO shared memory,
QC Perf group, 500 ms) plus a 30 s idle baseline either side.
- 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
Describe the bug
geniex_corestarts a clock-keeper spin thread before every decode loop (ThreadPool::startClockKeeper,third-party/geniex-qairt/core/src/threadpool.cpp:108, called fromllm_model.cpp:959), on by default (clock_keeper_threads_ = 1,llm_model.h:207). Thebody 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:
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
qualcomm/Qwen3-VL-4B-InstructW4A16,-c npu, 1200 tokens × 3 rounds ≈ 150 s decode per arm).GENIEX_CLOCK_KEEPER_THREADS=0and=1. Read once inLLMModel::initialize()(llm_model.cpp:420), so set it before the model loads.QC Perfgroup, 500 ms) plus a 30 s idle baseline either side.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_to0(llm_model.h:207), keepingGENIEX_CLOCK_KEEPER_THREADSas 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):
= 1as ofv0.3.19-rc.1)qairt,-c npu,qualcomm/Qwen3-VL-4B-InstructW4A16