Skip to main content

Python bindings for Apple Metal GPU compute

Project description

mtlpy

Python bindings for GPU compute on Apple Metal, built on pybind11 and Apple's metal-cpp. Write a Metal compute kernel as a string, dispatch it over NumPy arrays, get the result back as a NumPy array — no separate build step, no manual buffer plumbing.

import numpy as np
import mtlpy

device = mtlpy.Device()
a = device.buffer(np.array([1.0, 2.0, 3.0], dtype=np.float32))
b = device.buffer(np.array([10.0, 20.0, 30.0], dtype=np.float32))

print((a + b).contents)  # [11. 22. 33.]

Why this exists

mtlpy is a from-scratch rewrite of themetalgpu project. Rather than build on top of that codebase's ctypes-based bindings and global singleton state, mtlpy starts over with a few deliberate improvements:

  • pybind11 instead of ctypes — real type safety across the Python/C++ boundary, and Metal errors propagate as Python exceptions instead of silent failures.
  • No global singleton state — each Device owns its own command queue and pipeline cache; nothing is saved/restored behind your back.
  • Pipeline compile caching — a compute pipeline is compiled once per (shader source, function name) and reused, both within a process and (via an on-disk Metal binary archive) across process launches.
  • Async dispatchPipeline.run(..., wait=False) lets you batch work without stalling on every call.

Status

Alpha, but built, tested, and benchmarked on real Apple Silicon hardware — see Building from source and the test suite for current coverage.

Architecture

metal-cpp/          Apple's C++ Metal headers (git submodule)
csrc/                C++ extension (pybind11 + metal-cpp)
  device.{h,cpp}       MTL::Device + MTL::CommandQueue owner
  buffer.{h,cpp}       MTL::Buffer wrapper (shared-storage, CPU/GPU unified memory)
  pipeline.{h,cpp}     Dispatches a compiled MTL::ComputePipelineState
  pipeline_cache.{h,cpp}  Compiles-once cache, keyed on (source, function name),
                          backed by an on-disk MTL::BinaryArchive
  metal_impl.mm        Single Obj-C++ translation unit providing the
                        NS::/CA::/MTL:: private implementations
  bindings.cpp         pybind11 module definition (`_mtlpy`)
src/mtlpy/          Python package (src layout, for PyPI)
  device.py            Device: buffer/empty/compile, list_devices(), wraps _mtlpy.Device
  buffer.py             Buffer: NumPy-backed contents, arithmetic/comparison/in-place operators
  pipeline.py           Pipeline: thin wrapper over _mtlpy.Pipeline
  operators.py          sqrt/cos/sin/tan/exp/log, sum/max/min/mean reductions
  shader.py             Generates Metal Shading Language source per dtype
  utils.py              NumPy dtype <-> Metal type name mapping
tests/               pytest suite
benchmarks/          Standalone performance baseline scripts
examples/            Runnable usage examples

Each Device in Python owns exactly one MTL::Device, one MTL::CommandQueue, and one PipelineCache. Buffers use MTL::ResourceStorageModeShared, so on Apple Silicon's unified memory there's no copy between CPU and GPU views of the same allocation — Buffer.contents is a NumPy array backed directly by GPU-visible memory (accessing .contents is a true zero-copy view; writing new data into it via buf.contents[:] = arr is still a real memcpy from arr's own memory, same as it would be for any destination).

Features

  • Elementwise operators: +, -, *, /, unary -, and in-place +=/-=/*=//= (which dispatch in-place, into the same Buffer, with no extra allocation) on Buffer — each also works with a NumPy/Python scalar on either side (buf + 5.0, 5.0 - buf), not just Buffer op Buffer. Plus sqrt, cos, sin, tan, exp, log, and astype for dtype conversion.
  • Comparisons: ==, !=, <, <=, >, >= (against another Buffer or a scalar) return a bool Buffer, matching NumPy's ndarray convention — which also makes Buffer unhashable, same tradeoff NumPy makes.
  • Reductions: operators.sum/max/min/mean — an O(log n) multi-pass tree reduction returning a plain Python scalar.
  • Custom kernels: compile and dispatch arbitrary Metal Shading Language source directly (see Custom kernels below). Pipeline.run validates the buffer count against the kernel's own argument reflection, so passing too few buffers raises a clear Python exception instead of leaving a Metal buffer argument unbound (undefined behavior).
  • Dtype support: float32, float16, int32, uint32, int16, uint16, int64, uint64, bool — mapped to their Metal equivalents (float, half, int, uint, short, ushort, long, ulong, bool) in src/mtlpy/utils.py. float64 has no Metal equivalent (no Apple GPU supports double precision), so it's silently downcast to float32 at buffer creation. Note that Buffer / Buffer uses Metal's native / for the shared dtype (truncating for integers), not NumPy's always-promote-to-float64 semantics.
  • Pipeline caching: identical (source, function name) pairs are compiled once per process and reused; a binary archive on disk (~/Library/Caches/mtlpy/pipelines.metallib) carries compiled pipelines across process launches too. Device.flush_cache() (or using Device as a context manager: with mtlpy.Device() as d:) serializes it on demand, rather than only when the Device is garbage collected.
  • Async dispatch: wait=False commits work without blocking; Metal retires command buffers on a queue in commit order, so a later wait=True dispatch that reads the result is enough to synchronize (see examples/async_dispatch.py). Pipeline.run releases the GIL for the whole call, so other Python threads keep running during the GPU wait instead of being blocked for its full duration.
  • Multi-GPU support: mtlpy.list_devices() lists every Metal-capable GPU on the machine; mtlpy.Device(index=...) selects one (the default targets the system default GPU).
  • Errors as exceptions: shader compile failures, missing kernel functions, mismatched buffer counts, mismatched-Device operands, and GPU execution errors all raise Python exceptions with a clear message, instead of failing silently or invoking undefined behavior.

Building from source

Requires macOS with Metal support, Xcode (for the Metal/Objective-C++ toolchain), CMake, and Python 3.9+.

git clone --recursive git@github.com:peyton-howe/mtlpy.git
cd mtlpy
pip install -e ".[dev]"

If you already cloned without --recursive:

git submodule update --init

Quick start

import numpy as np
import mtlpy

device = mtlpy.Device()

a = device.buffer(np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32))
b = device.buffer(np.array([10.0, 20.0, 30.0, 40.0], dtype=np.float32))

c = a + b
print(c.contents)          # numpy.ndarray([11. 22. 33. 44.])

d = mtlpy.operators.sqrt(a)
print(d.contents)

e = a.astype(np.int32)
print(e.dtype, e.contents)

Custom kernels

Device.compile(source, function_name) compiles arbitrary Metal Shading Language and returns a Pipeline you can dispatch directly:

source = """
#include <metal_stdlib>
using namespace metal;
kernel void square(
    device const float *a [[buffer(0)]],
    device       float *b [[buffer(1)]],
    uint id [[thread_position_in_grid]])
{
    b[id] = a[id] * a[id];
}
"""
pipeline = device.compile(source, "square")

a = device.buffer(np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32))
b = device.empty(4, np.float32)
pipeline.run([a, b], grid=4)

print(b.contents)  # [1. 4. 9. 16.]

grid may be an int (1D dispatch) or a 3-tuple/list for 2D/3D dispatch. Threadgroup sizing is computed automatically from the pipeline's thread_execution_width and max_threads_per_threadgroup.

Reusing buffers in a hot loop

Buffer.contents is a live NumPy view over the same underlying Metal allocation, not a copy — writing buf.contents[:] = ... updates GPU-visible memory in place, and reading it back after a wait=True dispatch needs no reallocation either. For a kernel dispatched repeatedly (e.g. in a while loop), compile the pipeline and allocate buffers once, then just write/read .contents each iteration:

pipeline = device.compile(source, "square")

a   = device.buffer(np.zeros(4, dtype=np.float32))  # allocated once
out = device.empty(4, np.float32)                    # allocated once

while running:
    a.contents[:] = get_next_input()   # in-place write, no realloc
    pipeline.run([a, out], grid=4)     # wait=True by default
    consume(out.contents)              # in-place read, no realloc

The out-of-place convenience operators (a + b, operators.sqrt(a), astype, etc.) don't follow this pattern — each call allocates a fresh output Buffer internally, which is fine for one-off use but wasteful in a tight loop. The in-place operators (a += b, a *= 2.0, ...) do reuse a's own buffer with no extra allocation, if that fits your loop. See examples/reuse_buffers.py.

Testing

pytest tests/
  • test_basic.py / test_operators.py — correctness for every operator (arithmetic, scalar broadcasting, comparisons, in-place, reductions), dtype, and astype conversion, plus error handling for mismatched buffer sizes/dtypes/devices and wrong kernel argument counts.
  • test_async.pywait=False dispatch ordering.
  • test_buffer_reuse.py — in-place .contents writes and repeated dispatch against the same buffers, without reallocation.
  • test_stability.py — repeated-dispatch and object-lifetime stress tests (regression coverage for the Metal object-ownership rules in csrc/), plus multi-threaded dispatch/compilation tests (Pipeline.run releases the GIL, so this exercises genuinely concurrent Metal calls).
  • test_pipeline_persistence.py — spawns separate processes to verify the on-disk pipeline binary archive is actually written and read back, and that Device.flush_cache() writes it on demand.

Benchmarking

python benchmarks/bench.py

Measures first-dispatch (compile-included) and steady-state warm-dispatch latency/throughput for every operator across a range of buffer sizes, with NumPy CPU timings alongside for context. Each run is saved as JSON (timestamped, tagged with the git commit) under benchmarks/results/ so you can baseline future changes:

python benchmarks/bench.py --baseline benchmarks/results/<earlier-run>.json

benchmarks/demosaic_bench.py is a separate, more involved benchmark comparing the edge-aware Bayer demosaicing kernel (benchmarks/bayer2rgb_ea_kernel.txt) against OpenCV's own COLOR_Bayer*2BGR_EA (requires pip install -e ".[bench]"), covering both single-shot dispatch latency and realistic streaming throughput (a rotating buffer pool pipelining dispatches instead of waiting on every frame).

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mtlpy-0.1.0.tar.gz (196.5 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

mtlpy-0.1.0-cp314-cp314-macosx_14_0_arm64.whl (188.0 kB view details)

Uploaded CPython 3.14macOS 14.0+ ARM64

mtlpy-0.1.0-cp313-cp313-macosx_14_0_arm64.whl (187.8 kB view details)

Uploaded CPython 3.13macOS 14.0+ ARM64

mtlpy-0.1.0-cp312-cp312-macosx_14_0_arm64.whl (187.7 kB view details)

Uploaded CPython 3.12macOS 14.0+ ARM64

mtlpy-0.1.0-cp311-cp311-macosx_14_0_arm64.whl (186.7 kB view details)

Uploaded CPython 3.11macOS 14.0+ ARM64

mtlpy-0.1.0-cp310-cp310-macosx_14_0_arm64.whl (185.6 kB view details)

Uploaded CPython 3.10macOS 14.0+ ARM64

File details

Details for the file mtlpy-0.1.0.tar.gz.

File metadata

  • Download URL: mtlpy-0.1.0.tar.gz
  • Upload date:
  • Size: 196.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for mtlpy-0.1.0.tar.gz
Algorithm Hash digest
SHA256 c4568ca200ac662b2fdcea65d9ec70166b40176d9430e557acde8d98c3c2cf6a
MD5 684e22f09711840ec98ac8974551fc6e
BLAKE2b-256 0c3c0246339160899500f8db485bd8e41b4c7bc77ec6c6db1644a0e503e0e006

See more details on using hashes here.

Provenance

The following attestation bundles were made for mtlpy-0.1.0.tar.gz:

Publisher: release.yml on peyton-howe/mtlpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mtlpy-0.1.0-cp314-cp314-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for mtlpy-0.1.0-cp314-cp314-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 7a080a4499c41ea2461fd3d5bb50e88d6aa5338f00f257c49ee5a815590e81c0
MD5 4530f2c8f5e9d58d3d25d08d9b32678e
BLAKE2b-256 82b431fa3468a4d2ec92687dd3c97c72f5dfc9e900be72cf7282a15a7431e9cc

See more details on using hashes here.

Provenance

The following attestation bundles were made for mtlpy-0.1.0-cp314-cp314-macosx_14_0_arm64.whl:

Publisher: release.yml on peyton-howe/mtlpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mtlpy-0.1.0-cp313-cp313-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for mtlpy-0.1.0-cp313-cp313-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 c10ca0470445b98043ccc7219291ad51a5a4d49979bd6f0ada6edcfb683a3442
MD5 9efaddf4ba36ab5d0bf52c024f6eff38
BLAKE2b-256 3261b45b2619058b48eb932616a835e29b75d99a79a4f210c31ea9e670094d46

See more details on using hashes here.

Provenance

The following attestation bundles were made for mtlpy-0.1.0-cp313-cp313-macosx_14_0_arm64.whl:

Publisher: release.yml on peyton-howe/mtlpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mtlpy-0.1.0-cp312-cp312-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for mtlpy-0.1.0-cp312-cp312-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 d1247e37517072fd36747af386baef60407f6b5ba008b6b261548217295c5102
MD5 733083398f287aff0ffb875821315cae
BLAKE2b-256 4f1456a6d49e1f56541c65a5367d9e768383fef4c4f1274c9e5d18546a136e6d

See more details on using hashes here.

Provenance

The following attestation bundles were made for mtlpy-0.1.0-cp312-cp312-macosx_14_0_arm64.whl:

Publisher: release.yml on peyton-howe/mtlpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mtlpy-0.1.0-cp311-cp311-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for mtlpy-0.1.0-cp311-cp311-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 a690f273b7e0b61f60a8c1e6344170365fdead2203685b502b9247b622727ea1
MD5 59c63d7ae4889071989d4cd0009d50ba
BLAKE2b-256 46715893b346dda4e9926ff2794203842b34d52687ab2dbda11e93e996d60127

See more details on using hashes here.

Provenance

The following attestation bundles were made for mtlpy-0.1.0-cp311-cp311-macosx_14_0_arm64.whl:

Publisher: release.yml on peyton-howe/mtlpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file mtlpy-0.1.0-cp310-cp310-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for mtlpy-0.1.0-cp310-cp310-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 d5fd1dad5f16bd9683540317831b72823d46c5d6c1aa5180bbaaabcf8b9efb16
MD5 dd8d7624eec59148a12c2908466fc5ab
BLAKE2b-256 6eaac025b2ef5abd230fda5f5d1a09e5bcbe97f99a53d4b0c53733f19eda97bf

See more details on using hashes here.

Provenance

The following attestation bundles were made for mtlpy-0.1.0-cp310-cp310-macosx_14_0_arm64.whl:

Publisher: release.yml on peyton-howe/mtlpy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page