hw-model

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

Overview
Detector
Calibration
C Export
MCU Footprint
A53 Latency
Policy Simulator
Frame Protocol
Roadmap

What is hw-model?

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.

0.97
Test macro F1 (5-class, honest split)
0.014
ECE post-calibration (was 0.093)
2.88µs
A53 inference @ 1.333 GHz, p50
26 KB
Cortex-M0+ ROM, fixed-only
0.99
Diagonal correctness when actuator commits
8/8
MCU §1.4 confidence gates met

Pipeline

eBPF syscall monitor
→
100 ms windowed features
→
LightGBM (32 trees × 5 classes)
→
temperature scaling
→
workload_predict_fixed (C)
→
Cortex-A53 / -M0+ / Zynq fabric

What we measured

phasewhatheadlinegate
1Detector training (honest leakage rules, group split) 0.972 acc, 0.972 macro F1pass
5Temperature-scaling calibration (T = 0.6733) ECE 0.093 → 0.014pass
6C export with float & Q15 fixed-point parity vs Python 0.9991 / 0.9986pass
7 crossCortex-M ROM at -Os 24.5 KB M4 / 26.3 KB M0+ (fixed-only)pass
7 frameUSB-CDC frame protocol, magic+CRC32, bit-flip fuzz 1.2 M flips, 0 false-positivepass
7 A53Real-chip latency on Kria KV260 Cortex-A53 @ 1.333 GHz 4.73 µs float / 2.88 µs fixed (p50)pass
9Policy simulator (read-only, no /sys writes) 0.99 correctness when committed; off-diagonal collapses to safepass

What we still owe

phasewhatstatus
7+FPGA HLS bitstream of workload_predict_fixed on Zynq fabric, decision in cycles not microseconds next
3.3KV-cache eviction policy as tree ensemble (lmcache-agentic-traces on W7900) future
dataWorkload 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
Discipline contribution. The methodology contribution is not "we beat GPUs" or "we replaced an SSD controller." It's the discipline of extract a learned policy and compile it faithfully: forbidden-leakage columns, session-level group split, temperature calibration with measured ECE, abstention plus hysteresis, bounded inputs, equivalence-tested fixed-point export, simulator-first deployment with a measured failure-mode matrix.

Workload-mode detector (Phase 1)

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.

Per-class performance on the held-out fold

classprecisionrecallF1support
idle0.850.990.92291
random_read0.990.950.97297
random_write1.000.950.97297
sequential_read0.990.960.98289
sequential_write1.000.950.97297
macro avg0.960.961471

Top features by gain

Why these features and not others

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.

Honest split rules

rulewhy
Group split by session_idfio 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 columnsprocess_name “fio” would trivially solve the problem and learn nothing about the underlying behaviour.
StratifiedGroupKFold2 sessions per label is a corner case; KFold ensures every label appears on both sides of the split.

Confidence calibration (Phase 5)

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.

Temperature scaling fits a single scalar T

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.

Result on the 32 / 8 / 4 model, calibration set = held-out test fold (1471 rows)

metricbeforeafterdelta
Temperature1.00.6733sharpens
ECE (top-1, 15 bins)0.09310.0135~7× better
Brier score (multiclass)0.07310.0664−0.007
Mean confidence0.8800.965matches accuracy 0.965
Test accuracy0.9650.965unchanged (argmax preserved)

Behavioural impact: idle sessions emit a stable mode

With the same hysteresis settings (5-window stable, 0.80 confidence, 0.20 margin), idle session emit-rate jumps:

sessionuncalibrated emit-ratecalibrated emit-rate
run_a_idle0.4560.942
run_b_idle0.7800.949
run_c_idle0.8350.953
abstention rate (conf < 0.80)0.0410.014
The 0.80 threshold means what it advertises now. Pre-calibration, "I am 0.80 confident" was empirical accuracy ~0.65 in some bins. Post-calibration, "I am 0.80 confident" matches empirical accuracy ~0.80. The MCU and the actuator both read the same threshold; calibrating the host means calibrating both.

Compile to runtime-free C (Phase 6)

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.

Two paths from the same booster

workload_predict_floatworkload_predict_fixed
Feature inputconst float[36]const int16_t[36] (Q15)
Threshold comparesfloatint16
Leaf accumulationfloatfloat (kept floating for parity test)
Softmax tailsame: expf((s − max_s) / T)same
Argmax preserved vs. Python0.99910.9986

Bake-in: temperature, quantization, calibration

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[]);

Parity gate (1466 rows of 5 distinct workloads)

variantmatchbarresult
x86_64 float-C ↔ Python4408 / 4412 = 0.9991≥ 0.99pass
x86_64 fixed Q15 ↔ Python4406 / 4412 = 0.9986≥ 0.95pass
aarch64 (A53) float-C ↔ Python1465 / 1466 = 0.9993≥ 0.99pass
aarch64 (A53) fixed Q15 ↔ Python1464 / 1466 = 0.9986≥ 0.95pass
IEEE 754 means cross-architecture stability. Disagreements concentrate on rows whose top two classes are within ~1 ULP — only one or two such rows in 1466 in either direction. Soft predictions (confidence, margin) shift by < 0.06 absolute on Q15 vs Python, well below any operationally meaningful threshold.

Cortex-M ROM at -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.

Per-target footprint

target / configtext + dataBSSbarresult
Cortex-M4 soft-float (both paths)55 346 B (54.0 KB)0≤ 64 KBpass
Cortex-M4 hard-float fpv4 (both paths)58 346 B (57.0 KB)0≤ 64 KBpass
Cortex-M0+ soft-float (both paths)58 582 B (57.2 KB)0≤ 32 KBover
Cortex-M4 fixed-only25 058 B (24.5 KB)0≤ 64 KBpass
Cortex-M0+ fixed-only26 922 B (26.3 KB)0≤ 32 KBpass

Frame-protocol layer adds (workload_frame.o only)

targettextnote
Cortex-M4346 Bbyte-at-a-time CRC-32, no static lookup table
Cortex-M0+468 Bsame code, slightly larger because thumb-1 lacks 32-bit immediates

Total fixed-only deployment cost

targetmodel + framechip ROMheadroom
Cortex-M4~25.0 KBtypically 64+ KB~39 KB for newlib + CRT
Cortex-M0+~27.0 KBtypically 32 KB~5 KB — tight; warrants a real-linker remeasure
Caveat: .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.

Real-chip inference latency (Phase 7 A53)

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.

Per-call latency distribution

metricfloatfixed (Q15)fixed advantage
min4 090 ns2 160 ns1.89×
p504 730 ns2 880 ns1.64×
p997 730 ns4 140 ns1.87×
max (preemption tail)50 ms50 msRT outlier in 7.3 M samples; not the predictor
mean5 486 ns3 087 ns1.78×
throughput / core178 K / s314 K / s1.76×

Why fixed is faster

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.

Cross-architecture reference (prune x86_64, gcc 13.3, -O2)

metricfloatfixed
p50130 ns110 ns
p99210 ns170 ns
throughput / core6.5 M / s7.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.

The latency gate closes with two orders of magnitude of margin. Decision budget is 100 ms per window; we use 0.005% of it per call. Detector-side latency is not what gates anything in this design. The next bar (decision in nanoseconds, not microseconds) is for the FPGA path that we picked the Kria to enable.

Policy simulator (Phase 9)

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.

15
Sessions evaluated
4 412
Total windows
94.9%
Windows committed (non-default)
0.99
Correctness when committed
5.1%
Abstained to default_safe
4.2
Policy transitions per session

Failure-mode matrix — the part that matters

true labelstable mode (post-hysteresis)policy appliedcount
idleidlerun_deferred_maintenance839
idleunknown_transitiondefault_safe46
random_readrandom_readlatency_first829
random_readunknown_transitiondefault_safe49
random_readidlerun_deferred_maintenance7
random_writerandom_writeabsorb_write_burst830
random_writeunknown_transitiondefault_safe47
random_writeidlerun_deferred_maintenance14
sequential_readsequential_readthroughput_read_bias818
sequential_readunknown_transitiondefault_safe39
sequential_readidlerun_deferred_maintenance8
sequential_writesequential_writewrite_coalescing_bias830
sequential_writeunknown_transitiondefault_safe43
sequential_writeidlerun_deferred_maintenance13
Diagonal-or-safe. Every off-diagonal committed cell collapses to 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.

Policy mapping (from labels.yaml)

stable modeapplied policytypical SSD-side intent
idlerun_deferred_maintenanceGC, scrub, wear-leveling that's been waiting
sequential_readthroughput_read_biasincrease prefetch, larger queue depth
sequential_writewrite_coalescing_biascollect writes into bigger flash pages
random_readlatency_firstshorten queue, prefer freshly-cached blocks
random_writeabsorb_write_burstopen more SLC cache, defer GC
unknown_transitiondefault_safevendor default; never make things worse

USB-CDC frame protocol (Phase 7 frame)

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.

Feature frame (host → device, 90 bytes for 36 features)

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;

Prediction frame (device → host, 24 bytes)

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;

Bit-flip fuzz results

variantiterationsfalse-positivecaught by
single-bit (canonical §1.4 gate)200 0000 MAGIC 21 219 / VERSION 10 519 / FEAT_COUNT 2 227 / CRC 166 035
multi-bit stress (1..4 distinct flips)1 000 0000 MAGIC 230 885 / VERSION 91 803 / FEAT_COUNT 23 711 / CRC 653 601
Layered checks classify at the cheapest gate. Most CRC rejections are body-bit flips that did not happen to land in magic / version / feature_count; the rest land on those header fields and are rejected before CRC even runs. There is no path through validate() where an out-of-spec frame returns OK in 1.2 M test iterations.

Where this scales

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.

Storage path (the original thesis)

A53 software baseline (today)
→
Vitis HLS → Zynq fabric on KV260
→
Cortex-M0+ USB device (RP2040)
→
Computational-storage SSD (Samsung SmartSSD = same Zynq family)

Transformer-side path (the bigger play)

Three concrete PoCs ranked by tractability, from MICROCONTROLLER_AND_ACTUATOR.md §3.3–3.5:

PoCcost levergatedata 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
What the methodology does not claim. Trees do not replace transformer inference end to end. A 70 B-parameter LLM produces a distribution over 100 K-token vocabulary; tree ensembles cannot encode that. What they can encode are the small, low-rank decisions that transformer inference makes inside the loop — cache eviction, expert routing, draft tokens. Those decisions are individually cheap, run at decode-step rates, and collectively account for a meaningful chunk of HBM-bandwidth-bound serving cost.

Confidence checklist for any actuator (from MICROCONTROLLER_AND_ACTUATOR.md §2.3)

categorygate
pre-deploymentreversibility test
bounded action space
idempotency
kill-switch latency ≤ 1 s
audit log on every action
failure-mode matrix documented
deploymentA/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

First actuator candidate: read_ahead_kb

Per-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.