[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>
This commit is contained in:
Ömer Ağacan
2025-04-11 04:14:04 -07:00
committed by Commit Queue
parent 929657adc6
commit 6952a80978
8 changed files with 210 additions and 10 deletions
@@ -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.');
}
+1
View File
@@ -904,6 +904,7 @@ class ConstantCanonicalizer extends ConstantVisitor<void> {
translator.wasmI8Class,
translator.wasmAnyRefClass,
translator.wasmExternRefClass,
translator.wasmI31RefClass,
translator.wasmFuncRefClass,
translator.wasmEqRefClass,
translator.wasmStructRefClass,
+36 -3
View File
@@ -191,7 +191,11 @@ enum StaticIntrinsic {
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 @@ class Intrinsifier {
Member target = node.interfaceTarget;
Class cls = target.enclosingClass!;
// WasmAnyRef.isObject
// WasmAnyRef.isObject, WasmAnyRef.isI31
if (cls == translator.wasmAnyRefClass) {
assert(name == "isObject");
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 @@ class Intrinsifier {
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;
}
}
+3
View File
@@ -187,6 +187,7 @@ mixin KernelNodes {
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 @@ mixin KernelNodes {
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 =
+1
View File
@@ -294,6 +294,7 @@ class Translator with KernelNodes {
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),
+14 -4
View File
@@ -163,6 +163,7 @@ bool areEqualInJS(WasmExternRef? l, WasmExternRef? r) =>
// 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 @@ WasmExternRef? jsifyRaw(Object? o) {
}
}
@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;
+25
View File
@@ -35,6 +35,9 @@ class WasmAnyRef extends _WasmBase {
/// 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 @@ external WasmAnyRef? _internalizeNullable(WasmExternRef? ref);
@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 {
+63
View File
@@ -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);