diff --git a/src/mono/mono/mini/interp/jiterpreter.c b/src/mono/mono/mini/interp/jiterpreter.c index cff40a400384b..2f867757c96a9 100644 --- a/src/mono/mono/mini/interp/jiterpreter.c +++ b/src/mono/mono/mini/interp/jiterpreter.c @@ -1075,6 +1075,9 @@ mono_jiterp_update_jit_call_dispatcher (WasmDoJitCall dispatcher) // blocked the use of Module.addFunction if (!dispatcher) dispatcher = (WasmDoJitCall)mono_llvm_cpp_catch_exception; + else if (((int)(void*)dispatcher)==-1) + dispatcher = mono_jiterp_do_jit_call_indirect; + jiterpreter_do_jit_call = dispatcher; } diff --git a/src/mono/sample/wasm/browser-advanced/index.html b/src/mono/sample/wasm/browser-advanced/index.html index b4cefe75f4072..0532ef8fcef32 100644 --- a/src/mono/sample/wasm/browser-advanced/index.html +++ b/src/mono/sample/wasm/browser-advanced/index.html @@ -11,6 +11,7 @@ + diff --git a/src/mono/sample/wasm/browser-bench/appstart-frame.html b/src/mono/sample/wasm/browser-bench/appstart-frame.html index 1582a356568d8..b54590f423895 100644 --- a/src/mono/sample/wasm/browser-bench/appstart-frame.html +++ b/src/mono/sample/wasm/browser-bench/appstart-frame.html @@ -11,6 +11,7 @@ + diff --git a/src/mono/wasm/build/WasmApp.targets b/src/mono/wasm/build/WasmApp.targets index df0858f237e10..b5ab71e762566 100644 --- a/src/mono/wasm/build/WasmApp.targets +++ b/src/mono/wasm/build/WasmApp.targets @@ -364,7 +364,7 @@ <_WasmAppIncludeThreadsWorker Condition="'$(WasmEnableThreads)' == 'true' or '$(WasmEnablePerfTracing)' == 'true'">true - <_WasmPThreadPoolSize Condition="'$(_WasmPThreadPoolSize)' == '' and ('$(WasmEnableThreads)' == 'true' or '$(WasmEnablePerfTracing)' == 'true')">-1 + <_WasmPThreadPoolSize Condition="'$(_WasmPThreadPoolSize)' == ''">-1 diff --git a/src/mono/wasm/memory-snapshot.md b/src/mono/wasm/memory-snapshot.md new file mode 100644 index 0000000000000..d9d392555cb10 --- /dev/null +++ b/src/mono/wasm/memory-snapshot.md @@ -0,0 +1,27 @@ +# Memory snapshot of the Mono runtime + +We take snapshot of WASM memory after first cold start at run time​. +We store it on the client side in the browser cache. +For subsequent runs with the same configuration and same assets, we use the snapshot +instead of downloading everything again and doing the runtime startup again. +These subsequent starts are significantly faster. + +### Implementation details + +- the consistency of inputs (configuration and assets) with the snapshot is done by calculating SHA256 of the inputs. + - the DLLs and other downloaded assets each have SHA256 which is used to validate 'integrity' by the browser. + - the mono-config has field `assetsHash` which is summary SHA256 of all the assets. + - the configuration could be changed programmatically and so we calculate the hash at the runtime, just before taking snapshot. +- the snapshot is taken just after we initialize the Mono runtime. + - before cwraps are initialized (they would be initialized again on subsequent start). + - after loading all the DLLs into memory. + - after loading ICU and timezone data. + - after applying environment variables and other runtime options. + - before any worker threads initialization. + - before any JavaScript interop initialization. + - before any Managed code executes. + - therefore we do not expect to store any application state in the snapshot. + +### How to opt out +You can turn this feature of by calling `withStartupMemoryCache (false)` on [dotnet API](https://github.com/dotnet/runtime/blob/main/src/mono/wasm/runtime/dotnet.d.ts). + diff --git a/src/mono/wasm/runtime/assets.ts b/src/mono/wasm/runtime/assets.ts index d3ae9456bd3e8..2852e1f4d463b 100644 --- a/src/mono/wasm/runtime/assets.ts +++ b/src/mono/wasm/runtime/assets.ts @@ -8,7 +8,7 @@ import { mono_wasm_load_bytes_into_heap } from "./memory"; import { endMeasure, MeasuredBlock, startMeasure } from "./profiler"; import { createPromiseController, PromiseAndController } from "./promise-controller"; import { delay } from "./promise-utils"; -import { abort_startup, beforeOnRuntimeInitialized } from "./startup"; +import { abort_startup, beforeOnRuntimeInitialized, memorySnapshotSkippedOrDone } from "./startup"; import { AssetBehaviours, AssetEntry, AssetEntryInternal, LoadingResource, mono_assert, ResourceRequest } from "./types"; import { InstantiateWasmSuccessCallback, VoidPtr } from "./types/emscripten"; @@ -38,6 +38,19 @@ const skipBufferByAssetTypes: { "dotnetwasm": true, }; +const containedInSnapshotByAssetTypes: { + [k: string]: boolean +} = { + "resource": true, + "assembly": true, + "pdb": true, + "heap": true, + "icu": true, + "js-module-threads": true, + "dotnetwasm": true, +}; + + // these assets are instantiated differently than the main flow const skipInstantiateByAssetTypes: { [k: string]: boolean @@ -63,8 +76,10 @@ export async function mono_download_assets(): Promise { runtimeHelpers.maxParallelDownloads = runtimeHelpers.config.maxParallelDownloads || runtimeHelpers.maxParallelDownloads; runtimeHelpers.enableDownloadRetry = runtimeHelpers.config.enableDownloadRetry || runtimeHelpers.enableDownloadRetry; try { + const alwaysLoadedAssets: AssetEntryInternal[] = []; + const containedInSnapshotAssets: AssetEntryInternal[] = []; const promises_of_assets: Promise[] = []; - // start fetching and instantiating all assets in parallel + for (const a of runtimeHelpers.config.assets!) { const asset: AssetEntryInternal = a; mono_assert(typeof asset === "object", "asset must be object"); @@ -73,6 +88,14 @@ export async function mono_download_assets(): Promise { mono_assert(!asset.resolvedUrl || typeof asset.resolvedUrl === "string", "asset resolvedUrl could be string"); mono_assert(!asset.hash || typeof asset.hash === "string", "asset resolvedUrl could be string"); mono_assert(!asset.pendingDownload || typeof asset.pendingDownload === "object", "asset pendingDownload could be object"); + if (containedInSnapshotByAssetTypes[asset.behavior]) { + containedInSnapshotAssets.push(asset); + } else { + alwaysLoadedAssets.push(asset); + } + } + + const countAndStartDownload = (asset: AssetEntryInternal) => { if (!skipInstantiateByAssetTypes[asset.behavior] && shouldLoadIcuAsset(asset)) { expected_instantiated_assets_count++; } @@ -80,7 +103,36 @@ export async function mono_download_assets(): Promise { expected_downloaded_assets_count++; promises_of_assets.push(start_asset_download(asset)); } + }; + + // start fetching assets in parallel, only assets which are not part of memory snapshot + for (const asset of alwaysLoadedAssets) { + countAndStartDownload(asset); + } + + // continue after we know if memory snapshot is available or not + await memorySnapshotSkippedOrDone.promise; + + // start fetching assets in parallel, only if memory snapshot is not available. + for (const asset of containedInSnapshotAssets) { + if (!runtimeHelpers.loadedMemorySnapshot) { + countAndStartDownload(asset); + } else { + // Otherwise cleanup in case we were given pending download. It would be even better if we could abort the download. + asset.pendingDownloadInternal = null as any; // GC + asset.pendingDownload = null as any; // GC + asset.buffer = null as any; // GC + // tell the debugger it is loaded + if (asset.behavior == "resource" || asset.behavior == "assembly" || asset.behavior == "pdb") { + const url = resolve_path(asset, ""); + const virtualName: string = typeof (asset.virtualPath) === "string" + ? asset.virtualPath + : asset.name; + loaded_files.push({ url: url, file: virtualName }); + } + } } + allDownloadsQueued.promise_control.resolve(); const promises_of_asset_instantiation: Promise[] = []; @@ -96,8 +148,9 @@ export async function mono_download_assets(): Promise { asset.pendingDownload = null as any; // GC asset.buffer = null as any; // GC + // wait till after onRuntimeInitialized and after memory snapshot is loaded or skipped + await memorySnapshotSkippedOrDone.promise; await beforeOnRuntimeInitialized.promise; - // this is after onRuntimeInitialized _instantiate_asset(asset, url, data); } } else { diff --git a/src/mono/wasm/runtime/diagnostics/server_pthread/index.ts b/src/mono/wasm/runtime/diagnostics/server_pthread/index.ts index 01587e0bc8511..4f757d092970c 100644 --- a/src/mono/wasm/runtime/diagnostics/server_pthread/index.ts +++ b/src/mono/wasm/runtime/diagnostics/server_pthread/index.ts @@ -230,7 +230,7 @@ class DiagnosticServerImpl implements DiagnosticServer { } async stopEventPipe(ws: WebSocket | MockRemoteSocket, sessionID: EventPipeSessionIDImpl): Promise { - console.info("MONO_WASM: stopEventPipe", sessionID); + console.debug("MONO_WASM: stopEventPipe", sessionID); cwraps.mono_wasm_event_pipe_session_disable(sessionID); // we might send OK before the session is actually stopped since the websocket is async // but the client end should be robust to that. @@ -266,7 +266,7 @@ class DiagnosticServerImpl implements DiagnosticServer { resumeRuntime(): void { if (!this.runtimeResumed) { - console.info("MONO_WASM: resuming runtime startup"); + console.debug("MONO_WASM: resuming runtime startup"); cwraps.mono_wasm_diagnostic_server_post_resume_runtime(); this.runtimeResumed = true; } diff --git a/src/mono/wasm/runtime/dotnet.d.ts b/src/mono/wasm/runtime/dotnet.d.ts index 4e3b4610accff..d80c3a4fb72c5 100644 --- a/src/mono/wasm/runtime/dotnet.d.ts +++ b/src/mono/wasm/runtime/dotnet.d.ts @@ -18,6 +18,7 @@ interface DotnetHostBuilder { withDebugging(level: number): DotnetHostBuilder; withMainAssembly(mainAssemblyName: string): DotnetHostBuilder; withApplicationArgumentsFromQuery(): DotnetHostBuilder; + withStartupMemoryCache(value: boolean): DotnetHostBuilder; create(): Promise; run(): Promise; } @@ -38,6 +39,7 @@ declare interface EmscriptenModule { HEAP8: Int8Array; HEAP16: Int16Array; HEAP32: Int32Array; + HEAP64: BigInt64Array; HEAPU8: Uint8Array; HEAPU16: Uint16Array; HEAPU32: Uint32Array; @@ -128,6 +130,10 @@ type MonoConfig = { * initial number of workers to add to the emscripten pthread pool */ pthreadPoolSize?: number; + /** + * If true, the snapshot of runtime's memory will be stored in the browser and used for faster startup next time. Default is true. + */ + startupMemoryCache?: boolean; /** * hash of assets */ diff --git a/src/mono/wasm/runtime/icu.ts b/src/mono/wasm/runtime/icu.ts index f362ec82861da..5175453f34167 100644 --- a/src/mono/wasm/runtime/icu.ts +++ b/src/mono/wasm/runtime/icu.ts @@ -5,53 +5,50 @@ import cwraps from "./cwraps"; import { Module, runtimeHelpers } from "./imports"; import { VoidPtr } from "./types/emscripten"; -let num_icu_assets_loaded_successfully = 0; - // @offset must be the address of an ICU data archive in the native heap. // returns true on success. export function mono_wasm_load_icu_data(offset: VoidPtr): boolean { - const ok = (cwraps.mono_wasm_load_icu_data(offset)) === 1; - if (ok) - num_icu_assets_loaded_successfully++; - return ok; + return (cwraps.mono_wasm_load_icu_data(offset)) === 1; } -// Performs setup for globalization. -// @globalizationMode is one of "icu", "invariant", or "auto". -// "auto" will use "icu" if any ICU data archives have been loaded, -// otherwise "invariant". -export function mono_wasm_globalization_init(): void { - const config = runtimeHelpers.config; - let invariantMode = false; - if (!config.globalizationMode) - config.globalizationMode = "auto"; - if (config.globalizationMode === "invariant") - invariantMode = true; +export function init_globalization() { + runtimeHelpers.invariantMode = runtimeHelpers.config.globalizationMode === "invariant"; + runtimeHelpers.preferredIcuAsset = get_preferred_icu_asset(); - if (!invariantMode) { - if (num_icu_assets_loaded_successfully > 0) { - if (runtimeHelpers.diagnosticTracing) { - console.debug("MONO_WASM: ICU data archive(s) loaded, disabling invariant mode"); - } - } else if (config.globalizationMode !== "icu") { - if (runtimeHelpers.diagnosticTracing) { - console.debug("MONO_WASM: ICU data archive(s) not loaded, using invariant globalization mode"); - } - invariantMode = true; + if (!runtimeHelpers.invariantMode) { + if (runtimeHelpers.preferredIcuAsset) { + if (runtimeHelpers.diagnosticTracing) console.debug("MONO_WASM: ICU data archive(s) available, disabling invariant mode"); + } else if (runtimeHelpers.config.globalizationMode !== "icu") { + if (runtimeHelpers.diagnosticTracing) console.debug("MONO_WASM: ICU data archive(s) not available, using invariant globalization mode"); + runtimeHelpers.invariantMode = true; + runtimeHelpers.preferredIcuAsset = null; } else { - const msg = "invariant globalization mode is inactive and no ICU data archives were loaded"; + const msg = "invariant globalization mode is inactive and no ICU data archives are available"; Module.err(`MONO_WASM: ERROR: ${msg}`); throw new Error(msg); } } - if (invariantMode) { - cwraps.mono_wasm_setenv("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", "1"); + const invariantEnv = "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"; + const env_variables = runtimeHelpers.config.environmentVariables!; + if (env_variables[invariantEnv] === undefined && runtimeHelpers.invariantMode) { + env_variables[invariantEnv] = "1"; + } + if (env_variables["TZ"] === undefined) { + try { + // this call is relatively expensive, so we call it during download of other assets + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null; + if (timezone) { + env_variables!["TZ"] = timezone; + } + } catch { + console.info("MONO_WASM: failed to detect timezone, will fallback to UTC"); + } } } export function get_preferred_icu_asset(): string | null { - if (!runtimeHelpers.config.assets) + if (!runtimeHelpers.config.assets || runtimeHelpers.invariantMode) return null; // By setting user can define what ICU source file they want to load. diff --git a/src/mono/wasm/runtime/jiterpreter-jit-call.ts b/src/mono/wasm/runtime/jiterpreter-jit-call.ts index 28ca9db7c30ff..e84fc620da4ff 100644 --- a/src/mono/wasm/runtime/jiterpreter-jit-call.ts +++ b/src/mono/wasm/runtime/jiterpreter-jit-call.ts @@ -3,7 +3,7 @@ import { mono_assert, MonoType, MonoMethod } from "./types"; import { NativePointer, Int32Ptr, VoidPtr } from "./types/emscripten"; -import { Module } from "./imports"; +import { Module, runtimeHelpers } from "./imports"; import { getU8, getI32_unaligned, getU32_unaligned, setU32_unchecked } from "./memory"; @@ -256,6 +256,7 @@ function getIsWasmEhSupported () : boolean { export function mono_jiterp_do_jit_call_indirect ( jit_call_cb: number, cb_data: VoidPtr, thrown: Int32Ptr ) : void { + mono_assert(!runtimeHelpers.storeMemorySnapshotPending, "Attempting to set function into table during creation of memory snapshot"); const table = getWasmFunctionTable(); const jitCallCb = table.get(jit_call_cb); diff --git a/src/mono/wasm/runtime/jiterpreter-support.ts b/src/mono/wasm/runtime/jiterpreter-support.ts index 1a49c4eeee627..c7d4524b5573f 100644 --- a/src/mono/wasm/runtime/jiterpreter-support.ts +++ b/src/mono/wasm/runtime/jiterpreter-support.ts @@ -3,7 +3,7 @@ import { mono_assert } from "./types"; import { NativePointer, ManagedPointer, VoidPtr } from "./types/emscripten"; -import { Module } from "./imports"; +import { Module, runtimeHelpers } from "./imports"; import { WasmOpcode } from "./jiterpreter-opcodes"; import cwraps from "./cwraps"; @@ -1201,8 +1201,9 @@ export function getWasmFunctionTable () { } export function addWasmFunctionPointer (f: Function) { - if (!f) - throw new Error("Attempting to set null function into table"); + mono_assert(f,"Attempting to set null function into table"); + mono_assert(!runtimeHelpers.storeMemorySnapshotPending, "Attempting to set function into table during creation of memory snapshot"); + const table = getWasmFunctionTable(); if (wasmFunctionIndicesFree <= 0) { wasmNextFunctionIndex = table.length; diff --git a/src/mono/wasm/runtime/jiterpreter.ts b/src/mono/wasm/runtime/jiterpreter.ts index 58f50804a1d05..c5cb2e636bc89 100644 --- a/src/mono/wasm/runtime/jiterpreter.ts +++ b/src/mono/wasm/runtime/jiterpreter.ts @@ -3,7 +3,7 @@ import { mono_assert, MonoMethod } from "./types"; import { NativePointer } from "./types/emscripten"; -import { Module } from "./imports"; +import { Module, runtimeHelpers } from "./imports"; import { getU16, getU32_unaligned } from "./memory"; @@ -762,6 +762,8 @@ function generate_wasm ( // independently jitting traces will not stomp on each other and all threads // have a globally consistent view of which function pointer maps to each trace. rejected = false; + mono_assert(!runtimeHelpers.storeMemorySnapshotPending, "Attempting to set function into table during creation of memory snapshot"); + const idx = trapTraceErrors ? Module.addFunction( diff --git a/src/mono/wasm/runtime/polyfills.ts b/src/mono/wasm/runtime/polyfills.ts index 78fdd1f345bbf..edbc423123d9b 100644 --- a/src/mono/wasm/runtime/polyfills.ts +++ b/src/mono/wasm/runtime/polyfills.ts @@ -215,6 +215,7 @@ export async function init_polyfills_async(): Promise { } } } + runtimeHelpers.subtle = globalThis.crypto?.subtle; } const dummyPerformance = { diff --git a/src/mono/wasm/runtime/profiler.ts b/src/mono/wasm/runtime/profiler.ts index 1948fc85a19b5..489cd4ff86856 100644 --- a/src/mono/wasm/runtime/profiler.ts +++ b/src/mono/wasm/runtime/profiler.ts @@ -41,6 +41,7 @@ export const enum MeasuredBlock { preRunWorker = "mono.preRunWorker", onRuntimeInitialized = "mono.onRuntimeInitialized", postRun = "mono.postRun", + memorySnapshot = "mono.memorySnapshot", loadRuntime = "mono.loadRuntime", bindingsInit = "mono.bindingsInit", bindJsFunction = "mono.bindJsFunction:", diff --git a/src/mono/wasm/runtime/run-outer.ts b/src/mono/wasm/runtime/run-outer.ts index 7f6902e2d9712..c49317523403a 100644 --- a/src/mono/wasm/runtime/run-outer.ts +++ b/src/mono/wasm/runtime/run-outer.ts @@ -18,6 +18,7 @@ export interface DotnetHostBuilder { withDebugging(level: number): DotnetHostBuilder withMainAssembly(mainAssemblyName: string): DotnetHostBuilder withApplicationArgumentsFromQuery(): DotnetHostBuilder + withStartupMemoryCache(value: boolean): DotnetHostBuilder create(): Promise run(): Promise } @@ -137,6 +138,19 @@ class HostBuilder implements DotnetHostBuilder { } } + withStartupMemoryCache(value: boolean): DotnetHostBuilder { + try { + const configInternal: MonoConfigInternal = { + startupMemoryCache: value + }; + Object.assign(this.moduleConfig.config!, configInternal); + return this; + } catch (err) { + mono_exit(1, err); + throw err; + } + } + withConfig(config: MonoConfig): DotnetHostBuilder { try { const providedConfig = { ...config }; diff --git a/src/mono/wasm/runtime/run.ts b/src/mono/wasm/runtime/run.ts index 7fc3729896638..27d1a65fd6fb6 100644 --- a/src/mono/wasm/runtime/run.ts +++ b/src/mono/wasm/runtime/run.ts @@ -118,8 +118,10 @@ function set_exit_code_and_quit_now(exit_code: number, reason?: any): void { Module.err(JSON.stringify(reason)); } } - else { + else if (!reason) { reason = new runtimeHelpers.ExitStatus(exit_code); + } else if (typeof reason.status === "number") { + exit_code = reason.status; } } logErrorOnExit(exit_code, reason); diff --git a/src/mono/wasm/runtime/snapshot.ts b/src/mono/wasm/runtime/snapshot.ts new file mode 100644 index 0000000000000..eb96dec46c561 --- /dev/null +++ b/src/mono/wasm/runtime/snapshot.ts @@ -0,0 +1,188 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import ProductVersion from "consts:productVersion"; +import GitHash from "consts:gitHash"; +import MonoWasmThreads from "consts:monoWasmThreads"; +import { runtimeHelpers } from "./imports"; + +const memoryPrefix = "https://dotnet.generated.invalid/wasm-memory"; + +// adapted from Blazor's WebAssemblyResourceLoader.ts +async function openCache(): Promise { + // caches will be undefined if we're running on an insecure origin (secure means https or localhost) + if (typeof globalThis.caches === "undefined") { + return null; + } + + // cache integrity is compromised if the first request has been served over http (except localhost) + // in this case, we want to disable caching and integrity validation + if (ENVIRONMENT_IS_WEB && globalThis.window.isSecureContext === false) { + return null; + } + + // Define a separate cache for each base href, so we're isolated from any other + // Blazor application running on the same origin. We need this so that we're free + // to purge from the cache anything we're not using and don't let it keep growing, + // since we don't want to be worst offenders for space usage. + const relativeBaseHref = document.baseURI.substring(document.location.origin.length); + const cacheName = `dotnet-resources${relativeBaseHref}`; + + try { + // There's a Chromium bug we need to be aware of here: the CacheStorage APIs say that when + // caches.open(name) returns a promise that succeeds, the value is meant to be a Cache instance. + // However, if the browser was launched with a --user-data-dir param that's "too long" in some sense, + // then even through the promise resolves as success, the value given is `undefined`. + // See https://stackoverflow.com/a/46626574 and https://bugs.chromium.org/p/chromium/issues/detail?id=1054541 + // If we see this happening, return "null" to mean "proceed without caching". + return (await globalThis.caches.open(cacheName)) || null; + } catch { + // There's no known scenario where we should get an exception here, but considering the + // Chromium bug above, let's tolerate it and treat as "proceed without caching". + console.warn("MONO_WASM: Failed to open cache"); + return null; + } +} + +export async function getMemorySnapshotSize(): Promise { + try { + const cacheKey = await getCacheKey(); + if (!cacheKey) { + return undefined; + } + const cache = await openCache(); + if (!cache) { + return undefined; + } + const res = await cache.match(cacheKey); + const contentLength = res?.headers.get("content-length"); + return contentLength ? parseInt(contentLength) : undefined; + } catch (ex) { + console.warn("MONO_WASM: Failed find memory snapshot in the cache", ex); + return undefined; + } +} + +export async function getMemorySnapshot(): Promise { + try { + const cacheKey = await getCacheKey(); + if (!cacheKey) { + return undefined; + } + const cache = await openCache(); + if (!cache) { + return undefined; + } + const res = await cache.match(cacheKey); + if (!res) { + return undefined; + } + return res.arrayBuffer(); + } catch (ex) { + console.warn("MONO_WASM: Failed load memory snapshot from the cache", ex); + return undefined; + } +} + +export async function storeMemorySnapshot(memory: ArrayBuffer) { + try { + const cacheKey = await getCacheKey(); + if (!cacheKey) { + return; + } + const cache = await openCache(); + if (!cache) { + return; + } + const copy = MonoWasmThreads + // storing SHaredArrayBuffer in the cache is not working + ? (new Int8Array(memory)).slice(0) + : memory; + + const responseToCache = new Response(copy, { + headers: { + "content-type": "wasm-memory", + "content-length": memory.byteLength.toString(), + }, + }); + + await cache.put(cacheKey, responseToCache); + + cleanupMemorySnapshots(cacheKey); // no await + } catch (ex) { + console.warn("MONO_WASM: Failed to store memory snapshot in the cache", ex); + return; + } +} + +export async function cleanupMemorySnapshots(protectKey: string) { + try { + const cache = await openCache(); + if (!cache) { + return; + } + const items = await cache.keys(); + for (const item of items) { + if (item.url && item.url !== protectKey && item.url.startsWith(memoryPrefix)) { + await cache.delete(item); + } + } + } catch (ex) { + return; + } +} + +// calculate hash of things which affect the memory snapshot +async function getCacheKey(): Promise { + if (runtimeHelpers.memorySnapshotCacheKey) { + return runtimeHelpers.memorySnapshotCacheKey; + } + if (!runtimeHelpers.subtle) { + return null; + } + const inputs = Object.assign({}, runtimeHelpers.config) as any; + // above already has env variables, runtime options, etc + + if (!inputs.assetsHash) { + // this is fallback for blazor which does not have assetsHash yet + inputs.assetsHash = []; + for (const asset of inputs.assets) { + if (!asset.hash) { + // if we don't have hash, we can't use the cache + return null; + } + inputs.assetsHash.push(asset.hash); + } + } + // otherwise config.assetsHash already has hashes for all the assets (DLLs, ICU, .wasms, etc). + + // Now we remove assets collection from the hash. + delete inputs.assets; + // some things are calculated at runtime, so we need to add them to the hash + inputs.preferredIcuAsset = runtimeHelpers.preferredIcuAsset; + // timezone is part of env variables, so it is already in the hash + + // some things are not relevant for memory snapshot + delete inputs.forwardConsoleLogsToWS; + delete inputs.diagnosticTracing; + delete inputs.appendElementOnExit; + delete inputs.logExitCode; + delete inputs.pthreadPoolSize; + delete inputs.asyncFlushOnExit; + delete inputs.assemblyRootFolder; + delete inputs.remoteSources; + delete inputs.ignorePdbLoadErrors; + delete inputs.maxParallelDownloads; + delete inputs.enableDownloadRetry; + delete inputs.exitAfterSnapshot; + + inputs.GitHash = GitHash; + inputs.ProductVersion = ProductVersion; + + const inputsJson = JSON.stringify(inputs); + const sha256Buffer = await runtimeHelpers.subtle.digest("SHA-256", new TextEncoder().encode(inputsJson)); + const uint8ViewOfHash = new Uint8Array(sha256Buffer); + const hashAsString = Array.from(uint8ViewOfHash).map((b) => b.toString(16).padStart(2, "0")).join(""); + runtimeHelpers.memorySnapshotCacheKey = `${memoryPrefix}-${hashAsString}`; + return runtimeHelpers.memorySnapshotCacheKey; +} diff --git a/src/mono/wasm/runtime/startup.ts b/src/mono/wasm/runtime/startup.ts index 482c967bec26a..970a8408f3eea 100644 --- a/src/mono/wasm/runtime/startup.ts +++ b/src/mono/wasm/runtime/startup.ts @@ -4,11 +4,10 @@ import BuildConfiguration from "consts:configuration"; import MonoWasmThreads from "consts:monoWasmThreads"; import WasmEnableLegacyJsInterop from "consts:WasmEnableLegacyJsInterop"; -import { CharPtrNull, DotnetModule, RuntimeAPI, MonoConfig, MonoConfigInternal, DotnetModuleInternal } from "./types"; +import { CharPtrNull, DotnetModule, RuntimeAPI, MonoConfig, MonoConfigInternal, DotnetModuleInternal, mono_assert } from "./types"; import { ENVIRONMENT_IS_NODE, ENVIRONMENT_IS_SHELL, INTERNAL, Module, runtimeHelpers } from "./imports"; import cwraps, { init_c_exports } from "./cwraps"; import { mono_wasm_raise_debug_event, mono_wasm_runtime_ready } from "./debug"; -import { get_preferred_icu_asset, mono_wasm_globalization_init } from "./icu"; import { toBase64StringImpl } from "./base64"; import { mono_wasm_init_aot_profiler, mono_wasm_init_browser_profiler } from "./profiler"; import { mono_on_abort, mono_exit } from "./run"; @@ -27,16 +26,19 @@ import { mono_wasm_init_diagnostics } from "./diagnostics"; import { preAllocatePThreadWorkerPool, instantiateWasmPThreadWorkerPool } from "./pthreads/browser"; import { export_linker } from "./exports-linker"; import { endMeasure, MeasuredBlock, startMeasure } from "./profiler"; +import { getMemorySnapshot, storeMemorySnapshot, getMemorySnapshotSize } from "./snapshot"; // legacy import { init_legacy_exports } from "./net6-legacy/corebindings"; import { cwraps_binding_api, cwraps_mono_api } from "./net6-legacy/exports-legacy"; import { BINDING, MONO } from "./net6-legacy/imports"; +import { init_globalization } from "./icu"; let config: MonoConfigInternal = undefined as any; let configLoaded = false; export const dotnetReady = createPromiseController(); export const afterConfigLoaded = createPromiseController(); +export const memorySnapshotSkippedOrDone = createPromiseController(); export const afterInstantiateWasm = createPromiseController(); export const beforePreInit = createPromiseController(); export const afterPreInit = createPromiseController(); @@ -106,6 +108,9 @@ function instantiateWasm( const mark = startMeasure(); if (userInstantiateWasm) { + init_globalization(); + // user wasm instantiation doesn't support memory snapshots + memorySnapshotSkippedOrDone.promise_control.resolve(); const exports = userInstantiateWasm(imports, (instance: WebAssembly.Instance, module: WebAssembly.Module | undefined) => { endMeasure(mark, MeasuredBlock.instantiateWasm); afterInstantiateWasm.promise_control.resolve(); @@ -154,9 +159,10 @@ function preInit(userPreInit: (() => void)[]) { // It will block emscripten `userOnRuntimeInitialized` by pending addRunDependency("mono_pre_init") (async () => { try { + // - init the rest of the polyfills + // - download Module.config from configSrc await mono_wasm_pre_init_essential_async(); - // - download Module.config from configSrc // - start download assets like DLLs await mono_wasm_pre_init_full(); @@ -232,10 +238,30 @@ async function onRuntimeInitializedAsync(userOnRuntimeInitialized: () => void) { try { await wait_for_all_assets(); + // Diagnostics early are not supported with memory snapshot. See below how we enable them later. + // Please disable startupMemoryCache in order to be able to diagnose or pause runtime startup. + if (MonoWasmThreads && !config.startupMemoryCache) { + await mono_wasm_init_diagnostics(); + } + // load runtime and apply environment settings (if necessary) - await mono_wasm_before_user_runtime_initialized(); + await mono_wasm_before_memory_snapshot(); + + if (config.exitAfterSnapshot) { + const reason = runtimeHelpers.ExitStatus + ? new runtimeHelpers.ExitStatus(0) + : new Error("Snapshot taken, exiting because exitAfterSnapshot was set."); + reason.silent = true; + + abort_startup(reason, false); + return; + } if (MonoWasmThreads) { + if (config.startupMemoryCache) { + // we could enable diagnostics after the snapshot is taken + await mono_wasm_init_diagnostics(); + } await instantiateWasmPThreadWorkerPool(); } @@ -295,6 +321,7 @@ async function postRunAsync(userpostRun: (() => void)[]) { export function abort_startup(reason: any, should_exit: boolean): void { if (runtimeHelpers.diagnosticTracing) console.trace("MONO_WASM: abort_startup"); dotnetReady.promise_control.reject(reason); + memorySnapshotSkippedOrDone.promise_control.reject(reason); afterInstantiateWasm.promise_control.reject(reason); beforePreInit.promise_control.reject(reason); afterPreInit.promise_control.reject(reason); @@ -302,7 +329,7 @@ export function abort_startup(reason: any, should_exit: boolean): void { beforeOnRuntimeInitialized.promise_control.reject(reason); afterOnRuntimeInitialized.promise_control.reject(reason); afterPostRun.promise_control.reject(reason); - if (should_exit) { + if (should_exit && (typeof reason !== "object" || reason.silent !== true)) { mono_exit(1, reason); } } @@ -392,8 +419,11 @@ async function mono_wasm_after_user_runtime_initialized(): Promise { function _print_error(message: string, err: any): void { + if (typeof err === "object" && err.silent) { + return; + } Module.err(`${message}: ${JSON.stringify(err)}`); - if (err.stack) { + if (typeof err === "object" && err.stack) { Module.err("MONO_WASM: Stacktrace: \n"); Module.err(err.stack); } @@ -443,11 +473,27 @@ async function instantiate_wasm_module( ): Promise { // this is called so early that even Module exports like addRunDependency don't exist yet try { + let memorySize: number | undefined = undefined; await mono_wasm_load_config(Module.configSrc); if (runtimeHelpers.diagnosticTracing) console.debug("MONO_WASM: instantiate_wasm_module"); const assetToLoad = resolve_asset_path("dotnetwasm"); // FIXME: this would not apply re-try (on connection reset during download) for dotnet.wasm because we could not download the buffer before we pass it to instantiate_wasm_asset - await start_asset_download(assetToLoad); + const wasmDownloadPromise = start_asset_download(assetToLoad); + + // this is right time as we have free CPU time to do this + init_globalization(); + + if (config.startupMemoryCache) { + memorySize = await getMemorySnapshotSize(); + runtimeHelpers.loadedMemorySnapshot = !!memorySize; + runtimeHelpers.storeMemorySnapshotPending = !runtimeHelpers.loadedMemorySnapshot; + } + if (!runtimeHelpers.loadedMemorySnapshot) { + // we should start downloading DLLs etc as they are not in the snapshot + memorySnapshotSkippedOrDone.promise_control.resolve(); + } + + await wasmDownloadPromise; await beforePreInit.promise; Module.addRunDependency("instantiate_wasm_module"); @@ -458,6 +504,21 @@ async function instantiate_wasm_module( assetToLoad.buffer = null as any; // GC if (runtimeHelpers.diagnosticTracing) console.debug("MONO_WASM: instantiate_wasm_module done"); + + if (runtimeHelpers.loadedMemorySnapshot) { + try { + const wasmMemory = (Module.asm?.memory || Module.wasmMemory)!; + + // .grow() takes a delta compared to the previous size + wasmMemory.grow((memorySize! - wasmMemory.buffer.byteLength + 65535) >>> 16); + runtimeHelpers.updateMemoryViews(); + } catch (err) { + console.warn("MONO_WASM: failed to resize memory for the snapshot", err); + runtimeHelpers.loadedMemorySnapshot = false; + } + // now we know if the loading of memory succeeded or not, we can start loading the rest of the assets + memorySnapshotSkippedOrDone.promise_control.resolve(); + } afterInstantiateWasm.promise_control.resolve(); } catch (err) { _print_error("MONO_WASM: instantiate_wasm_module() failed", err); @@ -467,8 +528,18 @@ async function instantiate_wasm_module( Module.removeRunDependency("instantiate_wasm_module"); } -async function mono_wasm_before_user_runtime_initialized() { - mono_wasm_globalization_init(); +async function mono_wasm_before_memory_snapshot() { + const mark = startMeasure(); + if (runtimeHelpers.loadedMemorySnapshot) { + // get the bytes after we re-sized the memory, so that we don't have too much memory in use at the same time + const memoryBytes = await getMemorySnapshot(); + mono_assert(memoryBytes!.byteLength === Module.HEAP8.byteLength, "MONO_WASM: Loaded memory is not the expected size"); + Module.HEAP8.set(new Int8Array(memoryBytes!), 0); + if (runtimeHelpers.diagnosticTracing) console.info("MONO_WASM: Loaded WASM linear memory from browser cache"); + + // all things below are loaded from the snapshot + return; + } for (const k in config.environmentVariables) { const v = config.environmentVariables![k]; @@ -477,7 +548,10 @@ async function mono_wasm_before_user_runtime_initialized() { else throw new Error(`Expected environment variable '${k}' to be a string but it was ${typeof v}: '${v}'`); } - + if (config.startupMemoryCache) { + // disable the trampoline for now, we will re-enable it after we stored the snapshot + cwraps.mono_jiterp_update_jit_call_dispatcher(0); + } if (config.runtimeOptions) mono_wasm_set_runtime_options(config.runtimeOptions); @@ -487,13 +561,17 @@ async function mono_wasm_before_user_runtime_initialized() { if (config.browserProfilerOptions) mono_wasm_init_browser_profiler(config.browserProfilerOptions); - // init diagnostics after environment variables are set - if (MonoWasmThreads) { - await mono_wasm_init_diagnostics(); - } - mono_wasm_load_runtime("unused", config.debugLevel); + // we didn't have snapshot yet and the feature is enabled. Take snapshot now. + if (config.startupMemoryCache) { + // this would install the mono_jiterp_do_jit_call_indirect + cwraps.mono_jiterp_update_jit_call_dispatcher(-1); + await storeMemorySnapshot(Module.HEAP8.buffer); + runtimeHelpers.storeMemorySnapshotPending = false; + } + + endMeasure(mark, MeasuredBlock.memorySnapshot); } export function mono_wasm_load_runtime(unused?: string, debugLevel?: number): void { @@ -604,6 +682,7 @@ function normalizeConfig() { config.assets = config.assets || []; config.runtimeOptions = config.runtimeOptions || []; config.globalizationMode = config.globalizationMode || "auto"; + if (config.debugLevel === undefined && BuildConfiguration === "Debug") { config.debugLevel = -1; } @@ -611,21 +690,17 @@ function normalizeConfig() { config.diagnosticTracing = true; } runtimeHelpers.diagnosticTracing = !!config.diagnosticTracing; + runtimeHelpers.waitForDebugger = config.waitForDebugger; + config.startupMemoryCache = config.startupMemoryCache == undefined ? true : !!config.startupMemoryCache; + if (config.startupMemoryCache && runtimeHelpers.waitForDebugger) { + if (runtimeHelpers.diagnosticTracing) console.info("MONO_WASM: Disabling startupMemoryCache because waitForDebugger is set"); + config.startupMemoryCache = false; + } runtimeHelpers.enablePerfMeasure = !!config.browserProfilerOptions && globalThis.performance && typeof globalThis.performance.measure === "function"; - runtimeHelpers.preferredIcuAsset = get_preferred_icu_asset(); - if (runtimeHelpers.timezone === undefined && config.environmentVariables["TZ"] === undefined) { - try { - runtimeHelpers.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null; - if (runtimeHelpers.timezone) config.environmentVariables["TZ"] = runtimeHelpers.timezone; - } catch { - console.info("MONO_WASM: failed to detect timezone, will fallback to UTC"); - } - } - runtimeHelpers.waitForDebugger = config.waitForDebugger; } export function mono_wasm_asm_loaded(assembly_name: CharPtr, assembly_ptr: number, assembly_len: number, pdb_ptr: number, pdb_len: number): void { diff --git a/src/mono/wasm/runtime/types.ts b/src/mono/wasm/runtime/types.ts index 19a13ea6a7c2a..cd8b63dbe9ff0 100644 --- a/src/mono/wasm/runtime/types.ts +++ b/src/mono/wasm/runtime/types.ts @@ -119,6 +119,10 @@ export type MonoConfig = { * initial number of workers to add to the emscripten pthread pool */ pthreadPoolSize?: number, + /** + * If true, the snapshot of runtime's memory will be stored in the browser and used for faster startup next time. Default is true. + */ + startupMemoryCache?: boolean, /** * hash of assets */ @@ -134,6 +138,7 @@ export type MonoConfigInternal = MonoConfig & { logExitCode?: boolean forwardConsoleLogsToWS?: boolean, asyncFlushOnExit?: boolean + exitAfterSnapshot?: number, }; export type RunArguments = { @@ -228,8 +233,12 @@ export type RuntimeHelpers = { locateFile: (path: string, prefix?: string) => string, javaScriptExports: JavaScriptExports, loadedFiles: string[], + loadedMemorySnapshot: boolean, + storeMemorySnapshotPending: boolean, + memorySnapshotCacheKey: string, + subtle: SubtleCrypto | null, preferredIcuAsset: string | null, - timezone: string | null, + invariantMode: boolean, updateMemoryViews: () => void } diff --git a/src/mono/wasm/runtime/types/emscripten.ts b/src/mono/wasm/runtime/types/emscripten.ts index 0c3fca990c97d..cc8ac6462125e 100644 --- a/src/mono/wasm/runtime/types/emscripten.ts +++ b/src/mono/wasm/runtime/types/emscripten.ts @@ -71,6 +71,8 @@ export declare interface EmscriptenModuleInternal { mainScriptUrlOrBlob?: string; wasmModule: WebAssembly.Instance | null; ready: Promise; + asm: { memory?: WebAssembly.Memory }; + wasmMemory?: WebAssembly.Memory; getWasmTableEntry(index: number): any; removeRunDependency(id: string): void; addRunDependency(id: string): void; diff --git a/src/mono/wasm/test-main.js b/src/mono/wasm/test-main.js index 979c8a2d90ae0..73437d7a5351d 100644 --- a/src/mono/wasm/test-main.js +++ b/src/mono/wasm/test-main.js @@ -66,8 +66,7 @@ async function getArgs() { let runArgsJson; // ToDo: runArgs should be read for all kinds of hosts, but // fetch is added to node>=18 and current Windows's emcc node<18 - if (is_browser) - { + if (is_browser) { const response = await globalThis.fetch('./runArgs.json'); if (response.ok) { runArgsJson = initRunArgs(await response.json()); @@ -243,53 +242,97 @@ const App = { }; globalThis.App = App; // Necessary as System.Runtime.InteropServices.JavaScript.Tests.MarshalTests (among others) call the App.call_test_method directly +function configureRuntime(dotnet, runArgs, INTERNAL) { + dotnet + .withVirtualWorkingDirectory(runArgs.workingDirectory) + .withEnvironmentVariables(runArgs.environmentVariables) + .withDiagnosticTracing(runArgs.diagnosticTracing) + .withDiagnosticTracing(true) + .withExitOnUnhandledError() + .withExitCodeLogging() + .withElementOnExit(); + + if (is_node) { + dotnet + .withEnvironmentVariable("NodeJSPlatform", process.platform) + .withAsyncFlushOnExit(); + + const modulesToLoad = runArgs.environmentVariables["NPM_MODULES"]; + if (modulesToLoad) { + dotnet.withModuleConfig({ + onConfigLoaded: (config) => { + loadNodeModules(config, INTERNAL.require, modulesToLoad) + } + }) + } + } + if (is_browser) { + dotnet.withEnvironmentVariable("IsWebSocketSupported", "true"); + } + if (runArgs.runtimeArgs.length > 0) { + dotnet.withRuntimeOptions(runArgs.runtimeArgs); + } + if (runArgs.debugging) { + dotnet.withDebugging(-1); + dotnet.withWaitingForDebugger(-1); + } + if (runArgs.forwardConsole) { + dotnet.withConsoleForwarding(); + } +} + +async function dry_run(runArgs) { + try { + console.log("Silently starting separate runtime instance as another ES6 module to populate caches..."); + // this separate instance of the ES6 module, in which we just populate the caches + const { dotnet, exit, INTERNAL } = await loadDotnet('./dotnet.js?dry_run=true'); + mono_exit = exit; + configureRuntime(dotnet, runArgs, INTERNAL); + // silent minimal startup + await dotnet.withConfig({ + forwardConsoleLogsToWS: false, + diagnosticTracing: false, + appendElementOnExit: false, + logExitCode: false, + pthreadPoolSize: 0, + // this just means to not continue startup after the snapshot is taken. + // If there was previously a matching snapshot, it will be used. + exitAfterSnapshot: true + }).create(); + } catch (err) { + if (err && err.status !== 0) { + return false; + } + } + console.log("Separate runtime instance finished loading."); + return true; +} + async function run() { try { + const runArgs = await getArgs(); + console.log("Application arguments: " + runArgs.applicationArguments.join(' ')); + + if (is_browser) { + const dryOk = await dry_run(runArgs); + if (!dryOk) { + mono_exit(1, "Failed during dry run"); + return; + } + } + + // this is subsequent run with the actual tests. It will use whatever was cached in the previous run. + // This way, we are testing that the cached version works. const { dotnet, exit, INTERNAL } = await loadDotnet('./dotnet.js'); mono_exit = exit; - const runArgs = await getArgs(); if (runArgs.applicationArguments.length == 0) { mono_exit(1, "Missing required --run argument"); return; } - console.log("Application arguments: " + runArgs.applicationArguments.join(' ')); - dotnet - .withVirtualWorkingDirectory(runArgs.workingDirectory) - .withEnvironmentVariables(runArgs.environmentVariables) - .withDiagnosticTracing(runArgs.diagnosticTracing) - .withExitOnUnhandledError() - .withExitCodeLogging() - .withElementOnExit(); - - if (is_node) { - dotnet - .withEnvironmentVariable("NodeJSPlatform", process.platform) - .withAsyncFlushOnExit(); - - const modulesToLoad = runArgs.environmentVariables["NPM_MODULES"]; - if (modulesToLoad) { - dotnet.withModuleConfig({ - onConfigLoaded: (config) => { - loadNodeModules(config, INTERNAL.require, modulesToLoad) - } - }) - } - } - if (is_browser) { - dotnet.withEnvironmentVariable("IsWebSocketSupported", "true"); - } - if (runArgs.runtimeArgs.length > 0) { - dotnet.withRuntimeOptions(runArgs.runtimeArgs); - } - if (runArgs.debugging) { - dotnet.withDebugging(-1); - dotnet.withWaitingForDebugger(-1); - } - if (runArgs.forwardConsole) { - dotnet.withConsoleForwarding(); - } + configureRuntime(dotnet, runArgs, INTERNAL); + App.runtime = await dotnet.create(); App.runArgs = runArgs diff --git a/src/tasks/WasmAppBuilder/WasmAppBuilder.cs b/src/tasks/WasmAppBuilder/WasmAppBuilder.cs index 40a74d7f57975..c74b5816a26bf 100644 --- a/src/tasks/WasmAppBuilder/WasmAppBuilder.cs +++ b/src/tasks/WasmAppBuilder/WasmAppBuilder.cs @@ -334,7 +334,7 @@ protected override bool ExecuteInternal() { throw new LogAsErrorException($"PThreadPoolSize must be -1, 0 or positive, but got {PThreadPoolSize}"); } - else + else if (PThreadPoolSize > -1) { config.Extra["pthreadPoolSize"] = PThreadPoolSize; }