Skip to content

Instantly share code, notes, and snippets.

@NiclasOlofsson
Last active August 12, 2026 00:59
Show Gist options
  • Select an option

  • Save NiclasOlofsson/398d2f229c7f755209c15fb3c2c4a56d to your computer and use it in GitHub Desktop.

Select an option

Save NiclasOlofsson/398d2f229c7f755209c15fb3c2c4a56d to your computer and use it in GitHub Desktop.
WebRTC data-channel benchmark: MiNET RTC vs Google libwebrtc vs libdatachannel vs pion

RtcPerf: WebRTC data-channel head-to-head (MiNET RTC, libwebrtc, libdatachannel, pion, webrtc-rs sans-I/O + async)

Published as a public gist: https://gist.github.com/NiclasOlofsson/398d2f229c7f755209c15fb3c2c4a56d - the gist mirrors this file; after editing here, push with gh gist edit 398d2f229c7f755209c15fb3c2c4a56d rtc-datachannel-benchmark.md from temp_auto/rtc-perf. Relative links resolve in the repo checkout, not in the gist.

Six stacks, identical scenario shapes and methodology:

  • C rows - MiNET RTC: MiNET's own stack (UdpMux + ICE + DTLS + SCTP), RtcPeer<->RtcPeer. Harness: temp_auto/rtc-perf/Program.cs, dotnet run -c Release, net10.0, ServerGC=false.
  • J rows - Google libwebrtc via dev.kastle.webrtc:webrtc-java 1.0.4, the exact artifact GeyserNetherNet ships (JNI over native libwebrtc, data-channels-only build, win32-x64 natives from the 1.0.4 GitHub release). Harness: temp_auto/rtc-perf-java/RtcPerfJava.java, JDK 21.0.11, -Xms2g -Xmx2g -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:+PerfDisableSharedMem.
  • L rows - libdatachannel 0.24.2 (usrsctp + OpenSSL) via node-datachannel 0.32.3, the only living binding (no Java/.NET binding exists; datachannel-unity is dead since 2021). Harness: temp_auto/rtc-perf-node/harness.mjs, node v24.14.1, --expose-gc.
  • P rows - pion/webrtc v4.2.18 (pure Go, pinned in go.mod), the stack go-nethernet (Dragonfly's NetherNet) builds on and the de facto Go standard. Harness: temp_auto/rtc-perf-go/main.go, go 1.25.0, portable toolchain in temp_auto/tools/go (nothing installed system-wide). Both peers in one process with non-trickle signaling (GatheringCompletePromise, full gather, no candidate plumbing); pacing waits yield via runtime.Gosched(), so P rows sit on the yield side of the pacer wrinkle, like L. Pion is pure Go, so its bytes/message and retained heap are the WHOLE stack's managed churn (runtime TotalAlloc delta), same semantics class as the C rows - no ETW pass needed. PD is INVALID for throughput/CPU (see the pion loss finding); its memory columns stay valid. Raw logs: temp_auto/rtc-perf-go run1-affinity7.log, run2-P1-affinity7.log, run2-P2-affinity7.log, and the ladder-P1-*.log files.
  • R rows - webrtc-rs/rtc via crates.io rtc 0.21.0-alpha.1, the sans-I/O Rust WebRTC stack (github.com/webrtc-rs/rtc). Harness: temp_auto/rtc-perf-rust/src/main.rs, rustc 1.96.1, release profile with lto = true, codegen-units = 1. Sans-I/O means the stack has no threads and no sockets of its own; the harness IS its runtime: one thread drives both peers' poll/handle loops over real nonblocking loopback UDP sockets, which is the stack's intended deployment shape. Its pacing wait is neither a spin nor a yield - waiting IS pumping the stack - so the R rows sit outside the pacer wrinkle entirely. Rust has no GC; bytes/message is the WHOLE PROCESS's allocation churn counted by a wrapping global allocator (every alloc/realloc byte), whole-stack semantics like the C/P rows, no ETW pass needed. Retained-heap has no runtime-visible equivalent (no GC heap to ask), so that column is empty for R rows. Raw logs: temp_auto/rtc-perf-rust run1-affinity7.log, run2-affinity7.log, and the ladder-R1-*.log files.
  • W rows - webrtc-rs/webrtc async via crates.io webrtc 0.17.2, the tokio-based classic webrtc-rs API and the line Rust deployments actually run (0.17.1 alone has 286k downloads; the 0.20+ releases are a new architecture rebuilt on the sans-I/O core with almost no adoption yet). A pion port: same API shape, non-trickle signaling via gathering_complete_promise, default multi-thread tokio runtime, out-of-the-box settings. Harness: temp_auto/rtc-perf-rust-async/src/main.rs, rustc 1.96.1, same release profile and counting-allocator churn semantics as the R rows; pacing waits yield via tokio::task::yield_now(), the L/P side of the pacer wrinkle. R and W are different codebases from the same org - R says nothing about W and vice versa. Raw logs: temp_auto/rtc-perf-rust-async run1-affinity7.log, run2-affinity7.log, and the ladder-W1-*.log files.

Both endpoints of every pair run the same stack, so each row is that stack end to end, never a mixed pair.

SIPSorcery (10.0.13) was measured and then dropped from the benchmark: at ~150-400 msgs/sec it is two to three orders of magnitude off every other stack, too slow to be a relevant comparison and too slow to keep benchmarking (its runs dominated total harness time).

Hardware: AMD Ryzen AI 7 350 (Krackan Point, 4x Zen 5 + 4x Zen 5c, 16 threads, 2.0GHz base), 32GB DDR5-7500, Windows 11 Home 26200. Laptop-class silicon: absolute numbers carry power/thermal variance; cross-stack ratios on the same box are the reliable read.

Methodology (the standard for every stack)

  • Scenarios: 1000B reliable-ordered (C1/J1/L1/P1/R1/W1), 1800B reliable-ordered and fragmented (C2/J2/L2/P2/R2/W2 - 1800B exceeds every stack's SCTP MTU constant and stays under 2x each, so every stack sends exactly two DATA chunks; MiNET fragments at 1024, libwebrtc ~1190, usrsctp/pion ~1200, so no smaller payload fragments uniformly), 1000B unreliable (maxRetransmits 0, unordered - D/JD/LD/PD/RD/WD). Loopback peer pair, one data channel.
  • Pacing: at most 512 messages in flight, measured off the receiver's delivered count. Spin-wait (SpinWait/Thread.onSpinWait) so the wait itself allocates nothing; the Node harness must yield via setImmediate instead, because a spin would block the event loop that delivers its onMessage callbacks - its wall time therefore includes event-loop scheduling.
  • 1000-message warm-up (not measured, doubles as calibration), then a measured window of 100,000 messages timed from first send to full drain. If the calibrated rate projects the window past a 60s budget, the count drops to 10,000 with a NOTE line; per-message metrics stay comparable. No included stack triggers this.
  • Pinning: measured processes are pinned to a fixed set of cores with cmd /c "start /affinity <mask> /b /wait <command>" (from Git Bash write cmd //c), because results are strongly sensitive to core count and placement (see the core-scaling section) and unpinned runs lose heavy CPU to cross-core contention. This CPU interleaves fast and compact cores (cores 0,2,4,6 = Zen 5, cores 1,3,5,7 = Zen 5c, verified via GetLogicalProcessorInformationEx; logical CPUs pair per core in order), so the masks used are: 0x3 = 1 fast core (both SMT threads), 0x7 = that plus one compact thread (the current standard for pinned table rows), 0x33 = 2 fast cores, 0x111 = 3 fast cores one thread each. The standard mask is retained after the core-scaling runs; the pacer difference (hard-spin in the .NET/Java harnesses, yield in node/Go) remains a documented wrinkle. All main-table rows are pinned to the standard. Everywhere else in this document "pinned" means this scheme and "N cores" means these masks.
  • libwebrtc: one PeerConnectionFactory per peer. A factory owns exactly one network thread and every peer connection created from it does all transport work there, so a shared factory serializes both endpoints on one thread. Per-peer factories mirror real deployments (the two ends are separate processes). The wrapper exposes no thread configuration beyond this.
  • 2 process runs x 2 in-harness repeats = 4 samples per scenario, averaged, no outliers dropped.
  • Metrics: wall time, process-wide CPU time (native threads included), allocation and retained memory where the runtime can see them (see column notes).

Run procedure (per stack, from its harness folder)

Log files are named runN-affinity3.log. After a re-run: recompute the averages, update the comparison table, and push the gist (command at the top).

Sampling convention (uniform across all stacks): every harness does 3 in-process runs per scenario per invocation, 2 invocations. Run 1 of each invocation is the sacrificial warm-up and is EXCLUDED from throughput / effective-cores / CPU-per-message averages (JIT stacks execute much of run 1 on unoptimized code: .NET tier-0 and HotSpot C2 both promote on background wall time that call-count warmups cannot cover; AOT stacks are flat across runs, so the uniform rule costs them nothing). Averages therefore use runs 2-3 of each invocation, 4 samples. MEMORY is the exception: per-message churn is code-path behavior, not timing behavior, so the churn column also averages runs 2-3 (steady state), but run 1's allocation numbers stay in the logs and one-time costs (pool growth, lazy setup, JIT-era allocations) must be read from run 1 and the retained-heap/working-set columns rather than treated as nonexistent. A fresh server pays run 1's memory once; the table's churn column is the per-message price after that.

  • .NET C rows, temp_auto/rtc-perf (MiNET scenarios only): dotnet build -c Release once, then twice cmd /c "start /affinity 7 /b /wait dotnet run -c Release --no-build". References the MiNET project directly - coordinate with concurrent build/test activity on the solution (DLL locking).
  • Java J rows, temp_auto/rtc-perf-java: one-time setup javac -cp lib/webrtc-1.0.4.jar RtcPerfJava.java (jars sit in lib/, from the webrtc-java 1.0.4 GitHub release), then twice cmd /c "start /affinity 7 /b /wait java -Xms2g -Xmx2g -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:+PerfDisableSharedMem -cp .;lib/webrtc-1.0.4.jar;lib/webrtc-java-1.0.4-windows-x86_64.jar RtcPerfJava".
  • Node L rows, temp_auto/rtc-perf-node: one-time setup npm install (pins node-datachannel 0.32.3), then twice cmd /c "start /affinity 7 /b /wait node --expose-gc harness.mjs".
  • Go P rows, temp_auto/rtc-perf-go: one-time setup temp_auto/tools/go/bin/go build -o rtcperfgo.exe ., then twice cmd /c "start /affinity 7 /b /wait rtcperfgo.exe". Env SCENARIO=P1|P2|PD and MEMRUN=1 select single scenarios / memory-run windows like the other harnesses.
  • Rust R rows, temp_auto/rtc-perf-rust: one-time setup cargo build --release, then twice cmd /c "start /affinity 7 /b /wait target\release\rtc-perf-rust.exe". Env SCENARIO=R1|R2|RD and MEMRUN=1 as in the other harnesses. No memory pass exists or is needed: the counting allocator reports whole-process churn inline.

Memory runs (fills the J/L bytes/message column): temp_auto/tools/memory-pass.cmd runs PerfView OS-heap traces around single-scenario memrun invocations of the Java/node harnesses (env SCENARIO/MEMRUN for node, J1|J2|JD memrun args for Java) and writes ETLs to temp_auto/rtc-perf-mem/. ETW heap tracing requires elevation - run the script from an admin prompt. Analysis: dotnet run in temp_auto/tools/HeapSum against each ETL. Memory runs are measurement-only; CPU/throughput numbers always come from untraced runs.

Comparison table (4-sample averages)

Every row is pinned to the standard 3 hardware threads: one fast core's SMT pair plus one compact-core thread, about 2 cores' worth of real computing. C rows are measured at MiNET commit 1e16a16f (inbound DTLS queue deleted, decrypt inline on the receive path); C/J/L rows use the 3-run warm-sample convention described under "Run procedure", P/R/W rows predate it (AOT stacks are flat across runs, so their mixed averages equal warm averages). The effective-cores column counts busy hardware threads, not delivered compute, so pinned rows can legitimately read up to ~3.0 on that pin (see the metric caveat in the core-scaling section). P rows are 4 samples from temp_auto/rtc-perf-go runs; pion is pure Go so its bytes/message and retained heap are whole-stack managed churn, same semantics class as C/S.

Scenario msgs/sec bytes/message effective cores CPU us/message retained heap (bytes)
C1 (MiNET RTC, 1000B reliable) 104,191 389.4 2.33 22.5 389,580
J1 (libwebrtc, 1000B reliable) 30,836 65,315 + 1.71 55.5 10,524,624 *
L1 (libdatachannel, 1000B reliable) 41,967 19,175 + 2.54 60.6 4,592,096 **
P1 (pion, 1000B reliable) 37,948 15,125.2 2.57 68.0 1,252,692
R1 (webrtc-rs sans-I/O, 1000B reliable) 69,653 22,053.6 1.00 14.4 -
W1 (webrtc-rs async, 1000B reliable) 26,652 69,794.1 2.50 94.0 -
C2 (MiNET RTC, 1800B reliable, fragmented) 52,110 1,127.3 2.27 44.0 435,248
J2 (libwebrtc, 1800B reliable, fragmented) 21,961 124,925 + 1.87 85.3 10,523,706 *
L2 (libdatachannel, 1800B reliable, fragmented) 24,074 32,060 + 2.62 109.2 4,682,356 **
P2 (pion, 1800B reliable, fragmented) 27,145 27,679.6 2.62 99.3 1,345,112
R2 (webrtc-rs sans-I/O, 1800B reliable, fragmented) 27,191 38,165.4 0.97 35.7 -
W2 (webrtc-rs async, 1800B reliable, fragmented) 13,792 136,003.7 2.45 178.1 -
D (MiNET RTC, 1000B unreliable) 100,741 368.0 2.32 23.2 392,974
JD (libwebrtc, 1000B unreliable) 31,112 66,272 + 1.69 54.3 10,523,984 *
LD (libdatachannel, 1000B unreliable) 46,406 19,171 + 2.57 54.9 4,717,540 **
PD (pion, 1000B unreliable) INVALID - see findings 15,234 - - 1,261,668
RD (webrtc-rs sans-I/O, 1000B unreliable) 26,667 29,952.3 0.96 36.1 -
WD (webrtc-rs async, 1000B unreliable) 28,850 69,866.3 2.56 88.9 -

Column semantics:

  • msgs/sec, effective cores ( = process CPU-seconds / wall-seconds over the measured window), and CPU us/message are process-wide, native threads included, comparable across every row.
  • bytes/message is allocation churn per message in the stack's own allocator. C: managed GC churn (GC.GetTotalAllocatedBytes over the measured window). + J/L: NATIVE heap churn measured from outside via ETW OS-heap tracing (PerfView), one dedicated memory run per scenario (21,000 messages, pinned, setup excluded by allocation-timeline bucketing - see findings). Churn is not cost: a GC allocation is a pointer bump, a malloc/free pair is not, so equal bytes are not equal expense; the CPU column carries the cost side.
  • retained heap: * J values are Java heap only, dominated by wrapper objects the JNI shim pins (see findings); ** L values are JS heap only. Native retention is invisible to both VMs; the ETW churn column does not measure retention.
  • The JNI shim itself allocates 88.0 Java-heap bytes per received message (its per-message wrapper), measured by ThreadMXBean in the CPU runs.
  • Working set is deliberately absent: it is process-cumulative (later scenarios inherit earlier peaks) and, for the JVM, dominated by the pre-touched 2GB heap. Raw per-run values are in the logs.

Core scaling (1000B reliable scenario per stack)

Each row: same scenario, pinned to that many cores (applied affinity verified live via ProcessorAffinity during the Java runs). 2 samples per row (1 process run x 2 repeats) except the "1 fast + 1 compact" and all-16-J/L rows, which carry the 4-sample table numbers.

Metric caveat: "effective cores" and CPU time count thread-seconds, not delivered compute - two busy SMT threads on one physical core bill ~2.0 effective cores while delivering ~1.2 cores' worth of computing. Cross-stack comparisons on the same row are fair (same distortion for both); one stack's CPU us/message compared across rows flatters exclusive-core rows over SMT rows.

Cores C1 MiNET RTC J1 libwebrtc (hard-spin pacer) J1 libwebrtc (yield pacer) L1 libdatachannel P1 pion R1 webrtc-rs sans-I/O W1 webrtc-rs async
1 core 80,480 / 18.6us 7,508 / 242.8us - 38,031 / 48.8us 30,912 / 62.2us 69,548 / 14.3us 10,638 / 137.3us
1 fast + 1 compact thread 104,191 / 22.5us 30,836 / 55.5us 10,994 / 183.1us 41,967 / 60.6us 37,948 / 68.0us 69,653 / 14.4us 26,652 / 94.0us
2 cores 90,080 / 33.1us 10,374 / 191.6us - 44,719 / 72.1us 42,338 / 71.1us 71,820 / 13.8us 35,308 / 94.3us
3 cores 113,347 / 22.7us 12,469 / 157.0us 12,472 / 154.4us 53,536 / 48.6us 44,912 / 57.4us 67,588 / 14.6us 33,496 / 78.5us
all 16 101,279 / 29.2us 22,432 / 83.0us - 32,218 / 117.5us 39,528 / 138.2us 62,501 / 15.9us 33,045 / 112.1us

Cells are msgs/sec / CPU us per message. The "1 fast + 1 compact" row carries the current main-table values (MiNET commit 1e16a16f, warm-sample convention, for C1/J1/L1); the other mask rows predate both and still reflect MiNET commit df2e482d under the old mixed-run averaging, so compare shapes down a column, not absolute values across eras. The C1 column, like the .NET main rows, uses the hard-spin pacer.

Findings from this table:

  • libdatachannel and pion scale sanely: throughput rises with real cores, best row is 3 full cores at their lowest CPU/message, and the full unpinned box is their WORST CPU/message - both waste heavily when their threads spread across 16 CPUs.
  • webrtc-rs sans-I/O is flat by construction: sans-I/O on one harness thread, every row measures ~1.0 effective cores and 13.8-15.9 us/message regardless of mask. Cores beyond the first buy nothing inside one connection; a server scales it by sharding connections across threads at the application layer. Its us/message is genuinely single-thread work with no SMT double-billing, the only column in this table free of that caveat. Note the R throughput rows are a floor: the harness thread runs BOTH endpoints serialized, so a one-thread-per-peer deployment gets more per connection than the table shows.
  • webrtc-rs async collapses on 1 core (10,638 / 137.3us - tokio's multi-thread scheduler thrashing on a single core) and peaks at 2 full cores (35,308); 3 cores and all-16 buy nothing more. Its scaling shape is pion's, its absolute numbers ~15-25% below pion's.
  • libwebrtc's standard-pin result is pacer-sensitive. With the hard-spin pacer (Thread.onSpinWait), whose spinner sits on an SMT sibling of the thread it polls, the standard pin measures 28,015 / 61.2us; with a yielding pacer (LockSupport.parkNanos, env SPIN=yield) the same pin measures 10,994 / 183.1us, while the 3-core row is pacer-insensitive (12,472 vs 12,469). Pacer-independent, libwebrtc's pinned results are flat ~7.5-12.5k msgs/sec at 150-240us/message, worse than its own all-16 run; of the four stacks it handles constrained topologies worst.
  • Pacer-comparability wrinkle (applies to the main table too): the .NET and Java harnesses hard-spin their pacing waits; node and Go yield by construction. The J pinned rows in the main table therefore carry the same spin assistance described above. The L/P rows never had it. Re-baselining the standard mask and pacer mode is part of picking the standard configuration.

Findings (every one found, none filtered)

  • MiNET RTC's send-queue budget is 4MB per association (DefaultSendQueueBudgetBytes), and RtcDataChannel.Send is void: when the budget is exhausted the message is dropped with only a log warning, on the reliable channel too. Backpressure-API design (bool return / throw / block) is an open product decision. Consumers pacing large messages must keep in-flight bytes under the budget; the harness's 512-message window at 1800B needs ~921KB plus SACK-lag headroom, which the 4MB budget covers.

  • MiNET RTC's allocation churn is pressure-dependent: 32 B/message on the clean path (all 16 cores, Gen0 near zero), 380-460 B/message pinned, where constrained scheduling exercises retransmit-path allocations the clean path never touches. The table carries the pinned number, matching every other row's configuration; 32 is the floor.

  • MiNET RTC scales DOWN exceptionally: 80,480 msgs/sec at 18.6 us/message on a single fast core - higher throughput than any other stack achieves on its best configuration - and peaks at 113,347 on 3 fast cores. The us/message on the SMT rows carries the thread-seconds caveat.

  • pion's unreliable channel actually drops messages on loopback under pressure - ~450 of 101,000 per run, reproduced in both repeats - where MiNET RTC, libwebrtc and libdatachannel all delivered 100% on loopback in the same shape. Two consequences: (1) the PD throughput/CPU cells are INVALID - the harness paces off the receiver's delivered count, and under real loss every dropped message permanently shrinks the in-flight window until the sender crawls (61s+ drain timeouts, ~1.6k msgs/sec measured - an artifact, not pion's rate); a lossy- channel pacing redesign (pace off sender bufferedAmount) is required before PD can be measured. (2) The behavioral difference itself matters for NetherNet-over-pion servers: an unreliable channel on pion is unreliable in practice, not just in contract. PD's bytes/message and retained heap remain valid (allocation per processed message is load-shape-insensitive).

  • The async wrapper's runtime tax, measured on the same org's code: webrtc-rs async (W1) spends 94.0 us/message across 2.50 effective cores where webrtc-rs sans-I/O (R1) spends 14.4 on one - 6.5x the CPU per message - and its churn is 69.8KB/message vs 22.1KB. They are different codebases (0.17 async port vs the sans-I/O rewrite), so this is not a pure isolation of tokio overhead, but it is the choice a Rust adopter actually faces today.

  • Where webrtc-rs async's CPU goes, attributed by experiment (diag logs in the harness folder): forcing TOKIO_WORKERS=1 on the standard pin keeps throughput (24.6-32.6k vs 28.2k, wall clock is the noisy metric) while HALVING CPU to 44-52 us/message at 1.3-1.45 effective cores; TOKIO_WORKERS=3 measures the same as the 16-worker default. So roughly half the standard-pin CPU bill is multi-worker scheduler overhead (cross-worker task migration and wakeups on the ICE->DTLS->SCTP->channel task chain) that buys zero throughput for a single connection. The remaining ~3x gap to sans-I/O (44-52 vs 14.4 us) is the stack itself: 82.5 allocations per message (counting allocator; vs ~39 libwebrtc, ~62 libdatachannel native allocs) and per-layer async channel plumbing. The main-table W rows keep the 16-worker default because out-of-the-box behaviour is the suite's standard; a deployment that pins one worker gets the same throughput for half the CPU.

  • webrtc-rs async does NOT inherit pion's loopback drop bug: WD delivered 100% in all four samples with receiver-count pacing, despite the codebase being a pion port. WD is a valid row where PD is not.

  • webrtc-rs sans-I/O's unreliable channel is ~2.5x SLOWER than its reliable one: RD measures 26,667 msgs/sec at 36.1 us/message against R1's 69,653 at 14.4, on identical 1000B payloads with 100% delivery (loopback, no loss, no drain warnings). Every other stack's unreliable scenario is at least as fast as its reliable one. Unreproduced cause; the maxRetransmits 0 + unordered path in rtc-sctp simply costs more per message. Also visible in the churn column: 29.9KB/message unreliable vs 22.1KB reliable.

  • webrtc-rs allocation churn is 22.1KB across 57.5 allocations per 1000B message (38.2KB at 1800B fragmented), measured by a counting global allocator wrapping the system allocator, and it is deterministic to within a few hundred bytes per 100k-message run (2,205,358,047 vs 2,205,358,303 total). That is 58x MiNET RTC's 381B managed churn, in malloc/free-class allocations. Single-thread throughput is nonetheless second only to MiNET RTC's.

  • Native allocation churn (ETW memory runs): libdatachannel allocates ~19.2KB across ~62 native allocations per 1000B reliable message (32.1KB / 90.6 allocs at 1800B fragmented); libwebrtc ~65.3KB across ~39 allocations (124.9KB at 1800B fragmented; 66.3KB / 39.1 unreliable). MiNET RTC's equivalent churn is its GC column: 381 B per 1000B message pinned (32 B floor, see the churn finding). Method: PerfView /OSHeapExe kernel heap tracing around a single- scenario harness run (memrun mode: 20k measured + 1k warmup, one scenario per process, pinned); HeapAlloc/HeapRealloc bytes summed for the target process (temp_auto/tools/HeapSum), setup excluded by cutting everything before the send-phase plateau in 250ms allocation buckets (boundary error ~2%). Single run per scenario; the VMs' own GC heaps are VirtualAlloc-based and self-exclude from the trace.

  • Unpinned runs lose heavy CPU to cross-core contention. libdatachannel's processing pool defaults to hardware_concurrency() threads (16 here; verified in v0.24.2 src/impl/init.cpp L127-130, and node-datachannel exposes no setThreadPoolSize): unpinned it measures 117.5 us/message at 3.77 effective cores vs 59.0 at 2.51 pinned - contention doubles its per-message CPU and even costs wall-clock (32,218 vs 42,545 msgs/sec). libwebrtc shows the same effect at ~26% (83.0 -> 61.2 us/message, 22,432 -> 28,015 msgs/sec). The unpinned numbers are what a naive deployment gets; all-16 run logs sit in the harness folders.

  • Pinned results are strongly topology-sensitive (full data in the core-scaling section).

  • libwebrtc can hang in native teardown: PeerConnectionFactory construction after a prior dispose can block forever on a native lock (a JNI call, impossible to timeout from Java). Observed once, under concurrent build load with per-scenario factory cycling; does not reproduce on a quiet box. Upstream tracks the same class as devopvoid/webrtc-java #234.

  • libwebrtc pins per-message JNI wrapper objects until PeerConnectionFactory.dispose(). With a factory outliving scenarios, retained Java heap climbs ~9.0MB per 100k-message scenario (~88B per received message) and survives forced System.gc() x2; disposing the factory releases it. RTCPeerConnection.close() alone does NOT. A server holding one long-lived factory - the natural Geyser shape - accumulates pinned heap per received message with no GC relief. Needs an upstream report to Kas-tle.

  • J2/L2 memory-run notes: the L2 trace's process filter matched five node.exe processes on the box; per-pid isolation shows the harness pid carries >99.98% of the allocation, foreign processes are noise. The J2 trace shows a ~345MB allocation burst AFTER the send phase (separated from it by a near-zero 250ms bucket) attributed to libwebrtc factory teardown; it is excluded from the per-message number, which covers the send plateau only.

  • The node shim is inseparable from libdatachannel in this configuration. Every received message crosses N-API onto the V8 event loop; process.cpuUsage cannot attribute cost between the library and the shim, so the L CPU column is an UPPER BOUND for libdatachannel itself. A native C++ harness is the follow-up if attribution starts to matter; this box has no MSVC/CMake toolchain.

  • setSctpSettings (node-datachannel) is left at defaults - the L numbers are out-of-the-box behaviour.

  • Scenarios A/B/E/E-diag in the .NET raw output are MiNET-only pipeline probes (DTLS in-memory, full ICE+DTLS over loopback sockets, raw SctpAssociation SACK-gap costs) with no equivalent in the other stacks; they are not part of the comparison.

Raw output backing the table

Logs for every other configuration (all-16 runs, alternate masks and pacers) sit in the harness folders alongside the ones quoted below.

.NET runs backing the C rows

Current C rows (commit 1e16a16f, 3-run warm-sample convention): raw logs run19-affinity7.log and run20-affinity7.log in temp_auto/rtc-perf. Current J rows: run8-affinity7.log / run9-affinity7.log in temp_auto/rtc-perf-java. Current L rows: run5-affinity7.log / run6-affinity7.log in temp_auto/rtc-perf-node. The runs below are the earlier df2e482d-era snapshots, kept for the history of the optimization walk.

Historical: MiNET commit df2e482d

MiNET pinned run 1

Process: GC.IsServerGC=False, ProcessorCount=3
[Scenario A run 1] 200000 datagrams in 0,312s = 641 256 datagrams/sec | 1 576 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,375s = 1,20 effective cores = 1,88 us/message | retained heap 385 960 bytes | working set 49 917 952 bytes
[Scenario A run 2] 200000 datagrams in 0,243s = 821 600 datagrams/sec | 1 576 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,359s = 1,48 effective cores = 1,80 us/message | retained heap 401 920 bytes | working set 53 538 816 bytes
[Scenario B run 1] 20000 datagrams in 0,211s = 94 862 datagrams/sec | 4 816 bytes allocated total = 0,2 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,594s = 2,82 effective cores = 29,69 us/message | retained heap 1 435 648 bytes | working set 65 380 352 bytes
[Scenario B run 2] 20000 datagrams in 0,223s = 89 702 datagrams/sec | 4 912 bytes allocated total = 0,2 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,438s = 1,96 effective cores = 21,88 us/message | retained heap 1 468 400 bytes | working set 65 609 728 bytes
[Scenario C1 run 1] 100000 messages in 1,126s = 88 831 msgs/sec | 33 429 184 bytes allocated total = 334,3 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,30/0,00/0,00 (raw deltas 3/0/0) | CPU 2,766s = 2,46 effective cores = 27,66 us/message | retained heap 660 928 bytes | working set 74 878 976 bytes
[Scenario C1 run 2] 100000 messages in 0,879s = 113 702 msgs/sec | 46 263 944 bytes allocated total = 462,6 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,40/0,30/0,00 (raw deltas 4/3/0) | CPU 2,062s = 2,35 effective cores = 20,62 us/message | retained heap 704 072 bytes | working set 74 354 688 bytes
[Scenario C2 run 1] 100000 messages in 1,717s = 58 243 msgs/sec | 41 333 760 bytes allocated total = 413,3 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,30/0,10/0,00 (raw deltas 3/1/0) | CPU 3,797s = 2,21 effective cores = 37,97 us/message | retained heap 775 192 bytes | working set 74 997 760 bytes
[Scenario C2 run 2] 100000 messages in 1,709s = 58 504 msgs/sec | 43 273 408 bytes allocated total = 432,7 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,40/0,20/0,00 (raw deltas 4/2/0) | CPU 3,750s = 2,19 effective cores = 37,50 us/message | retained heap 765 536 bytes | working set 73 351 168 bytes
[Scenario D run 1] 100000 messages in 0,872s = 114 643 msgs/sec | 48 913 024 bytes allocated total = 489,1 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,40/0,10/0,00 (raw deltas 4/1/0) | CPU 2,047s = 2,35 effective cores = 20,47 us/message | retained heap 717 400 bytes | working set 69 644 288 bytes
[Scenario D run 2] 100000 messages in 0,876s = 114 110 msgs/sec | 42 934 648 bytes allocated total = 429,3 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,40/0,20/0,00 (raw deltas 4/2/0) | CPU 1,922s = 2,19 effective cores = 19,22 us/message | retained heap 717 176 bytes | working set 73 998 336 bytes
[Scenario E run 1] 1 121 576 bytes allocated for 20000 sends = 56,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 0,93 effective cores = 0,78 us/message | retained heap 709 088 bytes | working set 65 712 128 bytes
[Scenario E run 2] 1 120 904 bytes allocated for 20000 sends = 56,05 bytes/message | Gen0 raw delta 0 | CPU 0,000s = 0,00 effective cores = 0,00 us/message | retained heap 707 472 bytes | working set 65 650 688 bytes
[Scenario E-diag run 1] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,80 effective cores = 0,78 us/message | retained heap 707 472 bytes | working set 65 650 688 bytes
[Scenario E-diag run 2] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,79 effective cores = 0,78 us/message | retained heap 707 960 bytes | working set 65 650 688 bytes

MiNET pinned run 2

Process: GC.IsServerGC=False, ProcessorCount=3
[Scenario A run 1] 200000 datagrams in 0,228s = 878 054 datagrams/sec | 1 624 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,281s = 1,23 effective cores = 1,41 us/message | retained heap 385 960 bytes | working set 48 865 280 bytes
[Scenario A run 2] 200000 datagrams in 0,128s = 1 567 128 datagrams/sec | 1 576 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,125s = 0,98 effective cores = 0,62 us/message | retained heap 401 984 bytes | working set 49 270 784 bytes
[Scenario B run 1] 20000 datagrams in 0,208s = 96 268 datagrams/sec | 4 624 bytes allocated total = 0,2 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,594s = 2,86 effective cores = 29,69 us/message | retained heap 1 433 216 bytes | working set 63 442 944 bytes
[Scenario B run 2] 20000 datagrams in 0,183s = 109 465 datagrams/sec | 4 240 bytes allocated total = 0,2 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,484s = 2,65 effective cores = 24,22 us/message | retained heap 1 457 272 bytes | working set 64 131 072 bytes
[Scenario C1 run 1] 100000 messages in 1,217s = 82 150 msgs/sec | 33 698 136 bytes allocated total = 337,0 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,30/0,00/0,00 (raw deltas 3/0/0) | CPU 3,312s = 2,72 effective cores = 33,12 us/message | retained heap 4 862 496 bytes | working set 75 898 880 bytes
[Scenario C1 run 2] 100000 messages in 0,924s = 108 187 msgs/sec | 39 063 248 bytes allocated total = 390,6 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,40/0,20/0,00 (raw deltas 4/2/0) | CPU 2,219s = 2,40 effective cores = 22,19 us/message | retained heap 684 416 bytes | working set 77 987 840 bytes
[Scenario C2 run 1] 100000 messages in 1,708s = 58 564 msgs/sec | 44 364 208 bytes allocated total = 443,6 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,30/0,10/0,00 (raw deltas 3/1/0) | CPU 3,859s = 2,26 effective cores = 38,59 us/message | retained heap 774 256 bytes | working set 71 172 096 bytes
[Scenario C2 run 2] 100000 messages in 1,832s = 54 590 msgs/sec | 43 398 160 bytes allocated total = 434,0 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,30/0,10/0,00 (raw deltas 3/1/0) | CPU 4,000s = 2,18 effective cores = 40,00 us/message | retained heap 773 208 bytes | working set 72 060 928 bytes
[Scenario D run 1] 100000 messages in 0,991s = 100 859 msgs/sec | 49 981 168 bytes allocated total = 499,8 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,50/0,20/0,00 (raw deltas 5/2/0) | CPU 2,094s = 2,11 effective cores = 20,94 us/message | retained heap 728 680 bytes | working set 73 916 416 bytes
[Scenario D run 2] 100000 messages in 1,228s = 81 409 msgs/sec | 41 507 792 bytes allocated total = 415,1 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,40/0,20/0,00 (raw deltas 4/2/0) | CPU 2,875s = 2,34 effective cores = 28,75 us/message | retained heap 731 616 bytes | working set 74 928 128 bytes
[Scenario E run 1] 1 121 576 bytes allocated for 20000 sends = 56,08 bytes/message | Gen0 raw delta 0 | CPU 0,031s = 1,31 effective cores = 1,56 us/message | retained heap 720 872 bytes | working set 67 215 360 bytes
[Scenario E run 2] 1 121 576 bytes allocated for 20000 sends = 56,08 bytes/message | Gen0 raw delta 0 | CPU 0,000s = 0,00 effective cores = 0,00 us/message | retained heap 719 216 bytes | working set 67 207 168 bytes
[Scenario E-diag run 1] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,95 effective cores = 0,78 us/message | retained heap 719 024 bytes | working set 67 207 168 bytes
[Scenario E-diag run 2] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,000s = 0,00 effective cores = 0,00 us/message | retained heap 719 512 bytes | working set 67 207 168 bytes

MiNET all-16 run 1

Process: GC.IsServerGC=False, ProcessorCount=16
[Scenario A run 1] 200000 datagrams in 0,294s = 679 456 datagrams/sec | 1 336 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,344s = 1,17 effective cores = 1,72 us/message | retained heap 397 760 bytes | working set 49 045 504 bytes
[Scenario A run 2] 200000 datagrams in 0,145s = 1 375 433 datagrams/sec | 1 576 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,156s = 1,07 effective cores = 0,78 us/message | retained heap 422 600 bytes | working set 49 680 384 bytes
[Scenario B run 1] 20000 datagrams in 0,168s = 119 116 datagrams/sec | 4 048 bytes allocated total = 0,2 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,500s = 2,98 effective cores = 25,00 us/message | retained heap 1 472 784 bytes | working set 62 861 312 bytes
[Scenario B run 2] 20000 datagrams in 0,195s = 102 780 datagrams/sec | 6 648 bytes allocated total = 0,3 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,734s = 3,77 effective cores = 36,72 us/message | retained heap 1 509 504 bytes | working set 63 635 456 bytes
[Scenario C1 run 1] 100000 messages in 1,140s = 87 708 msgs/sec | 3 337 672 bytes allocated total = 33,4 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 3,156s = 2,77 effective cores = 31,56 us/message | retained heap 2 140 712 bytes | working set 69 005 312 bytes
[Scenario C1 run 2] 100000 messages in 1,167s = 85 715 msgs/sec | 3 330 472 bytes allocated total = 33,3 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 3,000s = 2,57 effective cores = 30,00 us/message | retained heap 1 159 336 bytes | working set 70 426 624 bytes
[Scenario C2 run 1] 100000 messages in 2,632s = 37 992 msgs/sec | 15 669 096 bytes allocated total = 156,7 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,10/0,00/0,00 (raw deltas 1/0/0) | CPU 7,172s = 2,72 effective cores = 71,72 us/message | retained heap 1 354 032 bytes | working set 75 137 024 bytes
[Scenario C2 run 2] 100000 messages in 2,613s = 38 264 msgs/sec | 16 144 920 bytes allocated total = 161,4 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,10/0,00/0,00 (raw deltas 1/0/0) | CPU 7,203s = 2,76 effective cores = 72,03 us/message | retained heap 1 344 176 bytes | working set 70 033 408 bytes
[Scenario D run 1] 100000 messages in 1,361s = 73 484 msgs/sec | 3 416 624 bytes allocated total = 34,2 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 3,688s = 2,71 effective cores = 36,88 us/message | retained heap 1 311 664 bytes | working set 66 195 456 bytes
[Scenario D run 2] 100000 messages in 1,308s = 76 459 msgs/sec | 3 255 136 bytes allocated total = 32,6 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 3,531s = 2,70 effective cores = 35,31 us/message | retained heap 1 296 872 bytes | working set 66 326 528 bytes
[Scenario E run 1] 1 121 576 bytes allocated for 20000 sends = 56,08 bytes/message | Gen0 raw delta 0 | CPU 0,031s = 1,48 effective cores = 1,56 us/message | retained heap 1 289 104 bytes | working set 62 152 704 bytes
[Scenario E run 2] 1 121 576 bytes allocated for 20000 sends = 56,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,15 effective cores = 0,78 us/message | retained heap 1 283 576 bytes | working set 62 074 880 bytes
[Scenario E-diag run 1] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,000s = 0,00 effective cores = 0,00 us/message | retained heap 1 283 344 bytes | working set 62 074 880 bytes
[Scenario E-diag run 2] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,11 effective cores = 0,78 us/message | retained heap 1 283 840 bytes | working set 62 074 880 bytes

MiNET all-16 run 2

Process: GC.IsServerGC=False, ProcessorCount=16
[Scenario A run 1] 200000 datagrams in 0,236s = 848 963 datagrams/sec | 1 336 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,297s = 1,26 effective cores = 1,48 us/message | retained heap 397 776 bytes | working set 49 037 312 bytes
[Scenario A run 2] 200000 datagrams in 0,126s = 1 585 399 datagrams/sec | 1 576 bytes allocated total = 0,0 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,141s = 1,11 effective cores = 0,70 us/message | retained heap 433 040 bytes | working set 49 664 000 bytes
[Scenario B run 1] 20000 datagrams in 0,181s = 110 785 datagrams/sec | 5 328 bytes allocated total = 0,3 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,641s = 3,55 effective cores = 32,03 us/message | retained heap 1 489 176 bytes | working set 62 320 640 bytes
[Scenario B run 2] 20000 datagrams in 0,165s = 121 042 datagrams/sec | 6 080 bytes allocated total = 0,3 bytes/datagram | Gen0/Gen1/Gen2 per 10k datagrams = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 0,562s = 3,40 effective cores = 28,12 us/message | retained heap 1 511 384 bytes | working set 62 570 496 bytes
[Scenario C1 run 1] 100000 messages in 0,930s = 107 518 msgs/sec | 3 257 592 bytes allocated total = 32,6 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 3,234s = 3,48 effective cores = 32,34 us/message | retained heap 6 307 152 bytes | working set 66 957 312 bytes
[Scenario C1 run 2] 100000 messages in 0,805s = 124 173 msgs/sec | 3 000 520 bytes allocated total = 30,0 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 2,281s = 2,83 effective cores = 22,81 us/message | retained heap 5 340 384 bytes | working set 74 031 104 bytes
[Scenario C2 run 1] 100000 messages in 2,601s = 38 442 msgs/sec | 26 020 008 bytes allocated total = 260,2 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,20/0,00/0,00 (raw deltas 2/0/0) | CPU 6,453s = 2,48 effective cores = 64,53 us/message | retained heap 1 569 200 bytes | working set 78 807 040 bytes
[Scenario C2 run 2] 100000 messages in 2,602s = 38 429 msgs/sec | 24 593 800 bytes allocated total = 245,9 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,20/0,00/0,00 (raw deltas 2/0/0) | CPU 6,734s = 2,59 effective cores = 67,34 us/message | retained heap 1 349 336 bytes | working set 69 844 992 bytes
[Scenario D run 1] 100000 messages in 1,502s = 66 586 msgs/sec | 6 377 552 bytes allocated total = 63,8 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 3,953s = 2,63 effective cores = 39,53 us/message | retained heap 1 301 136 bytes | working set 68 362 240 bytes
[Scenario D run 2] 100000 messages in 1,493s = 66 962 msgs/sec | 6 491 736 bytes allocated total = 64,9 bytes/message | Gen0/Gen1/Gen2 per 10k messages = 0,00/0,00/0,00 (raw deltas 0/0/0) | CPU 3,766s = 2,52 effective cores = 37,66 us/message | retained heap 1 300 128 bytes | working set 68 718 592 bytes
[Scenario E run 1] 1 121 576 bytes allocated for 20000 sends = 56,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 0,62 effective cores = 0,78 us/message | retained heap 1 291 360 bytes | working set 65 671 168 bytes
[Scenario E run 2] 1 121 576 bytes allocated for 20000 sends = 56,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,18 effective cores = 0,78 us/message | retained heap 1 289 752 bytes | working set 65 675 264 bytes
[Scenario E-diag run 1] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,23 effective cores = 0,78 us/message | retained heap 1 289 712 bytes | working set 65 675 264 bytes
[Scenario E-diag run 2] 561 576 bytes allocated for 20000 sends = 28,08 bytes/message | Gen0 raw delta 0 | CPU 0,016s = 1,30 effective cores = 0,78 us/message | retained heap 1 290 008 bytes | working set 65 675 264 bytes

Go runs backing the P rows

pion pinned run 1

Process: go go1.25.0, GOMAXPROCS=3, ProcessorCount=3
[Scenario P1 run 1] 100000 messages in 2.461s = 40633 msgs/sec | 1512345672 GO-HEAP bytes allocated total = 15123.5 bytes/message (whole stack, pure Go) | GC count delta 782 | CPU 6.438s = 2.62 effective cores = 64.38 us/message | retained GO heap 1271152 bytes | sys 16142336 bytes
[Scenario P1 run 2] 100000 messages in 2.511s = 39830 msgs/sec | 1511810688 GO-HEAP bytes allocated total = 15118.1 bytes/message (whole stack, pure Go) | GC count delta 753 | CPU 6.453s = 2.57 effective cores = 64.53 us/message | retained GO heap 1257808 bytes | sys 20598784 bytes
  NOTE [P2]: whether pion/sctp fragments a 1200B message depends on its effective MTU; not byte-verified on the wire in this run.
[Scenario P2 run 1] 100000 messages in 4.673s = 21398 msgs/sec | 2057570536 GO-HEAP bytes allocated total = 20575.7 bytes/message (whole stack, pure Go) | GC count delta 919 | CPU 12.312s = 2.63 effective cores = 123.12 us/message | retained GO heap 1321920 bytes | sys 20860928 bytes
[Scenario P2 run 2] 100000 messages in 4.501s = 22219 msgs/sec | 2055347944 GO-HEAP bytes allocated total = 20553.5 bytes/message (whole stack, pure Go) | GC count delta 948 | CPU 11.938s = 2.65 effective cores = 119.38 us/message | retained GO heap 1318376 bytes | sys 20860928 bytes
  WARNING [PD]: not all messages delivered within 60s. received=100551/101000
[Scenario PD run 1] 100000 messages in 61.889s = 1616 msgs/sec | 1522980304 GO-HEAP bytes allocated total = 15229.8 bytes/message (whole stack, pure Go) | GC count delta 823 | CPU 85.578s = 1.38 effective cores = 855.78 us/message | retained GO heap 1250312 bytes | sys 20860928 bytes
  WARNING [PD]: not all messages delivered within 60s. received=100549/101000
[Scenario PD run 2] 100000 messages in 63.453s = 1576 msgs/sec | 1523826920 GO-HEAP bytes allocated total = 15238.3 bytes/message (whole stack, pure Go) | GC count delta 739 | CPU 86.969s = 1.37 effective cores = 869.69 us/message | retained GO heap 1273024 bytes | sys 33509376 bytes

pion pinned run 2 (P1)

Process: go go1.25.0, GOMAXPROCS=3, ProcessorCount=3
[Scenario P1 run 1] 100000 messages in 2.663s = 37551 msgs/sec | 1512319512 GO-HEAP bytes allocated total = 15123.2 bytes/message (whole stack, pure Go) | GC count delta 798 | CPU 6.812s = 2.56 effective cores = 68.12 us/message | retained GO heap 1235176 bytes | sys 16404480 bytes
[Scenario P1 run 2] 100000 messages in 2.960s = 33779 msgs/sec | 1513588112 GO-HEAP bytes allocated total = 15135.9 bytes/message (whole stack, pure Go) | GC count delta 761 | CPU 7.484s = 2.53 effective cores = 74.84 us/message | retained GO heap 1246632 bytes | sys 16404480 bytes

pion pinned run 2 (P2)

Process: go go1.25.0, GOMAXPROCS=3, ProcessorCount=3
  NOTE [P2]: whether pion/sctp fragments a 1200B message depends on its effective MTU; not byte-verified on the wire in this run.
[Scenario P2 run 1] 100000 messages in 3.341s = 29935 msgs/sec | 2059849280 GO-HEAP bytes allocated total = 20598.5 bytes/message (whole stack, pure Go) | GC count delta 924 | CPU 8.953s = 2.68 effective cores = 89.53 us/message | retained GO heap 1305256 bytes | sys 20598784 bytes
[Scenario P2 run 2] 100000 messages in 5.125s = 19511 msgs/sec | 2060550696 GO-HEAP bytes allocated total = 20605.5 bytes/message (whole stack, pure Go) | GC count delta 942 | CPU 12.969s = 2.53 effective cores = 129.69 us/message | retained GO heap 1333048 bytes | sys 20598784 bytes

Java pinned run 1

Process: OpenJDK 64-Bit Server VM 21.0.11, ProcessorCount=16
JVM args: -Xms2g -Xmx2g -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:+PerfDisableSharedMem
Artifact: dev.kastle.webrtc:webrtc-java:1.0.4 (native libwebrtc via JNI, windows-x86_64)
[Scenario J1 run 1] 100000 messages in 3,472s = 28 801 msgs/sec | 8 801 400 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 5,984s = 1,72 effective cores = 59,84 us/message | retained JAVA heap 10 044 648 bytes | working set 2 258 714 624 bytes
[Scenario J1 run 2] 100000 messages in 3,483s = 28 709 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 6,172s = 1,77 effective cores = 61,72 us/message | retained JAVA heap 10 525 176 bytes | working set 2 260 041 728 bytes
[Scenario J2 run 1] 100000 messages in 4,866s = 20 549 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 9,031s = 1,86 effective cores = 90,31 us/message | retained JAVA heap 10 525 568 bytes | working set 2 260 348 928 bytes
[Scenario J2 run 2] 100000 messages in 4,648s = 21 516 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 8,797s = 1,89 effective cores = 87,97 us/message | retained JAVA heap 10 522 960 bytes | working set 2 260 738 048 bytes
[Scenario JD run 1] 100000 messages in 3,292s = 30 375 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 5,891s = 1,79 effective cores = 58,91 us/message | retained JAVA heap 10 522 896 bytes | working set 2 260 467 712 bytes
[Scenario JD run 2] 100000 messages in 3,413s = 29 297 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 6,328s = 1,85 effective cores = 63,28 us/message | retained JAVA heap 10 523 632 bytes | working set 2 261 012 480 bytes

Java pinned run 2

Process: OpenJDK 64-Bit Server VM 21.0.11, ProcessorCount=16
JVM args: -Xms2g -Xmx2g -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:+PerfDisableSharedMem
Artifact: dev.kastle.webrtc:webrtc-java:1.0.4 (native libwebrtc via JNI, windows-x86_64)
[Scenario J1 run 1] 100000 messages in 3,957s = 25 270 msgs/sec | 8 801 400 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 6,359s = 1,61 effective cores = 63,59 us/message | retained JAVA heap 10 044 312 bytes | working set 2 257 367 040 bytes
[Scenario J1 run 2] 100000 messages in 3,415s = 29 281 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 5,969s = 1,75 effective cores = 59,69 us/message | retained JAVA heap 10 524 456 bytes | working set 2 260 414 464 bytes
[Scenario J2 run 1] 100000 messages in 4,934s = 20 267 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 9,172s = 1,86 effective cores = 91,72 us/message | retained JAVA heap 10 524 792 bytes | working set 2 259 460 096 bytes
[Scenario J2 run 2] 100000 messages in 4,799s = 20 839 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 9,031s = 1,88 effective cores = 90,31 us/message | retained JAVA heap 10 522 176 bytes | working set 2 260 230 144 bytes
[Scenario JD run 1] 100000 messages in 3,275s = 30 537 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 5,828s = 1,78 effective cores = 58,28 us/message | retained JAVA heap 10 522 232 bytes | working set 2 260 180 992 bytes
[Scenario JD run 2] 100000 messages in 3,321s = 30 113 msgs/sec | 8 800 000 JAVA-HEAP bytes allocated total = 88,0 bytes/message (native allocs invisible) | GC young/old deltas 0/0 | CPU 5,813s = 1,75 effective cores = 58,13 us/message | retained JAVA heap 10 522 912 bytes | working set 2 260 197 376 bytes

Node pinned run 1

Process: node v24.14.1, ProcessorCount=16
Flags: --expose-gc
Artifact: node-datachannel 0.32.3 wrapping libdatachannel 0.24.2 (usrsctp + OpenSSL, prebuilt win32-x64)
[Scenario L1 run 1] 100000 messages in 2.422s = 41,280 msgs/sec | CPU 6.141s = 2.54 effective cores = 61.41 us/message | retained JS heap 4,724,448 bytes (native invisible) | working set 56,983,552 bytes
[Scenario L1 run 2] 100000 messages in 2.354s = 42,475 msgs/sec | CPU 5.922s = 2.52 effective cores = 59.22 us/message | retained JS heap 4,737,160 bytes (native invisible) | working set 61,456,384 bytes
[Scenario L2 run 1] 100000 messages in 4.192s = 23,856 msgs/sec | CPU 10.735s = 2.56 effective cores = 107.35 us/message | retained JS heap 4,782,664 bytes (native invisible) | working set 61,882,368 bytes
[Scenario L2 run 2] 100000 messages in 4.170s = 23,979 msgs/sec | CPU 10.937s = 2.62 effective cores = 109.37 us/message | retained JS heap 4,790,304 bytes (native invisible) | working set 62,152,704 bytes
[Scenario LD run 1] 100000 messages in 2.462s = 40,625 msgs/sec | CPU 5.922s = 2.41 effective cores = 59.22 us/message | retained JS heap 4,824,640 bytes (native invisible) | working set 61,976,576 bytes
[Scenario LD run 2] 100000 messages in 2.379s = 42,028 msgs/sec | CPU 6.063s = 2.55 effective cores = 60.63 us/message | retained JS heap 4,829,552 bytes (native invisible) | working set 62,468,096 bytes

Node pinned run 2

Process: node v24.14.1, ProcessorCount=16
Flags: --expose-gc
Artifact: node-datachannel 0.32.3 wrapping libdatachannel 0.24.2 (usrsctp + OpenSSL, prebuilt win32-x64)
[Scenario L1 run 1] 100000 messages in 2.371s = 42,168 msgs/sec | CPU 5.796s = 2.44 effective cores = 57.96 us/message | retained JS heap 4,723,440 bytes (native invisible) | working set 56,565,760 bytes
[Scenario L1 run 2] 100000 messages in 2.259s = 44,258 msgs/sec | CPU 5.751s = 2.55 effective cores = 57.51 us/message | retained JS heap 4,731,936 bytes (native invisible) | working set 61,362,176 bytes
[Scenario L2 run 1] 100000 messages in 3.960s = 25,250 msgs/sec | CPU 10.531s = 2.66 effective cores = 105.31 us/message | retained JS heap 4,786,288 bytes (native invisible) | working set 62,582,784 bytes
[Scenario L2 run 2] 100000 messages in 4.018s = 24,887 msgs/sec | CPU 10.437s = 2.60 effective cores = 104.37 us/message | retained JS heap 4,791,568 bytes (native invisible) | working set 63,373,312 bytes
[Scenario LD run 1] 100000 messages in 2.291s = 43,644 msgs/sec | CPU 5.906s = 2.58 effective cores = 59.06 us/message | retained JS heap 4,823,568 bytes (native invisible) | working set 63,676,416 bytes
[Scenario LD run 2] 100000 messages in 2.305s = 43,393 msgs/sec | CPU 5.765s = 2.50 effective cores = 57.65 us/message | retained JS heap 4,831,224 bytes (native invisible) | working set 63,062,016 bytes

Rust pinned run 1

Process: rustc (release), ProcessorCount=16
Artifact: crates.io rtc 0.21.0-alpha.1 (webrtc-rs/rtc, sans-I/O Rust)

=== Scenario R1: webrtc-rs/rtc reliable channel, 1000B messages (mirrors C1/J1/L1/P1) ===
[Scenario R1 run 1] 100000 messages in 1.334s = 74950 msgs/sec | 2205358047 RUST-HEAP bytes allocated total = 22053.6 bytes/message (whole process, counting allocator) | CPU 1.344s = 1.01 effective cores = 13.44 us/message
[Scenario R1 run 2] 100000 messages in 1.343s = 74437 msgs/sec | 2205358047 RUST-HEAP bytes allocated total = 22053.6 bytes/message (whole process, counting allocator) | CPU 1.344s = 1.00 effective cores = 13.44 us/message

=== Scenario R2: webrtc-rs/rtc reliable channel, 1800B messages (mirrors C2/J2/L2/P2) ===
[Scenario R2 run 1] 100000 messages in 3.507s = 28511 msgs/sec | 3816531086 RUST-HEAP bytes allocated total = 38165.3 bytes/message (whole process, counting allocator) | CPU 3.453s = 0.98 effective cores = 34.53 us/message
[Scenario R2 run 2] 100000 messages in 3.735s = 26774 msgs/sec | 3816531342 RUST-HEAP bytes allocated total = 38165.3 bytes/message (whole process, counting allocator) | CPU 3.625s = 0.97 effective cores = 36.25 us/message

=== Scenario RD: webrtc-rs/rtc unreliable channel (maxRetransmits 0, unordered), 1000B messages (mirrors D/JD/LD/PD) ===
[Scenario RD run 1] 100000 messages in 3.688s = 27116 msgs/sec | 2995226926 RUST-HEAP bytes allocated total = 29952.3 bytes/message (whole process, counting allocator) | CPU 3.625s = 0.98 effective cores = 36.25 us/message
[Scenario RD run 2] 100000 messages in 3.925s = 25475 msgs/sec | 2995244717 RUST-HEAP bytes allocated total = 29952.4 bytes/message (whole process, counting allocator) | CPU 3.719s = 0.95 effective cores = 37.19 us/message

Rust pinned run 2

Process: rustc (release), ProcessorCount=16
Artifact: crates.io rtc 0.21.0-alpha.1 (webrtc-rs/rtc, sans-I/O Rust)

=== Scenario R1: webrtc-rs/rtc reliable channel, 1000B messages (mirrors C1/J1/L1/P1) ===
[Scenario R1 run 1] 100000 messages in 1.475s = 67782 msgs/sec | 2205358303 RUST-HEAP bytes allocated total = 22053.6 bytes/message (whole process, counting allocator) | CPU 1.453s = 0.98 effective cores = 14.53 us/message
[Scenario R1 run 2] 100000 messages in 1.628s = 61442 msgs/sec | 2205358559 RUST-HEAP bytes allocated total = 22053.6 bytes/message (whole process, counting allocator) | CPU 1.609s = 0.99 effective cores = 16.09 us/message

=== Scenario R2: webrtc-rs/rtc reliable channel, 1800B messages (mirrors C2/J2/L2/P2) ===
[Scenario R2 run 1] 100000 messages in 3.630s = 27551 msgs/sec | 3816531342 RUST-HEAP bytes allocated total = 38165.3 bytes/message (whole process, counting allocator) | CPU 3.547s = 0.98 effective cores = 35.47 us/message
[Scenario R2 run 2] 100000 messages in 3.857s = 25926 msgs/sec | 3816551821 RUST-HEAP bytes allocated total = 38165.5 bytes/message (whole process, counting allocator) | CPU 3.656s = 0.95 effective cores = 36.56 us/message

=== Scenario RD: webrtc-rs/rtc unreliable channel (maxRetransmits 0, unordered), 1000B messages (mirrors D/JD/LD/PD) ===
[Scenario RD run 1] 100000 messages in 3.686s = 27131 msgs/sec | 2995226926 RUST-HEAP bytes allocated total = 29952.3 bytes/message (whole process, counting allocator) | CPU 3.547s = 0.96 effective cores = 35.47 us/message
[Scenario RD run 2] 100000 messages in 3.711s = 26947 msgs/sec | 2995226926 RUST-HEAP bytes allocated total = 29952.3 bytes/message (whole process, counting allocator) | CPU 3.547s = 0.96 effective cores = 35.47 us/message

Rust-async pinned run 1

Process: rustc (release), tokio multi-thread default, ProcessorCount=16
Artifact: crates.io webrtc 0.17.2 (webrtc-rs async/tokio, the deployed Rust stack)

=== Scenario W1: webrtc-rs async reliable channel, 1000B messages (mirrors C1/J1/L1/P1/R1) ===
[Scenario W1 run 1] 100000 messages in 3.876s = 25801 msgs/sec | 6990482621 RUST-HEAP bytes allocated total = 69904.8 bytes/message (whole process, counting allocator) | CPU 9.641s = 2.49 effective cores = 96.41 us/message
[Scenario W1 run 2] 100000 messages in 3.947s = 25334 msgs/sec | 6978498866 RUST-HEAP bytes allocated total = 69785.0 bytes/message (whole process, counting allocator) | CPU 9.828s = 2.49 effective cores = 98.28 us/message

=== Scenario W2: webrtc-rs async reliable channel, 1800B messages (mirrors C2/J2/L2/P2/R2) ===
[Scenario W2 run 1] 100000 messages in 7.775s = 12862 msgs/sec | 13536831944 RUST-HEAP bytes allocated total = 135368.3 bytes/message (whole process, counting allocator) | CPU 18.688s = 2.40 effective cores = 186.88 us/message
[Scenario W2 run 2] 100000 messages in 7.015s = 14254 msgs/sec | 13552389110 RUST-HEAP bytes allocated total = 135523.9 bytes/message (whole process, counting allocator) | CPU 17.109s = 2.44 effective cores = 171.09 us/message

=== Scenario WD: webrtc-rs async unreliable channel (maxRetransmits 0, unordered), 1000B messages (mirrors D/JD/LD/PD/RD) ===
[Scenario WD run 1] 100000 messages in 3.507s = 28510 msgs/sec | 6959746587 RUST-HEAP bytes allocated total = 69597.5 bytes/message (whole process, counting allocator) | CPU 9.031s = 2.57 effective cores = 90.31 us/message
[Scenario WD run 2] 100000 messages in 3.704s = 27001 msgs/sec | 6940982822 RUST-HEAP bytes allocated total = 69409.8 bytes/message (whole process, counting allocator) | CPU 9.328s = 2.52 effective cores = 93.28 us/message

Rust-async pinned run 2

Process: rustc (release), tokio multi-thread default, ProcessorCount=16
Artifact: crates.io webrtc 0.17.2 (webrtc-rs async/tokio, the deployed Rust stack)

=== Scenario W1: webrtc-rs async reliable channel, 1000B messages (mirrors C1/J1/L1/P1/R1) ===
[Scenario W1 run 1] 100000 messages in 3.370s = 29677 msgs/sec | 7004847904 RUST-HEAP bytes allocated total = 70048.5 bytes/message (whole process, counting allocator) | CPU 8.578s = 2.55 effective cores = 85.78 us/message
[Scenario W1 run 2] 100000 messages in 3.876s = 25797 msgs/sec | 6943814098 RUST-HEAP bytes allocated total = 69438.1 bytes/message (whole process, counting allocator) | CPU 9.547s = 2.46 effective cores = 95.47 us/message

=== Scenario W2: webrtc-rs async reliable channel, 1800B messages (mirrors C2/J2/L2/P2/R2) ===
[Scenario W2 run 1] 100000 messages in 7.312s = 13677 msgs/sec | 13660319530 RUST-HEAP bytes allocated total = 136603.2 bytes/message (whole process, counting allocator) | CPU 17.875s = 2.44 effective cores = 178.75 us/message
[Scenario W2 run 2] 100000 messages in 6.957s = 14374 msgs/sec | 13651935676 RUST-HEAP bytes allocated total = 136519.4 bytes/message (whole process, counting allocator) | CPU 17.578s = 2.53 effective cores = 175.78 us/message

=== Scenario WD: webrtc-rs async unreliable channel (maxRetransmits 0, unordered), 1000B messages (mirrors D/JD/LD/PD/RD) ===
[Scenario WD run 1] 100000 messages in 3.321s = 30109 msgs/sec | 7022047577 RUST-HEAP bytes allocated total = 70220.5 bytes/message (whole process, counting allocator) | CPU 8.703s = 2.62 effective cores = 87.03 us/message
[Scenario WD run 2] 100000 messages in 3.358s = 29780 msgs/sec | 7023744925 RUST-HEAP bytes allocated total = 70237.4 bytes/message (whole process, counting allocator) | CPU 8.516s = 2.54 effective cores = 85.16 us/message
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment