Skip to content

Commit 2fd856d

Browse files
Optimize hot paths using Span<T> and BinaryPrimitives to reduce allocations (#33)
* Initial plan * Optimize GMCP and MSSP using Span for .NET 5+ Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> * Simplify WriteToOutput - remove unnecessary ArrayPool usage Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> * Optimize NAWS using BinaryPrimitives and add documentation Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> * Update CHANGELOG with performance improvements Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> * Address code review feedback - remove redundant conditionals and clarify NAWS endianness Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> * Fix GMCP optimization - defer ToArray() until actually needed Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> * Add clarifying documentation for stackalloc and encoding assumptions Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> * Bump version to 2.3.0 and update CHANGELOG release date Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: HarryCordewener <5649138+HarryCordewener@users.noreply.github.com>
1 parent d000a6c commit 2fd856d

8 files changed

Lines changed: 70 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# Change Log
22
All notable changes to this project will be documented in this file.
33

4+
## [2.3.0] - 2026-02-13
5+
6+
### Performance Improvements
7+
- **GMCP Protocol**: Optimized message parsing using `CollectionsMarshal.AsSpan()` for .NET 5+ to eliminate 2 `ToArray()` allocations per message
8+
- **MSSP Protocol**: Optimized string encoding operations using `CollectionsMarshal.AsSpan()` to avoid intermediate array allocations
9+
- **NAWS Protocol**: Replaced `BitConverter.GetBytes()` with `BinaryPrimitives.WriteInt16BigEndian()` and `stackalloc` for explicit big-endian encoding and improved performance on .NET 5+
10+
- **TelnetStandardInterpreter**: Simplified `WriteToOutput()` method by removing unnecessary ArrayPool pattern
11+
- **Documentation**: Added inline comments explaining design decisions for performance-critical code paths
12+
413
## [2.0.0] - 2026-01-19
514

615
### Added

TelnetNegotiationCore/Interpreters/TelnetGMCPInterpreter.cs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ private async ValueTask CompleteGMCPNegotiation(StateMachine<State, Trigger>.Tra
9595
return;
9696
}
9797

98-
var space = CurrentEncoding.GetBytes(" ").First();
98+
const byte space = (byte)' '; // Literal instead of GetBytes(" ").First()
99+
// Note: Space (0x20) is the same across ASCII, UTF-8, and most encodings, but we assume
100+
// GMCP uses ASCII-compatible encoding as per the protocol specification
99101
var firstSpace = gmcpBytes.FindIndex(x => x == space);
100102

101103
if (firstSpace < 0)
@@ -104,18 +106,25 @@ private async ValueTask CompleteGMCPNegotiation(StateMachine<State, Trigger>.Tra
104106
return;
105107
}
106108

109+
#if NET5_0_OR_GREATER
110+
// Use CollectionsMarshal.AsSpan with slicing for zero-copy access
111+
var gmcpSpan = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(gmcpBytes);
112+
var package = CurrentEncoding.GetString(gmcpSpan[..firstSpace]);
113+
#else
107114
var packageBytes = gmcpBytes.Take(firstSpace).ToArray();
108-
var rest = gmcpBytes.Skip(firstSpace + 1).ToArray();
109-
110-
// TODO: Consideration: a version of this that sends back a Dynamic or other similar object.
111115
var package = CurrentEncoding.GetString(packageBytes);
116+
#endif
112117

113118
if(package == "MSDP")
114119
{
115120
// Call MSDP plugin if available
116121
var msdpPlugin = PluginManager?.GetPlugin<Protocols.MSDPProtocol>();
117122
if (msdpPlugin != null && msdpPlugin.IsEnabled)
118123
{
124+
#if NET5_0_OR_GREATER
125+
// MSDPScan requires byte[] - only allocate when MSDP plugin is enabled
126+
var packageBytes = gmcpSpan[..firstSpace].ToArray();
127+
#endif
119128
await msdpPlugin.OnMSDPMessageAsync(this, JsonSerializer.Serialize(Functional.MSDPLibrary.MSDPScan(packageBytes, CurrentEncoding)));
120129
}
121130
}
@@ -125,7 +134,13 @@ private async ValueTask CompleteGMCPNegotiation(StateMachine<State, Trigger>.Tra
125134
var gmcpPlugin = PluginManager?.GetPlugin<Protocols.GMCPProtocol>();
126135
if (gmcpPlugin != null && gmcpPlugin.IsEnabled)
127136
{
137+
#if NET5_0_OR_GREATER
138+
var rest = gmcpSpan[(firstSpace + 1)..];
139+
await gmcpPlugin.OnGMCPMessageAsync((Package: package, Info: CurrentEncoding.GetString(rest)));
140+
#else
141+
var rest = gmcpBytes.Skip(firstSpace + 1).ToArray();
128142
await gmcpPlugin.OnGMCPMessageAsync((Package: package, Info: CurrentEncoding.GetString(rest)));
143+
#endif
129144
}
130145
}
131146
}

TelnetNegotiationCore/Interpreters/TelnetMSSPInterpreter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,11 @@ private async ValueTask ReadMSSPValues()
118118
#if NET5_0_OR_GREATER
119119
var grouping = _currentMSSPVariableList
120120
.Zip(_currentMSSPValueList)
121-
.GroupBy(x => CurrentEncoding.GetString([.. x.First]));
121+
.GroupBy(x => CurrentEncoding.GetString(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(x.First)));
122122

123123
foreach (var group in grouping)
124124
{
125-
StoreClientMSSPDetails(group.Key, group.Select(x => CurrentEncoding.GetString([.. x.Second])));
125+
StoreClientMSSPDetails(group.Key, group.Select(x => CurrentEncoding.GetString(System.Runtime.InteropServices.CollectionsMarshal.AsSpan(x.Second))));
126126
}
127127
#else
128128
var grouping = _currentMSSPVariableList

TelnetNegotiationCore/Interpreters/TelnetNAWSInterpreter.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,30 @@ public async ValueTask SendNAWS(short width, short height)
5353
{
5454
if(!_WillingToDoNAWS) await default(ValueTask);
5555

56+
#if NET5_0_OR_GREATER
57+
// Use BinaryPrimitives for explicit big-endian encoding (network byte order per RFC 1073)
58+
// Note: We use stackalloc for the working buffer then ToArray() for the callback.
59+
// While this still allocates, stackalloc provides better locality and the buffer is small (9 bytes).
60+
// A future API version could accept ReadOnlySpan<byte> to eliminate the allocation entirely.
61+
Span<byte> buffer = stackalloc byte[9];
62+
buffer[0] = (byte)Trigger.IAC;
63+
buffer[1] = (byte)Trigger.SB;
64+
buffer[2] = (byte)Trigger.NAWS;
65+
System.Buffers.Binary.BinaryPrimitives.WriteInt16BigEndian(buffer[3..], width);
66+
System.Buffers.Binary.BinaryPrimitives.WriteInt16BigEndian(buffer[5..], height);
67+
buffer[7] = (byte)Trigger.IAC;
68+
buffer[8] = (byte)Trigger.SE;
69+
70+
await CallbackNegotiationAsync(buffer.ToArray());
71+
#else
72+
// NOTE: BitConverter.GetBytes() uses system endianness (typically little-endian on modern systems).
73+
// This may produce incorrect byte order on big-endian systems, but those are extremely rare.
74+
// NAWS protocol requires network byte order (big-endian per RFC 1073).
75+
// For proper big-endian support on all platforms, upgrade to .NET 5+ which uses BinaryPrimitives.
5676
await CallbackNegotiationAsync([(byte)Trigger.IAC, (byte)Trigger.SB, (byte)Trigger.NAWS,
5777
.. BitConverter.GetBytes(width), .. BitConverter.GetBytes(height),
5878
(byte)Trigger.IAC, (byte)Trigger.SE]);
79+
#endif
5980
}
6081

6182
/// <summary>

TelnetNegotiationCore/Interpreters/TelnetStandardInterpreter.cs

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -305,40 +305,13 @@ private async ValueTask WriteToOutput()
305305
return;
306306
}
307307

308-
byte[]? rentedBuffer = null;
309-
try
310-
{
311-
// Use stackalloc for very small buffers (<=512 bytes), ArrayPool for larger
312-
// This reduces heap allocations for common case of small messages
313-
byte[] cp;
314-
if (_bufferPosition <= 512)
315-
{
316-
// For small buffers, allocate directly - no intermediate stackalloc needed
317-
// since we must create a byte[] for the callback anyway
318-
cp = _buffer.AsSpan()[.._bufferPosition].ToArray();
319-
}
320-
else
321-
{
322-
// Rent from pool for larger buffers to reduce allocations
323-
rentedBuffer = ArrayPool<byte>.Shared.Rent(_bufferPosition);
324-
_buffer.AsSpan()[.._bufferPosition].CopyTo(rentedBuffer);
325-
cp = rentedBuffer[.._bufferPosition];
326-
}
308+
// Create array for callback - always allocate exact size needed
309+
var cp = _buffer.AsSpan()[.._bufferPosition].ToArray();
310+
_bufferPosition = 0;
327311

328-
_bufferPosition = 0;
329-
330-
if (CallbackOnSubmitAsync is not null)
331-
{
332-
await CallbackOnSubmitAsync(cp, CurrentEncoding, this);
333-
}
334-
}
335-
finally
312+
if (CallbackOnSubmitAsync is not null)
336313
{
337-
// Return rented buffer to pool
338-
if (rentedBuffer != null)
339-
{
340-
ArrayPool<byte>.Shared.Return(rentedBuffer);
341-
}
314+
await CallbackOnSubmitAsync(cp, CurrentEncoding, this);
342315
}
343316
}
344317

TelnetNegotiationCore/Plugins/ProtocolContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ public async ValueTask SendNegotiationAsync(ReadOnlyMemory<byte> bytes)
5454
{
5555
if (_interpreter.CallbackNegotiationAsync != null)
5656
{
57+
// Note: ToArray() is necessary here because CallbackNegotiationAsync signature requires byte[]
58+
// Changing this would be a breaking API change. The allocation is acceptable since
59+
// negotiation messages are typically small (2-10 bytes) and infrequent.
5760
await _interpreter.CallbackNegotiationAsync(bytes.ToArray());
5861
}
5962
}

TelnetNegotiationCore/Protocols/GMCPProtocol.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,12 +224,9 @@ private async ValueTask CompleteGMCPNegotiation(StateMachine<State, Trigger>.Tra
224224
#if NET5_0_OR_GREATER
225225
// Use CollectionsMarshal.AsSpan with slicing for zero-copy access
226226
var gmcpSpan = CollectionsMarshal.AsSpan(gmcpBytes);
227-
var packageBytes = gmcpSpan[..firstSpace].ToArray();
228-
var rest = gmcpSpan[(firstSpace + 1)..].ToArray();
229227
var package = context.CurrentEncoding.GetString(gmcpSpan[..firstSpace]);
230228
#else
231229
var packageBytes = gmcpBytes.Take(firstSpace).ToArray();
232-
var rest = gmcpBytes.Skip(firstSpace + 1).ToArray();
233230
var package = context.CurrentEncoding.GetString(packageBytes);
234231
#endif
235232

@@ -239,6 +236,10 @@ private async ValueTask CompleteGMCPNegotiation(StateMachine<State, Trigger>.Tra
239236
var msdpPlugin = context.GetPlugin<MSDPProtocol>();
240237
if (msdpPlugin != null && msdpPlugin.IsEnabled)
241238
{
239+
#if NET5_0_OR_GREATER
240+
// MSDPScan requires byte[] - only allocate when MSDP plugin is enabled
241+
var packageBytes = gmcpSpan[..firstSpace].ToArray();
242+
#endif
242243
await msdpPlugin.OnMSDPMessageAsync(context.Interpreter, JsonSerializer.Serialize(Functional.MSDPLibrary.MSDPScan(packageBytes, context.CurrentEncoding)));
243244
}
244245
}
@@ -247,7 +248,13 @@ private async ValueTask CompleteGMCPNegotiation(StateMachine<State, Trigger>.Tra
247248
// Call GMCP plugin callback
248249
if (_onGMCPReceived != null)
249250
{
251+
#if NET5_0_OR_GREATER
252+
var rest = gmcpSpan[(firstSpace + 1)..];
250253
await _onGMCPReceived((Package: package, Info: context.CurrentEncoding.GetString(rest)));
254+
#else
255+
var rest = gmcpBytes.Skip(firstSpace + 1).ToArray();
256+
await _onGMCPReceived((Package: package, Info: context.CurrentEncoding.GetString(rest)));
257+
#endif
251258
}
252259
}
253260
}

TelnetNegotiationCore/TelnetNegotiationCore.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<PropertyGroup>
44
<TargetFrameworks>netstandard2.1;net8.0;net10.0</TargetFrameworks>
55
<LangVersion>latest</LangVersion>
6-
<Version>2.2.0</Version>
6+
<Version>2.3.0</Version>
77
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
88
<Title>Telnet Negotiation Core</Title>
99
<PackageId>$(AssemblyName)</PackageId>

0 commit comments

Comments
 (0)