-
Notifications
You must be signed in to change notification settings - Fork 4.9k
[Apple Silicon Support Phase 1] Add Metal FusedAdam kernel and CPU Adam build for Apple Silicon #8300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Apple Silicon Support Phase 1] Add Metal FusedAdam kernel and CPU Adam build for Apple Silicon #8300
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| // DeepSpeed Team | ||
|
|
||
| // Fused Adam / AdamW step for one tensor. Mirrors csrc/adam/multi_tensor_adam.cu: all math is done in | ||
| // fp32 regardless of the storage dtype, so fp16/bf16 parameters see the same numerics as on CUDA. | ||
|
|
||
| #include <metal_stdlib> | ||
| using namespace metal; | ||
|
|
||
| template <typename T> | ||
| kernel void fused_adam(device T* param [[buffer(0)]], | ||
| device const T* grad [[buffer(1)]], | ||
| device T* exp_avg [[buffer(2)]], | ||
| device T* exp_avg_sq [[buffer(3)]], | ||
| constant float& lr [[buffer(4)]], | ||
| constant float& beta1 [[buffer(5)]], | ||
| constant float& beta2 [[buffer(6)]], | ||
| constant float& epsilon [[buffer(7)]], | ||
| constant float& weight_decay [[buffer(8)]], | ||
| constant float& bias_correction1 [[buffer(9)]], | ||
| constant float& bias_correction2 [[buffer(10)]], | ||
| constant float& adam_w_mode [[buffer(11)]], | ||
| uint i [[thread_position_in_grid]]) | ||
| { | ||
| float g = float(grad[i]); | ||
| float p = float(param[i]); | ||
| float m = float(exp_avg[i]); | ||
| float v = float(exp_avg_sq[i]); | ||
|
|
||
| // L2 mode folds weight decay into the gradient; AdamW mode applies it to the parameter. | ||
| if (adam_w_mode == 0.0f) { g += weight_decay * p; } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why adam_w_mode is a float?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No good reason — at the time I hadn't verified that the shader binding marshals Python ints. It does: fixed in 34f7cb7, adam_w_mode is now constant uint& and the Python side passes int(adam_w_mode). |
||
| m = beta1 * m + (1.0f - beta1) * g; | ||
| v = beta2 * v + (1.0f - beta2) * g * g; | ||
| float denom = sqrt(v / bias_correction2) + epsilon; | ||
| float update = (m / bias_correction1) / denom; | ||
| if (adam_w_mode != 0.0f) { update += weight_decay * p; } | ||
|
|
||
| param[i] = T(p - lr * update); | ||
| exp_avg[i] = T(m); | ||
| exp_avg_sq[i] = T(v); | ||
| } | ||
|
|
||
| template [[host_name("fused_adam_float")]] kernel void fused_adam<float>( | ||
| device float*, device const float*, device float*, device float*, | ||
| constant float&, constant float&, constant float&, constant float&, constant float&, | ||
| constant float&, constant float&, constant float&, uint); | ||
| template [[host_name("fused_adam_half")]] kernel void fused_adam<half>( | ||
| device half*, device const half*, device half*, device half*, | ||
| constant float&, constant float&, constant float&, constant float&, constant float&, | ||
| constant float&, constant float&, constant float&, uint); | ||
| // bfloat is a Metal 3.1 type (macOS 14); older toolchains still get the float and half kernels. | ||
| #if __METAL_VERSION__ >= 310 | ||
| template [[host_name("fused_adam_bfloat")]] kernel void fused_adam<bfloat>( | ||
| device bfloat*, device const bfloat*, device bfloat*, device bfloat*, | ||
| constant float&, constant float&, constant float&, constant float&, constant float&, | ||
| constant float&, constant float&, constant float&, uint); | ||
|
PKUWZP marked this conversation as resolved.
Outdated
|
||
| #endif | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -306,7 +306,9 @@ Launch a single-process job as usual; no hostfile is needed: | |
| ``` | ||
| deepspeed --num_gpus 1 train.py --deepspeed --deepspeed_config ds_config.json | ||
| ``` | ||
| ZeRO stages 0 through 3 are supported with fp32, fp16, and bf16 (bf16 requires macOS 14 or newer). The fused Adam optimizer runs as a PyTorch implementation on MPS; ZeRO-Offload (`DeepSpeedCPUAdam`) is not yet available on this backend. | ||
| ZeRO stages 0 through 3 are supported with fp32, fp16, and bf16 (bf16 requires macOS 14 or newer), with or without ZeRO-Offload. | ||
|
|
||
| The fused Adam optimizer is a Metal kernel compiled at first use through `torch.mps.compile_shader`; no Xcode project or C++ build is involved. ZeRO-Offload uses the C++ `DeepSpeedCPUAdam` kernel, which is built just-in-time with the system clang. Apple's clang has no OpenMP, so the kernel is single-threaded unless Homebrew's `libomp` is installed (`brew install libomp`), in which case it is picked up automatically. Because Apple Silicon has unified memory, offloading to the CPU optimizer does not copy parameters between separate memories. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One question is for unified memory whether offloading to CPU optimizer is necessary.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair question — on unified memory offload is not about capacity at all (optimizer states occupy the same DRAM either way). What it still buys: Metal caps a process's GPU working set below total RAM (torch.mps.recommended_max_memory, ~75% here), and CPU-held optimizer state stays outside that budget; the step also runs on the CPU cores. So: unnecessary when the model fits the working-set budget, useful when that limit binds. Reworded the doc to say exactly this in 34f7cb7. |
||
|
|
||
| ## Limitations | ||
| * PyTorch exposes one MPS device per machine, so `device_count()` is 1 and multi-device data parallelism on a single Mac is not possible. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ | |
|
|
||
| # DeepSpeed Team | ||
|
|
||
| import os | ||
|
|
||
| try: | ||
| # is op_builder from deepspeed or a 3p version? this should only succeed if it's deepspeed | ||
| # if successful this also means we're doing a local install and not JIT compile path | ||
|
|
@@ -12,11 +14,7 @@ | |
|
|
||
|
|
||
| class MPSOpBuilder(OpBuilder): | ||
| """Base class for ops on Apple Silicon. | ||
|
|
||
| Ops here are currently pure PyTorch (torch.mps) implementations, so there is nothing to compile. | ||
| Metal kernels will plug into the same builder classes later. | ||
| """ | ||
| """Base class for ops on Apple Silicon that need no C++ compilation (pure torch.mps implementations).""" | ||
|
|
||
| def sources(self): | ||
| return [] | ||
|
|
@@ -27,3 +25,30 @@ def include_paths(self): | |
| def is_compatible(self, verbose=False): | ||
| import torch | ||
| return hasattr(torch.backends, "mps") and torch.backends.mps.is_available() | ||
|
|
||
|
|
||
| class MetalOpBuilder(MPSOpBuilder): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is that true that ops on Metal are not compiled and saved on disk cache? Is it temporary or its nature of Metal?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's the nature of the Metal workflow rather than a temporary gap: torch.mps.compile_shader compiles these small kernels in milliseconds at first load, and macOS's Metal framework maintains its own per-app on-disk cache of compiled pipelines, so a torch-extensions style build cache would add complexity without saving anything. Documented in the MetalOpBuilder docstring in 34f7cb7. |
||
| """Base class for ops implemented as Metal shaders. | ||
|
|
||
| Shaders are compiled at load time through torch.mps.compile_shader, which dispatches them on | ||
| PyTorch's own MPS command stream, so no Xcode toolchain or C++ extension build is needed. | ||
| Subclasses list their .metal files in metal_sources() and get back the compiled library. | ||
| """ | ||
| _libraries = {} | ||
|
|
||
| def metal_sources(self): | ||
| return [] | ||
|
|
||
| def is_compatible(self, verbose=False): | ||
| import torch | ||
| return super().is_compatible(verbose) and hasattr(torch.mps, "compile_shader") | ||
|
|
||
| def load_metal_library(self): | ||
| import torch | ||
| if self.name not in MetalOpBuilder._libraries: | ||
| source = "" | ||
| for path in self.metal_sources(): | ||
| with open(os.path.join(self.deepspeed_src_path(path))) as shader_file: | ||
| source += shader_file.read() + "\n" | ||
| MetalOpBuilder._libraries[self.name] = torch.mps.compile_shader(source) | ||
| return MetalOpBuilder._libraries[self.name] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| # DeepSpeed Team | ||
|
|
||
| import os | ||
| import subprocess | ||
|
|
||
| from .builder import MPSOpBuilder | ||
|
|
||
|
|
||
| class CPUAdamBuilder(MPSOpBuilder): | ||
| """Builds the C++ CPU Adam kernel on Apple Silicon for ZeRO-Offload. | ||
|
|
||
| Unified memory means the optimizer step can run on the CPU cores without copying parameters | ||
| off the GPU, so this is the offload path on Macs until a Metal Adam kernel exists. | ||
| """ | ||
| BUILD_VAR = "DS_BUILD_CPU_ADAM" | ||
| NAME = "cpu_adam" | ||
|
|
||
| def __init__(self): | ||
| super().__init__(name=self.NAME) | ||
|
|
||
| def absolute_name(self): | ||
| return f'deepspeed.ops.adam.{self.NAME}_op' | ||
|
|
||
| def sources(self): | ||
| return ['csrc/adam/cpu_adam.cpp', 'csrc/adam/cpu_adam_impl.cpp'] | ||
|
|
||
| def include_paths(self): | ||
| return ['csrc/includes'] | ||
|
|
||
| def builder(self): | ||
| from torch.utils.cpp_extension import CppExtension | ||
| include_dirs = [os.path.abspath(path) for path in self.strip_empty_entries(self.include_paths())] | ||
| return CppExtension(name=self.absolute_name(), | ||
| sources=self.strip_empty_entries(self.sources()), | ||
| include_dirs=include_dirs, | ||
| extra_compile_args={'cxx': self.strip_empty_entries(self.cxx_args())}, | ||
| extra_link_args=self.strip_empty_entries(self.extra_ldflags())) | ||
|
|
||
| def _libomp_prefix(self): | ||
| # Apple clang ships without OpenMP; Homebrew's libomp provides it when installed. | ||
| try: | ||
| return subprocess.check_output(['brew', '--prefix', 'libomp'], stderr=subprocess.DEVNULL).decode().strip() | ||
| except (OSError, subprocess.CalledProcessError): | ||
| return None | ||
|
|
||
| def cxx_args(self): | ||
| args = ['-O3', '-std=c++17', '-g', '-Wno-reorder', '-D__SCALAR__'] | ||
| libomp = self._libomp_prefix() | ||
| if libomp is not None: | ||
| args += ['-Xpreprocessor', '-fopenmp', f'-I{libomp}/include'] | ||
| return args | ||
|
|
||
| def extra_ldflags(self): | ||
| libomp = self._libomp_prefix() | ||
| if libomp is None: | ||
| return [] | ||
| return [f'-L{libomp}/lib', '-lomp'] |
Uh oh!
There was an error while loading. Please reload this page.