Skip to content

[WASM] Why JS mono AOT is so slow? #113979

Open
@elringus

Description

@elringus

I'm not sure if that's by design or a bug or I'm doing something wrong, but [JSExport/Import] interop in .NET 9 AOT is about 10 times slower than in Rust. Even considering the runtime overhead, 10x difference seems just too much.

Below are the samples I've used for the benchmark:

C#

.csproj

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
        <Configuration>Release</Configuration>
        <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
        <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
        <RunAOTCompilation>true</RunAOTCompilation>
        <OptimizationPreference>Speed</OptimizationPreference>
    </PropertyGroup>

    <ItemGroup>
        <AssemblyAttribute Include="System.Runtime.Versioning.SupportedOSPlatform">
            <_Parameter1>browser</_Parameter1>
        </AssemblyAttribute>
    </ItemGroup>
</Project>

Program.cs

using System.Runtime.InteropServices.JavaScript;
using System.Text.Json;
using System.Text.Json.Serialization;

public struct Data
{
    public string Info;
    public bool Ok;
    public int Revision;
    public string[] Messages;
}

[JsonSerializable(typeof(Data))]
internal partial class SourceGenerationContext : JsonSerializerContext;

public static partial class Program
{
    public static void Main () { }

    [JSExport]
    public static int EchoNumber ()
    {
        return GetNumber();
    }

    [JSExport]
    public static string EchoStruct ()
    {
        var json = GetStruct();
        var data = JsonSerializer.Deserialize(json, SourceGenerationContext.Default.Data);
        return JsonSerializer.Serialize(data, SourceGenerationContext.Default.Data);
    }

    [JSImport("getNumber", "x")]
    private static partial int GetNumber ();

    [JSImport("getStruct", "x")]
    private static partial string GetStruct ();
}

main.mjs

import { dotnet } from "./bin/Release/net9.0/browser-wasm/AppBundle/_framework/dotnet.js";

const { setModuleImports, getAssemblyExports, getConfig } = await dotnet
    .withDiagnosticTracing(false)
    .create();

setModuleImports("x", {
    getNumber: () => 42,
    getStruct: () => JSON.stringify({
        Info: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
        Ok: true,
        Revision: -112,
        Messages: ["foo", "bar", "baz", "nya", "far"]
    })
});

const exports = await getAssemblyExports(getConfig().mainAssemblyName);

await new Promise(res => setTimeout(res, 100));
let startTime = performance.now();
for (let i = 0; i < 100000; i++)
    exports.Program.EchoNumber();
console.log(`Echo number: ${(performance.now() - startTime).toFixed(2)} ms`);

await new Promise(res => setTimeout(res, 100));
startTime = performance.now();
for (let i = 0; i < 100000; i++)
    exports.Program.EchoStruct();
console.log(`Echo struct: ${(performance.now() - startTime).toFixed(2)} ms`);
Rust

Cargo.toml

[package]
name = "rust-wasm"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true

lib.rs

use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

#[derive(Serialize, Deserialize)]
pub struct Data {
    pub info: String,
    pub ok: bool,
    pub revision: i32,
    pub messages: Vec<String>,
}

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_name = getNumber)]
    fn get_number() -> i32;
    #[wasm_bindgen(js_name = getStruct)]
    fn get_struct() -> String;
}

#[wasm_bindgen(js_name = echoNumber)]
pub fn echo_number() -> i32 {
    get_number()
}

#[wasm_bindgen(js_name = echoStruct)]
pub fn echo_struct() -> String {
    let json = get_struct();
    let data: Data = serde_json::from_str(&json).unwrap();
    serde_json::to_string(&data).unwrap()
}

main.mjs

import { echoNumber, echoStruct } from './pkg/rust_wasm.js';

global.getNumber = () => 42;
global.getStruct = () => JSON.stringify({
    info: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
    ok: true,
    revision: -112,
    messages: ["foo", "bar", "baz", "nya", "far"]
});

await new Promise(res => setTimeout(res, 100));
let startTime = performance.now();
for (let i = 0; i < 100000; i++)
    echoNumber();
console.log(`Echo number: ${(performance.now() - startTime).toFixed(2)} ms`);

await new Promise(res => setTimeout(res, 100));
startTime = performance.now();
for (let i = 0; i < 100000; i++)
    echoStruct();
console.log(`Echo struct: ${(performance.now() - startTime).toFixed(2)} ms`);
dotnet publish -c Release
node main.mjs

Echo number: 65.49 ms
Echo struct: 1082.17 ms
wasm-pack build --target nodejs
node main.mjs

Echo number: 5.91 ms
Echo struct: 238.33 ms

Is this something to be expected and can't be improved or maybe I'm missing some configuration/optimization options?

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions