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 16 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

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...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed type of _entries to IReadonlyDictionary<string, FeatureMap> + creating the _entries as ImmutableDictionary to completely make sure we are not breaking the shared-state dictionary.

}
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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

rewritten into 2 consequent foreach loops

@mgravell
Copy link
Member

concept looks solid, nice; added some thoughts

@danmoseley
Copy link
Member

could you please resolve conflict @DeagleGross

@@ -119,4 +121,40 @@ bool IDictionary<string, StringValues>.TryGetValue(string key, out StringValues
value = default(StringValues);
return false;
}

public struct Enumerator : IEnumerator<KeyValuePair<string, StringValues>>, IEnumerator
Copy link
Member

Choose a reason for hiding this comment

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

can this not share as public struct Enumerator : IEnumerator<KeyValuePair<string, T>>, IEnumerator with the one above? ConvertingEnumerator or something


if (_entries.ContainsKey(key) || _contextDependentEntries.ContainsKey(key))
{
_deletedKeys ??= new HashSet<string>();
Copy link
Member

Choose a reason for hiding this comment

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

looks like we're not consistent but new HashSet<string>(StringComparer.Ordinal) is more readable?

Copy link
Member

Choose a reason for hiding this comment

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

similar comment for all the string keyed dictionaries


foreach (var entry in _contextDependentEntries)
{
if (_deletedKeys?.Contains(entry.Key) == true)
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if (_deletedKeys?.Contains(entry.Key) == true)
if (_deletedKeys?.Contains(entry.Key))

?

Copy link
Member

Choose a reason for hiding this comment

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

same elsewhere

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
5 participants