package software.amazon.event.ruler;
import org.junit.Assume;
import org.junit.Test;
import software.amazon.event.ruler.MachineComplexityEvaluatorCorpus.Entry;
import software.amazon.event.ruler.MachineComplexityEvaluatorCorpus.Tier;
import software.amazon.event.ruler.MachineComplexityEvaluatorCorpus.WalkCountingEvaluator;
import com.sun.management.ThreadMXBean;
import java.io.BufferedWriter;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static software.amazon.event.ruler.MachineComplexityEvaluatorCorpus.PRODUCTION_CAP;
import static software.amazon.event.ruler.MachineComplexityEvaluatorCorpus.UNCAPPED;
/**
* Timings of {@link MachineComplexityEvaluator} over the {@link MachineComplexityEvaluatorCorpus}: for every machine,
* build time, then evaluation time, allocation and reported complexity under a production-style cap and uncapped.
*
*
Gated off by default, like {@link StableBenchmarks}. Flip it on with {@code -Druler.perf.run=true}:
*
*
* # Default: 3 warmup + 10 measured evaluations per row, except the seconds-long (HEAVY) machines' uncapped row,
* # which runs once (about 20 s of the run); the minutes-long (EXPENSIVE) machine runs capped only. About half a
* # minute in total, and a 2 GB heap suffices.
* mvn test -Dtest=MachineComplexityEvaluatorBenchmarks -Druler.perf.run=true
*
* # Custom pass counts (the HEAVY and EXPENSIVE uncapped rows stay single-pass)
* mvn test -Dtest=MachineComplexityEvaluatorBenchmarks -Druler.perf.run=true \
* -Druler.perf.warmup=5 -Druler.perf.measure=20
*
* # Only machines whose name contains one of the given substrings
* mvn test -Dtest=MachineComplexityEvaluatorBenchmarks -Druler.perf.run=true \
* -Druler.perf.only=quamina,multi-star
*
* # Uncapped evaluation of the heavy machines: false = none, true = HEAVY tier (default), all = EXPENSIVE tier too
* # (the 16-pattern leading-star set: about three minutes and 7 GB of live heap, e.g. -DargLine=-Xmx12g)
* mvn test -Dtest=MachineComplexityEvaluatorBenchmarks -Druler.perf.run=true -Druler.perf.heavy=all
*
* # Write a CSV for diffing two revisions
* mvn test -Dtest=MachineComplexityEvaluatorBenchmarks -Druler.perf.run=true \
* -Druler.perf.csv=/tmp/evaluator-perf.csv
*
*
* Reading the table: this class asserts {@code complexity} and {@code walks} against the corpus on every row.
* {@link MachineComplexityEvaluatorRegressionTest} checks both fields for every capped row and every FAST uncapped
* row. Compare {@code median_us} and {@code alloc_kb} across revisions on one host. The capped rows are what a consumer
* enforcing a maximum of 10 pays at rule-creation time; the uncapped rows are the cost of reporting the true number,
* which for the leading-star sets grows exponentially with the pattern count.
*/
public class MachineComplexityEvaluatorBenchmarks {
private static final int DEFAULT_WARMUP_PASSES = 3;
private static final int DEFAULT_MEASURE_PASSES = 10;
private static final String ROW_FORMAT = "%-36s %-9s %10d %6d %7d %10.1f %11.1f %11.1f %11.1f %10.1f%n";
/** One row of the report. */
private static final class Row {
final String machine;
final String cap;
final int complexity;
final int walks;
final int passes;
final double buildMedianUs;
final double medianUs;
final double minUs;
final double maxUs;
final double allocKb;
Row(String machine, String cap, int complexity, int walks, int passes, double buildMedianUs, double medianUs,
double minUs, double maxUs, double allocKb) {
this.machine = machine;
this.cap = cap;
this.complexity = complexity;
this.walks = walks;
this.passes = passes;
this.buildMedianUs = buildMedianUs;
this.medianUs = medianUs;
this.minUs = minUs;
this.maxUs = maxUs;
this.allocKb = allocKb;
}
void print() {
System.out.printf(Locale.ROOT, ROW_FORMAT, machine, cap, complexity, walks, passes, buildMedianUs, medianUs,
minUs, maxUs, allocKb);
}
}
@Test
public void evaluatorBenchmarks() throws Exception {
Assume.assumeTrue(
"Skipped: set -Druler.perf.run=true to run the complexity evaluator benchmarks. "
+ "See the class javadoc for options.",
Boolean.getBoolean("ruler.perf.run"));
int warmupPasses = getIntProp("ruler.perf.warmup", DEFAULT_WARMUP_PASSES, 0);
int measurePasses = getIntProp("ruler.perf.measure", DEFAULT_MEASURE_PASSES, 1);
String heavy = System.getProperty("ruler.perf.heavy", "true").trim().toLowerCase(Locale.ROOT);
Tier uncapUpTo;
switch (heavy) {
case "false":
uncapUpTo = Tier.FAST;
break;
case "true":
uncapUpTo = Tier.HEAVY;
break;
case "all":
uncapUpTo = Tier.EXPENSIVE;
break;
default:
throw new IllegalArgumentException("ruler.perf.heavy must be false, true or all, got: " + heavy);
}
List only = onlyFilter();
Path csv = csvPath();
System.out.printf(Locale.ROOT, "%nMachineComplexityEvaluator benchmarks: %s %s, %s %s %s, %d cpus, %d MB heap%n",
System.getProperty("java.vm.name"), System.getProperty("java.version"),
System.getProperty("os.name"), System.getProperty("os.version"), System.getProperty("os.arch"),
Runtime.getRuntime().availableProcessors(), Runtime.getRuntime().maxMemory() / (1024 * 1024));
System.out.printf(Locale.ROOT, "warmup=%d measure=%d heavy=%s only=%s%n%n", warmupPasses, measurePasses, heavy,
only.isEmpty() ? "(all)" : only);
List selected = new ArrayList<>();
for (Entry entry : MachineComplexityEvaluatorCorpus.entries()) {
if (only.isEmpty() || matchesFilter(entry.name, only)) {
selected.add(entry);
}
}
if (selected.isEmpty()) {
System.out.println("no corpus entry matches -Druler.perf.only=" + only);
return;
}
// Compile the evaluator's hot paths before the first measured row: the first machine's few capped passes are
// otherwise far too little work for the JIT, and its numbers straddle interpreted and compiled code. Over every
// fast entry, whatever the filter selected (about a second).
for (Entry entry : MachineComplexityEvaluatorCorpus.entries()) {
if (entry.tier == Tier.FAST) {
Machine machine = entry.build();
machine.evaluateComplexity(new WalkCountingEvaluator(PRODUCTION_CAP));
machine.evaluateComplexity(new WalkCountingEvaluator(UNCAPPED));
}
}
System.out.printf(Locale.ROOT, "%-36s %-9s %10s %6s %7s %10s %11s %11s %11s %10s%n",
"machine", "cap", "complexity", "walks", "passes", "build_us", "median_us", "min_us", "max_us",
"alloc_kb");
List rows = new ArrayList<>();
for (Entry entry : selected) {
double buildMedianUs = measureBuild(entry, warmupPasses, measurePasses);
rows.add(measureAndCheck(entry, PRODUCTION_CAP, warmupPasses, measurePasses, buildMedianUs));
if (entry.tier.compareTo(uncapUpTo) <= 0) {
boolean repeat = entry.tier == Tier.FAST;
rows.add(measureAndCheck(entry, UNCAPPED, repeat ? warmupPasses : 0, repeat ? measurePasses : 1,
buildMedianUs));
}
}
System.out.println();
if (csv != null) {
writeCsv(csv, rows);
System.out.println("CSV written to " + csv);
}
}
/** The {@code ruler.perf.csv} path with its parent directory created, or null when the property is unset. */
private static Path csvPath() throws IOException {
String csvPath = System.getProperty("ruler.perf.csv");
if (csvPath == null || csvPath.trim().isEmpty()) {
return null;
}
Path csv = Paths.get(csvPath.trim());
if (csv.getParent() != null) {
Files.createDirectories(csv.getParent());
}
return csv;
}
/** Comma-separated, case-insensitive substrings from {@code ruler.perf.only}; empty parts are dropped. */
private static List onlyFilter() {
List only = new ArrayList<>();
String onlyProp = System.getProperty("ruler.perf.only");
if (onlyProp != null) {
for (String part : onlyProp.split(",")) {
String needle = part.trim().toLowerCase(Locale.ROOT);
if (!needle.isEmpty()) {
only.add(needle);
}
}
}
return only;
}
private static boolean matchesFilter(String name, List only) {
String lower = name.toLowerCase(Locale.ROOT);
for (String needle : only) {
if (lower.contains(needle)) {
return true;
}
}
return false;
}
/** Median wall time of {@code new Machine() + addRule}, over the measured passes. */
private static double measureBuild(Entry entry, int warmupPasses, int measurePasses) throws Exception {
for (int i = 0; i < warmupPasses; i++) {
entry.build();
}
double[] us = new double[measurePasses];
for (int i = 0; i < us.length; i++) {
long start = System.nanoTime();
entry.build();
us[i] = (System.nanoTime() - start) / 1000.0;
}
return median(us);
}
/**
* Times {@code measurePasses} evaluations of the entry's machine under the given cap after {@code warmupPasses}
* unmeasured ones, asserts the reported complexity and walk count against the corpus, and prints the row.
*/
private static Row measureAndCheck(Entry entry, int maxComplexity, int warmupPasses, int measurePasses,
double buildMedianUs) throws Exception {
Machine machine = entry.build();
for (int i = 0; i < warmupPasses; i++) {
machine.evaluateComplexity(new WalkCountingEvaluator(maxComplexity));
}
ThreadMXBean allocation = allocationCounter();
long threadId = Thread.currentThread().getId();
double[] us = new double[measurePasses];
double[] kb = new double[measurePasses];
int complexity = -1;
int walks = -1;
for (int i = 0; i < measurePasses; i++) {
WalkCountingEvaluator evaluator = new WalkCountingEvaluator(maxComplexity);
long allocatedBefore = allocation == null ? 0 : allocation.getThreadAllocatedBytes(threadId);
long start = System.nanoTime();
complexity = machine.evaluateComplexity(evaluator);
us[i] = (System.nanoTime() - start) / 1000.0;
kb[i] = allocation == null ? Double.NaN
: (allocation.getThreadAllocatedBytes(threadId) - allocatedBefore) / 1024.0;
walks = evaluator.getWalks();
}
String capLabel = maxComplexity == UNCAPPED ? "uncapped" : String.valueOf(maxComplexity);
Row row = new Row(entry.name, capLabel, complexity, walks, measurePasses, buildMedianUs, median(us),
Arrays.stream(us).min().getAsDouble(), Arrays.stream(us).max().getAsDouble(), median(kb));
row.print();
// The corpus pins what an uncapped evaluation reports; a capped one reports the lesser of the cap and that.
assertEquals(entry.name + " complexity under cap " + capLabel,
Math.min(maxComplexity, entry.expectedComplexity), complexity);
assertEquals(entry.name + " ByteMachine walks under cap " + capLabel, entry.expectedWalks, walks);
return row;
}
/** The JVM's per-thread allocation counter, or null when this JVM does not support or has disabled it. */
private static ThreadMXBean allocationCounter() {
java.lang.management.ThreadMXBean threads = ManagementFactory.getThreadMXBean();
if (!(threads instanceof ThreadMXBean)) {
return null;
}
ThreadMXBean allocation = (ThreadMXBean) threads;
return allocation.isThreadAllocatedMemorySupported() && allocation.isThreadAllocatedMemoryEnabled()
? allocation : null;
}
private static void writeCsv(Path path, List rows) throws IOException {
try (BufferedWriter out = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
out.write("machine,cap,complexity,walks,passes,build_us,median_us,min_us,max_us,alloc_kb");
out.newLine();
for (Row row : rows) {
out.write(String.format(Locale.ROOT, "%s,%s,%d,%d,%d,%.1f,%.1f,%.1f,%.1f,%.1f%n", row.machine, row.cap,
row.complexity, row.walks, row.passes, row.buildMedianUs, row.medianUs, row.minUs, row.maxUs,
row.allocKb));
}
}
}
private static int getIntProp(String name, int defaultValue, int minimum) {
String value = System.getProperty(name);
if (value == null || value.trim().isEmpty()) {
return defaultValue;
}
int parsed = Integer.parseInt(value.trim());
if (parsed < minimum) {
throw new IllegalArgumentException(name + " must be at least " + minimum + ", got: " + parsed);
}
return parsed;
}
private static double median(double[] values) {
double[] sorted = values.clone();
Arrays.sort(sorted);
int n = sorted.length;
return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
}
}