[benchmark_harness]: add a bench command (#2091)
diff --git a/.github/workflows/benchmark_harness.yaml b/.github/workflows/benchmark_harness.yaml
index 5e783a4..4b83952 100644
--- a/.github/workflows/benchmark_harness.yaml
+++ b/.github/workflows/benchmark_harness.yaml
@@ -61,6 +61,11 @@
- uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c
with:
sdk: ${{ matrix.sdk }}
+ # Node 22 has wasmGC enabled, which allows the wasm tests to run!
+ - name: Setup Node.js 22
+ uses: actions/setup-node@v3
+ with:
+ node-version: 22
- id: install
name: Install dependencies
run: dart pub get
diff --git a/pkgs/benchmark_harness/CHANGELOG.md b/pkgs/benchmark_harness/CHANGELOG.md
index f0e24bf..87864a0 100644
--- a/pkgs/benchmark_harness/CHANGELOG.md
+++ b/pkgs/benchmark_harness/CHANGELOG.md
@@ -1,4 +1,6 @@
-## 2.3.2-wip
+## 2.4.0-wip
+
+- Added a `bench` command.
## 2.3.1
diff --git a/pkgs/benchmark_harness/README.md b/pkgs/benchmark_harness/README.md
index f156f23..d477808 100644
--- a/pkgs/benchmark_harness/README.md
+++ b/pkgs/benchmark_harness/README.md
@@ -114,3 +114,56 @@
This is the average amount of time it takes to run `run()` 10 times for
`BenchmarkBase` and once for `AsyncBenchmarkBase`.
> µs is an abbreviation for microseconds.
+
+## `bench` command
+
+A convenience command available in `package:benchmark_harness`.
+
+If a package depends on `benchmark_harness`, invoke the command by running
+
+```shell
+dart run benchmark_harness:bench
+```
+
+If not, you can use this command by activating it.
+
+```shell
+dart pub global activate benchmark_harness
+dart pub global run benchmark_harness:bench
+```
+
+Output from `dart run benchmark_harness:bench --help`
+
+```
+Runs a dart script in a number of runtimes.
+
+Meant to make it easy to run a benchmark executable across runtimes to validate
+performance impacts.
+
+-f, --flavor
+ [aot] Compile and run as a native binary.
+ [jit] Run as-is without compilation, using the just-in-time (JIT) runtime.
+ [js] Compile to JavaScript and run on node.
+ [wasm] Compile to WebAssembly and run on node.
+
+ --target The target script to compile and run.
+ (defaults to "benchmark/benchmark.dart")
+-h, --help Print usage information and quit.
+-v, --verbose Print the full stack trace if an exception is thrown.
+```
+
+Example usage:
+
+```shell
+dart run benchmark_harness:bench --flavor aot --target example/template.dart
+
+AOT - COMPILE
+/dart_installation/dart-sdk/bin/dart compile exe example/template.dart -o /temp_dir/bench_1747680526905_GtfAeM/out.exe
+
+Generated: /temp_dir/bench_1747680526905_GtfAeM/out.exe
+
+AOT - RUN
+/temp_dir/bench_1747680526905_GtfAeM/out.exe
+
+Template(RunTime): 0.005620051244379949 us.
+```
\ No newline at end of file
diff --git a/pkgs/benchmark_harness/bin/bench.dart b/pkgs/benchmark_harness/bin/bench.dart
new file mode 100644
index 0000000..43e1a91
--- /dev/null
+++ b/pkgs/benchmark_harness/bin/bench.dart
@@ -0,0 +1,39 @@
+// 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.
+
+import 'dart:io';
+
+import 'package:benchmark_harness/src/bench_command/bench_options.dart';
+import 'package:benchmark_harness/src/bench_command/compile_and_run.dart';
+
+Future<void> main(List<String> args) async {
+ BenchOptions? options;
+
+ try {
+ options = BenchOptions.fromArgs(args);
+ if (options.help) {
+ print('''
+\nRuns a dart script in a number of runtimes.
+
+Meant to make it easy to run a benchmark executable across runtimes to validate
+performance impacts.
+''');
+ print(BenchOptions.usage);
+ return;
+ }
+
+ await compileAndRun(options);
+ } on FormatException catch (e) {
+ print(e.message);
+ print(BenchOptions.usage);
+ exitCode = 64; // command line usage error
+ } on BenchException catch (e, stack) {
+ print(e.message);
+ if (options?.verbose ?? true) {
+ print(e);
+ print(stack);
+ }
+ exitCode = e.exitCode;
+ }
+}
diff --git a/pkgs/benchmark_harness/lib/src/bench_command/bench_options.dart b/pkgs/benchmark_harness/lib/src/bench_command/bench_options.dart
new file mode 100644
index 0000000..e1ffa1c
--- /dev/null
+++ b/pkgs/benchmark_harness/lib/src/bench_command/bench_options.dart
@@ -0,0 +1,82 @@
+// 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.
+
+import 'package:args/args.dart';
+
+enum RuntimeFlavor {
+ aot(help: 'Compile and run as a native binary.'),
+ jit(
+ help: 'Run as-is without compilation, '
+ 'using the just-in-time (JIT) runtime.',
+ ),
+ js(help: 'Compile to JavaScript and run on node.'),
+ wasm(help: 'Compile to WebAssembly and run on node.');
+
+ const RuntimeFlavor({required this.help});
+
+ final String help;
+}
+
+class BenchOptions {
+ BenchOptions({
+ required this.flavor,
+ required this.target,
+ this.help = false,
+ this.verbose = false,
+ }) {
+ if (!help && flavor.isEmpty) {
+ // This is the wrong exception to use, except that it's caught in the
+ // program, so it makes implementation easy.
+ throw const FormatException('At least one `flavor` must be provided', 64);
+ }
+ }
+
+ factory BenchOptions.fromArgs(List<String> args) {
+ final result = _parserForBenchOptions.parse(args);
+
+ if (result.rest.isNotEmpty) {
+ throw FormatException('All arguments must be provided via `--` options. '
+ 'Not sure what to do with "${result.rest.join()}".');
+ }
+
+ return BenchOptions(
+ flavor:
+ result.multiOption('flavor').map(RuntimeFlavor.values.byName).toSet(),
+ target: result.option('target')!,
+ help: result.flag('help'),
+ verbose: result.flag('verbose'),
+ );
+ }
+
+ final String target;
+
+ final Set<RuntimeFlavor> flavor;
+
+ final bool help;
+
+ final bool verbose;
+
+ static String get usage => _parserForBenchOptions.usage;
+
+ static final _parserForBenchOptions = ArgParser()
+ ..addMultiOption('flavor',
+ abbr: 'f',
+ allowed: RuntimeFlavor.values.map((e) => e.name),
+ allowedHelp: {
+ for (final flavor in RuntimeFlavor.values) flavor.name: flavor.help
+ })
+ ..addOption('target',
+ defaultsTo: 'benchmark/benchmark.dart',
+ help: 'The target script to compile and run.')
+ ..addFlag('help',
+ defaultsTo: false,
+ negatable: false,
+ help: 'Print usage information and quit.',
+ abbr: 'h')
+ ..addFlag('verbose',
+ defaultsTo: false,
+ negatable: false,
+ help: 'Print the full stack trace if an exception is thrown.',
+ abbr: 'v');
+}
diff --git a/pkgs/benchmark_harness/lib/src/bench_command/compile_and_run.dart b/pkgs/benchmark_harness/lib/src/bench_command/compile_and_run.dart
new file mode 100644
index 0000000..bd8f292
--- /dev/null
+++ b/pkgs/benchmark_harness/lib/src/bench_command/compile_and_run.dart
@@ -0,0 +1,186 @@
+// 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.
+
+import 'dart:io';
+
+import 'bench_options.dart';
+
+// TODO(kevmoo): allow the user to specify custom flags – for compile and/or run
+
+Future<void> compileAndRun(BenchOptions options) async {
+ if (!FileSystemEntity.isFileSync(options.target)) {
+ throw BenchException(
+ 'The target Dart program `${options.target}` does not exist',
+ 2, // standard bash code for file doesn't exist
+ );
+ }
+
+ for (var mode in options.flavor) {
+ await _Runner(flavor: mode, target: options.target).run();
+ }
+}
+
+class BenchException implements Exception {
+ const BenchException(this.message, this.exitCode) : assert(exitCode > 0);
+ final String message;
+ final int exitCode;
+
+ @override
+ String toString() => 'BenchException: $message ($exitCode)';
+}
+
+/// Base name for output files.
+const _outputFileRoot = 'out';
+
+/// Denote the "stage" of the compile/run step for logging.
+enum _Stage { compile, run }
+
+/// Base class for runtime-specific runners.
+abstract class _Runner {
+ _Runner._({required this.target, required this.flavor})
+ : assert(FileSystemEntity.isFileSync(target), '$target is not a file');
+
+ factory _Runner({required RuntimeFlavor flavor, required String target}) {
+ return (switch (flavor) {
+ RuntimeFlavor.jit => _JITRunner.new,
+ RuntimeFlavor.aot => _AOTRunner.new,
+ RuntimeFlavor.js => _JSRunner.new,
+ RuntimeFlavor.wasm => _WasmRunner.new,
+ })(target: target);
+ }
+
+ final String target;
+ final RuntimeFlavor flavor;
+ late Directory _tempDirectory;
+
+ /// Executes the compile and run cycle.
+ ///
+ /// Takes care of creating and deleting the corresponding temp directory.
+ Future<void> run() async {
+ _tempDirectory = Directory.systemTemp
+ .createTempSync('bench_${DateTime.now().millisecondsSinceEpoch}_');
+ try {
+ await _runImpl();
+ } finally {
+ _tempDirectory.deleteSync(recursive: true);
+ }
+ }
+
+ /// Overridden in implementations to handle the compile and run cycle.
+ Future<void> _runImpl();
+
+ /// Executes the specific [executable] with the provided [args].
+ ///
+ /// Also prints out a nice message before execution denoting the [flavor] and
+ /// the [stage].
+ Future<void> _runProc(
+ _Stage stage, String executable, List<String> args) async {
+ print('''
+\n${flavor.name.toUpperCase()} - ${stage.name.toUpperCase()}
+$executable ${args.join(' ')}
+''');
+
+ final proc = await Process.start(executable, args,
+ mode: ProcessStartMode.inheritStdio);
+
+ final exitCode = await proc.exitCode;
+
+ if (exitCode != 0) {
+ throw ProcessException(executable, args, 'Process errored', exitCode);
+ }
+ }
+
+ String _outputFile(String ext) =>
+ _tempDirectory.uri.resolve('$_outputFileRoot.$ext').toFilePath();
+}
+
+class _JITRunner extends _Runner {
+ _JITRunner({required super.target}) : super._(flavor: RuntimeFlavor.jit);
+
+ @override
+ Future<void> _runImpl() async {
+ await _runProc(_Stage.run, Platform.executable, [target]);
+ }
+}
+
+class _AOTRunner extends _Runner {
+ _AOTRunner({required super.target}) : super._(flavor: RuntimeFlavor.aot);
+
+ @override
+ Future<void> _runImpl() async {
+ final outFile = _outputFile('exe');
+ await _runProc(_Stage.compile, Platform.executable, [
+ 'compile',
+ 'exe',
+ target,
+ '-o',
+ outFile,
+ ]);
+
+ await _runProc(_Stage.run, outFile, []);
+ }
+}
+
+class _JSRunner extends _Runner {
+ _JSRunner({required super.target}) : super._(flavor: RuntimeFlavor.js);
+
+ @override
+ Future<void> _runImpl() async {
+ final outFile = _outputFile('js');
+ await _runProc(_Stage.compile, Platform.executable, [
+ 'compile',
+ 'js',
+ target,
+ '-O4', // default for Flutter
+ '-o',
+ outFile,
+ ]);
+
+ await _runProc(_Stage.run, 'node', [outFile]);
+ }
+}
+
+class _WasmRunner extends _Runner {
+ _WasmRunner({required super.target}) : super._(flavor: RuntimeFlavor.wasm);
+
+ @override
+ Future<void> _runImpl() async {
+ final outFile = _outputFile('wasm');
+ await _runProc(_Stage.compile, Platform.executable, [
+ 'compile',
+ 'wasm',
+ target,
+ '-O2', // default for Flutter
+ '-o',
+ outFile,
+ ]);
+
+ final jsFile =
+ File.fromUri(_tempDirectory.uri.resolve('$_outputFileRoot.js'));
+ jsFile.writeAsStringSync(_wasmInvokeScript);
+
+ await _runProc(_Stage.run, 'node', [jsFile.path]);
+ }
+
+ static const _wasmInvokeScript = '''
+import { readFile } from 'node:fs/promises'; // For async file reading
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+
+// Get the current directory name
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const wasmFilePath = join(__dirname, '$_outputFileRoot.wasm');
+const wasmBytes = await readFile(wasmFilePath);
+
+const mjsFilePath = join(__dirname, '$_outputFileRoot.mjs');
+const dartModule = await import(mjsFilePath);
+const {compile} = dartModule;
+
+const compiledApp = await compile(wasmBytes);
+const instantiatedApp = await compiledApp.instantiate({});
+await instantiatedApp.invokeMain();
+''';
+}
diff --git a/pkgs/benchmark_harness/pubspec.yaml b/pkgs/benchmark_harness/pubspec.yaml
index 8561c2a..b2ad9a0 100644
--- a/pkgs/benchmark_harness/pubspec.yaml
+++ b/pkgs/benchmark_harness/pubspec.yaml
@@ -1,5 +1,5 @@
name: benchmark_harness
-version: 2.3.2-wip
+version: 2.4.0-wip
description: The official Dart project benchmark harness.
repository: https://github.com/dart-lang/tools/tree/main/pkgs/benchmark_harness
issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Abenchmark_harness
@@ -10,9 +10,13 @@
environment:
sdk: ^3.2.0
+dependencies:
+ args: ^2.5.0
+
dev_dependencies:
- build_runner: ^2.0.0
- build_web_compilers: ^4.0.0
dart_flutter_team_lints: ^3.0.0
- path: ^1.8.0
- test: ^1.16.0
+ path: ^1.9.0
+ test: ^1.25.7
+
+executables:
+ bench:
diff --git a/pkgs/benchmark_harness/test/bench_command_test.dart b/pkgs/benchmark_harness/test/bench_command_test.dart
new file mode 100644
index 0000000..7760416
--- /dev/null
+++ b/pkgs/benchmark_harness/test/bench_command_test.dart
@@ -0,0 +1,127 @@
+// 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.
+
+@TestOn('vm')
+library;
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:benchmark_harness/src/bench_command/bench_options.dart';
+import 'package:benchmark_harness/src/bench_command/compile_and_run.dart';
+import 'package:test/test.dart';
+
+void main() {
+ test('readme', () async {
+ final output =
+ await Process.run(Platform.executable, ['bin/bench.dart', '--help']);
+
+ expect(output.exitCode, 0);
+ // Sadly, the help output likes to include trailing spaces that don't
+ // copy paste nicely or consistently into the README.
+ final trimmed = LineSplitter.split(output.stdout as String)
+ .map((e) => e.trimRight())
+ .join('\n');
+
+ final readmeFile = File('README.md').readAsStringSync();
+
+ expect(
+ readmeFile,
+ contains(trimmed),
+ );
+ });
+
+ group('invoke the command', () {
+ late final Directory tempDir;
+ late final String testFilePath;
+
+ setUpAll(() {
+ tempDir = Directory.systemTemp.createTempSync('benchtest_');
+ testFilePath = tempDir.uri.resolve('input.dart').toFilePath();
+ File(testFilePath)
+ ..create()
+ ..writeAsStringSync(_testDartFile);
+ });
+
+ tearDownAll(() {
+ tempDir.deleteSync(recursive: true);
+ });
+
+ group('BenchOptions.fromArgs', () {
+ test('options parsing', () async {
+ final options = BenchOptions.fromArgs(
+ ['--flavor', 'aot,jit', '--target', testFilePath],
+ );
+
+ await expectLater(
+ () => compileAndRun(options),
+ prints(
+ stringContainsInOrder([
+ 'AOT - COMPILE',
+ testFilePath,
+ 'AOT - RUN',
+ 'JIT - RUN',
+ testFilePath,
+ ]),
+ ),
+ );
+ });
+
+ test('rest args not supported', () async {
+ expect(
+ () => BenchOptions.fromArgs(
+ ['--flavor', 'aot,jit', testFilePath],
+ ),
+ throwsFormatException,
+ );
+ });
+ });
+
+ for (var bench in RuntimeFlavor.values) {
+ test('$bench', () async {
+ await expectLater(
+ () => compileAndRun(
+ BenchOptions(flavor: {bench}, target: testFilePath)),
+ prints(
+ stringContainsInOrder(
+ [
+ if (bench != RuntimeFlavor.jit) ...[
+ '${bench.name.toUpperCase()} - COMPILE',
+ testFilePath,
+ ],
+ '${bench.name.toUpperCase()} - RUN'
+ ],
+ ),
+ ),
+ );
+ }, skip: _skipWasm(bench));
+ }
+ });
+}
+
+// Can remove this once the min tested SDK on GitHub is >= 3.7
+String? _skipWasm(RuntimeFlavor flavor) {
+ if (flavor != RuntimeFlavor.wasm) {
+ return null;
+ }
+ final versionBits = Platform.version.split('.');
+ final versionValues = versionBits.take(2).map(int.parse).toList();
+
+ return switch ((versionValues[0], versionValues[1])) {
+ // If major is greater than 3, it's definitely >= 3.7
+ (int m, _) when m > 3 => null,
+ // If major is 3, check the minor version
+ (3, int n) when n >= 7 => null,
+ // All other cases (major < 3, or major is 3 but minor < 7)
+ _ => 'Requires Dart >= 3.7',
+ };
+}
+
+const _testDartFile = '''
+void main() {
+ // outputs 0 is JS
+ // 8589934592 everywhere else
+ print(1 << 33);
+}
+''';