Skip to content

Improve perf and scalability of Regex's cache - #542

Merged
stephentoub merged 1 commit into
dotnet:masterfrom
stephentoub:alternateregexcaching
Dec 6, 2019
Merged

Improve perf and scalability of Regex's cache#542
stephentoub merged 1 commit into
dotnet:masterfrom
stephentoub:alternateregexcaching

Conversation

@stephentoub

Copy link
Copy Markdown
Member

Regex maintains a cache used for the static methods on Regex, e.g. Regex.IsMatch. The cache is implemented as an LRU cache, which maintains a linked list and a dictionary of the cached instances. The linked list maintains the order in which the cached instances were last accessed, making it cheap to expunge older items from the cache. However, that comes at a significant cost: unless the item is the very first one in the linked list, all reads on the cache require taking a global lock, because the linked list needs to be mutated to move the found node to the beginning. That lock has both throughput and scalability implications.

This PR changes the cache from using a Dictionary<> and a linked list to instead using a ConcurrentDictionary<> and a List<>. Rather than making all accesses more expensive in order to make drops less expensive, it makes all reads much cheaper and more scalable, at the expense of making drops more expensive. Since dropping from the cache means we're already paying the expensive cost of creating/parsing/compiling/etc. a new Regex instance, this is a better trade-off, especially since any frequent dropping suggests the consuming app or library needs to revisit its Regex strategy, either using Regex.CacheSize to increase the cache size appropriately, or doing its own caching (e.g. creating the Regex instance it needs and storing it into a field for all future use).

The new scheme uses a ConcurrentDictionary<Key,Node>, a List<Node>, and a fast-path field storing the most recently used Regex instance (just as the existing implementation did). On lookups, if the fast-path field has the matching value, it's just returned. Otherwise, the dictionary is consulted, and if the item is found, the fast-path field is updated. No locking at all is employed, and only a few volatile read/writes are used to update a "last access stamp" that's used to indicate importance if/when items do need to be expunged. On additions, we do still take a global lock and add to the cache. If this puts us over our cache size, we pick an item from the list and remove it. If the list is small, we just examine all of the items looking for the oldest. If the list is larger, we examine a random subset of it; we may not get rid of the absolute oldest item, but it'll be old enough.

cc: @danmosemsft, @eerhardt, @ViktorHofer

Results from running master (old) vs this PR (new) on the RegexCache* tests from the dotnet/performance repo:

MethodToolchaintotaluniquecacheSizeMeanRatioGen 0
IsMatchnew400007055.792 ms0.9624750.0000
IsMatchold400007057.454 ms1.0025000.0000
IsMatch_Multithreadingnew400007031.867 ms0.9825000.0000
IsMatch_Multithreadingold400007033.059 ms1.0025000.0000
IsMatchnew40000160015112.867 ms0.6438000.0000
IsMatchold40000160015176.183 ms1.0039000.0000
IsMatch_Multithreadingnew4000016001554.960 ms0.5038000.0000
IsMatch_Multithreadingold40000160015109.076 ms1.0039000.0000
IsMatchnew40000160080087.606 ms0.5120000.0000
IsMatchold400001600800174.088 ms1.0022000.0000
IsMatch_Multithreadingnew40000160080050.726 ms0.4120000.0000
IsMatch_Multithreadingold400001600800123.640 ms1.0022000.0000
IsMatchnew400001600320013.444 ms0.94-
IsMatchold400001600320014.247 ms1.00-
IsMatch_Multithreadingnew40000160032005.500 ms0.42-
IsMatch_Multithreadingold400001600320013.180 ms1.00-
IsMatchnew40000011541.607 ms1.00-
IsMatchold40000011541.512 ms1.00-
IsMatch_Multithreadingnew40000011540.066 ms0.9018000.0000
IsMatch_Multithreadingold40000011544.558 ms1.0033500.0000
IsMatchnew40000071566.953 ms0.93-
IsMatchold40000071571.789 ms1.00-
IsMatch_Multithreadingnew40000071546.878 ms0.5212000.0000
IsMatch_Multithreadingold40000071590.335 ms1.009000.0000

Regex maintains a cache used for the static methods on Regex, e.g. Regex.IsMatch. The cache is implemented as an LRU cache, which maintains a linked list and a dictionary of the cached instances. The linked list maintains the order in which the cached instances were last accessed, making it cheap to expunge older items from the cache. However, that comes at a significant cost: unless the item is the very first one in the linked list, all reads on the cache require taking a global lock, because the linked list needs to be mutated to move the found node to the beginning. That lock has both throughput and scalability implications.
This PR changes the cache from using a `Dictionary<>` and a linked list to instead using a `ConcurrentDictionary<>` and a `List<>`. Rather than making all accesses more expensive in order to make drops less expensive, it makes all reads much cheaper and more scalable, at the expense of making drops more expensive. Since dropping from the cache means we're already paying the expensive cost of creating/parsing/compiling/etc. a new Regex instance, this is a better trade-off, especially since any frequent dropping suggests the consuming app or library needs to revisit its Regex strategy, either using Regex.CacheSize to increase the cache size appropriately, or doing its own caching (e.g. creating the Regex instance it needs and storing it into a field for all future use).
The new scheme uses a `ConcurrentDictionary<Key,Node>`, a `List<Node>`, and a fast-path field storing the most recently used Regex instance (just as the existing implementation did). On lookups, if the fast-path field has the matching value, it's just returned. Otherwise, the dictionary is consulted, and if the item is found, the fast-path field is updated. No locking at all is employed, and only a few volatile read/writes are used to update a "last access stamp" that's used to indicate importance if/when items do need to be expunged. On additions, we do still take a global lock and add to the cache. If this puts us over our cache size, we pick an item from the list and remove it. If the list is small, we just examine all of the items looking for the oldest. If the list is larger, we examine a random subset of it; we may not get rid of the absolute oldest item, but it'll be old enough.
@stephentoub

Copy link
Copy Markdown
MemberAuthor

The CI failures here are strange; lots of EventSource tests failing with, e.g.

 BasicEventSourceTests.TestsWrite.Test_Write_T_ETW [FAIL]
Assert.Equal() Failure
� (pos 0)
Expected: Actual: System.Collections.Concurrent.ConcurrentCúúú
� (pos 0)
Stack Trace:
/_/src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestUtilities.cs(50,0): at BasicEventSourceTests.TestUtilities.CheckNoEventSourcesRunning(String message)
/_/src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestsWrite.cs(456,0): at BasicEventSourceTests.TestsWrite.Test_Write_T(Listener listener)
/_/src/libraries/System.Diagnostics.Tracing/tests/BasicEventSourceTest/TestsWrite.Etw.cs(29,0): at BasicEventSourceTests.TestsWrite.Test_Write_T_ETW()

My assumption is that a) there was some kind of change in coreclr recently that is now causing a discrepancy with the tests, and b) this is now showing up after @safern's live/live change went in last night, but I'm not sure why I don't see similar failures on other PRs, nor why the "Actual" string above looks corrupted ("System.Collections.Concurrent.ConcurrentCúúú"). Regardless, I put up #565 to add this EventSource to the test's exempted list. @noahfalk, ideas?

@safern

Copy link
Copy Markdown
Member

but I'm not sure why I don't see similar failures on other PRs.

Does this repro locally with and without your change?

@eerhardteerhardt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This looks good to me. Just some clarifying questions to help me understand.

@stephentoub

stephentoub commented Dec 6, 2019

Copy link
Copy Markdown
MemberAuthor

Does this repro locally with and without your change?

No. And CI passed now.

@stephentoub
stephentoub merged commit d49fc9e into dotnet:masterDec 6, 2019
@stephentoub
stephentoub deleted the alternateregexcaching branch December 6, 2019 14:48
@noahfalk

Copy link
Copy Markdown
Member

@stephentoub - sorry for a very late reply, I've been on vacation all December and GitHub doesn't have a nice out-of-office feature. I could imagine that your usage of ConcurrentDictionary caused the ConcurrentCollectionsEventSource to get lazily created in a bunch of tests that previously never initialized it, which in turn caused it to get flagged by the test code which is asserting that no unexpected EventSources had been created. Adding it to the exclusion list of known BCL EventSources was the right move. As for why the string was showing up corrupted, that I can't explain. I think its much more likely that it is some issue relating to xunit or the console display given that the string comparison you added at line 39 would only work if eventSource.Name contained the expected string data at that point.

@stephentoub

Copy link
Copy Markdown
MemberAuthor

Thanks, Noah.

@danmoseley

Copy link
Copy Markdown
Contributor

Maybe úúú is meant to be an ellipsis with some special period, and the console codepage is corrupting it.

@stephentoubstephentoub mentioned this pull request Jan 7, 2020
41 tasks
@stephentoubstephentoub added the tenet-performance Performance related issue label Jan 12, 2020
@stephentoubstephentoub added this to the 5.0 milestone Jan 12, 2020
@ghostghost locked as resolved and limited conversation to collaborators Dec 11, 2020
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@stephentoub@safern@noahfalk@danmoseley@eerhardt@Dotnet-GitSync-Bot