eBPF syscall telemetry → LightGBM tree-ensemble workload-mode classifier → calibrated fixed-point C → runs on a real ARM chip in 2.9µs and an FPGA SoC for free. The boring detector first; the SSD-firmware / KV-cache-eviction story comes after. All eight MCU-port confidence gates met as of 2026-04-29.
ebpf-syscall ·
branch hw-model ·
@ 5dbcf3e
A research path from "observe the kernel" to "put a learned policy in firmware". The host watches its own syscall traffic with eBPF, aggregates it into one compact feature row every 100 ms, trains a small tree-ensemble classifier (LightGBM) over storage-relevant workload modes, and compiles the trained trees to fixed-point C that runs without the LightGBM runtime on a microcontroller, on an FPGA fabric block, or eventually inside an SSD controller.
| phase | what | headline | gate |
|---|---|---|---|
| 1 | Detector training (honest leakage rules, group split) | 0.972 acc, 0.972 macro F1 | pass |
| 5 | Temperature-scaling calibration (T = 0.6733) | ECE 0.093 → 0.014 | pass |
| 6 | C export with float & Q15 fixed-point parity vs Python | 0.9991 / 0.9986 | pass |
| 7 cross | Cortex-M ROM at -Os |
24.5 KB M4 / 26.3 KB M0+ (fixed-only) | pass |
| 7 frame | USB-CDC frame protocol, magic+CRC32, bit-flip fuzz | 1.2 M flips, 0 false-positive | pass |
| 7 A53 | Real-chip latency on Kria KV260 Cortex-A53 @ 1.333 GHz | 4.73 µs float / 2.88 µs fixed (p50) | pass |
| 9 | Policy simulator (read-only, no /sys writes) | 0.99 correctness when committed; off-diagonal collapses to safe | pass |
| phase | what | status |
|---|---|---|
| 7+ | FPGA HLS bitstream of workload_predict_fixed on Zynq fabric, decision in cycles not microseconds |
next |
| 3.3 | KV-cache eviction policy as tree ensemble (lmcache-agentic-traces on W7900) |
future |
| data | Workload diversity: metadata_heavy, sync_flush_heavy, mmap_heavy, build_compile |
limiter |
| 9+ | First read-only actuator (per-block-device read_ahead_kb), feature-flagged off |
future |
One LightGBM multiclass booster, 32 iterations × 5 classes = 160 trees, max 8 leaves each, depth ≤ 4.
Trained on three independent 30-second fio sessions per workload class, group-stratified by session_id
so no within-run autocorrelation leaks. Forbidden-leakage column list (PID, process_name, command, path, filename,
host_id) dropped at training time.
| class | precision | recall | F1 | support |
|---|---|---|---|---|
| idle | 0.85 | 0.99 | 0.92 | 291 |
| random_read | 0.99 | 0.95 | 0.97 | 297 |
| random_write | 1.00 | 0.95 | 0.97 | 297 |
| sequential_read | 0.99 | 0.96 | 0.98 | 289 |
| sequential_write | 1.00 | 0.95 | 0.97 | 297 |
| macro avg | 0.96 | 0.96 | 1471 | |
Byte features dominate after the BPF-side bug fix that made sys_exit.ret readable.
avg_read_size and avg_write_size went from 0 gain (with the bug) to top-4 (without).
Pure count features (pread64_count, pwrite64_count) are still the largest contributors,
since count rate already separates random from sequential workloads (random has 4× the IOPS at the same
fio rate cap). Burstiness collapsed from rank #3 to rank #13 once entry-side tracepoints made every syscall fire
both an sys_enter_* and raw_syscalls/sys_exit event — the within-pair gap dominates the
between-syscall gap, and burstiness becomes uninformative. That is a real characteristic of the new event stream,
not a regression.
| rule | why |
|---|---|
Group split by session_id | fio runs at fixed bs / fixed numjobs / rate-capped have huge within-run autocorrelation. Random row split would memorise the run, not the workload. |
| Drop forbidden columns | process_name “fio” would trivially solve the problem and learn nothing about the underlying behaviour. |
| StratifiedGroupKFold | 2 sessions per label is a corner case; KFold ensures every label appears on both sides of the split. |
LightGBM's multiclass softmax is roughly the right shape but is not honestly probabilistic. A diagnostic on the 4412-row dataset showed mean confidence 0.880 vs actual accuracy 0.965 — the model was systematically under-confident. Thresholding the raw softmax at 0.80 was therefore rejecting many windows the model was actually certain about, which made idle sessions emit only 0.27–0.83 of the time even though they were classified correctly.
From Guo et al. 2017: choose T > 0 minimising negative log-likelihood of softmax(logits / T) on a held-out calibration set. T < 1 sharpens an under-confident model; T > 1 softens an over-confident one. Crucially, temperature scaling preserves the argmax, so top-1 prediction is unchanged after calibration — only the distribution over classes is rescaled.
| metric | before | after | delta |
|---|---|---|---|
| Temperature | 1.0 | 0.6733 | sharpens |
| ECE (top-1, 15 bins) | 0.0931 | 0.0135 | ~7× better |
| Brier score (multiclass) | 0.0731 | 0.0664 | −0.007 |
| Mean confidence | 0.880 | 0.965 | matches accuracy 0.965 |
| Test accuracy | 0.965 | 0.965 | unchanged (argmax preserved) |
With the same hysteresis settings (5-window stable, 0.80 confidence, 0.20 margin), idle session emit-rate jumps:
| session | uncalibrated emit-rate | calibrated emit-rate |
|---|---|---|
| run_a_idle | 0.456 | 0.942 |
| run_b_idle | 0.780 | 0.949 |
| run_c_idle | 0.835 | 0.953 |
| abstention rate (conf < 0.80) | 0.041 | 0.014 |
The trained booster is reduced to plain C. No LightGBM runtime, no Python at inference time,
only expf() from <math.h> for the softmax confidence. Each tree compiles
to a static-inline function returning a leaf value via nested ternary; the top-level
workload_predict_*() sums per-class scores and runs argmax + numerically-stable softmax.
| workload_predict_float | workload_predict_fixed | |
|---|---|---|
| Feature input | const float[36] | const int16_t[36] (Q15) |
| Threshold compares | float | int16 |
| Leaf accumulation | float | float (kept floating for parity test) |
| Softmax tail | same: expf((s − max_s) / T) | same |
| Argmax preserved vs. Python | 0.9991 | 0.9986 |
Every constant used at runtime is generated from the trained model:
/* workload_model.h header (excerpt) */ #define WORKLOAD_NUM_FEATURES 36 #define WORKLOAD_NUM_CLASSES 5 #define WORKLOAD_TEMPERATURE 0.673289829f #define WORKLOAD_INV_TEMPERATURE 1.48524448f /* per-feature offset/scale arrays for Q15 quantization, derived from * LightGBM feature_infos with a 5% headroom margin */ extern const float workload_feature_offset[WORKLOAD_NUM_FEATURES]; extern const float workload_feature_scale[WORKLOAD_NUM_FEATURES]; void workload_quantize(const float features[], int16_t q[]); workload_prediction_t workload_predict_float(const float features[]); workload_prediction_t workload_predict_fixed(const int16_t q[]);
| variant | match | bar | result |
|---|---|---|---|
| x86_64 float-C ↔ Python | 4408 / 4412 = 0.9991 | ≥ 0.99 | pass |
| x86_64 fixed Q15 ↔ Python | 4406 / 4412 = 0.9986 | ≥ 0.95 | pass |
| aarch64 (A53) float-C ↔ Python | 1465 / 1466 = 0.9993 | ≥ 0.99 | pass |
| aarch64 (A53) fixed Q15 ↔ Python | 1464 / 1466 = 0.9986 | ≥ 0.95 | pass |
-Os (Phase 7 cross-compile)Cross-compiled with arm-none-eabi-gcc 15.2.1, full optimisation for size,
-ffunction-sections + -fdata-sections. The model + frame protocol live in .text;
no .bss, no .data beyond the per-class label name strings.
| target / config | text + data | BSS | bar | result |
|---|---|---|---|---|
| Cortex-M4 soft-float (both paths) | 55 346 B (54.0 KB) | 0 | ≤ 64 KB | pass |
| Cortex-M4 hard-float fpv4 (both paths) | 58 346 B (57.0 KB) | 0 | ≤ 64 KB | pass |
| Cortex-M0+ soft-float (both paths) | 58 582 B (57.2 KB) | 0 | ≤ 32 KB | over |
| Cortex-M4 fixed-only | 25 058 B (24.5 KB) | 0 | ≤ 64 KB | pass |
| Cortex-M0+ fixed-only | 26 922 B (26.3 KB) | 0 | ≤ 32 KB | pass |
| target | text | note |
|---|---|---|
| Cortex-M4 | 346 B | byte-at-a-time CRC-32, no static lookup table |
| Cortex-M0+ | 468 B | same code, slightly larger because thumb-1 lacks 32-bit immediates |
| target | model + frame | chip ROM | headroom |
|---|---|---|---|
| Cortex-M4 | ~25.0 KB | typically 64+ KB | ~39 KB for newlib + CRT |
| Cortex-M0+ | ~27.0 KB | typically 32 KB | ~5 KB — tight; warrants a real-linker remeasure |
.o-only sizes don't include newlib expf() at link time.
newlib-nano (-specs=nano.specs) is the right choice for production; the actual ELF cost
on M0+ will eat into the 5 KB headroom. Re-measuring after a real linker invocation against a chosen
chip's linker script is the next step before committing M0+ to product.
The eighth and final §1.4 confidence gate: "can the predictor actually run fast enough on real silicon?"
Measured on a Kria KV260 Vision AI Starter Kit (Zynq UltraScale+ MPSoC, Cortex-A53 @ 1.333 GHz),
fresh Canonical Limerick image, gcc 13.3 aarch64, -O2. SCHED_FIFO + taskset -c 0 +
scaling_governor=performance. 5000 iterations × 1466 feature rows = 7.33 M calls per path.
| metric | float | fixed (Q15) | fixed advantage |
|---|---|---|---|
| min | 4 090 ns | 2 160 ns | 1.89× |
| p50 | 4 730 ns | 2 880 ns | 1.64× |
| p99 | 7 730 ns | 4 140 ns | 1.87× |
| max (preemption tail) | 50 ms | 50 ms | RT outlier in 7.3 M samples; not the predictor |
| mean | 5 486 ns | 3 087 ns | 1.78× |
| throughput / core | 178 K / s | 314 K / s | 1.76× |
Both paths execute the same number of compares and the same softmax. The difference is that integer
compares fuse into a single ARM CMP + branch, while float compares require FCMP
through the FPU. Because the trees themselves contain no FP arithmetic to do, the FPU is idle on
the float path — it only adds latency.
-O2)| metric | float | fixed |
|---|---|---|
| p50 | 130 ns | 110 ns |
| p99 | 210 ns | 170 ns |
| throughput / core | 6.5 M / s | 7.8 M / s |
A53 path is ~36× slower than x86_64 in absolute time. A53 is in-order dual-issue; modern out-of-order x86 has wider pipelines, larger TLB, faster L1. The interesting fact is not the ratio — it's that both clear the §1.4 wall-clock budget by orders of magnitude.
Read-only by design. Runs the calibrated detector + the policy mapping in labels.yaml
against every 100 ms feature window and records what an actuator would have applied. Never opens,
writes, or ioctls a kernel knob. The point is to characterise the detector + actuator pair before granting
the actuator write permission to /sys — the empirical foundation for the actuator gates in
MICROCONTROLLER_AND_ACTUATOR.md §2.3.
| true label | stable mode (post-hysteresis) | policy applied | count |
|---|---|---|---|
| idle | idle | run_deferred_maintenance | 839 |
| idle | unknown_transition | default_safe | 46 |
| random_read | random_read | latency_first | 829 |
| random_read | unknown_transition | default_safe | 49 |
| random_read | idle | run_deferred_maintenance | 7 |
| random_write | random_write | absorb_write_burst | 830 |
| random_write | unknown_transition | default_safe | 47 |
| random_write | idle | run_deferred_maintenance | 14 |
| sequential_read | sequential_read | throughput_read_bias | 818 |
| sequential_read | unknown_transition | default_safe | 39 |
| sequential_read | idle | run_deferred_maintenance | 8 |
| sequential_write | sequential_write | write_coalescing_bias | 830 |
| sequential_write | unknown_transition | default_safe | 43 |
| sequential_write | idle | run_deferred_maintenance | 13 |
idle / run_deferred_maintenance. There is no
random_read → sequential_read, no random_write → sequential_write,
no read/write-direction swap. When the detector is wrong, it picks the safest possible policy —
steals a slice of bandwidth for deferred maintenance during what was actually an active workload,
but never harms durability or write paths. That's the property the §2.3 failure-mode matrix gate asks for.
labels.yaml)| stable mode | applied policy | typical SSD-side intent |
|---|---|---|
| idle | run_deferred_maintenance | GC, scrub, wear-leveling that's been waiting |
| sequential_read | throughput_read_bias | increase prefetch, larger queue depth |
| sequential_write | write_coalescing_bias | collect writes into bigger flash pages |
| random_read | latency_first | shorten queue, prefer freshly-cached blocks |
| random_write | absorb_write_burst | open more SLC cache, defer GC |
| unknown_transition | default_safe | vendor default; never make things worse |
The host aggregates eBPF events, runs the feature aggregator, and sends the device one
quantized feature row every 100 ms. The device runs workload_predict_fixed() and replies with a
prediction frame. Same wire format works for the MCU PoC and (later) the FPGA-fabric path.
typedef struct __attribute__((packed)) { uint32_t magic; /* WLDM = 0x4D444C57 little-endian */ uint32_t seq; /* monotonic, host-side */ uint16_t version; uint16_t window_ms; uint16_t feature_count; /* must equal WORKLOAD_NUM_FEATURES */ int16_t features_q[36]; /* Q15 quantized */ uint32_t crc32; /* CRC-32/IEEE-802.3 over preceding bytes */ } workload_feature_frame_t;
typedef struct __attribute__((packed)) { uint32_t magic; uint32_t seq; /* echoes feature.seq */ uint16_t version; uint16_t predicted_class; /* raw top-1, no hysteresis */ uint16_t stable_class; /* post-hysteresis or 0xFFFF unknown */ uint16_t confidence_q15; uint16_t margin_q15; uint8_t reserved[2]; uint32_t crc32; } workload_prediction_frame_t;
| variant | iterations | false-positive | caught by |
|---|---|---|---|
| single-bit (canonical §1.4 gate) | 200 000 | 0 | MAGIC 21 219 / VERSION 10 519 / FEAT_COUNT 2 227 / CRC 166 035 |
| multi-bit stress (1..4 distinct flips) | 1 000 000 | 0 | MAGIC 230 885 / VERSION 91 803 / FEAT_COUNT 23 711 / CRC 653 601 |
validate() where an out-of-spec frame
returns OK in 1.2 M test iterations.
The methodology is portable. Same discipline that produced the storage workload classifier in
workload_predict_fixed() can produce other small learned policies that today live inside a
GPU's hot path or inside an SSD's controller, and move them out of those paths.
Three concrete PoCs ranked by tractability, from MICROCONTROLLER_AND_ACTUATOR.md §3.3–3.5:
| PoC | cost lever | gate | data source |
|---|---|---|---|
| #1 KV-cache eviction policy | HBM bandwidth + capacity (the dominant cost) | capacity reduction ≥ 30 %, perplexity within 1 % | sammshen/lmcache-agentic-traces on W7900 |
| #2 MoE router surrogate | off-GPU dispatch decisions | top-1 dispatch parity ≥ 99 % | open MoE model + workload trace |
| #3 Narrow-domain spec-dec draft | fewer big-model calls | ≥ 1.5× wall-clock speedup | JSON / log / boilerplate workload |
| category | gate |
|---|---|
| pre-deployment | reversibility test |
| bounded action space | |
| idempotency | |
| kill-switch latency ≤ 1 s | |
| audit log on every action | |
| failure-mode matrix documented | |
| deployment | A/B harness vs detector-off baseline |
| effect-size threshold ≥ 5 % | |
| stability under classification noise | |
| stability under workload phase changes | |
| convergence in ≤ 5 s, ≤ 1 knob change / 30 s steady-state | |
| sub-second kill-switch latency |
read_ahead_kbPer-block-device /sys/block/<dev>/queue/read_ahead_kb. Reversible, durability-neutral,
observable via iostat, costs cache bandwidth not consistency on miclassification. Right first
knob to learn the actuator-side discipline before touching anything that affects barriers, fsync, or
write paths.