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

Avoid allocating a delegate in OptionsMonitor.Get() when possible. #66688

Merged
merged 7 commits into from
Mar 29, 2022

Conversation

madelson
Copy link
Contributor

Fix #61086

@ghost ghost added the community-contribution Indicates that the PR has been added by a community member label Mar 16, 2022
@ghost
Copy link

ghost commented Mar 16, 2022

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

Issue Details

Fix #61086

Author: madelson
Assignees: -
Labels:

area-Extensions-Options

Milestone: -


#if NETSTANDARD2_1
#if NET || NETSTANDARD2_1
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Given that we could use TryGetValue in all frameworks, I assume that it is desirable to use GetOrAdd where we can. This way we'll use it for the newer .NET targets as well.

Lazy<TOptions> value;

#if NET || NETSTANDARD2_1
value = _cache.GetOrAdd(name, static (name, arg) => new Lazy<TOptions>(arg.createOptions(name, arg.factoryArgument)), (createOptions, factoryArgument));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This would be less awkward if we weren't using Lazy. From what I can tell the benefit we're getting from that is ensuring that we never create an options instance that doesn't end up getting used. Is that worth it?

@@ -72,7 +95,7 @@ internal bool TryGetValue(string? name, [MaybeNullWhen(false)] out TOptions opti
public virtual bool TryAdd(string? name, TOptions options!!)
{
return _cache.TryAdd(name ?? Options.DefaultName, new Lazy<TOptions>(
#if !NETSTANDARD2_1
#if NETSTANDARD2_0 || NETFRAMEWORK
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Using the non-delegate constructor whenever we can feels like a win.

// For compatibility, fall back to public GetOrAdd() if we're in a derived class.
if (GetType() != typeof(OptionsCache<TOptions>))
{
return GetOrAdd(name, () => createOptions(name ?? Options.DefaultName, factoryArgument));
Copy link
Member

Choose a reason for hiding this comment

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

Note that even though this is guarded behind an if, it's still going to force a closure allocation at the beginning of the method for all uses due to the createOptions parameter being captured.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a method to create indirection. Is that sufficient to fix?

Copy link
Member

@stephentoub stephentoub Mar 16, 2022

Choose a reason for hiding this comment

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

You can do that, or you can just create a local inside the scope where you want the closure allocated and capture that local, e.g.

if (GetType() != typeof(OptionsCache<TOptions>))
{
    string? localName = name;
    Func<TOptions> localCreateOptions = createOptions;
    TArg localFactoryArgument = factoryArgument;
    return GetOrAdd(name, () => localCreateOptions(localName ?? Options.DefaultName, localFactoryArgument));
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense. Which pattern do you prefer here?

Copy link
Member

Choose a reason for hiding this comment

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

I don't have a strong preference. We've typically done the locals thing, though it's easier to "get it right" with a separate method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It looks like the legacy framework branches should have the same issue because of the delegate passed to the Lazy constructor in the cache miss case. Do you think it is worth fixing for those as well?

@@ -85,8 +85,9 @@ public TOptions CurrentValue
/// </summary>
public virtual TOptions Get(string? name)
{
name = name ?? Options.DefaultName;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This technically isn't necessary because the cache API accepts null. For compat, I left this logic on the branch where we are dealing with an unknown cache implementation.

public bool TryAdd(string? name, FakeOptions options) => throw new NotImplementedException();

public bool TryRemove(string? name) => throw new NotImplementedException();
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Are there standard patterns for testing the behavior that a cached Get() doesn't allocate?

Copy link
Contributor

Choose a reason for hiding this comment

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

I doubt there is a commonly used behavior for this.
But if this is important I would suggest that within a NoGC region you measure the allocated bytes before the get call and after the get call (

[MethodImpl(MethodImplOptions.InternalCall)]
public static extern long GetAllocatedBytesForCurrentThread();
).

Example

void TestMethod() {
    Assert.True(GC.TryStartNoGCRegion());

    long allocatedBytesBefore = GC.GetAllocatedBytesForCurrentThread();
    // my cool operation (Get)
    long allocatedBytesAfter = GC.GetAllocatedBytesForCurrentThread();
    Assert.Equal(allocatedBytesBefore, allocatedBytesAfter);

    GC.EndNoGCRegion();
}

How reliable this allocated bytes count is I can't tell you though. Probably someone from the GC area would have to tell you.

/cc @Maoni0

Copy link
Contributor Author

@madelson madelson Mar 19, 2022

Choose a reason for hiding this comment

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

This seems like a good approach assuming that the allocated byte count is reliable.

I could be wrong but I don't think we actually need the NoGC region because GetAllocatedBytesForCurrentThread() does not go down after a garbage collection (it returns the number of bytes that have ever been allocated). Does that sound right?

Copy link
Contributor

@deeprobin deeprobin Mar 20, 2022

Choose a reason for hiding this comment

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

Actually, wouldn't it make sense to put an AssertExtension for the allocations into the TestUtilities?
Is there anything against it? I could imagine that this will be a test pattern more often in the future.

Assert.NoAllocations(() => _ = monitor.CurrentValue);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@deeprobin this seems straightforward to add to https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/TestUtilities/System/AssertExtensions.cs if you think that's useful. I don't know what the guidelines are for adding new methods there.

In the snippet you show I don't think we could use static because you're capturing monitor in the closure. However, I don't think that matters since the method would check for allocations when calling the provided Action, not from creating it.

Anyway, let me know if you'd like me to move this to be a TestExtensions helper!

Copy link
Contributor

Choose a reason for hiding this comment

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

In the snippet you show I don't think we could use static because you're capturing monitor in the closure. However, I don't think that matters since the method would check for allocations when calling the provided Action, not from creating it.

You're right.

this seems straightforward to add to https://github.com/dotnet/runtime/blob/main/src/libraries/Common/tests/TestUtilities/System/AssertExtensions.cs if you think that's useful. I don't know what the guidelines are for adding new methods there.

I think there are hardly any guidelines (except the style guidelines).

Anyway, let me know if you'd like me to move this to be a TestExtensions helper!

In my opinion, of course, that would make sense but I might wait for an okay from a maintainer of the repository.
@stephentoub What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

I think it would be OK to add an AssertExtension for this functionality. It would be useful in other places. It doesn't have to happen in this PR though. It could happen the next time we need it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It could happen the next time we need it.

Makes sense. Rule of three and all that.

@madelson madelson marked this pull request as ready for review March 16, 2022 12:15
@madelson madelson requested a review from stephentoub March 18, 2022 17:30
@madelson madelson marked this pull request as draft March 18, 2022 17:31
public bool TryAdd(string? name, FakeOptions options) => throw new NotImplementedException();

public bool TryRemove(string? name) => throw new NotImplementedException();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I doubt there is a commonly used behavior for this.
But if this is important I would suggest that within a NoGC region you measure the allocated bytes before the get call and after the get call (

[MethodImpl(MethodImplOptions.InternalCall)]
public static extern long GetAllocatedBytesForCurrentThread();
).

Example

void TestMethod() {
    Assert.True(GC.TryStartNoGCRegion());

    long allocatedBytesBefore = GC.GetAllocatedBytesForCurrentThread();
    // my cool operation (Get)
    long allocatedBytesAfter = GC.GetAllocatedBytesForCurrentThread();
    Assert.Equal(allocatedBytesBefore, allocatedBytesAfter);

    GC.EndNoGCRegion();
}

How reliable this allocated bytes count is I can't tell you though. Probably someone from the GC area would have to tell you.

/cc @Maoni0

long initialBytes = GC.GetAllocatedBytesForCurrentThread();
_ = monitor.CurrentValue;
Assert.Equal(0, GC.GetAllocatedBytesForCurrentThread() - initialBytes);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think having this test is pretty helpful. Writing it helped me catch one more rogue closure allocation, and this should help prevent regression.

@@ -72,7 +105,7 @@ internal bool TryGetValue(string? name, [MaybeNullWhen(false)] out TOptions opti
public virtual bool TryAdd(string? name, TOptions options!!)
{
return _cache.TryAdd(name ?? Options.DefaultName, new Lazy<TOptions>(
#if !NETSTANDARD2_1
#if NETSTANDARD2_0 || NETFRAMEWORK
Copy link
Member

Choose a reason for hiding this comment

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

(nit) can we keep the #if checks consistent in this file?

This could be

#if !(NET || NETSTANDARD2_1)

value = _cache.GetOrAdd(name, static (name, createOptions) => new Lazy<TOptions>(createOptions), createOptions);
#else
if (!_cache.TryGetValue(name, out value))
{
value = _cache.GetOrAdd(name, new Lazy<TOptions>(createOptions));
// copying captured variables to locals avoids allocating a closure if we don't enter the if
Func<TOptions> localCreateOptions = createOptions;
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand what is allocating a closure here. new Lazy<TOptions>(createOptions) will always create the Lazy object. But what is creating the closure?

Copy link
Member

Choose a reason for hiding this comment

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

There's no closure here. The workaround used elsewhere isn't needed here.

Copy link
Contributor Author

@madelson madelson Mar 21, 2022

Choose a reason for hiding this comment

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

Yep I misread that this was not using the factory overload of GetOrAdd. Will fix.

#if NET || NETSTANDARD2_1
value = _cache.GetOrAdd(name, static (name, arg) => new Lazy<TOptions>(arg.createOptions(name, arg.factoryArgument)), (createOptions, factoryArgument));
#else
if (!_cache.TryGetValue(name, out value))
Copy link
Member

Choose a reason for hiding this comment

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

On .NET Framework and netstandard2.0, why don't we just fall back to the public GetOrAdd() as well? I don't think we need to try making a fast-path outside of .NETCoreApp. Especially considering how complicated this method is getting.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense. Wasn't sure how much we should care about this sort of thing on the older frameworks.

@madelson madelson requested a review from eerhardt March 22, 2022 22:59
Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

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

I think this looks good. Thanks for the contribution, @madelson.

Any other feedback?

@eerhardt eerhardt merged commit 670262a into dotnet:main Mar 29, 2022
@ghost ghost locked as resolved and limited conversation to collaborators Apr 28, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-Extensions-Options community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

A delegate object is created for each IOptionsMonitor<T>.Get call
4 participants