-
-
Notifications
You must be signed in to change notification settings - Fork 230
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
Feature: Lazy Loading of Transitive Shared Types [See Comments] #164
Conversation
Real World Performance TestPeople tend to often showcase the best case scenario for changes; I figured I'd go the other way around and show the worst case scenario instead. Notably; we are working around an I/O bottleneck here so the worst case condition is to perform a hot load (i.e. plugins are either cached by the OS in memory or by hardware). This was performed by loading 5 times in a row and taking the best time before and after. Before: After: This is Reloaded II, using a sample of 16 plugins. Specifically benchmarked is the process described in #60 (comment) , where Real world performance improvement for "worst case" tends to be in the range of 2.5x-3.0x considering the numbers also include finding the entry point etc. I haven't tested cold loads (best case scenario as this is an I/O bottleneck) but I'd expect the difference to be more significant. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for posting this proposal, @Sewer56. I have a few questions before we proceed
var names = new Queue<AssemblyName>(); | ||
names.Enqueue(assemblyName); | ||
while (names.TryDequeue(out var name)) | ||
{ | ||
if (name.Name == null || _defaultAssemblies.Contains(name.Name)) | ||
{ | ||
// base cases | ||
continue; | ||
} | ||
|
||
_defaultAssemblies.Add(assemblyName.Name); | ||
_defaultAssemblies.Add(name.Name); | ||
|
||
// Recursively load and find all dependencies of default assemblies. | ||
// This sacrifices some performance for determinism in how transitive | ||
// dependencies will be shared between host and plugin. | ||
var assembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName); | ||
foreach (var reference in assembly.GetReferencedAssemblies()) | ||
{ | ||
PreferDefaultLoadContextAssembly(reference); | ||
// Load and find all dependencies of default assemblies. | ||
// This sacrifices some performance for determinism in how transitive | ||
// dependencies will be shared between host and plugin. | ||
var assembly = _defaultLoadContext.LoadFromAssemblyName(name); | ||
|
||
foreach (var reference in assembly.GetReferencedAssemblies()) | ||
{ | ||
names.Enqueue(reference); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the purpose of this refactor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did it because the original method was recursive.
I wasn't really sure whether you would have preferred to (from a style perspective) to have a separate internal method for the non-lazy implementation. Personally, I just thought the idea of checking a branch that would always have been false in the beginning seemed a bit silly to me.
Doing it this way is also technically faster due to less branches/jumps; albeit by such a negligible non-perceptible amount it's not worth caring about.
@@ -40,11 +41,12 @@ internal class ManagedLoadContext : AssemblyLoadContext | |||
IReadOnlyDictionary<string, ManagedLibrary> managedAssemblies, | |||
IReadOnlyDictionary<string, NativeLibrary> nativeLibraries, | |||
IReadOnlyCollection<string> privateAssemblies, | |||
IReadOnlyCollection<string> defaultAssemblies, | |||
ICollection<string> defaultAssemblies, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we find a way to code this without changing this? The intention behind read-only behaviors was to minimize the chance for unintended side-effects between contexts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you referring to the parameter specifically or the class field?
I suppose one option is to have read only parameter but copy to our own private collection in the constructor.
This way the API would still signal to the user that the collection is immutable/cannot be changed once instantiated.
/// <summary> | ||
/// Variant of <see cref="TransitiveAssembliesOfSharedTypesAreResolved"/> which uses lazy loading for assemblies. | ||
/// </summary> | ||
[Fact] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of two separate test methods, this could be simplified by just making IsLazyLoaded a parameter.
[Theory]
[InlineData(true)]
[InlineData(false)]
public void TransitiveAssembliesOfSharedTypesAreResolved(bool isLazyLoaded)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Completely agree here; not really sure what I was thinking.
Maybe I thought that it would be easier to look at when checking reports.
That was silly of me; I'll fix that.
Besides my questions about the actual changes to code, my main question here is about the tradeoff between performance and possible non-determinism in loading dependencies. In your perf tests above, it shows this can save 20-30 milliseconds on loading a plugin. Is that amount of time worth it if this leads to strange, hard to reproduce bugs caused by dependencies loading in an unexpected way? |
In retrospect; it's something that should probably have a clear warning sticker put on it. More specifically; what happens if the plugin references a library which is also referenced by a transitive shared assembly ( I made a branch to demonstrate this. A test should fail as expected. I should note that transitively shared assemblies in general are rather dangerous and is a practice I'd recommend avoiding if possible. If there are two plugins expecting a different version of the same transitively shared assembly with incompatible APIs, all hell can break loose. Anyway, that said, I still think there is use for this feature however; for example those situations where you do only need to extract a small amount of data, such as metadata about the author, name etc. of the plugin before properly loading it in the future. In my own case, I have a setup where plugins can share types between each other using the main program as a proxy, and thus identifying exported types faster helps me (see: In practice the difference isn't too huge but I'm personally the kind of person where if I can optimize something for a noticeably better user experience, even if minor, I'd do it. |
I made minor changes to this PR based on the feedback; please let me know if those are a-ok with you. |
@Sewer56 thanks for the reply and detailed description! Thanks for your patience on this one. Let me re-review this code and get back to you soon about merging this. |
Codecov Report
@@ Coverage Diff @@
## main #164 +/- ##
==========================================
+ Coverage 81.86% 82.75% +0.88%
==========================================
Files 15 15
Lines 546 574 +28
==========================================
+ Hits 447 475 +28
Misses 99 99
Continue to review full report at Codecov.
|
Context
Please see Issue #163 for details.
This PR is also the same as #158 , except that I wanted to rename the source branch and split into Issue + PR.
Solution
This PR adds an optional feature
IsLazyLoaded
inPluginConfig
.Relevant patches are made in
AssemblyLoadContextBuilder.PreferDefaultLoadContextAssembly
andManagedLoadContext.Load
.Risks
The intended/main usage of the library, host sharing a contract in the form of a known interface at compile time with the plugins should be unaffected by this optional feature. There should not be any functional difference introduced by not loading ahead of time.
Transitive dependencies may come to mind, specifically dependencies of the plugin that the host is also aware or has a copy of. These function just the same way as if they were loaded ahead of time; I added a test for this just in case.
I have not managed to identify any cases in which the introduction of this feature would break existing functionality.
Tests
As for the test project; I wasn't really sure if this feature needed any special specific tests outside of ensuring transitive assemblies are still correctly resolved; so I added an additional variation of
TransitiveAssembliesOfSharedTypesAreResolved
inSharedTypesTests
that uses the new setting to ensure correct resolution.I also tested this on real software and a few random existing tests just in case, everything seems to be working properly.
Other Miscellaneous Changes (Performance)
PluginLoader.CreateFromAssemblyFile(string, Type[], Action<PluginConfig>)
Instead of adding the assembly for each type, I considered that there are end users (unfamiliar to inner workings) who may pass multiple types belonging to the same assembly to the method. With that in mind, I made the library remove duplicates before they are added to
config.SharedAssemblies
.The main reason for this is down the road, the loader will call
AssemblyLoadContextBuilder.PreferDefaultLoadContextAssembly
; this is an expensive operation when lazy loading is not enabled and it does not need to be executed multiple times with the same assembly name.