Skip to content

Commit 8801b49

Browse files
authored
Merge pull request #1124 from dotnet/perf/adaptive-receiving-window
Grow channel receiving windows on demand
2 parents 1831477 + 148e414 commit 8801b49

11 files changed

Lines changed: 1143 additions & 18 deletions

src/Nerdbank.Streams/MultiplexingStream.Channel.cs

Lines changed: 338 additions & 16 deletions
Large diffs are not rendered by default.

src/Nerdbank.Streams/MultiplexingStream.ControlCode.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ internal enum ControlCode : byte
5454
/// Recipients that do not recognize this code ignore it.
5555
/// </remarks>
5656
ContentReadingCompleted,
57+
58+
/// <summary>
59+
/// Sent by a channel's receiver to enlarge the receiving window it previously advertised,
60+
/// permitting the remote party to have more unacknowledged bytes in flight.
61+
/// </summary>
62+
/// <remarks>
63+
/// The payload carries the new (absolute) window size, which is only ever larger than the prior value.
64+
/// This code is additive to the protocol rather than a new version of it: recipients that predate it
65+
/// ignore it, which is safe because the window they already agreed to remains valid.
66+
/// It is only ever sent in reply to a <see cref="ChannelWindowGrowthRequest"/>, so it is never sent
67+
/// to a party that would not understand it.
68+
/// </remarks>
69+
ChannelWindowAdjust,
70+
71+
/// <summary>
72+
/// Sent by a channel's sender when it has run out of credit and still has data to send,
73+
/// asking the receiver to enlarge the window it advertises.
74+
/// </summary>
75+
/// <remarks>
76+
/// This code is additive to the protocol rather than a new version of it. A receiver that predates it
77+
/// ignores it and never replies, which costs one small frame per channel and leaves throughput
78+
/// exactly where a fixed window would have left it.
79+
/// </remarks>
80+
ChannelWindowGrowthRequest,
5781
}
5882
}
5983
}

src/Nerdbank.Streams/MultiplexingStream.Formatters.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ internal ValueTask<FlushResult> FlushAsync(CancellationToken cancellationToken)
9696

9797
internal abstract ReadOnlySequence<byte> SerializeContentProcessed(long bytesProcessed);
9898

99+
/// <summary>
100+
/// Serializes the payload of a <see cref="ControlCode.ChannelWindowAdjust"/> frame.
101+
/// </summary>
102+
/// <param name="windowSize">The new (absolute) size of the receiving window.</param>
103+
/// <returns>The serialized payload.</returns>
104+
/// <remarks>
105+
/// The payload is a single integer, so it shares an encoding with <see cref="SerializeContentProcessed(long)"/>.
106+
/// </remarks>
107+
internal virtual ReadOnlySequence<byte> SerializeWindowSize(long windowSize) => this.SerializeContentProcessed(windowSize);
108+
109+
/// <summary>
110+
/// Deserializes the payload of a <see cref="ControlCode.ChannelWindowAdjust"/> frame.
111+
/// </summary>
112+
/// <param name="payload">The payload to deserialize.</param>
113+
/// <returns>The new (absolute) size of the receiving window.</returns>
114+
internal virtual long DeserializeWindowSize(ReadOnlySequence<byte> payload) => this.DeserializeContentProcessed(payload);
115+
99116
protected static bool IsOdd(ReadOnlySpan<byte> localRandomBuffer, ReadOnlySpan<byte> remoteRandomBuffer)
100117
{
101118
bool? isOdd = null;

src/Nerdbank.Streams/MultiplexingStream.Options.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ public class Options
5353
/// </summary>
5454
private long defaultChannelReceivingWindowSize = RecommendedDefaultChannelReceivingWindowSize;
5555

56+
/// <summary>
57+
/// Backing field for the <see cref="MaxChannelReceivingWindowSize"/> property.
58+
/// </summary>
59+
private long maxChannelReceivingWindowSize = 16 * RecommendedDefaultChannelReceivingWindowSize;
60+
61+
/// <summary>
62+
/// Backing field for the <see cref="MaxTotalChannelReceivingWindowSize"/> property.
63+
/// </summary>
64+
private long maxTotalChannelReceivingWindowSize = 64 * RecommendedDefaultChannelReceivingWindowSize;
65+
5666
/// <summary>
5767
/// Backing field for the <see cref="DefaultChannelTraceSourceFactory"/> property.
5868
/// </summary>
@@ -91,6 +101,8 @@ public Options(Options copyFrom)
91101
Requires.NotNull(copyFrom, nameof(copyFrom));
92102

93103
this.defaultChannelReceivingWindowSize = copyFrom.defaultChannelReceivingWindowSize;
104+
this.maxChannelReceivingWindowSize = copyFrom.maxChannelReceivingWindowSize;
105+
this.maxTotalChannelReceivingWindowSize = copyFrom.maxTotalChannelReceivingWindowSize;
94106
this.traceSource = copyFrom.traceSource;
95107
this.protocolMajorVersion = copyFrom.protocolMajorVersion;
96108
this.defaultChannelTraceSourceFactory = copyFrom.defaultChannelTraceSourceFactory;
@@ -152,6 +164,61 @@ public long DefaultChannelReceivingWindowSize
152164
}
153165
}
154166

167+
/// <summary>
168+
/// Gets or sets the largest value that <see cref="DefaultChannelReceivingWindowSize"/> may grow to
169+
/// for an individual channel that demonstrates a need for it.
170+
/// </summary>
171+
/// <value>
172+
/// Must be a positive value. The default is 16 times <see cref="DefaultChannelReceivingWindowSize"/>'s default value.
173+
/// </value>
174+
/// <exception cref="ArgumentOutOfRangeException">Thrown if set to a non-positive value.</exception>
175+
/// <remarks>
176+
/// <para>
177+
/// This value is only used when <see cref="ProtocolMajorVersion"/> is at least 4, which adds the ability
178+
/// for a receiver to enlarge a channel's receiving window after the channel has been established.
179+
/// A channel's window only grows while its remote sender is actually blocked by it, so channels that never
180+
/// saturate their window never consume more than <see cref="DefaultChannelReceivingWindowSize"/>.
181+
/// </para>
182+
/// <para>
183+
/// Growth is additionally bounded across all channels by <see cref="MaxTotalChannelReceivingWindowSize"/>.
184+
/// </para>
185+
/// </remarks>
186+
public long MaxChannelReceivingWindowSize
187+
{
188+
get => this.maxChannelReceivingWindowSize;
189+
set
190+
{
191+
Requires.Range(value > 0, nameof(value));
192+
this.ThrowIfFrozen();
193+
this.maxChannelReceivingWindowSize = value;
194+
}
195+
}
196+
197+
/// <summary>
198+
/// Gets or sets the total number of bytes that all channels on this stream combined may commit to
199+
/// receiving window growth beyond their initial <see cref="DefaultChannelReceivingWindowSize"/>.
200+
/// </summary>
201+
/// <value>
202+
/// Must be a non-negative value. The default is 64 times <see cref="DefaultChannelReceivingWindowSize"/>'s default value.
203+
/// </value>
204+
/// <exception cref="ArgumentOutOfRangeException">Thrown if set to a negative value.</exception>
205+
/// <remarks>
206+
/// This value is only used when <see cref="ProtocolMajorVersion"/> is at least 4.
207+
/// It bounds the worst case memory that automatic window growth may commit for the entire stream,
208+
/// so that a stream with many busy channels cannot multiply <see cref="MaxChannelReceivingWindowSize"/>
209+
/// by an unbounded number of channels.
210+
/// </remarks>
211+
public long MaxTotalChannelReceivingWindowSize
212+
{
213+
get => this.maxTotalChannelReceivingWindowSize;
214+
set
215+
{
216+
Requires.Range(value >= 0, nameof(value));
217+
this.ThrowIfFrozen();
218+
this.maxTotalChannelReceivingWindowSize = value;
219+
}
220+
}
221+
155222
/// <summary>
156223
/// Gets or sets the protocol version to be used.
157224
/// </summary>
@@ -160,6 +227,7 @@ public long DefaultChannelReceivingWindowSize
160227
/// 1 is the original and default version.
161228
/// 2 is a protocol breaking change and adds backpressure support.
162229
/// 3 is a protocol breaking change that removes the initial handshake so no round-trip to establish the connection is necessary.
230+
/// 4 is a protocol breaking change that allows a receiver to enlarge a channel's receiving window after the channel is established.
163231
/// </remarks>
164232
public int ProtocolMajorVersion
165233
{

src/Nerdbank.Streams/MultiplexingStream.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,26 @@ public partial class MultiplexingStream : IDisposableObservable, System.IAsyncDi
119119
/// </summary>
120120
private readonly int protocolMajorVersion;
121121

122+
/// <summary>
123+
/// The largest receiving window any single channel may grow to via <see cref="ControlCode.ChannelWindowAdjust"/>.
124+
/// </summary>
125+
private readonly long maxChannelReceivingWindowSize;
126+
122127
/// <summary>
123128
/// A value indicating whether any open channels should be faulted (i.e. their <see cref="Channel.Completion"/> task will be faulted)
124129
/// when the <see cref="MultiplexingStream"/> is disposed.
125130
/// </summary>
126131
private readonly bool faultOpenChannelsOnStreamDisposal;
127132

133+
/// <summary>
134+
/// The number of bytes of receiving window growth (beyond each channel's initial window)
135+
/// that remain available to be committed across all channels of this stream.
136+
/// </summary>
137+
/// <remarks>
138+
/// Accessed only via <see cref="TryReserveWindowGrowth(long)"/> and <see cref="ReleaseWindowGrowth(long)"/>.
139+
/// </remarks>
140+
private long remainingWindowGrowthBudget;
141+
128142
/// <summary>
129143
/// The last number assigned to a channel.
130144
/// Each use of this should increment by two, if <see cref="isOdd"/> has a value.
@@ -162,6 +176,8 @@ private MultiplexingStream(Formatter formatter, bool? isOdd, Options options)
162176

163177
this.DefaultChannelReceivingWindowSize = options.DefaultChannelReceivingWindowSize;
164178
this.protocolMajorVersion = options.ProtocolMajorVersion;
179+
this.maxChannelReceivingWindowSize = Math.Max(options.MaxChannelReceivingWindowSize, options.DefaultChannelReceivingWindowSize);
180+
this.remainingWindowGrowthBudget = options.MaxTotalChannelReceivingWindowSize;
165181

166182
// Set up seed channels
167183
for (int i = 0; i < options.SeededChannels.Count; i++)
@@ -866,6 +882,12 @@ private async Task ReadStreamAsync()
866882
case ControlCode.ContentProcessed:
867883
this.OnContentProcessed(header, frame.Value.Payload);
868884
break;
885+
case ControlCode.ChannelWindowAdjust:
886+
this.OnChannelWindowAdjust(header, frame.Value.Payload);
887+
break;
888+
case ControlCode.ChannelWindowGrowthRequest:
889+
this.OnChannelWindowGrowthRequest(header);
890+
break;
869891
case ControlCode.ContentWritingCompleted:
870892
this.OnContentWritingCompleted(header.RequiredChannelId);
871893
break;
@@ -1086,6 +1108,72 @@ private void OnContentProcessed(FrameHeader header, ReadOnlySequence<byte> paylo
10861108
channel.OnContentProcessed(bytesProcessed);
10871109
}
10881110

1111+
private void OnChannelWindowAdjust(FrameHeader header, ReadOnlySequence<byte> payloadBuffer)
1112+
{
1113+
Channel? channel;
1114+
lock (this.syncObject)
1115+
{
1116+
this.openChannels.TryGetValue(header.RequiredChannelId, out channel);
1117+
}
1118+
1119+
if (channel is null)
1120+
{
1121+
// The channel closed concurrently with the remote party's decision to enlarge its window.
1122+
// Dropping the adjustment is safe: there is nothing left to send.
1123+
return;
1124+
}
1125+
1126+
long newWindowSize = this.formatter.DeserializeWindowSize(payloadBuffer);
1127+
channel.OnWindowAdjust(newWindowSize);
1128+
}
1129+
1130+
private void OnChannelWindowGrowthRequest(FrameHeader header)
1131+
{
1132+
Channel? channel;
1133+
lock (this.syncObject)
1134+
{
1135+
this.openChannels.TryGetValue(header.RequiredChannelId, out channel);
1136+
}
1137+
1138+
// A request for a channel that has since closed needs no answer.
1139+
channel?.OnWindowGrowthRequested();
1140+
}
1141+
1142+
/// <summary>
1143+
/// Attempts to claim a portion of this stream's total budget for receiving window growth.
1144+
/// </summary>
1145+
/// <param name="bytes">The number of additional bytes of window the caller wants to commit to.</param>
1146+
/// <returns><see langword="true"/> if the budget had room and has been debited; otherwise <see langword="false"/>.</returns>
1147+
private bool TryReserveWindowGrowth(long bytes)
1148+
{
1149+
long remaining = Volatile.Read(ref this.remainingWindowGrowthBudget);
1150+
while (remaining >= bytes)
1151+
{
1152+
long candidate = Interlocked.CompareExchange(ref this.remainingWindowGrowthBudget, remaining - bytes, remaining);
1153+
if (candidate == remaining)
1154+
{
1155+
return true;
1156+
}
1157+
1158+
remaining = candidate;
1159+
}
1160+
1161+
return false;
1162+
}
1163+
1164+
/// <summary>
1165+
/// Returns window growth budget previously claimed by <see cref="TryReserveWindowGrowth(long)"/>,
1166+
/// so that other channels may use it after a channel closes.
1167+
/// </summary>
1168+
/// <param name="bytes">The number of bytes to return to the budget.</param>
1169+
private void ReleaseWindowGrowth(long bytes)
1170+
{
1171+
if (bytes > 0)
1172+
{
1173+
Interlocked.Add(ref this.remainingWindowGrowthBudget, bytes);
1174+
}
1175+
}
1176+
10891177
private void OnOffer(QualifiedChannelId channelId, ReadOnlySequence<byte> payloadBuffer)
10901178
{
10911179
Channel.OfferParameters? offerParameters = this.formatter.DeserializeOfferParameters(payloadBuffer);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) Andrew Arnott. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
namespace Nerdbank.Streams.Benchmark
5+
{
6+
using System;
7+
using System.IO;
8+
using System.IO.Pipelines;
9+
using System.Threading.Tasks;
10+
using BenchmarkDotNet.Attributes;
11+
12+
/// <summary>
13+
/// Measures bulk transfer over a transport with artificial latency.
14+
/// </summary>
15+
/// <remarks>
16+
/// <para>
17+
/// The other benchmarks all run over loopback, where a round trip is essentially free. That makes
18+
/// them blind to the central trade-off in the receive window's flow control: returning credit less
19+
/// often costs fewer frames but makes the sender wait a full round trip when it does run out.
20+
/// On loopback the second half of that trade-off is invisible, so tuning against loopback alone
21+
/// will happily pick a setting that behaves badly on a real network.
22+
/// </para>
23+
/// <para>
24+
/// Throughput here is expected to approach the window divided by the round trip time whenever the
25+
/// window is the binding constraint, so the interesting signal is how much larger a window must be
26+
/// as latency grows.
27+
/// </para>
28+
/// </remarks>
29+
public class LatencyBulkTransferBenchmark : MultiplexingStreamBenchmarkBase
30+
{
31+
private byte[] payload = null!;
32+
33+
/// <summary>
34+
/// Gets or sets the one-way delay applied to the transport, in milliseconds.
35+
/// </summary>
36+
/// <remarks>
37+
/// The round trip time is twice this. Zero disables the wrapper entirely, giving a
38+
/// direct comparison against the plain loopback benchmarks. The platform timer cannot
39+
/// faithfully reproduce delays below about a millisecond, so no smaller value is offered.
40+
/// </remarks>
41+
[Params(0, 1, 8)]
42+
public int OneWayLatencyMs { get; set; }
43+
44+
/// <summary>
45+
/// Gets or sets the receiving window size to configure, or 0 to use the default.
46+
/// </summary>
47+
[Params(0, 4 * 1024 * 1024)]
48+
public int WindowSize { get; set; }
49+
50+
/// <summary>
51+
/// Gets or sets the number of bytes to transfer.
52+
/// </summary>
53+
/// <remarks>
54+
/// This is much smaller than the loopback benchmarks use, because a latent connection
55+
/// takes far longer to move the same volume.
56+
/// </remarks>
57+
[Params(4 * 1024 * 1024)]
58+
public int TransferSize { get; set; }
59+
60+
/// <summary>
61+
/// Transfers <see cref="TransferSize"/> bytes over a single channel.
62+
/// </summary>
63+
/// <returns>A task that tracks the transfer.</returns>
64+
[Benchmark]
65+
public async Task TransmitBulkData()
66+
{
67+
(MultiplexingStream.Channel sender, MultiplexingStream.Channel receiver) = await this.CreateChannelAsync(Guid.NewGuid().ToString("n"));
68+
69+
Task writeTask = Task.Run(async delegate
70+
{
71+
await sender.Output.WriteAsync(this.payload);
72+
await sender.Output.CompleteAsync();
73+
});
74+
75+
long bytesRead = 0;
76+
while (bytesRead < this.TransferSize)
77+
{
78+
ReadResult readResult = await receiver.Input.ReadAsync();
79+
if (readResult.Buffer.IsEmpty && readResult.IsCompleted)
80+
{
81+
break;
82+
}
83+
84+
bytesRead += readResult.Buffer.Length;
85+
receiver.Input.AdvanceTo(readResult.Buffer.End);
86+
}
87+
88+
await writeTask;
89+
sender.Dispose();
90+
receiver.Dispose();
91+
}
92+
93+
/// <inheritdoc/>
94+
protected override MultiplexingStream.Options CreateOptions()
95+
{
96+
MultiplexingStream.Options options = base.CreateOptions();
97+
if (this.WindowSize > 0)
98+
{
99+
options.DefaultChannelReceivingWindowSize = this.WindowSize;
100+
}
101+
102+
return options;
103+
}
104+
105+
/// <inheritdoc/>
106+
protected override Stream WrapTransport(Stream transport)
107+
=> this.OneWayLatencyMs == 0 ? transport : new LatencyStream(transport, TimeSpan.FromMilliseconds(this.OneWayLatencyMs));
108+
109+
/// <inheritdoc/>
110+
protected override Task OnConnectedAsync()
111+
{
112+
this.payload = new byte[this.TransferSize];
113+
return Task.CompletedTask;
114+
}
115+
}
116+
}

0 commit comments

Comments
 (0)