Interoperability — one buffer, every ecosystem's API
A NumSharp array is raw unmanaged memory plus four numbers that describe how to read it: a base address, element strides, an offset and a dtype. That is the same convention numpy, Python's buffer protocol, Arrow and every other strided-array system speak — so interop is not translation, it is introduction: hand the description across the boundary, agree on who frees the memory, and both sides work on the same bytes. This page states the contract that makes the introduction safe — three capabilities every bridge builds on — and maps the bridges themselves. Read it once and each bridge page becomes a variation on a theme you already know: only the far side of the boundary changes.
On this page: The contract · The bridges · Claims
Verified on CPython 3.12.12 · numpy 2.4.2 · pythonnet 3.0.5 · net8.0/net10.0. Every claim below is reproduced by a test in
NumSharp.Tests.Interop— this page's own gates run without Python, because the contract is NumSharp's alone.
The contract
Three NumSharp capabilities make a bridge possible: the full layout of any array is exposed, foreign memory wraps into a working array with a release hook, and the hook fires on the last reference — wherever that reference lives. Everything the bridge pages document — zero-copy views, leases, locks — reduces to these three, plus one declaration a bridge must get right: who owns the wrapped memory.
Raw layout access
A layout is four numbers, and NumSharp exposes all four. Any strided window — a slice, a transpose, a reversed axis — is the same base pointer with different strides and offset:
var nd = np.arange(24).reshape(4, 6).astype(NPTypeCode.Double);
var window = nd["1:3, ::2"];
window.Storage.Address == nd.Storage.Address (views share the base pointer)
window.Shape.Strides == [6, 2] (element strides, not bytes)
window.Shape.Offset == 6
window.typecode == Double
These are the same four fields numpy's __array_interface__ and the buffer protocol's Py_buffer
carry — the lingua franca of strided arrays, which is why no bridge needs a serialization format.
The one translation left is units: NumSharp strides count elements where numpy's count bytes, so a
bridge multiplies by the item size, adds the offset to the pointer, and any strided-array consumer
can address the window exactly. No elements move.
See here Contract_RawLayoutAccess_ExposesAddressStridesOffsetAndDtype
Wrapping foreign memory
One primitive: wrap a pointer with a release hook. Every import path is made of it — Python buffers, mmaps, memory another runtime owns:
using NumSharp.Backends;
using NumSharp.Backends.Unmanaged;
byte* ptr = (byte*)NativeMemory.Alloc(6); // any foreign allocation
bool released = false;
Action onLastReferenceReleased = () => released = true;
var nd = new NDArray(new UnmanagedStorage(
new ArraySlice<byte>(new UnmanagedMemoryBlock<byte>(ptr, 6, onLastReferenceReleased)),
new Shape(2, 3)));
The construction reads inside-out, one responsibility per layer: UnmanagedMemoryBlock<byte> takes
the pointer, the length in elements (not bytes), and the hook to call when the memory is released.
ArraySlice<byte> is the typed window all NumSharp storage works through, UnmanagedStorage binds
it to a dtype, and Shape gives it dimensions — no layer copies, so the finished NDArray
operates directly on ptr.
NumSharp kernels run over the foreign memory in place, writes land in it, and the hook fires when NumSharp is done with it:
nd.GetByte(1, 2) reads ptr[5] np.sum(nd) runs over the foreign buffer
nd[0, 0] = 200 -> ptr[0] == 200
nd.Dispose() -> released == true
See here Contract_ExternalMemoryWrapping_TheDocumentedPrimitive
Last-reference release
The hook fires on the last reference to the memory block — original or derived view, disposed or collected. The block is atomically reference-counted: a derived view — a slice, a transpose — holds the same block, so disposing the original frees nothing while any of them lives. The refcount decides, not disposal order — and the GC finalizer is the safety net when nothing was disposed at all:
derived = nd["2:"]; nd.Dispose() -> hook NOT fired; derived still reads valid memory
derived.Dispose() -> hook fired
(no Dispose at all, GC runs) -> hook fired by the finalizer safety net
The finalizer path guarantees eventual release, not timing — foreign memory whose lifetime matters
should see a deterministic Dispose, with collection as the backstop.
The same references feed NumSharp's resize guard: while a second view (or an export to Python)
holds the block, nd.resize(...) refuses with NumPy's own wording — cannot resize an array that references or is referenced by another array in this way.
See here Contract_ReleaseHook_FiresOnTheLastReference_IncludingDerivedViews, Contract_ReleaseHook_AlsoFiresByGarbageCollection, Contract_RefcheckGuard_SeesOtherReferencesToTheBlock
Ownership of wrapped memory
You declare the owner at wrap time — and the bare wrap declares NumSharp, which is usually wrong
for a bridge. A bare wrap claims ownership: a growing resize succeeds by reallocating into
fresh NumSharp memory, silently detaching from the foreign pointer (and firing the release hook).
A bridge that must stay attached aliases the storage instead, which gives the array numpy's
owndata == False semantics — exactly what the pythonnet import path does:
var attached = new NDArray(
new UnmanagedStorage(new ArraySlice<byte>(new UnmanagedMemoryBlock<byte>(p, 8, () => { })),
Shape.Vector(8))
.Alias(new Shape(8)));
Alias produces a second storage over the same memory whose base tracking points back at the
owner — in NumSharp's own bookkeeping the aliased array is a view, so ownership-gated operations
refuse rather than detach:
bare wrap: resize(16) succeeds — and the address changes; the hook fires
aliased: resize(16) throws IncorrectShapeException:
cannot resize this array: it does not own its data
See here Contract_BareWrapClaimsOwnership_AliasIsWhatKeepsItAttached
The bridges
Every bridge below implements the contract above; each page states its own claims and carries its own gates.
| Bridge | Ships in | Zero-copy | Page |
|---|---|---|---|
numpy, in process — NDArray ⇄ numpy.ndarray, every layout, both directions |
NumSharp.Interop.pythonnet |
✅ views both ways | Python & numpy (pythonnet) |
| PyTorch CPU tensors — NumSharp ⇄ Torch through PyTorch's official NumPy adapters | NumSharp.Interop.pythonnet |
✅ compatible views both ways | PyTorch |
Pandas containers — DataFrame / Series / Index / extension arrays through verified to_numpy projections |
NumSharp.Interop.pythonnet |
✅ when Pandas exposes stable storage; Auto copies otherwise | Pandas |
Python buffer consumers — torch.frombuffer, Pillow, Arrow, OpenCV, stdlib |
NumSharp.Interop.pythonnet |
✅ memoryview / PEP 3118 |
Any library via np.frombuffer |
| Numpy.NET coexistence — drive real numpy's C# API over NumSharp buffers | + Numpy.Bare |
✅ PyObject handoff |
Numpy.NET |
.npy / .npz files — np.save / np.load, byte-for-byte identical to NumPy's own writer |
NumSharp (core) |
— files, not memory | NumPy compliance |
Start with the page whose consumer matches yours: numpy code → the pythonnet page; a library
that wants bytes (or no numpy at all) → the frombuffer page; an existing Numpy.NET codebase → the
Numpy.NET page; data at rest → the file formats, whose writer is byte-exact against np.save
itself.
See here Bridges_ThePythonnetPackage_ShipsTheFourVerbs, NpyOracleTests
One NumSharp.Interop.* package is deliberately absent from this table.
NumSharp.Interop.OpenBLAS shares a binary, not a buffer: it routes
np.dot / np.matmul through the OpenBLAS library NumPy and SciPy ship, making float32/float64
matrix products faster on large matrices. No memory crosses any boundary, so none of the contract
above applies to it; it has its own page.
Claims ledger
| # | Claim | Evidence | Gate |
|---|---|---|---|
| 1 | A strided window is the base pointer plus strides/offset/dtype — no copy | same Storage.Address; strides [6, 2]; offset 6 |
Contract_RawLayoutAccess_ExposesAddressStridesOffsetAndDtype |
| 2 | Foreign memory wraps into a working NDArray with a release hook |
kernels + writes on the foreign buffer; hook fires on Dispose |
Contract_ExternalMemoryWrapping_TheDocumentedPrimitive |
| 3 | The hook fires on the last reference, derived views included | disposing the original leaves the hook unfired until the slice goes | Contract_ReleaseHook_FiresOnTheLastReference_IncludingDerivedViews |
| 4 | The GC finalizer is a safety net when nothing was disposed | hook observed fired after collection | Contract_ReleaseHook_AlsoFiresByGarbageCollection |
| 5 | The resize guard sees every reference to the block | verbatim NumPy-worded refusal while a second view lives | Contract_RefcheckGuard_SeesOtherReferencesToTheBlock |
| 6 | Bare wraps own and may detach; aliased wraps refuse to detach | address change vs does not own its data refusal |
Contract_BareWrapClaimsOwnership_AliasIsWhatKeepsItAttached |
| 7 | The pythonnet package really ships the four verbs | assembly identity + method presence | Bridges_ThePythonnetPackage_ShipsTheFourVerbs |
| 8 | .npy/.npz output is byte-identical to NumPy's own |
286-case oracle of real np.save output, replayed bit-exact |
NpyOracleTests |
See also
- Python & numpy (pythonnet) — the reference bridge: four verbs, layouts, lifetime, codec, GIL, dtypes, versions
- PyTorch — shared CPU tensors, autograd, all 15 dtypes, layout/device copy boundaries
- Pandas — frames/series/indexes, Copy-on-Write, mixed blocks and extension dtypes
- Any library via np.frombuffer — the buffer protocol route to libraries that never touch numpy
- Numpy.NET — running SciSharp's numpy binding over NumSharp memory
- OpenBLAS — the compute-side sibling: a native BLAS binary behind
np.dot/np.matmul, faster float32/float64 matrix products - Buffering & Memory — how NumSharp's own storage, slices and reference counting work underneath all of this