"use strict"; /* * Copyright (C) 2018-2024 Apple Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF * THE POSSIBILITY OF SUCH DAMAGE. */ const measureTotalTimeAsSubtest = false; // Once we move to preloading all resources, it would be good to turn this on. const defaultIterationCount = 120; const defaultWorstCaseCount = 4; if (!JetStreamParams.prefetchResources) console.warn("Disabling resource prefetching! All compressed files must have been decompressed using `npm run decompress`"); if (!isInBrowser && JetStreamParams.prefetchResources) { // Use the wasm compiled zlib as a polyfill when decompression stream is // not available in JS shells. load("./wasm/zlib/shell.js"); // Load a polyfill for TextEncoder/TextDecoder in shells. Used when // decompressing a prefetched resource and converting it to text. load("./utils/polyfills/fast-text-encoding/1.0.3/text.js"); } // Used for the promise representing the current benchmark run. this.currentResolve = null; this.currentReject = null; function displayCategoryScores() { document.body.classList.add("details"); } function getIterationCount(plan) { if (plan.name in JetStreamParams.testIterationCountMap) return JetStreamParams.testIterationCountMap[plan.name]; if (JetStreamParams.testIterationCount) return JetStreamParams.testIterationCount; if (plan.iterations) return plan.iterations; return defaultIterationCount; } function getWorstCaseCount(plan) { if (plan.name in JetStreamParams.testWorstCaseCountMap) return JetStreamParams.testWorstCaseCountMap[plan.name]; if (JetStreamParams.testWorstCaseCount) return JetStreamParams.testWorstCaseCount; if (plan.worstCaseCount !== undefined) return plan.worstCaseCount; return defaultWorstCaseCount; } if (isInBrowser) { document.onkeydown = (keyboardEvent) => { const key = keyboardEvent.key; if (key === "d" || key === "D") displayCategoryScores(); }; } function sum(values) { console.assert(values instanceof Array); let sum = 0; for (let x of values) sum += x; return sum; } function mean(values) { const totalSum = sum(values) return totalSum / values.length; } function geomeanScore(values) { console.assert(values instanceof Array); let product = 1; for (let x of values) product *= x; const score = product ** (1 / values.length); // Allow 0 for uninitialized subScores(). console.assert(score >= 0, `Got invalid score: ${score}`) return score; } function toScore(timeValue) { return 5000 / Math.max(timeValue, 1); } function toTimeValue(score) { return 5000 / score; } function updateUI() { return new Promise((resolve) => { if (isInBrowser) requestAnimationFrame(() => setTimeout(resolve, 0)); else resolve(); }); } function uiFriendlyNumber(num) { if (Number.isInteger(num)) return num; return num.toFixed(2); } function uiFriendlyScore(num) { return uiFriendlyNumber(num); } function uiFriendlyDuration(time) { return `${time.toFixed(2)} ms`; } const LABEL_PADDING = 45; function shellFriendlyLabel(label) { return `${label}`.padEnd(LABEL_PADDING); } const VALUE_PADDING = 11; function shellFriendlyDuration(time) { return `${uiFriendlyDuration(time)} `.padStart(VALUE_PADDING); } function shellFriendlyScore(time) { return `${uiFriendlyScore(time)} pts`.padStart(VALUE_PADDING); } // Files can be zlib compressed to reduce the size of the JetStream source code. // We don't use http compression because we support running from the shell and // don't want to require a complicated server setup. // // zlib was chosen because we already have it in tree for the wasm-zlib test. function isCompressed(name) { return name.endsWith(".z"); } function uncompressedName(name) { console.assert(isCompressed(name)); return name.slice(0, -2); } // TODO: Cleanup / remove / merge. This is only used for caching loads in the // non-browser setting. In the browser we use exclusively `loadCache`, // `loadBlob`, `doLoadBlob`, `prefetchResourcesForBrowser` etc., see below. class ShellFileLoader { constructor() { this.requests = new Map; } // Cache / memoize previously read files, because some workloads // share common code. load(url) { console.assert(!isInBrowser); let compressed = isCompressed(url); if (compressed && !JetStreamParams.prefetchResources) { url = uncompressedName(url); } // If we aren't supposed to prefetch this then return code snippet that will load the url on-demand. if (!JetStreamParams.prefetchResources) return `load("${url}");` if (this.requests.has(url)) { return this.requests.get(url); } let contents; if (compressed) { const compressedBytes = new Int8Array(read(url, "binary")); const decompressedBytes = zlib.decompress(compressedBytes); contents = new TextDecoder().decode(decompressedBytes); } else { contents = readFile(url); } this.requests.set(url, contents); return contents; } }; const shellFileLoader = new ShellFileLoader(); class Driver { constructor(benchmarks) { this.isReady = false; this.isDone = false; this.errors = []; // Make benchmark list unique and sort it. this.benchmarks = Array.from(new Set(benchmarks)); this.benchmarks.sort((a, b) => a.plan.name.toLowerCase() < b.plan.name.toLowerCase() ? 1 : -1); console.assert(this.benchmarks.length, "No benchmarks selected"); // TODO: Cleanup / remove / merge `blobDataCache` and `loadCache` vs. // the global `fileLoader` cache. this.blobDataCache = { }; this.loadCache = { }; this.counter = { }; this.counter.loadedResources = 0; this.counter.totalResources = 0; this.counter.failedPreloadResources = 0; } async start() { let statusElement = false; if (isInBrowser) { statusElement = document.getElementById("status"); statusElement.innerHTML = ``; } else if (!JetStreamParams.dumpJSONResults) console.log("Starting JetStream3"); performance.mark("update-ui-start"); const start = performance.now(); for (const benchmark of this.benchmarks) { await benchmark.updateUIBeforeRun(); await updateUI(); performance.measure("runner update-ui", "update-ui-start"); try { await benchmark.run(); } catch(e) { this.reportError(benchmark, e); throw e; } performance.mark("update-ui"); benchmark.updateUIAfterRun(); if (isInBrowser) { const cache = JetStream.blobDataCache; for (const file of benchmark.files) { const blobData = cache[file]; // If we didn't prefetch this resource, then no need to free it if (!blobData.blob) { continue } blobData.refCount--; if (!blobData.refCount) cache[file] = undefined; } } } performance.measure("runner update-ui", "update-ui-start"); const totalTime = performance.now() - start; if (measureTotalTimeAsSubtest) { if (isInBrowser) document.getElementById("benchmark-total-time-score").innerHTML = uiFriendlyNumber(totalTime); else if (!JetStreamParams.dumpJSONResults) console.log("Total-Time:", uiFriendlyNumber(totalTime)); allScores.push(totalTime); } const allScores = []; for (const benchmark of this.benchmarks) { const score = benchmark.score; console.assert(score > 0, `Invalid ${benchmark.name} score: ${score}`); allScores.push(score); } const categoryScores = new Map(); const categoryTimes = new Map(); for (const benchmark of this.benchmarks) { for (let category of Object.keys(benchmark.subScores())) categoryScores.set(category, []); for (let category of Object.keys(benchmark.subTimes())) categoryTimes.set(category, []); } for (const benchmark of this.benchmarks) { for (let [category, value] of Object.entries(benchmark.subScores())) { const arr = categoryScores.get(category); console.assert(value > 0, `Invalid ${benchmark.name} ${category} score: ${value}`); arr.push(value); } for (let [category, value] of Object.entries(benchmark.subTimes())) { const arr = categoryTimes.get(category); console.assert(value > 0, `Invalid ${benchmark.name} ${category} time: ${value}`); arr.push(value); } } const overallScore = geomeanScore(allScores); console.assert(overallScore > 0, `Invalid total score: ${overallScore}`); if (isInBrowser) { let summaryHtml = `
${uiFriendlyScore(overallScore)}
`; summaryHtml += `
`; for (let [category, scores] of categoryScores) { summaryHtml += ` ${uiFriendlyScore(geomeanScore(scores))} `; } summaryHtml += "
"; for (let [category, times] of categoryTimes) { summaryHtml += ` ${uiFriendlyDuration(geomeanScore(times))} `; } summaryHtml += "
"; const summaryElement = document.getElementById("result-summary"); summaryElement.classList.add("done"); summaryElement.innerHTML = summaryHtml; summaryElement.onclick = displayCategoryScores; statusElement.innerHTML = ""; } else if (!JetStreamParams.dumpJSONResults) { console.log("Overall:"); for (let [category, scores] of categoryScores) { console.log( shellFriendlyLabel(`Overall ${category}-Score`), shellFriendlyScore(geomeanScore(scores))); } for (let [category, times] of categoryTimes) { console.log( shellFriendlyLabel(`Overall ${category}-Time`), shellFriendlyDuration(geomeanScore(times))); } console.log(""); console.log(shellFriendlyLabel("Overall Score"), shellFriendlyScore(overallScore)); console.log(shellFriendlyLabel("Overall Wall-Time"), shellFriendlyDuration(totalTime)); console.log(""); } this.reportScoreToRunBenchmarkRunner(); this.dumpJSONResultsIfNeeded(); this.isDone = true; if (isInBrowser) { globalThis.dispatchEvent(new CustomEvent("JetStreamDone", { detail: this.resultsObject() })); } } prepareBrowserUI() { let text = ""; for (const benchmark of this.benchmarks) text += benchmark.renderHTML(); const resultsTable = document.getElementById("results"); resultsTable.innerHTML = text; document.getElementById("magic").textContent = ""; document.addEventListener('keypress', (e) => { if (e.key === "Enter") JetStream.start(); }); } reportError(benchmark, error) { this.pushError(benchmark.name, error); if (!isInBrowser) return; for (const id of benchmark.allScoreIdentifiers()) document.getElementById(id).innerHTML = "error"; for (const id of benchmark.allTimeIdentifiers()) document.getElementById(id).innerHTML = "error"; const benchmarkResultsUI = document.getElementById(`benchmark-${benchmark.name}`); benchmarkResultsUI.classList.remove("benchmark-running"); benchmarkResultsUI.classList.add("benchmark-error"); } pushError(name, error) { this.errors.push({ benchmark: name, error: error.toString(), stack: error.stack }); } async initialize() { if (isInBrowser) window.addEventListener("error", (e) => this.pushError("driver startup", e.error)); await this.prefetchResources(); this.benchmarks.sort((a, b) => a.plan.name.toLowerCase() < b.plan.name.toLowerCase() ? 1 : -1); if (isInBrowser) this.prepareBrowserUI(); this.isReady = true; if (isInBrowser) { globalThis.dispatchEvent(new Event("JetStreamReady")); if (typeof(JetStreamParams.startDelay) !== "undefined") { setTimeout(() => this.start(), JetStreamParams.startDelay); } } } async prefetchResources() { if (!isInBrowser) { if (JetStreamParams.prefetchResources) { await zlib.initialize(); } for (const benchmark of this.benchmarks) benchmark.prefetchResourcesForShell(); return; } // TODO: Cleanup the browser path of the preloading below and in // `prefetchResourcesForBrowser` / `retryPrefetchResourcesForBrowser`. const promises = []; for (const benchmark of this.benchmarks) promises.push(benchmark.prefetchResourcesForBrowser()); await Promise.all(promises); const counter = JetStream.counter; if (counter.failedPreloadResources || counter.loadedResources != counter.totalResources) { for (const benchmark of this.benchmarks) { const allFilesLoaded = await benchmark.retryPrefetchResourcesForBrowser(counter); if (allFilesLoaded) break; } if (counter.failedPreloadResources || counter.loadedResources != counter.totalResources) { // If we've failed to prefetch resources even after a sequential 1 by 1 retry, // then fail out early rather than letting subtests fail with a hang. globalThis.allIsGood = false; throw new Error("Fetch failed"); } } JetStream.loadCache = { }; // Done preloading all the files. const statusElement = document.getElementById("status"); statusElement.classList.remove('loading'); statusElement.innerHTML = `Start Test`; statusElement.onclick = () => { statusElement.onclick = null; JetStream.start(); return false; } } resultsObject(format = "run-benchmark") { switch(format) { case "run-benchmark": return this.runBenchmarkResultsObject(); case "simple": return this.simpleResultsObject(); default: throw Error(`Unknown result format '${format}'`); } } runBenchmarkResultsObject() { let results = {}; for (const benchmark of this.benchmarks) { const subResults = {} const subScores = benchmark.subScores(); for (const name in subScores) { subResults[name] = {"metrics": {"Time": {"current": [toTimeValue(subScores[name])]}}}; } results[benchmark.name] = { "metrics" : { "Score" : {"current" : [benchmark.score]}, "Time": ["Geometric"], }, "tests": subResults, }; } results = {"JetStream3.0": {"metrics" : {"Score" : ["Geometric"]}, "tests" : results}}; return results; } simpleResultsObject() { const results = {__proto__: null}; for (const benchmark of this.benchmarks) { if (!benchmark.isDone) continue; if (!benchmark.isSuccess) { results[benchmark.name] = "FAILED"; } else { results[benchmark.name] = { Score: benchmark.score, ...benchmark.subScores(), }; } } return results; } resultsJSON(format = "run-benchmark") { return JSON.stringify(this.resultsObject(format)); } dumpJSONResultsIfNeeded() { if (JetStreamParams.dumpJSONResults) { console.log("\n"); console.log(this.resultsJSON()); console.log("\n"); } } dumpTestList() { for (const benchmark of this.benchmarks) { console.log(benchmark.name); } } async reportScoreToRunBenchmarkRunner() { if (!isInBrowser) return; if (!JetStreamParams.report) return; const content = this.resultsJSON(); await fetch("/report", { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": content.length, "Connection": "close", }, body: content, }); } }; const BenchmarkState = Object.freeze({ READY: "READY", SETUP: "SETUP", RUNNING: "RUNNING", FINALIZE: "FINALIZE", ERROR: "ERROR", DONE: "DONE" }) class Scripts { constructor(preloads) { this.scripts = []; let preloadsCode = ""; let resourcesCode = ""; for (let { name, resource, blobURLOrPath } of preloads) { console.assert(name?.length > 0, "Invalid preload name."); console.assert(resource?.length > 0, "Invalid preload resource."); console.assert(blobURLOrPath?.length > 0, "Invalid preload data."); preloadsCode += `${JSON.stringify(name)}: "${blobURLOrPath}",\n`; resourcesCode += `${JSON.stringify(resource)}: "${blobURLOrPath}",\n`; } // Expose a globalThis.JetStream object to the workload. We use // a proxy to prevent prototype access and throw on unknown properties. this.add(` const throwOnAccess = (name) => new Proxy({}, { get(target, property, receiver) { throw new Error(name + "." + property + " is not defined."); } }); globalThis.JetStream = { __proto__: throwOnAccess("JetStream"), preload: { __proto__: throwOnAccess("JetStream.preload"), ${preloadsCode} }, resources: { __proto__: throwOnAccess("JetStream.preload"), ${resourcesCode} }, }; `); this.add(` performance.mark ??= function(name) { return { name }}; performance.measure ??= function() {}; performance.timeOrigin ??= performance.now(); `); } run() { throw new Error("Subclasses need to implement this"); } add(text) { throw new Error("Subclasses need to implement this"); } addWithURL(url) { throw new Error("addWithURL not supported"); } addBrowserTest() { this.add(` globalThis.JetStream.isInBrowser = ${isInBrowser}; globalThis.JetStream.isD8 = ${isD8}; `); } addDeterministicRandom() { this.add(`(() => { const initialSeed = 49734321; let seed = initialSeed; Math.random = () => { // Robert Jenkins' 32 bit integer hash function. seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffff_ffff; seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffff_ffff; seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffff_ffff; seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffff_ffff; seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffff_ffff; seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffff_ffff; // Note that Math.random should return a value that is // greater than or equal to 0 and less than 1. Here, we // cast to uint32 first then divided by 2^32 for double. return (seed >>> 0) / 0x1_0000_0000; }; Math.random.__resetSeed = () => { seed = initialSeed; }; })();`); } } class ShellScripts extends Scripts { constructor(preloads) { super(preloads); this.prefetchedResources = Object.create(null);; } run() { let globalObject; let realm; if (isD8) { realm = Realm.createAllowCrossRealmAccess(); globalObject = Realm.global(realm); globalObject.loadString = function(s) { return Realm.eval(realm, s); }; globalObject.readFile = read; } else if (isSpiderMonkey) { globalObject = newGlobal(); globalObject.loadString = globalObject.evaluate; globalObject.readFile = globalObject.readRelativeToScript; } else globalObject = runString(""); // Expose console copy in the realm so we don't accidentally modify // the original object. globalObject.console = Object.assign({}, console); globalObject.self = globalObject; globalObject.top = { currentResolve, currentReject }; // Pass the prefetched resources to the benchmark global. if (JetStreamParams.prefetchResources) { // Pass the 'TextDecoder' polyfill into the benchmark global. Don't // use 'TextDecoder' as that will get picked up in the kotlin test // without full support. globalObject.ShellTextDecoder = TextDecoder; // Store shellPrefetchedResources on ShellPrefetchedResources so that // getBinary and getString can find them. globalObject.ShellPrefetchedResources = this.prefetchedResources; } else { console.assert(Object.values(this.prefetchedResources).length === 0, "Unexpected prefetched resources"); } globalObject.performance ??= performance; for (const script of this.scripts) globalObject.loadString(script); return isD8 ? realm : globalObject; } addPrefetchedResources(prefetchedResources) { for (let [file, bytes] of Object.entries(prefetchedResources)) { this.prefetchedResources[file] = bytes; } } add(text) { this.scripts.push(text); } addWithURL(url) { console.assert(false, "Should not reach here in CLI"); } } class BrowserScripts extends Scripts { constructor(preloads) { super(preloads); this.add("window.onerror = top.currentReject;"); } run() { const string = this.scripts.join("\n"); const magic = document.getElementById("magic"); magic.contentDocument.body.textContent = ""; magic.contentDocument.body.innerHTML = `