[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);