Skip to content

Commit b5fb5db

Browse files
[Misc] Forward copy= through qd.Tensor, add copy=None option (#616)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1c284f1 commit b5fb5db

8 files changed

Lines changed: 354 additions & 69 deletions

File tree

docs/source/user_guide/interop.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,13 @@ Quadrants' zero-copy interop has been designed with **PyTorch as the first-class
7979
```python
8080
f = qd.field(qd.f32, shape=(1024,))
8181

82-
view = f.to_torch(copy=False) # zero-copy view: aliases f's memory
82+
view = f.to_torch(copy=False) # zero-copy view: aliases f's memory, or ValueError
83+
auto = f.to_torch(copy=None) # zero-copy if possible, otherwise copy
8384
clone = f.to_torch(copy=True) # independent copy (default)
84-
auto = f.to_torch() # same as copy=True
85+
plain = f.to_torch() # same as copy=True
8586
```
8687

87-
When using `copy=False`, modifications via the view are visible to subsequent Quadrants kernel reads, and vice-versa. The view stays valid until the underlying storage is reallocated -- typically on `qd.init()` or `qd.reset()`, after which a fresh call to `to_torch(copy=False)` / `to_numpy(copy=False)` returns a new view.
88+
When using `copy=False` or `copy=None` (when zero-copy succeeds), modifications via the view are visible to subsequent Quadrants kernel reads, and vice-versa. The view stays valid until the underlying storage is reallocated -- typically on `qd.init()` or `qd.reset()`, after which a fresh call to `to_torch(copy=False)` / `to_numpy(copy=False)` returns a new view.
8889

8990
### When zero-copy is available
9091

@@ -105,9 +106,10 @@ On **NumPy >= 2.1**, `to_numpy(copy=False)` returns a **writable** array (via a
105106
| Value | Behaviour |
106107
|---|---|
107108
| `True` (default) | Independent copy via kernel. |
109+
| `None` | Zero-copy view via DLPack when available, otherwise falls back to a copy silently. |
108110
| `False` | Zero-copy view via DLPack, or `ValueError` if zero-copy is unsupported for this backend/dtype. |
109111

110-
The default `copy=True` always returns a buffer that is safe to mutate without affecting the field/ndarray.
112+
The default `copy=True` always returns a buffer that is safe to mutate without affecting the field/ndarray. Use `copy=None` when you want zero-copy as a best-effort optimisation without having to handle exceptions — it gives you a view when possible and a safe copy otherwise.
111113

112114
### Examples
113115

@@ -304,8 +306,9 @@ print(x.grad[0]) # 4.0
304306
|--------|-------------|-------------------|---------------------|
305307
| `to_numpy()` / `from_numpy()` (default) | yes | yes | yes |
306308
| `to_torch()` / `from_torch()` (default) | yes | yes | yes |
309+
| `to_numpy(copy=None)` / `to_torch(copy=None)` | no when possible, yes otherwise | yes | yes |
307310
| `to_numpy(copy=False)` / `to_torch(copy=False)` | no (DLPack view) | yes | yes |
308311
| `to_dlpack()` | no (raw capsule) | yes | yes |
309312
| Direct pass-through | no | no | yes (as kernel arg) |
310313

311-
The `copy` parameter is supported on `to_numpy()` and `to_torch()` for `ScalarField`, `MatrixField` (and `VectorField`), `StructField`, and all `Ndarray` types. See [Zero-copy interop via DLPack](#zero-copy-interop-via-dlpack) for the support matrix and lifetime rules.
314+
The `copy` parameter is supported on `to_numpy()` and `to_torch()` for `ScalarField`, `MatrixField` (and `VectorField`), `StructField`, `qd.Tensor`, and all `Ndarray` types. See [Zero-copy interop via DLPack](#zero-copy-interop-via-dlpack) for the support matrix and lifetime rules.

docs/source/user_guide/tensor.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,33 @@ a.from_torch(out)
151151

152152
The exact same surface is available on both backends — switching `qd.tensor(..., backend=qd.Backend.FIELD/NDARRAY)` does not require any other code change at the call site.
153153

154+
### Zero-copy with `copy=False`
155+
156+
`to_numpy()` and `to_torch()` accept a keyword-only `copy` argument:
157+
158+
```python
159+
a = qd.tensor(qd.f32, shape=(1024,))
160+
a.fill(1.0)
161+
162+
view = a.to_torch(copy=False) # zero-copy: aliases a's memory, or ValueError
163+
auto = a.to_torch(copy=None) # zero-copy if possible, otherwise copy
164+
clone = a.to_torch(copy=True) # independent copy (default)
165+
```
166+
167+
| Value | Behaviour |
168+
|---|---|
169+
| `True` (default) | Independent copy via kernel. Safe to mutate freely. |
170+
| `None` | Zero-copy when available, otherwise falls back to a copy silently. |
171+
| `False` | Zero-copy DLPack view, or `ValueError` if unsupported for this backend/dtype. |
172+
173+
`copy=False` and `copy=None` avoid both the buffer allocation and the copy kernel when zero-copy is available — the returned numpy array or torch tensor points directly at Quadrants' existing memory. For a large tensor this eliminates a potentially expensive memcpy and a device-side kernel launch. Writes through the view are immediately visible to subsequent Quadrants kernels (and vice versa), removing the need for `to_torch` → modify → `from_torch` round-trips.
174+
175+
The difference between `False` and `None`: `copy=False` raises `ValueError` when zero-copy is not supported (e.g. unsupported dtype or GPU-to-numpy), while `copy=None` silently falls back to a kernel copy in those cases. Use `copy=None` when you want zero-copy as a best-effort optimisation without having to handle exceptions.
176+
177+
The tradeoff of zero-copy is lifetime coupling: the view is invalidated on `qd.reset()` or `qd.init()`, and on GPU you must be mindful of stream synchronisation when both frameworks write to the same buffer.
178+
179+
This works identically on both backends. For the full support matrix (which backends/dtypes qualify, lifetime caveats, Metal synchronisation) see [`interop`](interop.md#zero-copy-interop-via-dlpack).
180+
154181
Gradient buffers behave identically: `a.grad.to_numpy()` returns the canonical view of the gradient.
155182

156183
## Annotating kernel arguments: `qd.Tensor`

python/quadrants/_tensor_wrapper.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,18 +189,20 @@ def __setitem__(self, key: typing.Any, value: typing.Any) -> None:
189189
# Interop forwards (layout-aware on both impls already)
190190
# ------------------------------------------------------------------
191191

192-
def to_numpy(self, dtype: typing.Any = None) -> typing.Any:
193-
if dtype is None:
194-
return self._impl.to_numpy()
195-
return self._impl.to_numpy(dtype=dtype)
192+
def to_numpy(self, dtype: typing.Any = None, *, copy: typing.Optional[bool] = True) -> typing.Any:
193+
kw: dict[str, typing.Any] = {"copy": copy}
194+
if dtype is not None:
195+
kw["dtype"] = dtype
196+
return self._impl.to_numpy(**kw)
196197

197198
def from_numpy(self, arr: typing.Any) -> None:
198199
self._impl.from_numpy(arr)
199200

200-
def to_torch(self, device: typing.Any = None) -> typing.Any:
201-
if device is None:
202-
return self._impl.to_torch()
203-
return self._impl.to_torch(device=device)
201+
def to_torch(self, device: typing.Any = None, *, copy: typing.Optional[bool] = True) -> typing.Any:
202+
kw: dict[str, typing.Any] = {"copy": copy}
203+
if device is not None:
204+
kw["device"] = device
205+
return self._impl.to_torch(**kw)
204206

205207
def from_torch(self, arr: typing.Any) -> None:
206208
self._impl.from_torch(arr)

python/quadrants/lang/_ndarray.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -477,18 +477,22 @@ def to_numpy(self, dtype=None, *, copy=True):
477477
478478
Args:
479479
dtype: Optional numpy dtype to cast the result to. ``None`` keeps the native ndarray dtype.
480-
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises.
480+
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises,
481+
``None`` uses zero-copy when available and falls back to a copy otherwise.
481482
"""
482-
if copy is False:
483+
if copy is not True:
483484
from quadrants.lang.field import ( # pylint: disable=C0415
484485
_try_zerocopy_numpy,
485486
)
486487

487-
arr = _try_zerocopy_numpy(self, copy=False, is_ndarray=True)
488+
arr = _try_zerocopy_numpy(self, copy=copy, is_ndarray=True)
488489
if arr is not None:
489490
if dtype is not None and arr.dtype != dtype:
490-
raise ValueError(f"copy=False is incompatible with dtype conversion ({arr.dtype} -> {dtype})")
491-
return arr
491+
if copy is False:
492+
raise ValueError(f"copy=False is incompatible with dtype conversion ({arr.dtype} -> {dtype})")
493+
# copy=None: fall through to the copy path for dtype conversion
494+
else:
495+
return arr
492496

493497
arr = self._ndarray_to_numpy()
494498
if dtype is not None and arr.dtype != dtype:
@@ -508,14 +512,17 @@ def to_torch(self, device=None, *, copy=True):
508512
view just like ``to_numpy()`` does.
509513
510514
Args:
511-
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises.
515+
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises,
516+
``None`` uses zero-copy when available and falls back to a copy otherwise.
512517
"""
513-
if copy is False:
518+
if copy is not True:
514519
from quadrants.lang.field import ( # pylint: disable=C0415
515520
_try_zerocopy_torch,
516521
)
517522

518-
return _try_zerocopy_torch(self, copy=copy, device=device, is_ndarray=True)
523+
result = _try_zerocopy_torch(self, copy=copy, device=device, is_ndarray=True)
524+
if result is not None:
525+
return result
519526

520527
import torch # pylint: disable=C0415
521528

python/quadrants/lang/field.py

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -204,20 +204,25 @@ def _can_zerocopy_field(field: "Field", *, is_scalar: bool = False, is_ndarray:
204204
def _try_zerocopy_torch(field: "Field", *, copy, device=None, is_scalar: bool = False, is_ndarray: bool = False):
205205
"""Try to return a zero-copy torch tensor via DLPack.
206206
207-
Only called when ``copy is False``. Returns the tensor on success. Raises ``ValueError`` when zero-copy is not
208-
available. Does NOT call ``torch.mps.synchronize()`` -- the caller is expected to handle MPS sync for the copy=True
209-
(kernel-copy) path instead.
207+
Returns the tensor on success. When ``copy is False``, raises ``ValueError`` if zero-copy is not available. When
208+
``copy is None``, returns ``None`` if zero-copy is not available (allowing the caller to fall back to a copy). Does
209+
NOT call ``torch.mps.synchronize()`` -- the caller is expected to handle MPS sync for the copy=True (kernel-copy)
210+
path instead.
210211
"""
211212
if not _can_zerocopy_field(field, is_scalar=is_scalar, is_ndarray=is_ndarray):
212-
raise ValueError(f"Zero-copy not available for arch={impl.current_cfg().arch.name}, dtype={field.dtype}")
213+
if copy is False:
214+
raise ValueError(f"Zero-copy not available for arch={impl.current_cfg().arch.name}, dtype={field.dtype}")
215+
return None
213216

214217
import torch # pylint: disable=C0415
215218
import torch.utils.dlpack # pylint: disable=C0415
216219

217220
try:
218221
tc = torch.utils.dlpack.from_dlpack(field.to_dlpack())
219222
except RuntimeError as e:
220-
raise ValueError(f"Zero-copy not available: {e}") from None
223+
if copy is False:
224+
raise ValueError(f"Zero-copy not available: {e}") from None
225+
return None
221226
if impl.current_cfg().arch == _ARCH_METAL:
222227
impl.get_runtime().sync()
223228

@@ -228,9 +233,11 @@ def _try_zerocopy_torch(field: "Field", *, copy, device=None, is_scalar: bool =
228233
requested.index is not None and tc.device.index is not None and tc.device.index != requested.index
229234
)
230235
if type_mismatch or index_mismatch:
231-
raise ValueError(
232-
f"copy=False is incompatible with device transfer (data on {tc.device}, requested {device})"
233-
)
236+
if copy is False:
237+
raise ValueError(
238+
f"copy=False is incompatible with device transfer (data on {tc.device}, requested {device})"
239+
)
240+
return None
234241

235242
return tc
236243

@@ -431,7 +438,8 @@ def to_numpy(self, dtype: DataTypeCxx | None = None, *, copy=True):
431438
"""Converts `self` to a numpy array.
432439
433440
Args:
434-
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises.
441+
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises,
442+
``None`` uses zero-copy when available and falls back to a copy otherwise.
435443
436444
Returns:
437445
numpy.ndarray: The result numpy array.
@@ -444,7 +452,8 @@ def to_torch(self, device=None, *, copy=True):
444452
445453
Args:
446454
device (torch.device, optional): The desired device of returned tensor.
447-
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises.
455+
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises,
456+
``None`` uses zero-copy when available and falls back to a copy otherwise.
448457
449458
Returns:
450459
torch.tensor: The result torch tensor.
@@ -604,14 +613,17 @@ def to_numpy(self, dtype=None, *, copy=True):
604613
605614
Args:
606615
dtype: Optional target numpy dtype.
607-
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises.
616+
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises,
617+
``None`` uses zero-copy when available and falls back to a copy otherwise.
608618
"""
609-
if copy is False:
610-
arr = _try_zerocopy_numpy(self, copy=False, is_scalar=True)
619+
if copy is not True:
620+
arr = _try_zerocopy_numpy(self, copy=copy, is_scalar=True)
611621
if arr is not None:
612622
if dtype is not None and arr.dtype != dtype:
613-
raise ValueError(f"copy=False is incompatible with dtype conversion ({arr.dtype} -> {dtype})")
614-
return arr
623+
if copy is False:
624+
raise ValueError(f"copy=False is incompatible with dtype conversion ({arr.dtype} -> {dtype})")
625+
else:
626+
return arr
615627

616628
if self.parent()._snode.ptr.type == _qd_core.SNodeType.dynamic:
617629
warn(
@@ -634,10 +646,13 @@ def to_torch(self, device=None, *, copy=True):
634646
635647
Args:
636648
device: Optional torch device for the returned tensor.
637-
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises.
649+
copy: ``True`` (default) returns an independent copy, ``False`` requires zero-copy or raises,
650+
``None`` uses zero-copy when available and falls back to a copy otherwise.
638651
"""
639-
if copy is False:
640-
return _try_zerocopy_torch(self, copy=copy, device=device, is_scalar=True)
652+
if copy is not True:
653+
result = _try_zerocopy_torch(self, copy=copy, device=device, is_scalar=True)
654+
if result is not None:
655+
return result
641656

642657
import torch # pylint: disable=C0415
643658

0 commit comments

Comments
 (0)