Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Rust EP spike — proving a Rust rewrite of the MLX execution provider
This is a vertical-slice spike (not the full EP). It de-risks a full Rust
rewrite of onnxruntime-mlx by proving the two boundaries that were the only
real unknowns, end-to-end, against the existing test harness.
What it proves
-
The ORT plugin-EP C ABI can be implemented entirely from Rust.
src/factory.rs+src/ep.rsfill theOrtEpFactory,OrtEp, andOrtNodeComputeInfoC vtables withextern "C"functions, using the "embed the ORT struct as the first field" pattern so a*OrtEpFactoryhanded to ORT is pointer-identical to our*MlxEpFactory(repr(C), offset 0). Ownership crosses the C boundary viaBox::into_raw/Box::from_raw, mirroring the C++new/Release. -
mlx-c can be bound DIRECTLY (no
mlx-rscrate) and driven from Rust.build.rsrunsbindgenover the mlx-c headers (mlx/c/mlx.h) to get a 1:1mlx_*binding, andcompute_addruns the op throughmlx_array_new_data→mlx_add→mlx_array_eval→mlx_array_data_float32. We do NOT linklibonnxruntime— ORT is reached purely through theOrtApifunction-pointer table passed toCreateEpFactories.
Scope: claims Add (fp32) only. The oracle is the repo's own pytest suite:
tests/ops/test_mlx_ops.py::test_binary_fp32[Add-...] (compares the EP output
against ORT's CPU EP, tolerance-gated).
Results
[rust-mlx-ep] GetSupportedDevices: bound to GPU device
[rust-mlx-ep] GetCapability: claimed 1 Add node(s) of 1
[rust-mlx-ep] Add computed via mlx-c (6 elems)
1 passed
- Correctness:
test_binary_fp32[Add]passes (MLX output == ORT CPU). - Memory safety: 500 back-to-back sessions under macOS
leaks→ 0 leaks / 0 bytes, now enforced on every CI run (theLeak checkgate runs three multi-op stress loops underleaks --atExit). The spike caught a real per-sessionmlx_streamleak (499 leaks / 15968 bytes) that a 3-lineimpl Drop for MlxEpfixed — the exact RAII win that motivates the rewrite (the C++ EP has hit this class of bug repeatedly: teardown UAF, the MRR MTLBuffer leak, manualctx.Keep).
Build & run
# needs: brew install mlx-c mlx
ORT_LIB=<...ort-prebuilt/lib>
DYLD_LIBRARY_PATH= \
ONNXRUNTIME_MLX_EP_LIB=/target/release/libonnxruntime_mlx_ep.dylib \
Observability tracing (src/trace.rs)
Env-gated GPU tracing into the pure-Rust onnx-runtime-tracer (Chrome/Perfetto
JSON). It ships the feasible slice of docs/METAL_TRACING.md: because
mlx-c only exposes the Xcode mlx_metal_start_capture and MLX fuses a whole
subgraph into ONE hidden, synchronous mlx_eval, per-op MTLCommandBuffer
gpuStartTime and per-kernel counters (design §4/§6) are unreachable — so the
mlx_eval wall time (GPU-inclusive, since eval blocks) is the granularity.
# Write a Chrome/Perfetto trace (loads in https://ui.perfetto.dev):
ONNX_GENAI_MLX_TRACE=/tmp/mlx_trace.json DYLD_LIBRARY_PATH= \
ONNXRUNTIME_MLX_EP_LIB=/target/release/libonnxruntime_mlx_ep.dylib \
- Spans:
mlx.subgraph(catep, whole Compute) → nestedmlx.eval(catgpu, the synchronous eval = GPU-inclusive time); one<op_type>(catop) span per node at graph-build time, plus (when tracing is on) a rich per-op detail span carrying input/output shapes, dtype, element count and byte size so every op has resource context even without fine mode. - GPU counters (Chrome
"C"phase, own Perfetto tracks):mlx.gpu_mem_bytes(MTLDevice.currentAllocatedSize),mlx.gpu_mem_pct(÷recommendedMaxWorkingSetSize), andmlx.gpu_util_pct— GPU active-residency % read from the private IOReport framework (theGPUPH"GPU Performance States" channel, the same signalmacmon/powermetricsuse; no sudo). IOReport is resolved bydlopen/dlsymat runtime, so a missing/ABI-changed framework just disables the util counter — never a crash. - os_signpost intervals around the same subgraph/eval regions for an
Instruments Metal System Trace (
ONNX_GENAI_MLX_SIGNPOST=1forces them on). - Events are stamped with the real
pid(std::process::id()) so they merge into onnx-genai's Perfetto timeline. Written on EP teardown; the collector accumulates across sessions, so each teardown rewrites the full cumulative trace. - Cost when off (env unset): a single relaxed atomic load + early return per
entry point — no signpost log, no device handle, no IOReport sampler, no
allocation, and the single fused
mlx_evalis left untouched.
Seeing inside the fused mlx_eval
By default a whole fused subgraph evaluates as ONE opaque mlx.eval blob (MLX hides
its per-kernel Metal command buffers). Two opt-in modes break that open:
-
Fine-grained per-op GPU timing —
ONNX_GENAI_MLX_TRACE_FINE=1(implies tracing on). After each node's handler binds its outputs, they aremlx_array_eval'd individually and timed, emitting agpu.opspan per op with the op's GPU-inclusive wall time + shape/dtype/bytes Args. The single blob becomes per-op bars showing which op dominates. This BREAKS fusion (materialising per node defeats MLX's lazy graph) so it is slower and strictly a debug tool — the normal path keeps the single fused eval.ONNX_GENAI_MLX_TRACE=/tmp/fine.json ONNX_GENAI_MLX_TRACE_FINE=1 -
Xcode/Instruments GPU capture —
ONNX_GENAI_MLX_GPU_CAPTURE=<path.gputrace>(or=1for a default path) wraps the first eval only inmlx_metal_start_capture…stop_capture, producing a.gputracebundle with full per-kernel GPU timing / occupancy / memory-bandwidth. RequiresMTL_CAPTURE_ENABLED=1exported before the process starts (the capture layer is inserted at device creation); without it the mode logs a clear message and skips rather than aborting.MTL_CAPTURE_ENABLED=1 ONNX_GENAI_MLX_GPU_CAPTURE=/tmp/cap.gputrace -
Slowest-ops summary — whenever tracing is on, EP teardown logs a compact top-10 (op_type → total µs, %, call count) to stderr and as an
mlx.slowest_opstrace-metadata event, so an agent sees e.g. "Add = 80% of GPU time" without parsing the JSON. Times are GPU-inclusive in fine mode, otherwise build-time (noted in the summary).
Full-port plan (what this unlocks)
The two boundaries are proven; the rest is mechanical, guarded by the language-agnostic pytest suite (ONNX models vs ORT-CPU reference):
mlx-c-syscrate — bindgen over all mlx-c headers (the C++ EP uses 181mlx_*symbols, incl.fast_scaled_dot_product_attention,fast_rope,fast_rms_norm,quantized_matmul,compile), plus safe RAII wrappers (Array/Stream/VectorArraywithDrop).ort-ep-sys— bindgen over the ORT EP C ABI (reuse/extend onnx-genai'sonnx-genai-ort-sys).- Engine + registry — port
TranslationContext/NodeDescand the(domain,op,[min,max]opset)registry; add the ~24 op modules in waves, each validated against its pytest module. - DataTransfer + allocator — the unified-memory memcpy transfer + a
Metal-buffer allocator (the C++ has ~5 raw Metal calls; use
metal-rs). The spike keeps I/O on the CPU allocator, which was sufficient to prove the boundaries; the GPU-memory path is a known-simple follow-up. - pyo3 packaging — abi3 + free-threaded (abi3t) wheels, replacing nanobind.
Update: foundation + wave-1 (engine generalized)
The single-Add spike has been generalized into a real engine + registry that ports the first wave of ops. This is no longer a single hardcoded op.
Module structure
src/mlx.rs— RAII layer. SafeStream,Array,VectorArraywrappers oversys::mlx, each withimpl Dropcalling the matchingmlx_*_free. All ownership of mlx refs lives here; op handlers never free manually. This is where the 0-leak result comes from. Raw bindgen stays insys::mlx.src/engine.rs— Engine core.NodeDesc(op_type/domain/since_version + int/float/array/string attrs + input/output tensor names), thePlan(one per fused subgraph), andTranslationContextwhich owns aname -> mlx_arrayenvironment plus anarena: Vec<Array>(freed at run end) and a persistentcachefor constants. Providesresolve/bind/keepand the eagerexecute/finish_boundary/copy_out.mlx_dtype_from_onnxmaps ONNX element types tomlx_dtype(fp32/fp16/bf16/int32/int64/… ) for the copy path.src/registry.rs— Registry.(domain, op_type) -> { handler, claim predicate }, anOnceLocksingleton wired byregister_builtin_ops.claimandtranslateare the single source of truth so claimed == translatable.NodeViewis the claim-time FFI wrapper (reads inputs/outputs/attrs off theOrtNodebefore compile). Includes claim helpers (is_mlx_float,suffix_broadcast, …).src/ops/elementwise.rs,src/ops/math.rs— Wave-1 handlers. Each op is handler + claim predicate + registry entry.src/ep.rs— Generalized boundary.GetCapabilityclaims nodes via the registry then groups them into maximal convex connected subgraphs with a faithful port ofBuildConvexClusters(union-find + reachability bitsets, prevents the cycles ORT rejects).Compileextracts each node'sNodeDesc(attrs viaNode_GetAttributes/ReadOpAttr, tensor names viaNode_GetInputs/GetOutputs) and builds onePlanper subgraph.Compute(RunPlan port) resolves subgraph inputs from theKernelContext, runs each node's handler in topo order, does a singlemlx_evalat the boundary, then writes each output viaKernelContext_GetOutput+ a unified-memory memcpy.
Wave-1 ops (all pass through the Rust EP)
Add, Sub, Mul, Div, Neg, Abs, Sqrt, Exp, Log, Relu, Sigmoid, Tanh (+ Softmax
last-axis and Cast). fp32 required; the copy path also handles
fp16/bf16/int32/int64.
Normalization + attention ops (ops/norm.rs, ops/attention.rs)
The transformer decode path. All claim + translate through the same registry and
run on MLX (fp32/fp16/bf16), verified against ORT CPU by tests/ops:
- Normalization —
RMSNormalization,LayerNormalization,SimplifiedLayerNormalization,SkipLayerNormalization,SkipSimplifiedLayerNormalization,GroupNormalization,BatchNormalization(inference form),LpNormalization. The last-axis forms usemlx_fast_rms_norm/mlx_fast_layer_norm; the rest compose mean/var/rsqrt. - Attention —
GroupQueryAttention(in-op RoPE + KV-cache append + causal SDPA, multi-outputattn/present_key/present_value),Attention(ai.onnx opset 23 & 24, 3D/4D, optional attn_mask + past/present KV),MultiHeadAttention(com.microsoft, optional projection bias),RotaryEmbedding(ai.onnx opset 23 & com.microsoft, gather / offset / absent position_ids, rotate-half + interleaved, partial rotation). SDPA maps ontomlx_fast_scaled_dot_product_attention. - Leak check —
stress_norm_attn.pyunderMallocStackLogging=1 leaks --atExit→ 0 leaks / 0 total leaked bytes (exercises the fast-norm / fast-SDPA / RoPE / multi-output present-K/V paths).
Edge cases intentionally left on CPU (claim returns false), matching the C++ EP:
attention softcap, the qk_matmul_output extra output, the opset-24
nonpad_kv_seqlen input, and the is_causal + explicit attn_mask combination
(MLX fast SDPA cannot mix a causal mode with an array mask); GQA smooth_softmax
/ qk_output; MHA packed-QKV and every masked / past-KV form (they imply an
interior optional gap the subgraph builder cannot consume); norm Mean/InvStdDev
extra outputs. The compiled-decode fast-path (dynamic cos/sin slice, rotate-half
matmul) is next-wave — the eager single-mlx_eval path is implemented here.
Results
cargo build --release— clean.pytest tests/ops/test_mlx_ops.py -k "binary_fp32 or sigmoid_fp32 or softmax_fp32"— 5 passed, all through MLX (verified by[rust-mlx-ep]GetCapability/Compute stderr). Fulltest_mlx_ops.py— 31 passed; math ops (Relu/Tanh/Neg/Abs/Sqrt/Exp/Log/Div) — 21 passed, 18 through MLX.- Leak check — 500-session Add stress loop under
MallocStackLogging=1 leaks --atExit→ 0 leaks / 0 total leaked bytes (rust/stress_add.py).
What the next wave needs from the engine
- Initializer / constant handling —
Src::Initializer+ theconstant-flag cache path exist but are only lightly exercised; reductions/norm/matmul/quant need weights resolved once and kept in the persistentPlan.cache. - Reading constant host bytes (a
RawHostaccessor) — ops likeSlice/Trilu/OneHot/Reshaperead integer/shape operands on the host, not as mlx arrays. - Multi-output nodes — the plan/output binding currently assumes the common 1-output case; TopK/Split/attention need N outputs bound and copied.
- Subgraph / control-flow attrs —
SubgraphDesc(If/Scan/Loop bodies) is not yet ported; the convex-cluster singleton special-case for control-flow ops was intentionally omitted in wave-1. - Reductions & shape/data-movement helpers — axis normalization, keepdims,
and gather/concat/reshape wiring in
TranslationContext. - Compiled-decode fast-path (
mlx_compile) — omitted; a later perf item.