
-
通过 Instruments 优化 CPU 性能
了解如何借助 Instruments 中的两个新硬件辅助工具,针对 Apple 芯片来优化你的 App。我们将首先介绍如何分析你的 App,然后深入介绍通过 Processor Trace 调用的每个函数。此外,我们将讨论如何使用 CPU Counters 的各个模式来分析代码中的 CPU 瓶颈问题。
章节
- 0:00 - 简介与内容安排
- 2:28 - 性能思维
- 8:50 - 分析器
- 13:20 - Span 类型
- 14:05 - Processor Trace
- 19:51 - 瓶颈分析
- 31:33 - 总结
- 32:13 - 后续步骤
资源
- Analyzing CPU usage with the Processor Trace instrument
- Apple Silicon CPU Optimization Guide Version 4
- Performance and metrics
- Tuning your code’s performance for Apple silicon
相关视频
WWDC25
WWDC24
WWDC23
WWDC22
-
搜索此视频…
大家好 我叫 Matt 是一名操作系统内核工程师 今天我将教你如何使用 Instruments 来优化 Apple 芯片 CPU 的代码 高效利用 CPU 资源可以避免 当你的 App 需要处理大量数据 或快速响应交互时 出现明显的等待延迟 但预测软件性能表现很困难 这主要有两个原因 首先是 Swift 源代码 与实际运行代码之间 存在多层抽象 你编写的 App 源代码 会被编译成机器指令 最终在 CPU 上执行 但你的代码并非孤立运行 它会被编译器生成的 支撑代码、Swift 运行时 以及其他系统框架所增强 这些都可能通过内核系统调用 来代表你的 App 处理特权操作
这使得你难以评估代码所依赖的 软件抽象层的实际开销 第二个难以预测代码性能的原因在于 CPU 执行指令的方式 在单个 CPU 内部 功能单元会并行工作 以实现高效指令执行 为此 指令会采用乱序执行方式 仅保持表面上的顺序执行 此外 CPU 还受益于 多层内存缓存机制 来确保数据快速访问 这些特性能够大幅 加速常见的编码模式 比如线性内存扫描 或针对罕见条件的 防御性检查 如提前退出 但某些数据结构、算法或实现方式 若不经仔细优化甚至彻底重构 CPU 将难以高效执行 我们将介绍为 CPU 优化代码的 正确路径 首先会探讨如何开展性能调查 以数据为导向 优先聚焦最具提速潜力的部分 接着回顾传统的性能分析方法 这是识别代码中 CPU 过度消耗的很好的切入点 为了更深入分析 并填补性能分析的盲区 我们将使用 Processor Trace 记录每条指令并衡量 软件抽象层的开销 最后借助升级版的 CPU Counters 工具来分析 CPU 瓶颈 掌握算法微观优化的技巧 让我们首先建立正确的 性能调查思维模式 第一步是保持开放的心态: 性能瓶颈的源头往往出人意料 收集数据来验证假设 并确保你对代码执行过程的 心智模型是准确的
举例来说 除了单线程 CPU 性能外 还需考虑其他可能导致减速的因素 线程和任务除了 在 CPU 上执行外 还可能因阻塞而等待资源 如文件或共享可变状态的访问 “Swift 并发的可视化与优化” 讲座介绍了 分析任务“脱 CPU”原因的工具
当线程解除阻塞、 在 CPU 上运行时 也可能存在 API 误用问题 比如为代码设置了 错误的服务质量等级 或隐式创建了过多线程 更多详情请阅读 有关调节代码性能的文档 但如果问题出在效率上 你要么需要更改算法 及关联的数据结构 要么改进实现方式 即算法在编程语言中的具体表达 借助工具来确定 应该优先关注哪个方向 试试用 Xcode 内置的 CPU 监视器 检测用户与 App 交互时 是否存在 CPU 高负载情况 若需分析线程间的阻塞行为 及最终解除阻塞的线程 请使用 System Trace 工具
而针对影响 UI 或 App 主线程的问题 则使用专门的 Hangs 工具 关于如何确认 App CPU 使用率 需要优化的具体方法 请参考“使用 Instruments 分析挂起”讲座 但即使有工具的指导 实施优化时 也需谨慎 激进的微观优化可能使代码 难以扩展和维护 且往往依赖于脆弱的编译器优化 如自动向量化或引用计数消除 在着手侵入式的微观优化前 不妨探索能否彻底规避慢速操作 先思考这段代码为何需要执行 或许能直接删除这段代码 根本不做这项工作 感谢观看本次讲座…… 开个玩笑 但说真的 虽然这通常不现实 却能有效检验 你对这项工作结果重要性的预判
你也可以尝试将工作推迟到 关键路径之外执行 或仅在结果对用户可见时才执行 同样 预先计算数值也可以掩盖 实际完成工作所需的时间 甚至可能涉及在构建时 直接固化某些值 不过 这些方法可能 会导致不必要的功耗增加 或增大 App 的下载体积 对于输入相同的重复操作 缓存是另一种解决方案 但往往会带来新的难题 比如缓存失效或内存占用增加 当你已经穷尽一切方法 仍无法避免在性能敏感场景下 执行这些工作时 就需要让 CPU 更快地完成任务 这也正是我们今天要重点讨论的 优化工作应优先针对那些 对用户体验影响最大的代码 通常是用户 与 App 交互关键路径中 会感知到性能问题的部分 但也可能是那些 耗电的长时间运行操作 在本讲座中 我们将以预先生成的 整数列表的搜索为例展开分析 因为它正处在我 App 的关键路径上
我的 App 已经采用了 二分查找这一经典算法 它通过有序数组不断 对半缩小搜索范围来定位元素 以这个包含 16 个元素的数组为例 我们要搜索数字 5 所在的元素 首先 5 小于数组中间元素 20 说明目标必在前半部分 5 又小于前半部分中间元素 9 因此目标应在数组的前 1/4 区域 在与数字 3 比较后 仅用 4 步就锁定了匹配元素 这是我 App 中某个框架 提供的二分查找实现 它是一个独立的函数 它的参数命名采用了 “finding a needle in a haystack” (大海捞针) 的比喻 支持在 haystack 这个集合中 搜索可比较的 needle 这一算法跟踪两个变量: start 标记当前搜索范围的起始位置 length 表示待搜索区域的 剩余元素数量 当存在待搜索元素时 它检查搜索空间的中间值 若 needle 小于这个值 保持 start 不变 将搜索空间减半 若 needle 等于中间值 元素被找到 返回中间索引 否则 将 start 调整至 中间元素后一位 同时减半搜索空间
我们将准备逐步优化这一算法 通过比较每秒完成的搜索次数 或搜索吞吐量 来验证每个优化步骤是否有效 优化不必追求每次都有巨大飞跃 有些优化可能很难量化 但微小的 优化积累起来也会产生显著效果
为支持持续优化 我编写了自动化测试 来测量搜索吞吐量 测试环境无需特别复杂 只需获得性能的评估即可 这个 repeat-while 循环 会持续调用搜索闭包 直到达到指定时长 我在搜索闭包调用处 使用了 OS 标记区间 方便工具精准定位 待优化的测试代码段 我选择了“兴趣点”类别 因为 Instruments 默认集成了这个功能 计时部分采用 ContinuousClock 与 Date 不同 它不会回退且开销极低 这个方案虽简单 却能有效收集 算法性能的粗略数据 测试命名为 searchCollection 模拟了 App 二分查找用例 我们将运行搜索 1 秒钟 并为标记添加描述性名称 以便在同一记录中运行多组测试 闭包内的循环会 反复调用二分查找函数 从而分摊时间检测的开销 现在让我们在 Instruments 分析器下运行这个测试 分析二分查找的 CPU 表现 现有两个专注于 CPU 分析的 性能分析器可供选择: Time Profiler 和 CPU Profiler 经典的 Time Profiler 工具基于 计时器定期对正在系统 CPU 上 运行的内容进行采样 在本例中 我们看到两个 CPU 上 正在执行某些任务 每次采样时 Time Profiler 会捕获每个 CPU 上运行线程的 用户空间调用栈
随后 Instruments 可以将这些 采样数据可视化为调用树或火焰图 通过详情视图近似展示 需要针对 CPU 性能 进行优化的关键代码 这种方法有助于分析 工作负载随时间分布的情况 或识别哪些线程同时处于活跃状态 但使用计时器采样调用栈 存在一个“混叠”的问题 混叠是指系统中某些周期性任务 的执行节奏与 采样计时器的频率同步 在这里 蓝色区域实际占据了 大部分 CPU 时间 但采样器捕获调用栈时 恰巧总是遇到橙色函数正在执行 这导致橙色函数在 Instruments 调用树中的占比被错误放大
为解决这个问题 可改用 CPU Profiler 它基于各 CPU 的时钟频率自主采样 建议在 CPU 性能优化时优先选择 CPU Profiler 而非 Time Profiler 因为它的采样更精确 并能更公平地 衡量消耗 CPU 资源的软件
这些钟形标记代表 CPU 周期计数器 对当前运行调用栈的采样时刻 Apple 芯片采用非对称 CPU 设计 部分核心 会以较低时钟频率运行以提升能效 自动升频的单个 CPU 核心 会被更频繁采样 彻底避免 Time Profiler 对 高速运行 CPU 采样不足的问题 我们将使用 CPU Profiler 来找出 二分查找函数中哪些部分 消耗了最多的 CPU 周期 在 Xcode 的测试导航器中 你可以通过辅助点按测试名称 然后选择“Profile”选项 从单元测试启动 Instruments 本例中我们将选择 Profile searchCollection
这会打开 Instruments 并显示模板选择界面 我将选择 CPU Profiler 在录制器设置中 我们将切换到延迟模式 以降低开销 然后开始录制 分析器默认的即时模式 有助于确认 App 的交互是否被捕获 但对于在与 Instruments 同一台 机器上运行的自动化测试 我们希望等到录制结束后再进行分析 从而尽量减少工具 可能带来的额外开销 Instruments 中的新文稿界面 往往令人望而生畏 窗口分为上下两部分 上半部分显示时间轴上的活动轨迹 每个轨迹可以包含多个通道 其中通过图表来展示不同级别或区域
在时间轴下方是详情试图 其中显示 当前检测时间范围内的摘要信息 所有扩展详情都会在右侧呈现 为了快速定位 我们可以在“兴趣点”轨迹中 找到执行搜索操作的区间 辅助点按这个区间 可选择设置检测范围 这样下方详情试图 将仅显示标记区间内捕获的数据 点按测试运行进程对应的轨迹后 时间轴下方的详情试图 会展示 CPU 性能分析数据 这个视图以调用树的形式 呈现测试过程中 各函数被 CPU 周期计数器 采样的情况 按住 Option 键并点按 首列函数旁的 V 形图标 调用树会持续展开直至 样本计数出现显著差异的第一个节点 这个位置最接近我们的二分查找函数 我们将通过点按二分查找函数 名称旁边的箭头 并选择“聚焦子树” 来集中分析这个函数 每个函数的权重由样本计数 乘以每次采样间隔的周期数决定 调用树显示 二分查找函数调用了 大量处理 Collection 类型的函数 占据了相当比例的样本 协议见证的调用约占样本的四分之一 还有分配 甚至数组对 Objective-C 类型的检查 如果我们改用更匹配 搜索数据特征的容器类型 就能避免数组和泛型带来的开销 让我们尝试新的 Span 类型 当元素在内存中连续存储时 许多数据结构都符合这一特性 可以用 Span 替代 Collection 它本质上是一个基址和计数的组合 但同时能防止内存引用 逃逸或泄漏到 使用它的函数之外 如需深入了解 Span 请观看 “优化 Swift 代码的 内存使用和性能”讲座 改用 Span 只需要将 haystack 和 return 类型改为 Span 算法本身无需修改
这一微小调整能使 搜索速度提升四倍 但当前版本的二分查找 仍对 App 有影响 我想进一步分析 Span 的边界检查 是否导致了额外开销 为此 我们将切换到一款名为 “Processor Trace”的新工具 从 Instruments 16.3 开始 Processor Trace 可以完整追踪 应用进程在用户空间执行的所有指令 这标志着性能 测量方式的根本性变革: 无采样偏差 对 App 性能的影响极低 仅有 1% 的影响 Processor Trace 需要依赖 特定 CPU 功能 目前仅支持 Mac、搭载 M4 的 iPad Pro 或搭载 A18 的 iPhone 开始前 需先在设备上 启用处理器追踪功能 在 Mac 上 打开“隐私与安全性” 和“开发者工具”下的设置 在 iPhone 或 iPad 上 这个设置位于“开发者”部分 为了获得最佳的 Processor Trace 使用体验 建议将追踪时间控制在几秒钟内 与 CPU Profiler 的采样方式不同 你无需批量处理任务: 即使是单个需要优化的 代码实例也足以进行分析 让我们对 Span 版本的二分查找 运行 Processor Trace 这次测试只需少量迭代即可 要分析这个测试 我会在 行号边栏中辅助点按测试图标 这会显示与之前相同的菜单 但比切换导航栏更便捷 选择 Processor Trace 模板后
即可开始录制
Processor Trace 需要处理大量数据 因此捕获和分析过程 可能需要一些时间 Processor Trace 让 CPU 记录每个分支决策 同时还会记录周期计数和当前时间 以跟踪 CPU 在每个函数中花费的时间 随后 Instruments 会结合 App 和 系统框架的可执行二进制文件 重构程序的执行路径 并为每个函数调用标注 消耗的周期数和耗时 我们之所以限制追踪时间 是因为即使 CPU 已尽可能 精简记录的信息 对于一个多线程应用程序而言 仍可能产生每秒数 GB 的数据量 现在文稿已准备就绪 让我们放大时间轴以检查 二分查找的函数调用情况 由于搜索操作在整个录制中 仅占极小部分 我们需要在时间轴下方的 详情试图中 通过“关注区域列表”找到它 然后辅助点按对应行 并选择“设置检测范围并缩放” 为了定位执行二分查找的线程 我们将辅助点按“启动线程”单元格 然后选择“在时间轴中固定线程”
Processor Trace 会为每个线程轨迹 新增一个函数调用火焰图 因此我将向上拖动固定线程的分隔线 为它腾出空间
Processor Trace 以火焰图的形式 直观展示代码执行情况 火焰图是一种用于呈现 函数开销与调用关系的图形化工具: 条状宽度表示函数执行耗时 纵向层级代表嵌套调用栈 但大多数火焰图显示采样数据 它的成本仅是 基于样本数的估算值 但 Processor Trace 的时间轴 火焰图截然不同: 它能精确还原 CPU 实际执行的调用时序 各颜色条块代表不同的二进制来源: 棕色表示系统框架 洋红色表示 Swift 运行时与标准库 蓝色表示编译到 App 二进制 或自定义框架中的代码 当前追踪结果的第一部分显示了 发出标记的开销 因此我们进一步放大检测范围 末尾附近的二分查找代码部分 按住 Option 键 并在时间轴上 点按拖动来放大
在 10 次迭代中 我可以任选一个 二分查找函数调用 通过辅助点按设置 检测范围并进行缩放 这正是 Processor Trace 的 强大之处 即便某个函数仅运行几百纳秒 我们也能完整捕获所有调用过程 虽然可以进一步放大分析 但让我们直接查看 时间轴下方的函数调用摘要 这个摘要以表格形式 呈现与时间轴相同的信息 其中包含短时间调用的 完整函数名称 我将按周期数对这个表格进行排序
最初关于边界检查 导致了性能下降的假设是错误的 当前二分查找的实现 仍然存在协议元数据开销 且无法内联数值比较操作 这些操作最终占据了 搜索总周期数相当大的比例 这是因为泛型 Comparable 参数 未针对具体元素类型进行特化
由于我的代码位于 App 链接的框架中 Swift 编译器无法为 调用方传递的类型 生成专用的二分查找版本
当框架代码出现这类开销时 你应该为框架函数添加可内联标注 以在框架客户端的 二进制文件中生成特化实现
但内联会使代码分析变得复杂 因为代码会与调用方混合 为了避免测试工具中的内联干扰 我会将这个函数手动特化为 App 和测试使用的 Int 类型 并使用新函数名 虽然代码丧失了很多通用性 但速度提升了约 1.7 倍 我们仍需持续优化 因为二分查找仍然是 导致 App 卡顿的因素之一 花如此多时间优化 单个函数看似有些奇怪 随着持续评估和收集更多数据 你可能会发现 其他代码才是性能瓶颈所在 我们特化的 Span 二分查找 在 Processor Trace 中 并未显示任何意料之外的函数调用 因此需要从 CPU 执行层面 理解代码的运行机制 才能进一步优化 我们可以使用 CPU Counters 工具来检测 代码在 CPU 运行时遇到的瓶颈 在再次使用 Instruments 之前 我们需要先建立 对 CPU 工作原理的心智模型 从根本上说 CPU 只是按照 指令列表执行操作 修改寄存器和内存数据 并与外围设备交互
当 CPU 执行指令时 它需要遵循一系列步骤 这些步骤大致可分为两个阶段 首先是指令交付阶段 确保 CPU 有指令可执行 然后是指令处理阶段 负责实际执行这些指令 在指令交付阶段 CPU 会获取指令 将指令解码为 更易于执行的微操作 大多数指令会被解码为单个微操作 但有些指令会执行多个操作 如发起内存请求 递增索引值 要处理一个微操作 它会被发送到映射与调度单元 进行路由和派发 随后 操作会被分配到执行单元 或需要访问内存时的 加载-存储单元
如果 CPU 必须串行执行这些阶段 才能开始下一次获取 效率将会非常低下 因此 Apple 芯片采用了流水线技术 当一个单元完成当前操作后 立即处理下一个操作 保持所有单元持续处于工作状态
这种流水线设计和 执行单元的冗余复制 支持了指令级并行
这与通过 Swift Concurrency 或 Grand Central Dispatch 实现的进程或线程级并行有本质区别 后者依赖多个 CPU 核心 执行不同的操作系统线程 指令级并行性让单个 CPU 能利用原本可能闲置的 单元时间 并高效利用硬件资源 保持流水线各环节满载 你的 Swift 源代码并不直接 控制这种并行机制 而是必须帮助编译器生成 一个合适的指令序列
遗憾的是 由于 CPU 各单元间的 复杂交互关系 可并行化的指令序列 并不总是直观可见 单元间的每个箭头都代表着 流水线中可能发生停滞的位置 这些“瓶颈”会限制 可实现的并行度
要确定哪些性能瓶颈 与我们的工作负载相关 Apple 芯片 CPU 能够统计 每个执行单元中发生的 特定事件及指令的其他特征 CPU Counters 工具通过 读取这些计数器 将它们转化为更高层次的性能指标 今年我们为这些计数器 新增了预设模式 让它们更易使用 Instruments 采用一种称为 “瓶颈分析”的引导式迭代方法 来分析代码性能 现在我们就用它来查明 为何二分查找仍然运行缓慢 尽管它没有任何明显的函数调用开销 CPU Counters 工具采用 工作负载采样机制 因此我们需要 重新使用之前与 CPU Profiler 配合的测试框架来再次测量吞吐量
现在让我们用 Instruments 对 特化 Span 实现的测试用例进行分析
我们将选择 CPU Counters 模板
如今它提供了包含 精选测量模式的引导式配置
若想了解每个模式的具体功能 只需点按 模式选择框旁的信息图标 即可查看相关文档 这就开始计数检测吧
初始的 CPU 瓶颈模式 将 CPU 的工作负载分解为四大类 这些类别涵盖了 CPU 所有潜在的性能表现维度 Instruments 会以彩色堆叠条形图 和详情视图中的汇总表 来呈现这些分类数据 在记录过程中 Instruments 将采集 测试线程的 CPU 计数器数据 并将它们转化为具体的 瓶颈占比百分比 我们将像之前一样使用“兴趣点” 来定位和缩放选择搜索操作区域
接着将执行二分查找算法的 线程固定到时间轴上
当悬停在对应的 CPU 瓶颈轨道时 可以看到 “废弃瓶颈”指标占比较高 下方详情视图展示了 检测范围内的指标聚合数据 选择“废弃瓶颈”行后 右侧扩展详情视图 会显示相应说明 Instruments 还会在时间轴 图表上方显示备注信息 点按这个备注 可在下方查看更多细节 这些信息虽然有用 但我仍无法确定 搜索操作的哪个部分导致了瓶颈 在“建议下一模式”列下辅助点按 “已丢弃采样”单元格 将出现选项让你以不同模式 重新分析工作负载 我们来试一下 这个模式与 CPU Bottlenecks 略有不同 它仍会收集计数器数据 但同时会配置计数器 来触发采样 采样数据仅聚焦于 产生无效工作的指令 为了定位问题 我们再次通过 “兴趣点”确定分析范围
然后选择测试进程路径
并导航到时间线下方的“指令采样”
这里展示的并非调用栈 而是直接导致问题的具体指令 点按函数名旁的箭头打开 “源代码视图” 即可看到因 CPU 分支预测错误 而被采样的源代码 在这里 needle 与中间值的 比较指令被错误预测 要理解这些源代码行 为何导致如此多的错误预测 我们需要深入了解 CPU
CPU 很“狡猾” 会乱序执行指令 之所以指令看起来 是按顺序执行的 只是因为指令完成后 进行了额外的重排序步骤 这意味着 CPU 会预先推测 接下来要执行的指令 负责这项工作的分支预测器 通常很准确 但当缺乏历史执行规律 来判断是否进行分支跳转时 就可能选择错误路径
在我们的二分查找算法中 循环包含两种分支 第一种循环条件 在循环结束前通常都会执行 因此预测准确率高 未在采样中显现问题 而针对目标值的检查 本质上属于随机分支 预测器难以处理也就不足为奇
我已重写循环体 消除了那些 影响控制流且难以预测的分支 这个 If 语句的主体仅 根据条件分配值 这样 Swift 编译器就可以生成 条件移动指令 从而避免跳转到不同指令的分支操作 而基于条件从函数 返回或跳出循环的操作 必须通过分支指令实现 因此我还移除了提前返回的逻辑 我使用了未经检查的算术来避免 会终止程序的分支 这类微优化往往会 使代码变得脆弱易损 更不用说还会降低安全性和可读性 当我们做出这类改动时 应该返回初始的 CPU Bottlenecks 模式来检查 它对其他瓶颈指标的影响 我已经采集了新版无分支 二分查找的跟踪数据 现在的速度比带分支的版本快约两倍 现在性能瓶颈几乎 完全集中在指令处理环节 Instruments 建议我们 改用指令处理模式 重新分析工作负载
这个模式备注应运行 L1D 缓存未命中采样模式 缓存未命中样本显示 对数组的内存访问操作 正是导致 CPU 无法高效 执行指令的根本原因 我们来进一步了解 CPU 与内存 找出原因
CPU 通过多级缓存体系访问内存 这种设计能显著加速对相同地址 或可预测访问模式的重复访问 它从集成在每个 CPU 核心 内部的一级缓存开始 这一层级存储容量有限 但提供最快的内存访问速度 较慢的二级缓存位于 CPU 核心外部 提供更大的缓存空间 若两级缓存均未命中 则需访问主内存 访问速度比缓存命中路径慢 50 倍 这些缓存还将内存划分为 64 或 128 字节的分段 称为缓存行: 即使指令仅需读取 4 字节数据 缓存也会预加载更多数据 以期后续指令需要访问 邻近的其他字节
让我们看看这对 二分查找算法有何影响 在这个示例中 蓝色线条代表数组元素 灰色胶囊状区域则是 CPU 缓存操作的缓存行
初始状态下 整个数组都不在缓存中 第一次比较会将一个缓存行 及多个元素加载到一级数据缓存 但紧接着的下一次比较 就会遭遇缓存未命中 随后的迭代过程持续出现缓存失效 直到搜索范围缩小到 单个缓存行大小区域为止 二分查找对 CPU 内存层级结构 而言是个病态案例
但如果我们能接受对元素重新排序 以适配缓存特性 就可以将查找点放置在 同一个缓存行上 这种布局被称为 Eytzinger 布局 得名于 16 世纪奥地利一位用这个方式 编排家族图谱的家谱学家 这种优化并非毫无代价 它通过牺牲顺序遍历性能 来提升搜索速度 因为顺序遍历操作 现在反而会出现缓存未命中 让我们回到最初的二分查找示例 演示如何将 有序数组重组为 Eytzinger 布局 以中间元素为根节点 将二分查找过程建模为树形结构 其中每个中点都是子节点 Eytzinger 布局实际上就是 这颗树的广度优先遍历序列
靠近树根的元素排列更为紧凑 更可能共享缓存行 现在再次搜索数字 5 时 前三个步骤都在同一缓存行内完成 而位于数组末端的叶节点 则必然引发不可避免的缓存未命中
我记录的 CPU Bottlenecks 轨迹显示 Eytzinger 二分查找 比无分支版本还要快两倍 但这个案例揭示了一个有趣的现象 从技术层面看 性能瓶颈 仍然存在于指令处理环节 我们虽然优化了缓存友好性 但工作负载本质上 仍受限于内存带宽
这时应当监控性能表现 以确定何时停止 转而去优化 App 中的其他代码 因为当前的搜索操作已不再影响 关键路径性能 在这个过程中 我们 显著提升了搜索吞吐量 首先通过 CPU Profiler 检测到 从 Collection 切换到 Span 带来的显著加速
接着 Processor Trace 揭示了 未特化泛型的开销 最后在瓶颈分析的指导下 通过微优化实现了质的飞跃 总体而言 借助 Instruments 我们最终将搜索函数提速约 25 倍 要达成这些优化效果 我们一开始要秉持正确的思维模式 借助工具验证假设 逐步建立 对抽象成本的直觉认知 通过层层递进地使用更精细的工具 发现那些容易被忽视 但容易解决的开销 待软件层面的开销解决后 再转向针对 CPU 瓶颈的优化 我们逐渐理解 甚至开始体谅 那些被视为 理所当然的 CPU 特性 这个顺序很重要: 必须确保聚焦 CPU 的工具 不会被额外的软件运行时开销干扰
要将这些方法应用到你的 App 请以性能优化的思维 收集数据、追踪线索 编写性能测试用例以便能反复 使用这些 Instruments 进行测量 在论坛上反馈工具使用问题 请观看我之前提到的讲座以及 WWDC24 关于 Swift 性能的讲座 这些都能帮助你 构建更精确的心智模型 评估 Swift 强大抽象机制的开销 若想进一步理解 CPU 如何执行代码 请参阅 《Apple 芯片 CPU 优化指南》 感谢观看 愿你享受使用 Instruments 工具 在代码的“haystack”中 寻找优化“needle”的乐趣
-
-
6:37 - Binary search in Collection
public func binarySearch<E, C>( needle: E, haystack: C ) -> C.Index where E: Comparable, C: Collection<E> { var start = haystack.startIndex var length = haystack.count while length > 0 { let half = length / 2 let middle = haystack.index(start, offsetBy: half) let middleValue = haystack[middle] if needle < middleValue { length = half } else if needle == middleValue { return middle } else { start = haystack.index(after: middle) length -= half + 1 } } return start }
-
7:49 - Throughput benchmark
import Testing import OSLog let signposter = OSSignposter( subsystem: "com.example.apple-samplecode.MyBinarySearch", category: .pointsOfInterest ) func search( name: StaticString, duration: Duration, _ search: () -> Void ) { var now = ContinuousClock.now var outerIterations = 0 let interval = signposter.beginInterval(name) let start = ContinuousClock.now repeat { search() outerIterations += 1 now = .now } while (start.duration(to: now) < duration) let elapsed = start.duration(to: now) let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18 let throughput = Double(outerIterations) / seconds signposter.endInterval(name, interval, "\(throughput) ops/s") print("\(name): \(throughput) ops/s") } let arraySize = 8 << 20 let arrayCount = arraySize / MemoryLayout<Int>.size let searchCount = 10_000 struct MyBinarySearchTests { let sortedArray: [Int] let randomElements: [Int] init() { let sortedArray: [Int] = (0..<arrayCount).map { _ in .random(in: 0..<arrayCount) }.sorted() self.randomElements = (0..<searchCount).map { _ in sortedArray.randomElement()! } self.sortedArray = sortedArray } @Test func searchCollection() throws { search(name: "Collection", duration: .seconds(1)) { for element in randomElements { _ = binarySearch(needle: element, haystack: sortedArray) } } } }
-
13:46 - Binary search in Span
public func binarySearch<E: Comparable>( needle: E, haystack: Span<E> ) -> Span<E>.Index { var start = haystack.indices.startIndex var length = haystack.count while length > 0 { let half = length / 2 let middle = haystack.indices.index(start, offsetBy: half) let middleValue = haystack[middle] if needle < middleValue { length = half } else if needle == middleValue { return middle } else { start = haystack.indices.index(after: middle) length -= half + 1 } } return start }
-
15:09 - Throughput benchmark for binary search in Span
extension MyBinarySearchTests { @Test func searchSpan() throws { let span = sortedArray.span search(name: "Span", duration: .seconds(1)) { for element in randomElements { _ = binarySearch(needle: element, haystack: span) } } } @Test func searchSpanForProcessorTrace() throws { let span = sortedArray.span signposter.withIntervalSignpost("Span") { for element in randomElements[0..<10] { _ = binarySearch(needle: element, haystack: span) } } } }
-
19:17 - Binary search in Span
public func binarySearchInt( needle: Int, haystack: Span<Int> ) -> Span<Int>.Index { var start = haystack.indices.startIndex var length = haystack.count while length > 0 { let half = length / 2 let middle = haystack.indices.index(start, offsetBy: half) let middleValue = haystack[middle] if needle < middleValue { length = half } else if needle == middleValue { return middle } else { start = haystack.indices.index(after: middle) length -= half + 1 } } return start }
-
23:04 - Throughput benchmark for binary search in Span
extension MyBinarySearchTests { @Test func searchSpanInt() throws { let span = sortedArray.span search(name: "Span<Int>", duration: .seconds(1)) { for element in randomElements { _ = binarySearchInt(needle: element, haystack: span) } } } }
-
26:34 - Branchless binary search
public func binarySearchBranchless( needle: Int, haystack: Span<Int> ) -> Span<Int>.Index { var start = haystack.indices.startIndex var length = haystack.count while length > 0 { let remainder = length % 2 length /= 2 let middle = start &+ length let middleValue = haystack[middle] if needle > middleValue { start = middle &+ remainder } } return start }
-
27:20 - Throughput benchmark for branchless binary search
extension MyBinarySearchTests { @Test func searchBranchless() throws { let span = sortedArray.span search(name: "Branchless", duration: .seconds(1)) { for element in randomElements { _ = binarySearchBranchless(needle: element, haystack: span) } } } }
-
29:27 - Eytzinger binary search
public func binarySearchEytzinger( needle: Int, haystack: Span<Int> ) -> Span<Int>.Index { var start = haystack.indices.startIndex.advanced(by: 1) let length = haystack.count while start < length { let value = haystack[start] start *= 2 if value < needle { start += 1 } } return start >> ((~start).trailingZeroBitCount + 1) }
-
30:34 - Throughput benchmark for Eytzinger binary search
struct MyBinarySearchEytzingerTests { let eytzingerArray: [Int] let randomElements: [Int] static func reorderEytzinger(_ input: [Int], array: inout [Int], sourceIndex: Int, resultIndex: Int) -> Int { var sourceIndex = sourceIndex if resultIndex < array.count { sourceIndex = reorderEytzinger(input, array: &array, sourceIndex: sourceIndex, resultIndex: 2 * resultIndex) array[resultIndex] = input[sourceIndex] sourceIndex = reorderEytzinger(input, array: &array, sourceIndex: sourceIndex + 1, resultIndex: 2 * resultIndex + 1) } return sourceIndex } init() { let sortedArray: [Int] = (0..<arrayCount).map { _ in .random(in: 0..<arrayCount) }.sorted() var eytzingerArray: [Int] = Array(repeating: 0, count: arrayCount + 1) _ = Self.reorderEytzinger(sortedArray, array: &eytzingerArray, sourceIndex: 0, resultIndex: 1) self.randomElements = (0..<searchCount).map { _ in sortedArray.randomElement()! } self.eytzingerArray = eytzingerArray } @Test func searchEytzinger() throws { let span = eytzingerArray.span search(name: "Eytzinger", duration: .seconds(1)) { for element in randomElements { _ = binarySearchEytzinger(needle: element, haystack: span) } } } }
-
-
- 0:00 - 简介与内容安排
由于 Swift 源代码与机器指令之间存在多层抽象,以及 CPU 乱序执行指令和使用内存缓存的复杂方式,因此针对 Apple 芯片 CPU 的代码优化非常复杂。 Instruments 可帮助开发者应对这些复杂情况,并支持性能调查,通过分析系统性能来识别 CPU 使用率过高的情况。使用 Processor Trace 和 CPU Counters 工具来记录指令、衡量成本和分析瓶颈,最终提高代码效率并改进 App 性能。
- 2:28 - 性能思维
在调查 App 中的性能问题时,保持开放的心态并收集数据来验证假设至关重要。速度变慢可能是由多种因素导致的,例如等待资源的线程受阻、API 滥用或算法效率低下。 Xcode 中的 CPU Gauge 以及 Instruments 中的 System Trace 和 Hangs 等工具对于识别 CPU 使用模式、阻塞行为和 UI 无响应非常有用。由于微优化会使代码更难维护,因此在深入进行微优化之前,最好先探索替代方法。 这些替代方法包括避免不必要的工作、通过并发延迟相关任务、预先计算值,以及缓存由复杂操作计算的状态。如果这些策略已用尽,则有必要优化 CPU 密集型的代码。 重点优化对用户体验有重大影响的代码,例如用户交互的关键路径。建议采用逐步优化,通过 Xcode 和 Instruments 中的自动化测试和性能指标来衡量进度。
- 8:50 - 分析器
为了分析这个讲座中二分查找示例的 CPU 性能,Instruments 中提供了两个分析工具:Time Profiler 和 CPU Profiler。 Time Profiler 会定期对 CPU 活动进行采样,但可能会受到混叠的影响,即周期性任务会扭曲 CPU 使用情况的表示。而 CPU Profiler 则基于 CPU 的时钟频率独立地对 CPU 进行采样,因此更加精确,更适合用于 CPU 优化。 在这次分析中,我们选择了 CPU Profiler 并从 Xcode 的测试导航器启动,然后将 Instruments 中的录制模式设置为“延迟模式”,以最大程度地减少开销。讲座中还介绍了 Instruments 中的各个区域,包括时间线视图、轨道和通道,以及显示分析结果的详细信息视图。 通过查看“xctest”进程的 Points of Interest 轨道和 Process 轨道,可以识别出示例 App 中执行二分查找的具体区域。详细信息视图中的调用树显示,与“Collection”协议相关的函数会消耗大量 CPU 时间。为了优化性能,建议改用更高效的容器类型,例如“Span”,以避免使用具有写时拷贝语义的“Array”和泛型所带来的开销。
- 13:20 - Span 类型
Swift 6.2 引入“Span”,这是一种高效利用内存的数据结构,用于表示具有基地址和长度的连续内存范围。在二分查找的输入和输出类型中使用“Span”,可以在不改变算法的情况下将性能提高 400%。接下来,为了进一步优化性能,使用 Processor Trace 工具来分析边界检查带来的开销。
- 14:05 - Processor Trace
Instruments 16.3 引入了一个名为 Processor Trace 的重要新工具。借助这个工具,你可以在搭载 M4 及更新芯片的 Mac 和 iPad Pro 上,或搭载 A18 及更新芯片的 iPhone 上,全面记录 App 进程在用户空间中执行的所有指令。 Processor Trace 需要特定的设备设置才能启用,并且由于会生成大量数据,最适合用于短时间的会话跟踪。通过记录每个分支决策、周期计数和当前时间,Instruments 可以重建 App 的确切执行路径。 数据以火焰图的形式直观呈现,显示了每个函数调用在一段时间内所花费的时间。与使用采样的传统火焰图不同,Processor Trace 的火焰图提供了 CPU 如何执行代码的精确表示。这样,你就能以前所未有的精度识别性能瓶颈。 通过对追踪数据的分析可以清楚地看到,协议元数据开销以及数值比较操作无法内联是导致特定二分查找函数显著变慢的主要原因。为了解决这个问题,该函数被手动特化为 Int 类型,从而将性能大幅提高了约 170%。然而,由于 App 的二分查找实现仍然会导致 App 的整体速度变慢,因此仍需要进一步优化。
- 19:51 - 瓶颈分析
Apple 芯片 CPU 分两个阶段执行指令:指令传递和指令处理,这两个阶段采用流水线技术以实现指令级并行。这允许同时处理多个操作,从而最大限度地提高效率。但是,流水线中可能会出现瓶颈,导致操作停滞并限制并行度。 CPU Counters 工具通过对每个 CPU 单元中的事件进行计数来帮助识别这些瓶颈。它使用预设模式来衡量 CPU 性能,并将工作划分为几大类。当你分析采样数据时,它们可以查明导致问题的具体指令,例如错误预测的分支方向,这可能导致浪费周期和性能下降。 CPU 使用分支预测器乱序执行指令以提高性能。然而,随机分支可能会误导这些预测器。为了缓解这种情况,代码被重写以避免难以预测的分支,最终得到一个无分支的二分查找,速度大约提高了一倍。 由于 CPU 利用层次化的缓存来加快数据检索速度,App 的优化重点转向内存访问。二分查找算法的访问模式对于这种层次结构来说是病态的,导致频繁出现缓存未命中。通过使用 Eytzinger 布局重新排列数组元素,缓存局部性得到了改善,二分查找的速度又提高了 200%。 尽管进行了这些重大优化,但代码在指令处理方面仍然存在技术瓶颈,但通过各种分析和微优化技术,整体搜索函数的速度提高了约 2500%。
- 31:33 - 总结
通过首先测量和优化软件开销,然后专注于 CPU 的性能瓶颈,二分查找 App 的性能得到了提升。这一过程解决了那些容易被忽视的问题,并使代码更贴合 CPU 架构的特点。
- 32:13 - 后续步骤
要优化 App,请使用 Instruments 来收集数据、运行性能测试和分析结果。你还可以观看有关 Swift 性能的讲座,并阅读开发者文档中的《Apple 芯片 CPU 优化指南》。如有疑问或建议,也可以前往 Apple 开发者论坛。