perf(encode): consolidate the msgpack hot path#8504
Conversation
Overall package sizeSelf size: 6.07 MB Dependency sizes| name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.0.1 | 82.56 kB | 817.39 kB | | opentracing | 0.14.7 | 194.81 kB | 194.81 kB | | dc-polyfill | 0.1.11 | 25.74 kB | 25.74 kB |🤖 This report was automatically generated by heaviest-objects-in-the-universe |
|
BenchmarksBenchmark execution time: 2026-05-29 14:27:07 Comparing candidate commit 692ce3b in PR branch Found 57 performance improvements and 5 performance regressions! Performance is the same for 1435 metrics, 96 unstable metrics. scenario:encoders-0.4-20
scenario:encoders-0.4-22
scenario:encoders-0.4-24
scenario:encoders-0.4-events-legacy-20
scenario:encoders-0.4-events-legacy-22
scenario:encoders-0.4-events-legacy-24
scenario:encoders-0.4-events-native-20
scenario:encoders-0.4-events-native-22
scenario:encoders-0.4-events-native-24
scenario:encoders-0.5-20
scenario:encoders-0.5-22
scenario:encoders-0.5-24
scenario:encoders-0.5-events-legacy-20
scenario:encoders-0.5-events-legacy-22
scenario:encoders-0.5-events-legacy-24
scenario:exporting-pipeline-0.5-22
scenario:exporting-pipeline-0.5-24
scenario:log-with-debug-20
scenario:spans-finish-later-24
|
8c4e1ab to
524e45a
Compare
524e45a to
0355b5e
Compare
0355b5e to
d382d2a
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0064e4dda5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
0064e4d to
5b7f4bd
Compare
5b7f4bd to
7050b68
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5b7f4bddcd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Four small wins on the encode and debug-log paths: 1. `MsgpackEncoder#encodeValue` dispatches `string` first. The string arm covers tag values and map keys; the prior order paid four `switch` comparisons before reaching it. 2. `MsgpackEncoder#encodeMap` emits keys via `encodeString` directly. `Object.keys` only yields strings, so the typeof re-dispatch is dead work. 3. `AgentEncoder.encode` hoists the `DD_TRACE_ENCODING_DEBUG` hex-dump formatter to a module-level function. The closure used to allocate per encode whenever the debug switch was on. 4. `memoizedLogDebug` switches to printf-style. The substitution only runs when the debug channel has subscribers. `log` now forwards trailing arguments to a function delegate so the formatter in (3) can take `bytes`, `start`, `end` as parameters instead of capturing them via closure.
The tracer dispatched every byte-layout write through three layers: `subclass._encodeX(bytes, value)` → the base wrapper → the `MsgpackEncoder` instance. The wrappers existed so msgpack could be swapped at runtime, which never happened and was never planned. `MsgpackChunk` now owns the byte-layout primitives directly. Call sites encode straight into the chunk; the eight base-class wrappers and the `MsgpackEncoder` class go away. `DataStreamsWriter`, the one external caller of the deleted `msgpack.encode(payload)` method, imports the free `encode` function that exposes the same recursive dispatcher.
Initial capacity drops from 2 MiB to 1 MiB. Each `AgentEncoder` holds two `MsgpackChunk` instances and the datastreams writer adds one more, so the previous default cost every tracer-loaded process a fixed 4 - 6 MiB regardless of payload size. A representative HTTP trace serializes well under 100 KiB, but keeping a megabyte of headroom avoids the first resize on bursts of larger spans (long URLs, JSON event payloads) so the typical request never copies through an intermediate buffer. `reserve` doubles the capacity on overflow instead of rounding up to the next `minSize` multiple. Worst-case burst growth from 1 MiB to the 8 MiB soft flush limit takes the same number of resizes as the old 2 MiB-aligned walk but reaches the larger sizes earlier, cutting the time spent copying through intermediate buffers. `reset` is the new flush signal: it zeros the cursor and counts consecutive flushes whose peak length stayed under a quarter of the current buffer. After 32 such quiet flushes the chunk halves toward the `minSize` floor. One above-threshold peak resets the streak, so long-lived encoders give memory back during idle periods but hold on to the warmed buffer through sustained traffic.
Every encoded span emits a `[KEY_ERROR][writeIntOrFloat(span.error)]`
pair, and `span.error` is `0` or `1` on the overwhelming majority of
spans — only a handful of plugins use any other numeric value, and the
tracer never stores anything else there. The hot loop therefore paid
two `MsgpackChunk.reserve` calls, two memory writes, and one trip
through `writeIntOrFloat`'s magnitude-detection branches for what is
effectively a constant on every span.
Two precomputed `[KEY_ERROR, fixint]` constants (7 bytes each) collapse
the common case to a single `bytes.set`. The else branch keeps the
existing variable-value path so the rare non-{0,1} values still emit
the shortest valid encoding.
`_encodeString` and `#encodeMetaEntries` looked up every value in `_stringMap` before emitting it, including the multi-KiB strings the tracer produces from stringified `span_events`, stack traces, full SQL statements, and large query bodies. Those values are essentially unique per span on real traffic, so the cache hit rate is near zero and the lookup cost is paid for nothing. The `encoders-0.4-events-legacy` sirun scenario picked the regression up on a realistic trace fixture. Values longer than `STRING_CACHE_BYPASS_LIMIT` (1 KiB) now write directly through `MsgpackChunk.write`. The threshold sits well above the routine span-tag distribution (URLs, methods, IPs, resource names all comfortably below 256 bytes) and well below the size where the per-call hash dominates the per-call write. Short, repeating tag values still hit the cache and pay nothing. Bench (`benchmark/sirun/encoding/index.js` adapted: 30-span trace, 5 000 iterations, 7 trials drop best+worst; Node 24.15.0): * `encoder=0.4 events=legacy` master 324 ms -> patched 341 ms (was 346 ms before this commit, -1.5 %) * `encoder=0.4 events=none` master 50 ms -> patched 47 ms (-7.6 %) * `encoder=0.5 events=none` master 30 ms -> patched 29 ms (-5.5 %) `encoder=0.5 events=legacy` still regresses because the 0.5 wire ships strings as indices and cannot bypass the lookup. Wire output is unchanged: `bytes.write` emits the same fixstr / str32 bytes that `_cacheString` would have stored, just without the cache side effect.
The flush-on-soft-limit test was reaching into `encoder._estimatedSize` to push the encoder over the threshold. The reach-in only existed because the 8 MiB soft limit was a module-local constant, so the test had no other way to trigger the branch without actually encoding a multi-megabyte payload. Add a `softLimit` constructor parameter to `AgentlessJSONEncoder` — mirroring the same hook on the v0.4/v0.5 `AgentEncoder` — and rewrite the test to construct a tiny-limit encoder. The reach-in goes away, the soft-limit branch is now reachable from public API for any caller that wants to bound payload size for tests or for memory-constrained environments, and production behavior is unchanged when the parameter is omitted.
…ion keys Almost every real span carries `error: 0` / `error: 1` and a nanosecond `start` ≥ 2³². When both hold the encoder can fuse the error key+fixint, the `start` key + 0xCF type byte + 8-byte timestamp, and the `duration` key into the same `bytes.reserve` that already covers the map header, optional `type`, the three IDs, and name/resource/service. The new path collapses up to four extra reserves per span (one each for the error key+value, the start key, the start value's u64 dispatch, and the duration key) into the existing one. Spans that don't fit the assumption (synthetic test data with small `start`, rare non-binary error flags) drop back to the per-field emits so each integer still picks the shortest msgpack encoding — keeping the existing wire-format guarantees for those inputs. `KEY_START_PREFIX` is the precomputed `[KEY_START, 0xCF]` fusion. The 8-byte timestamp lands via two `Buffer.writeUInt32BE` calls; the hi/lo split matches what `writeLong` would have done.
The numeric branch of the meta-map writer used to pay two `reserve` calls per entry: one for the key prefix, one inside `writeIntOrFloat` for the value byte. The metrics map is mostly small positive integers (`_sampling_priority_v1`, `_dd.measured`, attribute counts, ms timings that fit in 0..127), so every one of those entries hits the fixint fast path inside `writeIntOrFloat` anyway — but only after paying a second reserve and a three-level branch chain. Speculate the fixint case at the call site: reserve `keyEntryLen + 1` up front, copy the key, and write the value byte directly. When the speculation misses (large counts, floats, signed numbers), rewind the speculative byte and fall back to the full writer so the wire still picks the shortest valid encoding.
Both formatters re-stringify `span_events` from scratch on every encode call even when the underlying event array survives across encode cycles (retries, re-emission paths). The events=legacy hot path is currently bound by raw memory bandwidth from those re-stringifications, not the cache lookups. Leave the WeakMap memoization out of this branch — it interacts with the formatter / span lifecycle in ways that warrant their own change. Drop a pointer at the two call sites so the next reader knows where the remaining headroom lives. No production behavior change.
Co-authored-by: Ruben Bridgewater <[email protected]>
`writeFixMap` has no caller -- `_encodeMap` and the v0.5 encoder go through `writeMapPrefix` (map32) regardless of size. Same shape for `writeFixArray`'s `= 0` default; its only caller passes the length explicitly.
Pin the patch lines codecov flagged as uncovered: 1. `MsgpackChunk.writeNull` / `writeBoolean` / `writeBin` (bin32 path for byteLength >= 65 536) / `writeSigned` (int8 the encoder never reaches via `writeIntOrFloat`) / `writeNumber` NaN coercion. The spec exercises the rest of the public `writeX` surface in the same pass so the boundaries between fixint / uint8 / uint16 / uint32 / uint64 and the signed counterparts are pinned. 2. `msgpack/encode` dispatcher arms for `null`, `boolean`, `symbol`, the `default` arm (function / undefined), and `array.length >= 16` (array32 prefix). 3. `AgentEncoder._encode`'s non-fuseTail fallback for `error === 1` and unusual error flags -- the synthetic-input path the comment on the fused-tail block calls out.
Every v0.5 span used to pay seven separate `bytes.reserve` calls for
its fixed-size prefix: one for the `0x9C` fixarray marker, three for
the service / name / resource indices, and three for the trace / span
/ parent uint64 ids. Pre-resolve the three string indices, then write
the whole 43-byte head block (1 + 5 × 3 + 9 × 3) under one reserve.
The v0.5 wire is positional — no map keys — so the fuse is simpler
than the v0.4 equivalent: no key bytes to concatenate, and the four
fixed-shape elements (marker + three indices + three ids) tile the
block exactly. The remaining per-span fields (`start`, `duration`,
`error`, `meta`, `metrics`, `type`) keep their per-call writes because
their byte sizes depend on the value.
While here, refactor `_cacheString` so it returns the resolved index
instead of just having the side effect. The head fuse uses
`stringMap[value] ?? this._cacheString(value)` to resolve indices
without re-fetching, and `_encodeString` collapses onto the same
pattern. `_reset()` (inherited from 0.4) calls `_cacheString('')` for
the seeding side effect and ignores the return value — no behavior
change there.
Two new private helpers (`#writeIndexAt`, `#writeIdAt`) keep the
fused block readable; both are inlinable single-shot writes the
encoder code calls directly into the pre-reserved buffer.
Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials drop best+worst, Node
24.15.0; same fixture on both checkouts, only the encoder source
differs):
* `encoder=0.5 events=none` master 98 ms -> patched 77 ms (-21 %)
* `encoder=0.5 events=legacy` master 395 ms -> patched 366 ms ( -7 %)
* `encoder=0.4 *` unchanged within noise (the v0.4 hot path is not
touched).
The v0.5 `_encodeMap` override used to pay two `bytes.reserve` calls per numeric entry — one for the key (5-byte uint32 index) and one inside `writeIntOrFloat` for the value byte. The metrics map is mostly small positive integers (`_sampling_priority_v1`, `_dd.measured`, attribute counts), so every one of those entries hits the fixint fast path inside `writeIntOrFloat` anyway, but only after paying that second reserve and a three-level branch chain. Speculate the fixint case at the call site: reserve `5 + 1` up front, write the key index, then write the value byte directly. Speculation misses rewind the speculative byte and fall back to the full encoder so the wire still picks the shortest valid encoding. Mirrors the same move the v0.4 `#encodeMetaEntries` made for the meta hot path. String entries collapse onto the same shape: both halves are uint32 indices on the v0.5 wire (5 bytes each), so the key and value emit under a single 10-byte reserve. Non-string / non-number entries skip early — same filter the previous loop applied implicitly. Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request trace, 5 000 iterations per trial, 7 trials drop best+worst, Node 24.15.0): * `encoder=0.5 events=none` master 100 ms -> patched 76 ms (-24 %) * `encoder=0.5 events=legacy` master 397 ms -> patched 364 ms ( -8 %) Wire output is unchanged.
`Buffer.allocUnsafe(size)` returns a pooled slice whose `byteOffset` is non-zero when `size <= Buffer.poolSize / 2`. The previous `new DataView(this.buffer.buffer)` and `new Uint8Array(this.buffer.buffer, sourceStart, ...)` shapes addressed the shared 8 KiB slab from offset 0 instead of the chunk's own window, so `writeFloat`, `writeBigInt`, and `MsgpackChunk.copy` either wrote into a sibling consumer's bytes or returned a stale slice. The 2048-byte prefix chunk is the first caller small enough to be pool-allocated; the default 1 MiB chunk always lands at `byteOffset === 0`, so the bug stayed latent on master.
2f63db1 to
80e8f34
Compare
| let newSize = this.buffer.length | ||
| // `*= 2` instead of `<<= 1`: `1073741824 << 1` is negative as int32, | ||
| // and msgpack values can legitimately reach the multi-GiB range. | ||
| while (newSize < needed) newSize *= 2 |
There was a problem hiding this comment.
@rochdev just a heads up this changes it from logarithmic resizing to quadratic
* perf(encode): tighten msgpack dispatch and debug-log allocation
Four small wins on the encode and debug-log paths:
1. `MsgpackEncoder#encodeValue` dispatches `string` first. The string
arm covers tag values and map keys; the prior order paid four
`switch` comparisons before reaching it.
2. `MsgpackEncoder#encodeMap` emits keys via `encodeString` directly.
`Object.keys` only yields strings, so the typeof re-dispatch is
dead work.
3. `AgentEncoder.encode` hoists the `DD_TRACE_ENCODING_DEBUG`
hex-dump formatter to a module-level function. The closure used
to allocate per encode whenever the debug switch was on.
4. `memoizedLogDebug` switches to printf-style. The substitution only
runs when the debug channel has subscribers.
`log` now forwards trailing arguments to a function delegate so the
formatter in (3) can take `bytes`, `start`, `end` as parameters
instead of capturing them via closure.
* perf(encode): fold msgpack primitives onto MsgpackChunk
The tracer dispatched every byte-layout write through three layers:
`subclass._encodeX(bytes, value)` → the base wrapper → the
`MsgpackEncoder` instance. The wrappers existed so msgpack could be
swapped at runtime, which never happened and was never planned.
`MsgpackChunk` now owns the byte-layout primitives directly. Call
sites encode straight into the chunk; the eight base-class wrappers
and the `MsgpackEncoder` class go away. `DataStreamsWriter`, the one
external caller of the deleted `msgpack.encode(payload)` method,
imports the free `encode` function that exposes the same recursive
dispatcher.
* perf(msgpack): right-size MsgpackChunk to its actual workload
Initial capacity drops from 2 MiB to 1 MiB. Each `AgentEncoder` holds
two `MsgpackChunk` instances and the datastreams writer adds one more,
so the previous default cost every tracer-loaded process a fixed
4 - 6 MiB regardless of payload size. A representative HTTP trace
serializes well under 100 KiB, but keeping a megabyte of headroom
avoids the first resize on bursts of larger spans (long URLs, JSON
event payloads) so the typical request never copies through an
intermediate buffer.
`reserve` doubles the capacity on overflow instead of rounding up to
the next `minSize` multiple. Worst-case burst growth from 1 MiB to
the 8 MiB soft flush limit takes the same number of resizes as the
old 2 MiB-aligned walk but reaches the larger sizes earlier, cutting
the time spent copying through intermediate buffers.
`reset` is the new flush signal: it zeros the cursor and counts
consecutive flushes whose peak length stayed under a quarter of the
current buffer. After 32 such quiet flushes the chunk halves toward
the `minSize` floor. One above-threshold peak resets the streak, so
long-lived encoders give memory back during idle periods but hold
on to the warmed buffer through sustained traffic.
* perf(encode): pre-fuse the `error: 0` and `error: 1` payloads
Every encoded span emits a `[KEY_ERROR][writeIntOrFloat(span.error)]`
pair, and `span.error` is `0` or `1` on the overwhelming majority of
spans — only a handful of plugins use any other numeric value, and the
tracer never stores anything else there. The hot loop therefore paid
two `MsgpackChunk.reserve` calls, two memory writes, and one trip
through `writeIntOrFloat`'s magnitude-detection branches for what is
effectively a constant on every span.
Two precomputed `[KEY_ERROR, fixint]` constants (7 bytes each) collapse
the common case to a single `bytes.set`. The else branch keeps the
existing variable-value path so the rare non-{0,1} values still emit
the shortest valid encoding.
* perf(encode): bypass the string cache for values over 1 KiB
`_encodeString` and `#encodeMetaEntries` looked up every value in
`_stringMap` before emitting it, including the multi-KiB strings the
tracer produces from stringified `span_events`, stack traces, full SQL
statements, and large query bodies. Those values are essentially
unique per span on real traffic, so the cache hit rate is near zero
and the lookup cost is paid for nothing. The
`encoders-0.4-events-legacy` sirun scenario picked the regression up
on a realistic trace fixture.
Values longer than `STRING_CACHE_BYPASS_LIMIT` (1 KiB) now write
directly through `MsgpackChunk.write`. The threshold sits well above
the routine span-tag distribution (URLs, methods, IPs, resource names
all comfortably below 256 bytes) and well below the size where the
per-call hash dominates the per-call write. Short, repeating tag
values still hit the cache and pay nothing.
Bench (`benchmark/sirun/encoding/index.js` adapted: 30-span trace,
5 000 iterations, 7 trials drop best+worst; Node 24.15.0):
* `encoder=0.4 events=legacy` master 324 ms -> patched 341 ms
(was 346 ms before this commit, -1.5 %)
* `encoder=0.4 events=none` master 50 ms -> patched 47 ms (-7.6 %)
* `encoder=0.5 events=none` master 30 ms -> patched 29 ms (-5.5 %)
`encoder=0.5 events=legacy` still regresses because the 0.5 wire ships
strings as indices and cannot bypass the lookup.
Wire output is unchanged: `bytes.write` emits the same fixstr / str32
bytes that `_cacheString` would have stored, just without the cache
side effect.
* test(encode): exercise agentless-JSON soft-limit flush via constructor
The flush-on-soft-limit test was reaching into `encoder._estimatedSize`
to push the encoder over the threshold. The reach-in only existed
because the 8 MiB soft limit was a module-local constant, so the test
had no other way to trigger the branch without actually encoding a
multi-megabyte payload.
Add a `softLimit` constructor parameter to `AgentlessJSONEncoder` —
mirroring the same hook on the v0.4/v0.5 `AgentEncoder` — and rewrite
the test to construct a tiny-limit encoder. The reach-in goes away, the
soft-limit branch is now reachable from public API for any caller that
wants to bound payload size for tests or for memory-constrained
environments, and production behavior is unchanged when the parameter
is omitted.
* perf(encode): extend per-span block past service into the start/duration keys
Almost every real span carries `error: 0` / `error: 1` and a nanosecond
`start` ≥ 2³². When both hold the encoder can fuse the error key+fixint,
the `start` key + 0xCF type byte + 8-byte timestamp, and the `duration`
key into the same `bytes.reserve` that already covers the map header,
optional `type`, the three IDs, and name/resource/service. The new path
collapses up to four extra reserves per span (one each for the error
key+value, the start key, the start value's u64 dispatch, and the
duration key) into the existing one.
Spans that don't fit the assumption (synthetic test data with small
`start`, rare non-binary error flags) drop back to the per-field emits
so each integer still picks the shortest msgpack encoding — keeping the
existing wire-format guarantees for those inputs.
`KEY_START_PREFIX` is the precomputed `[KEY_START, 0xCF]` fusion. The
8-byte timestamp lands via two `Buffer.writeUInt32BE` calls; the hi/lo
split matches what `writeLong` would have done.
* perf(encode): inline the fixint fast path in #encodeMetaEntries
The numeric branch of the meta-map writer used to pay two `reserve`
calls per entry: one for the key prefix, one inside `writeIntOrFloat`
for the value byte. The metrics map is mostly small positive integers
(`_sampling_priority_v1`, `_dd.measured`, attribute counts, ms timings
that fit in 0..127), so every one of those entries hits the fixint
fast path inside `writeIntOrFloat` anyway — but only after paying a
second reserve and a three-level branch chain.
Speculate the fixint case at the call site: reserve `keyEntryLen + 1`
up front, copy the key, and write the value byte directly. When the
speculation misses (large counts, floats, signed numbers), rewind the
speculative byte and fall back to the full writer so the wire still
picks the shortest valid encoding.
* docs(encode): note that span_events stringification is memoizable
Both formatters re-stringify `span_events` from scratch on every encode
call even when the underlying event array survives across encode cycles
(retries, re-emission paths). The events=legacy hot path is currently
bound by raw memory bandwidth from those re-stringifications, not the
cache lookups.
Leave the WeakMap memoization out of this branch — it interacts with
the formatter / span lifecycle in ways that warrant their own change.
Drop a pointer at the two call sites so the next reader knows where the
remaining headroom lives. No production behavior change.
* Apply suggestions from code review
Co-authored-by: Ruben Bridgewater <[email protected]>
* refactor(msgpack): drop dead writeFixMap and writeFixArray default
`writeFixMap` has no caller -- `_encodeMap` and the v0.5 encoder go
through `writeMapPrefix` (map32) regardless of size. Same shape for
`writeFixArray`'s `= 0` default; its only caller passes the length
explicitly.
* test(encode): cover msgpack chunk primitives and the 0.4 fallback branch
Pin the patch lines codecov flagged as uncovered:
1. `MsgpackChunk.writeNull` / `writeBoolean` / `writeBin` (bin32 path
for byteLength >= 65 536) / `writeSigned` (int8 the encoder never
reaches via `writeIntOrFloat`) / `writeNumber` NaN coercion. The
spec exercises the rest of the public `writeX` surface in the same
pass so the boundaries between fixint / uint8 / uint16 / uint32 /
uint64 and the signed counterparts are pinned.
2. `msgpack/encode` dispatcher arms for `null`, `boolean`, `symbol`,
the `default` arm (function / undefined), and `array.length >= 16`
(array32 prefix).
3. `AgentEncoder._encode`'s non-fuseTail fallback for `error === 1`
and unusual error flags -- the synthetic-input path the comment
on the fused-tail block calls out.
* perf(encode): fuse the per-span head block on the v0.5 wire
Every v0.5 span used to pay seven separate `bytes.reserve` calls for
its fixed-size prefix: one for the `0x9C` fixarray marker, three for
the service / name / resource indices, and three for the trace / span
/ parent uint64 ids. Pre-resolve the three string indices, then write
the whole 43-byte head block (1 + 5 × 3 + 9 × 3) under one reserve.
The v0.5 wire is positional — no map keys — so the fuse is simpler
than the v0.4 equivalent: no key bytes to concatenate, and the four
fixed-shape elements (marker + three indices + three ids) tile the
block exactly. The remaining per-span fields (`start`, `duration`,
`error`, `meta`, `metrics`, `type`) keep their per-call writes because
their byte sizes depend on the value.
While here, refactor `_cacheString` so it returns the resolved index
instead of just having the side effect. The head fuse uses
`stringMap[value] ?? this._cacheString(value)` to resolve indices
without re-fetching, and `_encodeString` collapses onto the same
pattern. `_reset()` (inherited from 0.4) calls `_cacheString('')` for
the seeding side effect and ignores the return value — no behavior
change there.
Two new private helpers (`#writeIndexAt`, `#writeIdAt`) keep the
fused block readable; both are inlinable single-shot writes the
encoder code calls directly into the pre-reserved buffer.
Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials drop best+worst, Node
24.15.0; same fixture on both checkouts, only the encoder source
differs):
* `encoder=0.5 events=none` master 98 ms -> patched 77 ms (-21 %)
* `encoder=0.5 events=legacy` master 395 ms -> patched 366 ms ( -7 %)
* `encoder=0.4 *` unchanged within noise (the v0.4 hot path is not
touched).
* perf(encode): speculate the fixint fast path in v0.5 _encodeMap
The v0.5 `_encodeMap` override used to pay two `bytes.reserve` calls
per numeric entry — one for the key (5-byte uint32 index) and one
inside `writeIntOrFloat` for the value byte. The metrics map is mostly
small positive integers (`_sampling_priority_v1`, `_dd.measured`,
attribute counts), so every one of those entries hits the fixint fast
path inside `writeIntOrFloat` anyway, but only after paying that
second reserve and a three-level branch chain.
Speculate the fixint case at the call site: reserve `5 + 1` up front,
write the key index, then write the value byte directly. Speculation
misses rewind the speculative byte and fall back to the full encoder
so the wire still picks the shortest valid encoding. Mirrors the same
move the v0.4 `#encodeMetaEntries` made for the meta hot path.
String entries collapse onto the same shape: both halves are uint32
indices on the v0.5 wire (5 bytes each), so the key and value emit
under a single 10-byte reserve. Non-string / non-number entries skip
early — same filter the previous loop applied implicitly.
Bench (`benchmark/sirun/encoding/index.js`, 30-span Express-request
trace, 5 000 iterations per trial, 7 trials drop best+worst, Node
24.15.0):
* `encoder=0.5 events=none` master 100 ms -> patched 76 ms (-24 %)
* `encoder=0.5 events=legacy` master 397 ms -> patched 364 ms ( -8 %)
Wire output is unchanged.
* fix(msgpack): honour Buffer byteOffset in chunk views
`Buffer.allocUnsafe(size)` returns a pooled slice whose `byteOffset` is
non-zero when `size <= Buffer.poolSize / 2`. The previous
`new DataView(this.buffer.buffer)` and `new Uint8Array(this.buffer.buffer,
sourceStart, ...)` shapes addressed the shared 8 KiB slab from offset 0
instead of the chunk's own window, so `writeFloat`, `writeBigInt`, and
`MsgpackChunk.copy` either wrote into a sibling consumer's bytes or
returned a stale slice.
The 2048-byte prefix chunk is the first caller small enough to be
pool-allocated; the default 1 MiB chunk always lands at
`byteOffset === 0`, so the bug stayed latent on master.
A series of related perf wins on the trace encoder's msgpack path.
Independent commits, but each one builds on the last, so reviewing
them in order is the easy way in.
The realistic-trace bench fixture this PR is benched against lives
in #8668. While that's open, the sirun bench-comparison comment on
this PR compares the new fixture (on this branch) against the old
30-clones-of-one-span fixture (on master), which inflates every
delta on the encoder hot path because the new fixture does roughly
5x the encode work per iteration. The local apples-to-apples
numbers below use the new fixture on both checkouts.
tighten msgpack dispatch and debug-log allocation: reorders thetype switch so the
stringarm matches the typical tag value first,emits map keys via
encodeStringdirectly (Object.keysonly yieldsstrings), and hoists the encode-debug hex-dump formatter to a
module-level function so it no longer allocates a closure per encode.
memoizedLogDebugswitches to printf-style so the value substitutiononly runs when the debug channel has subscribers.
fold msgpack primitives onto MsgpackChunk: removes the wrapperMsgpackEncoderclass. Encoder code writes straight into the chunk(
bytes.writeX) instead of routing throughthis._encodeX-> basewrapper ->
MsgpackEncoder. The freeencode(value)function movesto
../msgpacksoDataStreamsWriterkeeps the recursive dispatcherit imports.
right-size MsgpackChunk: initial buffer drops from 2 MiB to1 MiB. The previous default cost every tracer-loaded process a fixed
4 - 6 MiB regardless of payload size. Growth doubles on overflow so
worst-case bursts reach the larger sizes in fewer copies. 32
consecutive low-usage flushes halve the buffer toward
minSize, solong-lived encoders give memory back during quiet periods.
pre-fuse error: 0 / 1: precomputed[KEY_ERROR, fixint]constants emit the common error value via one
bytes.setinsteadof routing through
writeIntOrFloat's magnitude branches. The elsebranch keeps the variable-value path so rare non-binary error flags
still pick the shortest valid encoding.
bypass the string cache for values over 1 KiB: long uniquestrings (stringified
span_events, stack traces, full SQL bodies)stream through
bytes.writedirectly on the v0.4 wire. The cachehit rate stays near zero on them - they're unique per span - so
the per-emit lookup was paid for nothing. The same bypass does
not extend to v0.5 because the v0.5 wire ships strings as indices,
and a measurement showed the added per-call branch costs more on
v0.5 than the skipped map insertion saves.
exercise agentless-JSON soft-limit flush via constructor:follow-up that adds a
softLimitconstructor parameter toAgentlessJSONEncoder(mirroring the existing v0.4/v0.5 hook). Thesoft-limit branch is now reachable from public API; production
behavior is unchanged when the parameter is omitted.
extend per-span block past service into the start/duration keys:when
error: 0/1ANDstart >= 2^32(almost every real span), thev0.4 encoder fuses the error key+value, the
startkey +0xCFtypebyte + 8-byte timestamp, and the
durationkey into the samebytes.reservethat already covers the map header, optionaltype,the three IDs, and
name/resource/service. Synthetic inputs withsmall starts or unusual error flags drop back to the per-field
emits so each integer still picks the shortest msgpack encoding.
inline the fixint fast path in #encodeMetaEntries: speculatethe fixint case at the v0.4 meta-map call site - reserve
keyEntryLen + 1up front, copy the key, write the value bytedirectly. Misses rewind the speculative byte and fall back to the
full writer so the wire still picks the shortest valid encoding.
drop dead writeFixMap and writeFixArray default: ten-linecleanup of a method this branch added with no caller; the
= 0default on
writeFixArrayis dead in the same way (its onlycaller passes the length explicitly).
cover msgpack chunk primitives and the 0.4 fallback branch:pin the patch lines codecov flagged as uncovered - the
MsgpackChunkwriteX surface (bin32, int8, NaN coercion), themsgpack/encodedispatcher arms (null,boolean,symbol, thedefaultarm for function / undefined, andarray.length >= 16),and the
AgentEncoder._encodenon-fuseTail fallback forerror === 1and unusual error flags.cap MsgpackChunk growth at the agent intake limit: mastergrew the backing buffer without an upper bound (it rounded up to
multiples of
minSize); this branch's doubling inherited the sameproperty. A pathological single span (multi-MB stack trace,
unsanitized meta tag the size of a media file) used to grow the
chunk until allocation failed and took the host process with it.
reservenow refuses growth past 50 MiB - the agent's intakeceiling - with a tagged
RangeError.AgentEncoder.encodecatches it, drops the in-flight payload (rolling back partial
state would leave the string cache pointing at orphaned offsets),
and logs via
log.error. The hot path stays untouched: the capcheck only fires inside the existing overflow branch.
fuse the per-span head block on the v0.5 wire: every v0.5span used to pay seven separate reserves for its fixed-size prefix
(
0x9Cmarker, three string indices, three uint64 IDs). The v0.5wire is positional, so the fuse is structurally simpler than the
v0.4 equivalent -- no key bytes to concatenate, and the 43-byte head
block tiles exactly.
_cacheStringis refactored to return theresolved index so the head fuse and
_encodeStringcan share theresolve-or-cache shape.
speculate the fixint fast path in v0.5 _encodeMap: mirrorsthe v0.4 meta-map move on the v0.5 wire. String entries collapse
onto one 10-byte reserve (both halves are uint32 indices). Numeric
entries speculate the positive-fixint case (5 + 1 bytes) and rewind
the speculative byte on miss so the wire still picks the shortest
valid encoding.
Bench (
benchmark/sirun/encoding/index.js, 30-span Express-requesttrace, 5 000 iterations per trial, 7 trials with best+worst dropped,
Node 24.15.0; both runs use the new fixture from #8668, only the
encoder source differs):
encoder=0.4 events=noneencoder=0.5 events=noneencoder=0.4 events=legacyencoder=0.5 events=legacyThe v0.5 wire gains less than v0.4 because every string still has to
go through the indexed string table; the v0.4 cache-bypass shortcut
can't apply. The v0.5
events=legacycase is the slowest hot pathbecause the multi-KiB stringified
meta.eventsvalue lands in_stringBytesper span -- that's a future memoization target (seethe docs commit's TODO), not picked up here because the per-array
identity changes between encodes on every realistic call site.
Wire output is unchanged.
0.4.spec.js/0.5.spec.jscover thewhole hot path against master.