From b0aeca7c61fb687c52f141f8138fe8499912523b Mon Sep 17 00:00:00 2001 From: Marci Date: Thu, 27 Aug 2026 15:41:43 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=9F=BA=E5=87=86=E6=89=A9=E5=AE=B9?= =?UTF-8?q?=E2=80=94=E2=80=94Codec=EF=BC=88XmlDocumentCodec=EF=BC=89?= =?UTF-8?q?=E4=B8=8E=E7=AB=AF=E5=88=B0=E7=AB=AF=EF=BC=88=E5=9B=9E=E7=8E=AF?= =?UTF-8?q?=E5=90=9E=E5=90=90/=E5=BE=80=E8=BF=94=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=9F=BA=E5=87=86=E9=A1=B9=E7=9B=AE=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=E8=A7=A3=E5=86=B3=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- README.en.md | 3 +- README.md | 3 +- StreamFrame.slnx | 3 + bench/README.md | 30 ++++- .../StreamFrame.Benchmarks/CodecBenchmarks.cs | 63 +++++++++ .../EndToEndBenchmarks.cs | 126 ++++++++++++++++++ .../StreamFrame.Benchmarks.csproj | 1 + bench/StreamFrame.Benchmarks/Utf8TextCodec.cs | 16 +++ 9 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 bench/StreamFrame.Benchmarks/CodecBenchmarks.cs create mode 100644 bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs create mode 100644 bench/StreamFrame.Benchmarks/Utf8TextCodec.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b66f558..c88350c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ## [Unreleased] ### 新增 -- 暂无 +- 基准测试扩容:`CodecBenchmarks`(XmlDocumentCodec 典型报文编解码开销)与 `EndToEndBenchmarks`(真实 TCP 回环的单向吞吐(双 framer)与往返延迟);基准项目纳入解决方案(IDE 可见,随解决方案构建;`dotnet test` 依 IsTestProject 机制自动跳过)。 ### 修复 - 暂无 diff --git a/README.en.md b/README.en.md index dc63cd8..03dd4c3 100644 --- a/README.en.md +++ b/README.en.md @@ -152,7 +152,8 @@ For production, enable `TcpKeepAlive = true`; for protocols with periodic traffi Measured with BenchmarkDotNet (details and how to reproduce in [bench/README.md](bench/README.md)): - **Streaming encode**: halves per-frame heap allocation (single vs. double buffer); 25–35% faster for small payloads, on par for large ones; -- **Frame decoding**: `LengthPrefixFramer` ≈10 ns/frame vs `StxEtxFramer` ≈1.1 µs/frame (byte-by-byte scanning) — prefer length-prefix for high-throughput scenarios. +- **Frame decoding**: `LengthPrefixFramer` ≈10 ns/frame vs `StxEtxFramer` ≈1.1 µs/frame (byte-by-byte scanning) — prefer length-prefix for high-throughput scenarios; +- **End-to-end** (real TCP loopback): one-way throughput ≈246k msgs/s (1KB messages, LengthPrefix), round-trip latency ≈63 µs; the XML codec costs 2–16 µs per message (400B–4KB) — serialization dominates, not framing. ## Supported frameworks diff --git a/README.md b/README.md index b96e73b..d1d0fcc 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ var conn = new StreamConnection(..., logger: loggerFactory.CreateLogg BenchmarkDotNet 实测(详见 [bench/README.md](bench/README.md),可本地复现): - **流式编码**:每帧堆分配减半(单缓冲 vs 双缓冲),小负载耗时快 25–35%,大负载持平; -- **切帧吞吐**:`LengthPrefixFramer` ≈10 ns/帧,`StxEtxFramer` ≈1.1 µs/帧(逐字节扫描)——高吞吐场景优先长度前缀。 +- **切帧吞吐**:`LengthPrefixFramer` ≈10 ns/帧,`StxEtxFramer` ≈1.1 µs/帧(逐字节扫描)——高吞吐场景优先长度前缀; +- **端到端**(真实 TCP 回环):单向吞吐 ≈24 万条/秒(1KB 消息,LengthPrefix),往返延迟 ≈63 µs;XML codec 每条报文 2–16 µs(400B–4KB)——序列化开销远大于定界层。 ## 支持框架 diff --git a/StreamFrame.slnx b/StreamFrame.slnx index 7eab3f6..127037a 100644 --- a/StreamFrame.slnx +++ b/StreamFrame.slnx @@ -1,4 +1,7 @@ + + + diff --git a/bench/README.md b/bench/README.md index e073fdb..394be28 100644 --- a/bench/README.md +++ b/bench/README.md @@ -5,15 +5,18 @@ ## 运行 ```bash -dotnet run -c Release --project bench/StreamFrame.Benchmarks +dotnet run -c Release --project bench/StreamFrame.Benchmarks # 全部(约 15 分钟) +dotnet run -c Release --project bench/StreamFrame.Benchmarks -- --filter "*EndToEnd*" # 只跑端到端 ``` -(约 5–10 分钟;`--filter` 可只跑部分用例。) +> 该版本的 BenchmarkDotNet 不支持重复 `--filter`(一次只能一个模式),多个类请分次运行。 +> 基准项目已纳入解决方案(IDE 可见);`dotnet test` 依 `IsTestProject` 机制自动跳过。 ## 测什么 -- **编码两路径对比**:`Streaming`(`BeginFrame → 写负载 → EndFrame`,单缓冲,发送侧默认路径)vs `Plain`(负载先进缓冲 A,`EncodeFrame` 再整体拷贝进帧缓冲 B,两段缓冲含一次 memcpy)。负载为 64B / 1KB / 64KB 的 XML 风格文本。 -- **切帧吞吐**:从 100 × 1KB 粘包缓冲中循环 `TryDecodeFrame`。 +- **帧定界微基准**(`FramingBenchmarks`):`Streaming`(`BeginFrame → 写负载 → EndFrame`,单缓冲,发送侧默认路径)vs `Plain`(负载先进缓冲 A,`EncodeFrame` 再整体拷贝进帧缓冲 B)× 64B/1KB/64KB;以及切帧吞吐(100 × 1KB 粘包)。 +- **Codec 基准**(`CodecBenchmarks`):官方 XML 驱动 `XmlDocumentCodec` 对典型设备报文(~400B 单值上报 / ~4KB 批量明细)的编解码开销。 +- **端到端基准**(`EndToEndBenchmarks`):真实 TCP 回环上的完整连接(收发队列 + Pipe + 定界 + codec + socket)——单向吞吐(连发 1 万条 1KB 消息,双 framer)与往返延迟(逐条乒乓 2000 次)。 ## 结果 @@ -37,7 +40,24 @@ dotnet run -c Release --project bench/StreamFrame.Benchmarks ¹ 恰好压在 `EncodeBufferInitialSize`(默认 1024)的缓冲增长边界上:流式路径触发一次扩容租借, 抵消了省下的那次拷贝。负载远大于初始缓冲后两条路径的拷贝量趋同(几何扩容摊销)。 -测试环境:Windows(本地开发机),.NET 8,BenchmarkDotNet 0.15.8,SimpleJob(warmup 3 / iteration 15)。 +### Codec(XmlDocumentCodec) + +| 操作 | 报文 | 耗时 | 分配 | +|---|---|---:|---:| +| Decode | ~400B | 3.0 µs | 16.7 KB | +| Decode | ~4KB | 15.6 µs | 29.0 KB | +| Encode | ~400B | 1.9 µs | 7.8 KB | +| Encode | ~4KB | 10.9 µs | 10.4 KB | + +### 端到端(真实 TCP 回环,1KB 文本消息) + +| 指标 | LengthPrefix | StxEtx | +|---|---:|---:| +| 单向吞吐(每消息折算) | 4.06 µs ≈ **24.6 万条/秒** | 4.65 µs ≈ 21.5 万条/秒 | +| 往返延迟(乒乓串行) | 62.8 µs | 71.1 µs | +| 每消息堆分配(吞吐模式) | 6.5 KB | 6.9 KB | + +测试环境:Windows(本地开发机),.NET 8,BenchmarkDotNet 0.15.8,SimpleJob(warmup 3 / iteration 15;e2e 为 2/10)。 数值仅供量级参考,请以自己环境的复现为准。 ## 读数要点 diff --git a/bench/StreamFrame.Benchmarks/CodecBenchmarks.cs b/bench/StreamFrame.Benchmarks/CodecBenchmarks.cs new file mode 100644 index 0000000..ba64db7 --- /dev/null +++ b/bench/StreamFrame.Benchmarks/CodecBenchmarks.cs @@ -0,0 +1,63 @@ +using System.Buffers; +using System.Text; +using System.Xml.Linq; +using BenchmarkDotNet.Attributes; +using StreamFrame.Protocols.Xml; + +namespace StreamFrame.Benchmarks; + +/// +/// XML codec(官方示例驱动 XmlDocumentCodec)的编解码开销基准。 +/// 回答"接 XML 设备时序列化值不值得担心"——用典型设备报文尺寸度量。 +/// +[MemoryDiagnoser] +[SimpleJob(warmupCount: 3, iterationCount: 15)] +public class CodecBenchmarks +{ + private readonly XmlDocumentCodec _codec = new(); + private XDocument _small = null!; + private XDocument _large = null!; + private ReadOnlySequence _smallBytes = default; + private ReadOnlySequence _largeBytes = default; + + [GlobalSetup] + public void Setup() + { + _small = XDocument.Parse(MakeMessage(items: 3)); // ~400B 报文(心跳/单值上报) + _large = XDocument.Parse(MakeMessage(items: 48)); // ~4KB 报文(批量数据/明细) + _smallBytes = new ReadOnlySequence(Encode(_small)); + _largeBytes = new ReadOnlySequence(Encode(_large)); + } + + [Benchmark] public XDocument Decode_Small400B() => _codec.Decode(_smallBytes); + [Benchmark] public XDocument Decode_Large4KB() => _codec.Decode(_largeBytes); + + [Benchmark] public int Encode_Small400B() => EncodeToBuffer(_small).WrittenCount; + [Benchmark] public int Encode_Large4KB() => EncodeToBuffer(_large).WrittenCount; + + // ----- 辅助 ----- + + private ArrayBufferWriter EncodeToBuffer(XDocument doc) + { + var writer = new ArrayBufferWriter(); + _codec.Encode(doc, writer); + return writer; + } + + private static byte[] Encode(XDocument doc) + { + var writer = new ArrayBufferWriter(); + new XmlDocumentCodec().Encode(doc, writer); + return writer.WrittenSpan.ToArray(); + } + + private static string MakeMessage(int items) + { + var sb = new StringBuilder(128); + sb.Append("42PLC-A"); + for (var i = 0; i < items; i++) + sb.Append("").Append(i).Append("0123456789ABCDEF"); + sb.Append(""); + return sb.ToString(); + } +} diff --git a/bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs b/bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs new file mode 100644 index 0000000..e120919 --- /dev/null +++ b/bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Net.Sockets; +using System.Threading.Channels; +using BenchmarkDotNet.Attributes; + +namespace StreamFrame.Benchmarks; + +/// +/// 端到端管道基准(真实 TCP 回环):完整连接(收发队列 + Pipe + 定界 + codec + socket)的 +/// 单向吞吐(按 framer 分组)与往返延迟。回答"整库每秒能打多少消息、一条消息来回要多久"。 +/// +[MemoryDiagnoser] +[SimpleJob(warmupCount: 2, iterationCount: 10)] +public class EndToEndBenchmarks +{ + private const int ThroughputMessages = 10_000; + private const int PingPongRounds = 2_000; + + [Params("LengthPrefix", "StxEtx")] + public string Framer { get; set; } = "LengthPrefix"; + + private StreamConnection _server = null!; + private StreamConnection _client = null!; + private CancellationTokenSource _cts = null!; + + // 完成信号:消费端收到目标条数时置位(吞吐)/客户端收到回显时置位(乒乓) + private long _serverReceived; + private long _clientReceived; + private volatile TaskCompletionSource _serverDrained = NewTcs(); + private volatile TaskCompletionSource _clientDrained = NewTcs(); + + private static TaskCompletionSource NewTcs() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + + [GlobalSetup] + public async Task Setup() + { + var port = GetFreePort(); + var options = new StreamConnectionOptions { ConnectRetryDelayMs = 200 }; + StreamConnection Create(bool isActive) + => new( + Framer == "LengthPrefix" ? new LengthPrefixFramer() : new StxEtxFramer(), + Utf8TextCodec.Instance, + IPAddress.Loopback, + port, + isActive, + options); + + _server = Create(isActive: false); + _client = Create(isActive: true); + _cts = new CancellationTokenSource(); + + // 服务端:回显(乒乓用);同时计数(吞吐用) + _ = Task.Run(async () => + { + await foreach (var message in _server.GetMessages(_cts.Token)) + { + if (Interlocked.Increment(ref _serverReceived) == ThroughputMessages) + _serverDrained.TrySetResult(); + await _server.SendAsync(message, _cts.Token); + } + }); + // 客户端:只计数收到的回显 + _ = Task.Run(async () => + { + await foreach (var _ in _client.GetMessages(_cts.Token)) + { + if (Interlocked.Increment(ref _clientReceived) == PingPongRounds) + _clientDrained.TrySetResult(); + } + }); + + _server.Start(default); + _client.Start(default); + await Task.WhenAll(_server.WaitForConnectedAsync(), _client.WaitForConnectedAsync()); + } + + [GlobalCleanup] + public void Cleanup() + { + _cts.Cancel(); + _server.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _client.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _cts.Dispose(); + } + + /// 单向吞吐:客户端连发 1 万条 1KB 消息,计到服务端全部收到。Mean 已按 OperationsPerInvoke 折算为每消息。 + [Benchmark(OperationsPerInvoke = ThroughputMessages)] + public async Task OneWayThroughput_10k_1KB() + { + Interlocked.Exchange(ref _serverReceived, 0); + _serverDrained = NewTcs(); + + var payload = new string('x', 1024); + for (var i = 0; i < ThroughputMessages; i++) + await _client.SendAsync(payload); + + // 等全部送达(30 秒兜底,异常时让基准响亮失败而不是挂死) + await _serverDrained.Task.WaitAsync(TimeSpan.FromSeconds(30)); + } + + /// 往返延迟:客户端逐条发送并等回显(串行化每一条的完整 RTT)。Mean 已折算为每次往返。 + [Benchmark(OperationsPerInvoke = PingPongRounds)] + public async Task RoundTripLatency() + { + Interlocked.Exchange(ref _clientReceived, 0); + _clientDrained = NewTcs(); + + for (var i = 0; i < PingPongRounds; i++) + { + await _client.SendAsync("ping"); + // 串行等待这一条的回显:单条完整往返 + while (Volatile.Read(ref _clientReceived) <= i) + await Task.Yield(); + } + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/bench/StreamFrame.Benchmarks/StreamFrame.Benchmarks.csproj b/bench/StreamFrame.Benchmarks/StreamFrame.Benchmarks.csproj index 9e36132..9a6a1ad 100644 --- a/bench/StreamFrame.Benchmarks/StreamFrame.Benchmarks.csproj +++ b/bench/StreamFrame.Benchmarks/StreamFrame.Benchmarks.csproj @@ -13,6 +13,7 @@ + diff --git a/bench/StreamFrame.Benchmarks/Utf8TextCodec.cs b/bench/StreamFrame.Benchmarks/Utf8TextCodec.cs new file mode 100644 index 0000000..be2dd85 --- /dev/null +++ b/bench/StreamFrame.Benchmarks/Utf8TextCodec.cs @@ -0,0 +1,16 @@ +using System.Buffers; +using System.Text; + +namespace StreamFrame.Benchmarks; + +/// 基准用的 UTF-8 文本 codec(与 demo 相同:编解码即字符串与字节互转)。 +internal sealed class Utf8TextCodec : ICodec +{ + public static readonly Utf8TextCodec Instance = new(); + + public string Decode(in ReadOnlySequence frame, CancellationToken ct = default) + => Encoding.UTF8.GetString(frame); + + public void Encode(string message, IBufferWriter writer, CancellationToken ct = default) + => writer.Write(Encoding.UTF8.GetBytes(message)); +}