Attabench

Xcode 9 Swift 4 Platform Build Status

⚠️警告
此软件包已被 Swift Collections Benchmark 软件包 大部分取代。该软件包提供了一个可移植的基准测试解决方案,可在 Swift 支持的所有平台上运行,并且由 Swift 标准库团队维护。

Attabench 是一个 macOS 的微基准测试应用程序,旨在测量和可视化 Swift 代码的性能。

Screenshot of Attabench app

目录

背景

此应用程序用于对具有一个自由度(通常是大小)的底层算法进行微基准测试。它的工作原理是重复对各种大小的随机数据执行相同的操作,同时在漂亮的图表中持续绘制结果。Attabench 的默认对数-对数图非常适合一目了然地查看您的算法的性能。

Attabench 最初是为了我的 dotSwift 2017 演讲Optimizing Collections 书籍提供漂亮的对数-对数图而创建的。当时,似乎使用 Core Graphics 从头开始构建自定义图表渲染器比在 Excel 中处理一堆 CSV 文件和数据透视表更容易。(但必须指出的是,这种观点在实施过程中有所减弱。)

Attabench 是匆忙为一个用例而制作的,因此它的代码是礼貌的人可能会称之为有点乱。但是玩起来非常有趣,它生成的图表充满了奇怪而奇妙的奥秘。

如果您发现 Attabench 在您自己的项目中很有用,请考虑购买我的书的副本!它包含大量使用 Attabench 制作的基准测试;我确信您会觉得它既有趣又信息丰富。

安装

请按照以下步骤在您自己的电脑上编译 Attabench

  1. 将此存储库克隆到您的 Mac。

    git clone https://github.com/attaswift/Attabench.git Attabench --recursive
    cd Attabench
    
  2. 如果您还没有 Carthage,请安装它。(这假设您已安装 Homebrew。)

    brew install carthage
    
  3. 检索并构建依赖项(SipHashBTreeGlueKit)。

    carthage bootstrap --platform Mac
    
  4. 在 Xcode 9 中打开项目文件,然后构建并运行 Attabench 目标。

    open Attabench.xcodeproj
    

使用

Attabench 有两种文档格式:一种是定义要测试内容的基准文档(扩展名为 .attabench),另一种是包含基准测试时间的結果文档(扩展名为 .attaresult)。结果文档会记住它们来自哪个基准,因此您可以随时停止 Attabench 并重新启动任何特定的基准测试运行。您可以为任何基准创建许多结果文档。

应用程序启动时,它会提示您打开一个基准文档。每个基准包含一个或多个单独可测量的任务的可执行代码,这些任务可以接受一些可变输入。该存储库包含两个示例,因此您无需从头开始。

稍后我们将介绍如何定义您自己的基准测试文件;让我们先玩一下这个应用程序。

加载基准测试后,您可以按 ⌘-R 以使用工具栏和左侧面板中显示的参数开始运行基准测试。随着新测量的进行,图表会实时更新。

Screenshot of Attabench app

您可以通过查看中间面板中的状态栏来跟踪基准测试进度。在其下方是一个控制台区域,其中包含 Attabench 状态消息。如果基准测试在其运行期间在标准输出或标准错误上打印任何内容,也会将其包含在控制台区域中。

控制要测量的内容

您可以使用左侧面板中列表内的复选框来控制执行哪些任务。如果您有很多任务,则可以使用底部的搜索字段按名称过滤它们。(您可以使用否定、合取(AND)和析取(OR)来构建简单的表达式——例如,在搜索字段中键入 dog !brown, cat 将获取所有名称包含 dog 但不包含 brown 或包含 cat 的任务。)要一次选中/取消选中多个任务,只需选择它们并按任意一个复选框即可。

工具栏中的两个弹出按钮可让您选择要运行任务的大小间隔。Attabench 将在对数曲线上平滑地采样该间隔,该曲线完美地适合图表。

在有活动基准测试运行时,每当您更改窗口左侧的任何内容时,基准测试会立即停止并使用新参数重新启动。这包括在搜索栏中键入任何内容——仅运行可见的任务。小心不要中断长时间运行的测量。

底部的运行选项面板控制在报告测量结果之前执行任何特定任务的次数。通常,该任务重复迭代次数,并将最快的结果用作测量值。但是,当设置最短持续时间字段时,每个任务将继续重复,直到经过指定的时间为止;这可以平滑超级快速基准测试的图表,否则这些基准测试的结果会非常嘈杂。另一方面,当任务累积花费的时间超过最长持续时间字段中的时间间隔时,该任务将不会重复。(这将使您更快地获得长时间运行的任务的结果。)因此,使用持续时间字段,任何特定任务的运行次数可能会多于或少于您在迭代字段中设置的次数。

更改数据的显示方式

右侧面板用于配置图表的渲染方式。随意尝试调整这些控件;它们只会更改结果的显示方式;它们不会影响任何当前正在运行的基准测试。

顶部的弹出按钮可让您从少数内置视觉主题中选择一个,以彻底改变图表的外观。例如,演示文稿主题非常适合黑色背景上的演示文稿。(如果您需要更改这些主题或创建自己的主题,则当前需要修改应用程序的源代码。)

三个比例复选框可让您启用/禁用摊销时间显示或在任意两个轴上切换到线性比例。这些偶尔很有用。

曲线面板包括两个弹出按钮,用于选择要显示的数据。对于实际曲线,您可以从以下几种数据中进行选择

还有一个可选的误差带,您可以将其显示在每条曲线周围。以下是这些频段的可用选项

在所有情况下,底带始终设置为最小值,除非。(例如,μ - σ 很容易低于零,这在对数刻度上看起来很糟糕。)

警告:我对统计一无所知,也没有资格进行适当的统计分析。我选择这些选项是因为它们产生了看起来很酷的图表,这些图表似乎告诉了我一些关于数据分布的有意义的信息。这些 sigma 表达式看起来非常科学,但它们可能不是基准测试的最佳选择。(我很确定基准测试测量不遵循正态分布。)如果您确实了解这方面的信息,请提交 PR 以修复问题!

可见范围面板可让您选择要在图表上显示的值范围。默认情况下,图表会自动缩放以适合活动任务和整个活动范围的所有现有测量值。如果您需要放大图表的某个部分,设置特定范围很有用;抱歉,您无法直接在图表视图上执行此操作。

最后,两个性能字段可让您控制 Attabench 更新 UI 状态的频率以及重绘图表的频率。如果结果来得太快,则花在 Attabench 的 UI 更新上的 CPU 很容易影响测量结果。

从 Attabench 获取图表图像

要获取当前图表的 PNG 版本,只需使用鼠标将图表拖到 Finder 或另一个应用程序中。Attabench 还包含一个命令行工具来自动渲染图表 - 查看 Swift 包中的 attachart 可执行目标。当您需要生成多个图像时,可以使用它来防止手腕疲劳。(将命令行调用保存到脚本中还可以让您稍后重新生成整个批次。)

创建您自己的基准测试

Attabench 90% 的乐趣在于定义和运行您自己的基准测试。最简单的方法是制作包含的 SampleBenchmark.attabench 基准测试的副本,然后修改其中的代码。

Attabench 基准文档的剖析

.attabench 文档实际上是一个文件夹,其中包含运行基准测试任务所需的文件。唯一必需的文件是 run.sh;每次 Attabench 需要运行新测量时都会执行它。SampleBenchmark 中的那个使用 Swift Package Manager 来构建和运行文件夹中包含的 Swift 包。(您也可以用其他语言定义基准测试;但是,您需要自行实现 Attabench IPC 协议。Attabench 仅提供 Swift 中的客户端实现。)

在 Swift 中定义任务

SampleBenchmark 包含一个 Swift 包,该包已设置为在 Attabench 中运行基准测试;您只需将示例任务替换为您自己的任务即可。

(为了帮助您调试,您可能需要在 Terminal 而不是 Attabench 中构建包。它是一个普通的 Swift 包,因此您可以自行构建和运行它。它甚至包含一组命令行选项,您可以使用这些选项直接从命令行运行基准测试 - 当您需要调试有关任务的内容时,这非常有用。)

为了帮助您入门,让我们描述 SampleBenchmark 默认提供的任务。

要定义新的基准测试,您需要创建一个新的 Benchmark<Input> 泛型类的实例,并向其添加一些任务。

public class Benchmark<Input>: BenchmarkProtocol {
    public let title: String
    public var descriptiveTitle: String? = nil
    public var descriptiveAmortizedTitle: String? = nil

    public init(title: String, inputGenerator: @escaping (Int) -> Input)
    public func addTask(title: String, _ body: @escaping (Input) -> ((BenchmarkTimer) -> Void)?)    
}

每个基准测试都有一个 Input 类型参数,该参数定义该基准测试中所有任务采用的共享输入类型。要创建基准测试,您还需要提供一个函数,该函数接受大小(正整数)并返回该大小的 Input 值,通常使用某种随机数生成器。

例如,让我们创建一个简单的基准测试,衡量一些标准集合类型中的原始查找性能。为此,我们需要生成两样东西作为输入:集合应包含的元素列表,以及要执行的查找操作序列。 我们可以通过随机打乱从 0 到 size - 1 的整数来表示这两个部分,这样我们将元素插入集合的顺序与我们查找元素的顺序无关。

let inputGenerator: (Int) -> (input: [Int], lookups: [Int]) = { size in
    return ((0 ..< size).shuffled(), (0 ..< size).shuffled())
}

现在我们有了一个输入生成器,我们可以开始定义我们的基准测试了。

let benchmark = Benchmark(title: "Sample", inputGenerator: inputGenerator)
benchmark.descriptiveTitle = "Time spent on all elements"
benchmark.descriptiveAmortizedTitle = "Average time spent on a single element"

我们可以通过调用基准测试的 addTask 方法来向其添加任务。 让我们从一个任务开始,该任务通过在 input 数组上调用 Array.contains 来衡量线性搜索。

benchmark.addTask(title: "Array.contains") { (input, lookups) in
    guard input.count <= 16384 else { return nil }
    return { timer in
        for value in lookups {
            guard input.contains(value) else { fatalError() }
        }
    }
}

这个语法乍一看可能很奇怪,因为我们正在从一个闭包中返回一个闭包,而返回的闭包正在进行实际的测量。 这看起来很复杂,但它允许额外的功能,这些功能通常很重要。 在这种情况下,我们预计 Array.contains 实现的简单线性搜索会比较慢,因此为了保持测量速度,我们将输入的大小限制在 1.6 万个元素左右。 返回 nil 意味着该任务不想在特定的输入值上运行,因此它的曲线在图表上会出现一个与该特定大小相对应的间隙。

内部闭包接收一个 timer 参数,该参数可用于将测量范围缩小到我们实际感兴趣的代码部分。 例如,当我们测量 Set.contains 时,我们对构造集合所需的时间不感兴趣,因此我们需要将其从测量中排除。

benchmark.addTask(title: "Set.contains") { (input, lookups) in
    return { timer in
        let set = Set(input)
        timer.measure {
            for i in lookups {
                guard set.contains(i) else { fatalError() }
            }
        }
    }
}

但是,像这样预处理输入数据实际上最好在外部闭包中完成,这样任务的重复运行就不会浪费时间再次设置环境。

benchmark.addTask(title: "Set.contains") { (input, lookups) in
    let set = Set(input)
    return { timer in
        for value in lookups {
            guard set.contains(value) else { fatalError() }
        }
    }
}

这个变体在应用程序第二次及以后运行时会快得多。

为了让事情更有趣,让我们添加第三个任务,衡量在排序数组中的二分搜索。

benchmark.addTask(title: "Array.binarySearch") { input, lookups in
    let data = input.sorted()
    return { timer in 
        for value in lookups {
            var i = 0
            var j = array.count
            while i < j {
                let middle = i + (j - i) / 2
                if value > array[middle] {
                    i = middle + 1
                }
                else {
                    j = middle
                }
            }
            guard i < array.count && array[i] == value else { fatalError() }
        }
    }
}

就这样!为了完成工作,我们只需要启动基准测试即可。 start() 方法解析命令行参数并根据它收到的选项开始运行任务。

benchmark.start()

对结果感到惊讶

要运行新的基准测试,只需在 Attabench 中打开它,然后按播放。 这会得到像这样的图表

Sample benchmark results

该图表在两个轴上都使用对数刻度,并显示每个元素的摊销执行时间,其中每次测量的经过时间除以其大小。

通过仅查看 Attabench 生成的对数-对数图表,我们通常可以对算法的行为获得出人意料的深刻见解。 例如,让我们尝试解释上述图表中一些更明显的特征。

  1. 曲线从高处开始。 与在循环中查找多个成员相比,仅查找几个成员相对昂贵。 显然,存在一些开销(初始化迭代状态,预热指令缓存等),这对于小尺寸的执行时间是一个重要因素,但随着我们添加更多元素,它逐渐被我们的算法成本所掩盖。

  2. 在初始预热之后,使用 Array.contains 查找元素的成本似乎与数组的大小成正比。 这正是我们所期望的,因为线性搜索应该是线性的。 尽管如此,很高兴看到这一点得到证实。

  3. Set.contains 的图表具有显着的锯齿图案。 这一定是集合调整自身大小以防止哈希表过度拥挤的特定方式的副作用。 在锯齿的峰值处,哈希表处于满容量状态(已分配空间的 75%),导致相对频繁的哈希冲突,从而降低了查找操作的速度。 但是,当表增长到其先前大小的两倍时,这些冲突在下一个尺寸步骤中大部分消失。 因此,增加 Set 的大小有时会使其更快。 真棒!

  4. 从理论上讲,Set.contains 应该是一个 O(1) 操作,也就是说,所需的时间不应取决于集合的大小。 但是,我们的基准测试表明,这实际上仅在集合较小时才成立。

    从大约 50 万个元素开始,contains 似乎将档位切换到非恒定曲线:从那时起,每当我们使集合的大小加倍时,查找成本始终会略有增加。 我认为这是因为在 500,000 个元素处,我们基准测试的随机访问模式压倒了 转换后备缓冲区,该缓冲区使我们计算机的虚拟内存抽象高效。 即使数据仍然完全适合物理内存,也需要花费额外的时间来查找各个元素的物理地址。

    因此,当我们有很多数据时,随机分散的内存访问会变得非常缓慢---这实际上可能会破坏我们算法的复杂性分析。 可耻!

  1. Array.binarySearch 应该花费 O(log(n)) 时间才能完成,但是对于大型数组,这再次被证明是不正确的。 在五十万个元素处,二分搜索的曲线向上弯曲,就像 Set.contains 一样。 弯曲后,曲线的斜率看起来大致翻了一番。 加倍对数-对数图上线的斜率 会使原始函数平方,也就是说,时间复杂度似乎已经变为 O(log(n)*log(n)) 而不是 O(log(n))。

    仅仅通过查看图表,我们了解到在大型规模下,分散的内存访问会花费对数时间。 难道不是很了不起吗?

  1. 最后,Array.binarySearch 在二的幂次方大小处具有非常突出的峰值。 这不是一些随机的基准测试伪像:实际上,这些峰值是由于缓存行别名造成的,这是处理器 L2 缓存和我们的二分搜索算法之间有趣的(如果是不幸的)交互。 在足够大的连续数组上使用二的幂次方大小执行的二分搜索执行的一系列内存访问,往往都落在同一 L2 缓存行中,从而迅速压倒了它的关联容量。 尝试更改算法,以便在不影响曲线的整体形状和位置的情况下优化掉峰值!

内部细节:Attabench 协议

(在大多数情况下,您无需了解本节中的信息; 但是,如果您想使用除 Swift 以外的其他语言创建基准测试,则需要了解它。)

Attabench 使用两个参数运行 run.sh:第一个是常量字符串 attabench,用于标识协议版本,第二个是命名 FIFO 文件的路径,该文件将用作基准测试的报告通道。 (基准测试进度不会写入 stdout/stderr,以确保您仍然可以在基准测试代码中使用 print,而不必担心输出与进度报告交错。)

要运行的命令通过 stdin 文件馈送到 run.sh。 它由单个 JSON 编码的 BenchmarkIPC.Command 值组成; 类型定义包含一些文档,描述了每个命令应该做什么。 仅将一个命令发送到 stdin,然后立即关闭管道。 当 Attabench 想要运行多个命令时,它只会多次执行 run.sh

给出 run 命令后,Attabench 希望 run.sh 无限期地运行,不断进行新的测量,并在指定的大小和任务上无限循环。 测量结果应通过报告 FIFO 报告,以 JSON 编码的 BenchmarkIPC.Report 值。 每个报告必须写成单行,包括终止换行符。

当 Attabench 需要停止正在运行的基准测试时,它会将 SIGTERM(信号 15)发送到该进程。 预计该进程在 2 秒内退出; 如果没有,那么 Attabench 将立即使用 SIGKILL(信号 9)杀死它。 通常,您不需要执行任何操作即可使其工作---但是您应该意识到,基准测试可能会随时终止,因此,如果需要在退出之前进行任何清理,请务必为 SIGTERM 安装信号处理程序。