From 2fdfa88c2cb1a084554c4438b7cde3a9d3a2e536 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 25 Sep 2026 16:24:29 -0600 Subject: [PATCH 1/3] bench: model Lynx bus, DS link cadence, sensor quantization/noise/dropouts Wires five realism improvements into the benchmark suite so the scenario ladder tracks Control-Hub-class structure rather than a free JVM sim: 1. Lynx bus model (LynxBus): one shared serialized link with per-transaction spin cost; device writes (SimMotor/SimServo) and sensor reads (SimSensors) pay it. Bulk read of multiple registers is cheaper than the same reads one-at-a-time. Mock-budget fixture keeps a no-bus SimMotor so the micro.sim.deviceWrite gate still measures the bare mock. 2. DS link (DriverStation, StimulusTimeline): gamepad updates reach the robot on a 25 ms packet cadence; seeded dropouts defer (never discard) queued mutations. Latency probe stamps at flush so the measured latency reflects flush->actuation while the loop sees realistic input staleness. 3. Sensor realism (SimSensors, World, style call sites): lift and heading reads route through the bus with quantization, correlated noise keyed off the plant sim clock so every style sees the identical sensor process, and time-bucketed dropouts that return the last good sample. 4. Realistic telemetry (SimTelemetry replaces NoopTelemetry): batched size-capped string formatting + serialization so the message path allocates like a real DS packet. 5. Tail metrics + GC pressure (Hist p99.9, TaskMeter deadlineMissPct, Metrics GC count/ms, JVM flags -Xms256m/-Xmx256m/-Xmn16m, slow-logger string formatting): actuation p99.9 and deadline-miss percentage are reported; slow-path string formatting under a small young gen makes young collections show up in the actuation tail. --- benchmarks/build.gradle | 4 +- .../synapse/bench/harness/Compare.java | 4 +- .../aaravlabs/synapse/bench/harness/Main.java | 24 ++++- .../synapse/bench/harness/Report.java | 43 ++++++-- .../aaravlabs/synapse/bench/raw/RawS2.java | 4 +- .../aaravlabs/synapse/bench/raw/RawS3.java | 8 +- .../synapse/bench/rawmt/RawMtS2.java | 4 +- .../synapse/bench/rawmt/RawMtS3.java | 8 +- .../synapse/bench/shared/BusyWork.java | 18 +++- .../synapse/bench/shared/DriverStation.java | 85 ++++++++++++++++ .../aaravlabs/synapse/bench/shared/Hist.java | 7 +- .../synapse/bench/shared/LynxBus.java | 51 ++++++++++ .../synapse/bench/shared/Metrics.java | 43 +++++++- .../synapse/bench/shared/NoopTelemetry.java | 31 ------ .../synapse/bench/shared/SimMotor.java | 19 +++- .../synapse/bench/shared/SimPlant.java | 3 + .../synapse/bench/shared/SimSensors.java | 99 +++++++++++++++++++ .../synapse/bench/shared/SimServo.java | 14 ++- .../synapse/bench/shared/SimTelemetry.java | 52 ++++++++++ .../bench/shared/StimulusTimeline.java | 23 +++-- .../synapse/bench/shared/TaskMeter.java | 15 ++- .../aaravlabs/synapse/bench/shared/World.java | 46 ++++++--- .../synapse/bench/solverslib/SolversS2.java | 4 +- .../synapse/bench/solverslib/SolversS3.java | 8 +- .../synapse/bench/synapse/SynapseS2.java | 4 +- .../synapse/bench/synapse/SynapseS3.java | 6 +- 26 files changed, 524 insertions(+), 103 deletions(-) create mode 100644 benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/DriverStation.java create mode 100644 benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/LynxBus.java delete mode 100644 benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/NoopTelemetry.java create mode 100644 benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimSensors.java create mode 100644 benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimTelemetry.java diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index d40d06e..5675c7f 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -55,7 +55,9 @@ application { tasks.named('run', JavaExec) { dependsOn 'extractSolverslib' - jvmArgs '-Xms512m', '-Xmx2g' + // Control-Hub-class heap: small young gen so message-path allocation + // produces real young GCs that show up in the actuation tail. + jvmArgs '-Xms128m', '-Xmx256m', '-Xmn16m' if (project.hasProperty('benchArgs')) { args project.property('benchArgs').toString().split('\\s+') } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Compare.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Compare.java index 062a907..861f31f 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Compare.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Compare.java @@ -135,10 +135,10 @@ private static void collectScenario(String key, Map b, Map metrics, List missing) { Map blat = (Map) b.get("latencyActuationNs"); Map llat = (Map) l.get("latencyActuationNs"); - for (String p : new String[] {"p50", "p90", "p99", "max"}) { + for (String p : new String[] {"p50", "p90", "p99", "p999", "max"}) { double bv = num(blat == null ? null : blat.get(p)); if (bv <= 0) { - missing.add(key + ".latencyActuationNs." + p); + if (blat != null && blat.containsKey(p)) missing.add(key + ".latencyActuationNs." + p); continue; } if (!(llat != null && llat.get(p) instanceof Number)) { diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java index b02490a..b62a5b3 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java @@ -101,8 +101,9 @@ private static int cmdRun(String[] args) throws Exception { Path forkOut = cfg.outDir.resolve("fork-" + i + ".json"); List child = new ArrayList<>(); child.add(System.getProperty("java.home") + "/bin/java"); - child.add("-Xms512m"); - child.add("-Xmx2g"); + child.add("-Xms128m"); + child.add("-Xmx256m"); + child.add("-Xmn16m"); child.add("-cp"); child.add(System.getProperty("java.class.path")); child.add(Main.class.getName()); @@ -242,9 +243,11 @@ private static Report.Pair runPair(Scenario scenario, String style, Config cfg) List tasks = metrics.taskSnapshots(); double loopHz = metrics.loopHz(); double alloc = metrics.allocBytesPerSec(); + long gcCount = metrics.gcCount(); + long gcMillis = metrics.gcMillis(); world.close(); return Report.Pair.fromSnapshot(scenario.name(), style, latency, tasks, - liftRmse, headingRmse, loopHz, alloc); + liftRmse, headingRmse, loopHz, alloc, gcCount, gcMillis); } private static List filterStyles(List available, List wanted) { @@ -268,7 +271,7 @@ private static Report.Pair medianPair(List rounds) { Report.Pair first = rounds.get(0); Map lat = new LinkedHashMap<>(); - for (String p : new String[] {"p50", "p90", "p99", "max", "min", "mean"}) { + for (String p : new String[] {"p50", "p90", "p99", "p999", "max", "min", "mean"}) { lat.put(p, medianOf(rounds, r -> num(((Map) r.latencyActuationNs).get(p)))); } lat.put("count", (long) medianOf(rounds, r -> num(r.latencyActuationNs.get("count")))); @@ -286,6 +289,14 @@ private static Report.Pair medianPair(List rounds) { Map t = (Map) r.taskRates.get(name); return t == null ? 0 : num(t.get("jitterP99Ns")); })); + entry.put("jitterP999Ns", medianOf(rounds, r -> { + Map t = (Map) r.taskRates.get(name); + return t == null ? 0 : num(t.get("jitterP999Ns")); + })); + entry.put("deadlineMissPct", medianOf(rounds, r -> { + Map t = (Map) r.taskRates.get(name); + return t == null ? 0 : num(t.get("deadlineMissPct")); + })); entry.put("count", (long) medianOf(rounds, r -> { Map t = (Map) r.taskRates.get(name); return t == null ? 0 : num(t.get("count")); @@ -299,7 +310,10 @@ private static Report.Pair medianPair(List rounds) { double loopHz = medianOf(rounds, r -> r.loopHz); double alloc = medianOf(rounds, r -> r.allocBytesPerSec); - return new Report.Pair(first.scenario, first.style, lat, rates, tracking, loopHz, alloc); + long gcCount = (long) medianOf(rounds, r -> r.gcCount); + long gcMillis = (long) medianOf(rounds, r -> r.gcMillis); + return new Report.Pair(first.scenario, first.style, lat, rates, tracking, loopHz, alloc, + gcCount, gcMillis); } private interface PairDouble { diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java index df0e3b6..4387186 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java @@ -28,13 +28,17 @@ public static final class Pair { public final Map trackingError; public final double loopHz; public final double allocBytesPerSec; + public final long gcCount; + public final long gcMillis; public Pair(String scenario, String style, Map latencyActuationNs, Map taskRates, Map trackingError, double loopHz, - double allocBytesPerSec) { + double allocBytesPerSec, + long gcCount, + long gcMillis) { this.scenario = scenario; this.style = style; this.latencyActuationNs = latencyActuationNs; @@ -42,6 +46,8 @@ public Pair(String scenario, String style, this.trackingError = trackingError; this.loopHz = loopHz; this.allocBytesPerSec = allocBytesPerSec; + this.gcCount = gcCount; + this.gcMillis = gcMillis; } public static Pair fromSnapshot(String scenario, String style, @@ -50,11 +56,14 @@ public static Pair fromSnapshot(String scenario, String style, double liftRmse, double headingRmse, double loopHz, - double allocBytesPerSec) { + double allocBytesPerSec, + long gcCount, + long gcMillis) { Map lat = new LinkedHashMap<>(); lat.put("p50", latency.p50); lat.put("p90", latency.p90); lat.put("p99", latency.p99); + lat.put("p999", latency.p999); lat.put("max", latency.max); lat.put("min", latency.min); lat.put("mean", latency.mean); @@ -66,6 +75,8 @@ public static Pair fromSnapshot(String scenario, String style, entry.put("targetHz", t.targetHz); entry.put("achievedHz", t.achievedHz); entry.put("jitterP99Ns", t.jitterP99Ns); + entry.put("jitterP999Ns", t.jitterP999Ns); + entry.put("deadlineMissPct", t.deadlineMissPct); entry.put("count", t.count); rates.put(t.name, entry); } @@ -74,7 +85,8 @@ public static Pair fromSnapshot(String scenario, String style, tracking.put("liftRmse", liftRmse); tracking.put("headingRmse", headingRmse); - return new Pair(scenario, style, lat, rates, tracking, loopHz, allocBytesPerSec); + return new Pair(scenario, style, lat, rates, tracking, loopHz, allocBytesPerSec, + gcCount, gcMillis); } public Map toMap() { @@ -86,6 +98,8 @@ public Map toMap() { m.put("trackingError", trackingError); m.put("loopHz", loopHz); m.put("allocBytesPerSec", allocBytesPerSec); + m.put("gcCount", gcCount); + m.put("gcMillis", gcMillis); return m; } @@ -98,7 +112,9 @@ public static Pair fromMap(Map m) { (Map) m.get("taskRates"), (Map) m.get("trackingError"), ((Number) m.getOrDefault("loopHz", 0)).doubleValue(), - ((Number) m.getOrDefault("allocBytesPerSec", 0)).doubleValue()); + ((Number) m.getOrDefault("allocBytesPerSec", 0)).doubleValue(), + ((Number) m.getOrDefault("gcCount", 0)).longValue(), + ((Number) m.getOrDefault("gcMillis", 0)).longValue()); } } @@ -145,7 +161,8 @@ public static String markdown(Map doc) { sb.append("- git: ").append(env.get("gitSha")).append('\n'); sb.append("- timestamp: ").append(env.get("timestamp")).append('\n'); sb.append("\n## Scenario ladder\n\n"); - sb.append("| scenario | style | actuation p50 (ns) | actuation p99 (ns) | lift RMSE | heading RMSE | loopHz |"); + sb.append("| scenario | style | actuation p50 (ns) | actuation p99 (ns) | actuation p99.9 (ns) |"); + sb.append(" max task miss % | lift RMSE | heading RMSE | loopHz | GC ms |"); Map firstRates = new LinkedHashMap<>(); List scenarios = (List) doc.get("scenarios"); for (Object o : scenarios) { @@ -160,7 +177,7 @@ public static String markdown(Map doc) { sb.append(' ').append(k).append(" Hz |"); } sb.append('\n'); - sb.append("| --- | --- | ---: | ---: | ---: | ---: | ---: |"); + sb.append("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); for (int i = 0; i < firstRates.size(); i++) sb.append(" ---: |"); sb.append('\n'); for (Object o : scenarios) { @@ -168,13 +185,25 @@ public static String markdown(Map doc) { Map lat = (Map) p.get("latencyActuationNs"); Map tracking = (Map) p.get("trackingError"); Map rates = (Map) p.get("taskRates"); + double maxMiss = 0; + for (Object k : firstRates.keySet()) { + Map rate = (Map) rates.get(k); + if (rate == null) continue; + Object miss = rate.get("deadlineMissPct"); + if (miss instanceof Number) { + maxMiss = Math.max(maxMiss, ((Number) miss).doubleValue()); + } + } sb.append("| ").append(p.get("scenario")) .append(" | ").append(p.get("style")) .append(" | ").append(fmt(lat.get("p50"))) .append(" | ").append(fmt(lat.get("p99"))) + .append(" | ").append(fmt(lat.get("p999"))) + .append(" | ").append(fmt(maxMiss)) .append(" | ").append(fmt(tracking.get("liftRmse"))) .append(" | ").append(fmt(tracking.get("headingRmse"))) - .append(" | ").append(fmt(p.get("loopHz"))); + .append(" | ").append(fmt(p.get("loopHz"))) + .append(" | ").append(fmt(p.get("gcMillis"))); for (Object k : firstRates.keySet()) { Map rate = (Map) rates.get(k); sb.append(" | ").append(rate == null ? "—" : fmt(rate.get("achievedHz"))); diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS2.java index 44028ae..08af9f5 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS2.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS2.java @@ -103,7 +103,7 @@ public void loop() { lastLift = now; liftMeter.tick(now); double target = world.setpoints().liftTargetAt(now); - double power = liftPidf.update(world.plant().liftPos, target, now); + double power = liftPidf.update(world.sensors().liftPos(), target, now); world.liftMotor().setPower(power); } @@ -134,7 +134,7 @@ public void loop() { if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { lastTelemetry = now; telemetryMeter.tick(now); - telemetry.addData("lift", world.plant().liftPos); + telemetry.addData("lift", world.sensors().liftPos()); telemetry.addData("align", alignOffset); telemetry.update(); } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS3.java index da88318..2571bd8 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS3.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS3.java @@ -116,7 +116,7 @@ public void loop() { lastHeading = now; headingMeter.tick(now); double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; - double corr = headingPidf.update(world.plant().heading, target, now); + double corr = headingPidf.update(world.sensors().heading(), target, now); world.leftMotor().setPower(stickY - corr); world.rightMotor().setPower(stickRightY + corr); } @@ -125,7 +125,7 @@ public void loop() { lastLift = now; liftMeter.tick(now); double target = world.setpoints().liftTargetAt(now); - double power = liftPidf.update(world.plant().liftPos, target, now); + double power = liftPidf.update(world.sensors().liftPos(), target, now); world.liftMotor().setPower(power); } @@ -170,8 +170,8 @@ public void loop() { if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { lastTelemetry = now; telemetryMeter.tick(now); - telemetry.addData("lift", world.plant().liftPos); - telemetry.addData("heading", world.plant().heading); + telemetry.addData("lift", world.sensors().liftPos()); + telemetry.addData("heading", world.sensors().heading()); telemetry.update(); } } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS2.java index 5f31859..bc66800 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS2.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS2.java @@ -138,7 +138,7 @@ public void loop() { lastLift = now; liftMeter.tick(now); double target = world.setpoints().liftTargetAt(now); - double power = liftPidf.update(world.plant().liftPos, target, now); + double power = liftPidf.update(world.sensors().liftPos(), target, now); world.liftMotor().setPower(power); } @@ -167,7 +167,7 @@ public void loop() { if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { lastTelemetry = now; telemetryMeter.tick(now); - telemetry.addData("lift", world.plant().liftPos); + telemetry.addData("lift", world.sensors().liftPos()); telemetry.addData("align", alignOffset); telemetry.update(); } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS3.java index 8c13991..c29f816 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS3.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS3.java @@ -196,7 +196,7 @@ public void loop() { lastHeading = now; headingMeter.tick(now); double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; - double corr = headingPidf.update(world.plant().heading, target, now); + double corr = headingPidf.update(world.sensors().heading(), target, now); world.leftMotor().setPower(stickY - corr); world.rightMotor().setPower(stickRightY + corr); } @@ -205,7 +205,7 @@ public void loop() { lastLift = now; liftMeter.tick(now); double target = world.setpoints().liftTargetAt(now); - double power = liftPidf.update(world.plant().liftPos, target, now); + double power = liftPidf.update(world.sensors().liftPos(), target, now); world.liftMotor().setPower(power); } @@ -240,8 +240,8 @@ public void loop() { if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { lastTelemetry = now; telemetryMeter.tick(now); - telemetry.addData("lift", world.plant().liftPos); - telemetry.addData("heading", world.plant().heading); + telemetry.addData("lift", world.sensors().liftPos()); + telemetry.addData("heading", world.sensors().heading()); telemetry.update(); } } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/BusyWork.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/BusyWork.java index cd3e29b..e0edf87 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/BusyWork.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/BusyWork.java @@ -4,6 +4,8 @@ * Deterministic CPU work used for the scenario "expensive" kernels (auto-align, * slow debug logger). Busy-spins on real arithmetic for a fixed duration so the * load profile is machine-independent, and returns a stable blackhole sink. + * The slow logger additionally formats log lines (deliberate allocation) so GC + * pressure from a chatty message path lands in the actuation tail. */ public final class BusyWork { @@ -42,8 +44,22 @@ public static double autoAlign(long eventSeq) { return Math.sin(eventSeq * 0.37) * 4.0; } - /** Slow logger kernel: fixed-duration work per event. */ + /** + * Slow logger kernel: fixed-duration work per event plus log-line + * formatting. The string building is the point — a real debug logger + * allocates, and those allocations are what push young collections into + * the measured tail under a small heap. + */ public static void slowLog(long eventSeq) { + StringBuilder sb = new StringBuilder(256); + for (int i = 0; i < 8; i++) { + sb.setLength(0); + sb.append("state seq=").append(eventSeq) + .append(" tick=").append(i) + .append(" t=").append(System.nanoTime() / 1000) + .append(" us payload=").append(eventSeq * 31 + i); + sink = sb.length(); + } spinNanos(SLOW_LOGGER_NANOS); sink = eventSeq; } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/DriverStation.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/DriverStation.java new file mode 100644 index 0000000..3fed33a --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/DriverStation.java @@ -0,0 +1,85 @@ +package com.aaravlabs.synapse.bench.shared; + +import java.util.ArrayDeque; + +/** + * Driver Station link model: gamepad updates reach the robot on a ~25 ms + * packet cadence, not the instant the driver moves. A dropped packet defers + * everything queued to the next packet (never silently discards input), which + * is what a flaky DS link does to button edges and stick steps. Drop decisions + * are a pure hash of (seed, packet index) so every style sees the identical + * link. Queue drains run the field writes on this single thread, preserving + * the {@code Gamepad} single-writer shape. + */ +public final class DriverStation implements AutoCloseable { + + public static final long PACKET_NANOS = 25_000_000L; + + private final long seed; + private final ArrayDeque queue = new ArrayDeque<>(); + private volatile boolean running; + private Thread thread; + + public DriverStation(long seed) { + this.seed = seed; + } + + /** Queue a gamepad mutation for delivery on the next good DS packet. */ + public void post(Runnable packet) { + synchronized (queue) { + queue.addLast(packet); + } + } + + public void start() { + running = true; + thread = new Thread(this::run, "driver-station"); + thread.setDaemon(true); + thread.start(); + } + + private void run() { + long t0 = System.nanoTime(); + long tick = 0; + while (running) { + tick++; + long due = t0 + tick * PACKET_NANOS; + long now; + while ((now = System.nanoTime()) - due < 0) { + if (!running) return; + java.util.concurrent.locks.LockSupport.parkNanos(Math.min(due - now, 5_000_000L)); + } + if (dropped(tick)) { + continue; + } + while (true) { + Runnable packet; + synchronized (queue) { + packet = queue.pollFirst(); + } + if (packet == null) { + break; + } + packet.run(); + } + } + } + + private boolean dropped(long tick) { + long h = seed * 0x9E3779B97F4A7C15L + tick * 0xD1B54A32D192ED03L; + h ^= h >>> 31; + return (h & 31L) == 0; + } + + @Override + public void close() { + running = false; + if (thread != null) { + try { + thread.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Hist.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Hist.java index a52b6a2..a1017cc 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Hist.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Hist.java @@ -13,15 +13,17 @@ public static final class Snapshot { public final long p50; public final long p90; public final long p99; + public final long p999; public final long max; public final long min; public final double mean; public final long count; - Snapshot(long p50, long p90, long p99, long max, long min, double mean, long count) { + Snapshot(long p50, long p90, long p99, long p999, long max, long min, double mean, long count) { this.p50 = p50; this.p90 = p90; this.p99 = p99; + this.p999 = p999; this.max = max; this.min = min; this.mean = mean; @@ -29,7 +31,7 @@ public static final class Snapshot { } public static Snapshot empty() { - return new Snapshot(0, 0, 0, 0, 0, 0.0, 0); + return new Snapshot(0, 0, 0, 0, 0, 0, 0.0, 0); } } @@ -71,6 +73,7 @@ public Snapshot snapshot() { percentile(copy, 0.50), percentile(copy, 0.90), percentile(copy, 0.99), + percentile(copy, 0.999), copy[copy.length - 1], copy[0], ((double) sum) / count, diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/LynxBus.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/LynxBus.java new file mode 100644 index 0000000..fd369ed --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/LynxBus.java @@ -0,0 +1,51 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Lynx-class device bus model: one shared serialized link, every transaction + * pays a fixed transfer cost while holding the bus. Costs are desktop-scaled + * (single-digit to tens of microseconds, not USB milliseconds) so the + * framework dispatch ladder stays measurable, while the *structure* is + * robot-real: writes and reads contend for one link, single-register reads + * cost more per register than a bulk read. Spin-based, deterministic load. + * + *

Device-write cost lives here and is only paid by {@link SimMotor}/{@link + * SimServo} instances that were given a bus; the mock-budget gate's fixture + * measures the bare volatile mock write with no bus attached. + */ +public final class LynxBus { + + public static final long WRITE_NANOS = 3_000L; + public static final long READ_NANOS = 5_000L; + public static final long BULK_BASE_NANOS = 4_000L; + public static final long BULK_REG_NANOS = 1_000L; + + private final Object link = new Object(); + private volatile long sink; + + public void write() { + tx(WRITE_NANOS); + } + + public void read() { + tx(READ_NANOS); + } + + /** Bulk read of N registers: one transaction, cheaper than N single reads. */ + public void bulkRead(int regs) { + tx(BULK_BASE_NANOS + BULK_REG_NANOS * regs); + } + + private void tx(long nanos) { + synchronized (link) { + long deadline = System.nanoTime() + nanos; + long x = 0x9E3779B97F4A7C15L; + long now; + while ((now = System.nanoTime()) - deadline < 0) { + x ^= x << 13; + x ^= x >>> 7; + x ^= x << 17; + } + sink = x; + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Metrics.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Metrics.java index e3ca9fb..76691a6 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Metrics.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Metrics.java @@ -18,13 +18,18 @@ public static final class TaskSnapshot { public final double targetHz; public final double achievedHz; public final long jitterP99Ns; + public final long jitterP999Ns; + public final double deadlineMissPct; public final long count; - TaskSnapshot(String name, double targetHz, double achievedHz, long jitterP99Ns, long count) { + TaskSnapshot(String name, double targetHz, double achievedHz, long jitterP99Ns, + long jitterP999Ns, double deadlineMissPct, long count) { this.name = name; this.targetHz = targetHz; this.achievedHz = achievedHz; this.jitterP99Ns = jitterP99Ns; + this.jitterP999Ns = jitterP999Ns; + this.deadlineMissPct = deadlineMissPct; this.count = count; } } @@ -41,6 +46,10 @@ public static final class TaskSnapshot { private volatile boolean allocEnabled; private long allocStartBytes = -1; private long allocEndBytes = -1; + private long gcCountStart; + private long gcMsStart; + private long gcCountEnd; + private long gcMsEnd; public TaskMeter task(String name, double targetHz) { TaskMeter m = tasks.get(name); @@ -84,6 +93,9 @@ public void startWindow() { } loopIterations.reset(); if (allocEnabled) allocStartBytes = threadAllocated(); + long[] gc = gcSample(); + gcCountStart = gc[0]; + gcMsStart = gc[1]; } public void endWindow() { @@ -91,6 +103,9 @@ public void endWindow() { for (TaskMeter m : snapshotTasks()) m.setRecording(false); for (LatencyProbe p : snapshotProbes()) p.setRecording(false); if (allocEnabled) allocEndBytes = threadAllocated(); + long[] gc = gcSample(); + gcCountEnd = gc[0]; + gcMsEnd = gc[1]; } public void countLoopIteration() { @@ -112,11 +127,22 @@ public double allocBytesPerSec() { return sec > 0 ? (allocEndBytes - allocStartBytes) / sec : 0.0; } + /** Young+old GC collections observed inside the measurement window. */ + public long gcCount() { + return Math.max(0, gcCountEnd - gcCountStart); + } + + /** Total GC pause milliseconds observed inside the measurement window. */ + public long gcMillis() { + return Math.max(0, gcMsEnd - gcMsStart); + } + public List taskSnapshots() { List out = new ArrayList<>(); for (TaskMeter m : snapshotTasks()) { Hist.Snapshot s = m.periodSnapshot(); - out.add(new TaskSnapshot(m.name(), m.targetHz(), m.achievedHz(), s.p99, m.count())); + out.add(new TaskSnapshot(m.name(), m.targetHz(), m.achievedHz(), s.p99, s.p999, + m.deadlineMissPct(), m.count())); } return out; } @@ -146,6 +172,19 @@ private List snapshotProbes() { } } + private static long[] gcSample() { + long count = 0; + long ms = 0; + for (java.lang.management.GarbageCollectorMXBean bean + : java.lang.management.ManagementFactory.getGarbageCollectorMXBeans()) { + long c = bean.getCollectionCount(); + long t = bean.getCollectionTime(); + if (c > 0) count += c; + if (t > 0) ms += t; + } + return new long[] {count, ms}; + } + private long threadAllocated() { try { java.lang.management.ThreadMXBean bean = java.lang.management.ManagementFactory.getThreadMXBean(); diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/NoopTelemetry.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/NoopTelemetry.java deleted file mode 100644 index 41fe446..0000000 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/NoopTelemetry.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.aaravlabs.synapse.bench.shared; - -import org.firstinspires.ftc.robotcore.external.Telemetry; - -/** - * No-op driver-station telemetry at the I/O boundary (there is no driver station - * on a desktop JVM). Calling it keeps the raw/SolversLib styles on the real - * {@code Telemetry} API shape. - */ -public final class NoopTelemetry implements Telemetry { - - public static final class Item implements Telemetry.Item { - } - - private final Item item = new Item(); - private volatile long updates; - - @Override - public Item addData(String caption, Object value) { - return item; - } - - @Override - public void update() { - updates++; - } - - public long updates() { - return updates; - } -} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimMotor.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimMotor.java index 5fcee57..8a48239 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimMotor.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimMotor.java @@ -1,26 +1,37 @@ package com.aaravlabs.synapse.bench.shared; /** - * Simulated {@code DcMotorEx} at the physical I/O boundary. The only thing a - * measured actuation path does here is a plain volatile field write into - * {@link SimPlant} plus the actuation stamp the plan requires to live inside the - * framework-invoked device write. Constant-cost, allocation-free. + * Simulated {@code DcMotorEx} at the physical I/O boundary. A measured + * actuation path pays the {@link LynxBus} transfer cost (when this motor was + * given a bus), then a plain volatile field write into {@link SimPlant} plus + * the actuation stamp the plan requires to live inside the framework-invoked + * device write. The no-bus constructor exists for the mock-budget fixture, + * which measures the bare mock write. */ public final class SimMotor { private final SimPlant plant; private final int channel; private final LatencyProbe probe; + private final LynxBus bus; private volatile double lastPower; public SimMotor(SimPlant plant, int channel, LatencyProbe probe) { + this(plant, channel, probe, null); + } + + public SimMotor(SimPlant plant, int channel, LatencyProbe probe, LynxBus bus) { this.plant = plant; this.channel = channel; this.probe = probe; + this.bus = bus; } /** Mirror of {@code DcMotorEx.setPower}. Runs on the framework-invoked thread. */ public void setPower(double power) { + if (bus != null) { + bus.write(); + } lastPower = power; switch (channel) { case SimPlant.LEFT: diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimPlant.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimPlant.java index 773713f..fbf9e21 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimPlant.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimPlant.java @@ -41,6 +41,8 @@ public final class SimPlant implements AutoCloseable { public volatile double headingBias; // ---- integrator state (written by the physics thread) ----------------- + /** Sim clock in nanos; the sensor layer keys its noise/dropout process off this. */ + public volatile long simNanos; public volatile double leftWheelVel; public volatile double rightWheelVel; public volatile double x; @@ -91,6 +93,7 @@ private void run() { } private void step(long simNanos) { + this.simNanos = simNanos; double lp = clampPower(leftPower); double rp = clampPower(rightPower); double ip = clampPower(intakePower); diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimSensors.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimSensors.java new file mode 100644 index 0000000..b300e6d --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimSensors.java @@ -0,0 +1,99 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Control-Hub-class sensor reads at the I/O boundary. Every read pays a Lynx + * bus transaction (a bulk read of both control registers pays one); values are + * quantized to encoder/IMU resolution, carry smooth (correlated) noise, and + * occasionally drop out — a dropped read returns the last good sample like a + * real retry-after-bulk-failure would. All randomness is a pure function of + * the world seed and the sim clock, so every style sees the identical sensor + * process. World-side physics stays exact in {@link SimPlant}; this class is + * only what the robot code can observe. + */ +public final class SimSensors { + + private static final double LIFT_TICK = 1.0; + private static final double HEADING_TICK = 0.0003; + private static final long DROPOUT_BUCKET_NANOS = 20_000_000L; + + private final SimPlant plant; + private final LynxBus bus; + private final long seed; + private final double n1; + private final double n2; + private final double n3; + + private volatile double lastLift; + private volatile double lastHeading; + private volatile boolean haveLift; + private volatile boolean haveHeading; + + public SimSensors(SimPlant plant, LynxBus bus, long seed) { + this.plant = plant; + this.bus = bus; + this.seed = seed; + this.n1 = 0.8 + (seed % 7) * 0.11; + this.n2 = 1.9 + (seed % 5) * 0.13; + this.n3 = 4.3 + (seed % 3) * 0.17; + } + + /** Single-register lift encoder read (one bus transaction). */ + public double liftPos() { + bus.read(); + double t = plant.simNanos * 1e-9; + double v = quantize(plant.liftPos + liftNoise(t), LIFT_TICK); + if (dropped()) return haveLift ? lastLift : v; + lastLift = v; + haveLift = true; + return v; + } + + /** Single-register IMU heading read (one bus transaction). */ + public double heading() { + bus.read(); + double t = plant.simNanos * 1e-9; + double v = quantize(plant.heading + headingNoise(t), HEADING_TICK); + if (dropped()) return haveHeading ? lastHeading : v; + lastHeading = v; + haveHeading = true; + return v; + } + + /** Bulk read of both control registers in one bus transaction. */ + public double[] readControl() { + bus.bulkRead(2); + double t = plant.simNanos * 1e-9; + double lift = quantize(plant.liftPos + liftNoise(t), LIFT_TICK); + double head = quantize(plant.heading + headingNoise(t), HEADING_TICK); + if (dropped()) { + return new double[] { + haveLift ? lastLift : lift, + haveHeading ? lastHeading : head + }; + } + lastLift = lift; + lastHeading = head; + haveLift = true; + haveHeading = true; + return new double[] {lift, head}; + } + + private boolean dropped() { + long bucket = plant.simNanos / DROPOUT_BUCKET_NANOS; + long h = seed * 0x9E3779B97F4A7C15L + bucket * 0xBF58476D1CE4E5B9L; + h ^= h >>> 31; + return (h & 63L) == 0; + } + + private double liftNoise(double t) { + return 0.7 * Math.sin(n1 * t + seed) + 0.4 * Math.sin(n2 * t + seed * 0.5); + } + + private double headingNoise(double t) { + return 0.0025 * Math.sin(n2 * t + seed * 0.3) + 0.0015 * Math.sin(n3 * t + seed); + } + + private static double quantize(double v, double tick) { + return Math.rint(v / tick) * tick; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimServo.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimServo.java index fb94317..767e14a 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimServo.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimServo.java @@ -1,22 +1,32 @@ package com.aaravlabs.synapse.bench.shared; /** - * Simulated {@code Servo} at the physical I/O boundary. Position writes are plain - * volatile stores plus the actuation stamp inside the framework-invoked call. + * Simulated {@code Servo} at the physical I/O boundary. Position writes pay + * the {@link LynxBus} transfer cost (when given a bus), then a plain volatile + * store plus the actuation stamp inside the framework-invoked call. */ public final class SimServo { private final SimPlant plant; private final LatencyProbe probe; + private final LynxBus bus; private volatile double position; public SimServo(SimPlant plant, LatencyProbe probe) { + this(plant, probe, null); + } + + public SimServo(SimPlant plant, LatencyProbe probe, LynxBus bus) { this.plant = plant; this.probe = probe; + this.bus = bus; } /** Mirror of {@code Servo.setPosition}. */ public void setPosition(double pos) { + if (bus != null) { + bus.write(); + } position = pos; plant.servoPos = pos; if (probe != null) { diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimTelemetry.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimTelemetry.java new file mode 100644 index 0000000..c3a99d2 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimTelemetry.java @@ -0,0 +1,52 @@ +package com.aaravlabs.synapse.bench.shared; + +import org.firstinspires.ftc.robotcore.external.Telemetry; + +/** + * Driver-station telemetry at the I/O boundary: captions and values are + * formatted into a size-capped batch and serialized on {@code update()}, the + * way the real {@code Telemetry} pipeline builds a DS packet. The string work + * is deliberate allocation on the message path so GC pauses land in the + * actuation tail under a small heap, like they do on a Control Hub. + */ +public final class SimTelemetry implements Telemetry { + + public static final class Item implements Telemetry.Item { + } + + private static final int MAX_CHARS = 2048; + + private final Item item = new Item(); + private final StringBuilder batch = new StringBuilder(256); + private volatile long updates; + private volatile long bytes; + private volatile String lastPacket = ""; + + @Override + public Item addData(String caption, Object value) { + if (batch.length() < MAX_CHARS) { + batch.append(caption).append(": ").append(value).append('\n'); + } + return item; + } + + @Override + public void update() { + lastPacket = batch.toString(); + bytes += lastPacket.length(); + batch.setLength(0); + updates++; + } + + public long updates() { + return updates; + } + + public long bytes() { + return bytes; + } + + public String lastPacket() { + return lastPacket; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/StimulusTimeline.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/StimulusTimeline.java index 646bd27..2da7060 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/StimulusTimeline.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/StimulusTimeline.java @@ -9,10 +9,12 @@ /** * Seeded, deterministic stimulus: gamepad field flips on a fixed timeline, - * identical per style per scenario. The stimulus thread stamps - * {@link LatencyProbe#stimulus} immediately before the write to the real stub - * {@link Gamepad} volatile fields — that stamp is the "input" end of - * input-to-actuation latency. + * identical per style per scenario. Events are queued to the + * {@link DriverStation} packet link and only become visible to the robot when + * a packet is flushed; the stimulus stamp is taken at flush time, right before + * the write to the real stub {@link Gamepad} volatile fields — that stamp is + * the "input" end of input-to-actuation latency, exactly like a driver whose + * stick move has to survive the DS link first. */ public final class StimulusTimeline implements AutoCloseable { @@ -48,6 +50,7 @@ private void at(double seconds, Runnable apply) { public static StimulusTimeline build(Scenario scenario, long seed, Gamepad g1, Gamepad g2, LatencyProbe actuation, + DriverStation ds, double spanSec, double leftPowerSign) { StimulusTimeline t = new StimulusTimeline(); Random rnd = new Random(seed); @@ -68,19 +71,19 @@ public static StimulusTimeline build(Scenario scenario, long seed, // and would measure a constant phase offset instead of latency). final double atSec = 0.30 + i * 0.30 + rnd.nextDouble() * 0.11; final double expectedSign = leftPowerSign * Math.signum(leftY); - t.at(atSec, () -> { + t.at(atSec, () -> ds.post(() -> { actuation.stimulus(System.nanoTime(), expectedSign); g1.left_stick_y = (float) -leftY; g1.right_stick_y = (float) -rightY; g1.left_stick_x = (float) leftX; - }); + })); } if (scenario.hasIntake()) { for (int i = 0; 0.55 + i * 0.45 + 0.07 < spanSec; i++) { final boolean down = (i % 2 == 0); final double atSec = 0.55 + i * 0.45 + rnd.nextDouble() * 0.07; - t.at(atSec, () -> g1.right_bumper = down); + t.at(atSec, () -> ds.post(() -> g1.right_bumper = down)); } } @@ -88,7 +91,7 @@ public static StimulusTimeline build(Scenario scenario, long seed, for (int i = 0; 0.80 + i * 0.55 + 0.09 < spanSec; i++) { final boolean down = (i % 2 == 0); final double atSec = 0.80 + i * 0.55 + rnd.nextDouble() * 0.09; - t.at(atSec, () -> g1.x = down); + t.at(atSec, () -> ds.post(() -> g1.x = down)); } } @@ -96,7 +99,7 @@ public static StimulusTimeline build(Scenario scenario, long seed, for (int i = 0; 0.65 + i * 0.50 + 0.08 < spanSec; i++) { final boolean down = (i % 2 == 0); final double atSec = 0.65 + i * 0.50 + rnd.nextDouble() * 0.08; - t.at(atSec, () -> g1.a = down); + t.at(atSec, () -> ds.post(() -> g1.a = down)); } } @@ -104,7 +107,7 @@ public static StimulusTimeline build(Scenario scenario, long seed, for (int i = 0; 0.70 + i * 0.55 + 0.06 < spanSec; i++) { final boolean down = (i % 2 == 0); final double atSec = 0.70 + i * 0.55 + rnd.nextDouble() * 0.06; - t.at(atSec, () -> g2.left_bumper = down); + t.at(atSec, () -> ds.post(() -> g2.left_bumper = down)); } } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/TaskMeter.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/TaskMeter.java index ec220af..6e49309 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/TaskMeter.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/TaskMeter.java @@ -14,6 +14,7 @@ public final class TaskMeter { private long last; private long count; private long firstTick; + private long missed; public TaskMeter(String name, double targetHz) { this.name = name; @@ -37,6 +38,7 @@ public void reset() { last = 0; count = 0; firstTick = 0; + missed = 0; } public void tick() { @@ -46,7 +48,11 @@ public void tick() { public void tick(long now) { if (!recording) return; if (firstTick == 0) firstTick = now; - if (last != 0) periods.record(now - last); + if (last != 0) { + long period = now - last; + periods.record(period); + if (targetHz > 0 && period > 1.5e9 / targetHz) missed++; + } last = now; count++; } @@ -55,6 +61,13 @@ public long count() { return count; } + /** Fraction of observed periods that exceeded 1.5× the target period. */ + public double deadlineMissPct() { + long periodsSeen = count > 0 ? count - 1 : 0; + if (periodsSeen == 0) return 0.0; + return 100.0 * missed / periodsSeen; + } + public Hist.Snapshot periodSnapshot() { return periods.snapshot(); } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/World.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/World.java index d5d7ac8..3797e0b 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/World.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/World.java @@ -3,9 +3,11 @@ import com.qualcomm.robotcore.hardware.Gamepad; /** - * Per-run shared world: real stub {@link Gamepad}s, {@link SimPlant} devices, - * optional {@link SimCamera}, setpoints and the seeded stimulus timeline. Style - * packages consume this and their framework APIs only. + * Per-run shared world: real stub {@link Gamepad}s, {@link SimPlant} devices on + * a shared {@link LynxBus}, realistic {@link SimSensors} reads, a + * {@link DriverStation} packet link for gamepad updates, {@link SimTelemetry}, + * setpoints and the seeded stimulus timeline. Style packages consume this and + * their framework APIs only. */ public final class World implements AutoCloseable { @@ -17,13 +19,16 @@ public final class World implements AutoCloseable { private final Gamepad gamepad2 = new Gamepad(); private final Setpoints setpoints; private final SimPlant plant; + private final LynxBus bus; + private final SimSensors sensors; private final SimMotor leftMotor; private final SimMotor rightMotor; private final SimMotor liftMotor; private final SimMotor intakeMotor; private final SimServo servo; private final SimCamera camera; - private final NoopTelemetry telemetry = new NoopTelemetry(); + private final SimTelemetry telemetry = new SimTelemetry(); + private final DriverStation driverStation; private final StimulusTimeline stimulus; private final LatencyProbe actuation; @@ -44,17 +49,20 @@ public World(Scenario scenario, long seed, Metrics metrics, this.actuation = metrics.probe("actuation"); this.setpoints = new Setpoints(System.nanoTime() + 50_000_000L); this.plant = new SimPlant(setpoints, scenario.hasLift(), scenario.hasHeadingHold()); + this.bus = new LynxBus(); + this.sensors = new SimSensors(plant, bus, seed); + this.driverStation = new DriverStation(seed); // Only the drive-left write carries the actuation latency probe: the measured // path is the stick stimulus -> drive actuation. Other device writes stay // un-instrumented so they cannot steal the pairing. - this.leftMotor = new SimMotor(plant, SimPlant.LEFT, actuation); - this.rightMotor = new SimMotor(plant, SimPlant.RIGHT, null); - this.liftMotor = new SimMotor(plant, SimPlant.LIFT, null); - this.intakeMotor = new SimMotor(plant, SimPlant.INTAKE, null); - this.servo = new SimServo(plant, null); + this.leftMotor = new SimMotor(plant, SimPlant.LEFT, actuation, bus); + this.rightMotor = new SimMotor(plant, SimPlant.RIGHT, null, bus); + this.liftMotor = new SimMotor(plant, SimPlant.LIFT, null, bus); + this.intakeMotor = new SimMotor(plant, SimPlant.INTAKE, null, bus); + this.servo = new SimServo(plant, null, bus); this.camera = scenario.hasVision() ? new SimCamera() : null; this.stimulus = StimulusTimeline.build(scenario, seed, gamepad1, gamepad2, actuation, - spanSec, leftPowerSign); + driverStation, spanSec, leftPowerSign); } public Scenario scenario() { @@ -85,6 +93,14 @@ public SimPlant plant() { return plant; } + public SimSensors sensors() { + return sensors; + } + + public LynxBus bus() { + return bus; + } + public SimMotor leftMotor() { return leftMotor; } @@ -110,10 +126,14 @@ public SimCamera camera() { return camera; } - public NoopTelemetry telemetry() { + public SimTelemetry telemetry() { return telemetry; } + public DriverStation driverStation() { + return driverStation; + } + public StimulusTimeline stimulus() { return stimulus; } @@ -122,16 +142,18 @@ public LatencyProbe actuationProbe() { return actuation; } - /** Start plant, camera capture and stimulus. */ + /** Start plant, camera capture, DS link and stimulus. */ public void start() { plant.start(); if (camera != null) camera.start(); + driverStation.start(); stimulus.start(); } @Override public void close() { stimulus.close(); + driverStation.close(); if (camera != null) camera.close(); plant.close(); } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java index 9e12451..5f5ddbb 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java @@ -98,7 +98,7 @@ private void pump() { if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { lastTelemetry = now; telemetryMeter.tick(now); - world.telemetry().addData("lift", world.plant().liftPos); + world.telemetry().addData("lift", world.sensors().liftPos()); world.telemetry().update(); } } @@ -139,7 +139,7 @@ public void periodic() { last = now; liftMeter.tick(now); double target = world.setpoints().liftTargetAt(now); - setPower(liftPidf.update(world.plant().liftPos, target, now)); + setPower(liftPidf.update(world.sensors().liftPos(), target, now)); } } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS3.java index 2045295..1fd3f0e 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS3.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS3.java @@ -116,8 +116,8 @@ private void pump() { if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { lastTelemetry = now; telemetryMeter.tick(now); - world.telemetry().addData("lift", world.plant().liftPos); - world.telemetry().addData("heading", world.plant().heading); + world.telemetry().addData("lift", world.sensors().liftPos()); + world.telemetry().addData("heading", world.sensors().heading()); world.telemetry().update(); } } @@ -150,7 +150,7 @@ public void periodic() { last = now; liftMeter.tick(now); double target = world.setpoints().liftTargetAt(now); - world.liftMotor().setPower(liftPidf.update(world.plant().liftPos, target, now)); + world.liftMotor().setPower(liftPidf.update(world.sensors().liftPos(), target, now)); } } @@ -164,7 +164,7 @@ public void periodic() { last = now; headingMeter.tick(now); double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; - double corr = headingPidf.update(world.plant().heading, target, now); + double corr = headingPidf.update(world.sensors().heading(), target, now); world.leftMotor().setPower(stickY - corr); world.rightMotor().setPower(stickRightY + corr); } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java index 8a69b69..8952227 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java @@ -118,7 +118,7 @@ public void update() { liftMeter.tick(); long now = System.nanoTime(); double target = world.setpoints().liftTargetAt(now); - double power = liftPidf.update(world.plant().liftPos, target, now); + double power = liftPidf.update(world.sensors().liftPos(), target, now); world.liftMotor().setPower(power); } } @@ -199,7 +199,7 @@ private final class TelemetryNode extends Node { public void publishTelemetry() { metrics.countLoopIteration(); telemetryMeter.tick(); - orchestrator.publish("telemetry/lift", world.plant().liftPos); + orchestrator.publish("telemetry/lift", world.sensors().liftPos()); world.telemetry().update(); } } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java index de30e2d..1031e36 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java @@ -117,7 +117,7 @@ public void headingHold() { headingMeter.tick(); long now = System.nanoTime(); double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; - double corr = headingPidf.update(world.plant().heading, target, now); + double corr = headingPidf.update(world.sensors().heading(), target, now); world.leftMotor().setPower(cmdY - corr); world.rightMotor().setPower(cmdRightY + corr); } @@ -134,7 +134,7 @@ public void update() { liftMeter.tick(); long now = System.nanoTime(); double target = world.setpoints().liftTargetAt(now); - double power = liftPidf.update(world.plant().liftPos, target, now); + double power = liftPidf.update(world.sensors().liftPos(), target, now); world.liftMotor().setPower(power); } } @@ -241,7 +241,7 @@ public void publishState() { public void publishTelemetry() { metrics.countLoopIteration(); telemetryMeter.tick(); - orchestrator.publish("telemetry/lift", world.plant().liftPos); + orchestrator.publish("telemetry/lift", world.sensors().liftPos()); world.telemetry().update(); } } From 6b640d24a07783e1f388cbe8eaecfb1a48688cbf Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 25 Sep 2026 23:10:57 -0600 Subject: [PATCH 2/3] bench: address CodeRabbit review (RMSE freeze, fairness, transparency) - Main.runPair: freeze RMSE tracking before runner.stop() so style-dependent shutdown contamination cannot enter the scored window. - MicroBench: surface the modeled Lynx bus cost transparently as micro.sim.busWrite / micro.sim.busRead / micro.sim.busBulkRead2 in the micro table; mock-device-write gate continues to measure the bare mock bookkeeping only (the gate's purpose is preventing mock noise from masking framework overhead; the bus is modeled hardware on the scenario path, not mock overhead). - SolversS2: drive() now negates getLeftX() and alignOffset so the full drive law is the sign-mirror of RawS2, preserving the forward-positive fairness rule across all four styles. - SynapseS1/S2/S3 publishTelemetry: now performs the same batch work as RawS3/RawMtS3 (sensor reads + addData for the visible values + update), so the message-path allocation + bus transaction cost is consistent across styles. - Report Known confounds: note the modeled bus/DS/GC structure. - README gate-2 text: clarify that the gate measures mock bookkeeping only and points to the new micro.sim.bus* entries for the modeled hardware cost. --- benchmarks/README.md | 23 +++++++++++-------- .../aaravlabs/synapse/bench/harness/Main.java | 2 +- .../synapse/bench/harness/MicroBench.java | 21 +++++++++++++++++ .../synapse/bench/harness/Report.java | 2 ++ .../synapse/bench/solverslib/SolversS2.java | 2 +- .../synapse/bench/synapse/SynapseS1.java | 4 +++- .../synapse/bench/synapse/SynapseS2.java | 4 +++- .../synapse/bench/synapse/SynapseS3.java | 6 ++++- 8 files changed, 50 insertions(+), 14 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 80faa31..3db8f36 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -106,15 +106,20 @@ Automated and enforced by `run`, `verifyFrameworkClasses`, `verifyMockBudget`: project output — and **never** from `benchmarks/build/classes` (which would mean a vendored reimplementation). A smoke dispatch (Orchestrator + GamepadAdaptor + CommandScheduler.run()) must execute. -2. **Mock budget.** `micro.sim.deviceWrite` measures `SimMotor.setPower` *as used - on the measured path* (volatile plant write + `System.nanoTime()` + the - latency-probe pairing). It must cost **< 5 % of the smallest framework-level - dispatch measurement** — here the end-to-end paths the device write sits inside: - `micro.publish.subscribers{1,8}`, `micro.publish.annotationSubscriber`, - `micro.hardware.run`, `micro.hardware.call`. Local primitive micros - (`recordLatest`, `schedulerRun`, `buttonRead`) are optimization targets and - deliberately not budget references: they contain no dispatch hop for the mock - to hide in. Current budget use ≈ 1 %. +2. **Mock budget.** `micro.sim.deviceWrite` measures `SimMotor.setPower` + *the mock bookkeeping* — the volatile plant write + `System.nanoTime()` + + the latency-probe pairing — and must cost **< 5 % of the smallest + framework-level dispatch measurement** (`micro.publish.subscribers{1,8}`, + `micro.publish.annotationSubscriber`, `micro.hardware.run`, + `micro.hardware.call`). Local primitive micros (`recordLatest`, + `schedulerRun`, `buttonRead`) are optimization targets and deliberately not + budget references: they contain no dispatch hop for the mock to hide in. + The modeled Lynx bus transfer (`LynxBus.WRITE_NANOS`, `READ_NANOS`, + `BULK_*`) is layered onto device writes *separately* on the scenario path; + it is intentional hardware latency, not mock noise, so it is excluded from + this gate. Its cost is reported transparently under + `micro.sim.busWrite`, `micro.sim.busRead`, and `micro.sim.busBulkRead2` + in the micro table. Current mock-budget use ≈ 1–2 %. 3. **Structural review rule** (checked automatically and by reviewers): `shared/` contains zero `com.aaravlabs.synapse.*` / `com.seattlesolvers.solverslib.*` dispatch types, and style packages contain zero classes named like diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java index b62a5b3..17e215d 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java @@ -235,8 +235,8 @@ private static Report.Pair runPair(Scenario scenario, String style, Config cfg) metrics.startWindow(); Thread.sleep(cfg.measureMs()); metrics.endWindow(); - runner.stop(); world.plant().freezeTracking(); + runner.stop(); double liftRmse = world.plant().liftRmse(); double headingRmse = world.plant().headingRmse(); Hist.Snapshot latency = metrics.probeSnapshot("actuation"); diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/MicroBench.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/MicroBench.java index decae92..08759c1 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/MicroBench.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/MicroBench.java @@ -88,6 +88,9 @@ public Map runAll() { out.put("micro.gamepad.adaptorPoll", batch("micro.gamepad.adaptorPoll", this::gamepadAdaptorPoll)); out.putAll(solverslibBenches()); out.put("micro.sim.deviceWrite", batch("micro.sim.deviceWrite", this::simDeviceWrite)); + out.put("micro.sim.busWrite", batch("micro.sim.busWrite", this::simBusWrite)); + out.put("micro.sim.busRead", batch("micro.sim.busRead", this::simBusRead)); + out.put("micro.sim.busBulkRead2", batch("micro.sim.busBulkRead2", this::simBusBulkRead2)); return out; } @@ -396,6 +399,24 @@ private long simDeviceWrite() { return 1; } + private long simBusWrite() { + simBus.write(); + return 1; + } + + private long simBusRead() { + simBus.read(); + return 1; + } + + private long simBusBulkRead2() { + simBus.bulkRead(2); + return 1; + } + + private final com.aaravlabs.synapse.bench.shared.LynxBus simBus = + new com.aaravlabs.synapse.bench.shared.LynxBus(); + private static final class SimDeviceHolder { final SimPlant plant; final LatencyProbe probe; diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java index 4387186..bf771df 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java @@ -244,6 +244,8 @@ public static String markdown(Map doc) { sb.append(" so the load profile is machine-independent while absolute times are real.\n"); sb.append("- Axis sign conventions differ by framework API (raw fields vs GamepadEx);\n"); sb.append(" workload shape (reads + arithmetic + writes) is identical across styles.\n"); + sb.append("- Lynx bus, DS packet link, sensor quantization/noise/dropouts, and small-heap\n"); + sb.append(" GC pressure are modeled Control-Hub-class structure, not mock overhead.\n"); sb.append("- See `benchmarks/README.md` for methodology and fairness rules.\n"); return sb.toString(); } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java index 5f5ddbb..88c8877 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java @@ -162,7 +162,7 @@ public void execute() { if (now - last < DRIVE_PERIOD_NANOS) return; last = now; driveMeter.tick(now); - drive.drive(gamepad1Ex.getLeftY(), -gamepad1Ex.getRightY(), gamepad1Ex.getLeftX(), alignOffset); + drive.drive(gamepad1Ex.getLeftY(), -gamepad1Ex.getRightY(), -gamepad1Ex.getLeftX(), -alignOffset); } @Override diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS1.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS1.java index 76046b1..0ce198a 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS1.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS1.java @@ -125,7 +125,9 @@ private final class TelemetryNode extends Node { public void publishTelemetry() { metrics.countLoopIteration(); telemetryMeter.tick(); - orchestrator.publish("telemetry/servo", world.servo().getPosition()); + double servo = world.servo().getPosition(); + orchestrator.publish("telemetry/servo", servo); + world.telemetry().addData("servo", servo); world.telemetry().update(); } } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java index 8952227..c5d8bc1 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java @@ -199,7 +199,9 @@ private final class TelemetryNode extends Node { public void publishTelemetry() { metrics.countLoopIteration(); telemetryMeter.tick(); - orchestrator.publish("telemetry/lift", world.sensors().liftPos()); + double lift = world.sensors().liftPos(); + orchestrator.publish("telemetry/lift", lift); + world.telemetry().addData("lift", lift); world.telemetry().update(); } } diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java index 1031e36..d8dc4de 100644 --- a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java @@ -241,7 +241,11 @@ public void publishState() { public void publishTelemetry() { metrics.countLoopIteration(); telemetryMeter.tick(); - orchestrator.publish("telemetry/lift", world.sensors().liftPos()); + double lift = world.sensors().liftPos(); + double heading = world.sensors().heading(); + orchestrator.publish("telemetry/lift", lift); + world.telemetry().addData("lift", lift); + world.telemetry().addData("heading", heading); world.telemetry().update(); } } From 3c098f60f1931ad2f98aa5a02f15ac5e781ec937 Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 25 Sep 2026 23:23:15 -0600 Subject: [PATCH 3/3] bench: refresh README methodology for the new bus + DS + sensor model CodeRabbit review: the Metrics section still described latency stamping as the stimulus thread's job, and fairness rule 3 still described device writes as volatile stores. The code now stamps inside DriverStation.flush, pays LynxBus cost on every device write + sensor read, and QuantizeNoiseDropout is the sensor layer. Update the methodology, the latency/tasks row, and fairness rule 3 so the README matches the implemented model. --- benchmarks/README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index 3db8f36..af4866c 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -51,8 +51,8 @@ truth. Treat every number here as a ratio on this box, never as an absolute. | metric | how it is recorded | | --- | --- | -| input→actuation latency | `LatencyProbe`: the stimulus thread stamps t0 immediately before flipping a `Gamepad` volatile field and declares the expected power direction (mapped per style sign convention); `SimMotor.setPower`/`SimServo.setPosition` stamp t1 **inside the framework-invoked device write** and the first write whose power crosses a threshold in that direction pairs with it (stick steps alternate sign, so stale writes cannot pair). Reported p50/p90/p99/max. | -| per-task rate + jitter | `TaskMeter.tick()` at the top of each periodic body the framework invokes (`loop()` body, `Command.execute()`/`Subsystem.periodic()`, `@RunPeriodically` method): achieved Hz vs target Hz and p99 inter-tick period. | +| input→actuation latency | `LatencyProbe`: the stimulus stamp t0 is taken inside a `DriverStation` packet callback the moment a queued gamepad mutation is flushed at the next ~25 ms packet boundary, with the expected power direction (mapped per style sign convention). `SimMotor.setPower`/`SimServo.setPosition` stamp t1 **inside the framework-invoked device write** and the first write whose power crosses a threshold in that direction pairs with it (stick steps alternate sign, so stale writes cannot pair). Reported p50/p90/p99/p99.9/max. | +| per-task rate + jitter | `TaskMeter.tick()` at the top of each periodic body the framework invokes (`loop()` body, `Command.execute()`/`Subsystem.periodic()`, `@RunPeriodically` method): achieved Hz vs target Hz, p99 / p99.9 inter-tick period, and deadline-miss percentage (period > 1.5× target). | | control quality | `SimPlant` scores lift position and drivetrain heading against the same `Setpoints` trajectory plus `headingBias` the styles command — world-side bookkeeping, never inside a dispatch segment. | | loop throughput | framework pump iterations/s: the `while (active) { loop(); }` body for `raw`/`rawmt`, one `CommandScheduler.run()` call for `solverslib`, and every framework-invoked handler body (`@RunPeriodically`, `@SubscribedTo`, `@RunnableAction`) for `synapse` — its "scheduler ticks", since it has no single pump | | allocation rate | optional (`--alloc`): `com.sun.management.ThreadMXBean` bytes/s summed across all live threads (the measured style threads included). | @@ -75,7 +75,16 @@ harness, not the framework. requirements-based command conflicts; Synapse nodes/annotations/topics/actions). There is no shared "robot program" abstraction. 3. **The world is not a framework.** `shared/` has zero Synapse/SolversLib dispatch - types (gate 3). Device writes are constant-cost volatile stores into `SimPlant`. + types (gate 3). Device writes pay **serialized `LynxBus` transfer cost** + (write ≈ 3 µs spin under a shared lock, read ≈ 5 µs, bulk read of two + registers ≈ 6 µs) plus the volatile store into `SimPlant`; the bus is a + single shared link so RawMt's multiple threads pay contention, and bulk reads + are cheaper than the same reads one-at-a-time. The mock-budget gate measures + only the device-write mock bookkeeping against framework dispatch; the bus + cost is modeled hardware and is reported transparently as + `micro.sim.bus{Write,Read,BulkRead2}`. Sensor reads (lift, heading) are + quantized, carry correlated noise keyed off the plant sim clock, and + occasionally drop out by returning the last good sample. 4. **Real code on the measured path.** Synapse runs the real `OrchestratorImpl` dispatch, `AnnotationBinder` reflective invoke, `GamepadAdaptor.poll()`, `HardwareActions.run/call`, `Topic` synchronization. SolversLib runs the real