Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
## [Unreleased]

### 新增
- 暂无
- 基准测试扩容:`CodecBenchmarks`(XmlDocumentCodec 典型报文编解码开销)与 `EndToEndBenchmarks`(真实 TCP 回环的单向吞吐(双 framer)与往返延迟);基准项目纳入解决方案(IDE 可见,随解决方案构建;`dotnet test` 依 IsTestProject 机制自动跳过)。

### 修复
- 暂无
Expand Down
3 changes: 2 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ var conn = new StreamConnection<XDocument>(..., 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)——序列化开销远大于定界层。

## 支持框架

Expand Down
3 changes: 3 additions & 0 deletions StreamFrame.slnx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<Solution>
<Folder Name="/bench/">
<Project Path="bench/StreamFrame.Benchmarks/StreamFrame.Benchmarks.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/StreamFrame.Demo/StreamFrame.Demo.csproj" />
</Folder>
Expand Down
30 changes: 25 additions & 5 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 次)。

## 结果

Expand All @@ -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)。
数值仅供量级参考,请以自己环境的复现为准。

## 读数要点
Expand Down
63 changes: 63 additions & 0 deletions bench/StreamFrame.Benchmarks/CodecBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Buffers;
using System.Text;
using System.Xml.Linq;
using BenchmarkDotNet.Attributes;
using StreamFrame.Protocols.Xml;

namespace StreamFrame.Benchmarks;

/// <summary>
/// XML codec(官方示例驱动 XmlDocumentCodec)的编解码开销基准。
/// 回答"接 XML 设备时序列化值不值得担心"——用典型设备报文尺寸度量。
/// </summary>
[MemoryDiagnoser]
[SimpleJob(warmupCount: 3, iterationCount: 15)]
public class CodecBenchmarks
{
private readonly XmlDocumentCodec _codec = new();
private XDocument _small = null!;
private XDocument _large = null!;
private ReadOnlySequence<byte> _smallBytes = default;
private ReadOnlySequence<byte> _largeBytes = default;

[GlobalSetup]
public void Setup()
{
_small = XDocument.Parse(MakeMessage(items: 3)); // ~400B 报文(心跳/单值上报)
_large = XDocument.Parse(MakeMessage(items: 48)); // ~4KB 报文(批量数据/明细)
_smallBytes = new ReadOnlySequence<byte>(Encode(_small));
_largeBytes = new ReadOnlySequence<byte>(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<byte> EncodeToBuffer(XDocument doc)
{
var writer = new ArrayBufferWriter<byte>();
_codec.Encode(doc, writer);
return writer;
}

private static byte[] Encode(XDocument doc)
{
var writer = new ArrayBufferWriter<byte>();
new XmlDocumentCodec().Encode(doc, writer);
return writer.WrittenSpan.ToArray();
}

private static string MakeMessage(int items)
{
var sb = new StringBuilder(128);
sb.Append("<Message><Id>42</Id><Device>PLC-A</Device><Items>");
for (var i = 0; i < items; i++)
sb.Append("<Item><Index>").Append(i).Append("</Index><Value>0123456789ABCDEF</Value></Item>");
sb.Append("</Items></Message>");
return sb.ToString();
}
}
126 changes: 126 additions & 0 deletions bench/StreamFrame.Benchmarks/EndToEndBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Net;
using System.Net.Sockets;
using System.Threading.Channels;
using BenchmarkDotNet.Attributes;

namespace StreamFrame.Benchmarks;

/// <summary>
/// 端到端管道基准(真实 TCP 回环):完整连接(收发队列 + Pipe + 定界 + codec + socket)的
/// 单向吞吐(按 framer 分组)与往返延迟。回答"整库每秒能打多少消息、一条消息来回要多久"。
/// </summary>
[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<string> _server = null!;
private StreamConnection<string> _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<string> 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();
}

/// <summary>单向吞吐:客户端连发 1 万条 1KB 消息,计到服务端全部收到。Mean 已按 OperationsPerInvoke 折算为每消息。</summary>
[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));
}

/// <summary>往返延迟:客户端逐条发送并等回显(串行化每一条的完整 RTT)。Mean 已折算为每次往返。</summary>
[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;
}
}
1 change: 1 addition & 0 deletions bench/StreamFrame.Benchmarks/StreamFrame.Benchmarks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

<ItemGroup>
<ProjectReference Include="..\..\src\StreamFrame\StreamFrame.csproj" />
<ProjectReference Include="..\..\src\StreamFrame.Protocols.Xml\StreamFrame.Protocols.Xml.csproj" />
</ItemGroup>

</Project>
16 changes: 16 additions & 0 deletions bench/StreamFrame.Benchmarks/Utf8TextCodec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Buffers;
using System.Text;

namespace StreamFrame.Benchmarks;

/// <summary>基准用的 UTF-8 文本 codec(与 demo 相同:编解码即字符串与字节互转)。</summary>
internal sealed class Utf8TextCodec : ICodec<string>
{
public static readonly Utf8TextCodec Instance = new();

public string Decode(in ReadOnlySequence<byte> frame, CancellationToken ct = default)
=> Encoding.UTF8.GetString(frame);

public void Encode(string message, IBufferWriter<byte> writer, CancellationToken ct = default)
=> writer.Write(Encoding.UTF8.GetBytes(message));
}