Skip to content

The resolution floor is the kernel’s

This page answers the question that decides how to read every CPU number mikroscope produces: what is the smallest change it can show, and why is that limit set by the kernel rather than by the agent. It also says which source does see beneath that limit, what the reference kernel does not have, and how long a sample survives in the agent before it is gone.

A tick lasts 10 ms. /proc/stat does not count time, it counts USER_HZ ticks, 100 per second. A 100 ms sample can therefore hold 10 ticks per core, so the busy ratio of one core resolves to 10 % steps, and the average of four cores to 2.5 %. Over 1 s the resolution of one core is 1 %.

That is arithmetic from the tick, not a property of the agent, and no rate or setting changes it. What the agent does about it is refuse to hide it: it ships the raw ticks and the real interval of every sample (dt_ns), never a percentage, so the window you divide over is yours to choose.

Tick accounting is also quantised at the edges. A 100.3 ms interval can carry 11 ticks (seen on the amd64 development host, 2026-09-11/12), which would make a busy ratio above 1. The ratio the agent derives is capped at 1; the ticks themselves stay raw.

Sampling faster does not refine this. At 100 Hz a sample holds 0 or 1 busy tick, so the per-sample busy ratio has two possible values; above roughly 20 Hz the tick counters are an occupancy indicator rather than a percentage. What a higher rate does buy is on the rate ceiling.

The one source beneath it: the CPU’s own counters

Section titled “The one source beneath it: the CPU’s own counters”

One source reads below the tick: the CPU’s performance monitoring unit, through perf_event_open, which the agent collects as the perf source when the container is privileged. It is the only source in mikroscope that does not come from a file.

Measured on the RB5009 and recorded on 2026-09-12: in a 100.4 ms sample where /proc/stat reported zero busy ticks on all four cores, the PMU counted 2.2–4.6 million cycles and 0.8–2.1 million instructions retired, at a 3.8–5.7 % cache-miss rate. The jiffie rounds that work away; the counter does not.

The number worth watching is instructions per cycle, and the agent does not compute it: it ships the raw counts and you divide. It is worth the division because it separates a core doing work from a core stalled on memory, which no tick counter can express. Measured over 2 s on the same device, the same day, it ranged from 0.381 on cpu0 to 0.992 on cpu1.

What the source is, on the reference device (RB5009, RouterOS 7.24.2, kernel 5.6.3, Cortex-A72 r0p1, 2026-09-12, from inside a privileged container):

  • The agent asks for seven counters: cycles, instructions, cache-references, cache-misses, branch-instructions, branch-misses and bus-cycles. The probe of 2026-09-12 opened cycles, instructions, cache-misses, branch-misses and bus-cycles system-wide on 4 of 4 CPUs. In the agent’s own data over the 24 h ending 2026-09-12, six reported on 4 of 4 cores, cache-references among them, and branch-instructions produced no rows at all.
  • The generic stalled-frontend and stalled-backend events return ENOENT on the A72. They would need raw PMU event codes, so the agent does not ask for them.
  • A counter that cannot be opened is absent, never zero. Which counters open depends on the CPU, so read the counter label of mikroscope_perf_events_total{counter,cpu} rather than assuming a set.
  • Without privileged=yes the whole family is absent: the counters are opened system-wide, which the unprivileged container cannot do. See what privileged buys.

The collector uses the same two counters for one of its detections, ipc-collapse: per core, once at least 20 s of history exists, the one-second instructions-per-cycle falling below half its trailing 60 s median while the cycle rate is above its own median. That is described with the other rules on detections.

Do not expect a finer clock from PSI or schedstat. The RB5009’s RouterOS 7.24.2 kernel (Linux 5.6.3) has neither /proc/pressure nor /proc/schedstat, and its /proc/stat irq column is always 0, so hard-IRQ time is counted inside system:

Measured on RB5009UG+S+ · 4 × 1.4 GHz Cortex-A72 · RouterOS 7.24.2 · Linux 5.6.3 · · /proc/pressure and /proc/schedstat absent

Nor is there a tracing path. A privileged discovery container on the same device on 2026-09-12 found no eBPF, kprobes or ftrace: no BTF, no debugfs, no tracefs, and the mount points do not exist. A re-probe on 2026-09-14 found /sys/fs/bpf present and empty, and /proc/modules listing 238 loaded modules, but no /lib/modules, no kernel headers and no compiler on the device: a kernel feature that is missing cannot be added from a container.

The agent detects what a kernel has at start and reports it on /capabilities: the sources map says which sources this deployment actually reads. Fields for an absent source are absent from every sample and every sink, never zero. The agent does read PSI and schedstat where a kernel has them.

The other hard bound is depth, not resolution. The agent keeps its samples in a ring of --buffer seconds, 300 s by default and 10–3600 s allowed, which holds rate × buffer samples. Nothing older exists anywhere on the router.

A collector or recorder outage shorter than the ring is backfilled on reconnect: it asks for since=<seq> and receives every sample it missed. An outage longer than the ring is reported as a gap of known length, never papered over. The agent answers with a {"gap":{"from":…,"to":…}} line naming the sequence numbers that are gone, before the samples it still holds; record writes it as a marker reading samples N..M lost, and forward counts it in mikroscope_collector_gaps_total and hands it to every sink.

A longer ring costs memory in the agent, and the agent refuses one that cannot fit. At start it estimates the ring at 2 560 bytes a line (the mean line was measured at 2 439 B on the RB5009 on 2026-09-12, without the PMU, buddyinfo and MTD sources; a board with more cores or interrupt lines, or more sources, costs more), adds the triggered-capture budget, and exits with an error if the total exceeds the container’s memory.max. If the total is more than half the Go soft memory limit it starts but logs a warning, because a heap that tight keeps the garbage collector running. How to size both limits is on the cost of the observer.