perf(registry): skip the shared-label branch for metrics that have none - #804
perf(registry): skip the shared-label branch for metrics that have none#804milcho0604 wants to merge 2 commits into
Conversation
57b3051 to
623a74f
Compare
|
Rerunning benchmarks because of the preponderance of inconclusive tests. (I implemented that feature with great enthusiasm and I confess I'm a little irritated by how often it fires, and a little scared about how many times I or coworkers have used ratty data to justify making a change that did not in fact make the code 5% faster, just harder to maintain) |
|
Looks like around 4% average, which is better than anyone has done in a while, if less than I had hoped for. However, this is one of those categories where an optimization also makes the code easier to read. And I will always take those even if the speed is nothing to write home about. You combine three or four of those together and you get some more interesting numbers. They're easy to defend because they improve code quality at the same time. Unfortunately I don't have time to review this right this second. But if the timing works out I might consider this for v0.16, because then we can include a modest improvement to metrics() in the release notes. |
|
@milcho0604 If you're looking for other things to do, I believe there are a couple places in the code where we do |
|
Short answer: at benchmark-like cardinality it is worth about ±2–3% and the sign depends on the Node major; at very high cardinality the render call itself gets 7–20% faster on 24 and 26 while 22 is inconsistent-to-slightly-slower under default heap settings. Since it helps two supported majors and mildly hurts the third, I have not opened a PR. ScopeOnly one site qualifies: The rest are not candidates. NumbersReal path only, one build per process, order randomised per pair. "4 of 6 pairs" means the change was the faster side in 4 of 6 independent paired runs; positive percentages mean faster.
Two things to read carefully there. The percentages are for The Node 22 partOn 22 the change coincided with more GC — Running 22 with I also tried Correctness and compatibilityOutput is byte-identical across the shapes for the same registry. Neither callback uses the index or source-array arguments ( Availability is not an issue: the One methodology note, since it changed my answer: isolated micro-benchmarks of the pattern pointed the other way entirely, and only the real path reversed it. I would trust only the real-path numbers here. What I am not claimingNot that Happy to send the one-liner if you want it for its own sake, but on this evidence I would not present it as a performance change. |
|
Those AI generated summaries are brutal to read, yo. Node 22 exits LTS in April of 2027. People who are still using that LTS version are probably not on the upgrade treadmill. So I'm okay with making that change in a semver major release, and filing a release note that tells people this may be slightly slower on Node 22. And then they can choose how or if they wish to opt-in. |
| // We have to flatten these separately to avoid duplicate labels | ||
| // appearing between the base labels and the shared labels. | ||
| labelParts = [ | ||
| ...formatLabelsExcludingShared(labels, sharedLabels), |
There was a problem hiding this comment.
If you need a large comment, you probably need a different implentation.
This function doesn't need to exist at all. All we need is
if (sharedLabels) {
const distinctLabels = labels.filter(label => {
...
};
labelParts = [...formatLabels(distinctLabels),
...
].filter(Boolean); // TODO: should this be run on the inputs instead of the outputs?
}
There's not enough code here to support the indirection. You're better reading the funky code in the funky conditional directly.
There was a problem hiding this comment.
Done in 6d8bbf5. The helper is gone and formatLabels() is back to main's, unchanged.
There was a problem hiding this comment.
Thanks! Rule of Three felt like a good mantra here. The change I was asking for actually broke up the existing Ro3
Rendering spread the formatted labels into a second array along with the
flattened shared labels, then filtered that into a third. The filter only
ever dropped the empty string from an empty flatten, so a guard on the
push does the same work with one array.
Values that never set sharedLabels skip the flatten as well. Only
Histogram sets it, and the `{}` default made everything else allocate one
and run Object.hasOwn per label against it.
Signed-off-by: Changhyun Kim <milcho0604@gmail.com>
623a74f to
6d8bbf5
Compare
|
Fair point on the summaries. The helper is gone and I re-ran your revision first. 623a74f measures 4.9% on the registry Worth saying plainly that the gain is the filter, not the helper. Taking the helper out on its own measured 4.2%, slightly below where it started, and dropping the filter after that is worth 12 points. Your sketch kept the filter, so it was the TODO that paid, not the part I was arguing about. I did not measure hoisting I'll open the iterator change separately with the Node 22 note. |
jdmarshall
left a comment
There was a problem hiding this comment.
If you want to add the !== undefined we can still squeeze that in. The last PR for v0.16 is still awaiting review.
| describe('Registry with default labels', () => { | ||
| const Registry = require('../lib/registry'); | ||
|
|
||
| it('should not emit a label twice when a default label name is also a shared label', async () => { |
There was a problem hiding this comment.
Did this increase code coverage? I'm surprised we don't already have these tests.
There was a problem hiding this comment.
Statements and lines stay at 98.4%, branches go 96.72% to 98.48% on registry.js. The paths were already being executed, what was missing was any assertion on what they produce.
On main you can delete the dedup exclusion, or the escaping inside flattenSharedLabels(), and the suite is still 551 green. Dropping every shared label after the first is noticed by exactly one thing, an OpenMetrics snapshot in the exemplar tests.
| for (const val of metric.values || []) { | ||
| let { metricName = name, labels = {} } = val; | ||
| const { sharedLabels = {} } = val; | ||
| const { sharedLabels } = val; |
There was a problem hiding this comment.
Why not inline this into the previous line?
There was a problem hiding this comment.
Folded. It needed a bit more than moving the line, so flag it if you would rather I put it back.
With sharedLabels in that destructure prefer-const fires on it, since the other two are reassigned below and it is not. Getting a single const destructure means the reassignments become derived values, so the _total suffix is now seriesName and the defensive copy is seriesLabels. Same conditions as before on both. The 234-case differential against main is still byte identical, including absent and null labels, default label collisions and the OpenMetrics counter naming.
|
|
||
| const flattenedShared = flattenSharedLabels(sharedLabels); | ||
| const labelParts = [...formattedLabels, flattenedShared].filter(Boolean); | ||
| const labelParts = formatLabels(labels, sharedLabels); |
There was a problem hiding this comment.
wait, why is this back?
There was a problem hiding this comment.
That line deserved commentary and I skipped it, sorry. It happened in two steps and I only explained the second one.
I did implement the caller side filter, two ways. The entries shape and the shipped shape ran head to head in one process against trunk, and the literal object-copy translation ran separately under the same protocol.
The filter itself is a real win. Kept as a filter on the entries array, with formatLabels() staying one-argument, it beats main by 10% on all 42 metrics() cases. Translating your sketch literally, copying the non-shared labels into a fresh object, measured about 10% slower than main, and it drops a label named __proto__ when a custom collector supplies one, because plain assignment on {} hits the prototype setter. So I threw that shape out. Head to head the entries version trails the shipped one on all 42 cases, median about 4%, the shallowest 1% and the deepest 8%, control centered on zero within about 3%. The difference is one extra filtered array per shared-label series, and histogram series are 93% of what the benchmark renders.
That's the whole reason the second argument came back, it won the measurement. If the one-argument API matters more than the 4%, the entries version is already written and passes the suite. Your call.
jdmarshall
left a comment
There was a problem hiding this comment.
Looking for author commentary about how we ended up back at 2 argument formatLabels
prefer-const wants the whole destructure const, so the two values that were reassigned below become derived seriesName and seriesLabels. Signed-off-by: Changhyun Kim <milcho0604@gmail.com>
Towards #800. You recommended landing the
formatLabels()cleanup first, to see what it was worth on its own. I did not measure hoistingexcludeto the caller separately, so treat that part as unmeasured. What I did measure is one level out.getMetricsAsString()built the formatted labels, spread them into a second array along with the flattened shared labels, then filtered that into a third. The filter never filtered labels, becauseformatLabels()cannot return a falsy element, and the empty string from an empty flatten was the only thing it removed. A guard on the push does the same work with one array. That is where the time was.Separately,
sharedLabelswas destructured with a{}default. Only Histogram sets the field, so every series of every other metric allocated that object, ranObject.hasOwnagainst it once per label, and handedflattenSharedLabels()a key itsWeakMapwould never see again. Branching on the field being absent skips all of it. A histogram declared without label names still shares an empty object, so it keeps the slow path; making Histogram omit it is a separate change.Your sketch had
if (sharedLabels)and I went with!== undefined. A collector passingsharedLabels: nullthrows on main, and the truthy test turns that into a silent render with the shared labels missing. Happy to switch if you would rathernullrender as no shared labels, it is a behaviour change either way and I kept main's.The numbers are three reps of
npm run benchmarksper Node major on 22, 24 and 26, median per case, againsttrunkat 1908ddb. On the registrymetrics()cases this is 16%, 42 of 42 positive, +9% to +22%. Averaging the whole registry suite gives about 10%, sincegetMetricsAsJSON()is a third of those cases and cannot reach the change. Everything else in the harness sits at +0.07% median, 51 of 93 positive. Measured the same way, 623a74f is 4.9% and taking the helper out on its own is 4.2%, so removing the filter after that is the 12 points and the helper removal cost a little. faceoff's own verdicts on the run readregistry ⇒ metrics() no labels ⇒ current (1.24x faster)and similar down the list.The test is a histogram with two shared labels and a value that needs escaping. On main you can delete the dedup exclusion, or the escaping on shared label values, and all 551 tests still pass. Emitting only the first shared label is caught by one OpenMetrics snapshot in another file. The second test pins the
nullcase, which nothing covered either.I rendered 234 cases against main on both content types and compared hashes, covering 17 collector shapes including
null, primitives and an object with a shadowed prototype. Identical byte for byte, down to which error is thrown and from where. Bypassing the deduplication moves the digest, so the comparison is not asleep.