Repository navigation
Feature/v7 performance - #99
Merged
Merged
Conversation
Locks the v1 parameter-block wire format byte-for-byte with an independent reference encoder before any optimization work. Compressed frames are asserted structurally (type byte + frame layout + post-decompression payload) since gzip output varies by runtime. Also documents via test exclusion that scalar Type parameters are broken today (runtime type RuntimeType misses the exact-type map and falls to the serializer, which cannot serialize RuntimeType); fixed in a later stage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All changes are byte-identical on the wire (proven by golden tests) or fix frames no existing receiver could decode: - Memoize Type<->config-name resolution in NetExtensions (ConcurrentDictionary both directions; only successful Type lookups cached so late assembly loads still resolve). Removes 3 regex passes per complex parameter per call and the double Type.GetType in DefaultSerializer.Deserialize. - Fix NpChannel: BinaryReader/Writer now wrap the BufferedStream instead of the raw pipe stream, collapsing per-field pipe syscalls to one per message and fixing the never-disposed pipe stream leak. - Socket.NoDelay = true on client and accepted server sockets; both sides already buffer and flush once per message. - NpListener pipe buffers 512 -> 8192. - Replace TCP connect SpinWait busy-loop with ManualResetEventSlim wait. - DefaultCompressor: CompressionLevel.Fastest, pre-sized buffers. - ZkCrypto: create the symmetric algorithm once (same mode/padding/key/IV, identical ciphertext), per-direction locks, IDisposable. - Bug fix: string[] over compression threshold now writes CompressedUnknown (0x23, readable by every release since v1.5.0) instead of Unknown with a compressed payload no receiver could decode; null elements no longer NRE the size probe. - Bug fix: scalar Type parameters (runtime type RuntimeType) now map to the 0x12 Type code and send the value's config name; previously they fell to the serializer path and crashed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion fix - New MethodInvokerCompiler compiles service methods into Func<object, object[], object> via expression trees at AddService time; Host and InterceptChannel dispatch through the compiled delegate and fall back to MethodInfo.Invoke for byref methods. Compiled delegates throw raw exceptions; the existing conditional TargetInvocationException unwrap makes client-visible behavior identical (covered by new parity tests). - Async results: cached compiled Func<Task, object> Result getters replace per-call PropertyInfo.GetValue. - Client: cached compiled Task.FromResult<T> converters replace per-call reflection Invoke for async-signature methods. - ProxyFactory: proxy Type is created and its constructor compiled once per pooled builder; CreateProxy is now a delegate call instead of CreateTypeInfo + GetConstructor + ConstructorInfo.Invoke per proxy. - Bug fix (pre-existing since 5.5.0): System.Text.Json cannot serialize thrown exceptions (TargetSite), so any service exception killed the connection and clients saw EndOfStreamException. DefaultSerializer now registers an exception converter that preserves type, message, HResult, inner chain, and server stack trace, writing a JSON shape old receivers still parse without error. Also caches JsonSerializerOptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er writer - ServiceWire now ships lib/netstandard2.0 (unchanged 6.x dependency graph, so existing net48/netstandard consumers see no new binding redirects) and lib/net8.0 (no package dependencies; in-box APIs). AssemblyVersion pinned to 7.0.0.0 across TFMs. - net8.0-only, wire-byte-identical fast paths guarded by NET8_0_OR_GREATER: bulk span read/write for primitive arrays (little-endian bulk copy equals the per-element BinaryWriter bytes; BE platforms keep loops), stackalloc Guid writes, Aes.Create() for the ZK cipher (identical ciphertext), and RuntimeHelpers.GetUninitializedObject in the exception converter. - SHA256CryptoServiceProvider -> SHA256.Create() (obsolete on net8). - Opt-in TCP socket timeouts: TcpEndPoint.Receive/SendTimeoutMs for clients and TcpHost.Receive/SendTimeoutMs for accepted sockets; default 0 keeps infinite (pre-7.0) behavior. - Opt-in LoggerBase.PersistentFileWriter keeps the log file open between flushes; default false preserves open/append/close per flush. Both compiled assets pass the full suite including byte-for-byte goldens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nary DateTime) - ServiceSyncInfo gains CapabilityFlags (DataMember Order 5, plain int); servers advertise ProtocolCapabilities.WireV2 unless Host.EnableWireV2 is set false before AddService. Pre-7.0 clients ignore the extra member; pre-7.0 servers leave it 0 so 7.0 clients stay on the unchanged v1 path. Both mixed-version pairings therefore keep working over v1. - New framed messages: MethodInvocation2 (6) and Response2 (7), layout msgType | frameLength | correlationId | serviceKey+methodIdent (request) | status byte (response) | flags | payload. Correlation ids are per-channel Interlocked counters. - v2 parameter encodings: DateTime2/ArrayDateTime2 carry DateTime.ToBinary() (Kind-preserving, no string parse); all other codes byte-identical to v1. The v1 reader rejects v2 codes; the v1 encoder is textually untouched and its goldens still pass. - Host.ProcessInvocation refactored into shared stages (Materialize/ Execute/WriteReturn) reused verbatim by the new ProcessInvocation2, which buffers the framed payload up front: a payload that fails to decode is answered with a correlated error response and the connection survives (raw-socket tests prove frame-skip recovery and status-2 correlation). - Client detects the capability after SyncInterface; a stale process-wide cache entry claiming v2 against a downgraded server is evicted with a descriptive error on the first failed v2 exchange. - Full suite now exercises v2 end to end (TCP, NP, ZK, compression, async, byref) plus explicit v1 fallback via EnableWireV2 = false. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the whole-round-trip lock for v2 channels. Callers serialize only on a write lock covering the frame write; a lazily started reader thread demultiplexes Response2 frames by correlation id into a pending-request table of TaskCompletionSources, so each caller blocks solely on its own response. v1 channels keep the original lock and thread profile exactly. Transport work this required: - Client and server now buffer reads and writes independently (two BufferedStreams over one NetworkStream/PipeStream). A single shared BufferedStream cannot serve a concurrent reader and writer, and with pipelining the server read of one request prefetches the next into the read buffer, making the response write throw on non-seekable streams. - NpChannel opens the pipe with PipeOptions.Asynchronous: a synchronous pipe handle serializes concurrent ReadFile/WriteFile, which deadlocks the reader thread against writers. - Explicit flushes added wherever the old shared buffer flushed implicitly on the read that followed a write: client SyncInterface and ZK handshake, server ZkSession responses. - Dispose: best-effort terminate+flush under the write lock, defensive disposal of both stream wrappers (second one flushes into a closed transport), ZkCrypto disposal, and faulting of all pending calls. Reader loop faults every in-flight call on any stream error. - Per-channel method/return-type caches are now ConcurrentDictionary since v2 calls no longer serialize. New PipeliningTests prove correct response pairing across 24 concurrent callers on one shared proxy, serial per-connection server execution with no lost updates, and pending-call faulting on dispose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mentation
- New src/Tests/Interop: a shared contract assembly (identical type config
names on both sides), an Old601Peer console app pinned to the published
ServiceWire 6.0.1 NuGet package, and an InteropTests xunit driver that
spawns the old peer as a separate process. Matrix covered: {6.0.1 server
with 7.0 client, 7.0 server with 6.0.1 client} x {TCP, named pipes} x
{plain, compression}, plus the in-place downgrade scenario proving a
stale cached WireV2 capability yields a descriptive error, cache
eviction, and successful renegotiation over v1.
- The battery exercises the one-way CompressedUnknown path (7.0 sender to
6.0.1 receiver) and documents why a 6.0.1 peer must never send a large
compressed string[] (the pre-7.0 sender bug) and why the throw test only
runs against 7.0 servers (6.0.1 servers cannot serialize thrown
exceptions with the default serializer).
- CI workflow installs the 8.0.x runtime for the old peer alongside 10.0.x;
interop projects are in the solution so dotnet test runs them on both OS
jobs.
- README history entry and NuGet release notes for 7.0.0, including the
strict-custom-serializer caveat and mixed-fleet guidance
(Host.EnableWireV2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… results Performance measurement against the published 6.0.1 package drove three design corrections: - Replaced the dedicated reader thread with a reader-seat (leader/follower) demux: the caller holding the seat drains response frames, completing other callers by correlation id, until its own arrives. An uncontended caller reads inline with zero thread handoff (the dedicated thread cost a cross-thread wakeup on every sequential call); followers wake exactly on seat release because the server answers in request order. - Named-pipe channels stay on the v1 wire (AllowWireV2 false): sync pipe handles cannot overlap reads and writes, so v2 buys no pipelining there, and an overlapped handle costs more per op than pipelining recovers locally. Pipes get all Tier 1 wins without the v2 frame cost. - Added client-side TcpEndPoint.UseWireV2 (default true) alongside the server-side Host.EnableWireV2 opt-out, and eliminated payload ToArray copies on the v2 request/response paths (GetBuffer + length writes). Measured loopback results (5k-call batches, net8.0) vs 6.0.1: NP seq small calls +50% (39.7k -> 59.5k calls/s), NP int[100] echo +920% (5.8k -> 58.8k), NP DateTime +53%, NP 8-thread +38%; TCP 8-thread improved (high variance, up to 2x), TCP DateTime level, TCP seq small calls about -20% from v2 framing - documented with both opt-outs in the README. PipeliningTests now exercise true pipelining over TCP and correct pairing over serialized named pipes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss fixes - Named-pipe channels now default to the v2 wire like TCP. Their synchronous handles cannot overlap a read with a write, so v2 exchanges on a pipe serialize per channel (SupportsConcurrentStreamIO false) instead of pipelining; pipes gain v2 decode-error resilience and binary DateTime on top of the Tier 1 buffering wins. New NpEndPoint.UseWireV2 (default true) joins TcpEndPoint.UseWireV2 and Host.EnableWireV2 as v1 opt-outs. - Benchmark harness repairs (pre-existing): TcpBenchmarks and NamedPipesBenchmarks created hosts in their constructors, which also run in BenchmarkDotNet's orchestrating process during validation - the held ports made every TCP benchmark fail with address-in-use, and a stray pipe host could serve the measured client. Hosts now start in GlobalSetup (child process only), ConnectionBenchmarks got a distinct port and pipe name, and the project forces 64-bit so the net48 child no longer dies with BadImageFormatException. - README updated for the v2-default policy, the three opt-outs, and measured results. All suites green: 65 unit tests x net10.0/net48, 7 interop tests vs 6.0.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the out-of-support net6.0 benchmark job with net10.0 and upgrades BenchmarkDotNet 0.14.0 -> 0.15.8 for the Net10_0 runtime moniker. .NET 8.0 remains the ratio baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TcpBenchmarks now binds OS-assigned free ports per child process instead of fixed ports that fail with address-in-use when TIME_WAIT connections from the previous child linger. ConnectionBenchmarks probes a fresh free port per operation and bounds its jobs to explicit invocation counts (each operation opens a real TCP connection; unbounded runs exhausted the 16K Windows ephemeral port pool within seconds because closed connections linger in TIME_WAIT for minutes). All 42 benchmarks now measure on .NET 10, .NET 8, and .NET Framework 4.8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in README - Service method invokers now compile on each method's first invocation instead of eagerly at AddService, which cost about half a millisecond per service registration and penalized short-lived hosts (visible in the per-operation connection benchmarks). Byref methods cache null and keep the reflection path. Same change in InterceptChannel. - README gains a measured 6.0.1 vs 7.0.0 comparison: identical modernized BenchmarkDotNet 0.15.8 suite, same machine and day, across .NET 10, .NET 8, and .NET Framework 4.8. Steady-state named-pipe calls are 43-70% faster, TCP calls 13-47% faster, and TCP connection setup ~14x faster on modern runtimes (the 6.x connect spin-wait degraded to ~15 ms there). Honest caveats included: named-pipe connection setup is 0.1-0.2 ms slower, and the v2 frame trades a few microseconds and ~1 KB per call for pipelining and decode-error resilience, with v1 opt-outs available. The 6.0.1 baseline was produced from an uncommitted git worktree of master carrying the same benchmark harness changes. Note for future runs: BenchmarkDotNet resolves the benchmark csproj from the current working directory, so runs must execute with CWD inside the intended checkout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



ServiceWire 7.0.0 — performance release, negotiated wire protocol v2, and a full user guide
A major performance release plus the documentation to go with it. Steady-state calls are 43–70% faster than 6.0.1 on named pipes and 13–47% faster on TCP; TCP connection setup is roughly 14× faster on .NET 8 and 10.
Every mixed-version pairing keeps working. The new v2 wire activates only when both ends are 7.0, so a fleet can be upgraded in any order with no flag day.
📖 Documentation
The wiki is superseded by a versioned user guide that lives in the repo and travels with the code.
docs/user-guide.md is the hub, linked prominently from the README, with 11 topic pages:
ISerializer,ICompressor, when to compressHost.Statussupervisor pattern,ILog/IStats, MEL adapter13 hand-authored SVG diagrams in
docs/images/— call path, frame layouts, ZK handshake sequence, host status lifecycle, benchmark chart. All relative links and heading anchors are verified; all SVGs are XML-valid and render in both GitHub themes.Behaviour verified against the built library while writing these, correcting long-standing assumptions:
T Echo<T>(T)cannot be proxied)+=— there is no callback channelout decimal/ref GuidthrowNotSupportedExceptionat construction, not at call timeThe README is restructured around a quick start and the guide link; the full version history is preserved, and the 2024 benchmark run moved into
docs/performance.mdrather than being dropped.⚡ Internal optimizations
v1 wire format is unchanged, proven byte-identical by golden tests.
Type.GetTypelookups inDefaultSerializer.BufferedStream, collapsing dozens of per-field pipe syscalls per call into one (and fixing a pipe stream that was never disposed).Socket.NoDelayon client and accepted sockets to eliminate Nagle and delayed-ACK latency; both sides already buffer and flush once per message.MethodInfo.Invoke(byref methods fall back to reflection); async results through compiledTask.Resultgetters; clientTask.FromResultwrapping through compiled converters. Client-visible exception behaviour is unchanged and covered by parity tests.CompressionLevel.Fastestin the default compressor, and reused ZK cipher instances (identical ciphertext).🔌 Wire protocol v2
Default on both transports, negotiated per connection, never sent to a 6.x peer.
ServiceSyncInfo.CapabilityFlagsmember.MethodInvocation2/Response2messages with length prefixes and correlation ids: a request payload that fails to decode is answered with a correlated error response instead of desynchronizing the stream and killing the connection.DateTimevalues travel asToBinary()— Kind-preserving, no string parsing.DateTimetransfer on top of the Tier 1 buffering wins.Host.EnableWireV2 = false(beforeAddService), andTcpEndPoint.UseWireV2 = false/NpEndPoint.UseWireV2 = falseper client.🐛 Bug fixes
string[]above the compression threshold was written with a type code no receiver could decode; it now usesCompressedUnknown, which every release since 1.5.0 can read.Typeparameters crashed the default serializer; they now use the wire format'sTypecode.TargetSite), which killed the connection whenever a service method threw with the default serializer. A converter now preserves the exception type, message,HResult, inner chain, and server stack trace.📦 Packaging and opt-ins
The package now multi-targets:
netstandard2.0keeps the exact 6.x dependency graph, so existing .NET Framework and .NET Standard consumers see no new binding redirects.net8.0has no package dependencies, plus span-based fast paths that produce identical wire bytes.Assembly version is pinned at
7.0.0.0across both TFMs so mixed loads bind cleanly.Opt-in additions, all defaulting to prior behaviour:
ReceiveTimeoutMs,SendTimeoutMsTcpEndPoint,TcpHost0— infinite, as beforePersistentFileWriterLoggerBasefalseEnableWireV2HosttrueUseWireV2TcpEndPoint,NpEndPointtrueThis is a major version for exactly one reason:
ServiceSyncInfogained a member.Every tolerant serializer handles this — System.Text.Json ignores unknown members, protobuf-net skips unknown field numbers, Newtonsoft ignores extras by default. A strict custom
ISerializeron a 6.x client, configured to reject unmapped members or hand-coding the field list, may need updating. It is declared asintrather than an enum so every serializer sees a plain scalar.Compatibility matrix:
📊 Benchmarks
Identical suite (BenchmarkDotNet 0.15.8), same machine, same day: Windows 11, AMD Ryzen Threadripper PRO 5975WX, .NET SDK 10.0.204. Means are microseconds per call; lower is better.
Steady state — persistent host and client
Connection setup — host and client built and torn down per operation
The 6.0.1
TcpConnfigure is real — the old connect path busy-waited onSpinWait.SpinUntil, which degrades badly on modern runtimes. Named-pipe connection setup is 0.1–0.2 ms slower in 7.0.0 (split read/write buffered streams, larger pipe buffers); the per-call savings repay that within a handful of calls on any connection that is actually used.Shapes the suite does not cover, measured separately: a client sending an
int[1000]over a named pipe is roughly 10× faster (the 6.x pipe client issued one write syscall per array element), concurrent callers sharing one TCP proxy gain up to 2× from v2 pipelining, andDateTime-heavy payloads avoid string formatting and parsing entirely.✅ Testing
net10.0andnet48🔍 Review notes and follow-ups
Surfaced while documenting; none are regressions introduced by this branch except the first, and all are judged non-blocking.
Host.ProcessInvocation2andStreamingChannel.ReadOneResponseFrameboth callReadBytes(frameLength - header)with no upper bound, so a corrupt or hostile length allocates up to ~2 GB before anything validates it. Low severity under the trusted-peer model, but aMaxFrameBytesguard is cheap and worth a follow-up.ZkProtocol.CryptRandis not cryptographically random (pre-existing since 2.0.0). It usesnew Random(DateTime.Now.Millisecond)— roughly 1000 possible seeds — for salts, ephemerals and session-key material.docs/security.mdnow states this limitation plainly and recommends tunnelling anything crossing an untrusted network rather than implying audited-SRP strength.IsDebugEnabled()returnstruefor any customILog(pre-existing). Injecting a Serilog or MEL adapter makes ServiceWire build every debug string unconditionally, including base64 payloads on ZK connections — a hot-path cost and a place secrets can reach a log. Documented as a caveat indocs/observability.md.StatsandLoggerresolve their default directory differently (pre-existing).StatsusesAssembly.GetEntryAssembly().Location, which can be null and is empty under single-file publish, sonew Stats()can throw wherenew Logger()does not.One decision worth a look
The README is packed into the NuGet package, so the hero image uses an absolute
raw.githubusercontent.com/.../master/...URL. That renders on GitHub today but 404s until this branch merges tomaster, and nuget.org may not render SVG at all. A PNG would be safer for the NuGet surface if we want it guaranteed.