Dart の性能を実測:JIT と AOT、ウォームアップ、メモリ

Dart 3.11 で実測。hello world は dart run 339ms、exe 17ms。JIT ウォームアップ 19.5→4.8ms、List<int> 76MB 対 Uint8List 9.5MB、+= の文字列連結 1,974ms。

シリーズ:Dart 講座 6/6(上級)。 正しい Dart は書けるようになり、次はそのコードがどれだけ速く動き、メモリがどこへ行くのかを知りたい人向けです。読み終えると、Flutter のデバッグビルドが遅く感じる理由を説明でき、CLI ツールに合う dart compile の形式を選び、大きなバッファに typed data を使い、自分のコードではなくコンパイラを測っているベンチマークを見抜けるようになります。

レビューでよく見るのは「デバッグ実行で遅かったので最適化しました」というプルリクエストです。Dart の性能の話は、たいてい別々の 2 つの数字を混ぜています。プログラムが起動するまでの時間と、動き始めてからの速さです。2 つは別の仕組み(dart run の JIT コンパイラと、dart compile exe やすべての Flutter リリースビルドが使う AOT コンパイラ)から来ており、直し方も違います。この記事は両方を測り、続いて選ぶデータ構造のメモリコストと、Flutter 風のコードによく出る間違い 2 つを測ります。

実行環境: Dart SDK 3.11.5(stable)、Apple Silicon Mac(M1 Pro、8 コア、16GB)、macOS。「JIT」は dart run file.dart、「AOT」は dart compile exe で作ったバイナリです。時間には何回の中央値かを書き、シードは固定しています。package:benchmark_harness はこのマシンに入っておらず、入れてもいません。そのため時間はすべて Stopwatch と手動のウォームアップで測っており、その限界は後半の注意点で扱います。

JIT と AOT はそれぞれ何か

  • JIT(just-in-time、実行時コンパイル)。 dart run はソースを kernel(中間表現)に変換して VM を起動し、プログラムが動いている間に関数を機械語へコンパイルします。最初は手早く、よく呼ばれるコードは観察した情報をもとに最適化してコンパイルし直します。ホットリロードができるのはこのためで、Flutter の debug ビルドはこの方式です。
  • AOT(ahead-of-time、事前コンパイル)。 dart compile exe は実行前にプログラム全体を機械語にし、小さなランタイムを同梱します。ユーザーの端末でコンパイラは動きません。Flutter の profile と release ビルドが AOT です。

アプリ開発者にとっての結論は 1 つ。デバッグビルドで性能を判断しないことです。下の数字はその差のうち起動の部分を示します。UI の部分(フレームワークのデバッグ専用チェック)はここでは測っていません。

起動時間を測る:hello world を動かす 6 つの方法

void main() => print('hello');

それぞれ小さな Python スクリプト(subprocess.run、time.perf_counter)から 7 回起動した中央値です。

dart run hello.dart        median 339 ms
dart hello.dart            median 204 ms
dart hello.dill            median 118 ms   (dart compile kernel)
dart hello.jit             median 103 ms   (dart compile jit-snapshot)
dartaotruntime hello.aot   median  19 ms   (dart compile aot-snapshot)
./hello_exe                median  17 ms   (dart compile exe)
起動時間の棒グラフ。dart run hello.dart は 339 ミリ秒、dart hello.dart は 204、カーネルファイルは 118、JIT スナップショットは 103、dartaotruntime で動かす AOT スナップショットは 19、dart compile exe の実行ファイルは 17 ミリ秒。
1 行の hello world を実行するまでの実時間。Python スクリプトから 7 回起動した中央値。Dart 3.11.5、Apple M1 Pro。

dart run は同じファイルを素の dart で動かすより 135ms 遅くなりました。起動前にパッケージ設定を探すなどの追加の仕事をしていますが、その内訳まではさらに分解していません。kernel ファイルと JIT スナップショットはパースを省き、AOT 形式はコンパイラそのものを省きます。作りたての実行ファイルの初回起動は 1 度だけ 874ms かかりました(おそらく macOS が新しいバイナリを初回実行時に検査するため)。中央値を載せているのはそのためです。

1 日に千回動く CLI ツールなら、339ms と 17ms の差が使い心地のすべてです。同じアプリでも Flutter のリリースビルドがデバッグビルドよりずっと速く起動するのも同じ理由です。

定常状態と JIT のウォームアップ曲線

起動時間はスループットではありません。動き始めてからの速さを比べるため、同じ「一覧画面」の処理を 1 つのプロセスで 30 回測りました。固定シードの生成器でオブジェクトを 2 万個作り、絞り込み、並べ替え、文字列に整形します。

class Item {
  final int id;
  final String name;
  final double price;
  Item(this.id, this.name, this.price);
}

int work(int seed) {
  var x = seed;
  final items = List<Item>.generate(20000, (i) {
    x = (x * 1103515245 + 12345) & 0x7fffffff; // fixed-seed LCG
    return Item(i, 'item$i', (x % 100000) / 100);
  });
  final cheap = items.where((e) => e.price < 500).toList()
    ..sort((a, b) => a.price.compareTo(b.price));
  final labels = cheap.map((e) => '${e.name}: ${e.price.toStringAsFixed(2)}').toList();
  return labels.length + labels.last.length;
}

void main(List<String> args) {
  final rounds = args.isEmpty ? 30 : int.parse(args[0]);
  var check = 0;
  final times = <double>[];
  final total = Stopwatch()..start();
  for (var r = 0; r < rounds; r++) {
    final sw = Stopwatch()..start();
    check += work(42 + r);
    times.add(sw.elapsedMicroseconds / 1000);
  }
  print('rounds: ${times.map((t) => t.toStringAsFixed(1)).join(' ')}');
  final last10 = times.sublist(times.length - 10)..sort();
  print('first ${times.first.toStringAsFixed(1)} ms, median of last 10 '
      '${last10[5].toStringAsFixed(1)} ms, total ${total.elapsedMilliseconds} ms, check $check');
}

それぞれ 1 回実行した結果(最初の 10 ラウンド):

JIT  rounds: 19.5 6.7 6.0 6.2 4.9 4.5 5.4 4.8 4.7 4.9 ...
     first 19.5 ms, median of last 10 4.8 ms, total 165 ms, check 300921
AOT  rounds: 5.4 5.7 5.3 4.4 4.7 4.6 5.0 4.9 4.9 5.3 ...
     first 5.4 ms, median of last 10 4.5 ms, total 141 ms, check 300921

3 回の実行で、JIT の 1 ラウンド目は 20.8 / 19.1 / 19.5ms、定常状態は 5.1 / 4.7 / 4.8ms。AOT の 1 ラウンド目は 5.3 / 5.4 / 5.4ms、定常状態は 4.6 / 4.5 / 4.5ms でした。check の値が同じなので、両者が同じ仕事をしたことも確認できます。

棒グラフ。JIT では 1 ラウンド目 19.5 ミリ秒、2 ラウンド目 6.7、定常状態 4.8。AOT 実行ファイルは 1 ラウンド目 5.4 ミリ秒、定常状態 4.5。
同じリスト処理(2 万個のオブジェクト生成・絞り込み・並べ替え・整形)1 ラウンドの時間。JIT は最初が遅く追いつき、AOT は 1 ラウンド目から速い。3 回の中央値。

読み方はこうです。JIT の 1 ラウンド目はコンパイラがまだ働いているので 4 倍遅く、5 ラウンド目あたりで差は 10% 以内に縮まります。きつい整数ループ(第 5 回の素数カウント、5 ラウンド)では差がもっと大きく、AOT はラウンドあたり 274〜291ms、JIT は 352〜390ms でした。どちらも「AOT が常に 25% 速い」という意味ではありません。差はコード次第で、実行時の型情報が効くコードでは JIT が勝つこともあります。

dart compile の形式

dart compile をサブコマンドなしで打つと、6 つのターゲットが表示されます。hello world でそのうち 4 つを動かしました。

形式 コマンド 出力サイズ 実行方法 ここで実行
exe dart compile exe hello.dart 5,587,776 B ./hello(SDK 不要) した、17 ms
aot-snapshot dart compile aot-snapshot 932,576 B dartaotruntime hello.aot した、19 ms
jit-snapshot dart compile jit-snapshot 5,748,368 B dart hello.jit した、103 ms
kernel dart compile kernel 8,219,448 B dart hello.dill した、118 ms
js dart compile js 12,441 文字 ブラウザ / Node コンパイルのみ
wasm dart compile wasm — WasmGC 対応ブラウザ 試していない

実際に動かして初めて分かったこと:

  • jit-snapshot はコンパイル中にプログラムを実行します。 コンパイルの段階で hello が表示されました。「学習済み」のスナップショットを作るためです。main がファイルを消したりサーバーを呼んだりするコードなら、ビルド時にそれをやってしまいます。
  • exe は AOT スナップショットにランタイムを付けたものです。dartaotruntime 自体が 4,680,480 バイトで、932,576 + 4,680,480 は exe の 5,587,776 に近い値です。ベンチマークプログラムの exe は hello world より 49KB 大きいだけで、サイズの大半は固定費です。コンパイルには 0.88〜0.92 秒かかりました。
  • kernel ファイルが 8.2MB なのは、既定でプラットフォームライブラリ全体を含むからです。--no-link-platform を付けると 712 バイトで、dart でそのまま動きましたが、ヘルプにあるとおり AOT コンパイラの入力には使えなくなります。
  • AOT には dart:mirrors(リフレクション)がありません。 下のプログラムは dart run では kim と表示しますが、コンパイルは失敗します。
import 'dart:mirrors';

class User { String name = 'kim'; }

void main() {
  final m = reflect(User());
  print(m.getField(#name).reflectee);
}
mirrors.dart:1:8: Error: Dart library 'dart:mirrors' is not available on this platform.
import 'dart:mirrors';
       ^
Error: AOT compilation failed
Bad state: Generating AOT kernel dill failed!

リリースでは常に AOT の Flutter にリフレクションがなく、代わりにコード生成(json_serializable、freezed)を使う理由でもあります。

メモリ:List と typed data

List<int> は要素ごとにポインタサイズの枠を使います(小さな整数はタグ付きで枠の中に直接入りますが、64 ビット VM では枠 1 つが 8 バイト)。Uint8List と Int32List は 1 バイト、4 バイトの値をそのまま持ちます。値(0〜255)を 1,000 万個入れ、前後の ProcessInfo.currentRss を読みました。方式ごとに新しい AOT プロセスです。

import 'dart:io';
import 'dart:typed_data';

String mb(int bytes) => (bytes / (1024 * 1024)).toStringAsFixed(1);

void main(List<String> args) {
  const n = 10000000; // 10 million values, each 0..255
  final before = ProcessInfo.currentRss;
  final Object data;
  switch (args.first) {
    case 'list':
      data = List<int>.generate(n, (i) => i & 0xff);
    case 'growable':
      final l = <int>[];
      for (var i = 0; i < n; i++) { l.add(i & 0xff); }
      data = l;
    case 'int32':
      final l = Int32List(n);
      for (var i = 0; i < n; i++) { l[i] = i & 0xff; }
      data = l;
    case 'uint8':
      final l = Uint8List(n);
      for (var i = 0; i < n; i++) { l[i] = i & 0xff; }
      data = l;
    default:
      data = const [];
  }
  final after = ProcessInfo.currentRss;
  print('${args.first.padRight(9)} rss before ${mb(before)} MB, after ${mb(after)} MB, '
      'delta ${mb(after - before)} MB, maxRss ${mb(ProcessInfo.maxRss)} MB '
      '(len ${(data as List).length})');
}
none      rss before 13.7 MB, after 13.7 MB, delta 0.0 MB, maxRss 13.7 MB (len 0)
list      rss before 13.8 MB, after 90.1 MB, delta 76.3 MB, maxRss 90.1 MB (len 10000000)
growable  rss before 13.7 MB, after 238.1 MB, delta 224.4 MB, maxRss 238.1 MB (len 10000000)
int32     rss before 13.8 MB, after 51.9 MB, delta 38.2 MB, maxRss 51.9 MB (len 10000000)
uint8     rss before 13.7 MB, after 23.3 MB, delta 9.5 MB, maxRss 23.3 MB (len 10000000)
1,000 万個の値を持ったときのメモリ増加の棒グラフ。add で埋めた可変長リストは 224.4 メガバイト、List<int>.generate は 76.3、Int32List は 38.2、Uint8List は 9.5 メガバイトで、要素あたり 8・4・1 バイトに一致する。
小さな整数 1,000 万個を保持した後の常駐メモリ(ProcessInfo.currentRss)の増加。AOT 実行ファイル、行ごとに新しいプロセス。

固定長の数値は計算とぴったり合います。1,000 万 × 8B = 76.3MiB、× 4B = 38.1MiB、× 1B = 9.5MiB。アプリ開発者向けの注意が 1 つあり、64 ビットの Android と iOS の Flutter は圧縮ポインタ(compressed pointers)でビルドされた VM を使い、リストの枠は 4 バイトです。スマホでは List<int> が Int32List に近くなるはずです。スマホでは測っておらず、typed data はどこでも同じサイズです。意外なのは可変長リストで、同じ 1,000 万個で 224.4MB。私たちの解釈(VM 内部までは確認していない)では、add() は格納領域を 2 倍ずつ広げるので最終バッファに余りがあり、それ以前の小さなバッファは GC が解放しても、まだ OS に返していないということです。サイズが分かっているなら List.generate か List.filled、値がバイトなら Uint8List を使います。File.readAsBytes、http のレスポンス本文、画像コーデックが返すのも Uint8List です。

JIT の数値はここでは役に立ちませんでした。 dart run では割り当て前からプロセスが約 154MB を使っており(コンパイラとそのデータ)、uint8 の増加はわずか 0.2MB でした。おそらくコンパイル後に解放したメモリを VM が再利用したためです。メモリは AOT ビルドで、Flutter なら profile モードで測ってください。

よくある間違い:ループで += による文字列連結

間違い: ログ、CSV 書き出し、共有用テキストを作るのに、ループの中で s += 'line $i\n'。

実際に起きること: 文字列は不変なので、+= のたびにそれまでに作った全体をコピーします。コストは長さの 2 乗で増えます。同じテキストを作る 3 つの方法、AOT、5 回の中央値:

String withPlus(int n) {
  var s = '';
  for (var i = 0; i < n; i++) { s += 'line $i\n'; }
  return s;
}
String withBuffer(int n) {
  final b = StringBuffer();
  for (var i = 0; i < n; i++) { b.write('line $i\n'); }
  return b.toString();
}
String withJoin(int n) => [for (var i = 0; i < n; i++) 'line $i\n'].join();

void main() {
  for (final n in [1000, 10000, 50000]) {
    for (final (name, f) in [('+=', withPlus), ('StringBuffer', withBuffer), ('List.join', withJoin)]) {
      final ts = <double>[];
      var len = 0;
      for (var r = 0; r < 5; r++) {
        final sw = Stopwatch()..start();
        len = f(n).length;
        ts.add(sw.elapsedMicroseconds / 1000);
      }
      ts.sort();
      print('n=${n.toString().padLeft(5)} ${name.padRight(12)} median ${ts[2].toStringAsFixed(2).padLeft(8)} ms  (len $len)');
    }
  }
}
n= 1000 +=           median     0.36 ms  (len 8890)
n= 1000 StringBuffer median     0.08 ms  (len 8890)
n= 1000 List.join    median     0.07 ms  (len 8890)
n=10000 +=           median    25.97 ms  (len 98890)
n=10000 StringBuffer median     0.86 ms  (len 98890)
n=10000 List.join    median     0.88 ms  (len 98890)
n=50000 +=           median  1973.68 ms  (len 538890)
n=50000 StringBuffer median     5.16 ms  (len 538890)
n=50000 List.join    median     4.95 ms  (len 538890)

1 万行から 5 万行(5 倍)にすると、StringBuffer は 6 倍、+= は 76 倍遅くなりました。5 万行で += はバッファの 380 倍遅かったことになります。JIT では 5 万行の += が 3,101ms でした。

直し方:

final b = StringBuffer();
for (var i = 0; i < n; i++) { b.write('line $i\n'); }
return b.toString();

[for (...) 'line $i\n'].join() も同じくらい速いです。断片が 2〜3 個なら補間('$a $b')が一番読みやすく、測るほどのコストもありません。

Flutter 風のコード:大きなリストの再ビルドと const

Flutter は build() を頻繁に呼びます。setState のたび、祖先のアニメーションのフレームごと、テーマが変わるたび。build() の中の仕事はその都度支払われます。Flutter なしで再現しました。2 万行のうち約 1 万行が価格の絞り込みを通り、小さな「ウィジェット」オブジェクトで包みます。50 回ウォームアップしてから 200 回計測、AOT:

// What one "build()" costs when it re-filters and re-sorts 20,000 rows,
// versus reusing a cached result. Flutter-free stand-ins.
class Insets { final double all; const Insets(this.all); }
class Pad { final Insets insets; final String child; const Pad(this.insets, this.child); }
class Row { final String name; final double price; Row(this.name, this.price); }

List<Row> visibleRows(List<Row> all) =>
    all.where((r) => r.price < 500).toList()..sort((a, b) => a.price.compareTo(b.price));

int buildRecompute(List<Row> all) {
  final rows = visibleRows(all);
  return [for (final r in rows) Pad(Insets(8), r.name)].length;
}

List<Row>? _cache;
int buildCached(List<Row> all) {
  final rows = _cache ??= visibleRows(all);
  return [for (final r in rows) Pad(const Insets(8), r.name)].length;
}

int buildCachedVisibleOnly(List<Row> all) {
  final rows = _cache ??= visibleRows(all);
  // Like ListView.builder: only the ~20 rows on screen are built.
  return [for (var i = 0; i < 20; i++) Pad(const Insets(8), rows[i].name)].length;
}

void main() {
  var x = 42;
  final all = List<Row>.generate(20000, (i) {
    x = (x * 1103515245 + 12345) & 0x7fffffff;
    return Row('item$i', (x % 100000) / 100);
  });
  for (final (name, f) in [
    ('recompute every build', buildRecompute),
    ('cached list, build all rows', buildCached),
    ('cached list, build 20 visible', buildCachedVisibleOnly),
  ]) {
    _cache = null;
    var check = 0;
    for (var i = 0; i < 50; i++) { check += f(all); } // warm-up
    final sw = Stopwatch()..start();
    const frames = 200;
    for (var i = 0; i < frames; i++) { check += f(all); }
    final perFrame = sw.elapsedMicroseconds / frames;
    print('${name.padRight(30)} ${perFrame.toStringAsFixed(1).padLeft(7)} µs per build  (check $check)');
  }
}
recompute every build           1950.6 µs per build  (check 2519250)
cached list, build all rows       78.5 µs per build  (check 2519250)
cached list, build 20 visible      0.1 µs per build  (check 5000)

build() の中で絞り込みと並べ替えをやり直すと毎回約 1.9ms(3 回:1950.6 / 1924.6 / 1922.0µs)かかりました。Flutter がピクセルを 1 つもレイアウトしないうちに、60Hz のフレームの 12%、120Hz なら 23% を使っています。派生リストをキャッシュすると 25 分の 1 になりました。ListView.builder のように見えている行だけ作ると残りも消えました。0.1µs はコンパイラがループの一部を最適化で消した可能性もある水準なので、正確な値ではなく「無視できる」と読んでください。

const はコンパイル時に正規のインスタンスを 1 つ作ります。EdgeInsets と Padding に見立てたクラスで確かめると:

const == const identical: true
const vs new identical:   false

では何か節約になるのか。1 つのファイルを ./constmem new と ./constmem const で動かします。

import 'dart:io';
class Insets { final double all; const Insets(this.all); }
class Pad { final Insets insets; final int child; const Pad(this.insets, this.child); }

void main(List<String> args) {
  const n = 1000000;
  final useConst = args.first == 'const';
  final before = ProcessInfo.currentRss;
  final sw = Stopwatch()..start();
  final pads = useConst
      ? [for (var i = 0; i < n; i++) Pad(const Insets(8), i)]
      : [for (var i = 0; i < n; i++) Pad(Insets(8), i)];
  final ms = sw.elapsedMicroseconds / 1000;
  final delta = (ProcessInfo.currentRss - before) / (1024 * 1024);
  final distinct = pads.map((p) => p.insets).toSet().length; // identity-based
  print('${args.first.padRight(5)} ${ms.toStringAsFixed(1)} ms, '
      'rss +${delta.toStringAsFixed(1)} MB, distinct Insets objects: $distinct');
}
new   20.8 ms, rss +49.1 MB, distinct Insets objects: 1000000
const 21.2 ms, rss +39.0 MB, distinct Insets objects: 1

Pad(Insets(8), i) と Pad(const Insets(8), i) を 100 万個ずつ作ると(AOT、各 3 回)、時間はどちらも約 21ms で同じでしたが、常駐メモリの増加は 49.1MB 対 39.0MB、new 側は別々の Insets オブジェクトを 1,000,000 個、const 側は 1 個持っていました。素の Dart では const は CPU 時間より割り当てとメモリを節約します。Flutter ではもっと効きます。新しいウィジェットが前のものと同一(identical)なら、フレームワークがその部分木の再ビルドを省けるからですが、そこはここでは測っていません。見立てたファイルで analysis_options.yaml にリントを 2 つ有効にすると、アナライザーが候補を指摘します。flutter_lints がやってくれると思い込まないでください。4.0.0 は prefer_const_constructors と prefer_const_declarations を有効にしていましたが、5.0.0 と 6.0.0 に残っているのは prefer_const_constructors_in_immutables だけです(ローカルのパッケージキャッシュでバージョンごとの lib/flutter.yaml を確認)。

   info - fake_widgets.dart:24:43 - Use 'const' with the constructor to improve performance. Try adding the 'const' keyword to the constructor invocation. - prefer_const_constructors
   info - fake_widgets.dart:29:3 - Use 'const' for final variables initialized to a constant value. Try replacing 'final' with 'const'. - prefer_const_declarations

ベンチマークについての正直な注意点

  • benchmark_harness なし。 Dart のマイクロベンチマーク用の標準パッケージはこのマシンになく、このシリーズのルールで入れることもできません。時間は明示的なウォームアップと中央値を組み合わせた Stopwatch で測りました。2 倍以上の差には十分で、5% の差を語るには粗すぎます。
  • JIT の結果はウォームアップ次第です。 JIT の 1 ラウンド目は 5 ラウンド目の 4 倍遅かった。1 回だけ動かす JIT ベンチマークはコンパイラを測っています。
  • 結果を残すこと。 どのベンチマークも check 値を表示しています。これがないと、誰も使わない結果の計算を AOT コンパイラが消すことがあります。上の 0.1µs の行はおそらくそれです。
  • 中央値、固定シード、同じマシン。 ノート PC の CPU は温度や電源でクロックが変わります。1 回の計測は逸話にすぎません。
  • デスクトップの数値です。 普及価格帯の Android 端末は M1 Pro より数倍遅いです。アプリのコードを変える前に、実機の profile モードで Flutter DevTools を見てください。

判断表

状況 選ぶもの この記事の実測
CLI ツールを配布 dart compile exe 起動 17 ms 対 339 ms
SDK のあるマシンにツールを複数 aot-snapshot + dartaotruntime ツールあたり 0.9 MB、19 ms
開発中、ちょっとしたスクリプト dart run 起動 339 ms、ウォームアップ 19.5 → 4.8 ms
アプリの性能判断 Flutter profile/release(AOT)、debug は不可 —
リフレクションが必要なコード AOT では不可、コード生成を使う dart:mirrors のコンパイルエラー
バイトや小さな整数の大きな配列 Uint8List、Int32List 9.5 / 38.2 対 76.3 MB
サイズが分かっているオブジェクトのリスト List.generate / List.filled 76.3 対 可変長 224.4 MB
ループでテキストを組み立てる StringBuffer か join 5 ms 対 1,974 ms
build() で表示する派生データ 1 回計算してキャッシュ、変更時だけ再計算 1.9 ms → 78 µs
固定のウィジェット引数 const オブジェクト 1,000,000 個ではなく 1 個

練習問題

1. この書き出し関数は 2 万行に使われます。速くして、出力が変わっていないことを証明してください。

String csvPlus(List<(int, String, double)> rows) {
  var out = 'id,name,price\n';
  for (final (id, name, price) in rows) {
    out += '$id,$name,${price.toStringAsFixed(2)}\n';
  }
  return out;
}

答え

String csvBuffer(List<(int, String, double)> rows) {
  final out = StringBuffer('id,name,price\n');
  for (final (id, name, price) in rows) {
    out.writeln('$id,$name,${price.toStringAsFixed(2)}');
  }
  return out.toString();
}

writeln は \n を付けるので、行末は以前と同じです。csvPlus(rows) == csvBuffer(rows) で比べます。私たちの AOT 実行では 454,792 文字に対して +=: 962 ms、StringBuffer: 5 ms と表示され、続いて identical output: true が出ました(+= の時間は実行ごとに 828〜986ms で揺れました)。

2. 写真フィルターアプリが 1920 × 1080 のグレースケール画像を、1 ピクセル 1 要素の List<int> で持っています。メモリを見積もり、より良い型を選んでください。

答え

2,073,600 ピクセル × 8 バイト ≈ 15.8MiB(このデスクトップ VM の固定長 List<int> の場合。圧縮ポインタのスマホでは約半分)、add() で埋めたならもっと大きくなります。値は 0〜255 なので、Uint8List(1920 * 1080) なら約 2.0MiB で済みます。76.3 対 9.5MB の実測と同じ 8 倍の比率です。おまけに isolate へ送るのも安価です(第 5 回で 10MB に 1〜2ms)。

正直な限界とバージョンメモ

  • Dart 3.11.5(macOS arm64)でのみ確認し、古い SDK は動かしていません。dart compile wasm はこの SDK にありますが試しておらず、dart compile js はコンパイルしただけでブラウザでは動かしていません。
  • メモリの数値は OS が報告する常駐メモリ(RSS)で、Dart のヒープサイズではありません。アロケーターの余りや、GC がまだ返していないメモリを含みます。
  • Flutter についての説明(debug = ホットリロードできる JIT、profile/release = AOT、const ウィジェットの再ビルド省略)は Flutter の仕組みの説明で、この講座で Flutter アプリを使って測ったものではありません。
  • コード中の break のない switch と for (final (name, f) in ...) のパターンには Dart 3.0 以上が必要です。

Dart 講座はこれで終わりです。アプリを滑らかに動かすもう半分、メイン isolate を空けておく方法は第 5 回で扱いました。


前の講座: 第 5 回:非同期・Stream・Isolate · 最初から: 第 1 回:基礎、null 安全、型

関連記事:Swift 第 6 回:メモリ、ARC、性能 · Java の JIT・GC・メモリ · Python のプロファイリングと最適化 · PHP 第 6 回:OPcache・JIT・プロファイリング

よくある質問

Dart は AOT のほうが JIT より速いのですか?

起動は圧倒的に速いです。hello world は dart compile exe のバイナリで 17ms、dart run で 339ms でした。定常状態のスループットは差が小さく、コード次第です。リストのベンチマークは AOT 4.5ms、JIT 4.8ms に収束し、整数ループは 275ms 対 約 365ms でした。JIT の 1 ラウンド目はまだコンパイル中のため 19.5ms でした。

List<int> は Uint8List と比べてどれくらいメモリを使いますか?

小さな整数 1,000 万個を持つと、常駐メモリは List<int>.generate で 76.3MB(要素あたり 8 バイト)、Int32List で 38.2MB、Uint8List で 9.5MB 増えました。add() で埋めた可変長リストは 224.4MB 増えました。AOT 実行ファイルで ProcessInfo.currentRss を使って測りました。

Dart では + の代わりに StringBuffer を使うべき?

ループの中ならそうです。短い行 5 万個を += でつなぐと、AOT ビルドで 1,974ms かかりました。毎回文字列全体をコピーするためです。StringBuffer は 5.2ms、List.join は 5.0ms でした。数個の断片なら文字列補間で十分です。

  • #Dart
  • #パフォーマンス
  • #JIT
  • #AOT
  • #dart compile
  • #メモリ
  • #typed data
  • #Flutter
  • #上級

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑