Skip to content

DRIVERS-3620 Reduce OpenTelemetry tracing overhead, benchmark driver configurations - #3108

Draft
comandeo-mongo wants to merge 25 commits into
mongodb:masterfrom
comandeo-mongo:DRIVERS-3620
Draft

comandeo-mongo wants to merge 25 commits into
mongodb:masterfrom
comandeo-mongo:DRIVERS-3620

Conversation

@comandeo-mongo

@comandeo-mongo comandeo-mongo commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

DRIVERS-3620

OpenTelemetry tracing previously cost up to 26% of throughput on small-document tasks, mostly in work performed before the sampling decision. This PR reduces that overhead and adds configuration benchmarks to catch regressions.

Evergreen results from 2026-10-01 (10 repetitions, medians; throughput loss and added CPU/GC time and allocations vs off):

Task Configuration Throughput loss CPU target Added CPU µs/op CPU overhead Added GC µs/op Added objects/op Throughput range (MB/s)
Find one by ID api-only 5.35% ≤5%: pass +10.81 +4.80% -0.30 +10 5.25–5.774
Find one by ID sdk-never 7.90% ≤10%: pass +20.61 +9.16% +0.50 +19 5.073–5.788
Find one by ID sdk-parent-1pct 5.97% ≤10%: pass +19.36 +8.60% +1.00 +21 5.166–5.699
Find one by ID sdk-always 7.66% ≤15%: pass +26.90 +11.95% +1.10 +27 4.942–5.425
Small doc insertOne api-only 2.71% ≤5%: pass +7.30 +3.65% +0.20 +10 1.007–1.072
Small doc insertOne sdk-never 3.34% ≤10%: pass +13.43 +6.70% -0.70 +19 0.9486–1.06
Small doc insertOne sdk-parent-1pct 8.06% ≤10%: fail +20.50 +10.23% -0.70 +21 0.9275–1.057
Small doc insertOne sdk-always 11.22% ≤15%: pass +29.60 +14.78% +1.90 +27 0.8902–0.9867

CPU excludes GC; throughput ranges are min–max across repetitions. The comparison reports 2 spans for every row. Seven of eight CPU targets passed; sdk-parent-1pct insertOne exceeded its 10% target by 0.23 percentage points. The benchmark command exited successfully (test_status=0).

Related spec: mongodb/specifications#1986

DriverBench only ever measured the default configuration, so no
automated benchmark executed the OpenTelemetry code path at all and a
large regression in it went unnoticed. Make the driver configuration an
axis of the benchmark instead of a property of the environment.

A result is now identified by the pair (task, configuration), so every
configuration becomes its own time series that can be watched for
regressions independently. Comparison runs the tasks under each
configuration and records the throughput given up relative to the
baseline as a metric of its own, rather than leaving it to be derived by
comparing two time series later: recording it directly makes it
watchable, and computing it within a run cancels host-to-host variation.

Configurations are measured one per subprocess, because OpenTelemetry
cannot be reconfigured once its SDK has been installed and the
"api-only" configuration requires that the SDK was never loaded. They
are run interleaved rather than one to completion and then the next, so
that a machine which slows down partway through a run does not put one
configuration's samples in the fast half and another's in the slow half
and report the difference as overhead.

The SDK configurations install no span processor. The cost to attribute
to the driver is that of creating and recording spans; a processor would
additionally charge the SDK's SpanData conversion, background thread and
queue to the driver's account, and add variance that hides small
driver-side changes. Sampled spans are still fully recorded without one.

Task filters are included because the full suite spends at least a
minute per micro-benchmark per configuration, which is too slow for an
optimize-and-remeasure loop. Composites are skipped when the task list
is filtered, since they average a fixed list of micro-benchmarks.
The comparison is too long to run on a laptop: it runs the tasks once per
configuration, several times over, and each micro-benchmark has a 60 second
floor. It also has to run undisturbed, since the quantity being measured is a
difference between configurations and a machine that sleeps or throttles
partway through reports that as overhead.

Runs a bounded set of micro-benchmarks rather than the whole suite so that
the task fits in a sensible wall clock: the four high-signal single- and
multi-doc tasks, which create one operation span and one command span per
operation and so show the per-span cost most clearly. Parallel tasks are
excluded as disk- and concurrency-bound, and BSON tasks are excluded by
Comparison itself since they never reach a server and create no spans. All
three of the task list, the configuration list and the repetition count are
expansions, so a patch can widen them without a config change.

The results are uploaded as a task artifact rather than sent to the
performance store. What is wanted here is a baseline to compare later runs
against by hand, not a trend series.

The task needs its own exec_timeout_secs; the global 5400 is not enough.
The command span read db.mongodb.server_connection_id from the server
description, which comes from the monitoring connection's hello, not
from the connection the command ran on. Since connection attributes are
now cached per connection, that wrong value was also frozen for the
connection's lifetime. Use the connection's own description, as command
monitoring already does.
…Tel comparison

Throughput alone could not tell tracing cost from server noise: on
small-document inserts the loss moved by tens of percent between reps.
Each iteration now also records process CPU time and allocated objects,
and single-doc tasks report both per operation. Under sdk-always, one
extra iteration counts spans per operation with a counting processor,
outside the timed runs.

The comparison takes the median of every metric across reps, records
the spread, the CPU and allocation overhead, and each configuration's
target from the OpenTelemetry spec, and can fail the run when a target
is missed.

The Evergreen task now runs only the two small-document tasks the
targets are defined on, with five shorter reps, and sends the results
to the performance store as well as uploading them.
…ix perf.send

perf.send rejected the results because args only take integers and the
configuration was passed as a string. The configuration now goes into the
test name ("Find one by ID [api-only]"); the baseline keeps the bare name,
so its series continues the one recorded before configurations existed.

The comparison ran about two hours. It is now one Evergreen task per
micro-benchmark, which run in parallel on separate hosts and still
interleave every configuration on one host, with at most 10 iterations per
run instead of 30.

Both benchmark tasks now run with RUBY_YJIT_ENABLE=1, since without YJIT
the interpreter's per-call cost makes tracing look several times more
expensive than in production. A ruby built without YJIT only warns, so the
suite fails instead of quietly measuring the interpreter.
Under YJIT the time spent in GC varied between processes by more than the
whole cost of tracing, and the first YJIT run showed sdk-parent-1pct
cheaper than sdk-never. Each iteration now also records GC time where the
runtime reports it, and the comparison reports CPU per operation both with
and without GC; the summary table shows the latter and GC separately.

Also raise the iteration cap from 10 to 20: with ten, the throughput
spread across reps was 7-10%, wider than most of what is measured.
…arison

With a fixed order, sdk-parent-1pct measured cheaper than sdk-never in
every run on both tasks, though it does strictly more work and allocates
more. Interleaving cancels drift across a run but not an effect tied to a
slot within each rep. Rotating the order by one each rep puts every
configuration in every slot exactly once when there are as many reps as
configurations, as on Evergreen.
Throughput loss moved by 2-3 points between runs and ranked sdk-never
below api-only on inserts, which the work they do rules out. Targets are
now checked against the CPU overhead excluding GC, which ranked the
configurations correctly.

Ten reps instead of five halve the run-to-run spread's influence on the
median and keep the order rotation balanced, since every configuration
still runs in every slot equally often. The task timeout goes to two
hours to fit them.
…chmarks on one host

The split tasks run on different hosts, and host speed differs by more
than the insert-vs-find overhead difference being investigated. This task
runs both micro-benchmarks in one comparison so they can be compared with
each other. It is not activated on the waterfall; request it in a patch.
The split tasks ran on different hosts, which differ in speed by more
than the overheads being compared: the insert and find results swapped
places between runs. One task, driver-bench-otel, now runs both
small-document benchmarks on one host, daily.

Review fixes in the comparison: the median across reps averages the
middle two of an even count instead of always taking the lower one, and
the rotation and environment comments match what the code does.
Production deployments of Ruby commonly run with YJIT, and the toolchain
now builds MRI 3.2+ with it. Every run script now exports
RUBY_YJIT_ENABLE=1 after choosing its ruby, but only when YJIT is
actually available: JRuby and older MRI would just print a warning. A
value set by the caller, such as RUBY_YJIT_ENABLE=0, is kept.
Reviewers of the OpenTelemetry spec asked whether the cost of each span
attribute has been measured, and whether a span could be created with no
attributes and given all of them once it is known to be recorded.

Add a harness under profile/otel_attributes that answers both. One half
measures the SDK's handling of an attribute, with the values fixed as
literals: the cost of carrying an attribute through start_span and the
cost of setting one after creation. The other half measures the driver's
own cost of producing each value, calling the tracer helpers directly with
a realistic command document and connection, caches warmed.

Every profile runs in one process with the order rotated each repetition,
the same control the DriverBench comparison uses, and the sweep runs under
a recording sampler and a dropping one because set_attribute discards on a
span that is not recording.

rake otel_attributes:span and rake otel_attributes:construction run them.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant