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

perf: improve allocations in OwinEnvironment #58917

Open
wants to merge 14 commits into
base: main
Choose a base branch
from

Conversation

DeagleGross
Copy link
Contributor

@DeagleGross DeagleGross commented Nov 13, 2024

OwinEnvironment allocates a Dictionary<string, FeatureMap> with at least 23 entries of string and FeatureMap objects per request.

In most cases, Owin abstraction is used only to get the data in the specific format (i.e. access the HTTP method via OwinConstants.RequestMethod key), but not to remove \ add entries or rebuild the whole FeatureMap dictionary.

Therefore I am introducing the OwinEntries class (not visible to users), which allocates the static readonly entries dictionary (similar to what existed before) and uses that for the key-value access (so a single allocation per app lifetime against the per-request allocation). However, OwinEnvironment has a rich API to modify the dictionary (i.e. remove entries or clear them completely). Therefore I am doing the following to fully support existing API and dont introduce breaking changes:

  1. for API OwinEnvironment.FeatureMaps returning IDictionary<string, FeatureMap> there is no way to securely determine if the dictionary instance will be changed, and because of that we can't avoid performing the deep-copy of static _entries (same perf loss as existed). Next interaction with OwinEnvironment will be using _contextEntries (request-lifetime) instead of static _entries.
  2. for API OwinEnvironment.Remove(string key) I am using a separate HashSet<string> _deletedKeys to keep track of deleted entries per request lifetime. Even if all original entries are deleted, this is still a more lightweight flow than existed before
  3. for API OwinEnvironment.Clear() I am falling back to _contextEntries usage (request-lifetime)

Note: there are some FeatureMap objects, which are dependent on the HttpContext passed into OwinEnvironment, so I keep them separately in a dedicated Dictionary<string, FeatureMap>. It's contains a single entry so far.

I have added the microbenchmark (see PR), with a code that performs multiple requests using the default HttpContext, and used the new OwinEnvironment implementation against the old one:

    [Benchmark]
    public async Task ProcessMultipleRequests()
    {
        foreach (var i in Enumerable.Range(0, 10000))
        {
            await _requestDelegate(_httpContext);
        }
    }

Benchmark results:

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
OwinRequest_NoOperation (old) 27.46 ms 0.508 ms 0.821 ms 2750.0000 62.5000 - 133 MB
OwinRequest_AccessPorts (old) 29.45 ms 0.577 ms 0.931 ms 2750.0000 62.5000 - 133 MB
OwinRequest_AccessHeaders (old) 28.52 ms 0.565 ms 1.344 ms 2781.2500 93.7500 - 133 MB
OwinRequest_NoOperation (fix) 6.162 ms 0.1208 ms 0.1984 ms 234.3750 - - 12 MB
OwinRequest_AccessPorts (fix) 6.388 ms 0.1219 ms 0.1304 ms 234.3750 - - 12 MB
OwinRequest_AccessHeaders (fix) 6.478 ms 0.0921 ms 0.0817 ms 250.0000 - - 12 MB

(thanks to @deanward81 for the idea)

Closes #58916

@DeagleGross DeagleGross self-assigned this Nov 13, 2024
@dotnet-issue-labeler dotnet-issue-labeler bot added the area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions label Nov 13, 2024
@DeagleGross
Copy link
Contributor Author

DeagleGross commented Nov 13, 2024

TODO:

  • include DictionaryStringValuesWrapper and DictionaryStringArrayWrapper enumerator improvements
  • improve allocations for port string conversion

@DeagleGross DeagleGross requested review from wtgodbe and a team as code owners November 14, 2024 11:30
@DeagleGross DeagleGross force-pushed the dmkorolev/owin/environment-allocations branch from ebd529e to ebc7e4d Compare November 14, 2024 11:35

object IEnumerator.Current => Current;

void IEnumerator.Reset() => throw new NotImplementedException();
Copy link
Member

Choose a reason for hiding this comment

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

nit: change to NotSupportedException - NotImplementedException says "the library author is silly", NotSupportedException says "the caller is silly"

trivial example in support of this (seevoid IEnumerator.Reset()): https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA+ABADAAgwRhQG4BYAKHIwCYcAxCCHAb3LwGY982AeASwDsALgD4cAcRiD6EAM4AKAJTNO+HMFgBDANZEAvjgPldQA==


object IEnumerator.Current => Current;

void IEnumerator.Reset() => throw new NotImplementedException();
Copy link
Member

Choose a reason for hiding this comment

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

ditto


private sealed class OwinEntries : IEnumerable<KeyValuePair<string, FeatureMap>>
{
private static readonly IDictionary<string, FeatureMap> _entries = new Dictionary<string, FeatureMap>
Copy link
Member

Choose a reason for hiding this comment

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

if immutable dictionary is available, I wonder if we should use that instead, to advertise 100% "this will not change"; alternatively (other than for the deferred per-context populate, which might need thought), I wonder if this could just be a switch expression...

}
else
{
foreach (var entry in _entries.Union(_contextDependentEntries))
Copy link
Member

@mgravell mgravell Nov 14, 2024

Choose a reason for hiding this comment

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

Union isn't free; probably isn't an issue, but this might be something to check the impact of in pathological cases; since you have two dictionaries, it might be more efficient to hand roll this with a foreach and ContainsKey / TryGetValue

#pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code
string Plaintext() => "Hello, World!";
app.MapGet("/plaintext", Plaintext);

Copy link
Member

Choose a reason for hiding this comment

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

the CI code-style overlords are picky ;p

@mgravell
Copy link
Member

concept looks solid, nice; added some thoughts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-networking Includes servers, yarp, json patch, bedrock, websockets, http client factory, and http abstractions Perf
Projects
None yet
Development

Successfully merging this pull request may close these issues.

perf: improve allocations in OwinEnvironment
4 participants