Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand All @@ -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
Expand Down Expand Up @@ -106,15 +115,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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`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
Expand Down
4 changes: 3 additions & 1 deletion benchmarks/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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+')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,10 @@ private static void collectScenario(String key, Map<String, Object> b, Map<Strin
List<MetricRef> metrics, List<String> missing) {
Map<String, Object> blat = (Map<String, Object>) b.get("latencyActuationNs");
Map<String, Object> llat = (Map<String, Object>) 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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ private static int cmdRun(String[] args) throws Exception {
Path forkOut = cfg.outDir.resolve("fork-" + i + ".json");
List<String> 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());
Expand Down Expand Up @@ -234,17 +235,19 @@ 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");
List<Metrics.TaskSnapshot> 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<String> filterStyles(List<String> available, List<String> wanted) {
Expand All @@ -268,7 +271,7 @@ private static Report.Pair medianPair(List<Report.Pair> rounds) {
Report.Pair first = rounds.get(0);

Map<String, Object> 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"))));
Expand All @@ -286,6 +289,14 @@ private static Report.Pair medianPair(List<Report.Pair> 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"));
Expand All @@ -299,7 +310,10 @@ private static Report.Pair medianPair(List<Report.Pair> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ public Map<String, Result> 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;
}

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,26 @@ public static final class Pair {
public final Map<String, Object> trackingError;
public final double loopHz;
public final double allocBytesPerSec;
public final long gcCount;
public final long gcMillis;

public Pair(String scenario, String style,
Map<String, Object> latencyActuationNs,
Map<String, Object> taskRates,
Map<String, Object> trackingError,
double loopHz,
double allocBytesPerSec) {
double allocBytesPerSec,
long gcCount,
long gcMillis) {
this.scenario = scenario;
this.style = style;
this.latencyActuationNs = latencyActuationNs;
this.taskRates = taskRates;
this.trackingError = trackingError;
this.loopHz = loopHz;
this.allocBytesPerSec = allocBytesPerSec;
this.gcCount = gcCount;
this.gcMillis = gcMillis;
}

public static Pair fromSnapshot(String scenario, String style,
Expand All @@ -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<String, Object> 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);
Expand All @@ -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);
}
Expand All @@ -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<String, Object> toMap() {
Expand All @@ -86,6 +98,8 @@ public Map<String, Object> toMap() {
m.put("trackingError", trackingError);
m.put("loopHz", loopHz);
m.put("allocBytesPerSec", allocBytesPerSec);
m.put("gcCount", gcCount);
m.put("gcMillis", gcMillis);
return m;
}

Expand All @@ -98,7 +112,9 @@ public static Pair fromMap(Map<String, Object> m) {
(Map<String, Object>) m.get("taskRates"),
(Map<String, Object>) 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());
}
}

Expand Down Expand Up @@ -145,7 +161,8 @@ public static String markdown(Map<String, Object> 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<String, Object> firstRates = new LinkedHashMap<>();
List<Object> scenarios = (List<Object>) doc.get("scenarios");
for (Object o : scenarios) {
Expand All @@ -160,21 +177,33 @@ public static String markdown(Map<String, Object> 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) {
Map<String, Object> p = (Map<String, Object>) o;
Map<String, Object> lat = (Map<String, Object>) p.get("latencyActuationNs");
Map<String, Object> tracking = (Map<String, Object>) p.get("trackingError");
Map<String, Object> rates = (Map<String, Object>) p.get("taskRates");
double maxMiss = 0;
for (Object k : firstRates.keySet()) {
Map<String, Object> rate = (Map<String, Object>) 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<String, Object> rate = (Map<String, Object>) rates.get(k);
sb.append(" | ").append(rate == null ? "—" : fmt(rate.get("achievedHz")));
Expand Down Expand Up @@ -215,6 +244,8 @@ public static String markdown(Map<String, Object> 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();
}
Expand Down
Loading
Loading