π Nylon Ring
Ultra-Fast ABI-Stable HostβPlugin Interface for Rust
Build fast native plugins with an explicit C-compatible ABI.
Features β’ Quick Start β’ Performance β’ System Overview
π Features
- Single stable ABI β C-compatible types, fixed status values, version and structure-size validation; zero-copy responses, host-leased output buffers, and integer entry dispatch are all part of the one vtable.
- Flexible calls β fire-and-forget, async unary, synchronous fast path, and bounded streaming with backpressure.
- Safe lifecycle β in-flight call guards, graceful unload/reload, timeouts, and automatic request cleanup.
- Cross-language validation β Rust examples plus a buildable C plugin.
The workspace provides:
nylon-ringβ ABI types, vtables, and thedefine_plugin!macro.nylon-ring-hostβ dynamic loading, routing, streaming, lifecycle controls, and metrics.
MSRV: Rust 1.88.
π Quick start
Install
# Plugin
[]
= "0.2.1"
# Host
[]
= "0.2.1"
Plugin crates must also build as a dynamic library:
[]
= ["cdylib"]
Host
use ;
# async
Other call patterns:
plugin.call.await?;
plugin.call_response_fast.await?;
plugin.call_response_timeout.await?;
let = plugin.call_stream.await?;
See the complete Rust plugin example and Rust host example.
Run the workspace demo:
π Performance
Reference snapshot on an Apple M1 Pro (8 performance + 2 efficiency cores),
release build. All host numbers come from the worker-loop harness in
ex-nyring-host: workers await each call sequentially (in-flight depth 1 per
worker) and every counted call is asserted Ok. Single-stream is the same
harness at one worker, so the two columns are directly comparable. Each value
is the best of at least three 10-second runs, all captured in one session
(code at 94a1c33) β compare rows within this table, not against older
snapshots.
Throughput
| Host operation | 1 worker | 10 workers | Scaling |
|---|---|---|---|
| Fire-and-forget | 84.56M calls/s (11.8 ns) | 679.67M calls/s | 8.0Γ |
| Fire-and-forget (entry id) | 96.89M calls/s (10.3 ns) | 791.78M calls/s | 8.2Γ |
| Synchronous fast path | 40.62M calls/s (24.6 ns) | 332.10M calls/s | 8.2Γ |
| Synchronous fast path (entry id) | 58.94M calls/s (17.0 ns) | 476.26M calls/s | 8.1Γ |
| Standard unary | 24.67M calls/s (40.5 ns) | 197.11M calls/s | 8.0Γ |
| Streaming | 37.48M frames/s (26.7 ns) | 235.58M frames/s | 6.3Γ |
For calibration: the raw FFI boundary itself (indirect call into the
dylib, dispatch wrapper, and handler) measures 1.9 ns / 6 cycles in
cycle_budget_probe. Everything above that line is the priced cost of
session routing, async delivery, and hot-unload safety β not the ABI.
Plugins loaded with load_pinned opt out of unload/reload for the life
of the process, which removes the per-call in-flight tracking:
| Host operation (pinned) | 1 worker | 10 workers |
|---|---|---|
| Fire-and-forget (entry id) | 177.30M calls/s (5.6 ns) | 1.45B calls/s |
| Synchronous fast path (entry id) | 73.47M calls/s (13.6 ns) | 602.20M calls/s |
| Standard unary | 26.53M calls/s (37.7 ns) | 213.10M calls/s |
"Entry id" rows dispatch through a pre-resolved PluginEntry
(handle.entry(name) once, then id-only calls) instead of comparing the
entry name on every call. Streaming times full 9-frame round trips (8 data
frames + StreamEnd) through pooled per-stream channels.
Unary payload curve (1 worker, time per call)
| Payload | send_result (copying) |
send_result_owned |
buffer lease |
|---|---|---|---|
| empty | 40.5 ns | 56.9 ns | 47.4 ns |
| 128 B | 82.3 ns | 56.5 ns | 62.1 ns |
| 1 KiB | 136.5 ns | 56.5 ns | 95.9 ns |
| 4 KiB | 203.6 ns | 56.1 ns | 128.3 ns |
The owned column is flat: the plugin answers from its own long-lived buffer
and the host consumes it zero-copy through call_response_bytes. The lease
column pays one host alloc plus the plugin's serialize-in-place, and returns
a plain Vec<u8> through call_response.
Non-empty payloads pay two alloc/free pairs and two copies on the copying
send_result path: the response crosses the boundary as a foreign
allocation and is copied into host-owned memory because allocator
provenance cannot be proven across images. The send_result_owned and
buffer-lease paths avoid this; see docs/ABI_EVOLUTION.md.
These are reference measurements, not cross-platform guarantees; absolute
numbers shift with thermal and scheduling state (up to tens of percent
between sessions on the same machine), which is why every value above comes
from one session. Numbers published before 0.1.3 used per-iteration
block_on (single-stream) and a join_all batch loop (multi-core), and
multi-worker numbers published before 198518c under-read by 15β22%
(the harness updated shared counters every batch); neither is comparable
with this table. Reproduce with:
NYRING_BENCH_OPERATION=all NYRING_BENCH_WORKERS=10
# NYRING_BENCH_OPERATION: fire | fireid | fast | fastid | unary | unaryid
# | owned | lease | stream | all
# Also: NYRING_BENCH_WORKERS, NYRING_BENCH_SECONDS, NYRING_BENCH_BATCH_SIZE,
# NYRING_BENCH_PAYLOAD_BYTES, NYRING_BENCH_STREAM_FRAMES,
# NYRING_BENCH_CPU_SAMPLES
π System overview
+------------------------------------------------------+
| Host (nylon-ring-host) |
| NylonRingHost |
| ββ LoadedPlugin + in-flight call gate |
| ββ HostContext |
| ββ thread-local synchronous slot |
| ββ sharded unary router + inline completion |
| ββ bounded stream queues |
+-------------------------+----------------------------+
| C ABI
| NrPluginVTable / NrHostVTable
+-------------------------+----------------------------+
| Plugin |
| define_plugin! β named handlers β send_result |
+------------------------------------------------------+
The host validates and initializes a library, assigns each call a session ID, then routes plugin callbacks to the matching unary request or stream. Call guards keep the library loaded until active work finishes; graceful unload/reload stops new calls and waits for existing calls to drain.
π‘ Safety
- Host and plugin must use the same target and ABI version.
NrStrandNrBytesare borrowed; copy them before retaining their data.- Owned
NrVec<T>values carry the producer's drop callback, avoiding cross-allocator frees. - Plugins must stop worker threads and callbacks during shutdown.
- Only load trusted native libraries; this is not a security sandbox.
Development
Publishing is handled by GitHub Actions in dependency order using the
organization-level RUST_TOKEN secret.