·
Last changedVersion:
This version passed the hardening gate. Hardening applied 1 targeted change and preserved all other text. Classified findings before hardening: 3. Classified findings closed: 1. Classified findings remaining: 2. Unclassified scanner findings remaining: 0. Review findings closed: 0. Review findings remaining: 4.
1
1
1
1
1
Expand | File | Group | Subtype | Severity | Status |
|---|---|---|---|---|---|
SKILL.md | Memory & Context Poisoning | — | High | Closed | |
SKILL.md | Excessive Agency & Rogue Autonomy | — | Medium | Open | |
LICENSE | Excessive Agency & Rogue Autonomy | — | Low | Open |
3 findings
This skill guides you through implementing Metal kernels for PyTorch operators on Apple Silicon.
Important: The goal of this skill is to use native Metal capabilities via the c10/metal/ infrastructure, NOT MPSGraph. Native Metal kernels provide better control, performance, and maintainability.
There are two workflows covered by this skill:
Both workflows involve:
aten/src/ATen/native/native_functions.yamlaten/src/ATen/native/mps/kernels/aten/src/ATen/native/mps/operations/Location: aten/src/ATen/native/native_functions.yaml
Find the operator entry and add MPS dispatch:
When migrating an existing operator from MPSGraph to native Metal, consolidate the dispatch entry:
Key change: Replace MPS: my_op_out_mps with adding MPS to the shared dispatch line (e.g., CPU, CUDA, MPS: my_op_out).
Update every overload. A single op typically has several
native_functions.yaml entries — functional / inplace / .out, plus
Tensor and Scalar variants. Each entry has its own dispatch: block, and
each one must be moved over. Any entry left pointing at MPS: my_op_mps
still routes that overload to the MPSGraph code, so callers can silently
land on the old path depending on which overload they hit. Before declaring
the migration done, grep the legacy function name and confirm no entry still
references it.
Dispatch naming conventions:
MPS: function_name_mps - MPS-specific implementation (old MPSGraph pattern)CPU, CUDA, MPS: function_name - Shared stub implementation (native Metal pattern)Location: aten/src/ATen/native/mps/kernels/
For binary operations, use the convenience macros defined in BinaryKernel.metal:
Common patterns:
REGISTER_FLOAT_BINARY_OP and REGISTER_INT2FLOAT_BINARY_OPREGISTER_FLOAT_BINARY_OP and REGISTER_INTEGER_BINARY_OPREGISTER_FLOAT_BINARY_OP and REGISTER_INTEGER_BINARY_OPExample for atan2 (supports both float and int inputs):
Note on complex types: Complex numbers in Metal are represented as vector types:
c10::complex<float> maps to float2 (x = real, y = imaginary)c10::complex<half> maps to half2Use is_complex_v<T> to specialize for complex types in functors.
utils.h:
opmath_t<T> - Operation math type (half->float)accum_t<T> - Accumulation type for reductionsmax(), min() with NaN propagationspecial_math.h:
precise::exp(), precise::log(), precise::sqrt()precise::sin(), precise::cos(), precise::tan()erf(), erfc(), erfinv()indexing.h:
REGISTER_UNARY_OP(name, in_type, out_type)REGISTER_BINARY_OP(name, in_type, out_type)REGISTER_UNARY_ALPHA_OP(name, in_type, alpha_type, out_type)Location: aten/src/ATen/native/mps/operations/
Choose or create an appropriate file based on operation type:
UnaryKernel.mm - Single input operations via stub dispatchBinaryKernel.mm - Two input operations via stub dispatchUnaryOps.mm / BinaryOps.mm - Legacy MPSGraph implementations (for reference)ReduceOps.mm - Reductions (sum, mean, max, etc.)For structured kernels that use the TensorIterator pattern:
For unary operations:
When migrating from MPSGraph, also remove the old implementation:
Remove from BinaryOps.mm (or UnaryOps.mm):
TORCH_IMPL_FUNC(my_op_out_mps) implementation#include <ATen/ops/my_op_native.h> headerAdd to BinaryKernel.mm (or UnaryKernel.mm):
REGISTER_DISPATCH callType booleans as bool. Metal's bool is one byte, one-byte aligned,
matching the host (bool2 is 2/2; bool3 and bool4 are both 4/4). Never
widen a flag to uint32_t so the kernel can test != 0u. A pair of flags
packs into constant bool2&, fed by std::array<bool, 2>.
Put the bool members last in a shared params struct. kernels/<Op>.h is
compiled by both the Metal and the host compiler; a bool wedged between
wider members costs padding and invites doubt about where the next field
starts. Reorder the host aggregate initializer along with the struct --
brace initialization is positional.
After making changes, compile to verify everything builds correctly:
Basic operator support is already tested by test_output_match in test/test_mps.py. After implementing an operator, enable testing by removing expected failures:
Location: torch/testing/_internal/common_mps.py
Find and remove the operator from skip/xfail lists:
Location: torch/testing/_internal/common_methods_invocations.py (or related files)
Remove MPS-specific decorators from the OpInfo:
torch.mps.compile_shaderUse torch.mps.compile_shader to JIT-compile and test individual Metal kernels in isolation. This is invaluable for debugging multi-kernel pipelines where you need to verify each stage independently.
compile_shader uses dispatchThreads semantics (same as mtl_dispatch1DJob in PyTorch):
threads=[N, 1, 1] — total number of threads (NOT threadgroups)group_size=[G, 1, 1] — threads per threadgroupThis differs from the dispatchThreadgroups API used by some host-side code. To match dispatchThreadgroups:MTLSizeMake(num_tgs, num_slices, 1) threadsPerThreadgroup:MTLSizeMake(TG_SIZE, 1, 1):
Pass scalar constants as single-element tensors:
When a pipeline of kernels (e.g., histogram → prefix_sum → scatter) produces wrong results, test each kernel individually and verify its output against a Python/NumPy reference:
This isolates which kernel in the pipeline is broken, rather than debugging the entire pipeline at once.
threads count — threads is total threads, not threadgroups. For 5 threadgroups of 256, use threads=[1280, 1, 1].compile_shader doesn't support [[threadgroup(N)]] parameters directly. If your kernel needs threadgroup memory, restructure to use threadgroup arrays declared inside the kernel body instead.REGISTER_UNARY_OP / REGISTER_BINARY_OP hide the iterator plumbing. Kernels
with extra params or non-elementwise layouts have to drive TensorIterator
directly. The non-obvious rules:
Pass TensorIteratorBase&, not Tensor&. Tensor& loses the
offset/shape info with_32bit_indexing() produces. When the stub hands you
a const TensorBase& (e.g. bernoulli_scalar_stub), build the iter inline
with at::TensorIterator::borrowing_nullary_op(self) rather than
const-casting.
iter.tensor(0) returns the whole tensor for sub-iters. After
with_32bit_indexing(), sub-iters still reference the original storage, so
binding iter.tensor(0) naively makes every sub-iter overwrite the same
prefix and leaves the tail uninitialized. Use bind_iter_tensors, which
computes the per-chunk offset and binds buffer 0 to the slice. Dispatch
with iter.numel(), never iter.tensor(0).numel():
Prefer mtl_setArgs<N> over chained mtl_setBytes, and pack same-typed
scalars into one vector argument. mtl_setArgs<1>(encoder, a, b, c) binds
a/b/c at slots 1/2/3 with the same overload resolution as the macros, so
a std::array<T, N> arrives as a single constant T2&/T3& binding
(std::array<long,2> → constant long2&) rather than N of them. Two ints are
an int2, not two buffer slots.
Use ceil_div. Host: at::ceil_div from <ATen/ceil_div.h>. Metal:
c10::metal::ceil_div from <c10/metal/common.h> (unqualified after
using namespace c10::metal;). Both wrap (a + b - 1) / b.
IF_CONSTEXPR for Metal 3/4 portability. Metal 4 has if constexpr;
Metal 3 doesn't. Use the macro from <c10/metal/common.h>, e.g.
if IF_CONSTEXPR (sizeof(T) == 8) { ... }.
Share the CPU/CUDA stub via REGISTER_MPS_DISPATCH. When the op has a
DECLARE_DISPATCH stub upstream (distributions, fused ops), wire MPS in
with REGISTER_MPS_DISPATCH(stub_name, &fn) instead of an MPS-specific
native_functions.yaml entry. The stub already takes TensorIteratorBase&.
Most Metal kernels take numel as uint32_t and index in 32 bits, so anything
past INT32_MAX needs host-side splitting. Drive this through TensorIterator
rather than slicing manually.
Decompose via iter.with_32bit_indexing():
Every yielded sub-iter satisfies can_use_32bit_indexing(), so recursion
terminates after one level. The threshold is INT32_MAX (TensorIterator uses
signed 32-bit offsets), not UINT32_MAX — a test just past INT32_MAX
exercises the split but not the uint32_t cast; the cast itself only matters
for numel > 2^32.
Use a checked cast when narrowing:
Use c10::checked_convert (<c10/util/TypeCast.h>) so wraparound becomes a
TORCH_CHECK failure instead of a corrupt output.
NEVER copy results to CPU for the purposes of error checking. Any
.item(), .cpu(), or other host read on a GPU tensor forces a full
GPU->CPU sync that drains every in-flight op on the stream — not just the
reduction you're inspecting. In a realistic pipeline this stalls the
whole queue, and the cost dwarfs the actual check. Guarding the sync
behind is_mps() doesn't help; the stall happens every time the op runs.
Validate on-device instead and report errors through the mechanism below.
GPU code can't throw, but kernels can write into a shared error buffer that the
host raises as c10::AcceleratorError on the next sync. Use
TORCH_REPORT_ERROR(error_buf, ...) from <c10/metal/error.h>. Variadic
arguments are concatenated; integers are formatted in base 10. Delivery is
asynchronous — the failing thread keeps running, and the error surfaces
whenever MPSStream::checkLastError() next runs (after a synchronize() or
the next op that drains the stream), so don't rely on it for control flow
inside the same dispatch. Crucially, this adds no forced sync: the error
piggybacks on whatever sync the user's code already does.
Kernel side: take a device ErrorMessages* error_buf argument and call
TORCH_REPORT_ERROR on the bad path. Skip the offending element afterwards
:
Host side: bind the stream's error buffer as the corresponding argument.
mtl_setArgs accepts it directly:
The buffer is owned by MPSStream, sized for 30 messages, and reset after
each checkLastError() drain. Only the first message is reported; later ones
are kept for debugging but the AcceleratorError carries msg[0].
native_functions.yamlkernels/operations/bool and placed last in shared structstorch/testing/_internal/common_mps.pyGet started with chainctlchainctl skill install public/pytorch/metal-kernel:latest
Bring Chainguard's hardening pipeline to your first-party skills — custom rulesets, automated remediation, full audit trail.
skills.cgr.dev/public/github.com/pytorch/.claude/skills/metal-kernel:latest
BSD-3-Clause
HARDENING.md
LICENSE
SKILL.md