Files
blocker/docs/PERF.md
T
SellPL a43a8e8231
CI / ci (push) Has been cancelled
CI / android (push) Has been cancelled
docs: record the measured storage cost and the new crate boundary
Updates the ADR log, architecture, limitations, perf table and README to match
what now exists, rather than what was expected.

**The perf number was wrong and is now honest.** The M0 figure of 0.279 MB
excluded SQLite entirely — the placeholder binary never referenced the crate, so
dead-code elimination dropped it. With the daemon actually opening a database it
is **1.364 MB**, of which bundled SQLite is ~1.1 MB. That is 1.1 MB of the 5 MB
budget spoken for, leaving 3.8 MB for D-Bus, X11 and a UI toolkit.

This is the first number that makes the section 5 budget feel like a budget
rather than a formality, and it turns ADR-4's bundled-vs-system-linked choice
into a real trade-off with a measured price on both sides.

- ADR-4 marked accepted and implemented, with that trade-off left open.
- ARCHITECTURE.md documents why `blocker-storage` is a sibling crate and not a
  submodule: the dependency direction is what keeps the zero-dependency purity
  proof automatic rather than a grep someone can route around.
- LIMITATIONS.md drops the stale "storage has no I/O" shortfall and records the
  bundled-vs-system question instead, plus the fact that `LockState` still always
  evaluates to Normal.
- README status table and layout updated.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 13:10:25 +00:00

5.2 KiB

Performance

Two tables. Targets are from CLAUDE.md section 5 and are fixed. Results is what was actually measured, and it is currently almost empty because almost nothing is built yet.

The separation is deliberate. A single table mixing aspiration with measurement invites reading the aspiration as the result.

Targets (CLAUDE.md section 5)

Metric Android Windows / Linux daemon
Idle CPU ~0% (no wakeups without events) ~0%, ≤ 1 wakeup/min when idle
Idle RAM ≤ 30 MB PSS ≤ 15 MB RSS (daemon), UI process exits when its window closes
Block latency (foreground change → block shown) ≤ 150 ms ≤ 150 ms
Daemon binary — ≤ 5 MB stripped
APK size ≤ 10 MB (release, R8) —
Battery no wakelocks outside active enforcement —

The release profile in the workspace Cargo.toml carries opt-level = "z", LTO, codegen-units = 1, panic = "abort", and strip = true, which is what makes the size budget reachable. scripts/size-check.sh enforces it in CI from the first commit.

Results

Measured on: Ubuntu 26.04 LTS, KDE Plasma 6.6.6, 6 CPUs. Toolchain: rustc 1.98.1.

Metric Target Measured When
Daemon binary (stripped, release) ≤ 5 MB 1.364 MB 2026-10-01, M1
Block latency ≤ 150 ms not measured —
Idle CPU ~0% not measured —
Idle RAM (daemon) ≤ 15 MB RSS not measured —
Idle wakeups ≤ 1/min not measured —
APK size ≤ 10 MB not measured —

What the 1.364 MB contains, and why it matters. That figure is with bundled SQLite linked in and the daemon actually opening a database — not the 0.279 MB placeholder from M0, which was two println! lines and excluded SQLite entirely through dead-code elimination.

So the honest read is: bundled SQLite costs roughly 1.1 MB of a 5 MB budget. That leaves 3.8 MB for D-Bus, X11, and the UI toolkit. It is a real constraint rather than a comfortable margin, and it is the first number that makes the section 5 budget feel like a budget instead of a formality.

It also makes ADR-4 (rusqlite bundled vs system-linked) a genuine trade-off rather than a settled one: system-linking would recover most of that 1.1 MB at the cost of a runtime dependency on the host's libsqlite3. Recorded as an open question.

How to measure

Linux daemon

# Binary size — the gate CI runs
scripts/size-check.sh

# Idle RSS
./systemctl --user start blocker
ps -o rss= -C blocker-daemon | awk '{printf "%.1f MB\n", $1/1024}'

# Idle CPU — sample over a minute, not instantaneously
pidstat -p "$(pidof blocker-daemon)" 10 6

# Block latency — timestamp on the compositor event, timestamp on the window map
# TODO: script this in M2-Linux once the KWin backend emits both.

perf stat -e context-switches,cpu-migrations is useful for the wakeup count. If idle wakeups exceed one per minute, a timer is firing that next_wakeup did not ask for — that is the first thing to look for.

Windows

Process Explorer, or ETW. The relevant counters are working set (RAM) and the process's CPU over a sampled window.

Android

adb shell dumpsys meminfo vu.valiq.blocker     # PSS, must be <= 30 MB
adb shell dumpsys batterystats vu.valiq.blocker
adb shell am profile start --sampling 1 vu.valiq.blocker /data/local/tmp/p

Perfetto for the wakeup analysis. A wakeup from the blocked package when nothing is happening is a bug in next_wakeup accounting, and battery stats will show it long before a user complains.

Why idle is hard, and what breaks it

The budget is zero idle wakeups, so the common causes are worth naming.

  1. A timer that fires to check something. The engine's job is to make these unnecessary. Every condition that contributes a next_wakeup boundary is a deliberate, named commitment. A new timer that does not correspond to a boundary is a bug.
  2. Conditions that should contribute no boundary but do. Wi-Fi and location schedules are changed by OS events — NetworkManager's callback, the geofencing API. Arming a timer for them would be a wakeup for nothing. wifi_schedule_declares_no_time_wakeup pins this.
  3. The UI holding a GL context. A resident graphics context costs both RAM and compositor wakeups. This is why the daemon and the block window are separate processes: the window exits when the block lifts, and nothing holds a context while idle.
  4. Unbounded Vec growth in the engine. blocked_apps is rebuilt per evaluation. With a handful of schedules it is small, but a schedule set in the thousands would allocate per focus event. next_wakeup returning None means the daemon is asleep and this does not matter, which is exactly why the budget and the design reinforce each other.
  5. Storage polling. Nothing in the design polls the database. If a future change introduces it, it will show up here first.

Recording a result

Add a row to the table above with the date and the tool used. Then note in the commit message whether the change touches the hot path — section 9 requires a re-measurement when it does.

A milestone is not done until its acceptance checks pass and its numbers are recorded here (section 7).