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-java1.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 viaruntime.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-gorun1-affinity7.log,run2-P1-affinity7.log,run2-P2-affinity7.log, and theladder-P1-*.logfiles. - R rows - webrtc-rs/rtc via crates.io
rtc0.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 withlto = 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-rustrun1-affinity7.log,run2-affinity7.log, and theladder-R1-*.logfiles. - W rows - webrtc-rs/webrtc async via crates.io
webrtc0.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 viagathering_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 viatokio::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-asyncrun1-affinity7.log,run2-affinity7.log, and theladder-W1-*.logfiles.
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.
- 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 viasetImmediateinstead, because a spin would block the event loop that delivers itsonMessagecallbacks - 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 writecmd //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 viaGetLogicalProcessorInformationEx; 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
PeerConnectionFactoryper 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).
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 Releaseonce, then twicecmd /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 setupjavac -cp lib/webrtc-1.0.4.jar RtcPerfJava.java(jars sit inlib/, from the webrtc-java 1.0.4 GitHub release), then twicecmd /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 setupnpm install(pins node-datachannel 0.32.3), then twicecmd /c "start /affinity 7 /b /wait node --expose-gc harness.mjs". - Go P rows,
temp_auto/rtc-perf-go: one-time setuptemp_auto/tools/go/bin/go build -o rtcperfgo.exe ., then twicecmd /c "start /affinity 7 /b /wait rtcperfgo.exe". EnvSCENARIO=P1|P2|PDandMEMRUN=1select single scenarios / memory-run windows like the other harnesses. - Rust R rows,
temp_auto/rtc-perf-rust: one-time setupcargo build --release, then twicecmd /c "start /affinity 7 /b /wait target\release\rtc-perf-rust.exe". EnvSCENARIO=R1|R2|RDandMEMRUN=1as 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.
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.GetTotalAllocatedBytesover 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.
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, envSPIN=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.
-
MiNET RTC's send-queue budget is 4MB per association (
DefaultSendQueueBudgetBytes), andRtcDataChannel.Sendis 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=1on 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=3measures 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
/OSHeapExekernel heap tracing around a single- scenario harness run (memrunmode: 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.2src/impl/init.cppL127-130, and node-datachannel exposes nosetThreadPoolSize): 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:
PeerConnectionFactoryconstruction 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 forcedSystem.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.cpuUsagecannot 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.
Logs for every other configuration (all-16 runs, alternate masks and pacers) sit in the harness folders alongside the ones quoted below.
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.
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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