diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 00000000..01d114cb --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,366 @@ +name: Publish Benchmarks + +on: + push: + tags: + - "*" + workflow_dispatch: + inputs: + ref: + description: "Git ref to benchmark" + required: false + default: "main" + version: + description: "Version label to publish; defaults to the selected ref" + required: false + default: "" + pattern: + description: "JMH benchmark pattern" + required: false + default: "JRTCompare2Benchmark" + jmhArgs: + description: "Extra JMH arguments; when provided, CLI options override benchmark annotations" + required: false + default: "" + publish: + description: "Publish results to GitHub Pages" + required: false + type: boolean + default: true + +concurrency: + group: benchmarks-${{ github.ref }} + cancel-in-progress: false + +env: + DEFAULT_JMH_PATTERN: JRTCompare2Benchmark + BENCHMARK_SITE_URL: https://jawk.io/ + +jobs: + benchmarks: + name: Run JMH benchmarks + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + publish: ${{ steps.select.outputs.publish }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Temurin JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: maven + + - name: Select benchmark ref + id: select + shell: bash + env: + INPUT_REF: ${{ inputs.ref }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_PATTERN: ${{ inputs.pattern }} + INPUT_JMH_ARGS: ${{ inputs.jmhArgs }} + INPUT_PUBLISH: ${{ inputs.publish }} + run: | + if [ "${{ github.event_name }}" = "push" ]; then + ref="${GITHUB_REF_NAME}" + version="${GITHUB_REF_NAME}" + pattern="${DEFAULT_JMH_PATTERN}" + jmh_args="" + publish="true" + else + ref="${INPUT_REF}" + version="${INPUT_VERSION}" + pattern="${INPUT_PATTERN}" + jmh_args="${INPUT_JMH_ARGS}" + publish="${INPUT_PUBLISH}" + fi + + if [ -z "${ref}" ]; then + ref="main" + fi + if [ -z "${version}" ]; then + version="${ref#refs/tags/}" + fi + if [ -z "${pattern}" ]; then + pattern="${DEFAULT_JMH_PATTERN}" + fi + if [[ "${ref}" == -* ]]; then + echo "Benchmark ref must not start with '-'." >&2 + exit 1 + fi + + git fetch --tags --force origin + if commit="$(git rev-parse --verify --quiet -- "${ref}^{commit}")"; then + git checkout --detach "${commit}" + else + git fetch origin -- "${ref}" + commit="$(git rev-parse --verify --quiet -- "FETCH_HEAD^{commit}")" + git checkout --detach "${commit}" + fi + + safe_version="$(printf '%s' "${version}" | tr '/\\ ' '---' | tr -cd 'A-Za-z0-9._-')" + if [ -z "${safe_version}" ]; then + safe_version="$(git rev-parse --short HEAD)" + fi + + { + echo "BENCHMARK_REF=${ref}" + echo "BENCHMARK_VERSION=${version}" + echo "BENCHMARK_VERSION_PATH=${safe_version}" + echo "BENCHMARK_COMMIT=$(git rev-parse HEAD)" + echo "JMH_PATTERN=${pattern}" + echo "JMH_ARGS=${jmh_args}" + echo "PUBLISH_BENCHMARKS=${publish}" + } >> "${GITHUB_ENV}" + echo "publish=${publish}" >> "${GITHUB_OUTPUT}" + + - name: Display environment details + shell: bash + run: | + java -version + mvn -version + echo "Benchmark ref: ${BENCHMARK_REF}" + echo "Benchmark version: ${BENCHMARK_VERSION}" + echo "Benchmark commit: ${BENCHMARK_COMMIT}" + echo "JMH pattern: ${JMH_PATTERN}" + echo "JMH arguments: ${JMH_ARGS}" + + - name: Build benchmark jar + shell: bash + run: mvn -B -V -Pbenchmark -DskipTests package + + - name: Run JMH + shell: bash + run: | + mkdir -p target/benchmarks + benchmark_jar="$(find target -maxdepth 1 -name '*-benchmarks.jar' -print -quit)" + if [ -z "${benchmark_jar}" ]; then + echo "Benchmark jar was not created." >&2 + exit 1 + fi + java -jar "${benchmark_jar}" "${JMH_PATTERN}" ${JMH_ARGS} -rf json -rff target/benchmarks/jmh-results.json + + - name: Capture benchmark environment + shell: bash + run: | + node <<'NODE' + const childProcess = require('child_process'); + const fs = require('fs'); + const os = require('os'); + + function commandOutput(command) { + return childProcess.execSync(command, { encoding: 'utf8', shell: '/bin/bash' }); + } + + const cpuModels = [...new Set(os.cpus().map(cpu => cpu.model))]; + const environment = { + version: process.env.BENCHMARK_VERSION, + versionPath: process.env.BENCHMARK_VERSION_PATH, + ref: process.env.BENCHMARK_REF, + commit: process.env.BENCHMARK_COMMIT, + runDate: new Date().toISOString(), + workflowRunUrl: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`, + benchmarkPattern: process.env.JMH_PATTERN, + jmhArgs: process.env.JMH_ARGS, + runner: { + os: process.env.RUNNER_OS, + arch: process.env.RUNNER_ARCH, + name: process.env.RUNNER_NAME + }, + system: { + platform: os.platform(), + release: os.release(), + arch: os.arch(), + cpuCount: os.cpus().length, + cpus: cpuModels, + totalMemory: os.totalmem() + }, + javaVersion: commandOutput('java -version 2>&1'), + mavenVersion: commandOutput('mvn -version 2>&1') + }; + + fs.mkdirSync('target/benchmarks', { recursive: true }); + fs.writeFileSync('target/benchmarks/environment.json', `${JSON.stringify(environment, null, 2)}\n`); + NODE + + - name: Upload benchmark artifacts + uses: actions/upload-artifact@v7 + with: + name: benchmarks-${{ env.BENCHMARK_VERSION_PATH }} + path: | + target/benchmarks/jmh-results.json + target/benchmarks/environment.json + + - name: Build Maven site + if: env.PUBLISH_BENCHMARKS == 'true' + shell: bash + run: mvn -B -V verify site + + - name: Restore published benchmark history + if: env.PUBLISH_BENCHMARKS == 'true' + shell: bash + run: | + node <<'NODE' + const fs = require('fs'); + const http = require('http'); + const https = require('https'); + const path = require('path'); + + const siteBase = new URL(process.env.BENCHMARK_SITE_URL || 'https://jawk.io/'); + const siteRoot = 'target/site'; + const resolvedSiteRoot = path.resolve(siteRoot); + const indexPath = path.join(siteRoot, 'benchmarks', 'index.json'); + + function getText(url) { + return new Promise(resolve => { + const client = url.protocol === 'http:' ? http : https; + const request = client.get(url, response => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + resolve(getText(new URL(response.headers.location, url))); + return; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + response.resume(); + resolve(null); + return; + } + response.setEncoding('utf8'); + let data = ''; + response.on('data', chunk => { + data += chunk; + }); + response.on('end', () => resolve(data)); + }); + request.on('error', () => resolve(null)); + request.setTimeout(15000, () => { + request.destroy(); + resolve(null); + }); + }); + } + + async function restoreFile(relativePath) { + if (!relativePath) { + return; + } + const normalizedPath = relativePath.replace(/^\/+/, ''); + if (normalizedPath.includes('\\') || normalizedPath.split('/').includes('..')) { + return; + } + const content = await getText(new URL(normalizedPath, siteBase)); + if (content === null) { + return; + } + const outputPath = path.resolve(resolvedSiteRoot, normalizedPath); + const outputRelativePath = path.relative(resolvedSiteRoot, outputPath); + if (outputRelativePath.startsWith('..') || path.isAbsolute(outputRelativePath)) { + return; + } + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, content); + } + + (async () => { + const indexText = await getText(new URL('benchmarks/index.json', siteBase)); + if (indexText === null) { + return; + } + + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, indexText); + + let index; + try { + index = JSON.parse(indexText); + } catch (error) { + return; + } + + for (const release of index.releases || []) { + await restoreFile(release.jmh); + await restoreFile(release.environment); + } + })().catch(error => { + console.log(`Benchmark history restore failed: ${error.message}`); + }); + NODE + + - name: Add benchmark JSON to Maven site + if: env.PUBLISH_BENCHMARKS == 'true' + shell: bash + run: | + release_dir="target/site/benchmarks/releases/${BENCHMARK_VERSION_PATH}" + mkdir -p "${release_dir}" + cp target/benchmarks/jmh-results.json "${release_dir}/jmh-results.json" + cp target/benchmarks/environment.json "${release_dir}/environment.json" + + node <<'NODE' + const fs = require('fs'); + const path = require('path'); + + const indexPath = 'target/site/benchmarks/index.json'; + const version = process.env.BENCHMARK_VERSION; + const versionPath = process.env.BENCHMARK_VERSION_PATH; + const environment = JSON.parse(fs.readFileSync(`target/site/benchmarks/releases/${versionPath}/environment.json`, 'utf8')); + let index = { latest: null, releases: [] }; + + if (fs.existsSync(indexPath)) { + try { + index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); + } catch (error) { + index = { latest: null, releases: [] }; + } + } + + const entry = { + version, + versionPath, + date: environment.runDate.substring(0, 10), + commit: environment.commit, + workflowRunUrl: environment.workflowRunUrl, + jmh: path.posix.join('benchmarks', 'releases', versionPath, 'jmh-results.json'), + environment: path.posix.join('benchmarks', 'releases', versionPath, 'environment.json') + }; + + index.releases = (index.releases || []).filter(release => release.versionPath !== versionPath); + index.releases.unshift(entry); + index.latest = version; + + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`); + NODE + + - name: Configure GitHub Pages + if: env.PUBLISH_BENCHMARKS == 'true' + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: env.PUBLISH_BENCHMARKS == 'true' + uses: actions/upload-pages-artifact@v4 + with: + path: target/site + + deploy: + name: Deploy benchmark site + needs: benchmarks + if: needs.benchmarks.outputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab702bf..db2ba071 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,19 @@ mvn site For more information about Maven-generated documentation, visit [Maven Site plugin](https://maven.apache.org/plugins/maven-site-plugin/) and [Sentry Maven Skin](https://sentrysoftware.github.io/sentry-maven-skin/). +## Benchmarks + +Microbenchmarks use [JMH](https://openjdk.org/projects/code-tools/jmh/) and are built only when the benchmark profile is enabled: + +```bash +mvn -Pbenchmark -DskipTests package +java -jar target/jawk--benchmarks.jar JRTCompare2Benchmark +``` + +Release benchmark data is published by the *Publish Benchmarks* GitHub Action. It builds the Maven site, writes JMH +JSON files under `target/site/benchmarks/releases//`, updates `target/site/benchmarks/index.json`, and +deploys the complete site through GitHub Pages. + ## Development workflows Please follow this workflow to contribute to this project: diff --git a/pom.xml b/pom.xml index ad22e6a2..2096c1f2 100644 --- a/pom.xml +++ b/pom.xml @@ -75,6 +75,7 @@ 2026-05-07T07:56:38Z + 1.37 @@ -148,6 +149,7 @@ ${project.build.sourceDirectory} ${project.build.testSourceDirectory} ${project.basedir}/src/it/java + ${project.basedir}/src/jmh/java **/*.java @@ -166,6 +168,7 @@ main/java/**/*.java test/java/**/*.java it/java/**/*.java + jmh/java/**/*.java @@ -379,4 +382,89 @@ + + + benchmark + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-benchmark-source + generate-sources + + add-source + + + + ${project.basedir}/src/jmh/java + + + + + + + maven-compiler-plugin + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + maven-shade-plugin + + + benchmarks + package + + shade + + + true + benchmarks + false + + + *:* + + META-INF/*.SF + META-INF/*.RSA + + + + + + + org.openjdk.jmh.Main + + + + + + + + + + + diff --git a/src/jmh/java/io/jawk/jrt/JRTCompare2Benchmark.java b/src/jmh/java/io/jawk/jrt/JRTCompare2Benchmark.java new file mode 100644 index 00000000..2c67bf77 --- /dev/null +++ b/src/jmh/java/io/jawk/jrt/JRTCompare2Benchmark.java @@ -0,0 +1,197 @@ +package io.jawk.jrt; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * Jawk + * ჻჻჻჻჻჻ + * Copyright (C) 2006 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Microbenchmarks for {@link JRT#compare2(Object, Object, int)} operand shapes + * that are common in comparison-heavy AWK programs. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS) +@Fork(2) +@State(Scope.Thread) +public class JRTCompare2Benchmark { + + private Object longLeft; + private Object longRightEqual; + private Object longRightGreater; + private Object doubleLeft; + private Object doubleRightEqual; + private Object doubleRightGreater; + private Object longAsDoubleRightEqual; + private Object stringLeft; + private Object stringRightEqual; + private Object stringRightGreater; + private Object numericStringLeft; + private Object numericStringRightEqual; + private Object numericStringRightGreater; + private Object nonNumericString; + + /** + * Initializes benchmark operands as mutable state fields so the benchmark body + * does not feed compile-time constants directly to the JIT. + */ + @Setup(Level.Trial) + public void setup() { + this.longLeft = Long.valueOf(123L); + this.longRightEqual = Long.valueOf(123L); + this.longRightGreater = Long.valueOf(456L); + this.doubleLeft = Double.valueOf(123.25D); + this.doubleRightEqual = Double.valueOf(123.25D); + this.doubleRightGreater = Double.valueOf(456.5D); + this.longAsDoubleRightEqual = Double.valueOf(123.0D); + this.stringLeft = "alpha"; + this.stringRightEqual = "alpha"; + this.stringRightGreater = "bravo"; + this.numericStringLeft = "123"; + this.numericStringRightEqual = "123.0"; + this.numericStringRightGreater = "456"; + this.nonNumericString = "2x"; + } + + /** + * Measures equality for two boxed {@link Long} operands. + * + * @return the comparison result + */ + @Benchmark + public boolean longEquals() { + return JRT.compare2(this.longLeft, this.longRightEqual, 0); + } + + /** + * Measures less-than comparison for two boxed {@link Long} operands. + * + * @return the comparison result + */ + @Benchmark + public boolean longLessThan() { + return JRT.compare2(this.longLeft, this.longRightGreater, -1); + } + + /** + * Measures equality for two boxed {@link Double} operands. + * + * @return the comparison result + */ + @Benchmark + public boolean doubleEquals() { + return JRT.compare2(this.doubleLeft, this.doubleRightEqual, 0); + } + + /** + * Measures less-than comparison for two boxed {@link Double} operands. + * + * @return the comparison result + */ + @Benchmark + public boolean doubleLessThan() { + return JRT.compare2(this.doubleLeft, this.doubleRightGreater, -1); + } + + /** + * Measures equality for a boxed {@link Long} and boxed {@link Double}. + * + * @return the comparison result + */ + @Benchmark + public boolean mixedLongDoubleEquals() { + return JRT.compare2(this.longLeft, this.longAsDoubleRightEqual, 0); + } + + /** + * Measures equality for two equal plain {@link String} operands. + * + * @return the comparison result + */ + @Benchmark + public boolean stringEquals() { + return JRT.compare2(this.stringLeft, this.stringRightEqual, 0); + } + + /** + * Measures less-than comparison for two plain {@link String} operands. + * + * @return the comparison result + */ + @Benchmark + public boolean stringLessThan() { + return JRT.compare2(this.stringLeft, this.stringRightGreater, -1); + } + + /** + * Measures equality for two numeric string operands. + * + * @return the comparison result + */ + @Benchmark + public boolean numericStringEquals() { + return JRT.compare2(this.numericStringLeft, this.numericStringRightEqual, 0); + } + + /** + * Measures less-than comparison for two numeric string operands. + * + * @return the comparison result + */ + @Benchmark + public boolean numericStringLessThan() { + return JRT.compare2(this.numericStringLeft, this.numericStringRightGreater, -1); + } + + /** + * Measures equality for a boxed {@link Long} and a numeric string operand. + * + * @return the comparison result + */ + @Benchmark + public boolean mixedLongNumericStringEquals() { + return JRT.compare2(this.longLeft, this.numericStringRightEqual, 0); + } + + /** + * Measures fallback string comparison for a numeric operand and a non-numeric + * string. + * + * @return the comparison result + */ + @Benchmark + public boolean mixedLongNonNumericStringLessThan() { + return JRT.compare2(this.longLeft, this.nonNumericString, -1); + } +} diff --git a/src/main/java/io/jawk/jrt/JRT.java b/src/main/java/io/jawk/jrt/JRT.java index 5f1384be..90980e33 100644 --- a/src/main/java/io/jawk/jrt/JRT.java +++ b/src/main/java/io/jawk/jrt/JRT.java @@ -646,11 +646,25 @@ public static long parseFieldNumber(Object obj) { * @return a boolean */ public static boolean compare2(Object o1, Object o2, int mode) { - // Pre-compute String representations of o1 and o2 + boolean o1Numeric = o1 instanceof Number; + boolean o2Numeric = o2 instanceof Number; + double o1Number; + double o2Number; + + if (o1Numeric && o2Numeric) { + o1Number = ((Number) o1).doubleValue(); + o2Number = ((Number) o2).doubleValue(); + if (mode < 0) { + return o1Number < o2Number; + } else if (mode == 0) { + return o1Number == o2Number; + } else { + return o1Number > o2Number; + } + } String o1String = o1.toString(); String o2String = o2.toString(); - // Special case of Uninitialized objects if (o1 instanceof UninitializedObject) { if (o2 instanceof UninitializedObject || "".equals(o2String) || "0".equals(o2String)) { return mode == 0; @@ -666,39 +680,105 @@ public static boolean compare2(Object o1, Object o2, int mode) { } } - if (!(o1 instanceof Number)) { + if (o1String.equals(o2String)) { + return mode == 0; + } + + if (o1Numeric) { + o1Number = ((Number) o1).doubleValue(); + } else if (isComparisonNumber(o1String)) { try { - o1 = new BigDecimal(o1String).doubleValue(); + o1Number = new BigDecimal(o1String).doubleValue(); + o1Numeric = true; } catch (NumberFormatException nfe) { // NOPMD - ignore invalid number - // ignore invalid number, handled by subsequent logic + o1Number = 0.0; } + } else { + o1Number = 0.0; } - if (!(o2 instanceof Number)) { + if (o2Numeric) { + o2Number = ((Number) o2).doubleValue(); + } else if (isComparisonNumber(o2String)) { try { - o2 = new BigDecimal(o2String).doubleValue(); + o2Number = new BigDecimal(o2String).doubleValue(); + o2Numeric = true; } catch (NumberFormatException nfe) { // NOPMD - ignore invalid number - // ignore invalid number, handled by subsequent logic + o2Number = 0.0; } + } else { + o2Number = 0.0; } - if ((o1 instanceof Number) && (o2 instanceof Number)) { + if (o1Numeric && o2Numeric) { if (mode < 0) { - return ((Number) o1).doubleValue() < ((Number) o2).doubleValue(); + return o1Number < o2Number; } else if (mode == 0) { - return ((Number) o1).doubleValue() == ((Number) o2).doubleValue(); + return o1Number == o2Number; } else { - return ((Number) o1).doubleValue() > ((Number) o2).doubleValue(); + return o1Number > o2Number; } + } + + if (mode == 0) { + return o1String.equals(o2String); + } else if (mode < 0) { + return o1String.compareTo(o2String) < 0; } else { - // string equality usually occurs more often than natural ordering comparison - if (mode == 0) { - return o1String.equals(o2String); - } else if (mode < 0) { - return o1String.compareTo(o2String) < 0; - } else { - return o1String.compareTo(o2String) > 0; + return o1String.compareTo(o2String) > 0; + } + } + + static boolean isComparisonNumber(String value) { + int index = 0; + int length = value.length(); + + if (length == 0) { + return false; + } + + char current = value.charAt(index); + if (current == '+' || current == '-') { + index++; + if (index == length) { + return false; } } + + boolean digitFound = false; + while (index < length && value.charAt(index) >= '0' && value.charAt(index) <= '9') { + index++; + digitFound = true; + } + + if (index < length && value.charAt(index) == '.') { + index++; + while (index < length && value.charAt(index) >= '0' && value.charAt(index) <= '9') { + index++; + digitFound = true; + } + } + + if (!digitFound) { + return false; + } + + if (index < length && (value.charAt(index) == 'e' || value.charAt(index) == 'E')) { + index++; + if (index < length && (value.charAt(index) == '+' || value.charAt(index) == '-')) { + index++; + } + + boolean exponentDigitFound = false; + while (index < length && value.charAt(index) >= '0' && value.charAt(index) <= '9') { + index++; + exponentDigitFound = true; + } + if (!exponentDigitFound) { + return false; + } + } + + return index == length; } /** diff --git a/src/site/resources/css/site.css b/src/site/resources/css/site.css index 2b774e38..16ad9906 100644 --- a/src/site/resources/css/site.css +++ b/src/site/resources/css/site.css @@ -672,3 +672,61 @@ body.dark { height: 10px; } } + +/* Benchmarks */ +.benchmarks-page[ng-cloak], +.benchmarks-page [ng-cloak], +.benchmarks-page[data-ng-cloak], +.benchmarks-page [data-ng-cloak] { + display: none !important; +} + +.benchmark-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin: 1rem 0; +} + +.benchmark-toolbar label { + margin-bottom: 0; +} + +.benchmark-toolbar .form-control { + width: auto; + min-width: 16rem; +} + +.benchmark-note { + margin-bottom: 1rem; +} + +.benchmark-table code { + white-space: nowrap; +} + +.benchmark-number { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.benchmark-environment { + margin-top: 1rem; +} + +.benchmark-environment-output { + white-space: pre-wrap; +} + +@media (max-width: 767px) { + .benchmark-toolbar { + align-items: stretch; + flex-direction: column; + } + + .benchmark-toolbar .form-control, + .benchmark-toolbar .btn { + width: 100%; + } +} diff --git a/src/site/resources/js/benchmarks.js b/src/site/resources/js/benchmarks.js new file mode 100644 index 00000000..f4686cc9 --- /dev/null +++ b/src/site/resources/js/benchmarks.js @@ -0,0 +1,170 @@ +/* + * Jawk + * Copyright (C) 2006 - 2026 MetricsHub + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + */ +(function() { + 'use strict'; + + function installBenchmarkSupport(angular) { + var siteModule = angular.module('sentry.site'); + var host = document.getElementById('benchmark-application'); + if (host) { + host.innerHTML = ''; + } + + siteModule.component('jawkBenchmarkReport', { + controller: ['$http', BenchmarkController], + controllerAs: '$ctrl', + templateUrl: 'templates/benchmarks.html' + }); + } + + function BenchmarkController($http) { + var $ctrl = this; + var indexUrl = 'benchmarks/index.json'; + + $ctrl.loading = true; + $ctrl.releaseLoading = false; + $ctrl.error = null; + $ctrl.releaseError = null; + $ctrl.releases = []; + $ctrl.results = []; + $ctrl.environment = null; + + $ctrl.metric = function(result) { + return result.primaryMetric || {}; + }; + + $ctrl.shortBenchmarkName = function(benchmark) { + var lastSeparatorIndex; + var classSeparatorIndex; + if (!benchmark) { + return ''; + } + lastSeparatorIndex = benchmark.lastIndexOf('.'); + if (lastSeparatorIndex < 0) { + return benchmark; + } + classSeparatorIndex = benchmark.lastIndexOf('.', lastSeparatorIndex - 1); + return benchmark.substring(classSeparatorIndex + 1); + }; + + $ctrl.forkCount = function(result) { + var metric = $ctrl.metric(result); + return metric.rawData ? metric.rawData.length : ''; + }; + + $ctrl.releaseKey = function(release) { + return release.versionPath || release.version; + }; + + $ctrl.releaseLabel = function(release) { + if (!release) { + return ''; + } + return release.date ? release.version + ' (' + release.date + ')' : release.version; + }; + + $ctrl.runnerLabel = function(environment) { + if (!environment || !environment.runner) { + return ''; + } + return [environment.runner.os, environment.runner.arch, environment.runner.name].filter(Boolean).join(' / '); + }; + + $ctrl.systemLabel = function(environment) { + var label; + var system; + if (!environment || !environment.system) { + return ''; + } + system = environment.system; + label = [system.platform, system.release, system.arch].filter(Boolean).join(' / '); + if (system.cpuCount) { + label += ' / ' + system.cpuCount + ' CPUs'; + } + if (system.cpus && system.cpus.length) { + label += ' / ' + system.cpus.join(', '); + } + return label; + }; + + $ctrl.loadRelease = function(release) { + if (!release) { + return; + } + + $ctrl.releaseLoading = true; + $ctrl.releaseError = null; + $ctrl.results = []; + $ctrl.environment = null; + + $http.get(release.jmh, { cache: false }).then(function(response) { + $ctrl.results = response.data || []; + $ctrl.results.sort(function(left, right) { + return left.benchmark.localeCompare(right.benchmark); + }); + }, function() { + $ctrl.releaseError = 'Benchmark results could not be loaded for ' + release.version + '.'; + }).finally(function() { + $ctrl.releaseLoading = false; + }); + + if (release.environment) { + $http.get(release.environment, { cache: false }).then(function(response) { + $ctrl.environment = response.data; + }); + } + }; + + $http.get(indexUrl, { cache: false }).then(function(response) { + var data = response.data || {}; + var latest = data.latest; + var releases = data.releases || []; + var selected = releases.length ? releases[0] : null; + var index; + + $ctrl.releases = releases; + for (index = 0; index < releases.length; index++) { + if (releases[index].version === latest) { + selected = releases[index]; + break; + } + } + + if (!selected) { + $ctrl.error = 'No published benchmark releases were found in ' + indexUrl + '.'; + return; + } + + $ctrl.selectedRelease = selected; + $ctrl.loadRelease(selected); + }, function() { + $ctrl.error = 'No benchmark index is published yet. Release benchmarks will create ' + indexUrl + '.'; + }).finally(function() { + $ctrl.loading = false; + }); + } + + function showAngularMissing() { + var host = document.getElementById('benchmark-application'); + if (host) { + host.innerHTML = '
AngularJS is not available, so benchmark results cannot be loaded dynamically.
'; + } + } + + function installWhenReady() { + if (!window.angular) { + showAngularMissing(); + return; + } + installBenchmarkSupport(window.angular); + } + + document.addEventListener('DOMContentLoaded', installWhenReady); +}()); diff --git a/src/site/resources/templates/benchmarks.html b/src/site/resources/templates/benchmarks.html new file mode 100644 index 00000000..77ab2e5b --- /dev/null +++ b/src/site/resources/templates/benchmarks.html @@ -0,0 +1,101 @@ + +
+
+ Loading published benchmark index... +
+ +
+ {{$ctrl.error}} +
+ +
+ + +
+ Compare releases only after checking the environment details below. JMH scores depend on JVM version, JVM flags, + operating system, runner load, and CPU model. +
+ +
+ Loading benchmark results... +
+ +
+ {{$ctrl.releaseError}} +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
BenchmarkModeScoreErrorUnitsForks
{{$ctrl.shortBenchmarkName(result.benchmark)}}{{result.mode}}{{$ctrl.metric(result).score | number:3}}{{$ctrl.metric(result).scoreError | number:3}}{{$ctrl.metric(result).scoreUnit}}{{$ctrl.forkCount(result)}}
+
+ +

Benchmark Environment

+ +
+
+
Version
+
{{$ctrl.environment.version || $ctrl.selectedRelease.version}}
+
Commit
+
{{$ctrl.environment.commit}}
+
Run date
+
{{$ctrl.environment.runDate}}
+
Runner
+
{{$ctrl.runnerLabel($ctrl.environment)}}
+
System
+
{{$ctrl.systemLabel($ctrl.environment)}}
+
JMH pattern
+
{{$ctrl.environment.benchmarkPattern}}
+
JMH arguments
+
{{$ctrl.environment.jmhArgs}}
+
Workflow run
+
{{$ctrl.environment.workflowRunUrl}}
+
+ +

Java

+
{{$ctrl.environment.javaVersion}}
+ +

Maven

+
{{$ctrl.environment.mavenVersion}}
+
+
+
diff --git a/src/site/site.xml b/src/site/site.xml index a1398600..8714e539 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -76,6 +76,7 @@ + diff --git a/src/site/xhtml/benchmarks.xhtml b/src/site/xhtml/benchmarks.xhtml new file mode 100644 index 00000000..9446272d --- /dev/null +++ b/src/site/xhtml/benchmarks.xhtml @@ -0,0 +1,23 @@ + + + + Performance Benchmarks + + + + +
+

Performance Benchmarks

+ +

+ Jawk publishes release-time JMH benchmark + results for selected hot runtime paths. These numbers are useful for comparisons within the same benchmark + environment, but should not be treated as absolute performance claims across different JVMs, operating systems, + or hardware. +

+ +
+ +
+ + diff --git a/src/test/java/io/jawk/JRTTest.java b/src/test/java/io/jawk/JRTTest.java index 6dbb3957..744e6dd5 100644 --- a/src/test/java/io/jawk/JRTTest.java +++ b/src/test/java/io/jawk/JRTTest.java @@ -115,6 +115,47 @@ public void testCompare2Uninitialized() { assertTrue(JRT.compare2(1, new UninitializedObject(), 1)); } + @Test + public void testCompare2NumericOperands() { + assertTrue(JRT.compare2(3L, 3L, 0)); + assertFalse(JRT.compare2(3L, 4L, 0)); + assertTrue(JRT.compare2(3L, 4L, -1)); + assertTrue(JRT.compare2(4L, 3L, 1)); + assertTrue(JRT.compare2(3.5D, 3.5D, 0)); + assertTrue(JRT.compare2(3L, 3.0D, 0)); + assertTrue(JRT.compare2(3L, 3.5D, -1)); + } + + @Test + public void testCompare2NumericStrings() { + assertTrue(JRT.compare2("3", "3.0", 0)); + assertTrue(JRT.compare2("3", "4.0", -1)); + assertTrue(JRT.compare2("4.0", "3", 1)); + assertTrue(JRT.compare2("1e2", "100", 0)); + assertTrue(JRT.compare2("+.5", "0.5", 0)); + assertTrue(JRT.compare2("5.", "5.0", 0)); + assertTrue(JRT.compare2("-1E+2", "-100", 0)); + assertFalse(JRT.compare2("1e2147483649", "2", 0)); + assertTrue(JRT.compare2("1e2147483649", "2", -1)); + } + + @Test + public void testCompare2MixedNumberAndString() { + assertTrue(JRT.compare2(3L, "3.0", 0)); + assertTrue(JRT.compare2("3.0", 3L, 0)); + assertTrue(JRT.compare2(3L, "4", -1)); + assertTrue(JRT.compare2("4", 3L, 1)); + } + + @Test + public void testCompare2FallsBackToStringComparison() { + assertFalse(JRT.compare2("3x", "3.0", 0)); + assertTrue(JRT.compare2("3x", "4", -1)); + assertTrue(JRT.compare2(10L, "2x", -1)); + assertTrue(JRT.compare2("2x", 10L, 1)); + assertTrue(JRT.compare2("1e", "2", -1)); + } + @Test public void testSpawnProcessCat() throws Exception { Assume.assumeFalse(IS_WINDOWS); diff --git a/src/test/java/io/jawk/jrt/JRTComparisonNumberTest.java b/src/test/java/io/jawk/jrt/JRTComparisonNumberTest.java new file mode 100644 index 00000000..b3e3c6c2 --- /dev/null +++ b/src/test/java/io/jawk/jrt/JRTComparisonNumberTest.java @@ -0,0 +1,69 @@ +package io.jawk.jrt; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * Jawk + * ჻჻჻჻჻჻ + * Copyright (C) 2006 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class JRTComparisonNumberTest { + + @Test + public void testIsComparisonNumberAcceptsDecimalForms() { + assertTrue(JRT.isComparisonNumber("0")); + assertTrue(JRT.isComparisonNumber("123")); + assertTrue(JRT.isComparisonNumber("+123")); + assertTrue(JRT.isComparisonNumber("-123")); + assertTrue(JRT.isComparisonNumber("123.45")); + assertTrue(JRT.isComparisonNumber("+.5")); + assertTrue(JRT.isComparisonNumber("5.")); + assertTrue(JRT.isComparisonNumber("1e2")); + assertTrue(JRT.isComparisonNumber("1E2")); + assertTrue(JRT.isComparisonNumber("-1E+2")); + assertTrue(JRT.isComparisonNumber("+1e-2")); + } + + @Test + public void testIsComparisonNumberRejectsInvalidDecimalForms() { + assertFalse(JRT.isComparisonNumber("")); + assertFalse(JRT.isComparisonNumber("+")); + assertFalse(JRT.isComparisonNumber("-")); + assertFalse(JRT.isComparisonNumber(".")); + assertFalse(JRT.isComparisonNumber("e1")); + assertFalse(JRT.isComparisonNumber("1e")); + assertFalse(JRT.isComparisonNumber("1e+")); + assertFalse(JRT.isComparisonNumber("1e-")); + assertFalse(JRT.isComparisonNumber("1.2.3")); + assertFalse(JRT.isComparisonNumber("123abc")); + assertFalse(JRT.isComparisonNumber("abc123")); + } + + @Test + public void testIsComparisonNumberRejectsHexadecimal() { + assertFalse(JRT.isComparisonNumber("0x0")); + assertFalse(JRT.isComparisonNumber("0x10")); + assertFalse(JRT.isComparisonNumber("-0x10")); + assertFalse(JRT.isComparisonNumber("+0XFF")); + } +}