-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Improve environment performance #8846
Improve environment performance #8846
Conversation
…ance # Conflicts: # core/shared/src/main/scala/zio/Scope.scala
|
||
def isEmpty: Boolean = size == 0 | ||
|
||
def updated[V1 >: V](key: K, value: V1): UpdateOrderLinkedMap[K, V1] = { |
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.
TODO: Need to add a hard limit on how big the underlying Vector can get before rebuilding it fresh. It's very unlikely we'll ever have a case that keeps forever adding services but it's not impossible
@kyri-petrou Thanks for your work on this! The fast-path for Scope is clearly working. I am concerned that there are a number of benchmarks that seem to get slower after the PR. Is this still a work-in-progress or are you looking for more final feedback here? |
@jdegoes I'm looking for some final feedback; The work that it's left to do is adding tests getting MiMa happy.
There are only 2 benchmarks that become slower after this PR, one that only benchmarks On the other hand, for a 1:10 add-to-get ratio(which I think is a more representative usage pattern), we get ~10x improvement with the new implementation. Previously, the |
@jdegoes in case you started reviewing the code already; I just realised I didn't push my latest commit. Just pushed it with some minor fixes to |
@jdegoes marking this as ready for review as I've added the tests that I wanted to. There's still 1 MiMa failure, complaining about about a synthetic method missing. This synthetic method was due to the parameter that had a default value. However, given that the constructor of ZEnvironment is private, I think it's relatively safe to add that as a MiMa exclusion? Let me know what you think |
@kyri-petrou Yes, for sure! |
@jdegoes done 👍 I also deleted one of the tests as it didn't work on JS/Native and I could just see it end up being flaky |
/claim #8828
/closes #8828
@jdegoes Leaving this as draft I would like some feedback on this approach, until I finish the following:
UpdateOrderLinkedMap
Main changes
This PR pretty much rewrites the underlying implemention of ZEnvironment to be optimized in the following 2 ways:
Scope
, but we could potentially add more in the future.ZEnvironment
using a map which preserves ordering based on when an entry was last modified. Besides improved performance, this also allows us to remove all the code that was previously needed to keep track of update orderingWhy are we caching?
If you're familiar with the internals of the ZEnvironment, you can skip to the next section
The
ZEnvironment
is designed to use theTag
of an object as the key to the map. When we add a new service to the environment, we simply add it using the tag as the key to the map. This will replace the existing service if it exists in the map. However, since the environment is covariant, we may come across this issue:In the example above, our
env
now contains 2 entries, one with theService
as key and anotherFoo
as the key. A naive approach while accessing theService
afterwards would be to just useService
as the key, but this would return us the wrong value (fooAsService
). This is wrong because theZEnvironment
is Covariant, which means the most up-to-date value forService
is at theFoo
key!And because of this comes the need to iterate through all of the services in the map, find the ones that are subtypes of
Service
and return the most recently modified one. This is very computationally expensive, and that's where the cache comes in; for tags that we've previously seen and got a match for, put them in a secondary internal cache so that we don't have to do this all over again. This works well except for cases when we add new services to the map, as the underlying cache becomes busted.Improving performance of uncached keys
The approach taken in this PR is to use an update-ordered linked Map implementation, where
iterator
/reverseIterator
return the elements in the order they've been last updated. ForreverseIterator
, that means the first key would be the one that was last updated. This means that when a key is uncached, we don't need to iterate through the entire map; we can just stop iterating on the first subtype tag match, making the complexity at mostO(n)
. In addition, since logically we're more likely to access the most recently added services, we're more likely to be closer toO(1)
thanO(n)
.Implementation of
UpdateOrderLinkedMap
The implementation used in this PR took the implementation of Scala's
VectorMap
(2.13+) and adapted it to suite the specific needs of the ZEnvironment; primarily:VectorMap
is insertion ordered. This means to achieve our goal we would have to domap.remove(key).updated(key, value)
, which is less efficient than doing a single updateVectorMap
doesn't provide an efficientreverseIterator
implementation, which is something we really needThe implementation works by placing
Tombstone
s at the locations where entries were previously but then updated (and thus places to the end). This approach allows us to create iterators that don't require to traverse the entirety of the underlying Vector.In addition, the iterators are cached using a poor-man's version of a LazyList. This means that whenever we create a new iterator, we cache the entries as we keep iterating through it. The implementation in this PR would be the equivalent of doing this with a LazyList, but it allows us to use it in Scala 2.12 and it's more performant as it has a very specialized usecase:
Using an update-ordered map also allows us to delete a lot of code that was previously there to keep track of the order that entries were updated!
Other changes
ZEnvironment#unsafe.addScope
method to bypass type-checking wherever possibleprune
andunionAll
ZEnvironment.Patch.diff
(important when joining fibers!!)Benchmark results
One of the most difficult things I had to do in this PR was come up with sensible benchmarks. Please let me know if you can think of any case that wasn't included. I used a
ZEnvironment
with 50 entries in the benchmark. For full transparency, very small ZEnvironments don't get much benefit out of these changes since the cost of checking the entire map was quite small.The good:
get
on cached entries is roughly the sameget
on uncached entries is significantly fasterget[Scope]
afterZIO.scoped
is significantly fasterget[A]
afterZIO.scoped
is significantly fasterprune
is significantly fasterunionAll
is significantly fasterThe bad:
add
is slowerFull results
series/2.x
PR: