Skip to content

Commit 68d632f

Browse files
committed
Improve websocket message performance
Merge the receive-path optimization from PR #158 and add further low-allocation improvements for pooled receive buffers, text-send encoding, cached observables, and binary message logging. Add a BenchmarkDotNet project with documented before/after results so receive buffering, text encoding, observable access, and response-message formatting can be measured repeatably.
1 parent a716ea2 commit 68d632f

18 files changed

Lines changed: 900 additions & 116 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ ClientBin/
175175
*.publishsettings
176176
node_modules/
177177
orleans.codegen.cs
178+
BenchmarkDotNet.Artifacts/
178179

179180
# RIA/Silverlight projects
180181
Generated_Code/

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,33 @@ This is a wrapper over native C# class `ClientWebSocket` with built-in reconnect
1515
### Features
1616

1717
* installation via NuGet ([Websocket.Client](https://www.nuget.org/packages/Websocket.Client))
18-
* targeting .NET Standard 2.0 (.NET Core, Linux/MacOS compatible) + Standard 2.1, .NET 5 and .NET 6
18+
* targeting .NET Standard 2.1, .NET 6, .NET 7, and .NET 8
1919
* reactive extensions ([Rx.NET](https://github.com/Reactive-Extensions/Rx.NET))
20-
* integrated logging abstraction ([LibLog](https://github.com/damianh/LibLog))
20+
* integrated logging abstraction (`Microsoft.Extensions.Logging`)
2121
* using Channels for high performance sending queue
22+
* allocation-conscious receive and send paths using reusable buffers and pooled text encoding
23+
24+
### Performance
25+
26+
Websocket.Client is designed for long-running websocket consumers where per-message allocation and reconnect behavior matter:
27+
28+
* incoming messages are accumulated in a reusable pooled receive buffer
29+
* text messages are decoded directly from the received bytes
30+
* outgoing text messages are encoded into rented `ArrayPool<byte>` buffers
31+
* the sending queue uses `System.Threading.Channels` with a single reader
32+
* public observable wrappers are cached to avoid per-access wrapper allocations
33+
34+
Representative BenchmarkDotNet results show meaningful improvements on typical small and medium messages:
35+
36+
* 128 B text receive path: `355.76 ns / 560 B` to `54.36 ns / 280 B`
37+
* 4 KB text receive path: `1.130 us / 8496 B` to `738.98 ns / 8216 B`
38+
* 1024 char text send encoding: `134.04 ns / 1048 B` to `87.44 ns / 0 B`
39+
* 8192 char text send encoding: `974.85 ns / 8216 B` to `501.86 ns / 0 B`
40+
* stream-backed binary `ResponseMessage.ToString()` at 32 KB: `1.880 us / 32872 B` to `101.23 ns / 104 B`
41+
42+
For very large text messages, the resulting `string` allocation dominates the receive cost, so the library focuses on avoiding unnecessary intermediate allocations and avoiding retention of oversized receive buffers after traffic spikes.
43+
44+
See the [benchmarks](benchmarks/README.md) folder for the BenchmarkDotNet project, commands, and full result tables.
2245

2346
### Usage
2447

Websocket.Client.sln

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,32 +29,92 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Websocket.Client.Sample", "
2929
EndProject
3030
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Websocket.Client.Sample.Blazor", "test_integration\Websocket.Client.Sample.Blazor\Websocket.Client.Sample.Blazor.csproj", "{AFAC261C-E5B5-4558-A869-6AEF5F439060}"
3131
EndProject
32+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{66320409-64EC-F7C5-3DEF-65E7510DAAD1}"
33+
EndProject
34+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Websocket.Client.Benchmarks", "benchmarks\Websocket.Client.Benchmarks\Websocket.Client.Benchmarks.csproj", "{4A962092-136C-489E-A1A7-B40D8506E048}"
35+
EndProject
3236
Global
3337
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3438
Debug|Any CPU = Debug|Any CPU
39+
Debug|x64 = Debug|x64
40+
Debug|x86 = Debug|x86
3541
Release|Any CPU = Release|Any CPU
42+
Release|x64 = Release|x64
43+
Release|x86 = Release|x86
3644
EndGlobalSection
3745
GlobalSection(ProjectConfigurationPlatforms) = postSolution
3846
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
3947
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Debug|Any CPU.Build.0 = Debug|Any CPU
48+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Debug|x64.ActiveCfg = Debug|Any CPU
49+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Debug|x64.Build.0 = Debug|Any CPU
50+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Debug|x86.ActiveCfg = Debug|Any CPU
51+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Debug|x86.Build.0 = Debug|Any CPU
4052
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Release|Any CPU.ActiveCfg = Release|Any CPU
4153
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Release|Any CPU.Build.0 = Release|Any CPU
54+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Release|x64.ActiveCfg = Release|Any CPU
55+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Release|x64.Build.0 = Release|Any CPU
56+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Release|x86.ActiveCfg = Release|Any CPU
57+
{FF449015-D8F9-4152-8E8A-2D870DF21A83}.Release|x86.Build.0 = Release|Any CPU
4258
{A34F25AA-365F-4709-A642-7B21743C91D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
4359
{A34F25AA-365F-4709-A642-7B21743C91D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
60+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Debug|x64.ActiveCfg = Debug|Any CPU
61+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Debug|x64.Build.0 = Debug|Any CPU
62+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Debug|x86.ActiveCfg = Debug|Any CPU
63+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Debug|x86.Build.0 = Debug|Any CPU
4464
{A34F25AA-365F-4709-A642-7B21743C91D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
4565
{A34F25AA-365F-4709-A642-7B21743C91D7}.Release|Any CPU.Build.0 = Release|Any CPU
66+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Release|x64.ActiveCfg = Release|Any CPU
67+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Release|x64.Build.0 = Release|Any CPU
68+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Release|x86.ActiveCfg = Release|Any CPU
69+
{A34F25AA-365F-4709-A642-7B21743C91D7}.Release|x86.Build.0 = Release|Any CPU
4670
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
4771
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Debug|Any CPU.Build.0 = Debug|Any CPU
72+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Debug|x64.ActiveCfg = Debug|Any CPU
73+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Debug|x64.Build.0 = Debug|Any CPU
74+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Debug|x86.ActiveCfg = Debug|Any CPU
75+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Debug|x86.Build.0 = Debug|Any CPU
4876
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Release|Any CPU.ActiveCfg = Release|Any CPU
4977
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Release|Any CPU.Build.0 = Release|Any CPU
78+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Release|x64.ActiveCfg = Release|Any CPU
79+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Release|x64.Build.0 = Release|Any CPU
80+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Release|x86.ActiveCfg = Release|Any CPU
81+
{FAFB1608-8DFF-4DDA-81D3-36385582DA07}.Release|x86.Build.0 = Release|Any CPU
5082
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
5183
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Debug|Any CPU.Build.0 = Debug|Any CPU
84+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Debug|x64.ActiveCfg = Debug|Any CPU
85+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Debug|x64.Build.0 = Debug|Any CPU
86+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Debug|x86.ActiveCfg = Debug|Any CPU
87+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Debug|x86.Build.0 = Debug|Any CPU
5288
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Release|Any CPU.ActiveCfg = Release|Any CPU
5389
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Release|Any CPU.Build.0 = Release|Any CPU
90+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Release|x64.ActiveCfg = Release|Any CPU
91+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Release|x64.Build.0 = Release|Any CPU
92+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Release|x86.ActiveCfg = Release|Any CPU
93+
{085B9246-446B-4F61-A6EF-0D8949CA546D}.Release|x86.Build.0 = Release|Any CPU
5494
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
5595
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Debug|Any CPU.Build.0 = Debug|Any CPU
96+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Debug|x64.ActiveCfg = Debug|Any CPU
97+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Debug|x64.Build.0 = Debug|Any CPU
98+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Debug|x86.ActiveCfg = Debug|Any CPU
99+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Debug|x86.Build.0 = Debug|Any CPU
56100
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Release|Any CPU.ActiveCfg = Release|Any CPU
57101
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Release|Any CPU.Build.0 = Release|Any CPU
102+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Release|x64.ActiveCfg = Release|Any CPU
103+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Release|x64.Build.0 = Release|Any CPU
104+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Release|x86.ActiveCfg = Release|Any CPU
105+
{AFAC261C-E5B5-4558-A869-6AEF5F439060}.Release|x86.Build.0 = Release|Any CPU
106+
{4A962092-136C-489E-A1A7-B40D8506E048}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
107+
{4A962092-136C-489E-A1A7-B40D8506E048}.Debug|Any CPU.Build.0 = Debug|Any CPU
108+
{4A962092-136C-489E-A1A7-B40D8506E048}.Debug|x64.ActiveCfg = Debug|Any CPU
109+
{4A962092-136C-489E-A1A7-B40D8506E048}.Debug|x64.Build.0 = Debug|Any CPU
110+
{4A962092-136C-489E-A1A7-B40D8506E048}.Debug|x86.ActiveCfg = Debug|Any CPU
111+
{4A962092-136C-489E-A1A7-B40D8506E048}.Debug|x86.Build.0 = Debug|Any CPU
112+
{4A962092-136C-489E-A1A7-B40D8506E048}.Release|Any CPU.ActiveCfg = Release|Any CPU
113+
{4A962092-136C-489E-A1A7-B40D8506E048}.Release|Any CPU.Build.0 = Release|Any CPU
114+
{4A962092-136C-489E-A1A7-B40D8506E048}.Release|x64.ActiveCfg = Release|Any CPU
115+
{4A962092-136C-489E-A1A7-B40D8506E048}.Release|x64.Build.0 = Release|Any CPU
116+
{4A962092-136C-489E-A1A7-B40D8506E048}.Release|x86.ActiveCfg = Release|Any CPU
117+
{4A962092-136C-489E-A1A7-B40D8506E048}.Release|x86.Build.0 = Release|Any CPU
58118
EndGlobalSection
59119
GlobalSection(SolutionProperties) = preSolution
60120
HideSolutionNode = FALSE
@@ -65,6 +125,7 @@ Global
65125
{FAFB1608-8DFF-4DDA-81D3-36385582DA07} = {42E4078C-4FBE-4B03-B889-1573332945A9}
66126
{085B9246-446B-4F61-A6EF-0D8949CA546D} = {42E4078C-4FBE-4B03-B889-1573332945A9}
67127
{AFAC261C-E5B5-4558-A869-6AEF5F439060} = {42E4078C-4FBE-4B03-B889-1573332945A9}
128+
{4A962092-136C-489E-A1A7-B40D8506E048} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1}
68129
EndGlobalSection
69130
GlobalSection(ExtensibilityGlobals) = postSolution
70131
SolutionGuid = {EE3E0B94-CF20-4C22-B50B-E720E8DFD675}

benchmarks/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Websocket.Client Benchmarks
2+
3+
This folder contains BenchmarkDotNet benchmarks for the allocation-sensitive Websocket.Client hot paths.
4+
5+
The latest representative run below was captured on Windows 11, AMD Ryzen 9 3900X, .NET SDK 10.0.201, running the benchmarks on .NET 8.0.25 with BenchmarkDotNet `ShortRun`.
6+
7+
Run all benchmarks:
8+
9+
```powershell
10+
dotnet run --configuration Release --project benchmarks\Websocket.Client.Benchmarks -- --filter "*"
11+
```
12+
13+
Run the most relevant receive/send benchmarks:
14+
15+
```powershell
16+
dotnet run --configuration Release --project benchmarks\Websocket.Client.Benchmarks -- --filter "*ReceiveBufferBenchmarks*"
17+
dotnet run --configuration Release --project benchmarks\Websocket.Client.Benchmarks -- --filter "*TextSendEncodingBenchmarks*"
18+
```
19+
20+
Results are written to `BenchmarkDotNet.Artifacts\results`.
21+
22+
## What The Benchmarks Cover
23+
24+
- `ReceiveBufferBenchmarks` compares the old per-message `RecyclableMemoryStream` receive path, PR #158's `ArrayBufferWriter<byte>` path, and the current pooled receive buffer.
25+
- `TextSendEncodingBenchmarks` compares outgoing text encoding through `Encoding.GetBytes(string)` against encoding into an `ArrayPool<byte>` buffer.
26+
- `ResponseMessageBenchmarks` measures the `ResponseMessage.ToString()` stream-copy fix.
27+
- `ObservablePropertyBenchmarks` measures cached observable properties versus creating an `AsObservable()` wrapper on every access.
28+
- `ClientReceiveBenchmarks` measures the current public `WebsocketClient` receive path using an in-memory scripted `WebSocket`.
29+
30+
Use the Ratio and Allocated columns for the quick answer. Values below 1.00 in Ratio are faster than the baseline, and lower Allocated values mean less GC pressure.
31+
32+
## Representative Results
33+
34+
### Receive Buffering
35+
36+
`ReceiveBufferBenchmarks` compares the original receive path, PR #158, and the current pooled buffer. These benchmarks include UTF-8 decoding to `string`, so large text messages are dominated by the required output string allocation.
37+
38+
| Message size | Before: RecyclableMemoryStream | PR #158: ArrayBufferWriter | Current: pooled buffer | Current impact |
39+
| ---: | ---: | ---: | ---: | --- |
40+
| 128 B | 355.76 ns, 560 B | 63.18 ns, 280 B | 54.36 ns, 280 B | 85% faster, 50% less allocation |
41+
| 4 KB | 1.130 us, 8496 B | 847.05 ns, 8216 B | 738.98 ns, 8216 B | 35% faster, slightly less allocation |
42+
| 32 KB | 4.966 us, 65840 B | 5.440 us, 65560 B | 5.166 us, 65560 B | roughly neutral |
43+
| 128 KB | 98.302 us, 262476 B | 98.932 us, 262196 B | 105.350 us, 262224 B | roughly neutral; avoids retaining a large receive buffer |
44+
45+
### Text Send Encoding
46+
47+
`TextSendEncodingBenchmarks` measures the change from `Encoding.GetBytes(string)` to encoding into a rented `ArrayPool<byte>` buffer before calling `WebSocket.SendAsync`.
48+
49+
| Message length | Before: `Encoding.GetBytes` | Current: ArrayPool encode | Current impact |
50+
| ---: | ---: | ---: | --- |
51+
| 32 chars | 16.39 ns, 56 B | 40.13 ns, 0 B | removes allocation with a small CPU cost |
52+
| 1024 chars | 134.04 ns, 1048 B | 87.44 ns, 0 B | 35% faster, allocation-free |
53+
| 8192 chars | 974.85 ns, 8216 B | 501.86 ns, 0 B | 48% faster, allocation-free |
54+
55+
### Binary Message Logging
56+
57+
`ResponseMessageBenchmarks` measures `ResponseMessage.ToString()` for stream-backed binary messages. The previous implementation copied the stream with `ToArray()` just to print the length.
58+
59+
| Message size | Before: stream `ToArray()` | Current: stream `Length` | Current impact |
60+
| ---: | ---: | ---: | --- |
61+
| 64 B | 56.69 ns, 160 B | 98.88 ns, 96 B | less allocation, slightly slower |
62+
| 4 KB | 348.65 ns, 4192 B | 103.42 ns, 96 B | 70% faster, 98% less allocation |
63+
| 32 KB | 1.880 us, 32872 B | 101.23 ns, 104 B | 95% faster, near-zero payload allocation |
64+
65+
### Observable Property Access
66+
67+
`ObservablePropertyBenchmarks` measures repeated access to public observable properties such as `MessageReceived`.
68+
69+
| Scenario | Mean | Allocated |
70+
| --- | ---: | ---: |
71+
| Before: `AsObservable()` per access | 17.81 ns | 24 B |
72+
| Current: cached observable wrapper | 0.69 ns | 0 B |
73+
74+
### Current Client Receive Path
75+
76+
`ClientReceiveBenchmarks` measures the current public `WebsocketClient` receive flow using an in-memory scripted `WebSocket`. It is not a before/after benchmark; it is a package-level smoke benchmark for the real client path.
77+
78+
| Messages | Message size | Mean | Allocated |
79+
| ---: | ---: | ---: | ---: |
80+
| 100 | 64 B | 15.24 us | 29.4 KB |
81+
| 100 | 1024 B | 32.86 us | 216.9 KB |
82+
| 1000 | 64 B | 135.71 us | 240.34 KB |
83+
| 1000 | 1024 B | 275.40 us | 2115.34 KB |
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Buffers;
2+
3+
namespace Websocket.Client.Benchmarks;
4+
5+
internal sealed class BenchmarkPooledBufferWriter : IBufferWriter<byte>, IDisposable
6+
{
7+
private readonly ArrayPool<byte> _pool;
8+
private byte[] _buffer;
9+
private int _written;
10+
11+
public BenchmarkPooledBufferWriter(int initialCapacity)
12+
{
13+
if (initialCapacity <= 0)
14+
throw new ArgumentOutOfRangeException(nameof(initialCapacity));
15+
16+
_pool = ArrayPool<byte>.Shared;
17+
_buffer = _pool.Rent(initialCapacity);
18+
}
19+
20+
public ReadOnlySpan<byte> WrittenSpan => _buffer.AsSpan(0, _written);
21+
22+
public void Advance(int count)
23+
{
24+
if (count < 0)
25+
throw new ArgumentOutOfRangeException(nameof(count));
26+
27+
if (_written > _buffer.Length - count)
28+
throw new InvalidOperationException("Cannot advance past the end of the buffer.");
29+
30+
_written += count;
31+
}
32+
33+
public Memory<byte> GetMemory(int sizeHint = 0)
34+
{
35+
Ensure(sizeHint);
36+
return _buffer.AsMemory(_written);
37+
}
38+
39+
public Span<byte> GetSpan(int sizeHint = 0)
40+
{
41+
Ensure(sizeHint);
42+
return _buffer.AsSpan(_written);
43+
}
44+
45+
public void Clear(int maxRetainedCapacity)
46+
{
47+
if (maxRetainedCapacity <= 0)
48+
throw new ArgumentOutOfRangeException(nameof(maxRetainedCapacity));
49+
50+
_written = 0;
51+
52+
if (_buffer.Length <= maxRetainedCapacity)
53+
return;
54+
55+
var buffer = _buffer;
56+
_buffer = _pool.Rent(maxRetainedCapacity);
57+
_pool.Return(buffer);
58+
}
59+
60+
public void Dispose()
61+
{
62+
var buffer = _buffer;
63+
_buffer = Array.Empty<byte>();
64+
_written = 0;
65+
66+
if (buffer.Length > 0)
67+
_pool.Return(buffer);
68+
}
69+
70+
private void Ensure(int sizeHint)
71+
{
72+
if (sizeHint < 0)
73+
throw new ArgumentOutOfRangeException(nameof(sizeHint));
74+
75+
if (sizeHint == 0)
76+
sizeHint = 1;
77+
78+
if (sizeHint <= _buffer.Length - _written)
79+
return;
80+
81+
Grow(sizeHint);
82+
}
83+
84+
private void Grow(int sizeHint)
85+
{
86+
var newSize = checked(_buffer.Length + Math.Max(sizeHint, _buffer.Length));
87+
var newBuffer = _pool.Rent(newSize);
88+
89+
_buffer.AsSpan(0, _written).CopyTo(newBuffer);
90+
_pool.Return(_buffer);
91+
_buffer = newBuffer;
92+
}
93+
}

0 commit comments

Comments
 (0)