jdaugherty commented on code in PR #16169: URL: https://github.com/apache/grails-core/pull/16169#discussion_r4146694058
########## grails-testing-support-http-client/src/testFixtures/groovy/org/apache/grails/testing/http/client/bench/AppHttpBench.groovy: ########## @@ -0,0 +1,316 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.testing.http.client.bench + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +import groovy.json.JsonOutput +import groovy.json.JsonException +import groovy.transform.CompileStatic + +/** + * App-level HTTP microbench helper that emits JMH-compatible JSON so results can be compared + * with {@code :grails-test-examples-benchmarks:jmhCompare} using the same methodology as the framework JMH suite. + * + * <p>This type lives in test fixtures, not the published main API of grails-testing-support-http-client. + * + * <p>Measurement model: + * <ul> + * <li>warm up the full Spring Boot stack with {@code warmup} requests (discarded)</li> + * <li>collect {@code samples} timed requests as one raw series</li> + * <li>split the series into {@code forks} equal chunks and report a t-interval over fork means</li> + * <li>also emit a ruler-equivalent tight loop so runner-health gates apply</li> + * </ul> + * + * <p>Enable gated specs with {@code -PappBench=true}. Optional properties: + * {@code appBenchWarmup}, {@code appBenchSamples}, {@code appBenchForks}, + * {@code appBenchOut}, {@code appBenchOutDir}. + */ +@CompileStatic +final class AppHttpBench { + + static final String RULER_BENCHMARK = 'org.apache.grails.benchmarks.ruler.AppBenchCpu.measure' + + private AppHttpBench() { + } + + static boolean enabled() { + Boolean.getBoolean('app.bench') || Boolean.parseBoolean(System.getProperty('appBench', 'false')) + } + + static int warmupCount() { + intProperty('app.bench.warmup', 'appBenchWarmup', 200, true) + } + + static int sampleCount() { + intProperty('app.bench.samples', 'appBenchSamples', 1000, false) + } + + static int forkCount() { + intProperty('app.bench.forks', 'appBenchForks', 2, false) + } + + static Path outputPath(String defaultFileName) { + String configured = System.getProperty('app.bench.out', System.getProperty('appBenchOut', '')) + if (configured) { + return Paths.get(configured) + } + String directory = System.getProperty('app.bench.out.dir', System.getProperty('appBenchOutDir', '')) + Path dir = directory ? Paths.get(directory) : Paths.get('build', 'app-bench') + Files.createDirectories(dir) + return dir.resolve(defaultFileName) + } + + /** + * Time a single request body. The closure must perform the HTTP call and assert success. + * + * @return elapsed nanoseconds + */ + static long timeNanos(Closure<?> request) { + long start = System.nanoTime() + request.call() + return System.nanoTime() - start + } + + /** + * Warm up, sample, and replace {@code out} with a JMH-shaped array containing the HTTP + * benchmark and a ruler-equivalent entry. + * + * @param benchmark fully-qualified-style name, e.g. {@code appbench.latency.FastPing.httpGet} + * @param request closure that performs one successful request + */ + static void measureAndWrite(String benchmark, Path out, Closure<?> request) { + int warmup = Math.max(0, warmupCount()) + int samples = sampleCount() + if (samples < 1) { + throw new IllegalArgumentException("app.bench.samples must be >= 1, was ${samples}") + } + + for (int i = 0; i < warmup; i++) { + request.call() + } + + double[] values = new double[samples] + for (int i = 0; i < samples; i++) { + values[i] = (double) timeNanos(request) + } + + int forks = forkCount() + writeEntries(out, [ + toJmhEntry(benchmark, values, forks), + toJmhEntry(RULER_BENCHMARK, rulerSamples(samples, warmup), forks) + ]) + } + + static Map<String, Object> toJmhEntry(String benchmark, double[] values, int forks) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException('values must contain at least one sample') + } + + int forkCount = Math.min(Math.max(1, forks), values.length) + int perFork = Math.max(1, values.length.intdiv(forkCount)) + List<List<Double>> rawData = new ArrayList<>(forkCount) + int offset = 0 + for (int f = 0; f < forkCount; f++) { + int end = (f == forkCount - 1) ? values.length : Math.min(values.length, offset + perFork) + List<Double> chunk = new ArrayList<>(Math.max(0, end - offset)) + for (int i = offset; i < end; i++) { + chunk.add(values[i]) + } + rawData.add(chunk) + offset = end + } + + double[] forkMeans = new double[rawData.size()] + for (int f = 0; f < rawData.size(); f++) { + forkMeans[f] = mean(rawData.get(f)) + } + double score = mean(forkMeans) + double scoreError = tIntervalError(forkMeans) Review Comment: With the default and CI setting of `forks=2`, this is a t-interval over two chunk means with one degree of freedom, so the critical value is 12.706 and `scoreError` works out to about 6.4 × |mean₁ − mean₂|. The spec shows the scale: four samples with score 25 produce ±127. For real data, a 2% JIT/GC drift between the first and second half of the series gives a ±13% interval on each side, so `BenchmarkComparator.disjoint` can essentially never fire and every verdict collapses to `no clear change`. The ~1–2% indy deltas already posted on #16165 are far inside that band. These "forks" are contiguous slices of one in-JVM series, so they are free to increase. JMH's `scoreError`, which the comparator's disjointness test was designed around, comes from 10 iteration means (2 forks × 5 measurement iterations). Suggest defaulting `appBenchForks` (and the CI value in `benchmark.yml`) to at least 10, and possibly renaming the property to `appBenchIterations`/`chunks`, since nothing is forked. ########## grails-test-examples/benchmarks/src/reportTest/groovy/org/apache/grails/benchmarks/report/AppIndyBenchSpec.groovy: ########## @@ -0,0 +1,275 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.grails.benchmarks.report + +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path + +class AppIndyBenchSpec extends Specification { + + @TempDir + Path temporaryDirectory + + void 'parse requires project-dir and applies defaults'() { + when: + AppIndyBench.Options options = AppIndyBench.parse(['--project-dir', temporaryDirectory.toString()] as String[]) + + then: + options.projectDir == temporaryDirectory.toAbsolutePath().normalize() + options.outputDir == options.projectDir.resolve('build').resolve('app-bench') + options.warmup == 200 + options.samples == 1000 + options.forks == 2 + options.maxWorkers == 4 + } + + void 'parse rejects a missing project-dir'() { + when: + AppIndyBench.parse(['--warmup', '10'] as String[]) + + then: + IllegalArgumentException error = thrown() + error.message.contains('--project-dir is required') + } + + void 'run alternates indy mode per app then compares directory results'() { + given: + Path outputDir = temporaryDirectory.resolve('out') + Path runDir = outputDir.resolve(AppIndyBench.OUTPUT_DIRECTORY_NAME) + Path summary = temporaryDirectory.resolve('summary.md') + List<List<String>> invocations = [] + Files.createDirectories(outputDir.resolve('noindy')) + Files.writeString(outputDir.resolve('noindy').resolve('caller-owned.json'), '[]', StandardCharsets.UTF_8) + Files.writeString(outputDir.resolve('indy-vs-noindy.md'), 'stale report', StandardCharsets.UTF_8) + AppIndyBench.GradleRunner runner = { Path projectDir, List<String> args -> + invocations.add(args) + writeDummyResult(args) + } as AppIndyBench.GradleRunner + + when: + int exit = AppIndyBench.run( + [ + '--project-dir', temporaryDirectory.toString(), + '--output-dir', outputDir.toString(), + '--warmup', '80', + '--samples', '300', + '--forks', '2', + '--max-workers', '3' + ] as String[], + runner, + new GitHubComments(), + [GITHUB_STEP_SUMMARY: summary.toString()] + ) + + then: + exit == 0 + invocations.size() == 6 + invocations[0].contains(':grails-test-examples-latency:integrationTest') + invocations[0].contains('latencyapp.AppBenchFastPingSpec') + invocations[0].contains('-PgrailsIndy=false') + invocations[0].contains('-PappBench=true') + invocations[0].contains('-PappBenchWarmup=80') + invocations[0].contains('-PappBenchSamples=300') + invocations[0].contains('-PappBenchForks=2') + invocations[0].contains('--no-daemon') + !invocations[0].contains('--rerun-tasks') + invocations[0].contains('--max-workers=3') + invocations[0].contains('--project-cache-dir') + invocations[0].any { String argument -> Path.of(argument).endsWith(Path.of(AppIndyBench.OUTPUT_DIRECTORY_NAME, 'project-cache', 'noindy', 'latency')) } + invocations[1].contains('-PgrailsIndy=true') + invocations[1].contains(':grails-test-examples-latency:integrationTest') + invocations[2].contains(':grails-test-examples-app1:integrationTest') + invocations[2].contains('functionaltests.AppBenchInterceptorDemoSpec') + invocations[2].contains('-PgrailsIndy=true') + invocations[3].contains('-PgrailsIndy=false') + invocations[3].contains(':grails-test-examples-app1:integrationTest') + invocations[4].contains('-PgrailsIndy=false') + invocations[4].contains(':grails-test-examples-gsp-layout:integrationTest') + invocations[5].contains('-PgrailsIndy=true') + invocations[5].contains(':grails-test-examples-gsp-layout:integrationTest') + invocations.each { List<String> arguments -> + boolean indy = arguments.contains('-PgrailsIndy=true') + String outputArgument = arguments.find { String argument -> argument.startsWith('-PappBenchOut=') } + Path result = Path.of(outputArgument.substring('-PappBenchOut='.length())) + result.parent == runDir.resolve(indy ? 'indy' : 'noindy') Review Comment: These two comparisons live inside an `each` closure, so Spock does not treat them as assertions; the closure evaluates the booleans and discards them. I changed this line to `result.parent == runDir.resolve('bogus-never-exists')` and the spec still passes. The mode/directory correlation this block is meant to guard is currently only caught indirectly by the `IMPROVED` check on the report (because the dummy scores differ per mode). Either prefix each with `assert`, or rewrite them as top-level `every { }` expressions so a wrong `--project-cache-dir` or `-PappBenchOut` routing fails here with a useful message. ########## grails-test-examples/benchmarks/build.gradle: ########## @@ -131,6 +131,27 @@ tasks.register('jmhCompare', JavaExec) { mainClass = 'org.apache.grails.benchmarks.report.JmhCompare' } +tasks.register('appIndyBench', JavaExec) { + group = 'verification' + description = 'Runs app-level HTTP benches for grailsIndy=false then true and compares JMH-shaped JSON.' + classpath = sourceSets.report.runtimeClasspath + mainClass = 'org.apache.grails.benchmarks.report.AppIndyBench' + def warmup = providers.gradleProperty('appBenchWarmup').orElse('200') + def samples = providers.gradleProperty('appBenchSamples').orElse('1000') + def forks = providers.gradleProperty('appBenchForks').orElse('2') + def maxWorkers = providers.gradleProperty('appBenchMaxWorkers').orElse('4') + def outputDir = providers.gradleProperty('appBenchOutDir') + .orElse(rootProject.layout.projectDirectory.dir('build/app-bench').asFile.absolutePath) Review Comment: Nit: a relative `-PappBenchOutDir=foo` is passed through unchanged and `AppIndyBench.parse` resolves it against the JavaExec working directory, which is `grails-test-examples/benchmarks`, not the root, so `foo` lands somewhere different from where the default (`<root>/build/app-bench`) suggests. Resolving the property through `rootProject.layout.projectDirectory.dir(...)` before handing it over would make both forms consistent. While there: the README table lists the default as `build/app-bench`, but the JSON and `indy-vs-noindy.md` actually land under `build/app-bench/run/`; worth saying so. ########## grails-test-examples/benchmarks/src/report/groovy/org/apache/grails/benchmarks/report/AppIndyBench.groovy: ########## @@ -0,0 +1,337 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.grails.benchmarks.report + +import groovy.transform.CompileStatic + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.util.concurrent.TimeUnit + +@CompileStatic +class AppIndyBench { + + static final String OUTPUT_DIRECTORY_NAME = 'run' + + static final List<App> APPS = [ + new App('latency', ':grails-test-examples-latency:integrationTest', 'latencyapp.AppBenchFastPingSpec'), + new App('app1', ':grails-test-examples-app1:integrationTest', 'functionaltests.AppBenchInterceptorDemoSpec'), + new App('gsp-layout', ':grails-test-examples-gsp-layout:integrationTest', 'org.example.grails.layout.AppBenchDemoRenderTextSpec') + ].asImmutable() + + static void main(String[] args) { + int exit = run(args, new WrapperGradleRunner()) + if (exit != 0) { + System.exit(exit) + } + } + + static int run(String[] args, GradleRunner runner) { + return run(args, runner, new GitHubComments(), System.getenv()) + } + + static int run(String[] args, GradleRunner runner, CommentPoster poster, Map<String, String> environment) { + try { + Options options = parse(args) + Path runDir = recreateOwnedDirectory(options.outputDir) + Path noindyDir = Files.createDirectories(runDir.resolve('noindy')) + Path indyDir = Files.createDirectories(runDir.resolve('indy')) + Path report = runDir.resolve('indy-vs-noindy.md') + boolean nestedFailed = false + + APPS.eachWithIndex { App app, int index -> + List<String> modes = index % 2 == 0 ? ['false', 'true'] : ['true', 'false'] + modes.each { String indy -> + Path modeDir = indy == 'true' ? indyDir : noindyDir + Path out = modeDir.resolve(app.name + '.json') + try { + runner.run(options.projectDir, gradleArgs(options, app, indy, out)) + } catch (Exception error) { + nestedFailed = true + System.err.println("Nested Gradle failed for ${app.name} with grailsIndy=${indy}: ${error.message}") + error.printStackTrace(System.err) + } + if (!Files.isRegularFile(out)) { + nestedFailed = true + System.err.println(missingResultMessage(out)) + } + } + } + + int compareExit = JmhCompare.run( + ['--base', noindyDir.toString(), '--head', indyDir.toString(), '--output', report.toString()] as String[], + poster, + environment + ) + if (compareExit != 0 || nestedFailed) { + appendFallbackStepSummary(environment, 'JmhCompare could not produce an app indy benchmark comparison. Nested result files were retained as artifacts for diagnosis.') + } else { + appendStepSummary(report, environment) Review Comment: Nit: the success path appends the raw comparison, whose first line is `### JMH Benchmark Report`, while the failure path writes `## App indy benchmark comparison`. On a PR that also ran the JMH job the step summaries read as two JMH reports. Suggest prefixing the same `## App indy benchmark comparison` heading here so the two are distinguishable. ########## grails-testing-support-http-client/src/testFixtures/groovy/org/apache/grails/testing/http/client/bench/AppHttpBench.groovy: ########## @@ -0,0 +1,316 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.testing.http.client.bench + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +import groovy.json.JsonOutput +import groovy.json.JsonException +import groovy.transform.CompileStatic + +/** + * App-level HTTP microbench helper that emits JMH-compatible JSON so results can be compared + * with {@code :grails-test-examples-benchmarks:jmhCompare} using the same methodology as the framework JMH suite. + * + * <p>This type lives in test fixtures, not the published main API of grails-testing-support-http-client. + * + * <p>Measurement model: + * <ul> + * <li>warm up the full Spring Boot stack with {@code warmup} requests (discarded)</li> + * <li>collect {@code samples} timed requests as one raw series</li> + * <li>split the series into {@code forks} equal chunks and report a t-interval over fork means</li> + * <li>also emit a ruler-equivalent tight loop so runner-health gates apply</li> + * </ul> + * + * <p>Enable gated specs with {@code -PappBench=true}. Optional properties: + * {@code appBenchWarmup}, {@code appBenchSamples}, {@code appBenchForks}, + * {@code appBenchOut}, {@code appBenchOutDir}. + */ +@CompileStatic +final class AppHttpBench { + + static final String RULER_BENCHMARK = 'org.apache.grails.benchmarks.ruler.AppBenchCpu.measure' + + private AppHttpBench() { + } + + static boolean enabled() { + Boolean.getBoolean('app.bench') || Boolean.parseBoolean(System.getProperty('appBench', 'false')) + } + + static int warmupCount() { + intProperty('app.bench.warmup', 'appBenchWarmup', 200, true) + } + + static int sampleCount() { + intProperty('app.bench.samples', 'appBenchSamples', 1000, false) + } + + static int forkCount() { + intProperty('app.bench.forks', 'appBenchForks', 2, false) + } + + static Path outputPath(String defaultFileName) { + String configured = System.getProperty('app.bench.out', System.getProperty('appBenchOut', '')) + if (configured) { + return Paths.get(configured) + } + String directory = System.getProperty('app.bench.out.dir', System.getProperty('appBenchOutDir', '')) + Path dir = directory ? Paths.get(directory) : Paths.get('build', 'app-bench') + Files.createDirectories(dir) + return dir.resolve(defaultFileName) + } + + /** + * Time a single request body. The closure must perform the HTTP call and assert success. + * + * @return elapsed nanoseconds + */ + static long timeNanos(Closure<?> request) { + long start = System.nanoTime() + request.call() + return System.nanoTime() - start + } + + /** + * Warm up, sample, and replace {@code out} with a JMH-shaped array containing the HTTP + * benchmark and a ruler-equivalent entry. + * + * @param benchmark fully-qualified-style name, e.g. {@code appbench.latency.FastPing.httpGet} + * @param request closure that performs one successful request + */ + static void measureAndWrite(String benchmark, Path out, Closure<?> request) { + int warmup = Math.max(0, warmupCount()) + int samples = sampleCount() + if (samples < 1) { + throw new IllegalArgumentException("app.bench.samples must be >= 1, was ${samples}") + } + + for (int i = 0; i < warmup; i++) { + request.call() + } + + double[] values = new double[samples] + for (int i = 0; i < samples; i++) { + values[i] = (double) timeNanos(request) + } + + int forks = forkCount() + writeEntries(out, [ + toJmhEntry(benchmark, values, forks), + toJmhEntry(RULER_BENCHMARK, rulerSamples(samples, warmup), forks) + ]) + } + + static Map<String, Object> toJmhEntry(String benchmark, double[] values, int forks) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException('values must contain at least one sample') + } + + int forkCount = Math.min(Math.max(1, forks), values.length) + int perFork = Math.max(1, values.length.intdiv(forkCount)) + List<List<Double>> rawData = new ArrayList<>(forkCount) + int offset = 0 + for (int f = 0; f < forkCount; f++) { + int end = (f == forkCount - 1) ? values.length : Math.min(values.length, offset + perFork) + List<Double> chunk = new ArrayList<>(Math.max(0, end - offset)) + for (int i = offset; i < end; i++) { + chunk.add(values[i]) + } + rawData.add(chunk) + offset = end + } + + double[] forkMeans = new double[rawData.size()] + for (int f = 0; f < rawData.size(); f++) { + forkMeans[f] = mean(rawData.get(f)) + } + double score = mean(forkMeans) + double scoreError = tIntervalError(forkMeans) + + Map<String, Object> percentiles = new LinkedHashMap<>() + double[] sorted = Arrays.copyOf(values, values.length) + Arrays.sort(sorted) + percentiles.put('0.0', sorted[0]) + percentiles.put('50.0', percentile(sorted, 0.50d)) + percentiles.put('90.0', percentile(sorted, 0.90d)) + percentiles.put('95.0', percentile(sorted, 0.95d)) + percentiles.put('99.0', percentile(sorted, 0.99d)) + percentiles.put('100.0', sorted[sorted.length - 1]) + + Map<String, Object> primary = new LinkedHashMap<>() + primary.put('score', score) + primary.put('scoreError', scoreError) + primary.put('scoreConfidence', [score - scoreError, score + scoreError]) + primary.put('scorePercentiles', percentiles) + primary.put('scoreUnit', 'ns/op') + primary.put('rawData', rawData) + + Map<String, Object> entry = new LinkedHashMap<>() + entry.put('jmhVersion', 'app-bench-1.0') + entry.put('benchmark', benchmark) + entry.put('mode', 'avgt') + entry.put('threads', 1) + entry.put('forks', forkCount) + entry.put('jdkVersion', System.getProperty('java.version', 'unknown')) + entry.put('vmName', System.getProperty('java.vm.name', 'unknown')) + entry.put('vmVersion', System.getProperty('java.vm.version', 'unknown')) + entry.put('warmupIterations', 1) + entry.put('warmupTime', "${warmupCount()} reqs") + entry.put('measurementIterations', values.length) + entry.put('measurementTime', '1 req') + entry.put('primaryMetric', primary) + entry.put('secondaryMetrics', Collections.emptyMap()) + return entry + } + + static void writeEntries(Path out, List<Map<String, Object>> entries) { + if (Files.exists(out)) { + String existing = Files.readString(out, StandardCharsets.UTF_8).trim() + if (!existing) { + throw new IllegalStateException("Truncated or invalid bench output: ${out}") + } + Object parsed + try { + parsed = new groovy.json.JsonSlurper().parseText(existing) + } catch (JsonException error) { + throw new IllegalStateException("Truncated or invalid bench output: ${out}", error) + } + if (!(parsed instanceof List)) { + throw new IllegalStateException("Refusing to overwrite non-JSON-array bench output: ${out}") + } + } + Path parent = out.getParent() + if (parent != null) { + Files.createDirectories(parent) + } + Files.writeString(out, JsonOutput.prettyPrint(JsonOutput.toJson(entries)), StandardCharsets.UTF_8) + } + + static int intProperty(String primary, String fallback, int defaultValue, boolean allowZero) { + String raw = System.getProperty(primary) + String name = primary + if (raw == null) { + raw = System.getProperty(fallback) + name = fallback + } + if (raw == null) { + return defaultValue + } + return parseDecimalInt(raw, name, allowZero) + } + + private static int parseDecimalInt(String raw, String name, boolean allowZero) { + int value + try { + value = Integer.parseInt(raw) + } catch (NumberFormatException ignored) { + throw new IllegalArgumentException("${name} must be a decimal integer, was '${raw}'") + } + if (value < 0 || (!allowZero && value < 1)) { + throw new IllegalArgumentException("${name} must be ${allowZero ? '>= 0' : '>= 1'}, was ${value}") + } + return value + } + + private static double[] rulerSamples(int samples, int warmup) { + for (int i = 0; i < warmup; i++) { + rulerNanos() + } + double[] values = new double[samples] + for (int i = 0; i < samples; i++) { + values[i] = (double) rulerNanos() + } + return values + } + + private static long rulerNanos() { + long start = System.nanoTime() + long acc = 0L + for (int i = 0; i < 64; i++) { Review Comment: This ruler does 64 integer adds between two `System.nanoTime()` calls, so it measures clock granularity rather than CPU throughput (C2 also folds the loop to a constant, since the `acc < 0` guard is statically false). A quick probe of this exact loop on JDK 21 after 200k warm-up calls: mean 22 ns, 58% of samples exactly 0 ns, the rest quantized at 41/42 ns. Between two separate JVMs (the noindy and indy nested builds) that ratio will swing well past the 5% threshold `ReportRenderer` uses to print the "runner was unstable" warning, so the health gate either fires spuriously or says nothing about the runner. `CpuRulerBenchmark` in the JMH suite runs 1,024 LCG rounds (~1 µs) with state carried across calls so it cannot be folded. Suggest the app ruler do the same amount of the same work (mirror `integerArithmetic`, keep `state` in a static field) so it sits well above clock resolution and is comparable with the JMH ruler. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
