Table of Contents

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.Tensors 10.0.0 · net8.0/net10.0 · Windows, Linux, macOS. Every claim below is reproduced by a test in NumSharp.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_TENSORS around 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 .ReadOnlySpan only - .Span throws, 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.Tensors forbids negative strides (its TensorSpan ctor throws "Strides cannot be less than 0"), so a reversed slice nd["::-1"] is refused with the fix in the message: np.ascontiguousarray(nd) / nd.copy(), or ToTensor (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.Tensors requires 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 through ToTensor), 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>.Empty is no substitute: it is rank 0, and the BCL's own FlattenTo and Tensor.Sum throw IndexOutOfRangeException on it.)
  • No 2 GB limit on AsTensorSpan. The span fronts a native pointer with an nint length, unlike a Memory<T>-backed tensor; arrays over 2 GB share fine. (ToTensor / ToNDArray copy into a managed T[], which is int-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. AsTensorSpan takes 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 the TensorSpanHandle<T> drops the reference; a forgotten handle is released by a finalizer safety net. LiveExports counts open handles. Both export verbs take that reference before reading anything, so an array whose buffer has already been released is refused with ObjectDisposedException whatever its layout (a strided one is never densified from freed memory first).
  • Imports. AsNDArray leases 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 the Tensor<T> so its backing cannot move underneath it, and does not own its data (owndata == false), so a size-changing resize refuses instead of detaching. LiveImports counts 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 / ascontiguousarray them.
  • The copies are size-limited. ToTensor fills a managed T[], which holds at most Array.MaxLength (2,147,483,591) elements, and ToNDArray fills through FlattenTo's Span<T>, at most int.MaxValue. Past those limits they throw NotSupportedException, before allocating anything. The zero-copy verbs have no such limit: AsTensorSpan fronts a native pointer, and AsNDArray of a stride-0 tensor gives a read-only broadcast view of any size.
  • A ref struct span cannot be leased: importing a TensorSpan<T> / ReadOnlyTensorSpan<T> always copies (span.ToNDArray()). Only a Tensor<T> (a heap object whose backing can be pinned) supports the zero-copy AsNDArray view.
  • 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