微型标枪

2021-01-23 17:44:24

在过去的几个月中,我开始收到越来越多的有关某些特定Dart操作性能的问题。这是Romain Rastel在提高Flutter的ChangeNotifier性能方面所做的工作中提出的这样一个问题的示例。

看起来创建一个包含少量项目的定长列表可能比创建可扩展列表的性能低很多。 pic.twitter.com/B5opjZkmrX

-Romain Rastel💙(@ lets4r)2020年11月30日

有了我的经验,我一眼就知道了这个基准测试中到底出了什么问题……但是为了讲故事,我装作没有。那我该如何处理呢?

我通常会先尝试重复报告的数字。在这种情况下,我将从创建一个空的Flutter应用程序开始

// ubench / lib / benchmark.dart import' package:benchmark_harness / benchmark_harness.dart' ;抽象类Benchmark扩展BenchmarkBase {const Benchmark(String name):super(name); @override void Exercise(){for(int i = 0; i< 100000; i ++){run(); }}}类GrowableListBenchmark扩展了Benchmark {const GrowableListBenchmark(this。length):super(' growable [$ length]');最终的int长度; @override void run(){列表< int>().. length = length; }}类FixedLengthListBenchmark扩展了Benchmark {const FixedLengthListBenchmark(this。length):super(' fixed-length [$ length]');最终的int长度; @override void run(){List(length); }} void main(){const GrowableListBenchmark(32)。报告(); const FixedLengthListBenchmark(32)。报告(); }

结果似乎显示固定长度列表的分配速度比可增长列表快43倍。我们是否应该保留它,然后重新构建代码以使用尽可能多的固定长度列表?

绝对不会……或者至少不会期望我们的代码快43倍。实际上,在固定长度列表很适合的情况下,优先选择固定长度列表而不是可增长列表。它们的内存占用空间略小,分配速度更快,并且涉及访问元素的间接调用更少。但是,您应该基于对事物工作原理的清晰了解而不是基于微基准的未经解释的原始结果来故意进行此选择。

在没有进行任何严格分析的情况下从原始微基准数字得出结论是与微基准相关的常见陷阱,但我们应尽最大努力避免陷入这种困境。package:benchmark_harness并不能使其更容易避免此类陷阱:它为开发人员提供了一种编写方法微基准测试,但没有为它们提供有关如何验证基准并解释其结果的工具或指南。更糟糕的是,package:benchmark_harness甚至没有尝试使编写精确的微基准测试非常简单。

例如,考虑一下我可以按照以下方式编写此列表基准,而无需进行过多练习来重复运行100000次:

// ubench / lib / benchmark-without-exercise.dart import' package:benchmark_harness / benchmark_harness.dart' ; //仅直接使用BenchmarkBase。休息是一样的。 class GrowableListBenchmark扩展了BenchmarkBase {// ...} //仅直接使用BenchmarkBase。休息是一样的。类FixedLengthListBenchmark扩展BenchmarkBase {// ...}

运行此变体将显示可增长列表仅比固定长度列表慢6倍

我应该相信哪个基准测试结果?他们俩都不是!我应该仔细研究一下,并试图了解正在发生的事情。

Flutter和Dart已经为开发人员提供了足够的工具,以弄清基准数字为何如此。不幸的是,其中一些工具有些晦涩难懂,很难发现。

例如,众所周知,您可以使用flutter run --profile通过Observatory对应用程序进行概要分析,但是,您还可以使用本机概要分析器(例如simpleperfon Android或iOS上的Instruments)对发行版本进行概要分析。类似地,您无法(通过在VM上工作的一组工程师以外的人完全不知道)可以通过以下操作从AOT构建中转储带注释的特定方法的反汇编:

我可以用这篇文章的其余部分解释如何使用这些工具来理解这些列表基准测试中到底发生了什么,但是我想尝试想象一下如何从Dart和Dart提供的原语中构建用于基准测试的集成工具。扑。该工具不仅应运行基准测试,还应为开发人员自动提供足够的洞察力,以发现他们在基准测试过程中犯的错误并帮助他们解释结果。

我已将Benchmark_harness包分叉到GitHub上的mraleph / benchmark_harness中。我所有的原型代码都将驻留在fork中的一个新的experimental-cli分支中。

从这里开始,我将记录此实验性基准测试CLI的演变。我想强调一下该工具的高度实验性质:您会注意到,该工具的某些功能最终将取决于Dart和Flutter SDK内部的补丁。这些补丁可能要花几周或几个月的时间,才有可能将我的更改合并到线束的上游版本中。

我首先添加了一个简单的bin / benchmark_harness.dart脚本,该脚本将作为我们新的基准测试工具的切入点。

$ git clone [受电子邮件保护]:mraleph / benchmark_harness.git $ cd Benchmark_harness $ cat> bin / benchmark_harness.dart void main(){print('正在运行基准测试...'); } ^ D

最后,我在ubench项目中更改了pubspec.yaml(请记住,这是我们创建的用于托管基准测试的emptyFlutter项目),以对我的beta_harness版本具有路径依赖性

事实证明,此程序包的操作相当简单(天真地太天真了):它启动了一个秒表,然后重复调用exercise,直到根据该秒表经过2秒为止。报告的基准分数是经过的时间除以被称为运动的次数。 Takea看看自己:

// Benchmark_harness / lib / src / benchmark_base.dart抽象类BenchmarkBase {//测量基准得分并返回。 double measure(){// ... //运行基准测试至少2000ms。 var结果= measureFor(练习,2000); // ...} //执行基准测试。默认情况下,调用[run] 10次。无效运动(){for(var i = 0; i< 10; i ++){run(); }} //通过重复执行该基准测试分数,直到//达到最小时间。静态double measureFor(函数f,int minimumMillis){var minimumMicros = minimumMillis * 1000; var iter = 0; var watch =秒表();看。开始(); var elapsed = 0;而(过去的< minimumMicros){f();过去=看。经过的微秒; iter ++;返回经过/迭代; }}

不幸的是,这段代码有一个问题,使其不适合进行微基准测试:被测循环具有大量与练习本身无关的开销。最明显的是,它从每个操作系统以及每次迭代获取当前时间。在所测量的循环和包含要测量的实际操作的run方法主体之间,还存在与多级虚拟调度相关的开销。有一个针对Benchmark_harness的PR,该PR试图解决过于频繁地调用Stopwatch.elapsedMilliseconds的问题,但是尽管获得了批准,它还是陷入了困境。

避免这些开销的最佳方法是为每个基准设置一个单独的测量环路。

这就是它的样子。用户通过编写标有@benchmark批注的顶级函数来声明微基准。

// ubench / lib / main.dart import' package:benchmark_harness / benchmark_harness.dart' ;常量N = 32; @benchmark voidallocateFixedArray(){列表。填充(N,null,growable:false); } @benchmark voidallocateGrowableArray(){列表。填充(N,null,growable:true); }

然后,基准测试工具将生成一个辅助源文件,其中包含每个基准的一个测量循环,以及一些代码来选择在编译时应运行的基准:

// ubench / lib / main.benchmark.dart import' package:benchmark_harness / benchmark_harness.dart'作为基准线束;导入package:ubench / main.dart'作为lib; // ... void _ $ measuredLoop $ allocateFixedArray(int numIterations){while(numIterations-> 0){lib。 allocateFixedArray(); }} // ... const _targetBenchmark = String。 fromEnvironment(' targetBenchmark',默认值:' all'); const _shouldMeasureAll = _targetBenchmark ==' all' ; const _shouldMeasure $ allocateFixedArray = _shouldMeasureAll || _targetBenchmark ==' allocateFixedArray' ; // ... void main(){基准测试运行器。 runBenchmarks(const {// ... if(_shouldMeasure $ allocateFixedArray)' allocateFixedArray':_ $ measuredLoop $ allocateFixedArray,// ...}); }

// Benchmark_harness / lib / benchmark_runner.dart ///以给定的测量值[loop]函数以指数方式增加///,直到找到一个导致[loop]运行///至少[thresholdMilliseconds]和返回描述运行的[/ BenchmarkResult]。 BenchmarkResult度量(void Function(int)循环,{必需的字符串名称,int thresholdMilliseconds = 5000}){var n = 2; final sw =秒表();做{n * = 2; sw重启 (); sw开始();循环(n); sw停 (); } while(sw。elapsedMilliseconds< thresholdMilliseconds);返回BenchmarkResult(名称:name,elapsedMilliseconds:sw。elapsedMilliseconds,numIterations:n,); }

我们从一个非常简单的实现开始,尽管这样应该可以满足我们最初的微基准测试需求。但是对于更复杂的情况,我们可能需要做一些更严格的操作:例如,一旦找到足够大的numIterations,我们可以重复执行loop(numIterations)多次并评估观察到的运行时间的统计属性。

要生成main.benchmark.dart,我们需要解析main.dart并找到所有带有@benchmark注释的函数。幸运的是,Dart有许多用于代码生成的规范工具,这使这变得非常容易。

我要做的就是依靠package:source_gen并定义GeneratorForAnnotation的子类:

// Benchmark_harness / lib / src / benchmark_generator.dart类BenchmarkGenerator扩展GeneratorForAnnotation<基准> {// ... @override字符串generateForAnnotatedElement(元素元素,ConstantReader注释,BuildStep buildStep){最终名称=元素。名称 ;返回''&void $ {_ \ $ measuredLoop \ $$ name}(int numIterations){while(numIterations-> 0){lib。 $ {name}(); }}''' ; }}

基本上就是这样。现在,每当我在ubench中运行build_runner build时,我都会为lib / main.dart中定义的基准生成lib / main.benchmark.dart:

$ flutter run --release --dart-define targetBenchmark = allocateFixedArray -t lib / main.benchmark.dart在释放模式下在Pixel 3a上启动lib / main.benchmark.dart ...正在运行Gradle任务' assembleRelease&#39 ; ...正在运行Gradle任务' assembleRelease' ...完成4.9秒✓构建了build / app / outputs / flutter-apk / app-release.apk(4.9MB)。安装build / app / outputs / flutter-apk / app.apk ... 1,268ms颤振运行键命令。h重复此帮助消息。c清除screenq退出(终止设备上的应用程序)。I / flutter(12463):Benchmark_harness [{" event":" bunningmark.running"}] I / flutter(12463):benchmark_harness [{" event":" benchmark.result",&#34 ; params":{...}}] I / flutter(12463):Benchmark_harness [{" event":" benchmark.done"}]应用程序完成。

但是手动执行此操作并不是我的目标。相反,我打算将bin / benchmark_harness.dart脚本更改为既构建基准,又运行所有生成的文件以收集基准结果(有关完整代码,请参见此提交)。

// // Benchmark_harness / bin / benchmark_harness.dart void main()async {// ... //生成基准包装器脚本。打印(红色('生成基准包装器')); ' flutter pub run build_runner build' 。 start(progress:Progress。devNull()); //运行所有生成的基准。最终结果ByFile =<字符串,映射< String,BenchmarkResult>> {}; for(var文件in find(' *。benchmark.dart')。toList()。map(p。relative)){resultsByFile [file] =等待runBenchmarksIn(file); } //报告结果。 // ...} ///逐一运行`.benchmark.dart` [file]中的所有基准,并收集///的结果。未来<地图< String,BenchmarkResult>> runBenchmarksIn(字符串文件)异步{// ...

$ flutter pub run基准测试_harness生成基准测试包装器在lib / main.benchmark.dart中找到2个基准测试,allocateFixedArray基准测试已完成,测量allocateGrowableArray基准测试已完成,-------------------- -------------------------------------------------- ---------- lib / main.benchmark.dartallocateFixedArray的结果:0.0000030226074159145355 ms /迭代(最快)allocateGrowableArray:0.00018900632858276367 ms /迭代(慢62.5倍)

既然我们有了运行微基准测试的工具,就可以在运行基准测试时对其进行扩展以提供支持。这将有助于我们了解基准测试在哪里花费时间,并确认它正在准确地测量我们想要衡量的水平。

Flutter的发行版本不包含Dart的内置探查器,因此我们将不得不使用本机探查器,例如Android上的simpleperf。

Android提供了有关使用simpleperf的全面文档,在此不再赘述。 simpleperf还附带有名为app_api的C ++(和Java)代码,可以将其链接到应用程序中,以允许以编程方式访问探查器。

实际上,app_api并不会做任何花哨的事情:它只是使用正确的命令行选项运行simpleperf二进制文件。这就是为什么我决定只将app_api的相关部分移植到纯Dart的原因。我们也可以使用Dart FFI绑定到app_api的C ++版本,但这需要将C ++打包到Flutter插件中,这使事情变得复杂,因为Benchmark_harness是纯Dart程序包,并且不能依赖Flutter插件程序包。

// // beta_harness / lib / src / simpleperf / profiling_session.dart类ProfilingSession {未来<无效开始({RecordingOptions options = const RecordingOptions()})async {// ...等待_startSimpleperfProcess(options); }未来<无效_startSimpleperfProcess(RecordingOptions选项)async {最终simpleperfBinary =等待_findSimplePerf(); _simpleperf =等待流程。开始(simpleperfBinary,[' record','-log-to-android-buffer','-log',' debug' ,--stdio-controls-profiling','-app-#39; --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文件中。该文件将在应用程序的数据目录中创建:

未来<无效runBenchmarks(Map< String,void Function(int)>基准)async {_event(' benchmark.running');最终探查器= Platform。是Android吗? ProfilingSession():空;对于(基准中的可变项。项){最终结果=度量(项。值,名称:项。键); _event(&benchmark.result',result); if(profiler!= null){//以相同的迭代次数运行基准并对其进行概要分析。等待分析器。 start(选项:RecordingOptions(outputFilename:' perf- $ {entry.key} .data'));进入。值(结果。numIterations);等待分析器。停 (); }} _event(' benchmark.done'); }

api_profiler.py prepare配置您的设备进行性能分析-我们将在运行基准测试之前调用它;

api_profiler.py collect从设备中提取收集到的配置文件-在所有基准测试运行完毕之后,将调用它来从设备中提取所有生成的perf-*。data。

NDK的simpleperf二进制文件支持记录和报告命令,就像Linux性能一样。在NDK中环顾四周,我还发现了一堆用Python编写的帮助程序脚本(例如,report_html.py可以生成HTML报告)。深入了解这些脚本,我发现它们使用libsimpleperf_report.so库,该库处理收集的配置文件的解析和符号化。该库的API在simpleperf源代码中的simpleperf / report_lib_interface.cpp文件的顶部定义。

使用ffigen,我为此库生成了基于dart:ffi的绑定,从而使我可以从Benchmark_harnessscript中使用它来处理收集的性能分析样本:

最终reportLib = report_bindings。 NativeLibrary(ffi。DynamicLibrary。open(ndk。simpleperfReportLib));未来<无效_printProfile(字符串profileData)异步{最终会话= reportLib。 CreateReportLib(); reportLib。 SetRecordFile(session,Utf8。toUtf8(profileData)。cast()); //遍历所有收集的样本。 for(;;){最终样本= reportLib。 GetNextSample(session);如果(样本== ffi。nullptr){中断; }期末=样本。参考。时期;最终符号= reportLib。 GetSymbolOfCurrentSample(session);最终dsoName = Utf8。 fromUtf8(符号。ref。dso_name。cast());最后的symbolName = Utf8。 fromUtf8(symbol。ref。symbol_name。cast()); //在dso [dsoName]中处理符号[symbolName]的样本并//收集汇总统计信息(每个符号的样本,总采样时间等)。 // ...} //报告前N个最热门的符号}

当我第一次运行此程序时,我发现simpleperf不能将大多数样本真正地归因于libapp.so(包含AOT编译的Dart代码)或libflutter.so(包含Flutter引擎代码)的有意义符号。 )。这是我得到的第一份报告:

运行allocateGrowableArray时的热门方法:88.24%_kDartIsolateSnapshotInstructions(libapp.so)4.04%未知(libflutter.so)3.15%未知([kernel.kallsyms])1.44%pthread_mutex_lock(libc.so)1.30%pthread_mutex_unlock(libc.so).. 。

这不足为奇:这两个库都被剥离,并且不包含任何有用的符号信息供simpleperf使用。

幸运的是,可以从Cloud Storage提取libflutter.so符号,其中构建基础架构正在将它们归档。位于提交e115066d的Flutter引擎的ARM64 Android版本构建的符号...驻留在gs://flutter_infra/flutter/e115066d.../android-arm64-release/symbols.zip中。就在几个月前,我已经编写了一些Dart代码,用于基于@ flutter-symbolizer-bot的提交哈希值来下载和缓存Flutter Engine符号,因此我可以在这里重复使用相同的代码。

获取libapp.so的符号是一个更有趣的问题。 Dart VM AOTcompiler能够在ELF bin中生成DWARF调试部分

......