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
IOptionsSnapshot is very slow #53793
Comments
Tagging subscribers to this area: @maryamariyan Issue DetailsThis came up after a perf investigation on a recently refactored endpoint. Moving us back from IOptionsSnapshot to IOptionsMonitor knocked off ~100us, nothing else changed. This option type of ours is fairly costly to create, and looking into Looking through the docs this is vaguely alluded to (though there are contra-indicators) but after doing a search on SO and blogs on the subject most users seem to focus entirely on the 'you get a constant value during a scope/request' aspect of it. As nobody (famous last words) seems to care whether it is entirely recreated my question would be:
|
Yes this is a known issue #42222. IOptionsSnapshot is currently a performance trap.
Agreed that this implementation would have been more efficient. My only guess as to why it was done this way was because we had unified IOptions and IOptionsSnapsot implementations via the OptionsManager (though that's now changed in .NET 6). The sad truth is that it would have been fixed if our own components were using it. I'd be willing to take this change and see if there was any real impact not re-running factories per request. There are some very subtle differences but maybe worth the break. cc @HaoK to see if he remembers why it was done this way. |
The original idea was: |
If we are talking specifically about IOptionsSnapshot, that was intended to be really the no-caching you get a really new fresh instance on every scope, so it literally was intended to recompute everything on every request |
Right. We should consider changing the implementation so that it caches the currently cached value so scoped services are a consistent view of the data but there's no need to recompute if nothing changes |
@davidfowl I see it's been placed in 7.0 is a PR for 6.0 still welcome? |
Yes, if you send a PR, it can be done without adding public API (I believe). |
I agree, alright will be cooking it up. |
@davidfowl started work on it just now, practically the change is extremely simple * if * OptionsManager can take IOptionsMonitor instead of IOptionsFactory. This does mean an api compat error for the constructor, what's the policy on those? Alternatively I could duplicate the OptionsMonitor code into OptionsManager as that's as good as it gets with access to just IOptionsFactory.
Ideally I'd also inline that silly cache (which is only used in OptionsManager) but it's a public type so I guess that's a no ;) |
Or just make a new internal type so we don't need to change the public surface here. Call it OptionsSnapshot<T> |
This should be re-opened since it was reverted in #57570? |
Yes - good call, I had forgotten. |
So the performance issue is by design but the thing that can be cached here is the parsing of configuration. We should be able to only read that once until it changes, VS reading it once per request. The scoped |
Without doing some hacky diffing how would you propose to request just the IConfigureOptions that have a scoped registration? |
@davidfowl that was an honest question, I would like to fix it up but suggestions are welcome. |
IMHO the root problem of the interface
Documentation states that if developer know that each subsequent recalculation will give the same result (in between of configuration change events), then he can enable caching and it will be an optimization. We can at the time of services registration explicitly allow caching of the Implementation idea: public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string name, IConfiguration config, Action<BinderOptions> configureBinder,
bool allowCaching // New argument
)
where TOptions : class
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (config == null)
{
throw new ArgumentNullException(nameof(config));
}
services.AddOptions()
services.AddSingleton<IOptionsChangeTokenSource<TOptions>>(new ConfigurationChangeTokenSource<TOptions>(name, config));
if (allowCaching)
{
// Hope DI supports overriding of open generics by specific registrations.
services.AddScoped<IOptionsSnapshot<T>, OptionsSnapshotCached<T>>();
}
return services.AddSingleton<IConfigureOptions<TOptions>>(new NamedConfigureFromConfigurationOptions<TOptions>(name, config, configureBinder));
}
// Just an idea, final code should be well implemented with various optimizations
public class OptionsSnapshotCached<T>: IOptionsSnapshot<T>
{
private readonly ConcurrentDictionary<string, T> _scopeCache = new ();
private readonly IOptionsMonitor<T> _optionsMonitor;
public OptionsSnapshotCached(IOptionsMonitor<T> optionsMonitor)
{
_optionsMonitor = optionsMonitor;
}
public T Value => _scopeCache.GetOrAdd(string.Empty, _ =>_optionsMonitor.Value);
public T Get(string name) => _scopeCache.GetOrAdd(name, key => _optionsMonitor.Get(key));
} |
However, there's a possible issue. Parsing of the configuration applies the configuration values to an existing
To be honest, I don't like that If it's really by design, it should at least be documented (maybe we can contribute with a documentation update PR?) as this behavior is not obvious. |
Have you profiled where the performance bottle-neck is? |
Some micro-optimization/workaround would be not using anonymous methods, because they are currently not optimized by jit 1.
Footnotes
|
@deeprobin I think the bottleneck is this: #33954. And if we use |
@tompazourek is right. The problem is the rebinding per request. |
There is also a difference in behavior if your code doesn't properly track what causes configuration to change. You can write something like this: class TestOptionsConfigure : IConfigureOptions<TestOptions>
{
private IConfiguration _root;
private ILogger<TestOptionsConfigure> _logger;
public TestOptionsConfigure(IConfiguration root, ILogger<TestOptionsConfigure> logger)
{
_root = root;
_logger = logger;
_logger.LogError("Constructed");
}
public void Configure(TestOptions options)
{
var sec = _root.GetSection("Test");
if (sec != null)
{
options.Val = sec["Value"];
}
_logger.LogError("Configured");
}
} If you add this to the service collection, it will get called each time for IOptionsSnapshot but only once for IOptionsMonitor, even if you change the configuration underneath. IOptionsMonitor only gets notified of changes if you actually bind configuration on adding options, not if you just use IConfigureOptions classes. |
Has there been any movement on this? This is a significant pain point. My naïve take is that whatever is building the IOptionSnapshots should just use IOptionsMonitor internally and only re-bind when a change event occurs. So at least then MOST requests wouldn't have to rebind. |
@profet23, |
Yes it would be great to have the official support of both: per-context state + caching (probably with custom key calculated from the request). And yest the cache should be invalidated once the configuration changes. |
IOptionsSnapshot can't use IOptionsMonitor. In fact, we tried to do that in .NET 7 and it is a major breaking change. IOptionsSnapshot supports injecting scoped The combination of IOptionsSnapshot with uncached configuration binding is a performance pit of failure worth investigating. Specifically, the problem is that calling: |
To do anything useful here we'd need something like the API I proposed in #36130 (comment). This is essentially what the new configuration source generator does today so it's possible that is a reasonable mitigation for this. |
This is a similar solution that I implemented as a workaround for issue in our code. I ended up writing an extension to provide a custom binder, and wrote some binders by hand that were written for the exact types used. It performed very well. A source generator that will generate similar code as the one I wrote by hand sounds like a very good solution to me. |
@davidfowl I understand that this isn't a one size all solution and that this does not behave 1:1 with the current implementation of IOptionsSnapshot, but I am curious how much would break with this: public static IServiceCollection AddFastOptions(this IServiceCollection services)
{
var descriptor = services.Single(descriptor => descriptor.ServiceType == typeof(IOptionsSnapshot<>));
services.Remove(descriptor);
services.AddScoped(typeof(IOptionsSnapshot<>), typeof(FastOptionsSnapshot<>));
return services;
}
public class FastOptionsSnapshot<TOptions> : IOptionsSnapshot<TOptions> where TOptions : class
{
private readonly IOptionsMonitor<TOptions> monitor;
private readonly ConcurrentDictionary<string, TOptions> namedValuesDictionary = new ConcurrentDictionary<string, TOptions>();
public FastOptionsSnapshot(IOptionsMonitor<TOptions> monitor)
{
this.monitor = monitor;
this.Value = monitor.CurrentValue;
}
public TOptions Value { get; }
public TOptions Get(string name)
{
name ??= Options.DefaultName;
namedValuesDictionary.TryAdd(name, monitor.Get(name));
return namedValuesDictionary[name];
}
} Again, understanding that this maintains the same TOptions for each scope lifetime. But does not maintain that the entire configuration is the same for each scope. That said, this is a significant performance boost. Between 200 and 600ms per request (for my use case). EDIT: Updated the non relevant Dictionary snippet because people keep talking about it, and not the actual issue. |
@profet23 Your I just wonder if the tide turns with the total number of options. This should be analyzed. |
@profet23, your code lets a thread read the dictionary without locking, while another thread is writing. That combination is not safe for System.Collections.Generic.Dictionary<TKey, TValue>, although it is safe for System.Collections.Hashtable. |
@davidfowl Are there any linkable examples of the following?
|
This deleted line needs to continue working. |
This isn't a fix, but more of a workaround for the current "performance pit of failure". public static IServiceCollection AddFastOptions(this IServiceCollection services)
{
var descriptor = services.Single(descriptor => descriptor.ServiceType == typeof(IOptionsSnapshot<>));
services.Remove(descriptor);
services.Add(new ServiceDescriptor(descriptor.ImplementationType!, descriptor.ImplementationType!, descriptor.Lifetime));
services.AddScoped(typeof(IOptionsSnapshot<>), typeof(FastOptionsSnapshot<>));
return services;
}
public class FastOptionsSnapshot<TOptions> : IOptionsSnapshot<TOptions> where TOptions : class
{
private readonly IServiceProvider serviceProvider;
private readonly IOptionsMonitor<TOptions>? monitor;
private readonly ConcurrentDictionary<string, TOptions> namedValuesDictionary = new ConcurrentDictionary<string, TOptions>();
public FastOptionsSnapshot(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider;
try
{
monitor = serviceProvider.GetService(typeof(IOptionsMonitor<TOptions>)) as IOptionsMonitor<TOptions>;
}
catch (InvalidOperationException)
{
// Swallow the exception and continue without the monitor.
// This means that the type contains at least one scoped option and we'll need to fall back to OptionsManager (slow) later.
}
}
public TOptions Value => Get(null);
public TOptions Get(string? name)
{
name ??= Options.DefaultName;
var value = monitor?.Get(name) ?? ((OptionsManager<TOptions>) serviceProvider.GetRequiredService(typeof(OptionsManager<TOptions>))).Get(name);
namedValuesDictionary.TryAdd(name, value);
return namedValuesDictionary[name];
}
} Idea being, use the Again, this is a bit hacky, but the performance of the default |
Yes it’s a workaround that we can’t ship but you can use in your apps if they don’t hit this scenario |
Understood. But if others find it useful, I've published my workaround: |
Is there anything we can optimize in The code looks quite complex. |
@deeprobin what is it that you’re attempting to fix? |
@NinoFloris @davidfowl How slow is very slow? I'm currently troubleshooting a performance issue in an application that seems to point to IOptionsSnapshot being passed into the InvokeAsync method in a custom middleware component. We are seeing Waiting times of 30 seconds or more for Framework/Library Lazy`1.CreateValue in application insights profiler on some requests while others sail through just fine. Could this performance issue be attributed to this issue with IOptionsSnapshot or is this orders of magnitude slower than what has been discussed here previously? |
This came up after a perf investigation on a recently refactored endpoint. Moving us back from IOptionsSnapshot to IOptionsMonitor knocked off ~100us, nothing else changed.
This option type of ours is fairly costly to create and looking into
OptionsManager<TOptions>
showed an obvious clue why we saw issues.OptionsManager
does not just cache the instance for the duration of the DI scope but it also creates from scratch an instance through the factory per scope.Looking through the docs this is vaguely alluded to (though there are contra-indicators) but after doing a search on SO and blogs on the subject most users seem to focus entirely on the 'you get a constant value during a scope/request' aspect of it.
As nobody (famous last words) seems to care whether it is entirely recreated my question would be:
Why doesn't
OptionsManager<TOptions>
cache the result of anIOptionsMonitor.Get
call? (leaving out the backwards compatibility part for the sake of argument)The text was updated successfully, but these errors were encountered: