Skip to content

Commit 2e8b4f7

Browse files
committed
feat: add Grill JVM benchmark command
1 parent 7f5b838 commit 2e8b4f7

9 files changed

Lines changed: 1769 additions & 59 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,49 @@ The command exits with code `0` when dependencies are up to date and `1` when up
7070
> grill outdated
7171
```
7272

73+
### Benchmarking Wurst functions
74+
75+
`grill benchmark` measures Wurst code in the JVM-hosted Wurst IL interpreter. A benchmark is a package-level, parameterless function annotated with `@benchmark` and returning `int`; its return value must be a stable, workload-derived checksum.
76+
77+
```wurst
78+
package BenchmarkExample
79+
80+
@benchmark function benchmarkName() returns int
81+
var checksum = 0
82+
for i = 0 to 999
83+
checksum = checksum * 31 + i
84+
return checksum
85+
```
86+
87+
The checksum should depend on the work being measured, not on a clock, random value, object identity, or mutable global state. When comparing two implementations, use the same inputs, operation count, and checksum calculation in both functions. A changing checksum indicates a correctness or benchmark-design problem and causes the comparison to fail.
88+
89+
For very small operations, batch many fixed inputs inside one benchmark invocation and return one checksum for the complete batch. This makes the measured workload large enough to distinguish implementations; the runner also reports the calibrated invocation `batchSize` for each fork. Keep correctness tests separate from benchmark functions so `grill test` never discovers or executes benchmarks.
90+
91+
Run all benchmarks or select package/function names with an optional substring filter:
92+
93+
```cmd
94+
> grill benchmark
95+
> grill benchmark Polygon
96+
> grill benchmark Polygon --forks 5 --warmup 5 --iterations 20
97+
> grill benchmark Polygon --format json
98+
> grill benchmark --help
99+
```
100+
101+
Options are:
102+
103+
- `[filter]` — optional substring used to select benchmark names.
104+
- `--forks N` — positive number of isolated compiler JVMs per benchmark, serially (default `3`).
105+
- `--warmup N` — non-negative number of unmeasured warmup samples per fork (default `5`).
106+
- `--iterations N` — positive number of measured samples per fork (default `10`).
107+
- `--format human|json` — concise comparison output or machine-readable `wurst-benchmark-v1` JSON (default `human`).
108+
- `--help` — show benchmark-specific help without loading the project.
109+
110+
Global options such as `-projectDir`, `--quiet`, and `--debug` remain available.
111+
112+
Use JSON when a script needs raw samples, checksums, statistics, and environment metadata; diagnostics are kept off JSON stdout. For credible relative results, keep the machine, OS, Java runtime, compiler and Grill versions, project inputs, fork/warmup/iteration settings, and background load consistent. Pin the process to dedicated CPU cores and avoid thermal or power-state changes where practical; `grill benchmark` does not itself control CPU affinity, frequency scaling, garbage collection, or other host-level noise.
113+
114+
Benchmark results measure the JVM-hosted Wurst IL interpreter. They vary with the machine, JVM, compiler version, host load, and benchmark setup. Use them for controlled side-by-side comparisons under the same conditions, not as absolute Warcraft III, Jass, Lua, or in-game performance numbers.
115+
73116

74117
### Building the project
75118

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
package benchmark
2+
3+
import com.fasterxml.jackson.databind.JsonNode
4+
import com.fasterxml.jackson.databind.json.JsonMapper
5+
import java.nio.file.Files
6+
import java.nio.file.Path
7+
import java.util.Comparator
8+
import java.util.LinkedHashMap
9+
10+
class BenchmarkCoordinator(
11+
private val launcher: BenchmarkProcessLauncher,
12+
commonArguments: List<String>,
13+
private val debug: Boolean = false,
14+
private val compilerIdentity: String = "unknown",
15+
private val grillIdentity: String = "unknown"
16+
) {
17+
private val commonArguments = commonArguments.toList()
18+
private val mapper = JsonMapper.builder().build()
19+
20+
fun run(request: BenchmarkRequest): BenchmarkReport {
21+
val temporaryRoot = Files.createTempDirectory("grill-benchmark-")
22+
try {
23+
val discoveryOutput = temporaryRoot.resolve("discovery.json")
24+
val filterArguments = request.filter
25+
?.takeIf { it.isNotBlank() }
26+
?.let { arrayOf("-benchmarkFilter", it) }
27+
?: emptyArray()
28+
val discoveryArguments = workerArguments(
29+
"-runbenchmarks",
30+
"-benchmarkList",
31+
*filterArguments,
32+
"-benchmarkOutput",
33+
discoveryOutput.toString()
34+
)
35+
val selected = launchAndRead(discoveryArguments, discoveryOutput, "discovery", ::parseDiscovery).value
36+
37+
val forksByBenchmark = LinkedHashMap<String, MutableList<BenchmarkFork>>()
38+
val checksums = mutableMapOf<String, Int>()
39+
var executionIndex = 0
40+
repeat(request.forks) { forkRound ->
41+
val roundNames = if (forkRound % 2 == 0) selected else selected.asReversed()
42+
roundNames.forEach { qualifiedName ->
43+
val output = temporaryRoot.resolve("execution-$executionIndex-$forkRound-${safeFileName(qualifiedName)}.json")
44+
executionIndex++
45+
val arguments = workerArguments(
46+
"-runbenchmarks",
47+
"-benchmarkName",
48+
qualifiedName,
49+
"-benchmarkWarmup",
50+
request.warmup.toString(),
51+
"-benchmarkIterations",
52+
request.iterations.toString(),
53+
"-benchmarkOutput",
54+
output.toString()
55+
)
56+
val parsedExecution = launchAndRead(arguments, output, "execution for $qualifiedName") {
57+
parseExecution(it, qualifiedName, request.iterations)
58+
}
59+
val result = parsedExecution.value
60+
val previousChecksum = checksums.putIfAbsent(qualifiedName, result.checksum)
61+
if (previousChecksum != null && previousChecksum != result.checksum) {
62+
if (!parsedExecution.diagnosticsEmitted) {
63+
parsedExecution.diagnostics.forEach(System.err::println)
64+
}
65+
check(false) { "benchmark checksum changed across forks for $qualifiedName" }
66+
}
67+
val forks = forksByBenchmark.getOrPut(qualifiedName) { mutableListOf() }
68+
forks += BenchmarkFork(forkRound + 1, result.batchSize, result.samplesNanos)
69+
}
70+
}
71+
72+
val aggregates = selected.map { qualifiedName ->
73+
val forks = forksByBenchmark[qualifiedName].orEmpty().toList()
74+
check(forks.size == request.forks) {
75+
"benchmark $qualifiedName did not produce ${request.forks} fork results"
76+
}
77+
val allSamples = forks.flatMap { it.samplesNanos }
78+
BenchmarkAggregate(
79+
qualifiedName = qualifiedName,
80+
checksum = checksums.getValue(qualifiedName),
81+
forks = forks,
82+
statistics = BenchmarkStatistics.fromSamples(allSamples)
83+
)
84+
}
85+
return BenchmarkReport(
86+
environment = BenchmarkEnvironment(
87+
os = System.getProperty("os.name", "unknown"),
88+
jvm = System.getProperty("java.version", "unknown"),
89+
compiler = compilerIdentity,
90+
grill = grillIdentity,
91+
cpuCount = Runtime.getRuntime().availableProcessors()
92+
),
93+
filter = request.filter,
94+
forks = request.forks,
95+
warmup = request.warmup,
96+
iterations = request.iterations,
97+
benchmarks = aggregates
98+
)
99+
} finally {
100+
deleteTree(temporaryRoot)
101+
}
102+
}
103+
104+
fun run(filter: String?, forks: Int, warmup: Int, iterations: Int): BenchmarkReport {
105+
return run(BenchmarkRequest(filter, forks, warmup, iterations))
106+
}
107+
108+
private fun workerArguments(vararg extra: String): List<String> {
109+
val arguments = commonArguments.toMutableList()
110+
if (!arguments.contains("-compactOutput")) {
111+
arguments += "-compactOutput"
112+
}
113+
arguments += extra
114+
return arguments
115+
}
116+
117+
private fun <T> launchAndRead(
118+
arguments: List<String>,
119+
output: Path,
120+
phase: String,
121+
parse: (JsonNode) -> T
122+
): ParsedWorker<T> {
123+
val result = launcher.run(arguments)
124+
var diagnosticsEmitted = false
125+
fun emitDiagnostics() {
126+
if (!diagnosticsEmitted) {
127+
result.output.forEach(System.err::println)
128+
diagnosticsEmitted = true
129+
}
130+
}
131+
if (debug) {
132+
emitDiagnostics()
133+
}
134+
if (result.exitCode != 0) {
135+
emitDiagnostics()
136+
}
137+
check(result.exitCode == 0) { "$phase worker exited with code ${result.exitCode}" }
138+
if (!Files.isRegularFile(output)) {
139+
emitDiagnostics()
140+
check(false) { "$phase worker did not write ${output.fileName}" }
141+
}
142+
val document = try {
143+
mapper.factory.createParser(Files.readString(output)).use { parser ->
144+
val parsed = mapper.readTree<JsonNode>(parser)
145+
check(parser.nextToken() == null) { "$phase worker wrote trailing JSON content" }
146+
parsed
147+
}
148+
} catch (exception: Exception) {
149+
emitDiagnostics()
150+
throw IllegalStateException("$phase worker wrote malformed JSON", exception)
151+
}
152+
if (document == null || !document.isObject) {
153+
emitDiagnostics()
154+
check(false) { "$phase worker JSON must be an object" }
155+
}
156+
return try {
157+
ParsedWorker(parse(document), result.output.toList(), diagnosticsEmitted)
158+
} catch (exception: Exception) {
159+
emitDiagnostics()
160+
throw exception
161+
}
162+
}
163+
164+
private fun parseDiscovery(document: JsonNode): List<String> {
165+
requireExactFields(document, setOf("schema", "mode", "benchmarks"), "discovery")
166+
check(document.requiredText("schema") == BENCHMARK_WORKER_SCHEMA) { "discovery worker schema is invalid" }
167+
check(document.requiredText("mode") == "discovery") { "discovery worker mode is invalid" }
168+
val benchmarks = document["benchmarks"]
169+
check(benchmarks.isArray) { "discovery worker benchmarks must be an array" }
170+
val names = benchmarks.map {
171+
check(it.isTextual && it.textValue().isNotBlank()) { "discovery benchmark name must be non-empty text" }
172+
it.textValue()
173+
}
174+
check(names.size == names.distinct().size) { "discovery returned duplicate benchmark names" }
175+
check(names.isNotEmpty()) { "benchmark discovery selected no benchmarks" }
176+
return names
177+
}
178+
179+
private fun parseExecution(document: JsonNode, expectedName: String, iterations: Int): WorkerExecution {
180+
requireExactFields(
181+
document,
182+
setOf("schema", "mode", "qualifiedName", "checksum", "batchSize", "samplesNanos", "statistics"),
183+
"execution"
184+
)
185+
check(document.requiredText("schema") == BENCHMARK_WORKER_SCHEMA) { "execution worker schema is invalid" }
186+
check(document.requiredText("mode") == "execution") { "execution worker mode is invalid" }
187+
check(document.requiredText("qualifiedName") == expectedName) {
188+
"execution worker name does not match requested benchmark $expectedName"
189+
}
190+
val checksum = document["checksum"]
191+
check(checksum.isIntegralNumber && checksum.canConvertToInt()) { "execution checksum must be an integer" }
192+
val batchSize = document["batchSize"]
193+
check(batchSize.isIntegralNumber && batchSize.canConvertToInt() && batchSize.intValue() > 0) {
194+
"execution batchSize must be positive"
195+
}
196+
val workerStatistics = validateWorkerStatistics(document["statistics"])
197+
val samplesNode = document["samplesNanos"]
198+
check(samplesNode.isArray && samplesNode.size() == iterations) {
199+
"execution samplesNanos must contain exactly $iterations samples"
200+
}
201+
val samples = samplesNode.map {
202+
check(it.isIntegralNumber && it.canConvertToLong() && it.longValue() >= 0) {
203+
"execution samplesNanos must contain non-negative integers"
204+
}
205+
it.longValue()
206+
}
207+
check(workerStatistics == BenchmarkStatistics.fromSamples(samples)) {
208+
"execution statistics do not match samplesNanos"
209+
}
210+
return WorkerExecution(checksum.intValue(), batchSize.intValue(), samples)
211+
}
212+
213+
private fun validateWorkerStatistics(statistics: JsonNode): BenchmarkStatistics {
214+
check(statistics.isObject) { "execution statistics must be an object" }
215+
requireExactFields(
216+
statistics,
217+
setOf("mean", "standardDeviation", "min", "max", "median", "p90", "p95"),
218+
"execution statistics"
219+
)
220+
221+
val mean = statistics["mean"]
222+
check(mean.isNumber && mean.doubleValue().isFinite() && mean.doubleValue() >= 0.0) {
223+
"execution statistics mean must be finite and non-negative"
224+
}
225+
val standardDeviation = statistics["standardDeviation"]
226+
check(
227+
standardDeviation.isNumber &&
228+
standardDeviation.doubleValue().isFinite() &&
229+
standardDeviation.doubleValue() >= 0.0
230+
) {
231+
"execution statistics standardDeviation must be finite and non-negative"
232+
}
233+
val min = statistics.requiredNonNegativeLong("min")
234+
val max = statistics.requiredNonNegativeLong("max")
235+
val median = statistics.requiredNonNegativeLong("median")
236+
val p90 = statistics.requiredNonNegativeLong("p90")
237+
val p95 = statistics.requiredNonNegativeLong("p95")
238+
check(min <= median && median <= p90 && p90 <= p95 && p95 <= max) {
239+
"execution statistics percentiles must be ordered between min and max"
240+
}
241+
check(mean.doubleValue() >= min && mean.doubleValue() <= max) {
242+
"execution statistics mean must be between min and max"
243+
}
244+
return BenchmarkStatistics(
245+
mean = mean.doubleValue(),
246+
standardDeviation = standardDeviation.doubleValue(),
247+
min = min,
248+
max = max,
249+
median = median,
250+
p90 = p90,
251+
p95 = p95
252+
)
253+
}
254+
255+
private fun JsonNode.requiredNonNegativeLong(field: String): Long {
256+
val value = this[field]
257+
check(value.isIntegralNumber && value.canConvertToLong() && value.longValue() >= 0) {
258+
"execution statistics $field must be a non-negative integer"
259+
}
260+
return value.longValue()
261+
}
262+
263+
private fun requireExactFields(document: JsonNode, fields: Set<String>, label: String) {
264+
val actual = document.fieldNames().asSequence().toSet()
265+
check(actual == fields) { "$label worker JSON fields are invalid: expected $fields, got $actual" }
266+
}
267+
268+
private fun JsonNode.requiredText(field: String): String {
269+
val value = this[field]
270+
check(value != null && value.isTextual) { "worker field $field must be text" }
271+
return value.textValue()
272+
}
273+
274+
private fun safeFileName(name: String): String = name.replace(Regex("[^A-Za-z0-9_.-]"), "_")
275+
276+
private fun deleteTree(path: Path) {
277+
if (!Files.exists(path)) return
278+
Files.walk(path).use { stream ->
279+
stream.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) }
280+
}
281+
}
282+
283+
private data class WorkerExecution(
284+
val checksum: Int,
285+
val batchSize: Int,
286+
val samplesNanos: List<Long>
287+
)
288+
289+
private data class ParsedWorker<T>(
290+
val value: T,
291+
val diagnostics: List<String>,
292+
val diagnosticsEmitted: Boolean
293+
)
294+
}

0 commit comments

Comments
 (0)