ONNX Runtime - feed a model from NumSharp memory, read it back into NumSharp
NumSharp.Interop.OnnxRuntime connects NDArray to ONNX Runtime's C# API
without copying: an array becomes an OrtValue tensor over the very same unmanaged bytes and goes
straight into InferenceSession.Run; the outputs come back as NDArrays - an owning copy by default, or
a zero-copy view that owns ORT's buffer - ready for np.argmax, Postprocess.Softmax, TopK, np.clip
and everything else NumSharp does. The whole pre-processing loop every ORT tutorial hand-writes
(processedImage[0, c, y, x] = (pixel / 255f - mean) / std) and the whole post-processing loop
(Math.Exp / Sum / OrderByDescending().Take(k)) become vectorized NumSharp ops. This page is the
package's reference: setup, the verbs and their modes, the ergonomic Run, asynchronous runs, text, what
crosses zero-copy and what cannot, who frees what, dtypes and versions.
On this page: From zero to an inference ·
Views, copies and modes · The verbs ·
session.Run(NDArray) · Asynchronous runs ·
Text · Zero-copy: the rules · Lifetime ·
Post-processing · Dtypes · Versions · Limits ·
Claims
Verified on ONNX Runtime 1.16.0 (the floor) and 1.31.1 · net8.0/net10.0 · Windows, Linux, macOS. Every claim below is reproduced by a test in
NumSharp.Tests.Interop.OnnxRuntime, over committed.onnxmodels that ORT executes; no Python at test time.
From zero to an inference
dotnet add package NumSharp.Interop.OnnxRuntime
dotnet add package Microsoft.ML.OnnxRuntime # the native runtime: CPU here; or .Gpu / .DirectML / .QNN
The interop depends only on Microsoft.ML.OnnxRuntime.Managed - the C# API. ORT's native runtime ships
as mutually exclusive flavours, and you must reference exactly one to run any model at all, so the
package leaves that choice to you and composes with whichever flavour you pick.
On Windows, forgetting the native package does not fail at once: the loader then finds the
onnxruntime.dll that Windows itself ships in C:\Windows\System32 for Windows ML (version 1.17 on a
current Windows 11), and the app runs on that build - a different one from the version you reference,
whatever the machine has, and absent on Linux and macOS, where the same app throws
DllNotFoundException. Reference the native package explicitly.
F# interactive (dotnet fsi with #r "nuget: Microsoft.ML.OnnxRuntime, 1.31.1") finds that System32 copy even
WITH the native package referenced, and the first call fails: The requested API version [31] is not available, only API versions [1, 17] are supported in this build. Current ORT Version is: 1.17.1 (a
TypeInitializationException of ORT's NativeMethods). Load the package's own native library by its path before
the first ORT call, and the session binds to it:
let packages =
match System.Environment.GetEnvironmentVariable "NUGET_PACKAGES" with
| null | "" -> System.IO.Path.Combine(System.Environment.GetFolderPath System.Environment.SpecialFolder.UserProfile, ".nuget", "packages")
| folder -> folder
let nativeDll =
System.IO.Path.Combine(packages, "microsoft.ml.onnxruntime", "1.31.1", "runtimes", "win-x64", "native", "onnxruntime.dll")
System.Runtime.InteropServices.NativeLibrary.Load nativeDll |> ignore
using NumSharp;
using NumSharp.Interop.OnnxRuntime;
using Microsoft.ML.OnnxRuntime;
using var session = new InferenceSession("resnet50-v2-7.onnx");
// ImageNet's normalization, as float32: a float64 mean would make the input float64, which the
// model's float32 input refuses under the default casting="safe" (Python's onnxruntime refuses it too)
NDArray mean = np.array(new[] { 0.485f, 0.456f, 0.406f });
NDArray std = np.array(new[] { 0.229f, 0.224f, 0.225f });
// preprocessing as array ops: (H,W,3) uint8 -> (1,3,224,224) float32, normalized
// (pixels: the decoded image's 224*224*3 RGB bytes, row by row)
NDArray rgb = np.frombuffer(pixels, NPTypeCode.Byte).reshape(224, 224, 3);
NDArray chw = np.transpose(rgb, [2, 0, 1]).astype(NPTypeCode.Single) / 255f;
NDArray input = ((chw - mean.reshape(3, 1, 1)) / std.reshape(3, 1, 1)).reshape(1, 3, 224, 224);
using NDArray logits = session.Run(input); // zero-copy in (C-contiguous f32), owning NDArray out
using NDArray probs = Postprocess.Softmax(logits); // (1,1000), rows sum to 1
var (top5, classes) = Postprocess.TopK(probs, 5); // float32 values + int64 class ids, descending
One Run call: the input is handed to ORT as an OrtValue over NumSharp's buffer, the output tensor is
copied into an owning NDArray, and every ORT object created on the way is disposed before the call
returns.
Views, copies and OnnxCodecMode
An ORT tensor is a pointer, a row-major shape and a fixed-size element type - no strides. ORT can read
a NumSharp buffer in place only when the array is C-contiguous and its dtype crosses bit for bit. Every
entry point that could share memory takes an OnnxCodecMode (the member names and meanings of the
pythonnet bridge's NumpyCodecMode):
| Mode | What it does |
|---|---|
Auto (default) |
A zero-copy view when the dtype and the layout permit it, an independent copy otherwise. It never fails because of a layout or a convertible dtype. |
View |
Always a view. A crossing that needs a copy throws InvalidOperationException and names what prevents the view. |
Copy |
Always an independent copy. |
A copy is needed for a Fortran-ordered, stepped, reversed, transposed or broadcast view, for a Decimal
array (a float64 conversion) and for text. No mode repairs a dtype with no ONNX element type (Complex), a
released buffer, or a size the target cannot hold. Writes by ORT land in the memory it was given: with a
view that is your array, with a copy it is a temporary. handle.IsView says which.
The verbs
Everything is packaging over a few operations on the static NDArrayOnnxInterop class (also extension
methods). As… gives a view when it can, To… always copies.
| Direction | Verb | Semantics |
|---|---|---|
| NumSharp → ORT | nd.AsOrtValue(mode) → OrtTensor |
an OrtValue over the array's buffer when a view is possible, else (under Auto) over a temporary copy the handle owns; IsView says which; the handle owns the OrtValue and an ARC reference on the buffer - keep it alive across Run |
| NumSharp → ORT | nd.ToOrtValue() → OrtValue |
ORT-allocated copy; any layout, read in logical order; no lifetime coupling |
| NumSharp → ORT | nd.AsDenseTensor<T>(mode) → OrtTensor<T> · nd.ToDenseTensor<T>() |
the legacy DenseTensor<T> twins (for NamedOnnxValue.CreateFromTensor); always row-major, so a Fortran-ordered array is a copy under Auto |
| ORT → NumSharp | ortValue.ToNDArray() |
fresh owning C-contiguous copy - the safe default; a string tensor becomes a text array |
| ORT → NumSharp | ortValue.AsNDArray(ownsValue: false, mode) |
zero-copy view over the tensor's buffer (CPU tensors only); ownsValue: true → the last view disposes the OrtValue; mode: Copy → an independent copy |
| ORT → NumSharp | denseTensor.ToNDArray() · tensor.ToNDArray() · denseTensor.AsNDArray(mode) · namedOnnxValue.ToNDArray() |
the legacy-surface twins; column-major tensors transpose back on copy and stay Fortran-ordered as a view. A legacy session.Run result reads back with result.ToNDArray() on the DisposableNamedOnnxValue itself, or as the tutorials write it: Tensor<float> output = result.AsTensor<float>(); output.ToNDArray() (a copy; a Tensor<T> that is not a DenseTensor<T> is densified first). The zero-copy AsNDArray takes a DenseTensor<T>: cast, ((DenseTensor<float>)result.AsTensor<float>()).AsNDArray() |
| ORT → NumSharp | ortValue.ToNDArrays() · ToMap() · ToMaps() · GetMapKeys() · ReadStringTensor() |
non-tensor outputs: a sequence of tensors, a map, a ZipMap sequence of maps, a string tensor as string[]; each numeric array is a view that owns ORT's copy of its element |
Plus the dtype maps (ToTensorElementType, FromTensorElementType, Try…, ToTensorElementClrType),
IsCpuAccessible, and the counters NDArrayOnnxInterop.LiveExports / LiveImports, which count live
handles and leases so a leak is observable.
using OrtTensor t = input.AsOrtValue(OnnxCodecMode.View); // ZERO COPY, or it throws
using var results = session.Run(runOptions, new[] { "data" }, new[] { t.Value }, session.OutputNames);
NDArray copy = results[0].ToNDArray(); // owning; dispose `results` freely
NDArray view = results[0].AsNDArray(); // shares ORT's buffer while `results` lives
session.Run(NDArray) - the ergonomic tier
InferenceSessionExtensions turns a session into a function on arrays and hides every OrtValue:
NDArray y = session.Run(x); // one input, one output: names from metadata
NDArray y2 = session.Run("X", x, "Y2"); // named
var outs = session.Run(new Dictionary<string, NDArray> { { "A", a }, { "B", b } }); // -> name -> NDArray
var views = session.Run(inputs, zeroCopyOutputs: true); // views that OWN their OrtValue
session.RunInto(inputs, new Dictionary<string, NDArray> { { "Y", y } }); // ORT writes INTO y
What each call does for you, from the session's metadata:
- Names default when the model has one input and one output; otherwise name them. A name is looked up
among the model's inputs, then among its overridable initializers. An
optional(tensor)input takes a plain array. - The feed and the fetch are checked before the run. Every input that is not
optionalmust be fed (Python onnxruntime's rule and message:required inputs ([token_type_ids]) are missing from the input feed ([input_ids, attention_mask])); ONNX Runtime's C# API would run the graph and fail at the first node that reads the missing input. An output name the model lacks is refused with the list of its outputs, and an outputRuncannot return - a sequence, a map, a sparse tensor - is refused with the readers that take it (ToNDArrays,ToMap,ToMapsover ORT's ownRun), so a scikit-learn classifier's default fetch fails before the model runs, not after. - Dtype coercion, NumPy's way. An input whose dtype differs from the model's declaration is cast to it
when
casting(default"safe") allows - int32 → int64 (the BERTinput_idscase), float32 → float64, bool → anything. A lossy cast (float64 → float16) is refused with the exact rule to relax:input 'X' holds Double but the model declares Half (Float16); the cast is not allowed under casting='safe'. Convert explicitly - nd.astype(NPTypeCode.Half) - or relax the rule (casting: "same_kind" / "unsafe"). The table decides every pair: aDecimalarray into a float64 input needs"same_kind",Charinto a UInt16 input is safe and stays zero-copy. The rule name is checked on every call. - Shape validation before ORT. Declared rank and fixed extents are checked, symbolic dims
(
'batch','seq') are free, and the error names the input and the offending dimension. - The input mode.
Autoshares an input that is C-contiguous and of the declared dtype and feeds a temporary for any other;Viewthrows for an input that would need one;Copyfeeds a copy of every input. Every temporary is released with the call. - Outputs: copies by default;
zeroCopyOutputs: truereturns views that own theirOrtValue(ORT's memory is freed when the array - or its last derived slice - dies). In a loop, dispose the slices too:using NDArray last = outs["logits"]["0, -1"];- an undisposed slice keeps the WHOLE output's buffer until a collection (a 12-step GPT-2 decode loop left 7 outputs alive that way). Fed back as the next step's inputs (a KV cache's presents), zero-copy outputs are read in place. RunIntowrites into arrays you allocated (C-contiguous, writeable, declared dtype), zero-copy in both directions - the pattern for a denoising loop that reuses its buffers. ORT may write an output while it still reads an input: an output that shares memory with an input is computed from a copy of that input underAuto(NumPy's rule forout=) and refused underView; two outputs that overlap are always refused.- An output that is a fed input. ONNX permits a model output that no node produces: a model input
passed through, or an overridable initializer you feed. ORT does not write a pre-allocated array for it,
and the value it returns lies in the fed memory.
RunIntowrites the fed values into your array, the copy form copies them, and the zero-copy form returns a counted view of the fed buffer.
A model exported by ML.NET. mlContext.Model.ConvertToOnnx(model, data, stream) declares every column
of the training data as a model input - the label too, which scoring never reads - and every column as an
output (Score.output, PredictedLabel.output, ...). Feed each declared input; a label you do not have is
fed as zeros of its shape, and you fetch the outputs you need by name:
var feed = new Dictionary<string, NDArray>
{
["f0"] = x[":, 0:1"], ["f1"] = x[":, 1:2"], ["f2"] = x[":, 2:3"], ["f3"] = x[":, 3:4"], // (R, 1) each
["Label"] = np.zeros(new Shape(x.shape[0], 1), np.float32), // declared, unread
};
using NDArray score = session.Run(feed, new[] { "Score.output" })["Score.output"]; // (R, 1)
Left out, the label is not missed until ONNX Runtime runs the graph, and its error names a node of the
exported graph, not the input (Non-zero status code returned while running Identity node ... Trying to get a TensorSeq, but got: (null)).
Golden files from Python. A port is checked against the Python it replaces: save the inputs and Python
onnxruntime's outputs by name (np.savez("inputs.npz", **inputs), np.savez("outputs.npz", **dict(zip(names, sess.run(None, inputs))))). np.load_npz returns an NpzFile, an
IReadOnlyDictionary<string, NDArray> keyed by those names, so the inputs file feeds Run as it is - the
twin of sess.run(None, dict(np.load("inputs.npz"))):
using NpzFile inputs = np.load_npz("inputs.npz"); // NumSharp.IO
using NpzFile expected = np.load_npz("outputs.npz");
foreach (var (name, y) in session.Run(inputs))
using (y) Console.WriteLine($"{name}: {np.array_equal(y, expected[name])}");
Text crosses as U arrays both ways, so a model with string inputs or outputs checks the same way; save
Python's string outputs (object arrays) as str first, because an object array in an .npz needs pickle.
Asynchronous runs
await session.RunIntoAsync(inputs, outputs); // ORT writes into your arrays; no thread waits
IReadOnlyDictionary<string, NDArray> outs = await session.RunAsync(inputs); // outputs allocated for you
Both build on ORT's own native asynchronous run: the model executes on the session's intra-op thread pool and the task completes from a native callback.
- ONNX Runtime 1.30.0 or newer. Older managed assemblies do not pin the arrays the native run keeps
reading (microsoft/onnxruntime#32015). Both forms throw
NotSupportedExceptionthere;InferenceSessionExtensions.IsRunAsyncSupportedtells which applies. - 2 or more intra-op threads (
SessionOptions.IntraOpNumThreadsis not 1). With one, ORT refuses and the task is faulted. RunAsyncallocates its outputs before the run, so it needs their shape then: a numeric tensor with a fixed declared shape, an output that is a fed input, or a run-time dimension an input NAMES - the'N'/'batch_size'/'sequence_length'of the zoo's and Hugging Face's exports takes the fed array's extent, as ONNX gives one symbolic name one extent (ResNet50 v2, MobileNetV2 and MiniLM run this way, each equal toRun). An unnamed run-time dimension (scikit-learn's exports) or one no input names, a declaration without dimensions (a scalar or an unknown rank: ORT's metadata does not tell which), a string tensor and a non-tensor output are refused with what to do instead.RunIntoAsyncworks for every model whose outputs you can allocate.- Errors. A usage error throws from the call. A failure inside ORT faults the task. To cancel, pass a
RunOptionsand setTerminate = true. - Where your code continues. ORT completes its own task on an intra-op thread of the session, and code
that continues there and disposes the session never returns (measured). The bridge's task runs your
continuation off that thread. ORT also awaits without
ConfigureAwait(false), so its own task waits for the caller's synchronization context: a thread that blocked on it waited forever. The bridge hides the context from ORT, so a blocking wait is released. - In flight. Until the task completes, ORT reads the shared inputs and writes the outputs. Do not write
an input (pass
OnnxCodecMode.Copyto feed a snapshot). To dispose an array in flight is safe: the run holds a reference on every buffer. - An
NDScopedoes not cross theawait. A scope belongs to the thread that opened it, and the run's continuation resumes on another thread. Ausing var scope = NDScope.Open();held acrossawait session.RunAsync(...)reaches its end on the continuation's thread, and NumSharp refuses to close it there:DisposethrowsInvalidOperationException, and the scope's arrays are left to the finalizer. Closing it on that thread would free arrays the opening thread still uses: the opening thread keeps the scope as its current one, and the continuation's thread would reuse it for its next scope, whose close then disposes the opening thread's later arrays (an array filled with 7.0 would read another array's 9.0). Scope the synchronous code before theawaitand after it, never across it, or mark the async method[NDScopedAsync], which carries one scope across awaits. RunAsyncand anNDScope. It allocates its outputs when the call starts, so a scope open then tracks them. Hand the task out withscope.ReturnsTask(task), closing the scope withNDScope.CloseUnlessDeferred(scope)(the form[NDScopedAsync]emits): the result dictionary carries its outputs, so the scope yields them to you when the task completes. A plainusingcloses the scope at once and disposes them, and the task then returns arrays over freed memory. Under the synchronousRun, yield each output you keep withscope.Returns(outputs[name]).
Text
NumSharp's fixed-width text dtypes (U: UCS-4 code points; S: bytes) cross as ORT string tensors in
both directions: ToOrtValue, AsOrtValue, ToNDArray, AsNDArray, the readers and Run.
- Every crossing is a copy. ORT keeps each string as a separate object in its own memory.
AutoandCopydo the same;Viewthrows. - A
Uelement crosses as its text (ORT stores UTF-8); anSelement crosses as its bytes, unchecked. A string tensor reads back as aUarray as wide as its longest string. - The fixed width has a cost, because the bridge reads string tensors as
U, not as NumSharp's variable-widthStringDType: a string that ends in NUL characters loses them (ReadStringTensorreturns the strings exactly), and one long string widens every element. AStringDTypearray is not a bridge input (Python'sonnxruntimerefuses it too): cast it to a sizedUfirst, wide enough for its longest string (nd.astype(np.dtype("U16")); an unsized"U"target is refused forStringDType, as in NumPy). - The bridge does not cast between text and numbers, under any casting rule. Convert first with NumSharp's
own casts:
nd.astype(np.dtype("U"))writes each number as itsstr(), andnd.astype(np.float32)parses each text as NumPy does. RunIntoandRunAsynccannot take a string output; the synchronousRunreturns it.
Zero-copy: the rules
- C-contiguous arrays share. Scalars (0-d) and empty arrays cross too; a contiguous view at an offset
(
np.split/np.unstackchildren) shares exactly its window. - Anything else is copied under
Autoand refused underView- transposed, stepped, negative-stride, broadcast, Fortran - with the reason in the message. - Outputs are always dense row-major, so
ToNDArray/AsNDArraynever translate strides. - CPU memory only. A tensor resident on CUDA / DirectML / TensorRT is not CPU-addressable: both read
verbs refuse it (
the tensor lives in 'Cuda' memory ... which the CPU cannot address); bind a CPU output or copy to CPU first. Host-accessible device memory (CudaPinned,HipPinned,QnnHtpShared) andCpuAligned4Kare fine: the bridge asks ORT for the device memory kind where the loaded ORT can tell.
Under the hood the export is OrtValue.CreateTensorValueWithData(memInfo, elementType, shape, pointer, bytes) on slice.Address + Shape.Offset × itemsize; the import takes the pointer behind
GetTensorMutableRawData() and wraps it as a NumSharp memory block with a release hook - the same
"wrap foreign memory" primitive the pythonnet bridge leases Python buffers with.
Lifetime: who frees what
- Exports.
AsOrtValue/AsDenseTensor<T>take an atomic reference on the NumSharp buffer, so the memory outlives the source array if the array is disposed or collected while ORT holds the tensor. Disposing the handle disposes theOrtValue, drops the reference and disposes the temporary copy when the handle holds one. While exported,ndarray.resize(refcheck: true)refuses - NumPy's rule for a referenced buffer - and works again once the handle is gone. ToOrtValuereturns ORT's own value: dispose it. Its copy lives in memory ORT allocated and ORT frees, so the value stays valid for every native holder - anOrtIoBindingit was bound to, a run's feeds - after the managedOrtValueis disposed or collected. NumSharp books the copy's bytes with its own memory accounting until ONNX Runtime releases it - at yourDispose, or after the last view of it is gone (see Threads and disposal) - or the garbage collector collects it, so a forgotten value is seen like a forgotten NumSharp array: a loop that dropped 5,000 copies of a 602 KB array peaked at about 70 MB more private memory (unbooked, the same loop held 2.9 GB until an unrelated collection ran). A value you dispose never triggers a collection. The booking uses ONNX Runtime's privateOrtValue._memHandleslot (1.16.0 through 1.31.1 have it); on a version without it the value is returned unbooked, and a text array's string tensor is not booked (ORT keeps its strings in allocations of unknown size). The arrays and handles of this package hold up the same way (the loop overRunoutputs, owning views orAsOrtValuehandles stayed within 60 MB), andusingis still the rule.- Reachable means valid. The handle and what it hands out keep each other alive: holding only
handle.Value, or onlyTensor/Memory, keeps the handle and its reference, so a collection never disposes anOrtValueor disables a tensor you still hold, and never letsresizereallocate under it. A forgotten handle is released by a finalizer safety net once nothing reaches any of them; it only gives the reference back (never frees eagerly), so an alias of the buffer you still hold stays valid. - A value of a disposed handle is refused. After
handle.Dispose(), ONNX Runtime has releasedhandle.Value, and ORT does not guard a released value: a native call on one ends the process. Every verb that takes anOrtValuethrowsObjectDisposedExceptionfor such a value instead. The same holds for a value you disposed yourself (aToOrtValueresult, a value of ORT's ownRun) and for a disposedInferenceSession, where everyRunform throwsObjectDisposedExceptioninstead of ending the process: the bridge reads ONNX Runtime's own disposal flag. ADisposeon another thread DURING a verb or a run is covered by the leases: see Threads and disposal. - Pins outlive the handle. Whoever pins an
AsDenseTensor<T>handle'sMemory<T>- ORT'sCreateTensorValueFromMemory, anyMemory<T>.Pin()- takes its own reference on the NumSharp buffer, given back exactly once: when the pin is disposed (two copies of oneMemoryHandlecount once), or when it is collected undisposed. A disposed handle refuses only NEW access: a new span or pin of itsMemory<T>, andToNDArray/AsNDArrayof its tensor, throwObjectDisposedExceptionbefore anything is allocated. - A released source is refused. Every verb that reads an array - the exports, the copies, and
Run's cast and C-order copy of an input - takes its reference on the array's buffer before its first read, and throwsObjectDisposedExceptionif the buffer has already been released. Refusals that need only the dtype and the shape come first, so nothing is converted, copied or allocated for an array that cannot cross. - Imports of a buffer ORT owns.
AsNDArrayleases the buffer through NumSharp's memory-block reference count: the lease fires when the last NumSharp view over the memory - slices derived from it included - is disposed or collected. A non-owning view holds a lease on theOrtValue: you may dispose the value (or theRunresult collection) at any time, on any thread, and its memory stays valid until the last view is gone (see Threads and disposal); a value the lease cannot cover keeps ORT's rule forGetTensorDataAsSpan(do not dispose it while a view is in use). WithownsValue: truethe last view disposes theOrtValue; a call that throws never disposes it. An owning view books the buffer's size with NumSharp's memory accounting (not for aToOrtValueresult, which the value itself has booked already), and so does aDenseTensor<T>view of a managed array, whose pin keeps the array alive until the last view is gone: a forgotten view is then collected like a forgotten NumSharp array (5,000 forgotten views of fresh 602 KB tensors held 1.9-2.4 GB unbooked). A view of a legacy run's output books nothing: its results own it. Views do not own their data (owndata == false): a size-changingresizerefuses instead of detaching. - Imports of NumSharp memory are ordinary NumSharp views. A value this package made
(
nd.AsOrtValue().Value.AsNDArray()), atensor.AsNDArray()of anAsDenseTensor<T>handle, and an output that is a fed input are views of a NumSharp buffer: counted on it like any view, valid after the handle and the source are disposed, and read-only when the exported array was. ORT has no read-only tensors: a read-only array is a legal model input. - The readers return owning views. Each numeric array of
ToNDArrays/ToMap/ToMapsowns ORT's copy of its element and frees it when disposed; the value it was read from may be disposed at once. - A
DenseTensor<T>view roots its tensor. ORT's memory manager for a legacysession.Runoutput (DisposableNamedOnnxValue.AsTensor<T>()) pins nothing, sodenseTensor.AsNDArray()keeps the tensor - and through it theOrtValue- reachable itself. Disposing the results explicitly while a view lives still frees the memory under it - the same rule as a non-owningAsNDArray(OrtValue). - A pass-through output through ORT's own API. When you run the session yourself and feed an
AsOrtValuehandle, the value ORT returns for an output that is a fed input lies in the handle's memory, andAsNDArraycannot know that: the view is valid only while the handle, or the array you fed, is alive. - No GIL, no interpreter, no thread affinity - ORT is thread-safe and the verbs are plain calls. The
one rule is the handle rule: pass
handle.Value, keep the handle (or itsValue) reachable acrossRun, dispose after. ORT's ownRunreads the inputs' native handles and holds no reference to the managed values during the run, so an input referenced only by the argument list can be collected while native code reads it; the same holds for anything that keeps a value past a call (OrtIoBinding,SessionOptions.AddInitializer,OrtValue.CreateSequence).session.Run(NDArray…)does this for you. The full lifetime and garbage-collection analysis:docs/plans/interop-lifetime-gc-analysis.md.
Threads and disposal
ONNX Runtime's own Dispose takes no lock. Two threads disposing one OrtValue or one InferenceSession both
release it (heap corruption), a Dispose during a call releases what the call reads (an access violation), and a view
read after its value's Dispose returns whatever ORT's allocator put there next. The bridge makes its own verbs safe
against all three:
- Values: an ARC on every value the bridge touches. A
ToOrtValueresult, the value of anAsOrtValuehandle and aGetMapKeysresult carry it from birth; a value ORT allocated (an output of ORT's ownRun,CreateAllocatedTensorValue, an element read withGetValue) gets it at its first bridge verb. A verb holds a lease while it calls ORT on the value, a view - and every slice of it - for its whole life.Dispose, on any thread and any number of times at once, waits for the verbs in flight, and then ONNX Runtime releases the value exactly once: at once, or when its last view is gone. A verb that starts after theDisposebegan throwsObjectDisposedException. So a view stays valid after its value'sDispose(its bytes are booked from then on), in both directions: NumSharp viewing ORT's memory, and ORT's value over NumSharp memory, whoseDisposeby the handle and by you at once is still one release. - Sessions:
NDInferenceSession. A sealed drop-in subclass ofInferenceSessionwith ORT's eight constructors: itsDisposereleases the native session exactly once, however many threads call it, and only after everyRun/RunInto/RunAsync/RunIntoAsyncof the bridge on it returned; a run after theDisposebegan throwsObjectDisposedException, andIsDisposedturns true at once. The release after an asynchronous run runs on the .NET thread pool, never on ORT's intra-op thread (where it would never return). A plainInferenceSessioncannot be made safe from outside - itsDisposefrees the name buffers every run reads before anything could hook it - so it keeps ORT's rule: dispose it once, after every run returned. - What stays ORT's rule. ORT's own calls take no lease: its own
Runoverloads (on any session),GetTensorDataAsSpan, anOrtIoBinding. Dispose a value or a session only after those returned. A value over memory ORT does not own (CreateTensorValueFromMemory), a value that does not own its native value, and a value built from members (CreateSequence/CreateMap) keep ORT's rules too. Adoption is a plain store into ORT's value, so aDisposeracing a value's FIRST bridge verb is not covered: give a value that crosses threads its first verb on the thread that made it.
Measured on ORT 1.31.1, 800 races each, before and after: a double Dispose of a value or of a session corrupted
the heap, and a Dispose during ToNDArray, AsNDArray or the bridge's Run was an access violation; 400 of 400
reads of a view after its value's Dispose returned another value's data. With the leases: 0 crashes and 0 wrong
values, one release per value and per session - also with 8 threads disposing one value, 8 disposing one session, and
8 each disposing their own session and a neighbour's. Concurrent USE was safe before and stays safe (two threads
running one session, or reading one value: 0 wrong in 400). A lease costs within noise of the verb (ToNDArray of
16 KB: +17..+58 ns on 1.8 us).
Post-processing
Postprocess is the output half of the tutorials as np compositions - no new kernels, and kept in its
own type so the verbs stay conversion-only:
| Method | Replaces | Contract |
|---|---|---|
Softmax(logits, axis = -1) · LogSoftmax · Sigmoid |
the Math.Exp / Sum / Select loop |
max-shifted (stable for huge logits); float32 in, float32 out; a bool or integer input is promoted to NumPy's float tier first; LogSoftmax follows scipy.special.log_softmax for a non-finite row maximum |
Argmax(scores, axis = -1) |
GetMaxValueIndex |
int64, first occurrence - ONNX ArgMax(select_last_index=0); the axis is checked |
TopK(scores, k, axis = -1, largest = true) → (values, indices) |
OrderByDescending().Take(k) |
ONNX TopK: sorted, ties lower index first; values keep the input dtype |
The tests run ORT's own Softmax / ArgMax / TopK operators on the same data and compare - to float
tolerance for softmax, exactly for the indices.
Dtypes
| NumSharp | ORT | Crossing |
|---|---|---|
| Boolean, Byte, SByte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Single, Double | Bool, UInt8, Int8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float, Double | zero-copy |
| Half | Float16 | zero-copy reinterpret - ORT's Float16 is a blittable 16-bit IEEE struct, the same bits as System.Half; NaN payloads and subnormals cross untouched |
| Char | UInt16 | zero-copy, UTF-16 code units; directional - UInt16 imports as UInt16 |
String (U, S) |
String | always a copy; see Text |
| Decimal | - | no ONNX type: the array verbs convert to Double under Auto and Copy (an owned float64 temporary; lossy past ~16 digits), View refuses; the dtype maps refuse |
| Complex | Complex64/128 are in the enum but no ORT API or kernel takes them | refused; feed np.real / np.imag |
| - | BFloat16 | no NumSharp dtype; refused with the ORT-side alternative named |
Versions
The dependency is Microsoft.ML.OnnxRuntime.Managed 1.16.0 or later, with no upper bound - the form
ONNX Runtime's own packages declare it in. 1.16.0 (September 2023) is the floor because it is the release
that made the OrtValue API the preferred C# path and gave Float16 its full surface - every member the
package uses is present there (verified against the tagged source). The package compiles against the floor,
so any newer ORT rolls forward; your own native-flavour reference (say Microsoft.ML.OnnxRuntime 1.31.1)
carries a matching .Managed and NuGet unifies upward to it. There is no ceiling because a ceiling below your
ONNX Runtime would not protect you: NuGet reports it as a restore error (NU1107), the day you upgrade. CI runs
the suite at the floor and at the current release (1.31.1), and on the newest release on nuget.org as an
advisory check, so a breaking ONNX Runtime major is seen there first. The asynchronous forms need 1.30.0; on
an older ORT the tests of their behaviour report Inconclusive and the test of their refusal runs.
Limits
- Complex tensors (ORT has none), BFloat16 / float8 / 4-bit (NumSharp has none). A BFloat16 input is fed
through ORT's own API -
OrtValue.CreateTensorValueFromMemoryovernew BFloat16(bits)values and ORT'ssession.Run- and the refusal says so. A model that declares a float8 or 4-bit input or output does not load in ONNX Runtime's C# API itself (1.31.1:Unregistered TensorElementType value of: 19), so it never reaches the bridge. - Sparse tensors: ORT's C# API cannot create or read one. A model input declared sparse passes the checks
of
Runand fails inside ORT. Runreturns tensors only. Fetch a sequence or a map with ORT's ownsession.Runand read it with the readers. A string-keyed map's keys come back sorted: ORT keeps a map ordered by key.- GPU-resident tensors cannot be viewed or copied through this package - bring them to CPU first.
- ORT's managed data API is
int-indexed: it exposes a tensor's memory only as aSpanof bytes, whose length it casts tointunchecked, and offers no other route to the pointer.- Export:
ToOrtValuerefuses an array over 2 GB before it copies or allocates anything; share it withAsOrtValue(a native pointer, no limit). - Import: a tensor of 2 GB or more that ORT owns can be neither copied (
ToNDArray) nor viewed (AsNDArray) out of anOrtValue. Both refuse it up front, allocating nothing. To get such an output, let ORT write it into memory you allocated:session.RunInto(inputs, outputs). - A value this package made, and a
DenseTensor<T>(limited by elements,int.MaxValue, not bytes), cross at any byte size.
- Export:
ToDenseTensor<T>copies into a managedT[], which holds at mostArray.MaxLength(2,147,483,591) elements; a larger array is refused up front.AsDenseTensor<T>takes up toint.MaxValueelements,AsOrtValuehas no element limit.Run's shape check sees whatNodeMetadatareports; a declaration with no dimensions is either a scalar or unknown-rank (indistinguishable there) and is left to ORT's own validation.RunAsyncdoes not allocate such an output.
Claims ledger
| # | Claim | Gate |
|---|---|---|
| 1 | AsOrtValue round-trips bit-exact for 12 dtypes × {0-d, empty, 1-D, 2-D, 3-D} |
ExportTests.AsOrtValue_RoundTrips_BitExact_EveryDtype_EveryShape |
| 2 | The tensor pointer IS the NumSharp buffer; mutations flow both ways | ExportTests.AsOrtValue_IsZeroCopy_TensorPointerIsTheNumSharpBuffer, …_MutationsAreVisibleBothWays |
| 3 | Non-contiguous views are copied under Auto and refused under View; ToOrtValue copies them in logical order |
ExportTests.AsOrtValue_NonContiguousViews_AreCopiedUnderAuto_AndRefusedUnderView, ToOrtValue_CopiesAnyLayout… |
| 4 | Half ↔ Float16 crosses bit-identical, NaN payloads and subnormals included | ExportTests.AsOrtValue_Half_CrossesAsFloat16_BitIdentical… |
| 5 | A real session output copies bit-exact for every dtype; a view shares ORT's buffer both ways | ImportTests.ToNDArray_FromARealOutput…, AsNDArray_View_SharesOrtsOutputBuffer_BothWays |
| 6 | A non-owning view roots its value; an owning view disposes it with the last view; the copy form of Run disposes ORT's values |
ValueOwnershipTests |
| 7 | The source may die while exported; resize refuses while exported; forgotten handles/views are released by GC |
LifetimeTests |
| 8 | Run coerces int32 → int64 under safe, refuses float64 → float16, validates declared shapes with the input named |
SessionRunTests |
| 9 | The input mode on every form; the casting rule on every call; optional inputs; overridable initializers; an output that overlaps an input is computed from a copy or refused | SessionRunModeTests |
| 10 | Softmax / Argmax / TopK agree with ORT's own operators, ties lower-index-first; integer inputs give the mathematical result | PostprocessTests, PostprocessEdgeTests |
| 11 | The #512 BGRA → NCHW example and the Stable-Diffusion step run as written | DocExampleTests |
| 12 | A released source is refused by every verb and Run, in every layout, conversion and cast; dtype and size refusals come first, and an oversize copy is refused before anything is allocated |
ReleasedSourceTests |
| 13 | Every export verb satisfies its contract in every mode (bytes against NumSharp's and ORT's own reads, write-through, round trips, the refusals) for every dtype × 47 catalogued layouts - 0-d, empties, offsets, negative strides, broadcasts, F-order, rank 40 and 70 - and for seeded random views per dtype plus every slice of a small vector and every axis-slice pair of a small matrix | LayoutMatrixTests, PropertyBasedTests |
| 14 | Every feed route through ORT's own Identity returns the elements byte for byte, for every dtype and layout; a pre-allocated output window receives exactly the result; Add / MatMul over strided and broadcast inputs match NumSharp |
OrtConsumerTests |
| 15 | Routes that must agree do: slicing commutes with exporting, a view and its copy cross identically, round trips are idempotent | MetamorphicTests |
| 16 | Every path - success, refusal, failure half-way - leaves the buffer references and the pool balanced; Memory<T> pins outlive the handle; a stale tensor and a shape-decided refusal allocate nothing |
ArcAccountingTests |
| 17 | Handles disposed by many threads release once; pins racing a dispose stay valid or are refused; one session run from many threads over one shared input | ConcurrencyStressTests |
| 18 | Random bit patterns - every NaN payload, signalling NaNs, bool bytes other than 0 and 1 - cross every verb and ORT's Identity byte-identical |
BitPatternFuzzTests |
| 19 | Tensors of 2 GB or more that ORT owns are refused by both imports before anything is allocated; ToOrtValue refuses exactly past the span limit; AsDenseTensor refuses more elements than a Memory<T> indexes |
BoundaryTests, DenseTensorEdgeTests |
| 20 | The public surface equals the approved list (defaults included) | ApiSurfaceTests |
| 21 | In every order the runtime may run their finalizers, the handles, pins, views and imports never free a buffer an alias still reaches and give each reference back exactly once | LifetimeGcTests |
| 22 | A view of a value this package made is a counted alias, valid after the handle and the source; it inherits the read-only flag; an owning view and a view of a pinned managed array book their bytes, a legacy output's view books none, a ToOrtValue result is booked once |
ImportAliasTests |
| 23 | Text crosses in both directions through every route, as a copy; View refuses it; a trailing NUL is lost in a text array and kept by ReadStringTensor |
TextTests |
| 24 | An output that is a fed input: RunInto writes the fed values, the zero-copy form returns a counted view of the fed buffer, also for a temporary |
PassThroughOutputTests |
| 25 | The asynchronous forms: the version rule, the completion off ORT's thread, a blocking wait under a context or a single-thread scheduler, arrays disposed in flight, errors from the call and through the task, the outputs RunAsync can and cannot allocate |
SessionRunAsyncTests |
| 26 | The value of a disposed handle is refused by every import verb instead of ending the process | ValueOwnershipTests.ValueOfADisposedHandle_IsRefused_ByEveryImportVerb_InsteadOfCrashingInOnnxRuntime |
| 27 | Non-tensor readers: sequences in order with their own shapes, int64- and string-keyed maps, GetMapKeys, each reader's refusals |
NonTensorTests |
| 28 | Under an NDScope, which disposes the bridge's own temporaries when it closes, a handle (Decimal conversion, C-order copy, zero-copy, AsDenseTensor) and a Run output that leave the scope keep their bytes; a zero-copy import left to the scope is released when it closes |
NDScopeTests |
| 29 | A ToOrtValue result books its bytes until ONNX Runtime releases it (at its Dispose, or after its last view) or it is collected; forgotten values trigger collections and disposed ones never do; a value bound to an OrtIoBinding stays correct after the managed value is disposed or collected; a sequence that owns the value ends its booking; text and empty values are not booked |
ToOrtValueBookingTests |
| 30 | Any number of threads disposing a value at once, during a verb, or while views of it live: one native release after the last lease, every read right or refused; a view outlives its value's Dispose while ORT reuses the block; ORT's own outputs are adopted by their first verb; what cannot be leased is not adopted | OrtValueArcTests |
| 31 | NDInferenceSession: its eight constructors; one release however many threads dispose it; the bridge's runs in flight finish first (synchronous and asynchronous, the asynchronous release on the thread pool); runs after the Dispose are refused; ORT's own Run works on it | NDInferenceSessionTests |
See also
- An example project - a
console app to copy: photos classified with MobileNetV2 in one batch, decoded by SkiaSharp, normalized and
laid out channels-first with NumSharp, scored with
Postprocess.Softmax/TopK, and run again withRunIntointo an array allocated once - Train in ML.NET, serve with ONNX Runtime -
a console app to copy: a model ML.NET trained on NumSharp arrays and exported with
ConvertToOnnx, served from NumSharp column slices with its unreadLabelinput fed as zeros, and checked against ML.NET's scores - Interoperability overview - the contract every bridge builds on
- Python & numpy (pythonnet) - the sibling bridge whose lifetime model this one mirrors
- Package README:
src/NumSharp.Interop.OnnxRuntime/README.md· plan:docs/plans/onnxruntime.md· the 2026-10 rework and its record:docs/plans/onnxruntime-rework.md