Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce memory footprint of HTTP/2 connections #112719

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

MihaZupan
Copy link
Member

Since the logic is shared, this applies to both SocketsHttpHandler and Kestrel.
Possibly addresses dotnet/aspnetcore#60313
cc: @AlgorithmsAreCool, @halter73, @JamesNK for the server side

HPackDecoder allocates 4 buffers:

  • 4 KB _stringOctets, 4 KB _headerNameOctets, 4 KB _headerValueOctets
  • 3 KB DynamicTable

IMO these are oversized for initial sizes:

  • names are way smaller in practice
  • 4 KB is quite large for a single value, except maybe for large cookies
  • if a peer doesn't use dynamic header indexing (e.g. HttpClient), the 3 KB buffer will be unused

I changed these to start very small, letting them grow as needed.
We already have resizing logic, I only modified DynamicTable to also support starting with a non-max-size buffer (it already could resize afterwards).

Looking at HttpClient talking to Kestrel, I expect this should save around

  • 4-11 KB for HttpClient (at least the name buffer, likely a chunk of the value)
    • For cases with few requests (e.g. single long-lived stream) this could be ~14 KB since DynamicTable will stay small
  • 7-14 KB for Kestrel (at least the name and dynamic table, likely a chunk of the value)

@MihaZupan MihaZupan added this to the 10.0.0 milestone Feb 20, 2025
@MihaZupan MihaZupan requested a review from a team February 20, 2025 00:22
@MihaZupan MihaZupan self-assigned this Feb 20, 2025
@Copilot Copilot bot review requested due to automatic review settings February 20, 2025 00:22
Copy link
Contributor

Tagging subscribers to this area: @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

public const int DefaultStringOctetsSize = 4096;

// This is the initial size. Buffers will be dynamically resized as needed.
public const int DefaultStringOctetsSize = 32;
Copy link
Preview

Copilot AI Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reduced default string octets size of 32 may lead to more frequent buffer resizes if header values typically exceed this length; please ensure that the change is backed by performance benchmarks for realistic workloads.

Copilot is powered by AI, so mistakes are possible. Review output carefully before use.

Positive Feedback
Negative Feedback

Provide additional feedback

Please help us improve GitHub Copilot by sharing more details about this comment.

Please select one or more of the options
Copy link
Member Author

@MihaZupan MihaZupan Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could see more intermediate smaller allocations, but that is offset by the fact we're allocating less overall on average.
E.g. we may allocate more for value buffers in the worst case, but we'll allocate less for names. In either case these are temporary per-connection costs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have data on expected string sizes?

@@ -194,7 +194,7 @@ public async ValueTask SetupAsync(CancellationToken cancellationToken)
try
{
_outgoingBuffer.EnsureAvailableSpace(Http2ConnectionPreface.Length +
FrameHeader.Size + FrameHeader.SettingLength +
FrameHeader.Size + 2 * FrameHeader.SettingLength +
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calculation was technically wrong before, but 52 vs 58 both end up with at least 64.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: consider adding parens around the multiplication. I realize it's not necessary, but it took me a moment to visually parse what was being done.

@JamesNK
Copy link
Member

JamesNK commented Feb 20, 2025

@AlgorithmsAreCool
Copy link
Contributor

This would go a long way towards addressing dotnet/aspnetcore#60313. Probably reducing about 25% of memory footprint per connection.

{
var newBuffer = new HeaderField[maxSize / HeaderField.RfcOverhead];
Debug.Assert(_count + 1 <= _maxSize / HeaderField.RfcOverhead);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider putting _maxSize / HeaderField.RfcOverhead into a local (maxCount or whatever) so that it's clear we're validating _count against the same expression that's used in resizing.


var entry = new HeaderField(staticTableIndex, name, value);
_buffer[_insertIndex] = entry;
_insertIndex = (_insertIndex + 1) % _buffer.Length;
Copy link
Member

@stephentoub stephentoub Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be faster and equivalent as:

if (++_insertIndex == _buffer.Length)
{
    _insertIndex = 0;
}

or does it not matter?

var newBuffer = new HeaderField[maxSize / HeaderField.RfcOverhead];
Debug.Assert(_count + 1 <= _maxSize / HeaderField.RfcOverhead);

int newBufferSize = Math.Min(Math.Max(4, _buffer.Length * 2), _maxSize / HeaderField.RfcOverhead);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone is using the dynamic table feature, is it likely they'll only have 4 items in the table? Maybe I misunderstand typical use, but that seems small.

Copy link
Member Author

@MihaZupan MihaZupan Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'll heavily depend on the use case, but you're right, 4 is likely to be exceeded, it should likely start larger.
E.g. if you sent a single request, this might be the number of response headers for that request.
If you're hitting an API where response headers look the same, it might never grow after that.
If you were to use HttpClient as a crawler, then you're practically guaranteed to either use 0 if the server doesn't use it, or maxSize if it does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants