Skip to content

Commit da31112

Browse files
[MISC] Add docs for external_metal_command_queue / shared Metal queue
New page metal_shared_queue.md with full setup guide: extracting PyTorch MPS MTLCommandQueue, init ordering, sync implications, ownership, and fallback. Cross-referenced from init_options.md and interop.md. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 922de4d commit da31112

6 files changed

Lines changed: 136 additions & 10 deletions

File tree

docs/source/user_guide/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ parallelization
3131
:titlesonly:
3232
3333
interop
34+
metal_shared_queue
3435
```
3536

3637
```{toctree}

docs/source/user_guide/init_options.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ Forces every adstack in the program to exactly `N` slots and bypasses the launch
6464

6565
Cutoff (in bytes) below which the gate-passing-count sizing path described in [Memory footprint](./autodiff.md#memory-footprint) is skipped in favour of the eager `dispatched_threads * stride` heap. Default `100 MiB`. The sparse path saves memory on kernels of the shape `for i in range(...): if field[i] cmp literal: <adstack work>` but pays a per-launch reducer dispatch; below the threshold that overhead outweighs the savings. Set to `0` to always use the sparse path; lower it if the default still skips kernels you want shrunk. No effect when `ad_stack_experimental_enabled=False` or when the kernel has no such gate.
6666

67+
## Apple Metal
68+
69+
### `external_metal_command_queue`
70+
71+
An `MTLCommandQueue*` pointer (as an integer) to use instead of creating a new Metal command queue. Default `0` (create a new queue). When non-zero, Quadrants dispatches all GPU work on the provided queue, which enables GPU-side ordering with other frameworks that share the same queue (most notably PyTorch MPS).
72+
73+
See [Shared Metal command queue](./metal_shared_queue.md) for the full setup guide, including how to extract the queue pointer from PyTorch and the synchronisation implications.
74+
6775
## Debugging
6876

6977
See [Debug mode](./debug.md) for runnable examples and a typical develop / benchmark workflow.

docs/source/user_guide/interop.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ my_kernel(f) # now safe
178178

179179
This is intentional: forcing a sync on every Quadrants kernel that touches a previously-zerocopied field would be very expensive in workloads that batch many torch ops and many kernels back-to-back. If you mutate fields from torch and then read them from a Quadrants kernel on Metal, call `torch.mps.synchronize()` once between the torch ops and the kernels.
180180

181+
**Shared command queue.** The synchronisation overhead above can be eliminated entirely by passing PyTorch MPS's `MTLCommandQueue` to Quadrants at init time via `external_metal_command_queue`. When both frameworks share the same queue, Metal guarantees command buffer ordering automatically. See [Shared Metal command queue](./metal_shared_queue.md) for the setup guide.
182+
181183
### Lifetime caveats
182184

183185
A zero-copy view becomes invalid when the underlying Quadrants storage is freed. This happens on `qd.reset()` and `qd.init()`. Holding a `copy=False` tensor across either is undefined behaviour:
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Shared Metal command queue (PyTorch MPS)
2+
3+
On Apple Silicon, Quadrants and PyTorch MPS both dispatch GPU work via Metal. By default each framework creates its own `MTLCommandQueue`, which means there is no GPU-level ordering between them. Every zero-copy interop point therefore requires explicit CPU-side synchronisation (`qd.sync()` and `torch.mps.synchronize()`) to guarantee data visibility.
4+
5+
The `external_metal_command_queue` option lets you pass PyTorch's command queue to Quadrants so that both frameworks share a single queue. Metal processes command buffers in commit order within a queue, so GPU-side ordering is automatic and the per-interop sync overhead is eliminated.
6+
7+
## Quick start
8+
9+
```python
10+
import quadrants as qd
11+
12+
queue_ptr = get_mps_command_queue() # see below
13+
qd.init(arch=qd.metal, external_metal_command_queue=queue_ptr)
14+
```
15+
16+
Once initialised this way:
17+
18+
- `to_torch(copy=False)` no longer calls `qd.sync()` internally.
19+
- `to_torch(copy=True)` no longer calls `torch.mps.synchronize()` after the copy.
20+
- GPU work submitted by Quadrants and by PyTorch executes in the order it was committed — no manual sync needed between the two.
21+
22+
You can still call `qd.sync()` when you need to read results back to the CPU (e.g. `to_numpy()`); what changes is that you no longer need *both* `qd.sync()` and `torch.mps.synchronize()` at every framework boundary.
23+
24+
## Extracting PyTorch's MTLCommandQueue
25+
26+
PyTorch does not expose its MPS command queue through a public Python API. The following helper extracts it at runtime using `ctypes` and the Objective-C runtime, with no build-time PyTorch dependency:
27+
28+
```python
29+
import ctypes
30+
import os
31+
import torch
32+
33+
34+
def get_mps_command_queue() -> int:
35+
"""Return PyTorch MPS's MTLCommandQueue* as a Python int."""
36+
# Ensure MPS is initialised
37+
torch.zeros(1, device="mps")
38+
39+
torch_lib = os.path.join(
40+
os.path.dirname(torch.__file__), "lib", "libtorch_cpu.dylib"
41+
)
42+
handle = ctypes.CDLL(torch_lib)._handle
43+
44+
libdl = ctypes.CDLL(None)
45+
dlsym = libdl.dlsym
46+
dlsym.restype = ctypes.c_void_p
47+
dlsym.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
48+
49+
# at::mps::getDefaultMPSStream() -> MPSStream*
50+
func_addr = dlsym(handle, b"_ZN2at3mps19getDefaultMPSStreamEv")
51+
assert func_addr, "Cannot find getDefaultMPSStream — check PyTorch version"
52+
stream_ptr = ctypes.CFUNCTYPE(ctypes.c_void_p)(func_addr)()
53+
54+
# MPSStream::commandBuffer() -> id<MTLCommandBuffer>
55+
cb_addr = dlsym(handle, b"_ZN2at3mps9MPSStream13commandBufferEv")
56+
assert cb_addr, "Cannot find MPSStream::commandBuffer"
57+
cb_ptr = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p)(cb_addr)(
58+
stream_ptr
59+
)
60+
61+
# [commandBuffer commandQueue] via ObjC runtime
62+
objc = ctypes.CDLL("/usr/lib/libobjc.A.dylib")
63+
sel_reg = objc.sel_registerName
64+
sel_reg.restype = ctypes.c_void_p
65+
sel_reg.argtypes = [ctypes.c_char_p]
66+
msg_send = objc.objc_msgSend
67+
msg_send.restype = ctypes.c_void_p
68+
msg_send.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
69+
70+
queue_ptr = msg_send(cb_ptr, sel_reg(b"commandQueue"))
71+
assert queue_ptr, "Failed to extract MTLCommandQueue"
72+
return queue_ptr
73+
```
74+
75+
The C++ symbol `_ZN2at3mps19getDefaultMPSStreamEv` has been stable since PyTorch 1.13.
76+
77+
## Init ordering
78+
79+
PyTorch MPS must be initialised **before** `qd.init()` so that the command queue exists when Quadrants starts:
80+
81+
```python
82+
import torch
83+
torch.zeros(1, device="mps") # trigger MPS init
84+
85+
import quadrants as qd
86+
queue_ptr = get_mps_command_queue()
87+
qd.init(arch=qd.metal, external_metal_command_queue=queue_ptr)
88+
```
89+
90+
## What changes with a shared queue
91+
92+
| Scenario | Separate queues (default) | Shared queue |
93+
|----------|--------------------------|--------------|
94+
| `f.to_torch(copy=False)` | `qd.sync()` called internally | no sync needed |
95+
| `f.to_torch(copy=True)` | `qd.sync()` + `torch.mps.synchronize()` | no sync needed |
96+
| Quadrants kernel after torch write | manual `torch.mps.synchronize()` required | automatic (same queue) |
97+
| `f.to_numpy()` | `qd.sync()` (always needed for CPU readback) | `qd.sync()` (still needed) |
98+
99+
## Lifetime and ownership
100+
101+
The caller (your application) owns the command queue. Quadrants retains it for the duration of the runtime and does **not** release it on `qd.reset()`. You must keep PyTorch (and its MPS backend) alive for as long as the Quadrants runtime is active.
102+
103+
## Fallback
104+
105+
If extracting the queue fails (e.g. on an older PyTorch version or a non-Apple system), fall back to the default separate-queue path:
106+
107+
```python
108+
try:
109+
queue_ptr = get_mps_command_queue()
110+
except (AssertionError, OSError):
111+
queue_ptr = 0 # 0 means "create a new queue" (the default)
112+
113+
qd.init(arch=qd.metal, external_metal_command_queue=queue_ptr)
114+
```

quadrants/rhi/metal/metal_device.mm

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,17 +1016,18 @@
10161016
MTLCommandBuffer_id MetalCommandList::finalize() { return cmdbuf_; }
10171017

10181018
MetalStream::MetalStream(const MetalDevice &device,
1019-
MTLCommandQueue_id mtl_command_queue,
1020-
bool owns_queue)
1021-
: device_(&device), mtl_command_queue_(mtl_command_queue), owns_queue_(owns_queue) {}
1019+
MTLCommandQueue_id mtl_command_queue, bool owns_queue)
1020+
: device_(&device), mtl_command_queue_(mtl_command_queue),
1021+
owns_queue_(owns_queue) {}
10221022
MetalStream::~MetalStream() { destroy(); }
10231023

10241024
MetalStream *MetalStream::create(const MetalDevice &device) {
10251025
MTLCommandQueue_id compute_queue = [device.mtl_device() newCommandQueue];
10261026
return new MetalStream(device, compute_queue, /*owns_queue=*/true);
10271027
}
1028-
MetalStream *MetalStream::create_with_external_queue(const MetalDevice &device,
1029-
MTLCommandQueue_id external_queue) {
1028+
MetalStream *
1029+
MetalStream::create_with_external_queue(const MetalDevice &device,
1030+
MTLCommandQueue_id external_queue) {
10301031
[external_queue retain];
10311032
return new MetalStream(device, external_queue, /*owns_queue=*/false);
10321033
}
@@ -1224,8 +1225,8 @@ DeviceCapabilityConfig collect_metal_device_caps(MTLDevice_id mtl_device) {
12241225
MTLCommandQueue_id external_command_queue)
12251226
: mtl_device_(mtl_device) {
12261227
if (external_command_queue != nil) {
1227-
compute_stream_ =
1228-
std::unique_ptr<MetalStream>(MetalStream::create_with_external_queue(*this, external_command_queue));
1228+
compute_stream_ = std::unique_ptr<MetalStream>(
1229+
MetalStream::create_with_external_queue(*this, external_command_queue));
12291230
} else {
12301231
compute_stream_ = std::unique_ptr<MetalStream>(MetalStream::create(*this));
12311232
}
@@ -1241,7 +1242,8 @@ DeviceCapabilityConfig collect_metal_device_caps(MTLDevice_id mtl_device) {
12411242
MTLDevice_id mtl_device = MTLCreateSystemDefaultDevice();
12421243
return new MetalDevice(mtl_device);
12431244
}
1244-
MetalDevice *MetalDevice::create_with_external_queue(uint64_t external_queue_ptr) {
1245+
MetalDevice *
1246+
MetalDevice::create_with_external_queue(uint64_t external_queue_ptr) {
12451247
MTLDevice_id mtl_device = MTLCreateSystemDefaultDevice();
12461248
auto *queue = reinterpret_cast<MTLCommandQueue_id>(external_queue_ptr);
12471249
return new MetalDevice(mtl_device, queue);

tests/python/test_metal_shared_queue.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""Tests for the external Metal command queue feature (``external_metal_command_queue``)."""
22

3-
import platform
4-
53
import numpy as np
64
import pytest
75

86
import quadrants as qd
7+
98
from tests import test_utils
109

1110

0 commit comments

Comments
 (0)