System.Numerics.Tensors - NDArray as a Tensor<T>/TensorSpan<T>, and back
NumSharp.Interop.System.Numerics.Tensors connects NDArray to the BCL's
System.Numerics.Tensors types -
Tensor<T>, TensorSpan<T> and ReadOnlyTensorSpan<T> - without copying. An array of any layout
(contiguous, sliced, transposed, strided, even broadcast) becomes a TensorSpan<T> over the very same
unmanaged bytes; a Tensor<T> produced anywhere comes back as an NDArray - an owning copy, or a
zero-copy view over the tensor's pinned buffer. It is a conversion library: no TensorEngine
seam, no [ModuleInitializer], no native dependency. This page is its reference: setup, the verbs, what
crosses zero-copy and what cannot, who frees what, dtypes, frameworks.
On this page: From zero · The verbs · Zero-copy: the rules · Lifetime · Dtypes · Frameworks · Limits · Not a compute backend · Claims
Verified on
System.Numerics.Tensors10.0.0 · net8.0/net10.0 · Windows, Linux, macOS. Every claim below is reproduced by a test inNumSharp.Tests.Interop.System.Numerics.Tensors(101 tests, green on net8.0 AND net10.0; no Python, no native code).
From zero
dotnet add package NumSharp.Interop.System.Numerics.Tensors
That is the whole dependency - System.Numerics.Tensors is a purely managed BCL package and comes with
it. Nothing else to install, no native runtime to pick.
using System.Numerics.Tensors;
using NumSharp;
using NumSharp.Interop.Tensors;
var nd = np.arange(6).reshape(2, 3).astype(NPTypeCode.Single); // [[0,1,2],[3,4,5]]
// Zero-copy: view the NDArray as a TensorSpan and mutate it in place.
using (var h = nd.AsTensorSpan<float>())
{
TensorSpan<float> span = h.Span; // shares nd's buffer, no copy
for (nint i = 0; i < span.Lengths[0]; i++)
for (nint j = 0; j < span.Lengths[1]; j++)
span[new[] { i, j }] += 10f; // writes straight into nd
}
// nd is now [[10,11,12],[13,14,15]]
// A Tensor<T> produced elsewhere, read back as an NDArray with no copy.
var output = Tensor.Create(new[] { 0.1f, 0.7f, 0.2f }, new nint[] { 3 });
using NDArray probs = output.AsNDArray();
long best = np.argmax(probs); // 1 - NumSharp reductions on the shared buffer
Experimental.
Tensor<T>/TensorSpan<T>are[Experimental("SYSLIB5001")]in the BCL. This package re-marks its own surface[Experimental("NUMSHARP_TENSORS")]so you acknowledge that churn once, at the seam. Add<NoWarn>NUMSHARP_TENSORS</NoWarn>to your project, or#pragma warning disable NUMSHARP_TENSORSaround the calls.
The verbs
Everything is packaging over five operations on the static NDArrayTensorsInterop class (also extension
methods). The naming is the house convention: As… shares memory, To… copies.
| Direction | Verb | Semantics |
|---|---|---|
| NumSharp → Tensors | nd.AsTensorSpan<T>() → TensorSpanHandle<T> |
zero-copy TensorSpan<T> / ReadOnlyTensorSpan<T> over the array's buffer; any non-negative-stride layout. The handle owns the buffer pin - take the span from .Span / .ReadOnlySpan inside a using |
| NumSharp → Tensors | nd.ToTensor<T>() → Tensor<T> |
independent, dense copy; any layout, read in logical (C) order; no lifetime coupling |
| Tensors → NumSharp | tensor.ToNDArray() |
fresh owning C-contiguous copy - the safe default |
| Tensors → NumSharp | tensor.AsNDArray() |
zero-copy view over the tensor's pinned backing store; dense → a C-contiguous view, strided → a strided view (no densifying copy) |
| Tensors → NumSharp | span.ToNDArray() |
copy of a TensorSpan<T> / ReadOnlyTensorSpan<T> (a ref struct span cannot be leased) |
Plus the dtype maps (ToTensorElementClrType, TypeCodeOf<T>) and the counters
NDArrayTensorsInterop.LiveExports / LiveImports, which count live export handles and import leases so a
leak is observable.
using var h = nd.AsTensorSpan<float>(); // ZERO COPY: pointer + lengths + strides
TensorSpan<float> s = h.Span; // mutable (writes through)
ReadOnlyTensorSpan<float> ro = h.ReadOnlySpan; // read-only, always available
Tensor<float> t = nd.ToTensor<float>(); // dense copy, independent
using NDArray copy = t.ToNDArray(); // owning copy back
using NDArray view = t.AsNDArray(); // zero-copy view over t's buffer
Why a handle, not the span itself
TensorSpan<T> is a ref struct: it cannot be stored in a field, captured in a lambda, or returned to
outlive the pin that keeps its memory valid. So AsTensorSpan returns a small IDisposable
TensorSpanHandle<T> that owns the ARC reference on the NumSharp buffer and rebuilds the span on each
.Span / .ReadOnlySpan access (unmanaged memory never moves, so the pointer is stable). Wrap it in a
using and take the span from it - the same shape as keeping a pinned buffer alive across a native call.
Zero-copy: the rules
A TensorSpan<T> carries explicit lengths and strides, so a strided NumSharp view can be shared
as-is:
- Every non-negative-stride layout shares. Contiguous, an offset slice (
nd["2:5"]), a transpose (nd.T), a stepped slice (nd["::2"]), a Fortran-contiguous array, and a broadcast view (stride-0 dimensions) all cross zero-copy. A broadcast (or otherwise read-only) view is exposed through.ReadOnlySpanonly -.Spanthrows, because writing through overlapping stride-0 lanes would corrupt data, exactly as NumSharp forbids writing a broadcast view. - A negative-stride view is the one refusal.
System.Numerics.Tensorsforbids negative strides (itsTensorSpanctor throws "Strides cannot be less than 0"), so a reversed slicend["::-1"]is refused with the fix in the message:np.ascontiguousarray(nd)/nd.copy(), orToTensor(which reads any layout in logical order and copies). - A length-≤1 axis is normalized to stride 0. NumSharp assigns a unit axis a nonzero element stride;
System.Numerics.Tensorsrequires such an axis to have stride 0 (a nonzero stride there reads as over-claiming the buffer). The bridge normalizes it - safe, because a single-element axis is never stepped, so the addressing is identical. - A 0-d scalar crosses as a rank-1
[1]span (and as a[1]tensor throughToTensor), value intact. The BCL has no rank 0: empty lengths do not mean "scalar" to it, they build a rank-1 span or tensor of length 0, so the bridge passes[1]explicitly. - An empty array keeps its shape. A
(3,0,4)array exports as a rank-3 span with every stride 0 - the BCL refuses a zero-length backing only when a stride is nonzero - and the BCL can flatten, fill and reduce it like any other span. (TensorSpan<T>.Emptyis no substitute: it is rank 0, and the BCL's ownFlattenToandTensor.SumthrowIndexOutOfRangeExceptionon it.) - No 2 GB limit on
AsTensorSpan. The span fronts a native pointer with annintlength, unlike aMemory<T>-backed tensor; arrays over 2 GB share fine. (ToTensor/ToNDArraycopy into a managedT[], which isint-indexed - see Limits.)
Under the hood the export is new TensorSpan<T>(pointer, dataLength, lengths, strides) on
slice.Address + Shape.Offset × itemsize with the element strides from Shape.Strides; the import pins
the tensor's backing array (Tensor<T>.GetPinnedHandle()) and wraps the tensor's own first element as a
NumSharp memory block with a release hook - the same "wrap foreign memory" primitive the whole
interop family is built on. The first element comes from the tensor's span
(GetPinnableReference()), not from the handle: the handle points at the backing array's element 0,
which is not where a sliced tensor (t.Slice(…), a range indexer, Tensor.Create(array, start, …)) starts.
The BCL's own rank-0 values (Tensor<T>.Empty, ReadOnlyTensorSpan<T>.Empty, a default span) hold no
element, so they import as the empty vector (0,), never as a one-element 0-d array.
Lifetime: who frees what
- Exports.
AsTensorSpantakes an atomic reference on the NumSharp buffer, so the memory outlives the source array if it is disposed or collected while a span is in use. Disposing theTensorSpanHandle<T>drops the reference; a forgotten handle is released by a finalizer safety net.LiveExportscounts open handles. Both export verbs take that reference before reading anything, so an array whose buffer has already been released is refused withObjectDisposedExceptionwhatever its layout (a strided one is never densified from freed memory first). - Imports.
AsNDArrayleases the tensor's pinned backing through NumSharp's memory-block reference count: the lease fires (unpinning the array) when the last NumSharp view over the memory - slices derived from it included - is disposed or collected. The view roots theTensor<T>so its backing cannot move underneath it, and does not own its data (owndata == false), so a size-changingresizerefuses instead of detaching.LiveImportscounts open leases. - No GIL, no interpreter, no native handles. The verbs are plain managed calls; the one rule is the
handle rule - keep the
TensorSpanHandle<T>alive while you use its span, and dispose it.
Dtypes
System.Numerics.Tensors containers are unconstrained generics over an unmanaged T - there is no
dtype enum and no fixed element-type table. So all 15 NumSharp dtypes cross zero-copy as their own CLR
type; nothing is refused and nothing is converted:
| NumSharp | Crosses as | Note |
|---|---|---|
| Boolean, Byte, SByte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single, Double | bool, byte, sbyte, short, ushort, int, uint, long, ulong, float, double |
zero-copy |
| Half | System.Half |
directly - no Float16 wrapper; NaN payloads and subnormals cross untouched |
| Char | char |
zero-copy, UTF-16 code units; directional - a ushort element type reads back as UInt16, never Char |
| Decimal | System.Decimal |
zero-copy (a 16-byte unmanaged struct) - no conversion |
| Complex | System.Numerics.Complex |
zero-copy (a 16-byte unmanaged struct) - no refusal |
T must be the array's own element type: a mismatch (AsTensorSpan<int>() on a float array) is refused
up front rather than silently reinterpreting the bytes.
Frameworks
The package builds an assembly for net8.0 and net10.0. net9.0 and net11.0 consumers are
served by the net8.0 / net10.0 assets through NuGet nearest-TFM selection and .NET roll-forward - that is
how 9 and 11 are supported without producing two more byte-identical assemblies. An opt-in
-p:BuildAllTfms=true additionally emits the net9.0/net11.0 assets (once those SDKs are installed).
The dependency is System.Numerics.Tensors [10.0.0, 11.0.0). It is pinned to the 10.x line because
Tensor<T>/TensorSpan<T> are [Experimental] and their surface shifted across the 9.x previews; the
ctors this package binds are verified against 10.0.0. Any 10.x rolls forward; the upper bound keeps a
future 11.x (free to break the experimental API) from resolving automatically.
Limits
- Negative-stride views cannot be shared zero-copy (the BCL forbids negative strides) -
ToTensor/ascontiguousarraythem. - The copies are size-limited.
ToTensorfills a managedT[], which holds at mostArray.MaxLength(2,147,483,591) elements, andToNDArrayfills throughFlattenTo'sSpan<T>, at mostint.MaxValue. Past those limits they throwNotSupportedException, before allocating anything. The zero-copy verbs have no such limit:AsTensorSpanfronts a native pointer, andAsNDArrayof a stride-0 tensor gives a read-only broadcast view of any size. - A
ref structspan cannot be leased: importing aTensorSpan<T>/ReadOnlyTensorSpan<T>always copies (span.ToNDArray()). Only aTensor<T>(a heap object whose backing can be pinned) supports the zero-copyAsNDArrayview. - A 0-d scalar crosses as rank-1
[1]- the BCL has no rank 0 - so importing it back gives shape(1,), not(). The value is kept.
Not a compute backend
This bridge moves data, not computation. Do not route NumSharp's operations through
System.Numerics.Tensors' TensorPrimitives / Tensor.Add surface: NumSharp's own kernels are bit-exact
with NumPy (and frequently faster than TensorPrimitives), and swapping them out would trade that parity
away. System.Numerics.Tensors does not compute NumSharp operations - which is exactly why there is no
TensorEngine seam here. The bridge is for handing a NumSharp buffer to code that already speaks
Tensor<T>/TensorSpan<T>, and reading such tensors back.
Claims ledger
| # | Claim | Gate |
|---|---|---|
| 1 | All 15 dtypes map to their own CLR type and round-trip; the map is directional (UInt16 ≠ Char) | DtypeMapTests |
| 2 | AsTensorSpan shares memory both ways (span↔ndarray write-through) and shares every dtype's bytes 1:1 |
ExportTests |
| 3 | ToTensor is an independent dense copy that reads any layout (incl. negative stride) in logical order |
ExportTests |
| 4 | ToNDArray copies (dense + strided); AsNDArray is a zero-copy view, write-through, dense or strided |
ImportTests |
| 5 | Contiguous / offset / transposed / stepped / Fortran / broadcast / unit-axis / 3-D views all share zero-copy | LayoutTests |
| 6 | A negative-stride view is refused with the materialize hint; materializing then exporting works | LayoutTests |
| 7 | A broadcast view exposes .ReadOnlySpan only; .Span throws (read-only) |
LayoutTests |
| 8 | The export handle tracks LiveExports, dispose is idempotent, the source may die while a span lives |
LifetimeTests |
| 9 | The import lease releases only when the last view (derived slices included) dies; a forgotten handle/view is released by GC | LifetimeTests |
| 10 | Special values (NaN sign/payload/signaling, ±inf, ±0, subnormals, every dtype extreme) survive every crossing bit-exact | SpecialValueFidelityTests |
| 11 | NDArray → Tensor → NDArray and NDArray → TensorSpan → NDArray preserve values across all dtypes and layouts | RoundTripTests |
| 12 | The examples on this page run as written | DocExampleTests |
| 13 | A tensor that starts past element 0 of its backing array (a Slice, a range indexer, Tensor.Create(array, start, …)) imports its own elements through ToNDArray and AsNDArray, for all 15 dtypes; a write through the view lands on the element it names |
ImportTests |
| 14 | A 0-d array crosses as [1] through AsTensorSpan and ToTensor with its value kept, for all 15 dtypes |
ExportTests |
| 15 | An empty array exports with its shape and all-zero strides; the BCL can flatten, fill and reduce the span, and it imports back to the same shape, for all 15 dtypes; the BCL's rank-0 values import as (0,) |
ExportTests, ImportTests |
| 16 | Both export verbs refuse a released buffer with ObjectDisposedException, for every layout |
LifetimeTests |
| 17 | The copies refuse what they cannot hold before allocating (ToTensor past Array.MaxLength, ToNDArray past int.MaxValue); AsNDArray of a stride-0 tensor shares any size zero-copy |
ImportTests, BoundaryTests |
| 18 | Every dtype × every catalogued layout (≈45: offsets, steps, transposes, broadcasts, empties at offsets, unit axes, read-only flags, ranks 40/70) × every export verb holds, element by element through the BCL's own indexer, and each write through a span changes exactly the element it names | LayoutMatrixTests |
| 19 | Seeded random views (slices with any step sign, transposes, expand_dims, broadcasts) and random tensors (sub-blocks at offsets, permutations, slices, stride-0 axes) satisfy the export and import contracts; so does every slice of a small vector and every axis-slice pair of a small matrix | PropertyBasedTests |
| 20 | Slicing and permuting commute with crossing in both directions; the copy equals the view; repeated round trips change nothing; a zero-copy chain aliases one buffer | MetamorphicTests |
| 21 | Real BCL operations (Tensor.Sum, in-place Tensor.Add/Multiply, Fill, Clear, CopyTo, Tensor.Equals) read and write through exported spans exactly as NumSharp's views address them |
BclConsumerTests |
| 22 | Concurrent exports, copies, imports and disposals release every reference and lease exactly once | ConcurrencyTests |
| 23 | Refused exports leave no reference behind; a handle holds exactly one, which resize(refcheck) sees; a lease pins while live and unpins on release (as the GC counts pinned objects), also when only the finalizer releases it |
ArcAccountingTests |
| 24 | Random bit patterns (bool bytes beyond 0/1, NaN payloads, invalid decimals) survive every path byte-identical, contiguous and strided | BitPatternFuzzTests |
| 25 | The public surface is exactly the approved one, every type and verb marked [Experimental("NUMSHARP_TENSORS")] |
ApiSurfaceTests |
See also
- Interoperability overview - the contract every bridge builds on
- Package README:
src/NumSharp.Interop.System.Numerics.Tensors/README.md