Interoperability¶
Tack rarely owns a whole application. Its arrays have to reach PyTorch, CuPy, NumPy and VTK, and their arrays have to reach Tack kernels, without a copy through the host. This page describes the three mechanisms — DLPack, raw pointers and OS-handle export — then the VTK layer built on DLPack, and finally Level Zero, where sharing a pointer is not enough and the design had to share a context instead.
Ownership and the DeviceBuffer hierarchy are covered in Memory and
Aliasing; this page assumes them.
The mechanisms¶
| Mechanism | Direction | Zero-copy | Who keeps the memory alive |
|---|---|---|---|
array.__dlpack__() |
Tack → other | yes | Tack pins the array until the consumer's deleter runs |
tack.from_dlpack(x) |
other → Tack | yes (unless copy=True) |
the array holds the producer's capsule and calls its deleter when collected |
tack.array_from_ptr(ptr, ...) |
other → Tack | yes | the caller, by hand |
array.export_memory() |
Tack → another graphics/compute API | Metal: yes; CUDA: yes for exportable=True arrays, otherwise one copy |
the exporting buffer |
tack.interop.vtk |
both | yes | as DLPack |
DLPack export¶
Array.__dlpack__(*, stream=None, max_version=None, dl_device=None,
copy=None) (lang/array.py) delegates to array_to_dlpack in
lang/dlpack.py. copy=True raises BufferError: an export is always a
view. stream and dl_device are accepted and not used. GPU launches
are queued, so instead of ordering the consumer's stream after Tack's, the
export calls the buffer's share() (_get_device_info): it waits for the
launches queued so far, and marks the buffer so that every later launch
that uses it completes before returning, because the consumer may read
the memory at once, on its own stream, and keep reading it after later
launches. There is then no pending work for a consumer's stream to wait
on. See Memory and Aliasing.
Legacy or versioned. A consumer passing max_version >= (1, 0) gets a
DLPack 1.0 DLManagedTensorVersioned in a "dltensor_versioned" capsule;
otherwise it gets the legacy DLManagedTensor in a "dltensor" capsule.
The distinction matters because only the versioned struct has a flags
array, and so only it can carry DLPACK_FLAG_BITMASK_READ_ONLY, which Tack
sets for a non-writable array. Exported the legacy way, a read-only array
arrives as writable — the legacy protocol cannot say otherwise. The two
structs are never read interchangeably: dl_tensor sits at the end of
the versioned struct and at the start of the legacy one.
Device mapping. _get_device_info dispatches on the buffer class:
| Buffer | DLPack device type | Pointer exported |
|---|---|---|
NumpyBuffer |
kDLCPU (1) |
the array's data |
MetalBuffer |
kDLCPU (1) |
the shared view's data — unified memory is host-addressable |
CUDABuffer |
kDLCUDA (2) |
the device pointer |
HIPBuffer |
kDLROCM (10) |
the device pointer |
L0Buffer |
kDLOneAPI (14) |
the USM device pointer |
The device id is always 0. Shapes are copied into a c_int64 array, and
strides are always C-contiguous element strides with byte_offset = 0 —
there is nothing else to describe, since an array has no slicing. Every
array dtype maps to a DLPack type; anything else raises TypeError.
Keeping an export alive¶
An exported capsule points at the array's memory, so the array, the
managed-tensor struct, and the shape and stride arrays must all outlive the
consumer. array_to_dlpack pins them in the module dictionary _prevent_gc
under a fresh integer key, and stores that key in the struct's
manager_ctx. The key is the crux: DLPack calls the deleter with only the
managed-tensor pointer, so the producer's bookkeeping key has to travel
inside it. Keys come from itertools.count(1) — starting at 1 because a
zero would round-trip through c_void_p as None.
Two paths unpin:
- The consumer adopted the capsule. By protocol it renamed the capsule
to
"used_dltensor"(or"used_dltensor_versioned") and will call the tensor's deleter when it is done._dlpack_deleterand_dlpack_deleter_versionedare separate functions because the deleter receives only a pointer and cannot tell which layout to readmanager_ctxfrom. - Nobody adopted it. A capsule collected while still named
"dltensor"or"dltensor_versioned"was never consumed, so the capsule destructor,_destroy_capsule, releases the pin itself.
_destroy_capsule takes every helper it needs — PyCapsule_IsValid,
PyCapsule_GetPointer, _release, both struct types — as default
arguments, and _release does the same for ctypes.cast,
ctypes.POINTER and the pin table. A capsule can outlive the tack.lang.dlpack
module: a consumer that failed part-way through an import has been seen
holding one until interpreter exit, when module globals are already torn
down. A global lookup in the destructor then raised NameError inside an
ignored ctypes callback, and the array stayed pinned. Defaults are bound
when the function is defined, so they survive the module.
test_an_unadopted_capsule_is_released_without_module_globals in
tests/test_dlpack.py runs the destructor with an empty globals dictionary
to hold that line. The helpers also use raw c_void_p pointers rather than
py_object, so capsule teardown never touches the capsule's reference
count.
DLPack import¶
tack.from_dlpack(source, *, device=None, copy=False) wraps a tensor without copying. With
copy=True it instead calls np.from_dlpack(source) (or uses source
directly if it is a NumPy array) and copies into a new array with
array, so the copying path accepts host tensors.
The zero-copy path is dlpack_to_array(source, writable=True, device=None):
- Get a capsule.
_request_capsulecallssource.__dlpack__(max_version=(1, 0))and falls back tosource.__dlpack__()if the producer raisesTypeErrorbecause it predates DLPack 1.0. Asking for the versioned form first matters: NumPy refuses to export a read-only array over the legacy protocol. A bare capsule is used as given. - Check it is unconsumed. A capsule not named
"dltensor_versioned"or"dltensor"has already been taken:ValueError. - Honor read-only. A versioned tensor with the read-only flag makes
the array non-writable, overriding the default. Host writes to it raise
RuntimeError, and a dispatch that binds it where the kernel may store raisesValueError(see Memory and Aliasing). - Validate the tensor. A null data pointer raises
ValueError; a dtype with no Tack equivalent, orlanes != 1, raisesTypeError; non-C-contiguous strides raiseValueError, because reading with the wrong stride produces plausible-looking wrong numbers. Null strides mean C-contiguous and are accepted. -
Match the device to the backend.
_DEVICE_BACKENDSmaps the DLPack device type to the backends that can wrap it:DLPack device type Backend kDLCPU,kDLCUDAHost,kDLROCMHostcpukDLCUDA,kDLCUDAManagedcudakDLROCMhipkDLOneAPIlevel_zerokDLMetalis deliberately absent. Itsdatais an opaqueid<MTLBuffer>handle, not an address, andbyte_offsetlocates the tensor inside that buffer; wrapping one would need the handle turned back into a PyObjC object and an offset carried throughMetalBufferand the argument-buffer binding, neither of which exists. So akDLMetaltensor raisesValueErrorup front, on any backend, with a message saying to move the tensor to host memory and import it withcopy=True. Any other unknown device type raisesValueError. Withoutdevice=, the default device imports the tensor when it can wrap it, and otherwise the tensor's own device does (the first backend in the table, numbered by the tensor's device id), as the array API'sfrom_dlpackkeeps an array where it is: CUDA memory with the CPU as default is a CUDA array, host memory with Metal as default a CPU array. Adevice=that cannot wrap the tensor raisesRuntimeErrornaming the device to import it on; it never copies. 6. Wrap.array_from_ptr(data + byte_offset, shape, dtype, writable=...), which runs the backend's memory-space check. 7. Adopt. Rename the capsule to itsused_name, so no one else releases it, and attach a_CapsuleHoldto the array as_dlpack_hold.
Adopting is the DLPack term for taking over the producer's deleter.
_CapsuleHold keeps the capsule referenced and calls the managed tensor's
deleter exactly once — from release() or from its own __del__, which
swallows errors because interpreter teardown can remove ctypes first. The
hold is on the Array, not the buffer, so Array.reshape() copies it to
the view; the producer is told the memory is free only when the last array
over it is collected. The source object itself may be dropped immediately.
Unlike array_from_ptr, which defaults to read-only, an imported array is
writable unless the producer says otherwise: DLPack exchange is meant to
share memory in both directions, and the versioned flag is how a producer
opts out.
Metal and DLPack
A Metal array exports as kDLCPU, which is correct — its memory is
host-addressable — but the Metal backend does not import kDLCPU
tensors, including its own. Wrapping a host pointer as an MTLBuffer
without copying needs a page-aligned address, which host allocations
have only by accident of size, so support would work or fail depending
on how big the array happened to be. The refusal appends Metal's
dlpack_refusal_note, which says so and points to copy=True.
kDLMetal tensors are refused before any backend sees them (step 5).
Raw pointers¶
tack.array_from_ptr(ptr, dtype, shape, writable=False) is the lowest
level: no capsule, no deleter, no device type. Tack validates every
pointer with as_address() and memory_space() where the backend
distinguishes device memory, wraps them without taking ownership, and leaves lifetime entirely to
the caller. A wrapped buffer, and so every DLPack import, is marked shared
from the start: a launch that uses it completes before it returns, since
the owning library may touch the memory with no Tack call in between. The per-backend details are in Memory and
Aliasing. Use it for in situ
frameworks and simulation codes that hand over a device pointer, and prefer
DLPack whenever the producer speaks it, because then lifetime is handled.
Exporting device memory to other APIs¶
DLPack hands a pointer to another library in the same process and on the
same API. A renderer on Vulkan or another process needs an OS-level handle
instead. Array.export_memory() returns an ExportedMemory record of plain
Python values — backend, size, allocation_size, handle,
handle_type and device_uuid — so the consumer needs no GPU library to
read it, and dispatches on handle_type.
| Backend | handle_type |
Handle |
|---|---|---|
| Metal | mtl_buffer |
the MTLBuffer's Objective-C object pointer (objc.pyobjc_id) |
| CUDA (Linux) | posix_fd |
a file descriptor from cuMemExportToShareableHandle |
| CUDA (Windows) | win32_kmt |
a legacy global KMT handle; WDDM mode only, since a GPU in TCC mode has no virtual memory management and cannot export |
Other buffers raise RuntimeError (Backend ... does not support memory
export). CUDA memory from cuMemAllocAsync cannot be exported as an OS handle,
so CUDABuffer.export_memory() waits for the launches queued on the
array, copies it once, device to device, into a lazily created
ExportableCUDABuffer and exports that — later kernel writes to the
original are not reflected in the export. Metal's export_memory() calls
share(), as a DLPack export does. An array
allocated with tack.empty(..., exportable=True) is an
ExportableCUDABuffer from the start, and its export is zero-copy. The
exportable buffer is allocated through CUDA's virtual memory management API
(cuMemCreate, cuMemAddressReserve, cuMemMap, cuMemSetAccess),
rounded up to the allocation granularity, which is why allocation_size
can exceed size. The handle is created once and cached; the device UUID
lets the consumer match devices across APIs.
Who closes the descriptor
ExportableCUDABuffer.__del__ closes a POSIX descriptor it exported. A
consumer whose import call takes ownership of the descriptor — as
Vulkan's VK_KHR_external_memory_fd import does on success — should
import a duplicate made with os.dup(). A Win32 KMT handle is not a
kernel handle and is never closed.
VTK¶
tack.interop.vtk (packages/tack-vis/src/tack/interop/vtk.py) is a thin
layer over DLPack and VTK's vtkmodules.util.dlpack_support. If that module
is missing, both functions raise RuntimeError saying so. Host arrays work
with any VTK that has it; device arrays need VTK built with Viskores.
vtk_to_array(vtk_array, flatten=False) asks VTK for a capsule
(dlpack_support.vtk_to_dlpack) and imports it with tack.from_dlpack.
VTK always exports (tuples, components). A single-component array comes
back 1-D, and flatten=True returns a flat (tuples * components,) array —
the interleaved layout Tack's own visualization algorithms take. Both are
reshape() views, which carry the DLPack hold, so the VTK array may be
dropped as soon as the call returns.
array_to_vtk(array, n_components=None, name=None) works out the
layout first, before touching VTK, so a shape mistake is reported as one
(ValueError for a contradictory or non-dividing n_components, or for an
array of three or more dimensions without one). It reshapes the array to
(tuples, components) if needed and passes it to
dlpack_support.dlpack_to_vtk, which consumes the array's __dlpack__.
Tack's pin keeps the array alive until VTK calls the deleter, which is
VTK's responsibility when the array is released.
CUDA and HIP¶
On CUDA and HIP, Tack relies on the pointer alone. The DLPack device type
selects the backend, and memory_space() asks the driver whether the
address is device, pinned or managed memory. The interop module's own
premise is that these runtimes keep one context per device for the whole
process, so a device pointer means the same thing to every library.
sequenceDiagram
participant App
participant Tack
participant VTK
participant Driver as CUDA / HIP driver
App->>Tack: tack.set_default_device("cuda")
App->>VTK: run a Viskores filter (device array)
App->>Tack: vtk_to_array(vtk_array)
Tack->>VTK: dlpack_support.vtk_to_dlpack(vtk_array)
VTK-->>Tack: capsule (kDLCUDA, device pointer)
Tack->>Driver: memory_space(pointer) via cuPointerGetAttribute
Driver-->>Tack: "cuda"
Tack-->>App: Array (wrapped, holds the capsule)
App->>Tack: kernel(array, ...)
App->>Tack: array_to_vtk(array)
Tack->>VTK: dlpack_to_vtk(array) — consumes array.__dlpack__()
VTK-->>App: vtkDataArray over the same allocation
CUDABackend adopts whatever context is current on the calling thread when
it starts, and otherwise retains the device's primary context, the one the
CUDA runtime and libraries on it (PyTorch, CuPy, Warp) use, so work from
either side runs in one context whichever made it current; see
Backend Implementations.
Level Zero: one context, shared out of band¶
Why a pointer is not enough¶
A Level Zero USM pointer is meaningful only inside the context that
allocated it, and DLPack has no field for a context. kDLOneAPI and an
address say which device, not which context. Two libraries that each
create their own context therefore cannot legitimately exchange pointers,
however well DLPack is implemented on both sides.
A probe on an Intel Data Center GPU Max 1100 with oneAPI 2025.2.1 (2026-10-05) established the facts the design rests on:
- Both queue constructions Kokkos uses for its SYCL backend live in the SYCL platform's default context.
- Memory allocated with raw
zeMemAllocDevicein that context's native handle is device memory to SYCL, and a SYCL kernel writes it correctly. - The driver does not distinguish contexts. A pointer from a private
context is also device memory to SYCL,
zeMemGetAllocPropertiesreports it as device memory in the other context, and a SYCL kernel reads and writes it correctly. The Level Zero specification does not sanction this, so nothing may rely on it — and no runtime check can catch the mistake.
The alternatives were all worse. Relying on the driver's leniency is relying on unspecified behavior that happens to work. Copying through the host defeats the point. Putting a context in DLPack is a change to a standard neither project controls. So the context travels once, out of band: the other library reports the handles of the context its device queue uses, and Tack allocates in that context instead of its own. DLPack itself is unchanged.
Adopting a context¶
LevelZeroBackend declares device_options = frozenset({"external_context"}),
so
tack.set_default_device("level_zero", external_context={"driver": d, "device": dev, "context": ctx})
passes the dictionary to LevelZeroBackend.__init__. All three handles are
required and must be non-null; otherwise ValueError names the missing
ones. Tack does not fill in a driver or device of its own, because a driver
or device found separately need not be the one the context was created
against. With the handles adopted, _query_device runs against the given
device, self._context wraps the given context, and _create_queues
creates Tack's command list in that context. Every later
zeMemAllocDevice — every tack.empty() on that device — allocates there.
tack.device("level_zero", external_context=...) makes the same device
without making it the default. Either way it is a device of its own, not
the registry's plain tack.device("level_zero"): its memory_domain
includes the context handle, so its arrays and the plain device's never
meet in one launch (ValueError).
LevelZeroBackend.shares_external_context reports whether this happened.
The rejection of unknown options lives in tack.device()
(runtime/dispatch.py), which compares the keywords against the
constructor class's device_options before constructing it. The reason is
specific to this feature: if tack.device("cpu",
external_context=...) or a misspelled keyword were silently ignored, Tack
would start a private context while the caller believed memory was shared —
exactly the failure no runtime check can detect later.
An adopted context is never destroyed. In fact Tack destroys no Level
Zero object at all: LevelZeroBackend.__del__ skips cleanup, because the
driver may already be unloaded at interpreter shutdown. The command list
Tack created inside the adopted context is likewise left alone.
From VTK¶
tack.interop.vtk.init_level_zero() is the convenience form. It calls
dlpack_support.level_zero_handles(), passes the result to
tack.set_default_device("level_zero", external_context=handles) and
returns that device. If that
function is absent or returns nothing, it raises RuntimeError: the VTK is
too old, was built without Viskores on Kokkos' SYCL backend, or is not
running on a Level Zero device.
What VTK must provide is one function: level_zero_handles() in
vtkmodules.util.dlpack_support, returning the integer driver, device
and context handles of the SYCL queue its Viskores device runs on, or a
falsy value when there is none. This requires a VTK with
level_zero_handles, which is not yet in VTK master. With an older VTK the
other backends are unaffected, and on Level Zero the guard below refuses
every exchange.
sequenceDiagram
participant App
participant VTK as VTK / Viskores (SYCL)
participant Tack
participant L0 as Level Zero driver
App->>VTK: import vtkmodules (SYCL queue in the default context)
App->>Tack: tack.interop.vtk.init_level_zero()
Tack->>VTK: dlpack_support.level_zero_handles()
VTK-->>Tack: {driver, device, context}
Tack->>L0: queries on that device; queues in that context
Note over Tack: shares_external_context is True
App->>Tack: f = tack.empty(...)
Tack->>L0: zeMemAllocDevice(shared context)
App->>Tack: array_to_vtk(f)
Tack->>VTK: level_zero_handles() — same context? yes
Tack->>VTK: dlpack_to_vtk(f) — kDLOneAPI pointer
VTK-->>App: device vtkDataArray over Tack's allocation
App->>Tack: vtk_to_array(vtk_array)
Tack->>VTK: same-context check, then vtk_to_dlpack
Tack->>L0: memory_space(pointer) via zeMemGetAllocProperties
Tack-->>App: Array over VTK's allocation
The guard, and why only Tack can enforce it¶
Both vtk_to_array and array_to_vtk call
_require_shared_level_zero_context before exchanging anything, with the
backend whose memory is exchanged: the exported array's device for
array_to_vtk, the device the imported array landed on for
vtk_to_array. When that backend is
Level Zero, it asks VTK for its handles again and compares the context with
backend._context. If VTK has no handles, or the contexts differ, it
raises RuntimeError and tells the caller to start Tack with
init_level_zero() instead of tack.set_default_device('level_zero'). On
other backends it does nothing.
The check has to live in Tack. VTK cannot detect the mistake, because the driver vouches for a pointer from any context, so VTK's own pointer-type check passes; and on some drivers the memory even reads correctly, which makes cross-context exchange a mistake that works until it does not. Tack is the only party that knows which context it allocated in.
The guard covers tack.interop.vtk only. A kDLOneAPI tensor imported
directly with tack.from_dlpack from some other oneAPI library passes the
device-type and memory_space() checks whatever its context, because
neither DLPack nor the driver can say. Start Tack in that library's context
with external_context first.
Lifetime coupling¶
Sharing a context couples lifetimes in a direction Tack cannot see.
- The owner must outlive Tack's use of the context. Tack allocates
with
zeMemAllocDeviceand frees withzeMemFreeExt(deferred until the queued work is done) orzeMemFreein the adopted context, and it holds no reference that keeps the context alive. If the owning library destroys the context while Tack arrays or the backend still exist, those frees and any further dispatch act on a destroyed context. The owning library must keep its context alive until Tack's arrays and backend are gone. - Arrays from before
init_level_zero()belong to the old context. An array allocated on the plaintack.device("level_zero")keeps its device, so its memory remains valid in that private context and kernels given it run there — but it cannot be exchanged with VTK (the guard refuses it) or passed to one launch with the shared device's arrays. Allocate arrays after adopting the context, or copy one over witha.to_device(shared), which goes through the host.
This design was validated on 2026-10-05 on the Intel Data Center GPU Max
1100, with a VTK build carrying level_zero_handles: Tack's VTK interop,
Level Zero context and DLPack tests ran their device cases, and Viskores
operated on a Tack-allocated array with writes through VTK visible to Tack.
Summary of failures¶
| Situation | Raised by | Exception |
|---|---|---|
copy=True passed to __dlpack__ |
Array.__dlpack__ |
BufferError |
| Dtype with no DLPack equivalent on export | array_to_dlpack |
TypeError |
| Capsule already consumed | dlpack_to_array |
ValueError |
| Null data, non-contiguous strides, unknown device type | dlpack_to_array |
ValueError |
Unknown dtype or lanes != 1 |
dlpack_to_array |
TypeError |
| Tensor on a device that the importing device cannot wrap | dlpack_to_array |
RuntimeError |
| Integer pointer in the wrong memory space | array_from_ptr |
ValueError |
Address given to Metal's wrap_ptr |
MetalBackend.wrap_ptr |
TypeError |
| Export from HIP, Level Zero or CPU | Array.export_memory |
RuntimeError |
Unknown option to tack.device |
tack.device |
ValueError |
Missing or null external_context handle |
LevelZeroBackend.__init__ |
ValueError |
VTK lacks dlpack_support or level_zero_handles |
tack.interop.vtk |
RuntimeError |
| Level Zero contexts differ during VTK exchange | _require_shared_level_zero_context |
RuntimeError |