Under the hood - internals
This is a low-level look at how an NDArray is built, for people extending NumSharp or reasoning about its performance. It mirrors NumPy's Internal organization chapter: an array is a raw data buffer plus metadata that says how to read it, and almost everything cheap NumSharp does - slicing, transpose, reshape, broadcast - is a change to the metadata with the buffer left untouched.
For the gentle version of this, read Introduction first; for the memory-lifetime mechanics, Buffering & Memory.
The two components
An NDArray is three parts, but two carry the data model:
NDArray
├── Storage UnmanagedStorage - the raw data buffer (unmanaged memory)
├── Shape readonly struct - the metadata (how to interpret the buffer)
└── TensorEngine - the compute backend (DefaultEngine = pure C#)
The data buffer is what you'd think of as an array in C: one contiguous, fixed block of unmanaged memory holding fixed-size elements. Everything else is the metadata that describes how to read that block - and, exactly as in NumPy, it contains:
- the element size in bytes (
itemsize); - the start of the data within the buffer (
offset); - the number of dimensions and each dimension's size (
shape); - the separation between elements along each dimension (
strides); - whether the buffer is writeable;
- the dtype (how to interpret each element);
- whether the array is C-order or Fortran-order contiguous.
Byte order is item (5)'s NumPy sibling, but NumSharp is always host-endian - big-endian data is byte-swapped to native on read (e.g. from a .npy), so there is no non-native byte-order state to carry.
UnmanagedStorage - the buffer
NumSharp stores element data in unmanaged memory, not a managed T[] (benchmarked fastest; it avoids the GC and pins nothing). The buffer is owned by an atomically reference-counted memory block:
- Multiple arrays can share one block - every view (slice, transpose, reshape) holds the same block, so disposing one array frees nothing while another view lives.
- Release is deterministic when you
Dispose, and the GC finalizer is the safety net:~NDArrayabandons its reference (decrements the count) rather than freeing directly -Releaseis the eager path,Abandonthe finalizer path. - This refcount is what powers the resize guard and the interop lifetime contract - see Interoperability → the contract and Buffering & Memory.
Shape - the metadata
Shape is a readonly struct (immutable after construction, NumPy-aligned) that carries the metadata and precomputes what it can:
public readonly partial struct Shape
{
internal readonly int _flags; // cached ArrayFlags bitmask (O(1) property reads)
internal readonly int _hashCode; // precomputed
internal readonly long size; // total element count
internal readonly long[] dimensions; // dimension sizes
internal readonly long[] strides; // stride values in ELEMENTS (0 = broadcast axis)
internal readonly long bufferSize; // size of the underlying buffer
internal readonly long offset; // base offset into storage
}
ArrayFlags
Cached at construction, mirroring NumPy's ndarraytypes.h:
| Flag | Value | Meaning |
|---|---|---|
C_CONTIGUOUS |
0x0001 |
row-major contiguous |
F_CONTIGUOUS |
0x0002 |
column-major contiguous |
OWNDATA |
0x0004 |
array owns its buffer |
ALIGNED |
0x0100 |
always true for managed allocations |
WRITEABLE |
0x0400 |
false for broadcast views |
BROADCASTED |
0x1000 |
has a stride-0 axis with dim > 1 |
So Shape.IsContiguous, IsFContiguous, IsBroadcasted, IsWriteable, IsSliced, IsSimpleSlice are all O(1) flag reads, not walks.
Strides: Shape.Strides counts elements, nd.strides counts bytes ⚠️
This is the one detail that bites: there are two stride properties, in different units. nd.strides (the NumPy-facing property) is in BYTES, matching NumPy's ndarray.strides; nd.Shape.Strides is in ELEMENTS:
var a = np.arange(24).reshape(4, 6).astype(np.float64);
a.strides; // [48, 8] ← BYTES (public, NumPy-parity)
a.Shape.Strides; // [6, 1] ← ELEMENTS (public)
When you write a backend or a kernel and read a.Shape.Strides, they are element counts - multiply by itemsize to get bytes (NumPy's C-API gives you bytes directly). Getting this wrong is the classic off-by-itemsize bug.
Views: metadata-only reinterpretation
Because the buffer and the metadata are separate, a new "way of looking at" the same bytes is just a new Shape (and a shared Storage). Slicing, transpose, reshape (where possible), and broadcast all do exactly this - no data moves:
var a = np.arange(12).reshape(3, 4);
var t = a.T; // transpose: strides reversed, same buffer
var s = a["1:3, ::2"]; // slice: new strides + offset, same buffer
- Transpose reverses the stride order; the data doesn't move, only the index→address mapping changes.
- Slicing sets a new
offsetand strides into the same buffer. - Broadcast sets a stride of 0 on a stretched axis - one stored element is read for every logical position along it - which is why broadcast views are marked
WRITEABLE = false(writing would corrupt every aliasing position).
A view increments the buffer's reference count, so the buffer outlives the original array if any view remains. Forcing an independent buffer requires .copy(). This is the full story in Copies and views.
Multidimensional array indexing order
NumSharp uses C-order (row-major) indexing: the last index varies fastest in memory. a[i, j] selects row i, column j; a[0] is the first row (a contiguous sub-array), and iterating the first index steps by the largest stride.
The confusion NumPy documents is real and worth restating: matrix convention (first index = row) and image convention (first index = x/column) disagree, and each language's storage order (C row-major vs Fortran column-major) then decides which index is cheap to iterate. NumSharp resolves this the way NumPy does:
- The default is C-order, so
a[0](a row) is contiguous and cheap, whilea[:, 0](a column) is strided. Shapealso tracks F-contiguity, and APIs with anorderparameter resolve NumPy'sC/F/A/Kmodes throughOrderResolver, so column-major layouts are first-class where you ask for them.- Kernels don't care about the label order -
NDIterdetermines which axis is most rapidly varying in memory and makes that the inner loop, so a ufunc over a transposed view is as efficient as over a contiguous one.
The practical consequence: iterate with the memory order in mind. a.flat / the typed iterators walk in logical C-order; a fast per-chunk kernel should follow the array's actual strides (which NDIter and np.nditer_chunks<T> do for you). See Indexing and Iterating & Enumerating.
How kernels traverse it
Two managed subsystems turn this metadata into computation:
NDIter- the multi-operand iterator (NumSharp'sNpyIter): C/F/A/K order, broadcasting, buffering, casting, external loops, reductions. It picks the inner-loop axis from the strides. See NDIter.ILKernelGenerator/DirectILKernelGenerator- runtime IL emission with SIMD (V128/V256/V512), so the inner loops run at native speed with no C. See IL Generation.
Everything the Extending NumSharp seams expose is built on these two over the buffer/metadata split described here.
Inspecting the internals
Shape exposes the raw metadata through public properties, so you can read it with no special access:
a.Shape.Dimensions; // long[] dimension sizes
a.Shape.Strides; // long[] strides in ELEMENTS (0 = broadcast)
a.Shape.Offset; // base offset into storage
a.Shape.BufferSize; // underlying buffer element count
a.Shape.IsContiguous; // O(1) flag
a.@base; // owning array, or null (view detection)
// the NumPy-facing surface:
a.shape; a.strides; // shape is long[]; strides in BYTES (NumPy parity)
a.ndim; a.size; a.dtype;
Troubleshooting
"My strided kernel read the wrong elements"
a.Shape.Strides are in elements; a.strides are in bytes. Backends read element strides and scale by itemsize themselves.
"Writing to a broadcast view threw"
Broadcast axes have stride 0 and are WRITEABLE = false by design. Copy first - see Copies and views.
"The buffer wasn't freed when I disposed the array"
A view still holds the shared, refcounted block. The buffer frees on the last reference (or the GC finalizer as backstop). See Buffering & Memory.
API reference
| Item | Meaning |
|---|---|
NDArray.Storage (UnmanagedStorage) |
the unmanaged data buffer + refcounted block |
NDArray.Shape (readonly struct) |
dimensions, strides (elements), offset, flags, size |
NDArray.strides |
strides in bytes (public, NumPy parity) |
Shape.Strides |
strides in elements (public) |
Shape.{IsContiguous,IsFContiguous,IsBroadcasted,IsWriteable,IsSliced} |
O(1) ArrayFlags reads |
ArrayFlags |
C_CONTIGUOUS/F_CONTIGUOUS/OWNDATA/ALIGNED/WRITEABLE/BROADCASTED |
NDArray.@base / np.shares_memory |
view detection |
Related reading
- Introduction - the same model, gently.
- Buffering & Memory - the ARC block, slices, and lifetime.
- Copies and views - metadata-only reinterpretation in practice.
- NDIter · IL Generation - how kernels traverse and compile.
- Extending NumSharp · Native code & backends.
- NumPy internals - the upstream article this converts.