WWDC Quick Look 💓 By SwiftGGTeam
Accelerate machine learning with Metal

Accelerate machine learning with Metal

观看原视频

Highlight

Apple 为 PyTorch 1.12 加入 Metal Performance Shaders 后端,并扩展 TensorFlow Metal 与 MPS Graph,让 Mac 上的机器学习训练、定制算子和计算图同步都能使用 Apple GPU。

核心内容

机器学习训练最耗算力。模型要反复跑前向、反向和梯度更新,CPU 很快会成为瓶颈。GPU 适合并行工作,但开发者还需要框架把训练代码接到 GPU 上。

Metal 的机器学习入口是 Metal Performance Shaders(MPS)。MPS 提供高性能 GPU primitives,MPS Graph 在它之上提供通用计算图和多维张量支持。Core ML、TensorFlow、PyTorch 这类上层框架可以把算子落到 MPS Graph、MPS 和 Metal command queue 上。

这场 session 分三段讲清楚:PyTorch 现在有官方 MPS backend;TensorFlow Metal 支持更大的 batch、自定义 op 和分布式训练;MPS Graph 增加 shared events、RNN/LSTM/GRU、max pooling indices、Philox random 和张量变换操作。

PyTorch:三步切到 MPS device

03:32)PyTorch 社区最常请求的能力之一,是在 Apple silicon 上使用 GPU 加速。Apple 把 MPS backend 合入 PyTorch 官方 GitHub repo,并进入 PyTorch 1.12。它包含 PyTorch operation kernels 和 runtime framework:算子调用 MPS Graph/MPS,runtime 使用 Metal 的 command queue、command buffer 和同步 primitives。

开发者不需要换训练框架。安装 PyTorch,创建 MPS device,把模型和输入都移到这个 device 上,就能让后续操作在 GPU 上执行。

TensorFlow:让缺失算子留在 GPU timeline

08:01)TensorFlow Metal 从 TensorFlow 2.5 开始通过插件提供 GPU 加速。2022 年的更新包括更大的 batch size、新 GPU 加速操作、custom op、RNN 改进和分布式训练。

问题出现在自定义 loss 或 TensorFlow API 尚未支持的算子上。如果这段工作回到 CPU timeline,会引入同步开销,并让 GPU 等待。TensorFlow Metal Stream protocol 给自定义 op 暴露 command buffer、dispatch queue、commit 和 commitAndWait,让开发者把自定义 GPU kernel 编码到同一条 Metal 流里。

MPS Graph:多队列同步和更多训练算子

18:13)有些应用会把 compute、post processing、display 分到不同 command queue,以获得更多 GPU 并行度。这样做会带来数据竞争:后处理可能在 compute 产出结果前开始执行。MPS Graph 的 shared events API 用 signal/wait 建立跨 queue 依赖。

20:43)MPS Graph 还补上了一批训练和推理常用操作:RNN、LSTM、GRU;带 return indices 的 max pooling;Philox random;Hamming distance;expandDims、squeeze、split、stack、coordinateAlongAxis 等张量变换。

详细内容

PyTorch MPS backend 的最小路径

03:44)从 PyTorch 1.12 开始,基础包可以直接通过 pip 安装。

python -m pip install torch

关键点:

  • python -m pip 使用当前 Python 环境里的 pip,避免装到另一个解释器环境。
  • install torch 安装官方 PyTorch 包;MPS backend 是 PyTorch 1.12 的一部分。

03:59)创建 device 时,先检测 MPS 是否可用,再回退到 CPU。

import torch

mpsDevice = torch.device("mps" if torch.backends.mps.is_available() else "cpu")

关键点:

  • import torch 引入 PyTorch。
  • torch.backends.mps.is_available() 判断当前环境能否使用 MPS backend。
  • torch.device("mps" ...) 创建 MPS device;不可用时使用 cpu,同一段代码仍能运行。

04:15)模型默认在 CPU 上运行。要让中间 tensor 也走 MPS,需要把模型移动到 MPS device。

import torchvision

model = torchvision.models.resnet50()

model_mps = model.to(device=mpsDevice)

关键点:

  • torchvision.models.resnet50() 创建一个 ResNet50 模型。
  • model.to(device=mpsDevice) 把模型转到 MPS device。
  • model_mps 后续执行时,模型内部产生的 intermediate tensors 也会使用加速后的 MPS backend。

04:46)输入 tensor 也要放到同一个 device。默认 tensor 分配在 CPU 上,如果忘了指定 device,模型和输入会落在不同位置。

sample_input = torch.randn((32, 3, 254, 254), device=mpsDevice)

prediction = model_mps(sample_input)

关键点:

  • torch.randn(...) 创建一个 batch 的随机输入。
  • device=mpsDevice 让输入 tensor 直接分配在 MPS device 上。
  • model_mps(sample_input) 执行推理,后续 tensor 操作会在 GPU 上加速。

05:15)Apple 用 StyleTransfer 网络展示训练效果。在 M1 Ultra 上,PyTorch benchmark 最高有 20 倍加速,平均 8.3 倍。这个数字来自 session 中展示的 PyTorch benchmarks。

TensorFlow Metal custom op

09:21)自定义 TensorFlow op 要接入 Metal,核心是 TF_MetalStream protocol。它提供当前 command buffer、用于 CPU 侧同步的 dispatch queue,以及提交 GPU 工作的方法。

@protocol TF_MetalStream

- (id <MTLCommandBuffer>)currentCommandBuffer;
- (dispatch_queue_t)queue;
- (void)commit;
- (void)commitAndWait;

@end

关键点:

  • currentCommandBuffer 返回当前可编码 GPU kernel 的 Metal command buffer。
  • queue 返回 dispatch queue;多线程提交工作时,用它序列化编码过程。
  • commit 把当前 command buffer 提交给 GPU。
  • commitAndWait 会等待当前 command buffer 完成,适合调试提交顺序。

10:04)实现 custom op 分三步:注册 op,使用 Metal stream 实现 op,在训练脚本中导入动态库。

// Register the operation
REGISTER_OP("ZeroOut")
    .Input("to_zero: int32")
    .Output("zeroed: int32")
    .SetShapeFn([](::tensorflow::shape_inference::InferenceContext* c) {
      c->set_output(0, c->input(0));
      return Status::OK();
    });

关键点:

  • REGISTER_OP("ZeroOut") 向 TensorFlow 注册名为 ZeroOut 的操作。
  • .Input("to_zero: int32") 声明输入 tensor 名称和类型。
  • .Output("zeroed: int32") 声明输出 tensor 名称和类型。
  • SetShapeFn 说明输出 shape 与输入 shape 相同。

10:41)计算函数里先取输入和输出,再通过 TF_GetStream 取得 Metal stream,并在 stream 的 queue 中编码 GPU kernel。

// Define Compute function
void MetalZeroOut::Compute(TF_OpKernelContext *ctx) {
    // Get input and allocate outputs
    TF_Tensor* input = nullptr;
    TF_GetInput(ctx, 0, &input, status);
    TF_Tensor* output;
    OP_REQUIRES_OK(ctx, ctx->allocate_output(0, input.shape(), &output));

    // Use TF_MetalStream to encode the custom op
    id<TF_MetalStream> metalStream = (id<TF_MetalStream>)(TF_GetStream(ctx, status));
    dispatch_sync(metalStream.queue, ^() {
        id<MTLCommandBuffer> commandBuffer = metalStream.currentCommandBuffer;
        // Create encoder and encode GPU kernel
        [metalStream commit];
    });

    // Delete the TF_Tensors
    TF_DeleteTensor(input);
    TF_DeleteTensor(output);
}

关键点:

  • TF_GetInput 取得 TensorFlow 传入的输入 tensor。
  • allocate_output 为输出 tensor 分配空间。
  • TF_GetStream 从当前 op context 中取出 Metal stream。
  • dispatch_sync(metalStream.queue, ^{ ... }) 把编码过程放到 stream 提供的队列里,避免多线程提交互相踩踏。
  • metalStream.currentCommandBuffer 是后续创建 encoder 和编码 GPU kernel 的入口。
  • [metalStream commit] 提交这一段 GPU 工作。
  • TF_DeleteTensor 释放输入和输出 tensor 引用。

11:30)动态库构建完成后,可以在 Python 训练脚本中加载。

# Import operation in python script for training
import tensorflow as tf
zero_out_module = tf.load_op_library('./zero_out.so')
print(zero_out_module.zero_out([[1, 2], [3, 4]]).numpy())

关键点:

  • import tensorflow as tf 引入 TensorFlow。
  • tf.load_op_library('./zero_out.so') 加载 custom op 的共享动态库。
  • zero_out_module.zero_out(...) 像普通 Python 函数一样调用注册好的 op。
  • .numpy() 把结果转成 NumPy 表示,便于检查输出。

12:00)Apple 用 NeRF 示例解释 custom op 的价值。原始双层 MLP 版本每个 epoch 约 100 秒,训练 30 分钟后仍然模糊;加入自定义 hash table op 后,每个 epoch 约 10 秒,并能更快得到清晰模型。这个示例证明:TensorFlow 不原生支持的 hash table 也可以放到 Metal 插件里执行。

MPS Graph shared events

19:21)当 compute graph 和 post-process graph 运行在不同 command queue 上时,需要显式同步。shared event 的用法是:第一个 descriptor signal,第二个 descriptor wait。

// Using shared events
let executionDescriptor = MPSGraphExecutionDescriptor()
let event = MTLCreateSystemDefaultDevice()!.makeSharedEvent()!
executionDescriptor.signal(event, atExecutionEvent: .completed, value: 1)

let fetch = computeGraph.runAsync(with: commandQueue1,
                                  feeds: [input0Tensor: input0,
                                          input1Tensor: input1],
                                  targetTensors: [finalTensor],
                                  targetOperations: nil,
                                  executionDescriptor: executionDescriptor)

let executionDescriptor2 = MPSGraphExecutionDescriptor()
executionDescriptor2.wait(for: event, value: 1)

let fetch2 = postProcessGraph.runAsync(with: commandQueue2,
                                       feeds: [input0Tensor: fetch[finalTensor]!,
                                               input1Tensor: input1],
                                       targetTensors: [finalTensor],
                                       targetOperations: nil,
                                       executionDescriptor: executionDescriptor2)

关键点:

  • MPSGraphExecutionDescriptor() 创建运行图时使用的执行描述符。
  • makeSharedEvent() 在 Metal device 上创建 shared event。
  • signal(... .completed, value: 1) 要求 compute graph 完成时发出信号。
  • computeGraph.runAsync(...)commandQueue1 上异步运行 compute graph。
  • executionDescriptor2.wait(for: event, value: 1) 要求第二个 graph 等待同一个 event 达到指定值。
  • postProcessGraph.runAsync(...)commandQueue2 上运行后处理图,但依赖已经通过 event 约束。

MPS Graph 新操作:LSTM、pooling indices、random 和 tensor 操作

22:03)RNN、LSTM、GRU 是 recurrent neural network 常用层。过去可以手写复杂 subgraph,现在可以直接用 MPSGraphLSTMDescriptorgraph.LSTM(...)

let descriptor = MPSGraphLSTMDescriptor()

descriptor.inputGateActivation = .sigmoid
descriptor.forgetGateActivation = .sigmoid
descriptor.cellGateActivation = .tanh
descriptor.outputGateActivation = .sigmoid
descriptor.activation = .tanh
descriptor.bidirectional = false
descriptor.training = true

let lstm = graph.LSTM(inputTensor,
                      recurrentWeight: recurrentWeightsTensor,
                      inputWeight: weightsTensor,
                      bias: nil,
                      initState: nil,
                      initCell: nil,
                      descriptor: descriptor,
                      name: nil)

关键点:

  • MPSGraphLSTMDescriptor() 保存 LSTM 的配置。
  • 四个 gate activation 分别设置 input、forget、cell、output gate 的激活函数。
  • descriptor.activation 设置 cell 输出使用的激活函数。
  • bidirectional = false 表示单向 LSTM。
  • training = true 表示这个 LSTM 用于训练路径。
  • graph.LSTM(...) 把 LSTM 单元加入计算图,并传入 input、recurrent weight、input weight 和 descriptor。

23:35)Max pooling API 可以返回最大值所在位置的 indices。训练时反向传播要把梯度传回最大值位置,重用 indices 在 PyTorch 和 TensorFlow 中最高可快 6 倍。

// Forward pass
let descriptor = MPSGraphPooling4DOpDescriptor(kernelSizes: @[1,1,3,3],
                                               paddingStyle: .TF_SAME)
descriptor.returnIndicesMode = .globalFlatten4D

let [poolingTensor, indicesTensor] = graph.maxPooling4DReturnIndices(sourceTensor,
                                                                     descriptor: descriptor,
                                                                     name: nil)

// Backward pass
let outputShape = graph.shapeOf(destination, name: nil)
let gradientTensor = graph.maxPooling4DGradient(gradient: gradientTensor,
                                                indices: indicesTensor,
                                                outputShape: outputShape,
                                                descriptor: descriptor,
                                                name: nil)

关键点:

  • MPSGraphPooling4DOpDescriptor 描述 pooling window 和 padding。
  • returnIndicesMode = .globalFlatten4D 指定 indices 的返回模式。
  • maxPooling4DReturnIndices 同时返回 pooling 结果和 indices。
  • indicesTensor 可以缓存到训练 pipeline 的反向阶段。
  • maxPooling4DGradient 使用 indices 把梯度传回 forward pass 中取到最大值的位置。

24:42)新的 random operation 使用 Philox algorithm。给定相同 seed 时,它返回与 TensorFlow 一致的结果,可用于训练图的权重初始化。

// Declare Philox state tensor
let stateTensor = graph.randomPhiloxStateTensor(seed: 2022, name: nil)

// Declare RandomOp descriptor
let descriptor = MPSGraphRandomOpDescriptor(distribution: .truncatedNormal,
                                            dataType: .float32)
descriptor.mean = -1.0
 descriptor.standardDeviation = 2.5
descriptor.min = descriptor.mean - 2 * descriptor.standardDeviation
descriptor.max = descriptor.mean + 2 * descriptor.standardDeviation

let [randomTensor, stateTensor] = graph.randomTensor(shapeTensor: shapeTensor,
                                                     descriptor: descriptor,
                                                     stateTensor: stateTensor,
                                                     name: nil)

关键点:

  • randomPhiloxStateTensor(seed:) 创建带 seed 的 Philox state tensor。
  • MPSGraphRandomOpDescriptor 指定分布类型和数据类型。
  • .truncatedNormal 表示截断正态分布。
  • meanstandardDeviationminmax 定义分布范围。
  • randomTensor(...) 输出随机 tensor 和新的 state tensor;新的 state 可继续传给下一次 random operation。

26:18)MPS Graph 还加入了一组张量变换操作,用来扩维、压缩维度、切分、堆叠和生成坐标。

// Expand the input tensor dimensions, 4x2 -> 4x1x2
let expandedTensor = graph.expandDims(inputTensor,
                                      axis: 1,
                                      name: nil)

// Squeeze the input tensor dimensions, 4x1x2 -> 4x2
let squeezedTensor = graph.squeeze(expandedTensor,
                                   axis: 1,
                                   name: nil)

// Split the tensor in two, 4x2 -> (4x1, 4x1)
let [split1, split2] = graph.split(squeezedTensor,
                                   numSplits: 2,
                                   axis: 0,
                                   name: nil)

// Stack the tensor back together, (4x1, 4x1) -> 2x4x1
let stackedTensor = graph.stack([split1, split2],
                                axis: 0,
                                name: nil)

关键点:

  • expandDims(... axis: 1) 在第 1 维插入新维度。
  • squeeze(... axis: 1) 删除第 1 维上的单元素维度。
  • split(... numSplits: 2, axis: 0) 沿第 0 维把 tensor 平均切成两份。
  • stack([split1, split2], axis: 0) 沿第 0 维把多个 tensor 重新堆叠。

核心启发

  • 做什么:给 PyTorch 训练脚本加 MPS device 开关

    • 为什么值得做:session 展示 PyTorch 1.12 的 MPS backend 已进入官方包,模型和输入移到 MPS device 后,后续 tensor 操作会走 GPU。
    • 怎么开始:先用 torch.backends.mps.is_available() 创建 device,再把 model.to(device=mpsDevice) 和输入 tensor 的 device=mpsDevice 补齐。
  • 做什么:把 TensorFlow 训练中的自定义 loss 留在 GPU 上

    • 为什么值得做:自定义 loss 回到 CPU 会带来同步开销,并让 GPU timeline 出现空洞。
    • 怎么开始:用 REGISTER_OP 注册操作,在 Compute 中通过 TF_GetStream 取得 TF_MetalStream,再在 metalStream.queue 中编码 Metal kernel。
  • 做什么:用 shared events 拆开 ML 后处理 pipeline

    • 为什么值得做:compute 和 post processing 放在不同 command queue 可以利用更多 GPU 并行度,但必须处理数据依赖。
    • 怎么开始:在 compute graph 的 MPSGraphExecutionDescriptor 上 signal shared event,在 post-process graph 的 descriptor 上 wait 同一个 event。
  • 做什么:为训练图替换手写 LSTM subgraph

    • 为什么值得做:MPS Graph 新增 LSTM operation,session 明确指出它能高效编码 recurrent unit 所需的 GPU 工作。
    • 怎么开始:创建 MPSGraphLSTMDescriptor,设置 gate activation、training 等属性,再调用 graph.LSTM(...) 把单元加入图。
  • 做什么:在 pooling 反向传播中缓存 indices

    • 为什么值得做:max pooling return indices API 让反向阶段复用 forward 的最大值位置,session 给出的 PyTorch 和 TensorFlow 训练最高加速为 6 倍。
    • 怎么开始:forward 用 graph.maxPooling4DReturnIndices(...) 取出 indicesTensor,backward 用 graph.maxPooling4DGradient(...) 传入同一个 indices。

关联 Session

评论

GitHub Issues · utterances