大多数浏览器和
Developer App 均支持流媒体播放。
-
跨 Apple GPU 扩展计算工作负载
探索如何创建可跨 Apple GPU 高效扩展的计算工作负载。了解如何通过利用有效管道和并发调度优化工作分发,并最大限度缩小 GPU 时间线差异,从而增加 GPU 的占用;此外,您还将学习如何高效执行原子运算。我们还将向您介绍 Xcode 和 Instruments 中的最新计数器和工具,它们可以帮助您优化空间和时态内存访问模式。
资源
相关视频
WWDC23
WWDC22
Tech Talks
-
下载
大家好 欢迎 我是 Marco Giordano 是 Apple 的 GPU 软件工程团队的成员 在本期讲座中 我将给大家讲一下 如何将工作负载分配到 多个 Apple M1 GPU 上 如果您从事复杂的计算工作 想知道如何 充分利用 Apple 芯片硬件 并实现非常好的可扩展性 那么这个演讲就是为您准备的 我将首先讨论计算可扩展性的概念 以及 App 如何自然地在 M1 GPU 家族中扩展性能 然后 我将一步一步地分享 使用方法 并讨论哪些工具可用于 最大化您的工作负载的计算可扩展性 让我们首先了解一下什么是可扩展性 以及它为什么对您的工作负载很重要
Apple M1 GPU 从开始就为可扩展而设计 它能让您的工作负载 在整个 SoC 家族中实现卓越的性能 相同的 GPU 从 8 核 iPad 到 64 核 Mac Studio 都支持所有 Metal 3 功能
为了利用高水平的可扩展性 为 M1 优化过的 App 是一个很好的起点 许多著名的高端 App 已经 针对 Apple M1 进行了优化 并在所有设备上都达到了 出色的可扩展性
例如 这里我们有 Affinity Photo 和 DaVinci Resolve 后期制作行业的照片和视频编辑器 这些 App 都达到了优秀可扩展性 让我们来定义可扩展性的真正含义 以及如何实现 “理想的” 可扩展性 GPU 工作负载可扩展性 是指通过增加 GPU 内核数量 来提高性能的能力 右边的图表显示了 App 随着GPU核心数的增加 也在加速 线性比例改进被认为是理想的
然而 当您在做您的 App 时 您可能会注意到一种类型的扩展 它会进入一个平台期 随扩展的回报逐渐减少 或者由于 GPU 时间轴的间隙 而根本无法扩展 或者您可能会看到 另一种类型的扩展 性能有所提高 但在各阶段中并不统一 有些地方工作负载 会达到某些 GPU 限制 比如这里的 24 到 32 核 或 48 到 64 核
您的目标是尽可能接近线性扩展 我将向您展示识别瓶颈和实现 您想要的结果的工具和技术
在下个部分 我将讨论 最大化 GPU 扩展性的方法 对于每个工作负载 首先应该确定瓶颈在哪里 工作负载会受限于计算或带宽 在优化过程中 您可能会在两者之间来回切换 如果您有受限于算力 您可以尝试转移一些负载 利用内存来减少计算 反之亦然 当您扩充时 瓶颈可能会发生变化 一个好的解决方案是 使用像 MPS 或 MPSGraph 这样的 Apple 框架 如果可以利用它们的原语 我们就可以确保每个计算内核 在所有硬件上都能运行得最好 然而 您不能用 MPS 替换一切 所以配置并理解您的工作负载 至关重要
我将首先介绍三个 可以帮助最小化 GPU 间隙的事项
改进工作分配 消除 GPU 时间轴间隙 以及对原子操作的考虑 然后 我将解释 如何优化 GPU 限制 首先调研工作负载的计算网格尺寸 和内存布局的影响 然后研究 Blender Cycles 中的 一个特定示例 首先专注于最小化 GPU 间隙 这种扩展可能是 GPU 没有被充分利用的结果 GPU 时间轴上 存在硬件空闲间隙
让我们看看是否可以通过 研究任务分布来改善可扩展性
小的工作负载 通常不会使整个 GPU 饱和 而且内核同步也有一定的成本 因此两者都可能妨碍适当的扩展性 理解工作负载如何映射到硬件 是非常重要的 所以我们来讨论一下这一点 工作负载以线程组 3D 网格的形式分派 线程组被均匀地分布到 GPU 核心中 并且可以访问GPU内核本地 大小有限 但是非常快速的 线程组内存 单个线程组被进一步 分解为 SIMD 组 在不同的计算词汇中 也称为 wave 或 warp 在计算管道状态对象上检查 “threadExecutionWidth” 将返回 SIMD 并行宽度 在所有 Apple GPU 上 它都等于 32
每个线程组最多可以 有 1024 个线程 线程可以共享最多 32K 的 线程组内存
为了保持 GPU 忙碌 所有 GPU 核心 都应该有足够的工作要做
下面是一个要分派的网格的示例 线程组会被分配到 GPU 集群中 并分散到 GPU 核心中
如果线程组太少 工作负载就不会使机器完全饱和 下面是解决这个问题的方法
首先计算工作负载会产生多少线程 大致查看分派是否会 使整个机器饱和 对于相对复杂的内核 每着色器核心有 1K 到 2K 并发线程 被认为是非常好的占用情况 所以每个 GPU 核 1 到 2K 线程是一个经验法则 现在 如果有足够的工作 使硬件完全饱和 就可以进行计算了 这里的表显示了 使不同 SoC 饱和的 最低推荐线程数
另一件需要考虑的事 是避免使用不必要的 大线程组尺寸 更小的线程组可以更均匀地 将负载映射到硬件 使用更大的线程组可能会 阻止更均匀的分布 导致 GPU 核心不平衡
最好使用能够很好地 映射到工作负载的 SIMD 宽度的最小倍数 通过使用更小的线程组 GPU 有更多的机会 更好地平衡其工作负载
请经常使用 Xcode 或 Instruments GPU 工具 检查您的内核运行时性能
例如 在这个 GPU 捕获中 有一个内核在执行一些计算 占用率很低 这是意料之外的 编译器统计数据显示 最大理论占用率是100% 这是 Xcode 14 中的新数据 这表明可能没有足够的线程 实际上 我们可以看到 算法开始分派越来越少的线程 不再使机器饱和
占用率低可能还有其他几个原因 要了解所有细节 请查看演讲 Metal Compute on MacBook Pro Tech talk 好了 现在工作负载已经正确分配 是时候确保 GPU 一直忙碌了
低利用率的 GPU 永远不会带来理想的扩展性 最糟糕的情况是让 GPU 闲置 GPU 时间轴间隙 会导致 GPU 空闲
考虑一下这个例子 这是一个因为 CPU 和 GPU 之间的工作序列化 导致只使用了 50% GPU 的工作负载 在这种情况下 总的任务时间是 CPU 和 GPU 无重叠的工作总和
GPU 核数加倍会使 GPU 轨迹完成速度更快 但 CPU 轨迹不会受影响 整体性能只提高了 33% 与理想的扩展性相差甚远
如果 GPU 核心再次翻倍 GPU 上的工作负载甚至会更快 但总体延迟只比原来减少了 60% 因此在这种情况下 GPU 核心的扩展带来的回报被抵消 这远非理想 让我们解决它吧
M1 Pro 的 Instrument trace 显示了 很大的 GPU 时间间隔 这明显将阻止适当的扩展
在 M1 Ultra 上 相同的工作负载 确实更快一些 但 GPU 空闲时间变长了 工作负载不能很好地扩展 大间隙是因为 CPU 在命令缓冲区上 使用 waitUntilCompleted 同步引起的 在改变了等待逻辑 并移除序列化之后 GPU 得到了充分的利用 这是非常棒的
比较工作负载 扩展前后的情况 我们可以得出结论 扩展性已经更加接近理想状态了
在前面的例子中 完全移除 CPU/GPU 同步 是可能的 但由于您的 App 的性质 这并非总是如此 还有其他方法可以减少空闲时间 使用 MTLSharedEvents 来通知 CPU 输送更多的工作 考虑使用 GPU 驱动的编码 并使用并发调度 所以让我们来讨论一下 那些最小化 GPU 时间间隔的方法 它们中的一些可能适合您的工作流程
等待 CPU 完成 GPU 会导致扩展性不理想 如果您的 App 正在使用 WaitUntilCompleted 您可能会想尝试 使用 MTLSharedEvents 代替
MTLSharedEvent 具有较低的开销 可以帮助您减少时间间隔 接下来要考虑的事情是 工作负载管线化
如果算法拥有下一批处理 所需的数据 那么在等待 MTLSharedEvent 之前 就可以提前 对一批或多批进行编码 通过这样做 GPU 就不会无事可做 并且总是有工作要处理
如果工作不能在同一个队列上 提前编码 那么可以考虑使用第二个队列 来重叠工作 使用多个队列会允许您 提交独立的工作 并且不会在等待事件时 使其他提交线程暂停 这样 GPU 就有机会 继续接收和处理工作
在某些情况下 算法可以 直接从 GPU 编码工作
使用间接命令缓冲区 可以将下一批编码 直接转移到 GPU 上 避免了同步的需求 要了解更多 关于间接命令缓冲区的细节 请查看 “Modern Rendering with Metal” 该工作负载现在消除或尽可能减少了 CPU 和 GPU 之间高代价的同步 但即使 GPU 时间线很繁忙 扩展挑战也仍然存在 让我们来看看 这个图来自图像处理工作负载 在这里 每次处理 1 帧图像 大量的连续计算串行调度 也会限制扩展性 GPU 很忙 但是内核同步 是有成本的 此外 每次分派都会有一个小的增量 用于在还未饱和的内核上 继续分配线程组 同样地 当线程组完成并停用时 可能会没有足够的工作 使核心完全饱和 在这种情况下 建议 尽可能使独立的工作互相重叠 让我们来看一个直观的例子 我们有一个工作负载 在一个接一个地处理两个图像 正常情况下 内核之间需要进行同步 然而 这并不是安排工作的唯一方法 您可以使用并发分派 使两个图像的独立工作互相交错 在这里 由于并发调度 驱动程序能够交错不同的工作 我们可以看到 以前连续的两个内核 现在被独立的工作分开了 但是 当您使用 MTLDispatchTypeConcurrent 时 必须手动设置障碍 并发分派使驱动程序 能够更紧密地打包工作 隐藏依赖内核之间的 大部分同步成本 并衔接了不同内核的起始增量和结束 这种优化大大提高了 从 M1 Max 到 M1 Ultra 的 工作负载性能和扩展性 与之前的扩展性相比 两个图像交错时 工作负载运行速度提高了 30% 3 个图像并行时 运行速度提高了 70%
仔细考虑内核正在执行的 原子操作很重要 让我们确保它以最有效的方式使用 原子操作允许以安全的方式 从多个线程读取和写入数据 全局原子在整个 GPU 中是一致的 当许多线程试图读写相同的全局值时 这会导致争用 增加 GPU 核心的数量不能改变它 实际上还会导致更多的竞争 让我们通过一个例子 来研究如何改进算法中的 原子行为
这是一个归约算法 会将缓冲区中的 所有值都加起来 最简单的方法是在主内存中 为每个线程 执行一个原子加操作 但是 这并不理想 因为这会 给主内存中的单个值带来很大的压力 直接导致每个内存写入操作的序列化
硬件提供了两个方式来帮助处理 原子内存竞争 SIMD 组指令和线程组原子
prefix_exclusive sum 和 simd_min 等 SIMD 指令 允许在 SIMD 组之间通过寄存器 进行操作和交换内存 而不需要往返于内存 线程组原子由线程组内存完成 每个 GPU 核心都有自己的 线程组内存 使得可以按照 GPU 核的数量进行扩展 让我们看看这两个特性 能如何帮助您改善工作负载
这里我们有同样的归约问题 但是这次它开始使用一个 SIMD 组指令 一个包含内存求和 这样的操作将把 SIMD 组中 所有数字的总和 留在最后一个线程中 然后 每个 simd 组中的 最后一个线程可以在线程组内存中 执行单个原子加操作 将所有 simd 组归约到 线程组内存中的单个值 通过这种方式 使用 SIMD 组指令和线程组内存 在完全不使用主内存的情况下 完成了整个线程组的归约 每一组将能够独立并行归约
现在每个线程组已经归约到一个值 每个线程组可以在主内存中 执行一个原子 这不仅使每个线程组 只需要一个原子 而且由于线程组 在不同的时间完成 它会随着时间的推移分散原子 从而进一步减少内存争用 总结一下 要最大限度地提高原子的效率 请尝试利用内存局部性 尝试使用 SIMD 组操作 以及利用线程组内存原子 所有这些都将极大程度 帮助减少妨碍扩展性的原子操作压力
现在 GPU 间隙已经修复 是时候看看扩展性是否更接近理想了 Xcode 和 Metal System Trace 中的 GPU 限制器 有助于优化 GPU 核心 执行管道中的瓶颈和低效 例如 低效的内存访问模式 总是导致末级缓存 或 Memory Management Unit 也就是 MMU 限制很高 而利用率则很低 首先要解决的是调整线程组 和内存布局方法 减少内存跨度和发散的关键 是要清楚地理解 工作负载内存访问模式 包括空间和时间上的模式 一旦理解了这一点 就有两种可能的调整方向 重新组织数据布局 以提高数据访问局部性 或者调整访问模式 以更好地匹配数据布局 并提高内存和缓存局部性 让我们来看一个例子
这有一个内存缓冲区 数据是横向排列的 一行接着一行 然而 当调度计算内核时 通常会有一个类似 2D 的模式 其中分布着方形的线程组 这在很大程度上是空间局部化的 这种访问模式和数据布局 对于数据局部性来说不是很合适
例如 当第一个 SIMD 组访问数据时 请求被打包在缓存线中 大部分缓存线不会被使用 但是仍然会占用缓存空间 要重新排列数据 以更好地适应访问模式 例如 它不跨越整行 而是被局部化为条带
使用这种新的内存布局 线程组将能够利用 在缓存线中请求的大部分数据 从而减少分化并提高缓存效率
另一种选择是改变 3D 网格的分配方式 以更好地适应当前的数据布局 尝试调整线程组的大小 来创建能更好地映射到内存布局的组 例如 一个更偏向矩形的 形状 在这种情况下 访问模式与内存布局保持一致 从而提供了更高的缓存效率 您可能需要尝试找到 最适合您的工作负载的方法 有时 您可能需要做出权衡 牺牲线程发散性来换取内存位置 或者反过来 更改数据布局 网格分派 或它们的组合 每个工作负载和访问模式都是不相同
现在您已经了解了 改善内存位置的方法 让我们看看 Blender Cycles 中 更具体的例子
Cycles 是 Blender 用于 产品渲染的基于物理的路径追踪器 它旨在为生产需要 提供开箱即用的具有艺术控制 和灵活的着色节点的基于物理的结果
这个 Instrument trace 清楚地显示了 低读带宽 高最高 GPU 限制器 高缓存限制器 和低末级缓存利用率
把握带宽和 MMU 限制器 对于扩展性是很重要的 如果您的最高限制器 是末级缓存或 MMU 您就需要减少您的内存跨度 并最大化数据局部性 让我们看一个例子
Cycle 使用数据排序来减少分化 它通过按材质类型 对光线碰撞进行分类来实现这点 这利于减少线程分化 但是它增加了空间内存分化 导致了高 MMU 限制器 为了解决这一点 我们尝试了 在排序之前对内存范围进行分区 以增加数据的局部性 让我们设想一下 当光线被射入场景以模拟光的传播时 它们会击中物体 而数据则被收集到缓冲区中 在交点上 我们能知道很多东西 被击中的材料类型 比如玻璃 金属等等 交点的位置 光线等等 为了简单起见 我们只关注材料类型 这是内存缓冲区中的材料
由于每次射线命中都会收集大量数据 内存缓冲区可能会变得相当大 为了避免移动大量内存 要填充索引列表并对其进行排序 在排序之后 相同材料类型的索引 现在被包装在了一起 SIMD 组可以开始 加载索引并处理材料了 SIMD 组将使用索引 加载原始缓冲区中的相应数据
但是 simd 组 将在整个内存范围内读取数据 这会给 MMU 带来了压力 让我们来研究一下新方法 内存范围被划分在一个理想的分区中 这个分区不允许混合 来自不同分区的索引 在排序时 很明显 访问的数据范围被限制在分区中 而不是像以前那样 跨越整个内存范围 这是线程分化 和内存分化之间的权衡和平衡 理想的分区数量和大小 高度依赖于工作负载 您可能需要进行实验 看看哪种方法效果最好 让我们以另一个 metal system trace 为例 看看工作负载是否有所改善 在这里 我们看到了优化版本的 限制条件和使用情况 最高性能限制器下降了 末级缓存限制器也下降了 因此 带宽和着色器运行时间 有了显著改善 让我们看看改进了多少 最高限制器和 LLC 限制器 减少了约 20% 这意味着数据流更加高效 GPU 读取带宽显著增加 允许更多数据推送到 GPU 核心
总的来说 在这个实验中 增加内存局部性 可以提高 10% 到 30% 的性能 具体是多少取决于实际情况 这只是许多 改进内存访问模式方法中的一个例子 请不断试验 优化最高性能限制器 GPU 工具有更多 有用的计数器帮助优化
Xcode 在编译器统计窗口中 提供了一个新的理论占用率 Xcode 和 Instruments 现在都有 几个 MMU 相关限制器和计数器 特别是新 MMU 限制器 MMU 占用率计数 和 MMU TLB 缺失率计数
我今天讲了很多内容 我讨论了 GPU 的可扩展性 以及放大时瓶颈如何转移 还有这些工具能如何帮助您 发现并解决与可缩放性相关的问题 我还讨论了您可能需要 如何进行试验并做出权衡 以为您的应用获得最佳结果 我期待看到所有开发者的 优秀的 App 能够 在 Apple 芯片上完美扩展 感谢收看
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。