Dart微基准测试第一部分
在過去的幾個月里,我開始收到越來越多關于某些特定 Dart 操作性能的問題。以下是Romain Rastel在他關于提高 Flutter中 ChangeNotifier 性能的工作的背景下提出的此類問題的示例。
鑒于我的經驗,我第一眼就知道這個特定的基準測試出了什么問題……但是為了講故事,讓我假裝我沒有。那我將如何處理這個問題?
我通常會首先嘗試重復報告的數字。在這種特殊情況下,我將首先創建一個空的 Flutter 應用程序
$ flutter create ubench $ cd ubench然后在lib/benchmark.dart我把下面的代碼這段代碼取自 Romain 的要點并做了一個小修正:在原始版本中,基準名稱被意外交換,因此fixed-length基準正在分配可增長的列表,反之亦然。
// ubench/lib/benchmark.dart import 'package:benchmark_harness/benchmark_harness.dart';abstract class Benchmark extends BenchmarkBase {const Benchmark(String name) : super(name);@overridevoid exercise() {for (int i = 0; i < 100000; i++) {run();}} }class GrowableListBenchmark extends Benchmark {const GrowableListBenchmark(this.length) : super('growable[$length]');final int length;@overridevoid run() {List<int>()..length = length;} }class FixedLengthListBenchmark extends Benchmark {const FixedLengthListBenchmark(this.length) : super('fixed-length[$length]');final int length;@overridevoid run() {List(length);} }void main() {const GrowableListBenchmark(32).report();const FixedLengthListBenchmark(32).report(); }最后我會在發布模式下運行它
$ flutter run --release -t lib/benchmark.dart ... I/flutter (18126): growable[32](RunTime): 31464.890625 us. I/flutter (18126): fixed-length[32](RunTime): 713.8279800142756 us結果似乎顯示固定長度列表的分配速度比可增長列表快 43 倍。我們是否應該就此擱筆,然后重構我們的代碼以使用盡可能多的固定長度列表?
絕對不會……或者至少不會期望我們的代碼變得快 43 倍。它實際上是有意義的可增長超過名單,其中固定長度的列表是天作之合喜歡固定長度的列表。它們的內存占用略小,分配速度更快,訪問元素的間接訪問更少。但是,您應該基于對事物如何運作的清晰理解,而不是基于微基準測試的未經解釋的原始結果,故意做出這種選擇。
在沒有任何批判性分析的情況下從原始微基準數據中得出結論是與微基準測試相關的常見陷阱,我們應該盡力避免落入其中。不幸的package:benchmark_harness是,它并沒有讓避免此類陷阱變得更容易:它為開發人員提供了一種編寫微基準測試的方法,但沒有為他們提供有關如何驗證基準測試和解釋其結果的工具或指導。更糟糕的package:benchmark_harness是,甚至沒有嘗試讓編寫準確的微基準測試變得非常簡單。
例如,考慮我可以按以下方式編寫此列表基準測試,而無需覆蓋exercise重復run 100000次數:
// ubench/lib/benchmark-without-exercise.dart import 'package:benchmark_harness/benchmark_harness.dart';// Just using BenchmarkBase directly. Rest is the same. class GrowableListBenchmark extends BenchmarkBase {// ... }// Just using BenchmarkBase directly. Rest is the same. class FixedLengthListBenchmark extends BenchmarkBase {// ... }運行此變體將顯示可增長列表僅比固定長度列表慢 6 倍
$ flutter run --release -t lib/benchmark-without-exercise.dart I/flutter (14407): growable[32](RunTime): 1.8629797056305768 us. I/flutter (14407): fixed-length[32](RunTime): 0.3052065645659146 us.我應該相信哪個基準測試結果?**他們兩個都沒有!**我應該深入了解并嘗試了解到底發生了什么。
Flutter 和 Dart 已經為開發人員提供了足夠的工具來弄清楚為什么基準數據會這樣。不幸的是,其中一些工具有些晦澀難懂且難以發現。
例如,眾所周知,您可以使用flutter run --profileObservatory 來分析您的應用程序,但您還可以使用本機分析器(例如simpleperf 在 Android 上或在 iOS 上的 Instruments)來分析發布版本,這一點并不為人所知。同樣,不知道(很可能在一組從事 VM 的工程師之外根本不知道)您可以通過執行以下操作從 AOT 構建中轉儲特定方法的帶注釋的反匯編
$ flutter build apk --extra-gen-snapshot-options=--print-flow-graph,\--disassemble,\--print-flow-graph-filter=FixedLengthListBenchmark.run我可以用這篇文章的其余部分來解釋如何使用這些工具來了解這些列表基準測試中究竟發生了什么,但相反,我想嘗試想象如何從提供的原語中構建用于基準測試的集成工具通過 Dart 和 Flutter。該工具不僅應該運行基準測試,而且還應該自動為開發人員提供足夠的洞察力,以發現他們在基準測試過程中犯的錯誤并幫助他們解釋結果。
初步設置
我已經在 GitHub 上分叉了benchmark_harness包mraleph/benchmark_harness。我所有的原型代碼都將存在于experimental-clifork 的一個新分支中。
從這里開始,我將記錄這個實驗性基準 CLI 的演變。我想強調這個工具的高度實驗性質:你會注意到它的一些功能最終將取決于 Dart 和 Flutter SDK 內部的補丁。這些補丁可能需要數周或數月才能發布,并且可以將我的更改合并到工具的上游版本中。
我首先添加了一個簡單的bin/benchmark_harness.dart腳本,它將作為我們新的基準測試工具的入口點。
$ git clone git@github.com:mraleph/benchmark_harness.git $ cd benchmark_harness $ cat > bin/benchmark_harness.dart void main() { print('Running benchmarks...'); } ^D最后我改變pubspec.yaml了ubench項目(記住它是我們創建的一個空的 Flutter 項目來托管我們的基準測試)以對我的版本有路徑依賴benchmark_harness
# ubench/pubspec.yamldependencies:# ...benchmark_harness:path: ../benchmark_harness# ...這允許我benchmark_harness在ubench項目目錄中運行腳本
$ flutter pub get $ flutter pub run benchmark_harness Running benchmarks...生成基準
你有沒有看過benchmark_harness你的基準測試是如何運行的?
事實證明,這個包正在做一些相當簡單的事情(并且在某種程度上很天真):它啟動 a Stopwatch,然后exercise 根據秒表重復調用直到 2 秒過去。經過的時間除以exercise被調用的次數是報告的基準分數。自己看看:
// benchmark_harness/lib/src/benchmark_base.dart abstract class BenchmarkBase {// Measures the score for the benchmark and returns it.double measure() {// ...// Run the benchmark for at least 2000ms.var result = measureFor(exercise, 2000);// ...}// Exercises the benchmark. By default invokes [run] 10 times.void exercise() {for (var i = 0; i < 10; i++) {run();}}// Measures the score for this benchmark by executing it repeatedly until// time minimum has been reached.static double measureFor(Function f, int minimumMillis) {var minimumMicros = minimumMillis * 1000;var iter = 0;var watch = Stopwatch();watch.start();var elapsed = 0;while (elapsed < minimumMicros) {f();elapsed = watch.elapsedMicroseconds;iter++;}return elapsed / iter;} }不幸的是,這段代碼有一個問題,使它不適合微基準測試:測量循環有一堆與exercise自身無關的開銷 。最明顯的是,它在每次迭代時從操作系統獲取當前時間。還有一個開銷與測量循環和run包含我們想要測量的實際操作的方法體之間的多級虛擬調度相關聯 。有一個公關反對benchmark_harness,它試圖解決調用Stopwatch.elapsedMilliseconds過于頻繁的問題,但盡管獲得了批準,它還是以某種方式陷入了困境。
避免這些開銷的最好方法是為每個基準測試有一個單獨的測量循環。
這就是它的樣子。用戶通過編寫帶有@benchmark注釋標記的頂級函數來聲明微基準測試。
// ubench/lib/main.dart import 'package:benchmark_harness/benchmark_harness.dart';const N = 32;@benchmark void allocateFixedArray() {List.filled(N, null, growable: false); }@benchmark void allocateGrowableArray() {List.filled(N, null, growable: true); }然后基準測試工具會生成一個輔助源文件,其中包含每個基準測試的測量循環,以及一些代碼來選擇哪些基準測試應該在編譯時運行:
// ubench/lib/main.benchmark.dart import 'package:benchmark_harness/benchmark_harness.dart' as benchmark_harness;import 'package:ubench/main.dart' as lib;// ...void _$measuredLoop$allocateFixedArray(int numIterations) {while (numIterations-- > 0) {lib.allocateFixedArray();} }// ...const _targetBenchmark =String.fromEnvironment('targetBenchmark', defaultValue: 'all'); const _shouldMeasureAll = _targetBenchmark == 'all';const _shouldMeasure$allocateFixedArray =_shouldMeasureAll || _targetBenchmark == 'allocateFixedArray';// ...void main() {benchmark_runner.runBenchmarks(const {// ...if (_shouldMeasure$allocateFixedArray)'allocateFixedArray': _$measuredLoop$allocateFixedArray,// ...}); }實際測量將發生在一個簡單的measure輔助函數中:
// benchmark_harness/lib/benchmark_runner.dart/// Runs the given measured [loop] function with an exponentially increasing /// parameter values until it finds one that causes [loop] to run for at /// least [thresholdMilliseconds] and returns [BenchmarkResult] describing /// that run. BenchmarkResult measure(void Function(int) loop,{required String name, int thresholdMilliseconds = 5000}) {var n = 2;final sw = Stopwatch();do {n *= 2;sw.reset();sw.start();loop(n);sw.stop();} while (sw.elapsedMilliseconds < thresholdMilliseconds);return BenchmarkResult(name: name,elapsedMilliseconds: sw.elapsedMilliseconds,numIterations: n,); }我們從一個非常簡單的實現開始,但它應該能滿足我們最初的微基準測試需求。然而對于更復雜的情況,我們可能想要做一些更嚴格的事情:例如,一旦numIterations發現足夠大, 我們可以重復loop(numIterations)多次并評估觀察到的運行時間的統計特性。
使用 source_gen
要生成,main.benchmark.dart我們需要解析main.dart并找到所有帶有@benchmark注解的函數。幸運的是,Dart 有許多用于代碼生成的規范工具,這使得這非常容易。
我所要做的就是依賴package:source_gen并定義一個子類GeneratorForAnnotation:
// benchmark_harness/lib/src/benchmark_generator.dartclass BenchmarkGenerator extends GeneratorForAnnotation<Benchmark> {// ...@overrideString generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {final name = element.name;return ''' void ${_\$measuredLoop\$$name}(int numIterations) {while (numIterations-- > 0) {lib.${name}();} } ''';} }然后我把它包裹在一個 Builder
// benchmark_harness/lib/builder.dartBuilder benchmarkLibraryBuilder(BuilderOptions options) =>LibraryBuilder(BenchmarkGenerator(), generatedExtension: '.benchmark.dart');最后Builder通過曝光這家工廠build.yaml
# benchmark_harness/build.yamlbuilders:benchmark:import: "package:benchmark_harness/builder.dart"builder_factories: ["benchmarkLibraryBuilder"]build_extensions: {".dart": [".benchmark.dart"]}auto_apply: dependentsbuild_to: source基本上就是這樣。現在,每當我運行時build_runner build,ubench我都會lib/main.benchmark.dart為以下定義的基準生成lib/main.dart:
ubench $ flutter pub run build_runner build ubench $ ls lib/*.benchmark.dart lib/main.benchmark.dart您可以在此提交中查看代碼生成器的完整源代碼。
運行基準
我們可以通過將適當的傳遞--dart-define 給來執行單個基準測試flutter run,例如:
$ flutter run --release --dart-define targetBenchmark=allocateFixedArray -t lib/main.benchmark.dart Launching lib/main.benchmark.dart on Pixel 3a in release mode... Running Gradle task 'assembleRelease'... Running Gradle task 'assembleRelease'... Done 4.9s ? Built build/app/outputs/flutter-apk/app-release.apk (4.9MB). Installing build/app/outputs/flutter-apk/app.apk... 1,268msFlutter run key commands. h Repeat this help message. c Clear the screen q Quit (terminate the application on the device). I/flutter (12463): benchmark_harness[{"event":"benchmark.running"}] I/flutter (12463): benchmark_harness[{"event":"benchmark.result","params":{...}}] I/flutter (12463): benchmark_harness[{"event":"benchmark.done"}]Application finished.但是手動執行此操作并不是我的目標。相反,我要改變bin/benchmark_harness.dart腳本既構建基準,然后運行所有生成的文件,收集的基準測試結果(全代碼中看到這個承諾)。
// benchmark_harness/bin/benchmark_harness.dartvoid main() async {// ...// Generate benchmark wrapper scripts.print(red('Generating benchmark wrappers'));'flutter pub run build_runner build'.start(progress: Progress.devNull());// Run all generated benchmarks.final resultsByFile = <String, Map<String, BenchmarkResult>>{};for (var file in find('*.benchmark.dart').toList().map(p.relative)) {resultsByFile[file] = await runBenchmarksIn(file);}// Report results.// ... }/// Runs all benchmarks in `.benchmark.dart` [file] one by one and collects /// their results. Future<Map<String, BenchmarkResult>> runBenchmarksIn(String file) async {// ... }這樣的幫助腳本使運行基準測試變得非常簡單:
$ flutter pub run benchmark_harness Generating benchmark wrappers Found 2 benchmarks in lib/main.benchmark.dartmeasuring allocateFixedArraybenchmark is runningdonemeasuring allocateGrowableArraybenchmark is runningdone--------------------------------------------------------------------------------Results for lib/main.benchmark.dart allocateFixedArray: 0.0000030226074159145355 ms/iteration (fastest) allocateGrowableArray: 0.00018900632858276367 ms/iteration (62.5 times as slow)了解結果
現在我們有了一個運行微基準測試的工具,讓我們擴展它,支持在運行時分析基準測試。這將幫助我們了解基準測試在哪里花費時間并確認它正在測量我們想要它測量的內容。
Flutter 的發布版本不包括 Dart 的內置分析器,因此我們將不得不使用原生分析器,例如simpleperf在 Android 上。
Android有全面的文檔使用simpleperf,這我不打算在這里重復。simpleperf還帶有調用的 C++(和 Java)代碼app_api,這些代碼可以鏈接到應用程序中以允許以編程方式訪問分析器。
實際上app_api并沒有做任何過于花哨的事情:它只是simpleperf使用正確的命令行選項運行 二進制文件。這就是我決定將相關部分移植app_api到純 Dart 的原因。我們也可以綁定到app_api使用 Dart FFI 的C++ 版本,但這需要將這個 C++ 打包成一個Flutter 插件,這使事情變得復雜,因為它benchmark_harness是一個純 Dart 包,它不能依賴于 Flutter 插件包。
// benchmark_harness/lib/src/simpleperf/profiling_session.dartclass ProfilingSession {Future<void> start({RecordingOptions options = const RecordingOptions()}) async {// ...await _startSimpleperfProcess(options);}Future<void> _startSimpleperfProcess(RecordingOptions options) async {final simpleperfBinary = await _findSimplePerf();_simpleperf = await Process.start(simpleperfBinary,['record','--log-to-android-buffer','--log','debug','--stdio-controls-profiling','--in-app','--tracepoint-events','/data/local/tmp/tracepoint_events','-o',options.outputFilename ?? _makeOutputFilename(),'-e',options.event,'-f',options.frequency.toString(),'-p',_getpid().toString(),..._callgraphFlagsFrom(options),],workingDirectory: simpleperfDataDir,);// ...} }然后我調整benchmark_runner.dart運行基準測試它剛剛在分析器下測量并將配置perf-$benchmarkName.data 文件保存到文件中。此文件將在應用程序的數據目錄中創建:
Future<void> runBenchmarks(Map<String, void Function(int)> benchmarks) async {_event('benchmark.running');final profiler = Platform.isAndroid ? ProfilingSession() : null;for (var entry in benchmarks.entries) {final result = measure(entry.value, name: entry.key);_event('benchmark.result', result);if (profiler != null) {// Run benchmark for the same amount of iterations and profile it.await profiler.start(options: RecordingOptions(outputFilename: 'perf-${entry.key}.data'));entry.value(result.numIterations);await profiler.stop();}}_event('benchmark.done'); }NDK 還附帶了一個輔助腳本api_profiler.py,它實現了兩個命令:
- api_profiler.py prepare 配置您的設備以進行分析 - 我們將在運行基準測試之前調用它;
- api_profiler.py collect從設備中提取收集的配置文件 - 我們將在所有基準測試完成運行后調用它以提取perf-*.data從設備生成的所有文件 。
顯示收集的分析數據
NDK 的simpleperf二進制文件同時支持record和report命令,就像 Linux 一樣perf。在 NDK 中環顧四周,我還發現了一堆用 Python 編寫的幫助腳本(例如report_html.py,可以生成 HTML 報告)。深入了解這些腳本,我發現它們利用了libsimpleperf_report.so處理收集到的配置文件的解析和符號化的庫。此庫的 API 定義simpleperf/report_lib_interface.cpp在simpleperf源文件 的頂部。
使用ffigen我dart:ffi 為此庫生成的基于綁定,允許我從benchmark_harness 腳本中使用它來處理收集的分析樣本:
final reportLib = report_bindings.NativeLibrary(ffi.DynamicLibrary.open(ndk.simpleperfReportLib));Future<void> _printProfile(String profileData) async {final session = reportLib.CreateReportLib();reportLib.SetRecordFile(session, Utf8.toUtf8(profileData).cast());// Iterate over all collected samples.for (;;) {final sample = reportLib.GetNextSample(session);if (sample == ffi.nullptr) {break;}final period = sample.ref.period;final symbol = reportLib.GetSymbolOfCurrentSample(session);final dsoName = Utf8.fromUtf8(symbol.ref.dso_name.cast());final symbolName = Utf8.fromUtf8(symbol.ref.symbol_name.cast());// Process sample for the symbol [symbolName] in dso [dsoName] and collect// aggregate statistics (samples per symbol, total sampling period, etc).// ...}// Report top N hottest symbols }當我第一次運行它時,我發現simpleperf無論 for libapp.so(包含 AOT 編譯的 Dart 代碼)還是 for libflutter.so (包含 Flutter 引擎代碼)都不能真正將大多數樣本歸因于一個有意義的符號 。這是我收到的第一份報告:
Hot methods when running allocateGrowableArray:88.24% _kDartIsolateSnapshotInstructions (libapp.so)4.04% unknown (libflutter.so)3.15% unknown ([kernel.kallsyms])1.44% pthread_mutex_lock (libc.so)1.30% pthread_mutex_unlock (libc.so)...這并不奇怪:這兩個庫都被剝離了,并且不包含任何有用的符號信息以simpleperf供使用。
幸運的是,libflutter.so符號可以從 Cloud Storage 中獲取,其中構建基礎架構正在歸檔它們,例如,提交時 Flutter 引擎的 ARM64 Android 版本構建的符號e115066d... 駐留在 gs://flutter_infra/flutter/e115066d.../android-arm64-release/symbols.zip. 就在幾個月前,我編寫了一些 Dart 代碼,用于下載和緩存基于提交哈希的 Flutter 引擎符號 for @flutter-symbolizer-bot,所以我可以在這里重用完全相同的代碼。
獲取符號libapp.so是一個更有趣的問題。Dart VM AOT 編譯器能夠在 ELF 二進制文件中生成 DWARF 調試部分。然而,僅僅通過--extra-gen-snapshot-options=--dwarf-stack-traces并不能讓我們到達那里:libapp.so仍然會缺少符號。對 Flutter CLI 源代碼的檢查表明,它總是簡單地指示 AOT編譯器生成剝離的輸出。熟悉高級 Flutter 工具選項的用戶可能知道--split-debug-info=...flag,它指示 AOT 編譯器生成一個單獨的 ELF 文件,該文件只包含 DWARF 部分,但不包含快照有效負載本身。這個文件足以進行符號化,但不幸的是不能很好地與llvm-objdump. GNUobjdump支持通過 發現的單獨調試信息.gnu_debuglink,但混合源代碼和反匯編等某些功能在此模式下似乎不起作用。
出于原型設計的目的,我選擇修補flutterCLI 以允許用戶通過--no-strip在--extra-gen-snapshot-options.
現在,當我們構建基準測試時,我們可以保留 DWARF 調試信息 libapp.so,然后將其用于符號化,使我們的配置文件更具信息性:
Hot methods when running allocateGrowableArray:54.17% Precompiled_Stub__iso_stub_AllocateArrayStub (libapp.so)14.29% Precompiled______measuredLoop_allocateGrowableArray_21146765_1230 (libapp.so)8.01% Precompiled__GrowableList_0150898__GrowableList_0150898__withData_0150898_136 (libapp.so)7.63% Precompiled__GrowableList_0150898__GrowableList_0150898__164 (libapp.so)4.96% Precompiled__GrowableList_0150898__allocateData_0150898_161 (libapp.so)3.66% unknown ([kernel.kallsyms])然后我更進一步,并使用 DWARF 中可用的信息來漂亮地打印符號名稱(這simpleperf似乎并不行)。幸運的是,我實際上不必手動解析 DWARF,package:native_stack_traces 已經擁有所有必要的工具來為我們處理 ELF/DWARF 解析:
String _userFriendlyName(Dwarf dwarf, elf_lib.Elf elf, String symbol) {final elfSymbol = elf.staticSymbolFor(symbol);if (elfSymbol != null) {final callInfo = dwarf.callInfoFor(elfSymbol.value);if (callInfo != null && callInfo.isNotEmpty) {final lastInfo = callInfo.last;if (lastInfo is DartCallInfo) {return lastInfo.function.replaceFirst(r'_$measuredLoop$', 'measured loop for ');}return lastInfo.toString();}}return symbol.replaceFirst('Precompiled_Stub__iso_stub_', 'Stub::'); }這會產生更具可讀性的輸出:
$ flutter run benchmark_harness report Results for lib/main.benchmark.dart allocateFixedArray: 0.000006573274731636047 ms/iteration (fastest) allocateGrowableArray: 0.00020492076873779297 ms/iteration (31.2 times as slow)Hot methods when running allocateFixedArray:99.64% measured loop for allocateFixedArray (libapp.so)Hot methods when running allocateGrowableArray:54.17% Stub::AllocateArrayStub (libapp.so)14.29% measured loop for allocateGrowableArray (libapp.so)8.01% new _GrowableList._withData (libapp.so)7.63% new _GrowableList (libapp.so)4.96% _GrowableList._allocateData (libapp.so)3.66% unknown ([kernel.kallsyms])我們現在可以看到基準測試的行為似乎略有不同:allocateFixedArray將所有時間都花在其測量循環中,而 allocateGrowableArray將時間花在與數組分配相關的各種方法上。
為了更好地理解這一點,我們需要查看為這兩個測量循環生成的本機代碼。
有多種可能的方法來實現這一點,例如我可以選擇將此任務委托給perf annotate或類似的東西。然而perf ,在搜索符號時對文件夾結構相當挑剔,而且似乎沒有一個 NDK 腳本符合要求,所以我選擇只使用 llvm-objdump分析信息并注釋結果。
通過一些后期處理,使用 Dart VM 特定名稱作為保留寄存器thr(為當前Thread指針保留)和對符號名稱(通過_userFriendlyName幫助程序)進行解構,我們得到如下輸出:
Hot methods when running allocateFixedArray:99.64% measured loop for allocateFixedArray (libapp.so)0: stp fp, lr, [sp, #-16]!4: mov fp, sp8: ldr x2, [fp, #16]7.87% c: ldr x16, [thr, #64]18.40% 10: cmp sp, x169.30% 14: b.ls ->608.88% 18: cmp x2, null1c: b.eq ->689.65% 20: asr x3, x2, #124: tbz w2, #0, ->2c28: ldur x3, [x2, #7]9.45% 2c: sub x2, x3, #130: cmp x3, #09.85% 34: b.le ->509.72% 38: adds x0, x2, x23c: b.vc ->4840: bl Stub::AllocateMintSharedWithoutFPURegsStub44: stur x2, [x0, #7]8.90% 48: mov x2, x07.60% 4c: b.al ->c50: mov x0, null54: mov sp, fp58: ldp fp, lr, [sp], #165c: ret60: bl Stub::StackOverflowSharedWithoutFPURegsStub64: b.al ->1868: bl Stub::NullErrorSharedWithoutFPURegsStub熟悉 ARM 匯編語言的開發人員可能能夠僅從這個輸出中發現我們的基準測試的問題(是的,顯然存在問題)。
但是我決定花更多的時間在上面,并在程序集之上覆蓋有關 Dart VM 編譯器的中間語言的信息,使其更容易理解。
在內部,我們的編譯器支持使用人類可讀的注釋來注釋它生成的機器代碼,其中包括描述生成特定機器代碼片段的 IL。
我已經嘗試了兩種不同的路徑,我考慮過從 AOT 編譯器中獲取這些信息:
- 添加一個標志--write-code-comments-to=output.json,告訴編譯器將偏移量到注釋的映射轉儲到 JSON 文件中,然后在我們的基準測試 CLI 中攝取此 JSON。
- 添加一個標志--write-code-comments-as-synthetic-source-to=comments.txt,告訴編譯器從所有代碼注釋中合成一個巨大的文件,并將偏移量到注釋的映射作為 DWARF 行程序寫入.debug_line 部分。
最后我決定第二種方法更好,因為它使任何能夠理解.debug_line在反匯編的同時顯示代碼注釋的工具成為可能(例如,gdb也將開始顯示它)。
這是我針對 Dart SDK 實現必要管道的補丁。有了這個,我唯一要做的改變就是添加-S到llvm-objdump 調用中,它處理其余的:
Hot methods when running allocateFixedArray:99.64% measured loop for allocateFixedArray (libapp.so);; Enter frame0: stp fp, lr, [sp, #-16]!4: mov fp, sp;; ParallelMove r2 <- S+28: ldr x2, [fp, #16];; CheckStackOverflow:30(stack=0, loop=1)7.87% c: ldr x16, [thr, #64]18.40% 10: cmp sp, x169.30% 14: b.ls ->60;; CheckNull:10(v3, NoSuchMethodError) T{int}8.88% 18: cmp x2, null1c: b.eq ->68;; v26 <- UnboxInt64(v3 T{int}) T{int}9.65% 20: asr x3, x2, #124: tbz w2, #0, ->2c28: ldur x3, [x2, #7];; v6 <- BinaryInt64Op(- [tr], v26 T{int}, v32) T{int}9.45% 2c: sub x2, x3, #1;; Branch if RelationalOp(>, v26 T{int}, v34) T{bool} goto (3, 5)30: cmp x3, #09.85% 34: b.le ->50;; v30 <- BoxInt64(v6) T{int}9.72% 38: adds x0, x2, x23c: b.vc ->4840: bl Stub::AllocateMintSharedWithoutFPURegsStub44: stur x2, [x0, #7];; ParallelMove r2 <- r0 goto:28 B48.90% 48: mov x2, x07.60% 4c: b.al ->c;; ParallelMove r0 <- C50: mov x0, null;; Return:36(v0)54: mov sp, fp58: ldp fp, lr, [sp], #165c: ret;; CheckStackOverflowSlowPath60: bl Stub::StackOverflowSharedWithoutFPURegsStub64: b.al ->18;; slow path check null (nsm) operation68: bl Stub::NullErrorSharedWithoutFPURegsStubHot methods when running allocateGrowableArray:54.17% Stub::AllocateArrayStub (libapp.so)14.29% measured loop for allocateGrowableArray (libapp.so);; Enter frame0: stp fp, lr, [sp, #-16]!4: mov fp, sp8: sub sp, sp, #16;; CheckStackOverflow:8(stack=0, loop=0)c: ldr x16, [thr, #64]10: cmp sp, x1614: b.ls ->94;; v36 <- UnboxedConstant:32(#32) [32, 32] T{_Smi}18: mov x0, #32;; ParallelMove r1 <- S+2, S-2 <- r01c: ldr x1, [fp, #16]20: stur x0, [fp, #-16];; CheckStackOverflow:30(stack=0, loop=1)24: ldr x16, [thr, #64]0.73% 28: cmp sp, x162c: b.ls ->9c;; CheckNull:10(v3, NoSuchMethodError) T{int}0.33% 30: cmp x1, null34: b.eq ->a4;; v26 <- UnboxInt64(v3 T{int}) T{int}0.30% 38: asr x2, x1, #13c: tbz w1, #0, ->4440: ldur x2, [x1, #7];; v6 <- BinaryInt64Op(- [tr], v26 T{int}, v32) T{int}0.35% 44: sub x1, x2, #1;; ParallelMove S-1 <- r148: stur x1, [fp, #-8];; Branch if RelationalOp(>, v26 T{int}, v34) T{bool} goto (3, 5)10.41% 4c: cmp x2, #050: b.le ->84;; PushArgument(v18)0.29% 54: ldr x16, [pp, #5160]0.35% 58: stp x0, x16, [sp, #-16]!;; StaticCall:10( _GrowableList@0150898.<0> v18, v36, result_type = T{_GrowableList})5c: bl new _GrowableList0.33% 60: add sp, sp, #16;; ParallelMove r2 <- S-164: ldur x2, [fp, #-8];; v30 <- BoxInt64(v6) T{int}0.53% 68: adds x0, x2, x26c: b.vc ->7870: bl Stub::AllocateMintSharedWithoutFPURegsStub74: stur x2, [x0, #7];; ParallelMove r1 <- r0, r0 <- S-2 goto:28 B40.29% 78: mov x1, x07c: ldur x0, [fp, #-16]0.38% 80: b.al ->24;; ParallelMove r0 <- C84: mov x0, null;; Return:36(v0)88: mov sp, fp8c: ldp fp, lr, [sp], #1690: ret;; CheckStackOverflowSlowPath94: bl Stub::StackOverflowSharedWithoutFPURegsStub98: b.al ->18;; CheckStackOverflowSlowPath9c: bl Stub::StackOverflowSharedWithoutFPURegsStuba0: b.al ->30;; slow path check null (nsm) operationa4: bl Stub::NullErrorSharedWithoutFPURegsStub8.01% new _GrowableList._withData (libapp.so)7.63% new _GrowableList (libapp.so)4.96% _GrowableList._allocateData (libapp.so)3.66% unknown ([kernel.kallsyms])..(run with -v to disassemble all hot methods in libapp.so)..現在它應該在allocateFixedArray實際上不包含任何數組分配的輸出中可見,它被編譯器消除(沉沒),因此我們的基準測試似乎將空while (N-- > 0);循環的性能與實際執行一些數組分配的循環的性能進行比較 .
然而,空循環遠非微不足道。它本身似乎包含兩個開銷來源:
-
變量
numIterations似乎在每次循環迭代時都被拆箱和重新裝箱,從以下實現的指令序列中可以明顯看出
numIterations--:
;; CheckNull:10(v3, NoSuchMethodError) T{int} 18: cmp x2, null 1c: b.eq ->68;; v26 <- UnboxInt64(v3 T{int}) T{int} 20: asr x3, x2, #1 24: tbz w2, #0, ->2c 28: ldur x3, [x2, #7];; v6 <- BinaryInt64Op(- [tr], v26 T{int}, v32) T{int} 2c: sub x2, x3, #1<...>;; v30 <- BoxInt64(v6) T{int} 38: adds x0, x2, x2 3c: b.vc ->48 40: bl Stub::AllocateMintSharedWithoutFPURegsStub 44: stur x2, [x0, #7] -
循環頭包含
CheckStackOverflow執行內存加載和每次循環迭代比較的指令。
;; CheckStackOverflow:30(stack=0, loop=1) 24: ldr x16, [thr, #64] 28: cmp sp, x16 2c: b.ls ->9c
對于準確的微基準測試,我們當然應該努力將測量循環的開銷減少到最低限度。
防止 numIterations
void _$measuredLoop$allocateGrowableArray(int numIterations) {while (numIterations-- > 0) {lib.allocateGrowableArray();} }為什么要被numIterations編譯器裝箱?這里有多種因素在起作用。最重要的是,編譯器根本無法證明它numIterations不在null函數的入口處,這是因為我們使用了一個可測量的循環方法來調用它。TFA(類型流分析,我們的全局類型傳播算法)不會嘗試推斷閉包參數的精確類型信息。如果我們的代碼是使用類構建的,例如:如果我們在默認情況下以聲音不可為空 (NNBD) 模式運行,那么編譯器也將能夠依賴于numIterations永遠不能為null. 這說明了 NNBD 的好處之一:為編譯器提供更多信息以供使用。
abstract class MeasuredLoop {void run(int numIterations); }class Loop$allocateGrowableArray extends MeasuredLoop {void run(int numIterations) =>_$measuredLoop$allocateGrowableArray(numIterations); }void main() {await benchmark_runner.runBenchmarks(const {// Use class instead of tearing off _$measuredLoop$allocateGrowableArray'allocateGrowableArray': Loop$allocateGrowableArray(),}); }然后編譯器將能夠推斷出它numIterations永遠不能為空,并且不僅numIterations在內部本地拆箱run,_$measuredLoop$...而且甚至更改這兩個函數的調用約定以在沒有裝箱的情況下傳遞參數。
這適用于方法而不適用于閉包的原因是因為靜態類型的方法調用比靜態類型的閉包調用為編譯器提供了更多關于潛在被調用者的信息。
class Loop$allocateGrowableArray extends MeasuredLoop {void run(int numIterations) {// Can only be reached from a call-site where// static type of a receiver is either [dynamic] or// related to [Loop$allocateGrowableArray].} }MeasuredLoop loop; loop.run(...); // Can invoke only overrides of [MeasuredLoop.run].// ---------------------------------------------------------var g = (int numIterations) {// Can be reached from any closure call-site with a compatible// function type and invocation signature. };void Function(int) f; f(...); // Can reach any compatible closure in the program.現在 TFA 做了一個非常保守的近似,假設任何閉包都可以從其他任何地方調用。這當然可以改進:例如,TFA 可以使用靜態類型信息來縮小每個閉包的潛在調用點。這可以更進一步——TFA 可以嘗試通過程序跟蹤關閉流程并嘗試確定它可以到達哪些調用站點,但這是一個更復雜的分析。
鑒于 TFA 不能推斷numIterations為從不包含,null我們可能希望通過在測量循環包裝器的開頭添加顯式檢查來幫助它。
void _$measuredLoop$allocateGrowableArray(int numIterations) {if (numIterations == null) throw 'Unexpected null!';while (numIterations-- > 0) {lib.allocateGrowableArray();} }不幸的是,這無濟于事:即使編譯器推斷出numIterations它永遠不會null在循環內,它仍然不會對其進行拆箱,因為它的拆箱算法對于int類型變量過于保守。如果所有到達值都是裝箱操作或常量,則當前的啟發式集僅對整數變量進行拆箱。這可能是一個需要修復的錯誤,但同時我們可以簡單地滿足這個要求:在這里談論拆箱時,我允許自己有點不準確。編譯器實際上并不拆箱變量,因為它在 SSA 形式上運行,該形式不再包含任何變量操作。談論拆箱phis會更準確,但我不想讓沒有編譯器背景的讀者過多混淆。
void _$measuredLoop$allocateGrowableArray(int numIterations) {// n will be unboxed because it is either// Box(Unbox(numIterations) + 0) or Box(Unbox(n) - 1)var n = numIterations + 0;while (n-- > 0) {lib.allocateGrowableArray();} }消除 CheckStackOverflow
CheckStackOverflow編譯器插入的指令有雙重目的:它們檢查堆棧溢出(顧名思義),但也用作中斷點,允許 VM 干凈地中斷執行 Dart 代碼的線程。例如,GC 可以使用此機制將 mutator 線程停放在安全點。CheckStackOverflow的速度相當快:它們由通常會訪問 CPU 緩存的內存負載和幾乎從不使用的比較和分支組成。然而,它們在非常緊湊的循環中仍然具有可見的成本。
作為一項實驗,我更改了 VM 以完全消除CheckStackOverflow標有@pragma('vm:no-interrupts').
每次迭代有CheckStackOverflow指令時,空循環需要執行3.56 ns/iteration,沒有指令時,空循環的執行速度1.78 ns/iteration基本上是原來的兩倍。
空測量回路的最終形狀是:
;; v9 <- BinaryInt64Op(- [tr], v6 T{int}, v36) T{int} 24: sub x0, x1, #1;; Branch if RelationalOp(>, v6 T{int}, v34) T{bool} goto (3, 5) 28: cmp x1, #0 2c: b.le ->38;; ParallelMove r1 <- r0 goto:32 B4 30: mov x1, x0 34: b.al ->24防止編譯器優化計算
最后一步是修復基準以防止編譯器優化我們的計算。核心思想是改變每個基準以返回它產生的值并使測量循環消耗返回值:
// ubench/lib/main.dart@benchmark Object allocateFixedArray() {return List.filled(N, null, growable: false); }// ubench/lib/main.benchmark.dart @pragma('vm:never-inline') @pragma('vm:no-interrupts') void _$measuredLoop$allocateFixedArray(int numIterations) {// ...while (n-- > 0) {final result = lib.allocateFixedArray();// Call a special intrinsic from dart:_internal which keeps// the value alive and prevents it from being optimized out.// However does not result in any actual calls being produced.benchmark_runner.reachabilityFence(result);} }最終基準測試結果
使用基準工具和基準本身固定以防止編譯器優化列表分配,我們得到以下結果
Results for lib/main.benchmark.dart allocateFixedArray: 131.607 (±0.845) ns/iteration (fastest) allocateGrowableArray: 177.443 (±1.603) ns/iteration (1.3 times as slow)Hot methods when running allocateFixedArray:77.98% Stub::AllocateArrayStub (libapp.so)6.19% measured loop for allocateFixedArray (libapp.so)5.21% unknown ([kernel.kallsyms])1.16% pthread_mutex_lock (libc.so)1.16% pthread_mutex_unlock (libc.so)Hot methods when running allocateGrowableArray:53.13% Stub::AllocateArrayStub (libapp.so)11.53% new _GrowableList._withData (libapp.so)9.35% new _GrowableList (libapp.so)8.15% measured loop for allocateGrowableArray (libapp.so)6.23% _GrowableList._allocateData (libapp.so)3.92% unknown ([kernel.kallsyms])因此,與分配具有 32 個元素的固定長度列表相比,分配具有 32 個元素的可增長列表需要多出約 30% 的時間,這比我們使用初始基準獲得的結果要合理得多。
這也是有道理的:可增長數組只是一個包含在固定長度數組中的對象,因此分配可增長數組比分配固定長度數組要慢一些,因為您需要分配和初始化更多內存,并且需要跳過更多圈套到達那里:allocateFixedArray 大部分時間都花在分配數組的存根(一段手寫機器代碼)上,在完成allocateGrowableArray這項工作時涉及多個功能。
關于基準測試的最后評論
總的來說,我選擇在這篇文章中忽略一些與微基準測試相關的最困難的問題:例如,我決定完全忽略 JIT,專門關注 AOT。對 JIT 進行基準測試真的很難,因為它們的行為就像活的有機體,即使經過長時間的預熱,性能也會出現劇烈波動(參見例如虛擬機預熱吹冷熱論文)。我還決定專注于簡單化的平均運營成本指標,這可能會隱藏一些重要但不經常出現的開銷。數組分配實際上是一個很好的例子:它通常可能相當便宜,但它可能經常觸發 GC——其成本將與實時數據量成正比,在微基準測試中可能接近 0。平均數以千計的操作將完全消除 GC 的成本,但是在實際應用程序中,像這樣的 GC 可能很重要,因為它們可能會導致丟失幀。
下一部分
在本系列的第 2 部分中,我將使用基準測試工具 CLI,我剛剛實施來回答我在 Twitter 上遇到的更多性能問題,例如:
- 將字段初始值設定項從 更改為[]如何List.filled(0, null)可以提高代碼的性能;
- async語法糖的性能特點;
- 當您通過動態類型變量調用閉包時會發生什么;
總結
以上是生活随笔為你收集整理的Dart微基准测试第一部分的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【Linux:开源操作系统】
- 下一篇: flutter 版本控制fvm