Python & numpy — the same buffer on both sides
NumSharp.Interop.pythonnet embeds CPython in your .NET process through pythonnet and hands arrays
across the boundary without copying them: a NumSharp NDArray becomes a numpy view over the very
same unmanaged bytes, and any Python buffer becomes a NumSharp view over Python's memory. Four verbs
cover the surface — two per direction, one sharing and one copying — a registrable codec makes the
conversions implicit at every pythonnet boundary, and two counters make every live crossing
observable. This page is the package's reference: setup, the verbs, what they cost, which layouts
survive the crossing, who frees what, the codec, the GIL, dtypes and versions.
On this page: From zero to a shared array · The four verbs · What it costs · Exporting: layouts · Importing: three routes · Lifetime · The codec · The GIL · Dtypes · Versions · Troubleshooting · 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.
From zero to a shared array
dotnet add package NumSharp.Interop.pythonnet
That resolves pythonnet 3.0.5 — the floor of the package's [3.0.5, 4.0.0) range, and what NuGet
actually installs, since it picks the lowest applicable version — which drives Python 3.7 through
3.13. Point pythonnet at your CPython and start the engine once per process:
using NumSharp;
using NumSharp.Interop.PythonNet;
using Python.Runtime;
Runtime.PythonDLL = @"C:\Python312\python312.dll"; // or set the PYTHONNET_PYDLL env var
PythonEngine.Initialize();
PythonEngine.BeginAllowThreads(); // release the GIL from this thread; conversions re-acquire it
BeginAllowThreads is what lets any thread convert afterwards — including threads that have never
touched Python. From here, one buffer serves both sides:
var nd = np.arange(6).reshape(2, 3);
using (Py.GIL())
{
using var scope = Py.CreateScope();
scope.Exec("import numpy as np");
using (PyObject x = nd.ToNumpy()) // zero-copy view of NumSharp's buffer
scope.Set("x", x);
scope.Exec("x[1, 2] = 99"); // Python writes...
scope.Exec("r = np.sin(x / 3.0)"); // numpy computes a fresh array...
using PyObject r = scope.Get("r");
using NDArray copy = r.ToNDArray(); // ...independent copy of it
using NDArray view = r.AsNDArray(); // ...or a zero-copy view of it
}
Console.WriteLine(nd); // ...and NumSharp saw the write
x (dtype=int64):
[[0 1 2]
[3 4 5]]
nd after x[1, 2] = 99:
[[ 0 1 2]
[ 3 4 99]]
np.round(r, 4) (r.dtype=float64):
[[0. 0.3272 0.6184]
[0.8415 0.9719 0.9999]]
r.ToNDArray() -> Double, shape (2, 3), detached
r.AsNDArray() -> Double, writeable view over numpy's buffer
See here QuickStart_OneBufferBothSides, Bootstrap_AFreshThreadConverts
The four verbs
Everything else in the package is packaging over these four. The naming follows numpy's own
array / asarray split: To… copies, As… shares — with ToNumpy as the deliberate
exception, because the zero-copy view is the package's headline.
| Direction | Verb | Returns | Memory |
|---|---|---|---|
| NumSharp → Python | nd.ToNumpy() |
numpy view | shared, source rooted |
| NumSharp → Python | nd.ToNumpyCopy() |
numpy array | independent |
| Python → NumSharp | py.ToNDArray() |
NDArray |
independent, C-contiguous |
| Python → NumSharp | py.AsNDArray() |
NDArray view |
shared, exporter leased |
ToNDArrayView(py, allowReadonly) is the static spelling of AsNDArray; nd.ToPython() aliases
ToNumpy; nd.ToNumpy(copy: true) routes to ToNumpyCopy. nd.ToMemoryView() — raw writable
bytes for consumers that never touch numpy — has its own page.
A view shares later writes in both directions; a copy never does.
var nd = np.arange(4).astype(NPTypeCode.Double);
using (Py.GIL())
{
using (PyObject v = nd.ToNumpy()) Scope.Set("v", v);
using (PyObject c = nd.ToNumpyCopy()) Scope.Set("c", c);
Scope.Exec("v[0] = 11.0"); // reaches nd
Scope.Exec("c[1] = 22.0"); // does not
}
nd [11. 1. 2. 3.]
np.shares_memory(v, c) False
The import pair splits identically: ToNDArray copies any PEP 3118 exporter into a fresh
C-contiguous array (non-contiguous sources are linearized by CPython's memoryview.tobytes('C')),
while AsNDArray leases the exporter's own memory.
See here Verbs_ViewSharesAndCopyDetaches_BothDirections, Verbs_CopyTrueRoutesToTheCopy, Verbs_ToCopiesAndAsShares
What it costs
Does a view get more expensive as the array grows?
No. View verbs are flat in n; copy verbs are linear. A view is a pointer, a shape and a
lifetime hook — the elements are never read. Measured at one million float64, best of 5, on the
stack in the banner:
| Verb | Time | Scales with n |
|---|---|---|
ToMemoryView |
0.0069 ms | no |
ToNumpy |
0.0088 ms | no |
ToNDArrayView |
0.0273 ms | no |
ToNDArray (copy) |
0.6755 ms | yes |
ToNumpyCopy |
1.1266 ms | yes |
At ten million elements the gap is three orders of magnitude; at a thousand it inverts — sharing
has a fixed setup cost (a ctypes window, a frombuffer, a weakref.finalize registration) that
copying eight kilobytes undercuts. The crossover sits around ten thousand elements. Below it, if
you have no reason to share, copy: it is simpler, it locks nothing, and it outlives the
interpreter.
A view also carries obligations a copy does not — the lifetime coupling and the resize locks of Lifetime. Choose a view for size or for shared mutation; choose a copy for a detached snapshot.
See here Costs_ViewVerbsAreFlat_CopyVerbsScale
Exporting: every layout, strides intact
What does numpy see when I export a slice or a transpose?
The exact same strided window — never a copy, never a flattening. ToNumpy expresses the
NumSharp layout in numpy's own terms: dimension sizes, byte strides, and the writeable flag.
| NumSharp source | numpy sees |
|---|---|
C-contiguous (4,6) float64 |
C-contiguous, strides (48, 8) |
row slice b["1:3"] |
strides (48, 8) over the shared buffer |
column slice b[":, ::2"] |
strides (48, 16) |
transpose b.T |
strides (8, 48), F_CONTIGUOUS=True |
reversed b["::-1"] |
strides (-48, 8) — negative strides survive |
F-order (np.asfortranarray) |
strides (8, 32), F_CONTIGUOUS=True |
broadcast (3,) → (2,3) |
strides (0, 8), WRITEABLE=False |
| 0-d scalar | shape=(); x[()] = 7.5 writes through |
empty (0,3) |
shape=(0, 3) — a fresh array; there are no bytes to share |
The table is exhaustive over the layout classes NumSharp produces. Broadcast views are read-only on the numpy side because they are read-only in NumSharp — one element pretending to be many corrupts under a blind write, and both libraries guard it the same way.
sl = full[1:3, ::2]-equivalent exported directly:
sl.tolist() [[6.0, 8.0, 10.0], [12.0, 14.0, 16.0]]
np.shares_memory(full, sl) True
See here Export_TableRows_KeepStridesAndFlags
What is the exported array made of?
A numpy view chained onto a ctypes window over NumSharp's pointer. ToNumpy builds
(ctypes.c_char * nbytes).from_address(ptr), runs np.frombuffer over it, and reshapes — or, for
strided sources, np.lib.stride_tricks.as_strided with the exact byte strides:
full: strides=(48, 8) base=ndarray -> c_char_Array_192 OWNDATA=False
slice: strides=(48, 16) base=DummyArray (as_strided) OWNDATA=False
OWNDATA=False is numpy agreeing the memory is NumSharp's. The deepest base object — the ctypes
window — is where the export's weakref.finalize is rooted, which is why every derived numpy view
(arr[1:], arr.T, np.asarray(arr)) extends the buffer's life: they all chain back to it.
See here Export_BaseChain_EndsAtTheCtypesWindow
Importing: three zero-copy routes
Which Python objects can NumSharp view without copying?
Almost all of them — 47 of the 50 exporter varieties in the census below. AsNDArray /
ToNDArrayView takes one of three routes, chosen by what the exporter is:
- C-contiguous PEP 3118 exporters — any object: numpy arrays,
bytes,bytearray,array.array,memoryview, ctypes arrays,BytesIO.getbuffer(), mmaps, shared memory. The buffer is acquired withPyBUF.WRITABLE, which pins the exporter and blocks reallocation. - Non-contiguous numpy arrays — slices, transposes, Fortran order, broadcasts: imported
through
__array_interface__as a strided NumSharp view with the identical layout; broadcast sources arrive read-only. - Non-contiguous non-numpy exporters — a sliced, offset or reversed
memoryview, a strided window over anarray.array: the pointer comes from aPyBUF.STRIDEDrequest and the exact shape/strides from the memoryview itself. Negative strides included.
using (Py.GIL())
{
using PyObject ba = Scope.Eval("bytearray(b'abcd')");
using NDArray v = ba.AsNDArray(); // route 1: Byte[4], writable, shares the bytearray
v[0] = (NDArray)(byte)90; // ...lands in Python
}
The census, re-measured on every test run — 50 exporter varieties across builtins, array.array's
twelve typecodes, ctypes element types, numpy dtypes, numpy layouts and memoryview forms:
47 view, 2 copy, 1 rejected. The two copies are complex64 (widened to Complex) and a
sub-element-stride as_strided window (linearized); the one rejection is datetime64, whose element
type has no NumSharp dtype at any byte order. Big-endian multi-byte data is a third copy — the
view path refuses it (a native-endian shared view is impossible), but ToNDArray byte-reverses each
element into a value-correct native array, exactly as it widens complex64.
See here Import_ThreeRoutes_EachYieldsAView, Import_Census_47Of50VarietiesView
Can I view something read-only, like bytes?
Only by saying so — and the view arrives non-writeable. By default a read-only source is refused with guidance, so a view you can write through is the only thing a default call can return:
InvalidOperationException: the exporter's buffer is read-only; writing through a NumSharp view
would corrupt an immutable Python object. Use ToNDArray (copy), or pass allowReadonly:true to
take a NON-WRITEABLE view (guarded writes through it throw).
With allowReadonly: true the view's Shape.IsWriteable is false — numpy's writeable=False,
carried across — and a write raises assignment destination is read-only instead of corrupting
the source.
See here Import_ReadonlyRefusedByDefault_OptInIsNonWriteable
Does an imported view own its memory?
No — and it tells you so, exactly like np.frombuffer arrays do. An import view has numpy's
owndata == False semantics: a size-changing resize refuses rather than silently reallocating
away from the shared Python memory, and np.require(view, requirements: "O") is the escape hatch
that produces an owning, detached copy.
view.resize(new Shape(8)) -> IncorrectShapeException:
cannot resize this array: it does not own its data
np.require(view, null, "O") -> owning copy; owned[0] = -4.0 leaves Python's own[0] = 0.0
See here ImportView_OwnsNothing_ResizeRefusesAndRequireOCopies
Lifetime: two collectors, one buffer
The rule in both directions is the same: memory is released when the last reference on the far side lets go, never when the near side happens to tidy up first.
Can Python outlive my NDArray?
Yes. An export takes its own atomic reference on the NumSharp buffer and hands the release to a
weakref.finalize on the exported array's base object. Dispose the NDArray, drop the PyObject
wrapper, run the GC — Python still reads and writes valid memory, and the buffer is freed when the
last Python-side view dies:
after nd.Dispose() + GC: a = [0. 1. 2. 3.] LiveExports = 1
a[0] = 7.0 a = [7. 1. 2. 3.] (still writable)
del a, mv; gc.collect() LiveExports = 0
See here Lifetime_ExportOutlivesItsSource
Can my NDArray outlive Python's object?
Yes — the lease holds the exporter alive. An imported view keeps a Py_buffer lock (or, for
strided numpy sources, a strong reference) on the exporter; Python can del every name it has. The
lease is released when the last NumSharp view over the memory — including derived slices — is
disposed or collected. The refcount decides, not disposal order.
Both directions are observable, which is what makes leak tests possible:
int pinned = NDArrayPythonInterop.LiveExports; // NumSharp buffers held by Python
int leased = NDArrayPythonInterop.LiveImports; // Python buffers held by NumSharp
See here Lifetime_LeaseIsHeldByTheLastView, Lifetime_CountersTrackBothDirections
What does a live view forbid?
Reallocation, on both sides. While NumSharp leases a bytearray, ba.append(1) raises
BufferError: Existing exports of data: object cannot be re-sized; numpy's
resize(refcheck=True) refuses the same way. Mirror image: while Python holds a view of a NumSharp
array, nd.resize(...) raises NumPy's own wording — cannot resize an array that references or is referenced by another array in this way. Dispose the view and both locks lift.
See here Lifetime_ALiveConversionLocksResizing_BothSides
What happens at engine shutdown?
Imports are drained crash-free; orphaned exports are swept right after the engine dies. Import
views are tied to the interpreter that owns their memory: after PythonEngine.Shutdown() they must
not be touched, though disposing them stays safe — the shutdown handler releases every outstanding
lease first. Exports still referenced by Python cannot release through weakref.finalize (pythonnet
runs no Python atexit pass), so the package snapshots them and drops their pins as soon as the
engine has provably finished dying.
One pythonnet caveat on the way out: on .NET 8+ its shutdown crashes in its own state stashing
(BinaryFormatter was removed from the runtime). Opt out before calling it:
RuntimeData.FormatterType = typeof(NoopFormatter); // pythonnet's own opt-out
PythonEngine.Shutdown();
See here OrphanExport_HeldOnlyByPython_AwaitsTheShutdownSweep, OrphanImport_StillReferencedByCSharp_AwaitsTheShutdownDrain
The codec: conversions without calls
Can the conversions happen automatically?
Yes — register once, then every pythonnet boundary converts by itself. RegisterCodec() hooks
NumSharp into pythonnet's conversion pipeline: scope.Set("x", nd) encodes an NDArray as a
zero-copy numpy view, passing one to a Python callable does the same, and pyObj.As<NDArray>()
decodes any buffer exporter on the way back:
NDArrayPythonInterop.RegisterCodec(); // once per engine session; idempotent
var nd = np.arange(4).astype(NPTypeCode.Double);
using (Py.GIL())
{
Scope.Set("c", nd); // auto-encoded: a shared numpy view
Scope.Exec("c[1] = 88.5"); // ...reaches nd
using PyObject r = Scope.Eval("np.sqrt(c)");
using NDArray back = r.As<NDArray>(); // auto-decoded: a shared view of r
}
type(c).__name__ ndarray c.flags['OWNDATA'] False
after c[1] = 88.5 -> nd = [ 0. 88.5 2. 3. ]
As<NDArray>(np.sqrt(c)) Double, writeable view:
[0. 9.40744386 1.41421356 1.73205081]
See here Codec_RegisterOnce_ThenSetAndAsJustWork
An implicit encode does not linger: pythonnet takes its own reference from the wrapper an encoder
returns and never disposes that wrapper, which would keep every scope.Set("x", nd) / py.x = nd /
numpy.sum(nd) export pinned until the CLR finalizer ran. The codec hands its wrapper to a per-thread
slot that is disposed at the next encode on that thread and at the next conversion verb, so rebinding
a name in a loop keeps exactly one export live. (A dynamic read of a numpy array is an ordinary
pythonnet wrapper and lives until the GC; where a reference count matters, read the object as
using PyObject or read a scalar through scope.Eval(...).)
See here RebindingANameThroughTheCodec_KeepsTheLiveExportCountBounded
When must I register it?
Before the first As<NDArray>() anywhere in the process — this is the one ordering trap.
pythonnet caches the decoder lookup per (Python type, target type) pair, and it caches misses. A
decode attempted before registration fails — and keeps failing for that Python type for the rest of
the engine session, even after you register:
1. BEFORE RegisterCodec: As<NDArray>(ndarray) -> InvalidCastException:
'numpy.ndarray' value cannot be converted to NumSharp.NDArray
2. RegisterCodec() -> True
3. AFTER RegisterCodec: As<NDArray>(ndarray) -> still the same InvalidCastException
4. AFTER RegisterCodec: As<NDArray>(bytes) -> OK Byte (pair never tried before)
Register at startup, right after PythonEngine.Initialize(). Registration is per engine session —
pythonnet clears all codecs during Shutdown, and RegisterCodec() knows to re-register after a
later Initialize(). The explicit verbs (ToNumpy, AsNDArray, …) never involve the codec and
work regardless.
See here Codec_RegisterBeforeFirstConversion_OrThePairIsPoisoned
Which mode should I pick?
Auto unless you have a reason: it shares when a view is possible and copies only when one is
impossible. NumpyCodecOptions sets one of three modes per direction:
| Mode | On decode | On encode |
|---|---|---|
Auto (default) |
view when representable, else copy (complex64 widens, UCS-4 narrows, sub-element strides linearize) | always a view |
View |
view or decline — never a silent copy | view |
Copy |
always an independent snapshot; never locks the source | always a copy |
Read-only sources decode as non-writeable views under Auto/View. Decimal has no numpy dtype,
so encoding converts it to a float64 numpy array (the astype(NPTypeCode.Double) guidance,
automated — lossy beyond ~16 significant digits) rather than refusing, in every mode:
scope.Set("d", decimalArray) -> d.dtype == float64
View-mode TryDecode: complex64 -> False (declined), float64 -> True (view)
See here Codec_Modes_AutoViewCopy_AndTheDecimalConversion
Do C# tuples cross as Python tuples?
Yes — RegisterCodec() also registers TupleCodec, because pythonnet has no tuple conversion of
its own. Without it a shape written the numpy way through dynamic, numpy.zeros((2, 3)), reaches
numpy as an opaque wrapped System.ValueTuple ("expected a sequence of integers or a single integer"),
and (long, long) shape = a.shape cannot convert. With it every place a tuple is idiomatic Python reads
the same in C#:
using (Py.GIL())
{
dynamic numpy = Py.Import("numpy");
dynamic z = numpy.zeros((2, 3)); // a C# tuple is the shape
(long, long) strides = z.strides; // a Python tuple decodes into a C# one: (24, 8)
double corner = z.item((1, 2)); // a tuple multi-index
dynamic b = numpy.broadcast_to(nd, (2, 4)); // an NDArray and a tuple in one call
}
Any arity encodes (8+ elements ride the Rest slot), nested tuples and mixed element types too, and
every element crosses through the registered codecs — an NDArray element becomes a numpy view, null
becomes None. Decoding accepts a Python tuple or subclass (a namedtuple, torch.Size) into a C#
tuple of the same arity; a length mismatch or a list is declined, never truncated. Opt out with
NumpyCodecOptions.ConvertTuples = false.
See here Encode_ValueTupleShape_ReachesNumpyAsATuple, Decode_ShapeAndStrides_IntoValueTuples_ThroughDynamic
The GIL
Every conversion verb acquires the GIL itself, re-entrantly — nesting inside your own
Py.GIL() block is fine, and calling from a thread that never touched Python is fine. What you
still own is your PyObjects: create and dispose them inside a Py.GIL() scope, because
pythonnet's final decref needs it.
For a hot loop, lift the per-call acquisition:
using (Py.GIL()) // ONE acquisition...
foreach (var batch in batches)
using (PyObject p = batch.ToNumpy(requireGIL: false)) // ...N conversions inside
consumer.Invoke(p);
requireGIL: false means you hold the GIL — converting without actually holding it is an
immediate access violation, like any raw C-API misuse. The process-wide default is
NDArrayPythonInterop.RequireGIL (true); the per-call parameter overrides it, and null follows
it.
The trap. A .NET method or delegate invoked from Python does not hold the GIL — pythonnet's binder releases it around managed bodies. Inside a Python → .NET callback, leave GIL management on.
The package's background machinery (deferred lease disposal, the shutdown drain) always manages the GIL itself, so the opt-out never applies to it.
See here Gil_OneAcquisitionManyConversions, PythonToNetCallbackBody_DoesNotHoldTheGil_SoTheOptOutIsWrongThere
Dtypes
Fourteen of NumSharp's fifteen dtypes cross, and the maps are public
(ToNumpyDtypeStr / FromNumpyDtypeStr / ToBufferFormat / FromBufferFormat):
| NumSharp | numpy | PEP 3118 | numpy calls it |
|---|---|---|---|
Boolean |
\|b1 |
? |
bool |
Byte / SByte |
\|u1 / \|i1 |
B / b |
uint8 / int8 |
Int16 / UInt16 |
<i2 / <u2 |
h / H |
int16 / uint16 |
Int32 / UInt32 |
<i4 / <u4 |
i / I |
int32 / uint32 |
Int64 / UInt64 |
<i8 / <u8 |
q / Q |
int64 / uint64 |
Half / Single / Double |
<f2 / <f4 / <f8 |
e / f / d |
float16 / float32 / float64 |
Complex |
<c16 |
Zd |
complex128 |
Char |
<u2 |
H |
uint16 — a C# char is a UTF-16 code unit; numpy has no char dtype |
Decimal |
throws | throws | 16 bytes, non-IEEE; the maps refuse honestly, but ToNumpy/ToNumpyCopy auto-convert it to float64 |
Four element types deserve their fine print, all four handled by conversion rather than misrepresentation:
- complex64 (
c8/Zf) — two 4-byte floats whereComplexis two 8-byte doubles. No view is possible;ToNDArraywidens each pair during the copy. - UCS-4 text (
<U1, 4-bytewchar_t) — a 4-byte code point whereCharis a 2-byte UTF-16 unit.ToNDArraynarrows on copy; non-BMP code points throw (they need a surrogate pair). A 2-bytewchar_tbuffer (array.array('u')on Windows) is UTF-16 and views zero-copy asChar. - big-endian (
>i4,!H, …) — no zero-copy view for multi-byte types (a native read would byte-swap every value), so the view path refuses it with the byte-swap fix in the message. ButToNDArraybyte-reverses each element during the copy — complex128 reverses each 8-byte half, complex64 reverses then widens — so big-endian data crosses value-correct as a copy. Single-byte dtypes (>i1,\|u1,\|b1) view zero-copy regardless — byte order is meaningless at one byte. - long double (
g) — MSVC's 8-byte long double is IEEE double and views asDouble; the extended-precision widths have no NumSharp dtype and throw withastype(np.float64)guidance.
See here Dtypes_EveryRowRoundTrips
Versions
Which pythonnet do I need for my Python?
The package floor (3.0.5) covers Python 3.7–3.13; Python 3.14 needs pythonnet 3.1.0. Each
pythonnet release hard-caps the newest Python it can drive. The mapping below is read out of each
release's own PythonEngine.MaxSupportedVersion — and the package checks it once per session,
turning pythonnet's opaque symbol-load failures into an actionable error naming the version to
install:
| Your Python | Minimum pythonnet |
|---|---|
| 3.7 – 3.10 | 3.0.0 |
| 3.11 | 3.0.1 |
| 3.12 | 3.0.3 |
| 3.13 | 3.0.5 |
| 3.14 | 3.1.0 |
The floor is 3.0.5 because its range (3.7–3.13) is a strict superset of every earlier 3.0.x — the
older floors silently shipped defaults that could not run current Pythons. The 4.0.0 upper bound
keeps a future breaking pythonnet from resolving into your build unasked; the source compiles clean
against all of 3.0.0–3.1.0, so pinning 3.1.0 yourself is supported:
<PackageReference Include="pythonnet" Version="3.1.0" /> <!-- only for Python 3.14 -->
One version-specific repair worth knowing exists but not relying on: pythonnet 3.0.1's PyBuffer
is broken for shape/strides/format flags, so the package reads all buffer metadata through
Python's own memoryview and uses PyBuffer only with the crash-free flags — which is why every
import route works uniformly across pythonnet 3.0.x.
See here Versions_TableIsTheGuardsOwnMapping
Troubleshooting
| Symptom | Cause & fix |
|---|---|
Python engine is not initialized |
Set Runtime.PythonDLL (or PYTHONNET_PYDLL) and call PythonEngine.Initialize() before any conversion |
Python 3.x is not supported by the loaded pythonnet |
Your pythonnet caps out below your Python. Install the version the message names (table above) |
'numpy.ndarray' value cannot be converted to NumSharp.NDArray — even after RegisterCodec() |
A decode ran before registration and pythonnet cached the miss for that Python type. Register at startup, before the first As<NDArray>() |
the exporter's buffer is read-only |
You asked for a writable view of bytes or a writeable=False array. Pass allowReadonly: true, or ToNDArray() to copy |
assignment destination is read-only |
You wrote through a non-writeable view — a read-only or broadcast source. Copy first if you need to mutate |
Existing exports of data: object cannot be re-sized |
A live NumSharp view leases that object. Dispose() the view to release the lock |
cannot resize an array that references or is referenced by another array in this way |
A live Python export (or another NumSharp view) pins the buffer. Release it, or copy first |
cannot resize this array: it does not own its data |
Import views never own their memory. np.require(view, null, "O") for an owning copy |
big-endian dtype '>f8' cannot be shared with a native-endian NumSharp buffer |
Only the zero-copy view refuses big-endian. ToNDArray() copies it and byte-reverses each value; or byte-swap on the Python side first: arr.astype(arr.dtype.newbyteorder('<')) |
decimal has no numpy dtype (16-byte, non-IEEE) |
Nothing in numpy describes it. nd.astype(NPTypeCode.Double) first |
the object does not export a PEP 3118 buffer |
Not every object is an exporter — a dict, a PIL Image. np.asarray(obj) first if numpy understands it |
| A Python write did not show up in NumSharp | You copied. ToNumpy/AsNDArray share; ToNumpyCopy/ToNDArray do not |
| Access violation around a conversion | requireGIL: false on a thread that does not hold the GIL — including inside a Python → .NET callback, where pythonnet releases it |
PythonEngine.Shutdown() crashes on .NET 8+ |
pythonnet's state stashing uses the removed BinaryFormatter. Set RuntimeData.FormatterType = typeof(NoopFormatter) first |
Claims ledger
| # | Claim | Evidence | Gate |
|---|---|---|---|
| 1 | After BeginAllowThreads, a fresh thread converts |
conversion succeeds on a thread that never touched Python | Bootstrap_AFreshThreadConverts |
| 2 | Export is a shared view; Python writes reach NumSharp | x[1,2] = 99 → nd[1,2] == 99 |
QuickStart_OneBufferBothSides |
| 3 | View shares later writes; copy never does — both directions | write visibility + shares_memory == False |
Verbs_ViewSharesAndCopyDetaches_BothDirections |
| 4 | ToNumpy(copy: true) routes to the copy |
no write-through, no shared memory | Verbs_CopyTrueRoutesToTheCopy |
| 5 | To… copies, As… shares, incl. read-only opt-in |
write-through matrix over the aliases | Verbs_ToCopiesAndAsShares |
| 6 | View verbs are flat in n; copy verbs are linear | 10⁴× size step moves copy time >50×, view time <5× | Costs_ViewVerbsAreFlat_CopyVerbsScale |
| 7 | Every export layout keeps its strides and flags | strides/flags per table row; shares_memory == True |
Export_TableRows_KeepStridesAndFlags |
| 8 | Exports chain to a ctypes base; strided exports via as_strided |
base types c_char_Array_N / DummyArray; OWNDATA=False |
Export_BaseChain_EndsAtTheCtypesWindow |
| 9 | All three import routes produce writable views | a write crosses back on each route | Import_ThreeRoutes_EachYieldsAView |
| 10 | 47 of 50 exporter varieties view; 2 copy; 1 rejected | census constants, re-measured each run | Import_Census_47Of50VarietiesView |
| 11 | Read-only sources: refused by default, non-writeable on opt-in | verbatim refusal; IsWriteable == false |
Import_ReadonlyRefusedByDefault_OptInIsNonWriteable |
| 12 | Import views own nothing; require("O") detaches |
resize refusal text; detached write | ImportView_OwnsNothing_ResizeRefusesAndRequireOCopies |
| 13 | An export outlives every managed reference | Dispose() + GC, then Python reads and writes |
Lifetime_ExportOutlivesItsSource |
| 14 | The import lease is held by the last view, incl. derived slices | counter stays up until the derived slice dies | Lifetime_LeaseIsHeldByTheLastView |
| 15 | LiveExports/LiveImports track conversions exactly |
counters move by exactly one per conversion | Lifetime_CountersTrackBothDirections |
| 16 | A live view blocks reallocation on both sides | BufferError + both numpy-worded resize refusals |
Lifetime_ALiveConversionLocksResizing_BothSides |
| 17 | Shutdown drains imports and sweeps orphaned exports | post-shutdown counters at zero, no crash | OrphanExport_HeldOnlyByPython_AwaitsTheShutdownSweep, OrphanImport_StillReferencedByCSharp_AwaitsTheShutdownDrain |
| 18 | The registered codec converts at every boundary, as views | type(c) == ndarray; writes cross both ways |
Codec_RegisterOnce_ThenSetAndAsJustWork |
| 19 | A pre-registration decode poisons that type pair for the session | verbatim InvalidCastException; fresh pairs still work |
Codec_RegisterBeforeFirstConversion_OrThePairIsPoisoned |
| 20 | Modes: Auto falls back, View declines, Copy detaches; Decimal → float64 |
TryDecode outcomes; d.dtype == float64 |
Codec_Modes_AutoViewCopy_AndTheDecimalConversion |
| 21 | One GIL acquisition can host many conversions | hot loop under a single Py.GIL() |
Gil_OneAcquisitionManyConversions |
| 22 | Python → .NET callback bodies do not hold the GIL | PyGILState_Check() == 0 inside the body |
PythonToNetCallbackBody_DoesNotHoldTheGil_SoTheOptOutIsWrongThere |
| 23 | Every dtype row maps as printed; the four specials convert or refuse | round-trips + verbatim errors | Dtypes_EveryRowRoundTrips |
| 24 | The version table is the guard's own mapping | table compared against MinimumPythonnetFor |
Versions_TableIsTheGuardsOwnMapping |
| 25 | Every quoted Troubleshooting symptom is the message actually raised | verbatim assertion per row | Troubleshooting_SymptomsAreVerbatim |
| 26 | C# tuples cross as Python tuples and back, through the registered codec | numpy.zeros((2, 3)) takes the shape; (long, long) = a.shape decodes |
Encode_ValueTupleShape_ReachesNumpyAsATuple, Decode_ShapeAndStrides_IntoValueTuples_ThroughDynamic |
| 27 | An implicit encode's wrapper is released at the next encode / conversion, so a rebinding loop keeps one export live | 150 rebindings peak ≤ 2 above baseline; sys.getrefcount shows no lingering wrapper |
RebindingANameThroughTheCodec_KeepsTheLiveExportCountBounded, TheHandedOffWrapperIsRedundant_PythonnetHoldsItsOwnReference |
See also
- The examples
— twelve single-file
dotnet runtutorials (bootstrap, verbs, layouts, dtypes, buffers, lifetime, codec, GIL, PyTorch, Pandas, a custom adapter, application scenarios), written for reading, with adynamicPython namespace and oneNDScopeper script; every script has an asserted twin undertest/NumSharp.Tests.Interop/Examples - Any library via np.frombuffer — reaching consumers that want bytes, not numpy
arrays:
ToMemoryView+ the buffer protocol, torch/Pillow/Arrow/OpenCV verified - Numpy.NET — driving these same buffers through SciSharp's
Numpy.BareC# API - Interoperability — the contract underneath every NumSharp bridge