在过去的几个月里,我开始收到越来越多关于某些特定 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); @override void exercise() { for (int i = 0; i < 100000; i++) { run(); } } } class GrowableListBenchmark extends Benchmark { const GrowableListBenchmark(this.length) : super('growable[$length]'); final int length; @override void run() { List<int>()..length = length; } } class FixedLengthListBenchmark extends Benchmark { const FixedLengthListBenchmark(this.length) : super('fixed-length[$length]'); final int length; @override void 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.yaml dependencies: # ... 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.dart class BenchmarkGenerator extends GeneratorForAnnotation<Benchmark> { // ... @override String generateForAnnotatedElement( Element element, ConstantReader annotation, BuildStep buildStep) { final name = element.name; return ''' void ${_\$measuredLoop\$$name}(int numIterations) { while (numIterations-- > 0) { lib.${name}(); } } '''; } }