Skip to content

perf: reduce per-value work in unsigned int and header length decoding - #124

Merged
mcollina merged 1 commit into
masterfrom
perf/heap-micro
Oct 19, 2026
Merged

mcollina merged 1 commit into
masterfrom
perf/heap-micro

Conversation

@mcollina

Copy link
Copy Markdown
Owner

Motivation

This round was driven by @platformatic/flame's heap profiler (which also runs its CPU profiler) to look for allocation/GC benefits.

The heap profiles showed that decode is not allocation-bound: on every dataset — including the "primitive-heavy" mixed.json — native utf8Slice dominates (46–55% self), because object keys are strings and each is materialized as a JS string. Young-gen GC totals ~0.5–1.5% of runtime (all scavenges, no old-gen collections), so there is little headroom from reducing allocation.

Two concrete, safe wins did surface and are landed here:

  • decodeUnsignedInt (uint8/16/32) spun a loop calling buf.readUInt8(...) and Math.pow(256, ...) per byte. It now dispatches to the fixed-width readUInt8 / readUInt16BE / readUInt32BE, exactly mirroring decodeSigned. Results are exact (≤ 2³²), and size 8 keeps the existing path unchanged.
  • readUIntBE extracted a temporary slice for the 3-byte (ext16 length) header read. It now computes the value inline, avoiding that transient allocation.

Testing

  • All 4391 tests pass on Node 22, 24, and 26.
  • standard lint clean.
  • A/B decode (interleaved, median-of-ratios, sample-large/example4/mixed): ratios ≈ 1.016–1.033, i.e. neutral-to-modestly positive and within the noisy machine's error band.

Honest note on the rest of the round

I also tried frame object pooling in the iterative decoder (the heap profile's top application allocation site, ~43 frames/decode). It regressed decode by ~11–14%: the per-frame field-set on openArrayFrame/openMapFrame and the field-reset in freeFrame cost more than the (tiny) young-gen allocations they reclaimed. That change was reverted, not included.

The takeaway: on these string-heavy datasets msgpack5's decode is bounded by native UTF-8 string materialization and the tryDecode JS dispatch, not by allocation/GC. Closing the remaining gap to msgpackr is a native-acceleration problem rather than an allocation problem.

…header length reads

Profile (flame heap+CPU) of decode shows unsigned-int decoding runs a
Math.pow per byte, and the 3-byte header length read allocates a temporary
slice. decodeUnsignedInt now dispatches to the fixed-width readUInt8/
readUInt16BE/readUInt32BE (exact for sizes 1/2/4, matching decodeSigned),
and readUIntBE computes the 3-byte value inline instead of slicing.

Behavior-preserving; the 3-byte read produces the same value as the sliced
readUIntBE, and fixed-width readers are exact (<= 2^32). All 4391 tests pass
on Node 22/24/26.
@mcollina
mcollina merged commit 8930433 into master Oct 19, 2026
20 checks passed
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