Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion JetStreamDriver.js
Original file line number Diff line number Diff line change
Expand Up @@ -2564,7 +2564,7 @@ let BENCHMARKS = [
preload: {
skikoJsModule: "./workloads/Kotlin-compose-wasm/build/skiko.mjs",
skikoWasmBinary: "./workloads/Kotlin-compose-wasm/build/skiko.wasm",
composeJsModule: "./workloads/Kotlin-compose-wasm/build/compose-benchmarks-benchmarks.uninstantiated.mjs",
composeJsModule: "./workloads/Kotlin-compose-wasm/build/compose-benchmarks-benchmarks.import-object.mjs",
composeWasmBinary: "./workloads/Kotlin-compose-wasm/build/compose-benchmarks-benchmarks.wasm",
inputImageCompose: "./workloads/Kotlin-compose-wasm/build/compose-multiplatform.png",
inputImageCat: "./workloads/Kotlin-compose-wasm/build/example1_cat.jpg",
Expand Down
165 changes: 116 additions & 49 deletions workloads/Kotlin-compose-wasm/benchmark.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,58 +17,76 @@ if (!globalThis.navigator.languages) {
// Compose reads `window.isSecureContext` in its Clipboard feature:
globalThis.isSecureContext = false;

// Disable the browser CacheStorage API (`window.caches`) so Compose 1.12+'s
// `ResourceWebCache` reads preloaded resources directly from the in-memory
// `fetch` polyfill below in browsers just as it does in JS shells.
Object.defineProperty(globalThis, 'caches', { value: undefined, configurable: true });

// Disable explicit GC (it wouldn't work in browsers anyway).
globalThis.gc = () => {
// DEBUG
// console.log("gc()");
}

class URL {
href;
constructor(url, base) {
// DEBUG
// console.log('URL', url, base);
this.href = url;
if (typeof globalThis.AbortController === 'undefined') {
globalThis.AbortController = class {
constructor() {
this.signal = {};
}
abort() {}
};
}

class BlobPolyfill {
constructor(uint8, type = '') {
this._uint8 = uint8;
this._type = type;
}
get size() {
return this._uint8.byteLength;
}
get type() {
return this._type;
}
async arrayBuffer() {
return this._uint8.buffer;
}
slice(start = 0, end = this.size, type = this.type) {
return new BlobPolyfill(this._uint8.slice(start, end), type);
}
}
globalThis.URL = URL;

// We always polyfill `fetch` and `instantiateStreaming` for consistency between
// engine shells and browsers and to avoid introducing network latency into the
// first iteration / instantiation measurement.
// The downside is that this doesn't test streaming Wasm instantiation, which we
// are willing to accept.
let preload = { /* Initialized in init() below due to async. */ };

// Compose Multiplatform's `DefaultWebResourceReader` loads embedded UI assets
// (PNG/JPG images and TTF fonts) via:
// `fetch('./composeResources/compose_benchmarks.benchmarks.generated.resources/<dir>/<filename>')`
// followed by `.blob()`.
// We always override `fetch` to serve these assets from `preloadResources`
// (populated in `init()` below, keyed by `<filename>`) for consistency between
// engine shells and browsers and to avoid network requests during iterations.
let preloadResources = { /* Initialized in init() below due to async. */ };
const originalFetch = globalThis.fetch ?? function(url) {
throw new Error("no fetch available");
}
globalThis.fetch = async function(url) {
// DEBUG
// console.log('fetch', url);

// Redirect some paths to cached/preloaded resources.
if (preload[url]) {
const filename = url.split('/').pop();
if (preloadResources[filename]) {
const buf = preloadResources[filename];
return {
ok: true,
status: 200,
arrayBuffer() { return preload[url]; },
statusText: 'OK',
async blob() {
return {
size: preload[url].byteLength,
async arrayBuffer() { return preload[url]; }
}
return new BlobPolyfill(new Uint8Array(buf));
},
};
}

// This should only be called in the browser, where fetch() is available.
return originalFetch(url);
};
globalThis.WebAssembly.instantiateStreaming = async function(m,i) {
// DEBUG
// console.log('instantiateStreaming',m,i);
return WebAssembly.instantiate((await m).arrayBuffer(),i);
};

// Provide `setTimeout` for Kotlin coroutines.
const originalSetTimeout = setTimeout;
Expand All @@ -92,40 +110,66 @@ globalThis.setTimeout = function(f, delayMs) {
originalSetTimeout(f);
}

// Don't automatically run the main function on instantiation.
globalThis.skipFunMain = true;
// Compose 1.13 registers a `window.addEventListener('languagechange', ...)`
// listener on scene initialization, and `kotlinx.coroutines` checks
// `typeof window.addEventListener === 'function'` (`tryGetWindow`) to select
// its browser `WindowMessageQueue` (`window.postMessage`) dispatcher.
if (typeof globalThis.addEventListener === 'undefined') {
const messageListeners = [];
globalThis.addEventListener = function(type, listener) {
if (type === 'message') {
messageListeners.push(listener);
}
};
globalThis.postMessage = function(data) {
originalSetTimeout(() => {
const event = { source: globalThis, data, stopPropagation() {} };
for (const listener of messageListeners) {
listener(event);
}
});
};
}

// Prevent this from being detected as a shell environment, so that we use the
// same code paths as in the browser.
// See `compose-benchmarks-benchmarks.uninstantiated.mjs`.
delete globalThis.d8;
delete globalThis.inIon;
delete globalThis.jscOptions;
// Suppress Kotlin's console output unless WasmEMCCBenchmark's verbose mode is
// enabled, so benchmark iterations don't spam stdout/stderr.
if (typeof verbose === 'undefined' || !verbose) {
globalThis.console.log = () => {};
globalThis.console.warn = () => {};
}

class Benchmark {
skikoInstantiate;
mainInstantiate;
skikoModule;
skikoWasmBytes;
composeImportObjectModule;
composeWasmBytes;
wasmInstanceExports;

async init() {
// DEBUG
// console.log("init");

preload = {
'skiko.wasm': await JetStream.getBinary(JetStream.preload.skikoWasmBinary),
'./compose-benchmarks-benchmarks.wasm': await JetStream.getBinary(JetStream.preload.composeWasmBinary),
'./composeResources/compose_benchmarks.benchmarks.generated.resources/drawable/compose-multiplatform.png': await JetStream.getBinary(JetStream.preload.inputImageCompose),
'./composeResources/compose_benchmarks.benchmarks.generated.resources/drawable/example1_cat.jpg': await JetStream.getBinary(JetStream.preload.inputImageCat),
'./composeResources/compose_benchmarks.benchmarks.generated.resources/files/example1_compose-community-primary.png': await JetStream.getBinary(JetStream.preload.inputImageComposeCommunity),
'./composeResources/compose_benchmarks.benchmarks.generated.resources/font/jetbrainsmono_italic.ttf': await JetStream.getBinary(JetStream.preload.inputFontItalic),
'./composeResources/compose_benchmarks.benchmarks.generated.resources/font/jetbrainsmono_regular.ttf': await JetStream.getBinary(JetStream.preload.inputFontRegular),
};
this.skikoWasmBytes = await JetStream.getBinary(JetStream.preload.skikoWasmBinary);
this.composeWasmBytes = await JetStream.getBinary(JetStream.preload.composeWasmBinary);

// Preload all non-code Compose UI assets (images and fonts) from
// `JetStream.resources` (which maps `./workloads/Kotlin-compose-wasm/build/<filename>`
// to its preloaded URL), keyed by `<filename>` so the `fetch()` override
// above can match requests for `./composeResources/.../<dir>/<filename>`.
preloadResources = {};
for (const [path, url] of Object.entries(JetStream.resources)) {
if (!path.endsWith('.mjs') && !path.endsWith('.wasm')) {
const filename = path.split('/').pop();
preloadResources[filename] = await JetStream.getBinary(url);
}
}

// We patched `skiko.mjs` to not immediately instantiate the `skiko.wasm`
// module, so that we can move the dynamic JS import here but measure
// WebAssembly compilation and instantiation as part of the first iteration.
this.skikoInstantiate = (await JetStream.dynamicImport(JetStream.preload.skikoJsModule)).default;
this.mainInstantiate = (await JetStream.dynamicImport(JetStream.preload.composeJsModule)).instantiate;
this.skikoModule = await JetStream.dynamicImport(JetStream.preload.skikoJsModule);
globalThis.kotlinWasmImports = { './skiko.mjs': this.skikoModule };
this.composeImportObjectModule = await JetStream.dynamicImport(JetStream.preload.composeJsModule);
}

async runIteration() {
Expand All @@ -134,8 +178,31 @@ class Benchmark {

// Compile once in the first iteration.
if (!this.wasmInstanceExports) {
const skikoExports = (await this.skikoInstantiate()).wasmExports;
this.wasmInstanceExports = (await this.mainInstantiate({ './skiko.mjs': skikoExports })).exports;
// Pass Emscripten's `instantiateWasm` hook so `skiko.mjs` instantiates
// the preloaded `skiko.wasm` bytes directly without calling `fetch()`.
const { wasmExports: skikoWasmExports } = await this.skikoModule.default({
instantiateWasm: (imports, successCallback) => {
WebAssembly.instantiate(this.skikoWasmBytes, imports).then(
({ instance, module }) => successCallback(instance, module)
);
return {};
},
});

const { importObject, setWasmExports } = this.composeImportObjectModule;
// Replace `skiko.mjs`'s lazy JS wrapper thunks with the direct Wasm
// exports from `skiko.wasm`, matching JetStream 3.0.
importObject['./skiko.mjs'] = skikoWasmExports;

const wasmOptions = { builtins: ['js-string'], importedStringConstants: "'" };
const { instance } = await WebAssembly.instantiate(
this.composeWasmBytes,
importObject,
wasmOptions
);
setWasmExports(Object.assign({ memory: importObject.intrinsics.memory }, instance.exports));
instance.exports._start();
this.wasmInstanceExports = instance.exports;
}

// We render/animate/process fewer frames than in the upstream benchmark,
Expand Down
9 changes: 6 additions & 3 deletions workloads/Kotlin-compose-wasm/build.log
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
Built on 2025-08-14 12:27:42+02:00
Cloning into 'compose-multiplatform'...
84dad4d3f6 Use custom skiko (0.9.4.3) to fix the FinalizationRegistry API usage for web targets
Built on 2026-11-03T15:30:00Z
1574503571 Benchmarks: pass -Xwasm-use-new-exception-proposal to Wasm linkTask (#5787)
Toolchain versions (Skiko follows from Compose):
kotlin = "2.4.20"
compose-multiplatform = "1.12.1"
compose.version override = "1.13.0-beta01" (Skiko 0.156.0)
Copying generated files into build/
Build success
42 changes: 28 additions & 14 deletions workloads/Kotlin-compose-wasm/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,50 @@ rm -rf build/
rm -rf compose-multiplatform/

BUILD_LOG="$(realpath build.log)"
echo -e "Built on $(date --rfc-3339=seconds)" | tee "$BUILD_LOG"
echo -e "Built on $(date -u '+%Y-%m-%dT%H:%M:%SZ')" | tee "$BUILD_LOG"

# Build the benchmark from source.
# FIXME: Use main branch and remove hotfix patch below, once
# https://youtrack.jetbrains.com/issue/SKIKO-1040 is resolved upstream.
# See https://github.com/WebKit/JetStream/pull/84#discussion_r2252418900.
git clone -b ok/jetstream3_hotfix https://github.com/JetBrains/compose-multiplatform.git |& tee -a "$BUILD_LOG"
# TODO: Update CMP_REV to a newer compose-multiplatform commit once Compose
# 1.13.0 is stable and drop the explicit -Pcompose.version=1.13.0-beta01
# override below.
CMP_REV="1574503571a25a3e71f71fef51962beefc494df6"
CMP_VERSION_OVERRIDE="1.13.0-beta01"
git clone -q https://github.com/JetBrains/compose-multiplatform.git 2>&1 | tee -a "$BUILD_LOG"
pushd compose-multiplatform/
git checkout -q "$CMP_REV" 2>&1 | tee -a "$BUILD_LOG"
git log -1 --oneline | tee -a "$BUILD_LOG"
echo "Toolchain versions (Skiko follows from Compose):" | tee -a "$BUILD_LOG"
grep -E '^(kotlin|compose-multiplatform) = ' benchmarks/multiplatform/gradle/libs.versions.toml | tee -a "$BUILD_LOG"
echo "compose.version override = \"$CMP_VERSION_OVERRIDE\" (Skiko 0.156.0)" | tee -a "$BUILD_LOG"
# Do not read `isD8` in the main function which decides whether to run eagerly.
# Instead, just patch that out statically.
# TODO: Upstream that fix to the compose-multiplatform repo.
git apply ../empty-main-function.patch | tee -a "$BUILD_LOG"
# FIXME: Use stable 2.3 Kotlin/Wasm toolchain, once available around Sep 2025.
git apply ../use-beta-toolchain.patch | tee -a "$BUILD_LOG"
# Pass -Xwasm-kclass-fqn=false to the Kotlin/Wasm compile and link tasks.
# TODO(https://youtrack.jetbrains.com/issue/KT-91066): Remove
# compiler-options.patch once KT-91066 is fixed.
git apply ../compiler-options.patch | tee -a "$BUILD_LOG"
pushd benchmarks/multiplatform
./gradlew :benchmarks:wasmJsProductionExecutableCompileSync
./gradlew :benchmarks:wasmJsProductionExecutableCompileSync -Pcompose.version="$CMP_VERSION_OVERRIDE"
# For building polyfills and JavaScript launcher to run in d8 (which inspires the benchmark.js launcher here):
# ./gradlew :benchmarks:buildD8Distribution
# ./gradlew :benchmarks:prepareWasmJsD8ProductionRuntime
BUILD_SRC_DIR="compose-multiplatform/benchmarks/multiplatform/build/wasm/packages/compose-benchmarks-benchmarks/kotlin"
popd
popd

echo "Copying generated files into build/" | tee -a "$BUILD_LOG"
mkdir -p build/ | tee -a "$BUILD_LOG"
cp $BUILD_SRC_DIR/compose-benchmarks-benchmarks.{wasm,uninstantiated.mjs} build/ | tee -a "$BUILD_LOG"
git apply hook-print.patch | tee -a "$BUILD_LOG"
# FIXME: Remove once the JSC fixes around JSTag have landed in Safari, see
# https://github.com/WebKit/JetStream/pull/84#issuecomment-3164672425.
git apply jstag-workaround.patch | tee -a "$BUILD_LOG"
cp $BUILD_SRC_DIR/compose-benchmarks-benchmarks.wasm build/ | tee -a "$BUILD_LOG"
# Rewrite static ES module imports in the generated import-object module so it
# can be loaded from a Blob URL in browsers and without unused dependencies in
# JS shells (`@js-joda/core`, `.js-builtins.mjs`).
sed -E "s|^import \* as ([A-Za-z0-9_$]+) from ('[^']+');$|const \1 = globalThis.kotlinWasmImports?.[\2] ?? {};|" \
"$BUILD_SRC_DIR/compose-benchmarks-benchmarks.import-object.mjs" \
> build/compose-benchmarks-benchmarks.import-object.mjs
if grep -nE '^\s*import\s' build/compose-benchmarks-benchmarks.import-object.mjs; then
echo "Error: unrewritten static import in compose-benchmarks-benchmarks.import-object.mjs" >&2
exit 1
fi
cp $BUILD_SRC_DIR/skiko.{wasm,mjs} build/ | tee -a "$BUILD_LOG"
git apply skiko-disable-instantiate.patch | tee -a "$BUILD_LOG"
cp $BUILD_SRC_DIR/composeResources/compose_benchmarks.benchmarks.generated.resources/drawable/example1_cat.jpg build/ | tee -a "$BUILD_LOG"
Expand Down
Loading
Loading