Solving a Transactions Performance Mystery
Reuben Bond 12/7/2018 10:08:58 AM
After arriving in Redmond and completing the mandatory New Employee Orientation, my first task on the Orleans team has been to assist with some ongoing performance investigations in order to ensure that Orleans' Transactions support is ready for internal users and hence release.
We were seeing significant performance issues and a large number of transaction failures in the stress/load tests against our test cluster. A large fraction of transactions were stalling until timeout.
Our initial investigations focused on the transaction management code.
Maybe there was a deadlock somewhere.
We took a divide-and-conquer approach, replacing internal transaction components with stubbed-out variants.
The problem was more-or-less isolated to the
ITransactionalState<T> implementation which sits on every grain.
The transactional state is responsible for loading and modifying grain state and handling the various transaction phases (Start, Prepare, Abort, Commit, Confirm) as well as optimizing multiple overlapping transactions within the isolation guarantees using a reader-writer lock.
You can see that it's not a small amount of code, but isolating the issue further was proving difficult for reasons not limited to the fact that taking out any one piece was not showing a dramatic improvement.
Profiling data is critical for performance investigations, so after requesting obtaining permissions to directly access the machines in our test cluster, we collected ETW logs using PerfView using a command similar to this:
PerfView.exe /acceptEULA /noGui /threadTime /zip /maxCollectSec:30 /dataFile:1.etl collect
Analyzing the resulting
.etl file locally, looking at a flame graph for the stack trace samples, the problem is immediately apparent.
PerfView makes the cause of the issue apparent.
The details are too small to read on that view, but by hovering the mouse over each of the bars we can see which method that stack frame represents. The arrows point to the stack frames where the CPU is waiting on a lock and in this case, that lock is on the global .NET Timer queue. The plateau towards the right is from the thread servicing the timer queue and firing the expired timers, which also needs to acquire the lock.
Our load tests are running on .NET Framework 4.6.2 and therefore
System.Threading.Timer is implemented using a global queue (linked list) of timers which is protected by a single lock object. Any operations on this queue must acquire that lock. This is something we were already aware of and Orleans 2.1.0 includes a PR which alleviates potential lock contention
on this queue for our main source of timers (response timeout timers).
The transactions code never uses
Timer, so why is this a problem? Transactions makes use of
Task.Delay for several kinds of tasks and it shows up in most components.
This is why we couldn't narrow down the performance issues to one particular piece of code.
Task.Delay uses a
Timer under the hood, creating a
Timer which might fire once (if it isn't canceled) and deregisters it once it's no longer needed. Our use of
Task.Delay was causing this performance degradation under load.
How do we fix this contention? After verifying that a fix here would actually remedy the problem (success!), I set to work implementing a hopefully simple replacement for
Task.Delay. The result is in this PR.
The gist of how it works is that it uses a single
Timer instance to service thread-local timer collections. The firing of the timers does not need to be precise, so having a timer fire late is not a concern in these uses.
Lock contention is largely avoided by using thread-local data structures, but safety is retained by using a light-weight reentrant
See the PR for more details.