"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 = `