Repository navigation
Adding OpenTelemetry instrumentation results in 15% RPS degradation #107158
Description
Description
In the ASP.NET benchmarks, one of the scenarios we run is a "TODOs API" service which is basically a CRUD web api over top of a postgres database. We added it as part of our "cloud native" push in .NET 8. The intention was to track the perf difference between normal .NET, native AOT, trimming, etc.
It was always our intention to include OpenTelemetry in this scenario. It wasn't originally added because OpenTelemetry wasn't native AOT compatible at the time. But now it is, and I decided to enable the same configuration that .NET Aspire defaults to - Add OpenTelemetry to TodosApi (aspnet/Benchmarks#2014).
It adds tracing and metrics instrumentation, and the OTLP exporter configured on an environment variable. The environment variable isn't set in our system, so the telemetry doesn't go anywhere.
I would have thought there wouldn't be much runtime overhead in this scenario, but we are seeing a decent sized regression in RPS now that this is enabled.
Configuration
.NET 9 recent daily builds. Windows and Linux x64.
Regression?
This isn't a regression in .NET. But adding OpenTelemetry instrumentation to an application (even when there are no exporters) is a performance regression.
Data
https://aka.ms/aspnet/nativeaot/benchmarks/
- On the "Native AOT" page - 18 of 21 - (which tracks the same benchmark apps against regular coreclr and native AOT. Note that this issue repros on both, so it isn't a native AOT issue).
- Select "Stage2" on the right. You can pick Intel Linux or Intel Windows at the top, or both.
- Notice the drop in RPS and the increase in average latency on Aug 20, 2024
The RPS went from 270k => 230k on linux. This date is the first time the benchmark was run after aspnet/Benchmarks#2014 was merged.
The following .zip contains before and after .nettrace files of the benchmark.
OpenTelemetryTraces.zip
Analysis
Doing a diff between the 2, I see a bunch more "DotNETRuntime/Contention/Start" events in the "after" for System.Diagnostics.DiagnosticSource
Before:
After:
This appears to be the same issue as illustrated in this simplified repro: https://github.com/cijothomas/lock-contention-activity-repro/blob/main/lock-contention-activity-repro/Program.cs.



Activity
dotnet-policy-service commentedon Aug 29, 2024
Tagging subscribers to this area: @tommcdon
See info in area-owners.md if you want to be subscribed.
EgorBo commentedon Aug 29, 2024
Sounds like the same problem as dotnet/aspnetcore#50412
dotnet-policy-service commentedon Aug 29, 2024
Tagging subscribers to this area: @dotnet/area-system-diagnostics-activity
See info in area-owners.md if you want to be subscribed.
eerhardt commentedon Aug 29, 2024
The RPS being measured in the above benchmark is client side RPS using Wrk.
cijothomas commentedon Aug 29, 2024
Not fully. Based on the traces shown in screenshot, contention is at the Activity level. There is some contention shown for Metric path too. I am yet to check the traces fully to see if it is at the MeterListener level, or occurring at OpenTelemetry level. (I doubt if it is at OTel level, as there is no "lock" statement used by OTel Metrics in hot path.)
tarekgh commentedon Aug 29, 2024
Activity and Meter have the same mechanism in calling back the registered listener which requires the lock to avoid mutating the listeners list during the operation. We need to change that for both cases to avoid locking during the notification.
julealgon commentedon Aug 30, 2024
Interesting seeing this issue here, as we recently had to rollback our OpenTelemetry instrumentation in one of our tool applications.
We have a console app that executes tasks for our background task queue mechanism. In our beta environment, we have nearly 1000 instances of this same
.exeprocess running, across just 2 servers. Those 2 servers usually run at 25 to 30% CPU usage. When a very basic otel setup was introduced, usage capped the server at 100% CPU and would never drop from there: the environment stalled and we had to remove the instrumentation.Because of this extreme performance effect, we currently have a custom feature toggle on our side that we check during initialization to conditionally call
AddOpenTelemetry(and the rest of the OTEL setup) on a particular instance. So now we have OTEL enabled on just 3 or 4 out of the 1000 work types that we have (basically, one work type is mapped to one.exeinstance).Since our application is still using .NET 472, we've not pursued this much further for now as the intent was to reevaluate performance when we could perform the migration to .NET8, which is coming soon.
In our case, the degradation was vastly more significant than 15%, and it seemed to impact the project even if we removed every single instrumentation from the pipeline, which was extremely surprising to me. It is as if just the presence of the built-in hosted services already adds the biggest chunk of the overhead, for whatever reason.
Another thing worth mentioning is that this tool does not have ASPNETCore involved at all. It is just a simple console application that runs on an infinite
whileloop, dequeueing and processing work based on an input parameter that indicates "which type of work it should dequeue/process".7 remaining items
cijothomas commentedon Sep 3, 2024
@AlgorithmsAreCool I used this repro to test this : https://github.com/cijothomas/lock-contention-activity-repro/blob/main/lock-contention-activity-repro/Program.cs
(It exposes the metric about contentions via prometheus, leveraing OpenTelemetry internally!)
AlgorithmsAreCool commentedon Sep 3, 2024
I'll use this repro also to see it's impact. My current test was targeted at contention on the collection of
DiagnosticListenersMy main issue right now is convincing my test app to properly consume the new version of
System.Diagnostics.DiagnosticSourcethat i have built. 😥But based on my testing this should eliminate ~80% of the contention from starting and stopping activities
AlgorithmsAreCool commentedon Sep 3, 2024
@tarekgh Is there a doc somewhere on how to consume local versions of the runtime?
AlgorithmsAreCool commentedon Sep 3, 2024
My microbenchmark benchmark results (so far...)
Before
After
Unless i've done something quite wrong, this should be correct. My methodology was to switch to an immutable copy-on-write approach for modifying the listener collection that uses a CAS to mutate the collection.
AlgorithmsAreCool commentedon Sep 3, 2024
It should be noted that this approach will be slightly slower to add/remove listeners because the list of listeners must be copied each time it is mutated. That that performance will degrade with O(N) complexity as the number of listeners grows.
However, that event should be rare compared to creation of Activities which is made much much faster under contention and likely in the uncontented scenario also because the previous approach would acquire/release a lock for each item in the listener collection individually.
Note the above microbenchmark shows a 10x improvement in runtime using the lock-free approach under contention.
AlgorithmsAreCool commentedon Sep 3, 2024
Also, there is a slight behavioral change.
I doubt anyone will notice this at all and since this is a mutithreaded scenario, there would be no way to reliably reproduce the previous behavior anyway.
tarekgh commentedon Sep 3, 2024
Previously we had a problem too as it can report the event twice to the same listener. We should be ok/better with the change here.
AlgorithmsAreCool commentedon Sep 3, 2024
I can make this change, it is fairly simple. I'll retest after removing the dependency
AlgorithmsAreCool commentedon Sep 3, 2024
Btw, this change would affect my team also from a perf perspective. Given the date, is this looking like a .NET 9 fix or a .NET 10 fix?
tarekgh commentedon Sep 3, 2024
We are targeting .NET 10 here. We are trying to avoid any risk for current release.
AlgorithmsAreCool commentedon Sep 4, 2024
Updated figures after the following changes
AlgorithmsAreCool commentedon Sep 4, 2024
Opened a draft PR with the changes