Dart微基准测试第一部分

简介: 在过去的几个月里,我开始收到越来越多关于某些特定 Dart 操作性能的问题。以下是Romain Rastel在他关于提高 Flutter中 ChangeNotifier 性能的工作的背景下提出的此类问题的示例。鉴于我的经验,我第一眼就知道这个特定的基准测试出了什么问题……但是为了讲故事,让我假装我没有。那我将如何处理这个问题?我通常会首先尝试重复报告的数字。在这种特殊情况下,我将首先创建一个空的 Flutter 应用程序

在过去的几个月里,我开始收到越来越多关于某些特定 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.yamlubench项目(记住它是我们创建的一个空的 Flutter 项目来托管我们的基准测试)以对我的版本有路径依赖benchmark_harness

# ubench/pubspec.yaml
dependencies:
  # ...
  benchmark_harness:
    path: ../benchmark_harness
  # ...

这允许我benchmark_harnessubench项目目录中运行脚本

$ 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}();
  }
}
''';
  }
}
相关文章
|
5月前
|
传感器 Android开发 开发者
构建高效Android应用:Kotlin的协程与Flow
【4月更文挑战第26天】随着移动应用开发的不断进步,开发者寻求更简洁高效的编码方式以应对复杂多变的业务需求。在众多技术方案中,Kotlin语言凭借其简洁性和强大的功能库逐渐成为Android开发的主流选择。特别是Kotlin的协程和Flow这两个特性,它们为处理异步任务和数据流提供了强大而灵活的工具。本文将深入探讨如何通过Kotlin协程和Flow来优化Android应用性能,实现更加流畅的用户体验,并展示在实际开发中的应用实例。
|
关系型数据库 MySQL Go
Go语言微服务框架 - 8.Gormer迭代-定制专属的ORM代码生成工具
我们对比一下GORM库提供的`gorm.Model`,它在新增、修改时,会自动修改对应的时间,这个可以帮我们减少很多重复性的代码编写。这里,我就针对现有的gormer工具做一个示例性的迭代。
93 0
|
5月前
|
移动开发 监控 Android开发
构建高效安卓应用:Kotlin 协程的实践与优化
【5月更文挑战第16天】 在移动开发领域,性能优化一直是开发者们追求的重要目标。特别是对于安卓平台来说,由于设备多样性和系统资源的限制,如何提升应用的响应性和流畅度成为了一个关键议题。近年来,Kotlin 语言因其简洁、安全和高效的特点,在安卓开发中得到了广泛的应用。其中,Kotlin 协程作为一种轻量级的并发解决方案,为异步编程提供了强大支持,成为提升安卓应用性能的有效手段。本文将深入探讨 Kotlin 协程在安卓开发中的应用实践,以及通过合理设计和使用协程来优化应用性能的策略。
57 8
|
5月前
|
移动开发 监控 Android开发
构建高效Android应用:Kotlin协程的实践与优化
【5月更文挑战第12天】 在移动开发领域,性能与响应性是衡量一个应用程序优劣的关键指标。特别是在Android平台上,由于设备的多样性和系统资源的限制,开发者需要精心编写代码以确保应用流畅运行。近年来,Kotlin语言因其简洁性和功能性而广受欢迎,尤其是其协程特性,为异步编程提供了强大而轻量级的解决方案。本文将深入探讨如何在Android应用中使用Kotlin协程来提升性能,以及如何针对实际问题进行优化,确保应用的高效稳定执行。
|
5月前
|
Java Android开发 开发者
构建高效Android应用:Kotlin协程的实践指南
【5月更文挑战第31天】在现代Android开发中,异步编程和性能优化成为关键要素。Kotlin协程作为一种在JVM上实现轻量级线程的方式,为开发者提供了简洁而强大的并发处理工具。本文深入探讨了如何在Android项目中利用Kotlin协程提升应用的响应性和效率,包括协程的基本概念、结构以及实际运用场景,旨在帮助开发者通过具体实例理解并掌握协程技术,从而构建更加流畅和高效的Android应用。
|
Go 微服务
Go语言微服务框架 - 3.日志库的选型与引入
衡量日志库有多个指标,我们今天重点关注两点:简单易用 与 高性能。简单易用是一个日志库能被广泛使用的必要条件,而高性能则是企业级的日志库非常重要的衡量点,也能在源码层面对我们有一定的启发。
297 1
|
5月前
|
移动开发 数据处理 Android开发
构建高效Android应用:Kotlin的协程与Flow的使用
【5月更文挑战第23天】 在移动开发领域,性能优化和异步编程一直是核心议题。随着Kotlin语言在Android开发中的普及,其提供的协程(coroutines)和流式编程(Flow)功能为开发者带来了革命性的工具,以更简洁、高效的方式处理异步任务和数据流。本文将深入探讨Kotlin协程和Flow在Android应用中的实际应用,以及它们如何帮助开发者编写更加响应迅速且不阻塞用户界面的应用程序。我们将通过具体案例分析这两种技术的优势,并展示如何在现有项目中实现这些功能。
|
5月前
|
测试技术 Android开发 开发者
构建高效Android应用:Kotlin协程与Flow的完美融合
【5月更文挑战第20天】 在现代Android开发中,提升应用性能和用户体验是至关重要的任务。Kotlin作为一种现代化的编程语言,以其简洁、安全和易于理解的特点被广泛采用。特别是Kotlin协程和Flow这两个特性,它们为处理异步任务和数据流提供了强大而灵活的工具。通过深入探索Kotlin协程和Flow的结合使用,本文将揭示如何利用这些特性构建更加高效且响应迅速的Android应用。我们将探讨实现细节,以及如何通过这种技术堆栈来优化资源管理和用户界面的流畅度。
|
关系型数据库 测试技术 Go
Go语言微服务框架 - 5.GORM库的适配sqlmock的单元测试
与此同时,我们也缺乏一个有效的手段来验证自己编写的相关代码。如果依靠连接到真实的MySQL去验证功能,那成本实在太高。那么,这里我们就引入一个经典的sqlmock框架,并配合对数据库相关代码的修改,来实现相关代码的可测试性。
126 0
|
Go API 微服务
go-zero微服务框架代码生成神器goctl原理分析(一)
go-zero微服务框架代码生成神器goctl原理分析(一)