Runtime
Building a Fully Custom C ML Runtime Directly Against the Metal
Report summary
A fully custom C inference runtime with no managed wrappers around native ML libraries is technically feasible, but the right answer is not to support every model format and every accelerator path equally. The most practical architecture is: pure C graph/parser/scheduler/memory system , pure C CPU k
Key topics
- Runtime
- AI
- .NET
- C#
- Python
- Semantic Systems
- Strategy
- Audit
Research provenance
For citation, use the report title and canonical URL. Archival presence does not establish authorship or promote report statements into portfolio evidence.
Source availability: 49 citation markers in the source export have no recoverable source links. Those markers are omitted from this reader; any supplied bibliography and ordinary links remain. Check the original sources before relying on the cited claims.
This page renders the archived Markdown as safe, formatted HTML. It is background research and does not become a portfolio claim without evidence review.
Full report
On this page
Executive summary
A fully custom C# inference runtime with no managed wrappers around native ML libraries is technically feasible, but the right answer is not to support every model format and every accelerator path equally. The most practical architecture is: pure C# graph/parser/scheduler/memory system, pure C# CPU kernels for a meaningful operator subset, and very thin hand-written interop to platform GPU APIs or vendor drivers where necessary. In practice, the best first-class model target is ONNX because it has a formal protobuf schema, published IR and opset versioning rules, external-data support for large tensors, and a standard backend test suite. TensorFlow SavedModel is parseable in C#, but general execution is materially harder because the format is a directory containing saved_model.pb, signatures, and a separate variables checkpoint. TorchScript is the weakest target for a from-scratch pure-C# runtime because the official contract is primarily “save/load into PyTorch/C++,” and torch.jit.load explicitly warns that malicious pickle data can execute arbitrary code; that alone makes it a poor native ingestion format for a hardened custom loader.
For execution backends, the cleanest matrix is usually: CPU in pure C# first, then DirectML on Windows if you want broad GPU coverage with thin D3D12 interop, CUDA Driver API on Windows/Linux if you want NVIDIA-specific maximum control, and Vulkan compute on Windows/Linux only if cross-vendor GPU support is more important than implementation complexity. DirectML is attractive because Microsoft describes it as a low-level DirectX 12 ML library with broad DX12 GPU support and redist options, but it is now explicitly in maintenance mode and Microsoft recommends Windows ML on newer Windows 11 builds. For a strict “no third-party wrappers” policy, macOS is the hardest platform among the requested GPU paths: native Vulkan on Apple platforms generally means MoltenVK, which is itself a portability layer over Metal and therefore breaks the “no third-party wrapper” ideal. That makes CPU-first on macOS the most defensible baseline unless you also add a native Metal path.
From a systems point of view, the most important engineering decisions are not about syntax; they are about memory ownership, layout stability, copy avoidance, and version discipline. In .NET 7+ you can hand-author source-generated P/Invoke with [LibraryImport], use SafeHandle and unmanaged buffers, allocate aligned native memory via NativeMemory, vectorize CPU kernels with System.Numerics.Vector<T> and System.Runtime.Intrinsics, and publish with NativeAOT for restricted environments that forbid JIT. The corresponding trade-off is that NativeAOT disables dynamic loading and runtime codegen, so plugin architectures and reflection-heavy systems become a bad fit.
The highest-confidence recommendation is therefore this:
- Target ONNX first and treat it as the external interoperability format.
- Build a pure C# execution core around a compact internal IR and a small operator registry.
- Implement CPU kernels in C# first, including quantized int8/int4-aware data paths.
- Add one GPU backend at a time: DirectML for Windows breadth or CUDA Driver API for NVIDIA depth.
- Treat TorchScript and general SavedModel ingestion as conversion sources, not native runtime formats.
- Use official backend tests, differential tests, tracing, and sanitizer/profiler tools to prove correctness before chasing peak performance.
I also considered your uploaded background note as supplemental context while shaping the architecture and implementation plan.
Model formats and what pure C# must parse
The core strategic decision is whether your runtime is a general interchange runtime or a production deployment runtime. If it is the former, you need to tolerate model format drift, multiple opsets, and foreign operator domains. If it is the latter, you should aggressively narrow the accepted formats and convert everything to one canonical internal representation at build time. The official ONNX versioning rules make that approach realistic because ONNX cleanly separates IR version, operator version, and opset imports, and models explicitly declare (domain, opset_version) requirements in ModelProto.opset_import.
Format comparison
| Format | Pure C# parsing difficulty | Pure C# execution difficulty | Why it is or is not a good first target |
|---|---|---|---|
| ONNX | Moderate | Moderate to high | Formal protobuf schema, published IR/opset rules, external tensor data support, official backend tests. Best first target. |
| TensorFlow SavedModel | Moderate | Very high | saved_model.pb is parseable, but execution requires GraphDef semantics, SignatureDef handling, and checkpoint/variables loading. |
| TorchScript | High | Very high | Official API centers on torch.jit.save/load; loading is pickle-based and unsafe for untrusted input. Weakest first target. |
| Custom flat format | Low to moderate | Low to moderate | Best deployment format if you own export + runtime and want mmap-friendly, alignment-aware, zero-copy loading. |
This table synthesizes the ONNX IR/opset/versioning and external-data documentation, TensorFlow SavedModel folder/schema documentation, and the PyTorch torch.jit.save/load docs.
ONNX in pure C#
ONNX is a protobuf-based graph format. In pure C#, that means you need a protobuf reader that understands the wire types defined by the Protocol Buffers encoding spec, then a loader for ModelProto, GraphProto, NodeProto, TensorProto, and related messages from onnx.proto3. You also need to respect IR version and opset imports, because ONNX operator identity is versioned and semantics can change across opsets. If you want large weight files, you must implement external tensor data resolution: ONNX explicitly allows tensors to live outside the main protobuf with data_location = EXTERNAL, and the external bytes use the same binary byte string format as raw_data.
A strict production-grade ONNX loader in C# therefore needs five pieces: a protobuf reader, a schema-aware model reader, an opset resolver, an external-data loader, and a validator that rejects unsupported domains or versions up front. ONNX’s own backend guidance is useful here, not because you need their Python interface, but because it formalizes the distinction between one-shot execution and a prepared executable representation, and because ONNX ships a standard backend test suite that you can use as a correctness gate in CI.
using System;
using System.Buffers.Binary;
using System.IO;
/// <summary>
/// Minimal protobuf wire reader for model formats such as ONNX and TensorFlow protobufs.
/// This is intentionally small and suitable as the seed of a custom parser.
/// </summary>
public ref struct ProtoReader
{
private ReadOnlySpan<byte> _data;
private int _offset;
/// <summary>
/// Initializes a new reader over a protobuf payload.
/// </summary>
/// <param name="data">Serialized protobuf bytes.</param>
public ProtoReader(ReadOnlySpan<byte> data)
{
_data = data;
_offset = 0;
}
/// <summary>
/// Returns true when unread bytes remain.
/// </summary>
public bool HasData => _offset < _data.Length;
/// <summary>
/// Reads the next field tag and returns its field number and wire type.
/// </summary>
/// <param name="fieldNumber">Receives the protobuf field number.</param>
/// <param name="wireType">Receives the protobuf wire type.</param>
public void ReadTag(out int fieldNumber, out int wireType)
{
ulong tag = ReadVarUInt64();
fieldNumber = (int)(tag >> 3);
wireType = (int)(tag & 0x07);
}
/// <summary>
/// Reads an unsigned protobuf varint.
/// </summary>
public ulong ReadVarUInt64()
{
ulong value = 0;
int shift = 0;
while (true)
{
if (_offset >= _data.Length)
throw new EndOfStreamException("Unexpected end of protobuf payload.");
byte b = _data[_offset++];
value |= (ulong)(b & 0x7Fu) << shift;
if ((b & 0x80) == 0)
return value;
shift += 7;
if (shift > 63)
throw new InvalidDataException("Invalid protobuf varint.");
}
}
/// <summary>
/// Reads a length-delimited field as a span.
/// </summary>
public ReadOnlySpan<byte> ReadBytes()
{
int length = checked((int)ReadVarUInt64());
if (_offset + length > _data.Length)
throw new EndOfStreamException("Length-delimited field exceeds payload.");
ReadOnlySpan<byte> slice = _data.Slice(_offset, length);
_offset += length;
return slice;
}
/// <summary>
/// Skips a field by wire type.
/// </summary>
/// <param name="wireType">The protobuf wire type.</param>
public void SkipField(int wireType)
{
switch (wireType)
{
case 0: _ = ReadVarUInt64(); break; // varint
case 1: _offset += 8; break; // fixed64
case 2: _offset += checked((int)ReadVarUInt64()); break; // length-delimited
case 5: _offset += 4; break; // fixed32
default: throw new NotSupportedException($"Wire type {wireType} is not supported.");
}
if (_offset > _data.Length)
throw new EndOfStreamException("Skipped beyond payload.");
}
}
TensorFlow SavedModel in pure C#
SavedModel is a directory format, not just a single graph blob. The TensorFlow guide states that a SavedModel contains saved_model.pb, which stores the TensorFlow program and named signatures, and a variables directory that contains a standard training checkpoint. At the protobuf level, SavedModel contains one or more MetaGraphDef objects, and each MetaGraphDef contains a GraphDef plus a signature_def map.
That means a pure C# reader is possible, but a pure C# general executor is a large project. Parsing saved_model.pb is only step one. You must also either implement TensorFlow checkpoint loading for the variables directory or require export-time freezing/constant folding so weights are embedded in a simpler artifact. In other words, SavedModel is parseable, but for a clean-room C# runtime it is usually better used as an upstream conversion source, not as the deployment format you execute directly.
TorchScript in pure C#
The official torch.jit.save / torch.jit.load contract is workable for PyTorch and C++, but not attractive as a target for a hardened from-scratch C# runtime. The strongest official signal is the loader warning: PyTorch states that malicious pickle data can execute arbitrary code during torch.jit.load, and warns never to load untrusted data. The save/load docs also emphasize compatibility within the PyTorch ecosystem rather than publishing a stable, independent interchange specification comparable to ONNX’s IR/opset model.
That pushes a rigorous custom runtime toward a simple rule: do not ingest TorchScript directly in production. Convert it to ONNX or to your own deployment format during a trusted build step, then execute the converted form. From a security and maintenance perspective, that is dramatically better.
Custom deployment formats
If you own both export and runtime, a custom format is the most direct path to “against the metal” execution. The usual design is a fixed header, a tensor table, an operator table, aligned contiguous data blobs, little-endian scalar encoding, and optional memory-map-friendly pages so weights can be projected directly into the process address space. This is not an official-standard claim; it is the engineering pattern that best matches the behavior of ONNX external data, NativeAOT deployment, and low-copy tensor runtimes. In practice, many high-performance runtimes end up converging on an internal format like this even when ONNX remains the interchange boundary.
Runtime execution strategies for CPU and GPU
The backend choice determines where your complexity lives. A pure C# CPU backend moves complexity into kernel engineering and memory layout. GPU backends move complexity into interop, synchronization, shader/module compilation, and device-memory orchestration.
Backend comparison
| Backend | Best use | Strengths | Main drawbacks | Licensing / redistribution |
|---|---|---|---|---|
| Pure C# CPU | Baseline on all OSes | Portable, debuggable, NativeAOT-friendly, no native dependency required | You must hand-optimize kernels and scheduling | .NET app/runtime distribution only |
| DirectCompute | Windows GPU compute without ML layer | HLSL, groupshared memory, thread sync, atomics | D3D11-era constraints, you implement all kernels and graph lowering | Windows API |
| DirectX 12 compute | Windows explicit GPU path | Maximum control over resources/synchronization; good base for custom kernels | Highest engineering cost on Windows; no ML op library | Windows API |
| DirectML | Broad Windows GPU ML path | Official low-level D3D12 ML library; broad DX12 GPU coverage; low overhead and D3D12 interop | Maintenance mode; Windows-only; custom unsupported ops still yours | Built into Win10+; also redistributable |
| Vulkan compute | Cross-vendor GPU compute on Windows/Linux | Cross-platform and cross-vendor; explicit API | Very high implementation cost; macOS needs MoltenVK portability layer | Vulkan loader/SDK; MoltenVK is Apache-2.0 but third-party |
| CUDA Driver API | NVIDIA-specific depth | Explicit contexts/modules, PTX/fatbin loading, UVA, mature tooling | NVIDIA-only; proprietary SDK terms; highest payoff only on NVIDIA fleets | CUDA SDK/EULA + driver install |
The table above is a synthesis of Microsoft’s DirectCompute and DirectML docs, NVIDIA’s Driver API and EULA, and Khronos/MoltenVK’s own description of Apple-platform Vulkan portability.
CPU in pure C#
For a no-wrapper runtime, CPU is not the fallback path; it is the architectural anchor. .NET exposes both broad SIMD types and low-level intrinsics. System.Numerics.Vector<T> provides JIT-selected vector width based on the host CPU, while System.Runtime.Intrinsics exposes 128-, 256-, and 512-bit vector forms and architecture-specific instruction families. Microsoft also explicitly notes that SIMD speedups vary by workload and may simply expose the next bottleneck, often memory throughput.
That has two direct runtime consequences. First, dense kernels such as matmul, layer norm, RMSNorm, softmax prepasses, and elementwise fusion should be written against contiguous tiles, not arbitrary strided slices. Second, you should build your operator ABI around explicit strides and tensor metadata, then normalize or transpose troublesome layouts once at load/prepack time, not during every invocation. The biggest CPU wins usually come from prepacking weights and eliminating shape/layout ambiguity rather than from clever arithmetic alone. This conclusion is an inference from the SIMD and ONNX operator/versioning material, and it is highly consistent with real inference behavior, but it is still an inference rather than a vendor benchmark.
DirectCompute and DirectX 12 compute
Microsoft describes DirectCompute as the compute-shader path in Direct3D 11: it uses HLSL, supports memory sharing and thread synchronization, and exposes dispatch-based parallel execution. The D3D11 docs also show concrete capability limits, such as thread-group size caps, UAV limits, and groupshared memory limits, which matter if you want a long-lived general-purpose ML backend.
For a fresh implementation in 2026, DirectX 12 compute is usually the better long-term custom-kernel foundation when you need Windows-only custom GPU execution without depending on DirectML operator coverage. The reason is not that Microsoft’s docs hand you an ML runtime; they do not. The reason is architectural: D3D12 is the explicit resource/synchronization substrate that DirectML itself sits on top of. If you need bespoke fused attention kernels, token decode loops, or custom quantized GEMM kernels on Windows, D3D12 compute is the path that keeps you closest to the hardware contract. DirectCompute remains attractive for simpler engine integrations or older codebases, but it is not the path I would select for a clean-sheet ML runtime unless your scope is small. This is a design inference grounded by the DirectCompute capabilities and Microsoft’s characterization of DirectML as a low-level D3D12 library.
DirectML
DirectML is the most appealing Windows-only GPU acceleration path for a custom C# runtime because Microsoft explicitly describes it as a high-performance, hardware-accelerated DirectX 12 library for machine learning, supporting all DirectX 12-capable GPUs across AMD, Intel, NVIDIA, and Qualcomm. Microsoft also emphasizes its seamless interoperability with Direct3D 12, low overhead, and cross-hardware conformance. However, Microsoft now states that DirectML is in maintenance mode, continues to ship, and recommends Windows ML on Windows 11 24H2+ for newer deployments. DirectML is also available both as a Windows system component and as a standalone redistributable package.
The practical implication is nuanced: if your goal is a self-owned runtime with a Windows GPU backend that does not wrap ONNX Runtime or TensorFlow, DirectML is still a sound engineering choice today. But it should be treated as a stable substrate, not as a rapidly improving future-facing one. You should expect to write your own graph lowering, tensor binding, shape handling, memory planner, and fallbacks for unsupported or inconvenient op patterns. The payoff is broad Windows GPU coverage without binding yourself to one IHV.
A thin C# interop pattern looks like this conceptually:
// Pseudocode / interop pattern, not a drop-in binding.
// Intended to show the layering of a no-wrapper DirectML backend.
unsafe void ExecuteDirectMlOp(
nint dxgiAdapter,
nint inputBuffer,
nint outputBuffer,
ulong inputBytes,
ulong outputBytes)
{
// 1. Create ID3D12Device from adapter.
nint d3d12Device = CreateD3D12Device(dxgiAdapter);
// 2. Create command queue, allocator, command list.
nint queue = CreateComputeQueue(d3d12Device);
nint allocator = CreateCommandAllocator(d3d12Device);
nint cmdList = CreateCommandList(d3d12Device, allocator);
// 3. Create IDMLDevice on top of ID3D12Device.
nint dmlDevice = DmlCreateDevice(d3d12Device);
// 4. Describe operator and compile it.
nint opDesc = BuildDmlOperatorDescription();
nint dmlOperator = DmlCreateOperator(dmlDevice, opDesc);
nint compiledOp = DmlCompileOperator(dmlDevice, dmlOperator);
// 5. Create binding table and bind persistent/temp/input/output resources.
nint bindingTable = DmlCreateBindingTable(compiledOp);
BindResource(bindingTable, "Input", inputBuffer, inputBytes);
BindResource(bindingTable, "Output", outputBuffer, outputBytes);
// 6. Record dispatch.
DmlRecordDispatch(cmdList, compiledOp, bindingTable);
// 7. Execute and fence.
CloseAndExecute(queue, cmdList);
WaitForQueueIdle(queue);
}
That pattern follows Microsoft’s own framing of DirectML as a low-level D3D12 library with direct interoperability, even though production code will require full COM vtable bindings, descriptor heaps, state transitions, and resource lifetime management.
Vulkan compute
Vulkan compute is attractive when you want cross-vendor GPU execution on Windows and Linux without buying into DirectX or CUDA. In a strict no-wrapper design, though, Vulkan is a substantial undertaking: you must author or generate the C API bindings yourself, manage instance/device creation, queue families, pipelines, descriptor sets, synchronization, memory allocation, and SPIR-V module handling. That is viable for a long-lived runtime team, but it is a very steep first backend.
The macOS story is the decisive constraint. Khronos’ own MoltenVK repository describes MoltenVK as a layered implementation of a subset of Vulkan over Apple Metal and explains that it exists to bring Vulkan applications to macOS and related Apple platforms. That makes Vulkan-on-macOS incompatible with a strict reading of “no third-party wrappers,” because the portability story is, by design, a third-party portability layer.
CUDA via low-level interop
If you want maximum control on NVIDIA hardware, use the CUDA Driver API, not a managed wrapper and not the high-level runtime API. NVIDIA’s own documentation says the driver API provides more fine-grained control over module loading than the runtime API, allows explicit context management, and supports loading PTX, cubin, and fatbin modules using APIs such as cuModuleLoadDataEx. CUDA also supports unified addressing on supported devices, where host and device pointers can share a unified address space.
That gives you a clean thin-interop backend from C#: initialize the driver, create/select a context, load PTX/fatbin modules, get kernel handles, allocate or register memory, launch kernels, and synchronize streams. The cost is obvious: this path is NVIDIA-only and sits under NVIDIA’s SDK license and redistribution rules. NVIDIA’s EULA permits distribution of parts of the SDK only when they are identified as distributable and when distribution requirements are met; the toolkit is not a stand-alone redistributable for arbitrary repackaging. In practice, many production CUDA applications rely on the installed driver and only redistribute NVIDIA-approved runtime pieces when needed.
using System;
using System.Runtime.InteropServices;
internal static partial class CudaDriver
{
/// <summary>
/// Initializes the CUDA driver.
/// </summary>
/// <param name="flags">Reserved; pass 0.</param>
/// <returns>CUDA result code.</returns>
[LibraryImport("nvcuda", EntryPoint = "cuInit")]
internal static partial int CuInit(uint flags);
/// <summary>
/// Loads a CUDA module from PTX, cubin, or fatbin image data.
/// </summary>
/// <param name="module">Receives the created module handle.</param>
/// <param name="image">Pointer to module bytes or null-terminated PTX text.</param>
/// <param name="numOptions">Number of JIT options.</param>
/// <param name="options">Pointer to option keys.</param>
/// <param name="optionValues">Pointer to option values.</param>
/// <returns>CUDA result code.</returns>
[LibraryImport("nvcuda", EntryPoint = "cuModuleLoadDataEx")]
internal static unsafe partial int CuModuleLoadDataEx(
out nint module,
void* image,
uint numOptions,
int* options,
void** optionValues);
/// <summary>
/// Unloads a CUDA module.
/// </summary>
/// <param name="module">Module handle.</param>
/// <returns>CUDA result code.</returns>
[LibraryImport("nvcuda", EntryPoint = "cuModuleUnload")]
internal static partial int CuModuleUnload(nint module);
}
The signatures above match the official driver API pattern closely enough to show the intended binding style; in production you would also wrap result checking, context handles, stream lifetimes, and platform-specific library names. Microsoft’s interop guidance strongly favors [LibraryImport] on .NET 7+ and exact native-signature matching.
Performance expectations
There is no single credible universal “X is Y% faster” number here, and the official docs do not pretend otherwise. The defensible statement is this:
- CPU C# can be excellent for elementwise ops, norms, small/medium GEMMs, decode loops, and reference implementations if you control allocation, alignment, and layout.
- GPU wins grow with larger kernels, better fusion, higher arithmetic intensity, and fewer host/device synchronization points.
- DirectML typically offers a faster path to “good Windows GPU performance” than writing all Windows kernels yourself.
- CUDA Driver API is the highest-ceiling path on NVIDIA fleets because it exposes the most control.
- Vulkan is the most ambitious path for a cross-vendor custom backend, but probably not the fastest to production.
That ranking is an engineering inference from the official APIs, not a benchmark claim. It is the level of certainty I would trust for architecture decisions before project-specific profiling.
Memory, zero-copy I/O, tensor layout, quantization, and concurrency
The runtime that performs well is the runtime that owns memory explicitly. In .NET, you should make unmanaged memory the default representation for large tensors and model weights, then project typed spans or pointers onto those allocations at the operator boundary. NativeMemory gives you aligned allocation, zeroed allocation, explicit copy/fill, and explicit free; Microsoft’s interop best practices also recommend SafeHandle for unmanaged resource lifetime and suggest ArrayPool<T> for reusable host buffers.
Memory-mapped weights and zero-copy boundaries
The cleanest model-loading path is: memory-map immutable weights, pre-parse metadata into compact structs, and avoid copying weight blobs unless the backend requires repacking. ONNX external data is especially compatible with this pattern because external tensor bytes live outside the protobuf and use the same raw binary byte-string format. On CUDA-capable systems with unified addressing, some host-allocated memory classes can also be device-addressable, which can reduce pointer-translation complexity, though it does not eliminate bandwidth or residency concerns.
using System;
using System.IO.MemoryMappedFiles;
using System.Runtime.InteropServices;
/// <summary>
/// Projects a file into memory and exposes a stable pointer for zero-copy reads.
/// </summary>
public sealed unsafe class MappedBlob : IDisposable
{
private readonly MemoryMappedFile _mmf;
private readonly MemoryMappedViewAccessor _view;
private byte* _pointer;
/// <summary>
/// Opens and maps a file as read-only.
/// </summary>
/// <param name="path">Path to the model or tensor blob file.</param>
public MappedBlob(string path)
{
_mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
_view = _mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
byte* p = null;
_view.SafeMemoryMappedViewHandle.AcquirePointer(ref p);
_pointer = p + _view.PointerOffset;
Length = _view.Capacity;
}
/// <summary>
/// Gets the mapped byte length.
/// </summary>
public long Length { get; }
/// <summary>
/// Returns the base pointer to the mapped bytes.
/// </summary>
public byte* Pointer => _pointer;
/// <inheritdoc />
public void Dispose()
{
if (_pointer != null)
{
_view.SafeMemoryMappedViewHandle.ReleasePointer();
_pointer = null;
}
_view.Dispose();
_mmf.Dispose();
}
}
This pattern is especially effective when paired with a load-time “tensor catalog” that records offset, length, dtype, shape, and alignment for each immutable weight.
Tensor layout and prepacking
Do not let hot kernels discover layout on the fly. Make layout explicit in metadata: shape, strides, element size, storage offset, and logical order. For CPU kernels, layouts should collapse toward contiguous or predictably blocked forms. For GPU backends, layouts should be normalized into the backend’s preferred packings during a prepare/prepack phase analogous to ONNX’s “prepare” concept.
A minimal internal tensor descriptor should hold:
- storage pointer or handle,
- dtype,
- dimensions and strides,
- quantization metadata,
- ownership/lifetime flags,
- backend placement information.
I recommend structure-of-arrays metadata for the scheduler and compact per-tensor descriptors for the operator ABI. That minimizes branchy metadata access in the hot path.
Quantization support
ONNX’s quantization operators are explicit enough to drive a robust C# design. QuantizeLinear supports per-tensor and per-axis quantization, with formulas based on scale and zero-point; the docs list saturation ranges including int8, uint8, int4, and uint4. QLinearConv and QLinearMatMul specify quantized execution behavior, including per-output-channel and per-row/per-column variants and the expectation that multiplication not overflow while accumulation is constrained appropriately.
That leads to a sensible support order:
- Phase one: FP32, FP16/BF16 storage where usable, INT8/UINT8 per-tensor and per-axis.
- Phase two: INT4/UINT4 packed weights, mixed dequant/blockwise kernels.
- Phase three: specialized LLM packings such as grouped quantization or custom block formats.
If your primary workloads are transformers, custom formats often outperform generic ONNX quantized graphs because they can store already-packed blockwise weights that match your kernel tiling exactly. ONNX remains the interchange format; your own format becomes the deployment artifact.
Threading and concurrency in C#
The right split is usually inter-op parallelism at the graph level and intra-op parallelism inside a small number of kernels. Avoid letting both explode simultaneously or you will oversubscribe cores and destroy locality. On CPU, dense ops should use blocked partitioning and a fixed worker pool sized to physical cores or NUMA configuration. On GPU, the scheduler should batch or fuse work to reduce dispatch overhead and synchronization. Microsoft’s SIMD guidance explicitly warns that when you vectorize, the next bottleneck may become memory throughput; that is why a naive “parallelize everything” default usually loses.
In C#, I would implement:
- a lock-free ready queue for graph nodes,
- a small set of per-device execution lanes,
- cooperative cancellation and deadline propagation,
- immutable execution plans after
Prepare, - pooled temporary tensors and scratch buffers,
- zero allocations in the steady-state
Runpath.
A simple custom operator in C#
A good custom-operator model for the CPU path is: scalar reference implementation first, then a vectorized specialization behind feature checks.
using System;
using System.Numerics;
/// <summary>
/// Applies ReLU to a contiguous float buffer.
/// </summary>
/// <param name="input">Input values.</param>
/// <param name="output">Destination buffer. Must be at least as large as <paramref name="input"/>.</param>
public static void Relu(ReadOnlySpan<float> input, Span<float> output)
{
if (output.Length < input.Length)
throw new ArgumentException("Output span is too small.", nameof(output));
int i = 0;
if (Vector.IsHardwareAccelerated)
{
var zero = Vector<float>.Zero;
int width = Vector<float>.Count;
int stop = input.Length - (input.Length % width);
for (; i < stop; i += width)
{
var v = new Vector<float>(input.Slice(i, width));
Vector.Max(v, zero).CopyTo(output.Slice(i, width));
}
}
for (; i < input.Length; i++)
output[i] = MathF.Max(0f, input[i]);
}
This is the right style for custom CPU ops: clear scalar semantics, explicit buffer contracts, and optional vector acceleration. For GPU custom ops, the same operator should be representable as a backend-specific kernel or fused shader dispatch rather than requiring a different semantic definition.
Interop techniques, deployment, platform support, and security
The interop layer is where many “pure C#” projects quietly become wrapper projects. The main discipline is to keep the interop boundary thin, audited, and internal.
Interop method comparison
| Method | Best use | Advantages | Costs / risks |
|---|---|---|---|
Hand-written P/Invoke with [LibraryImport] | C APIs such as CUDA driver, POSIX, Win32, Vulkan C ABI | Thinest practical interop, cross-platform, NativeAOT-friendly | Laborious signatures, marshalling bugs, handle lifetime work |
| Hand-written COM interop / vtable bindings | D3D12, DXGI, DirectML | No external wrapper dependency; full control | Verbose, error-prone, Windows-specific |
| C++/CLI bridge | Complex C++ APIs on Windows | Good escape hatch for ugly native APIs | NativeAOT explicitly does not support C++/CLI; weak portability story |
| Native host process + IPC | Isolation, sandboxing, crash containment | Strongest fault/security boundary, can mix languages/toolchains | Serialization cost, operational complexity |
| NativeAOT | Locked-down deployment, startup, small operational surface | No JIT at runtime; works in restricted environments | No dynamic loading, no Reflection.Emit, no C++/CLI |
Microsoft’s guidance explicitly recommends [LibraryImport] on .NET 7+, exact native signatures, and SafeHandle for unmanaged lifetimes. NativeAOT explicitly states there is no dynamic loading, no runtime code generation, and no C++/CLI.
What “no third-party wrappers” should mean in practice
A rigorous interpretation is:
- No SharpDX/Vortice/managed DirectML wrappers in production.
- No managed CUDA wrappers.
- No native ML runtimes hidden behind a C# façade.
- Thin internal interop code is allowed.
- Pure-managed libraries that are not wrappers around native ML libraries are a policy choice, not a technical violation.
That still leaves you with two choices for Windows COM APIs: either hand-write [ComImport] interfaces, or generate/author internal vtable bindings. For C ABIs like CUDA or Vulkan, source-generated P/Invoke is the natural fit.
Platform support snapshot
| Platform | CPU backend | DirectCompute / D3D12 / DirectML | Vulkan compute | CUDA Driver API |
|---|---|---|---|---|
| Windows | Excellent | Native first-class option | Good | Good on NVIDIA |
| Linux | Excellent | Not applicable | Good | Good on NVIDIA |
| macOS | Excellent | Not applicable | Not native under strict policy; needs MoltenVK | Not a realistic modern target |
This matrix follows directly from Microsoft’s Windows-only Direct3D/DirectML materials, NVIDIA’s Windows/Linux driver-tooling story, and MoltenVK’s description of Vulkan-on-Apple as a layered portability implementation over Metal.
Security and sandboxing
Security is not optional when your runtime ingests foreign model files. The most obvious official red flag is TorchScript: torch.jit.load warns that malicious pickle data can execute arbitrary code. ONNX is safer structurally, but ONNX external data still introduces file-path resolution and blob-loading risk, so a production loader should pin external paths to an allow-listed root and reject traversal or symlink escapes.
For OS isolation, Windows offers AppContainer for least-privilege isolation and Job Objects for process-tree control and resource limits. Linux offers seccomp BPF filters, including a mode that constrains allowed system calls and propagates filters to children under the documented rules. On platforms or workloads where model loading is untrusted, the strongest design is often a native or managed worker process with file-system limits, memory caps, and kill-on-close semantics rather than an in-process plugin boundary.
Tooling, debugging, profiling, and testing
The testing strategy should be differential and layered:
- For ONNX compatibility, run the ONNX backend test suite in CI.
- For managed runtime behavior, use
dotnet-countersfor first-level health checks anddotnet-tracefor cross-platform EventPipe tracing without a native profiler. - For CUDA kernels, use Nsight Compute for kernel profiling and Compute Sanitizer for out-of-bounds, misalignment, init, and race checks. NVIDIA describes Compute Sanitizer’s memcheck and racecheck capabilities in detail.
A strong custom-runtime test pyramid is:
- parser fuzzing and corpus tests,
- operator golden tests,
- differential tests against trusted engines offline,
- randomized shape/layout tests,
- quantization round-trip tests,
- determinism and concurrency stress tests,
- long-run memory leak and fragmentation tests.
Minimal end-to-end architecture and implementation plan
The lowest-risk architecture is to compile every incoming format into a small internal IR, then execute that IR on one selected backend. That keeps parsing, optimization, scheduling, and execution decoupled.
flowchart LR
A[Importer<br/>ONNX first] --> B[Validator<br/>IR version opset domains]
B --> C[Internal IR<br/>typed tensors explicit strides]
C --> D[Prepare phase<br/>constant folding prepack memory plan]
D --> E[Execution plan<br/>node schedule bindings]
E --> F[CPU backend<br/>pure C# kernels]
E --> G[Windows GPU backend<br/>DirectML or D3D12]
E --> H[NVIDIA backend<br/>CUDA Driver API]
D --> I[Artifact cache<br/>custom packed format]
A minimal architecture that can really ship
The smallest architecture I would consider production-worthy is:
- Importer layer: ONNX first; SavedModel and TorchScript only through offline conversion.
- Validator: reject unsupported opsets/domains/dtypes before allocation.
- Internal IR: SSA-like graph with static tensor metadata, explicit constants, and backend-agnostic operator descriptors.
- Prepare phase: shape resolution, constant folding, weight prepacking, memory planning, backend selection.
- Execution core: scheduler + tensor arena + operator registry.
- Backends: pure C# CPU first; one GPU backend second.
- Artifact packer: optional custom deployment format produced from ONNX during build/release.
This directly mirrors ONNX’s prepare/run distinction and aligns with NativeAOT’s restrictions by keeping dynamic behavior out of the hot path.
Step-by-step plan with milestones and estimated effort
These effort estimates assume one strong senior engineer full-time. A small team can compress wall-clock time but not the logical dependency chain.
gantt
title Suggested build plan
dateFormat YYYY-MM-DD
section Core
Protobuf reader and binary model loader :a1, 2026-06-16, 21d
Internal IR and validator :a2, after a1, 21d
Memory arena and tensor descriptors :a3, after a1, 14d
section CPU
Reference CPU kernels :b1, after a2, 28d
SIMD and prepacking :b2, after b1, 28d
Quantized int8 path :b3, after b1, 21d
section GPU
First GPU backend :c1, after a2, 42d
Kernel/operator coverage expansion :c2, after c1, 42d
section Quality
Differential tests and fuzzing :d1, after a2, 28d
Profiling hardening and deployment :d2, after b2, 28d
A practical milestone sequence looks like this:
Milestone one — Parser and IR foundation, roughly 4 to 6 weeks. Implement protobuf wire parsing, ONNX model loading, opset/domain validation, external-data support, and a tiny internal IR with constants, elementwise ops, matmul, reshape, transpose, and concatenation. Exit criterion: load and inspect real ONNX models, reject unsupported constructs deterministically, and pass parser fuzzing on malformed inputs.
Milestone two — CPU reference runtime, roughly 6 to 10 weeks. Add a prepared execution plan, unmanaged tensor storage, arena planner, and correct scalar CPU kernels. Then add SIMD fast paths and weight prepacking. Exit criterion: end-to-end inference on a nontrivial ONNX model family with stable outputs and acceptable latency.
Milestone three — Quantization and operator coverage, roughly 4 to 8 weeks. Add QuantizeLinear/DequantizeLinear semantics, int8 qlinear matmul/conv, per-axis scales, and packed low-bit storage where useful. Exit criterion: quantized ONNX subsets execute correctly and match reference outputs within agreed tolerances.
Milestone four — First GPU backend, roughly 8 to 14 weeks. Choose one: DirectML for Windows breadth or CUDA Driver API for NVIDIA depth. Do not build multiple GPU backends before one is production-stable. Exit criterion: the same internal IR runs on CPU and the chosen GPU backend, with differential tests and repeatable profiling.
Milestone five — Deployment hardening, roughly 4 to 8 weeks. Add NativeAOT publishing where it fits, tracing hooks, crash dump support, sandbox/process isolation, and a packed deployment artifact. Exit criterion: reproducible builds, signed artifacts where relevant, resource caps in workers, and a CI gate covering parser, kernels, and model regressions.
Recommended primary sources and open questions
The best primary-source reading list is short and official. Start with the ONNX IR, opset/versioning, external-data, and backend-test docs, the ONNX protobuf schema, the Protocol Buffers encoding guide, the TensorFlow SavedModel guide plus saved_model.proto and meta_graph.proto, the PyTorch torch.jit.save/load docs, Microsoft’s P/Invoke best practices, NativeAOT docs, SIMD/intrinsics docs, Microsoft’s DirectML repository/docs, Direct3D’s compute shader docs, NVIDIA’s CUDA Driver API, Nsight Compute, Compute Sanitizer, and NVIDIA’s CUDA EULA. Those sources are enough to design the runtime honestly without outsourcing core decisions to blog posts.
The main open questions are architectural rather than factual. You still need to choose:
- whether your deployment artifact will remain ONNX or become a custom packed format,
- whether Windows GPU breadth or NVIDIA-specific peak performance matters more,
- whether macOS needs a real GPU backend or only a CPU baseline,
- how much dynamic shape support you truly require,
- and how much operator coverage you need before the runtime is useful in your environment.
If those answers are conservative, a fully custom C# runtime is realistic. If they are expansive from day one — every model format, every opset, every desktop OS, every accelerator path — the project becomes a multi-quarter runtime-engineering program rather than a focused product component.