[dart2wasm] JS interop: pass small ints as i31ref
In V8, the only way to pass a Wasm integer or float to JS without
allocation is by passing it as a 31-bit integer.
This can be done by:
1. Passing as `i32`. If the integer fits into 31 bits it's passed
without allocation.
2. Passing as externalized `i31ref`.
(1) requires importing the JS function with different signatures: for
each `int` argument we would need a signature with the `i32` as the Wasm
argument type, and another with `externref` (or `f64` if we want to pass
large integers as `f64`).
This is not feasible as with a JS function with N `int` arguments we
would need `2^N` imports. So we implement (2): we import each interop
function with one signature, passing `externref` as the argument, as
before. When the number fits into 31 bits we convert it to an `i31ref`
and externalize it. Otherwise we convert the number to `externref` as
before, by calling the JS function `(o) => o` imported with type `[f64]
-> [externref]`.
New benchmark checks `int` passing for small (31 bit) and large (larger
than 31 bit) integers. Results before:
WasmJSInterop.call.void.1ArgsSmi(RunTimeRaw): 0.020 ns.
WasmJSInterop.call.void.1ArgsInt(RunTimeRaw): 0.018 ns.
After:
WasmJSInterop.call.void.1ArgsSmi(RunTimeRaw): 0.014 ns.
WasmJSInterop.call.void.1ArgsInt(RunTimeRaw): 0.018 ns.
Issue: https://github.com/dart-lang/sdk/issues/60357
Change-Id: I749001e0e7e9784114415439298c2f3e0fb974b3
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/419880
Commit-Queue: Ömer Ağacan <omersa@google.com>
Reviewed-by: Martin Kustermann <kustermann@google.com>
diff --git a/benchmarks/WasmJSInterop/WasmJSInterop.dart b/benchmarks/WasmJSInterop/WasmJSInterop.dart
new file mode 100644
index 0000000..8b58d15
--- /dev/null
+++ b/benchmarks/WasmJSInterop/WasmJSInterop.dart
@@ -0,0 +1,64 @@
+// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// Benchmarks passing small and large integers to JS via `js_interop`.
+//
+// In Wasm, integers that fit into 31 bits can be passed without allocation by
+// by passing them as `i31ref`. To take advantage of this, dart2wasm checks the
+// size of the integer before passing to JS and passes the integer as `i31ref`
+// when possible.
+//
+// This benchmark compares performance of `int` passing for integers that fit
+// into 31 bits and those that don't.
+
+import 'dart:js_interop';
+
+import 'package:benchmark_harness/benchmark_harness.dart';
+
+@JS()
+external void eval(String code);
+
+// This returns `void` to avoid adding `dartify` overheads to the benchmark
+// results.
+// V8 can't figure out this doesn't do anything so the loop and JS calls aren't
+// eliminated.
+@JS()
+external void intId(int i);
+
+// Run benchmarked code for at least 2 seconds.
+const int minimumMeasureDurationMillis = 2000;
+
+class IntPassingBenchmark {
+ final int start;
+ final int end;
+
+ IntPassingBenchmark(this.start, this.end);
+
+ double measure() =>
+ BenchmarkBase.measureFor(() {
+ for (int i = start; i < end; i += 1) {
+ intId(i);
+ }
+ }, minimumMeasureDurationMillis) /
+ (end - start);
+}
+
+void main() {
+ eval('''
+ self.intId = (i) => i;
+ ''');
+
+ final maxI31 = (1 << 30) - 1;
+
+ final small = IntPassingBenchmark(maxI31 - 1000000, maxI31).measure();
+ report('WasmJSInterop.call.void.1ArgsSmi', small);
+
+ final large = IntPassingBenchmark(maxI31 + 1, maxI31 + 1000001).measure();
+ report('WasmJSInterop.call.void.1ArgsInt', large);
+}
+
+/// Reports in Golem-specific format.
+void report(String name, double nsPerCall) {
+ print('$name(RunTimeRaw): $nsPerCall ns.');
+}
diff --git a/pkg/dart2wasm/lib/dynamic_modules.dart b/pkg/dart2wasm/lib/dynamic_modules.dart
index 07c6f01..eba1412 100644
--- a/pkg/dart2wasm/lib/dynamic_modules.dart
+++ b/pkg/dart2wasm/lib/dynamic_modules.dart
@@ -904,6 +904,7 @@
translator.wasmI8Class,
translator.wasmAnyRefClass,
translator.wasmExternRefClass,
+ translator.wasmI31RefClass,
translator.wasmFuncRefClass,
translator.wasmEqRefClass,
translator.wasmStructRefClass,
diff --git a/pkg/dart2wasm/lib/intrinsics.dart b/pkg/dart2wasm/lib/intrinsics.dart
index b1ba317..3dfab22 100644
--- a/pkg/dart2wasm/lib/intrinsics.dart
+++ b/pkg/dart2wasm/lib/intrinsics.dart
@@ -191,7 +191,11 @@
storeFloatUnaligned('dart:ffi', null, '_storeFloatUnaligned'),
storeDouble('dart:ffi', null, '_storeDouble'),
storeDoubleUnaligned('dart:ffi', null, '_storeDoubleUnaligned'),
- ;
+ wasmI31RefNew('dart:_wasm', 'WasmI31Ref', 'fromI32'),
+ wasmI31RefExtensionsExternalize(
+ 'dart:_wasm', null, 'WasmI31RefExtensions|externalize'),
+ wasmI31RefExtensionsGetS('dart:_wasm', null, 'WasmI31RefExtensions|get_s'),
+ wasmI31RefExtensionsGetU('dart:_wasm', null, 'WasmI31RefExtensions|get_u');
final String library;
final String? cls;
@@ -408,12 +412,17 @@
Member target = node.interfaceTarget;
Class cls = target.enclosingClass!;
- // WasmAnyRef.isObject
+ // WasmAnyRef.isObject, WasmAnyRef.isI31
if (cls == translator.wasmAnyRefClass) {
- assert(name == "isObject");
- codeGen.translateExpression(receiver, w.RefType.any(nullable: false));
- b.ref_test(translator.topInfo.nonNullableType);
- return w.NumType.i32;
+ if (name == "isObject") {
+ codeGen.translateExpression(receiver, w.RefType.any(nullable: false));
+ b.ref_test(translator.topInfo.nonNullableType);
+ return w.NumType.i32;
+ } else if (name == "isI31") {
+ codeGen.translateExpression(receiver, w.RefType.any(nullable: false));
+ b.ref_test(w.RefType.i31(nullable: false));
+ return w.NumType.i32;
+ }
}
// WasmArrayRef.length
@@ -1499,6 +1508,30 @@
b.struct_get(translator.topInfo.struct, FieldIndex.classId);
b.emitClassIdRangeCheck(ranges);
return w.NumType.i32;
+
+ case StaticIntrinsic.wasmI31RefNew:
+ Expression value = node.arguments.positional[0];
+ codeGen.translateExpression(value, w.NumType.i32);
+ b.i31_new();
+ return w.RefType.i31(nullable: false);
+
+ case StaticIntrinsic.wasmI31RefExtensionsExternalize:
+ final value = node.arguments.positional.single;
+ codeGen.translateExpression(value, w.RefType.i31(nullable: false));
+ b.extern_convert_any();
+ return w.RefType.extern(nullable: false);
+
+ case StaticIntrinsic.wasmI31RefExtensionsGetS:
+ final value = node.arguments.positional.single;
+ codeGen.translateExpression(value, w.RefType.i31(nullable: false));
+ b.i31_get_s();
+ return w.NumType.i32;
+
+ case StaticIntrinsic.wasmI31RefExtensionsGetU:
+ final value = node.arguments.positional.single;
+ codeGen.translateExpression(value, w.RefType.i31(nullable: false));
+ b.i31_get_u();
+ return w.NumType.i32;
}
}
diff --git a/pkg/dart2wasm/lib/kernel_nodes.dart b/pkg/dart2wasm/lib/kernel_nodes.dart
index 8bd6019..8cc70a7 100644
--- a/pkg/dart2wasm/lib/kernel_nodes.dart
+++ b/pkg/dart2wasm/lib/kernel_nodes.dart
@@ -187,6 +187,7 @@
index.getClass("dart:_wasm", "WasmFunction");
late final Class wasmVoidClass = index.getClass("dart:_wasm", "WasmVoid");
late final Class wasmTableClass = index.getClass("dart:_wasm", "WasmTable");
+ late final Class wasmI31RefClass = index.getClass("dart:_wasm", "WasmI31Ref");
late final Class wasmArrayClass = index.getClass("dart:_wasm", "WasmArray");
late final Class immutableWasmArrayClass =
index.getClass("dart:_wasm", "ImmutableWasmArray");
@@ -212,6 +213,8 @@
index.getTopLevelProcedure("dart:_js_helper", "getInternalizedString");
late final Procedure areEqualInJS =
index.getTopLevelProcedure("dart:_js_helper", "areEqualInJS");
+ late final Procedure toJSNumber =
+ index.getTopLevelProcedure("dart:_js_helper", "toJSNumber");
// dart:_js_types procedures
late final Procedure jsStringEquals =
diff --git a/pkg/dart2wasm/lib/translator.dart b/pkg/dart2wasm/lib/translator.dart
index d292eb1..5311d9d 100644
--- a/pkg/dart2wasm/lib/translator.dart
+++ b/pkg/dart2wasm/lib/translator.dart
@@ -294,6 +294,7 @@
wasmF64Class: w.NumType.f64,
wasmAnyRefClass: const w.RefType.any(nullable: false),
wasmExternRefClass: const w.RefType.extern(nullable: false),
+ wasmI31RefClass: const w.RefType.i31(nullable: false),
wasmFuncRefClass: const w.RefType.func(nullable: false),
wasmEqRefClass: const w.RefType.eq(nullable: false),
wasmStructRefClass: const w.RefType.struct(nullable: false),
diff --git a/sdk/lib/_internal/wasm/lib/js_helper.dart b/sdk/lib/_internal/wasm/lib/js_helper.dart
index ede036b..881855d 100644
--- a/sdk/lib/_internal/wasm/lib/js_helper.dart
+++ b/sdk/lib/_internal/wasm/lib/js_helper.dart
@@ -163,6 +163,7 @@
// trip.
double toDartNumber(WasmExternRef? o) => JS<double>("o => o", o);
+@pragma('wasm:entry-point')
WasmExternRef? toJSNumber(double o) => JS<WasmExternRef?>("o => o", o);
bool toDartBool(WasmExternRef? o) => JS<bool>("o => o", o);
@@ -331,11 +332,20 @@
}
}
-@pragma('wasm:prefer-inline')
-WasmExternRef? jsifyInt(int o) => toJSNumber(o.toDouble());
+WasmExternRef? jsifyInt(int i) {
+ const int minI31 = -(1 << 30);
+ const int maxI31 = (1 << 30) - 1;
-@pragma('wasm:prefer-inline')
-WasmExternRef? jsifyNum(num o) => toJSNumber(o.toDouble());
+ // Pass small ints as `i31ref` to avoid allocation.
+ if (i >= minI31 && i <= maxI31) {
+ return WasmI31Ref.fromI32(WasmI32.fromInt(i)).externalize();
+ }
+
+ return toJSNumber(i.toDouble());
+}
+
+WasmExternRef? jsifyNum(num o) =>
+ o is int ? jsifyInt(o) : toJSNumber(unsafeCast<double>(o));
@pragma('wasm:prefer-inline')
WasmExternRef? jsifyJSValue(JSValue o) => o.toExternRef;
diff --git a/sdk/lib/_wasm/wasm_types.dart b/sdk/lib/_wasm/wasm_types.dart
index 932347e..9d62cef 100644
--- a/sdk/lib/_wasm/wasm_types.dart
+++ b/sdk/lib/_wasm/wasm_types.dart
@@ -35,6 +35,9 @@
/// Whether this reference is a Dart object.
external bool get isObject;
+ /// Whether this reference is an `i31`.
+ external bool get isI31;
+
/// Downcast `anyref` to a Dart object.
///
/// Will throw if the reference is not a Dart object.
@@ -78,6 +81,28 @@
@pragma("wasm:intrinsic")
external bool _wasmExternRefIsNull(WasmExternRef? ref);
+/// The Wasm `i31ref` type.
+@pragma("wasm:entry-point")
+class WasmI31Ref extends _WasmBase {
+ /// Wasm `i31.new` instruction.
+ @pragma("wasm:intrinsic")
+ external factory WasmI31Ref.fromI32(WasmI32 i);
+}
+
+extension WasmI31RefExtensions on WasmI31Ref {
+ /// Convert a `i31ref` to `externref` with `extern.convert_any`.
+ @pragma("wasm:intrinsic")
+ external WasmExternRef? externalize();
+
+ /// Wasm `i32.get_s` instruction.
+ @pragma("wasm:intrinsic")
+ external WasmI32 get_s();
+
+ /// Wasm `i32.get_u` instruction.
+ @pragma("wasm:intrinsic")
+ external WasmI32 get_u();
+}
+
/// The Wasm `funcref` type.
@pragma("wasm:entry-point")
class WasmFuncRef extends _WasmBase {
diff --git a/tests/web/wasm/js_int_passing_test.dart b/tests/web/wasm/js_int_passing_test.dart
new file mode 100644
index 0000000..615d26e
--- /dev/null
+++ b/tests/web/wasm/js_int_passing_test.dart
@@ -0,0 +1,63 @@
+// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// Test dart2wasm's `int` passing to `js_interop` functions.
+//
+// To avoid allocations when passing an `int` to JS in V8, dart2wasm passes
+// `int`s as externalized `i31ref`s. Test the `i31ref` edge cases:
+//
+// - Min i31 (should be passed as externalized `i31ref`)
+// - Max i31 (should be passed as externalized `i31ref`)
+// - Min i31 - 1 (should be passed as externalized `f64`)
+// - Max i31 + 1 (should be passed as externalized `f64`)
+
+// The option below allows importing `dart:_wasm`.
+// dart2wasmOptions=--extra-compiler-option=--enable-experimental-wasm-interop
+
+import 'dart:_wasm';
+import 'dart:js_interop';
+
+import 'package:expect/expect.dart';
+
+@JS('test')
+external int intTest(int i);
+
+@JS('test')
+external num numTest(num i);
+
+void main() {
+ const int maxI31 = (1 << 30) - 1;
+ const int minI31 = -(1 << 30);
+
+ int i31refs = 0;
+ int others = 0;
+
+ setReturnIdentity =
+ ((JSAny js) {
+ final isI31Ref = externRefForJSAny(js).internalize()!.isI31;
+
+ if (isI31Ref) {
+ i31refs += 1;
+ } else {
+ others += 1;
+ }
+
+ final dartValue = (js.dartify() as double).toInt();
+ Expect.equals(isI31Ref, dartValue >= minI31 && dartValue <= maxI31);
+ return js;
+ }).toJS;
+
+ for (int i in <int>[maxI31, maxI31 + 1, minI31, minI31 - 1]) {
+ returnIdentity(i);
+ }
+
+ Expect.equals(2, i31refs);
+ Expect.equals(2, others);
+}
+
+@JS('globalThis.returnIdentity')
+external void set setReturnIdentity(JSFunction fun);
+
+@JS('globalThis.returnIdentity')
+external JSAny returnIdentity(int i);