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, the sibling of the
ONNX Runtime and ML.NET bridges: 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(54 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 unlike an ONNX tensor (row-major, no
strides), 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 an empty array asTensorSpan<T>.Empty— the BCL's pointer ctor cannot express rank 0 and rejects a zero-length backing, so these two shapes take the documented fallback. - 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 pointer as a NumSharp memory
block with a release hook — the same "wrap foreign memory" primitive the whole interop family
is built on.
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. - 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 (unlike ONNX); 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, unlike the ONNX/ML.NET bridges |
| Complex | System.Numerics.Complex |
zero-copy (a 16-byte unmanaged struct) — no refusal, unlike the ONNX/ML.NET bridges |
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. ToTensor/ToNDArraycopy into a managed array, which isint-indexed: overint.MaxValueelements they throw. Share zero-copy withAsTensorSpan(a native pointer, no such limit) instead.- 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]and an empty array asTensorSpan<T>.Empty— the BCL's rank-0 / zero-length handling forces these two documented shapes.
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 |
See also
- Interoperability overview — the contract every bridge builds on
- ONNX Runtime — the sibling bridge whose handle / lease lifetime model this one mirrors
- ML.NET — the other conversion bridge (an
IDataViewover anNDArray) - Package README:
src/NumSharp.Interop.System.Numerics.Tensors/README.md