大多数浏览器和
Developer App 均支持流媒体播放。
-
针对 Apple 芯片优化游戏的 CPU 作业调度
图形密集型游戏对硬件资源的要求非常高,每一帧都需要处理数百甚至数千个 CPU 作业。我们将向您展示如何组织整理这些作业,从而充分发挥 M1、M1 Pro 和 M1 Max 芯片上的 CPU 效率和性能。了解如何优化游戏来为玩家带来更好的整体体验。
资源
-
下载
Pierre Morf:欢迎参加 我们的讲座 今天的主题是 如何为 Apple 芯片游戏 调优 CPU 作业调度 我是 Pierre Morf 来自于 Metal 生态系统团队 我一直在协助第三方开发者 优化他们在 Apple 平台上的 GPU 和 CPU 工作负载 在 CoreOS 团队帮助下 我在这里汇总了一些信息和准则 以帮助提升游戏中的 CPU 性能和效率 我们将重点放在游戏上 因为游戏的硬件资源 需求通常比较高 另外 游戏中每一帧的 典型工作负载通常需要处理 数百乃至数千个 CPU 作业 要在 16 毫秒或 更短时间内完成这些作业 必须对作业进行定制 以最大程度提高 CPU 吞吐量 并将提交开销降至最小 首先 我将简要介绍 Apple 芯片 CPU 及其独特架构 接着就如何安排工作以 最大程度提升 CPU 效率 提供一个基础性指导 最后介绍在贯彻这些准则后 可以利用哪些实用的 API 首先从 Apple CPU 架构开始 Apple 自己设计芯片 已有十多年了 这些芯片是 Apple 设备的核心 Apple 芯片提供 很高的性能 以及出色的效率 Apple 去年推出了 M1 芯片 这是供 Mac 电脑使用的 第一代 Apple 芯片 今年我们推出了 M1 Pro 和 M1 Max 全新的设计代表着 Apple 芯片的一大飞跃 使它们能够高效地处理 超高要求的工作负载 M1 芯片在一个封装中 融入了许多组件
包含 CPU、GPU 神经网络引擎等等 也包含高带宽、低延迟的 统一内存 通过 Apple Fabric 提供给芯片上的 所有组件访问 这意味着 CPU 和 GPU 可以处理同样的数据 而无需进行拷贝 现在让我们聚焦到 CPU M1 芯片上的 CPU 包含两种类型的核心 性能 (P) 核心 和能效 (E) 核心 两者外形有所区别 E 核心尺寸较小 能效核心设计为 以非常低的功耗来处理工作 需要牢记的一个重点是 P 和 E 核心采用相似的微架构 即它们的内部工作方式 其设计使开发者不需要关心 线程是在 P 核心 还是 E 核心上运行 如果程序经过优化后 在一种核心上性能良好 在另一种核心上也 应当有不错的表现 这些核心以物理方式 分组在一起形成簇 至少会依据它们的类型来分组 M1 上的每个簇 有一个末级缓存 L2 由它的所有核心共享 跨簇通信则经由 Apple Fabric 进行 此处显示的 CPU 拓扑 专属于 M1 芯片 其他设备可能有不同的 CPU 布局 例如在 iPhone XS 上 一个簇有两个 P 核心 另一个簇有四个 E 核心 在此架构中 系统可以在需要时 针对性能进行优化 或在性能不是首要因素时 通过针对效率的优化 来延长电池续航时间 每个簇可以独立激活 或由内核调度程序调整其频率 具体取决于当前工作负载 这个簇的当前热压力 和其他因素 最后 要注意 P 核心 并不总是可用的 系统保留在临界温度情景下 使其变为不可用的权利 这里概述了不同 API 层 与 CPU 的交互 首先是 XNU macOS 和 iOS 上 运行的内核 这是调度程序所在的位置 决定何时在 CPU 上运行什么 在这之上有两个库: 含有 pthread 的 POSIX 以及 Mach 对象 它们都向 app 提供 基本的线程和同步原语 在这之上是更高级别的库 线程相关的 NSObject 封装 POSIX 句柄 在这个示例中 NSLock 封装 pthread_mutex_lock NSThread 封装 pthread 诸如此类 这也是 GCD 所在的位置 GCD 是一个高级作业管理器 我们稍后会介绍 在这个讲座中 我们将从低级别开始 一直往上讲到 API 特性结束 我们首先关注 什么架构最适合 CPU 以及如何减轻调度程序 所承受的工作负载 这将是我们的基本效率准则 它们适用于所有作业管理器实施 与 API 无关且适用于许多平台 包括搭载 Apple 芯片和 基于 Intel 的 Mac 假设我们在一个理想化世界中 并且有这样一个作业 如果将它分散到四个核心上 处理速度应该正好加快四倍对吗? 可惜真实 CPU 中不会这样简单 需要进行许多的记账操作 各自消耗一定的执行时间 在效率方面有三个成本需要注意 看一下核心 1、2、3 在处理我们的作业前 它们什么也不做 如果某个 CPU 核心 有一段时间无所事事 它会进入闲置状态 以节省能源 而重新激活闲置的 CPU 需要花费一点时间 这是我们的第一项成本 即核心唤醒成本 还有一项成本 CPU 工作最初由 操作系统调度程序发起 它决定了接下来应该 运行什么进程和线程 以及在哪一个核心上运行 CPU 核心随后切换至 相应执行上下文 我们将这称为调度成本 现在是第三 也是最后一项成本 假设核心 0 上运行的线程 向核心 1、2、3 上的 线程发出信号 例如借助信号标 信号发送不是瞬间完成的 在这个间隔中 内核必须确定 哪一个线程在等待原语 如果线程不是活跃状态 它就需要进行调度 这一延时称为同步延迟 这些成本 在大多数 CPU 架构中 以这样或那样的形式出现 本身并不会造成问题 因为它们是非常短的 但如果它们积少成多 并且经常反复地出现 就有可能给性能造成影响 这些成本在现实中是如何表现的? 这是一个在 M1 上运行的 游戏的 Instruments 跟踪 该游戏呈现出一个有问题的模式 在大多数帧中反复出现 它试图以极为精细的 粒度并行处理作业 我们已经将其时间线放大了许多倍 先让您有一个概念 此部分为时只有 18 微秒 我们聚焦到这个 CPU 核心 以及那两个线程 这两个线程本该可以并行运行 最终却在同一核心上按顺序运行 我们看看原因为何 它们以非常高的频率彼此同步 线程一向线程二发出启动信号 并迅速等待其对等线程完成 线程二开始工作 迅速向线程一发信号 并随后等待极短时间 这个模式一直重复下去 我们在这里看到了两个问题 问题一是以极高的频率 使用同步原语 这会中断工作并带来开销 我们可通过红色部分看到这一开销 问题二出在用蓝色部分 表示的活跃工作上 它持续时间特别短 仅有 4 到 20 微秒 这个持续时间太短了 几乎不比唤醒 CPU 核心的用时长 在这些红色部分中 操作系统调度程序 大多时候在等待 CPU 核心唤醒 但刚好在这发生之前 一个线程阻塞并释放了核心 第二个线程接着在同一核心上运行 而不是再花一点时间去等待 另一个核心唤醒 两个线程就这样失去了 并行运行的微小机会 仅凭这一观察 就能得出两条准则 首先要选择恰当的作业粒度 我们可以通过将小作业 合并为大作业来实现这个目标 调度线程总要花费一点时间 不管大小如何 如果作业微小 其调度成本 就会在线程时间线上 占据较大的部分 CPU 也会利用不足 与之相反 较大作业会因为 运行时间长而摊销调度成本 我们曾目睹一个提交许多 30 微秒工作项的 app 在合并了这些工作项后 大大提升了性能 其次是在利用线程前 将足够的工作排入队列 这可以逐个帧来进行 一次性让大部分工作准备就绪 在发出信号并等待线程时 这通常意味着其中一些线程 会调度到 CPU 核心上 而另一些则被阻塞 并从核心上移走 多次这样做就会形成性能陷阱 反复等待并暂停线程 会增加刚才谈到的那些成本 相反 让线程不间断地处理 更多作业可以剔除同步点 举例来说 在处理 嵌套的 for 循环时 最好是以较粗的粒度 并行处理 for 外循环 从而使内循环不被中断 带来更好的连贯性 提高缓存利用率 并在整体上减少同步点 在利用更多线程前 先判断是否值得这样做 现在来看另一个游戏跟踪 它是在 iPhone XS 上运行的 让我们聚焦到这些帮助器线程上 可以看到这里的同步延迟 这是内核向这些不同帮助器 发送信号所花费的时间 这里有两个问题 问题一是 这次的实际工作 又是特别小的 只有大约 11 微秒 特别是与整个开销相比来看 将这些作业合并到一起 应该可以大大改进能效 问题二是 在这个时间范围内 80 个不同的线程 调度到了三个核心上 我们可以看到 上下文在这里切换 也就是活跃工作 之间的微小间隙 在本例中 这还没有成为问题 但随着线程数量增加 上下文切换时间也会累积 并对 CPU 性能造成障碍 在一个典型的游戏中 每帧有至少几百个作业 如何才能最大程度减少 所有这些不同种类的开销? 最好的办法是使用作业池 工作器线程通过 作业窃取来消耗它们 线程调度由内核完成 我们看到这要花费一些时间 CPU 也必须要做些工作 如上下文切换 另一方面 如果在用户空间启动新作业 成本会降低不少 一般情况下 工作器只需递减 原子计数器并抓取作业指针 第二点 避免与预先确定的线程交互 因为使用工作器会 减少上下文切换的次数 而且随着它们抓取更多作业 您可以在已活跃的核心上 利用已活跃的线程 最后一点是明智地使用您的池 为排队的工作唤醒 数量刚好足够的工作器 这里也适用上一原则 将足够的工作排入队列 以确保值得唤醒工作器线程 并让它保持忙碌 我们已降低了开销 现在必须要充分利用 CPU 周期 这里是一些要避免的模式 避免繁忙等待 这可能会锁定 P 核心 而不是用它做些实事 也会阻止调度程序将线程 从 E 核心提升到 P 核心 您还会浪费能源 并产生不必要的热量 吞噬掉您的热余量 其次 yield 函数的定义 在不同平台甚至不同 操作系统之间是宽松的 在 Apple 平台上它意味着 “尝试将我在运行的核心让给 系统上运行的任何其他线程 不管什么作业或优先级” 实际上是将当前线程的 优先级直降到零 yield 也有系统定义的持续时间 可能会非常长 最长可达 10 毫秒 第三 也不建议调用 sleep 等待特定事件的效率更高 另外注意在 Apple 平台上 sleep(0) 没有任何意义 此调用甚至会被丢弃 这些模式通常表示 一开始就发生了基本的调度错误 请改为使用信号标或 条件变量来等待明确信号 最后一条准则是 根据 CPU 核心数 相应地扩展线程数 避免在您使用的每个框架或 中间件中重新创建新线程池 也不要基于工作负载来扩展线程数 如果工作负载大幅增加 线程数也会随之变化 请改为查询 CPU 信息 来相应调整线程池大小 最大程度利用当前系统的 并行处理机会 我们来看看如何查询此信息 从 macOS Monterey 和 iOS 15 开始 您可以通过 sysctrl 界面 查询 CPU 布局的高级详情 除了能获得所有 CPU 核心的总数外 现在还能通过 nperflevels 查询机器上有多少种核心 M1 上有两种核心 P 核心和 E 核心 使用此范围来查询 每种核心的数据 零表示性能最高 例如 perflevel{N}.logicalcpu 显示当前 CPU 具有 几个 P 核心 这只是一个概述 也可以查询许多其他详情 例如多少个核心共享同一 L2 更多详情参考 sysctl 手册页 或相关文档网页 在分析您的 CPU 使用情况时 两个 Instruments 跟踪非常有用 在 Game Performance 模板中 可以找到它们 第一个是 System Load 提供每个 CPU 核心的活跃线程数 第二个是 Thread State Trace 默认情况下 详细信息面板中 显示每个进程的线程状态 更改数量及其持续时间 可以更改为 Context Switches 视图 这个视图中提供选定时间范围内 每个进程的上下文切换次数 上下文切换次数是 衡量 app 调度效率 的有用指标 我们来对这个部分做个总结 遵循了这些准则后 您就能充分利用 CPU 并简化调度程序的必要工作 将微小作业合并为 运行时间较长的作业 可以增大微架构特性带来的效益 如缓存、预取器和预测指标 一次性处理更多作业意味着 中断延迟和上下文切换减少 适度扩展的线程池可以让调度程序 更加轻松地在 E 核心和 P 核心之间重新均衡工作 效率和性能方面的一个关键要点是 最大程度降低 工作负载缩放的频率 现在我们来详细介绍 您在运用这些准则时 可以利用哪些 API 块 在这个部分中 我们将探讨优先级确定和调度策略 同步原语 以及多线程处理时的内存注意事项 但首先我们来快速看一下 GCD 如果您没有作业管理器 或者作业管理器没有达到 您想要的性能水平 GCD 是一个不错的选择 这是一个利用了作业窃取技术 的通用作业管理器 可以在所有 Apple 平台 和 Linux 上使用 而且是开源的 此 API 经过了高度优化 首先 它已经遵循了 我们的所有最佳做法 其次 它已经 集成在 XNU 内核中 这意味着 GCD 可以为您 跟踪内部的详细信息 如当前机器的散热能力 P/E 核心比和当前热压力 等等 其接口依赖于顺序和并发调度队列 您可以按照不同的优先级 为其中的作业排队 从内部而言 每一个调度队列 都从私有线程池中 利用可变数量的线程 该数量取决于队列类型和作业优先级 这个内部线程池供整个进程共享 也就是说 在给定的进程中 多个库可以使用 GCD 而无需重新创建新的池 GCD 提供了诸多功能 我们来快速查看来自于 并发调度队列的两个函数 以对其工作方式有个了解 第一个是 dispatch_async 它允许您将包含函数指针 和数据指针的作业排入队列 在启动作业时 如果队列中的 下一作业也已准备好进行处理 并发队列可以利用一个额外线程 这一个选项非常适合 典型的异步独立作业 但不太适合解决大规模并行问题 该情形适合使用 dispatch_apply 此函数从头开始就利用许多线程 不会使 GCD 线程管理器过载 我们已发现几个专业 app 为了提升性能 将并行 for 迁移为 使用 dispatch_apply 这只是对 GCD 的简要概述 要详细了解 GCD 以及应避免那些模式 请参考这两个 WWDC 讲座
现在我们来说一下自定作业管理器 我们将介绍在直接操作 线程并进行同步时 应注意的几个重点 首先是确定优先级 在上一个部分中 我们介绍了如何提高 提交作业时的 CPU 效率 但到目前为止 我们没有提及 并非所有作业都是平等的 一些作业对时间敏感 需要尽快获得结果 另一些则在一两个帧后 才需要获得结果 因此有必要在处理作业时 传达重要程度 以便将更多资源 提供给更重要的作业 这可通过确定线程优先级来达成 设定正确的线程优先级 也会告知系统您的游戏 比后台活动更加重要 要实现这个目标 您可以给线程设置 一个原始 CPU 优先级值 或一个 QoS 类 两个概念彼此相关但稍有不同 原始 CPU 优先级 是一个整数值 指示计算吞吐量的重要程度 与 Linux 相反 在 Apple 平台上 这是一个升序值 值越大表示越重要 除了其他因素外 此 CPU 优先级也提示 线程应当在 P 核心 还是 E 核心上运行 现在此 CPU 优先级 不影响其余的系统资源 因为它不提供有关 线程正在做什么的意图 要设定线程的优先级 可以使用“服务质量” 简称 QoS 根据设计 QoS 可给线程附加语义 此意图可极大地帮助调度程序 就何时执行任务作出明智的决策 也加快了操作系统的响应速度 例如 重要性较低的任务 可以稍稍延后处理以节省能源 也允许设定系统资源访问的优先级 例如网络或磁盘访问 它也为线程提供计时器聚合 这是一种节能功能 QoS 类也包括 CPU 优先级 一共有五个 QoS 类 从重要性最低的 QOS_CLASS_BACKGROUND 到重要性最高的 QOS_CLASS_USER_INTERACTIVE 各自都有一个默认 CPU 优先级 您也可以选择在有限范围内 稍稍降低它的级别 如果您要精细地调节 选用同一 QoS 类的 几个线程的 CPU 优先级 您可以利用这一功能 注意要非常留心 Background 类 使用它的线程可能会在 很长时间里完全不运行 总体来看 游戏使用的 CPU 优先级范围是 5 到 47 我们看看实践中如何完成 首先 您需要使用默认值 分配并初始化 pthread 属性 接着设置所需的 QoS 类 然后将这些属性传递给 pthread_create 函数 最后销毁属性结构 也可以将 QoS 类 设置给已有的线程 比如这个函数作用于调用方线程 注意这里使用了偏移值 -5 将类的 CPU 优先级 从 47 降级到 42 注意函数名称中包含了 np 后缀 这代表“不可移植” 是一种用于 Apple 平台 专用函数的命名约定 最后要注意的是 如果您不使用这些函数 而改为直接设置原始 CPU 优先级值 则表示您选择 对此线程不用 QoS 这是永久的 之后无法对此线程 重新选用 QoS iOS 和 macOS 会处理许多进程 包括面向用户的 或在后台运行的进程 有时系统可能会过载 如果发生这种情况 内核需要确保所有线程 都有机会在某一刻运行 这通过优先级衰减来达成 在这种特殊情形中 内核会随着时间推移而 缓慢地降低线程优先级 这样所有线程都有机会运行 在极端的特殊情形中 优先级衰减可能会造成问题 游戏通常含有几个非常关键的线程 例如主线程和渲染线程 如果渲染线程被抢占优先级 您可能失去显示窗口 并且游戏也会卡顿 发生这种情形时 您可以利用调度策略来 选择不用优先级衰减 默认情况下 线程创建时会使用 SCHED_OTHER 策略 这是一种时间共享策略 使用它的线程可能会 受优先级衰减的约束 它也与我们之前展示的 QoS 类相兼容 另一方面 我们有 可选的 SCHED_RR 策略 RR 代表“轮询” 选用它的线程具有固定优先级 不受优先级衰减的影响 它在执行延迟上提供更好的一致性 注意它是专门为一致且定期的 高优先级工作而设计的 如专用渲染线程或各帧工作器线程 选用它的线程必须在非常 具体的时间窗口中工作 并且不能让 CPU 一直不停地保持忙碌 使用此策略也可能会导致 其他线程中出现资源短缺 最后 此策略与 QoS 类不兼容 线程需要使用原始 CPU 优先级 这是游戏线程的推荐布局 首先在您的游戏中定义 高、中、低优先级分别是什么 什么对用户体验至关重要 按优先级划分工作 可让系统知道 app 中的哪些部分最为重要 利用 Instruments 对游戏进行分析 并且仅选择将 SCHED_RR 用于 确实需要它的线程 另外 不要对跨越多个帧的 长时间工作使用 SCHED_RR 那样的情形可依赖 QoS 以帮助系统在其他 进程之间进行性能平衡 优先选用 QoS 的另一场景是 当线程与 Apple 框架互动时 如 GCD 或 NSOperationQueues 这些框架会尝试将 QoS 类 从作业发布者传播到作业本身 如果发布方线程放弃了 QoS 这显然会被忽略 最后一点与优先级相关 优先级倒置 如果高优先级线程 由于被低优先级线程阻塞而停滞 则会发生优先级倒置 这通常随互斥的情况出现 两个线程试图访问相同资源 争抢同一个锁定 在一些情形中 系统或可通过 提升低优先级线程来解决倒置问题 我们来看看具体过程 假设有两个线程 这是它们的执行时间线 在本例中 蓝色线程优先级较低 绿色线程优先级较高 中间是锁定时间线 显示两个线程中 谁将拥有该锁定 蓝色线程开始执行 并获得该锁定 绿色线程也启动 这时绿色线程会尝试获得 当前由蓝色线程拥有的锁定 绿色线程会阻塞 并等待该锁定再次可用 在这种情况下 运行时可辨别 哪一线程拥有该锁定 因此它可以通过提升 蓝色线程的低优先级 来解决优先级倒置 哪些原语能够解决优先级倒置 而哪些原语不具此能力? 具有单个已知所有者的 对称原语可以做到这一点 例如 pthread_mutex_t 或 效率最高的 os_unfair_lock 不对称原语不具这一能力 如 pthread 条件变量 或 dispatch_semaphore 因为运行时不知道 哪个线程将发出信号 在选择同步原语时 您要牢记这个特性 互斥访问则要优先使用对称原语 在结束这个部分前 我们来谈谈几个内存相关的建议 在与 Objective-C 框架交互时 一些对象是以自动 释放的形式创建的 这意味着它们会添加到列表中 使其解除分配只会在以后发生 自动释放池块是限制此类对象 可以保留多久的范围 它们可有效帮助减少您 app 的 峰值内存占用量 每个线程入口点至少 要拥有一个自动释放池 这一点很重要 如有任何线程操作自动释放的对象 例如通过 Metal 没有自动释放池 会导致内存泄漏 自动释放池块可以嵌套 以更好地控制何时回收内存 渲染线程最好 围绕重复帧渲染例程 再创建一个自动释放池 工作器线程的第二自动释放池 则应在激活时启动 并在工作器停下等待 更多工作时关闭 我们来看一个示例 这是工作器线程入口点 它直接从自动释放池块开始 然后等待作业变为可用 当工作器激活后 添加新的自动释放池块 并在处理作业期间保留 当线程即将等待并停下时 我们会退出嵌套的池 最后提供一条内存相关的提示 若要提高性能 请不要让多个线程 同时写入位于同一缓存线的数据 这称为“虚假共享” 从同一数据结构进行 多重读取没有问题 但这种彼此竞争的写入 会导致在不同硬件缓存 之间争抢同一缓存线 Apple 芯片上的缓存线 长度为 128 个字节 一个解决办法是在数据结构中 插入填充来减少内存冲突 以上是最后一个部分的内容 我们来总结一下 我们首先概述了 Apple CPU 架构 介绍了它的突破性设计 如何大幅提高了效率 然后探讨了如何高效地 向 CPU 输入工作 让它顺畅地运行 同时减少对 OS 调度程序施加的负载 最后介绍了重要的 API 概念 例如线程优先级确定 调度策略和优先级倒置 结束前还提供了内存相关提示 记得定期使用 Instruments 对您的游戏进行分析 以留意它的工作负载 从而尽早查明性能问题 感谢您的关注
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。