Repository navigation
fix: a nested vector aggregation keeps every level's grouping on instant queries and tumbling windows, like Loki - #746
Conversation
🟢 Performance A/B: faster, no regression173 shape×range — 2 🟢 faster · 171 ⚪ same · results vs Loki: 141 same, 0 new difference(s), 32 pre-existing
¹ re-measured with 7 runs, on windows the first pass did not use, after it moved beyond noise. 171 unchanged shape×range (within noise)
Cold (first-run) timings — what a user opening a panel sees
† Loki's own counters say its results cache answered this cold run: not a computed time. ↺ Loki cold taken from the first set that ran the same query; Loki's cache answered this one. Why these shapes
base |
PR Quality ReportCompared against base branch Coverage and tests
Compatibility
Performance smokeLower CPU cost (
State
|
✅ Visual smoke: passed with warningsBase Fix proofCaptures of the exact cases this pull request adds or fixes (registry case, base | PR | Loki). The verdict compares the PR with Loki where the base did not match. The gate fails a case unless one capture is fixed or improved, or, beyond the Loki data window, the base failed where Loki and the PR both answered with data (the label does not excuse it; only
Regression check
Gate: a base-vs-PR data difference passes when the PR is closer to Loki, and fails when the PR diverges from Loki or Loki cannot decide it (at a range Loki does not hold, a difference passes when the same page proved it closer to Loki at a shorter range; otherwise label the pull request explore-nested-aggregation-outer-by-1h: pixel diff 3.1%
explore-nested-aggregation-outer-by-6h: no Loki data at this range; the same differences match Loki at a shorter range (1): query ('min by (service_name) (avg b
explore-without-nested-under-aggregation-6h: no Loki data at this range; the same differences match Loki at a shorter range (1): query ('max(sum without (pod) (count
explore-without-over-inner-aggregation-6h: no Loki data at this range; the same differences match Loki at a shorter range (1): query ('sum without (service_name) (
Core set montages (5): base | PR | Lokidd-fields-1h dd-landing-1h dd-service-logs-1h explore-logs-plain-1h explore-metric-plain-1h All montages of this run: |
Loki folds the innermost level first and each level keeps exactly its own grouping labels (pkg/logql/evaluator.go VectorAggEvaluator.Next). The native routes (an instant query, a tumbling window) dropped the outer by of a nested aggregation and answered one series without labels. Each level is now a stats pipe with its own by; a level above the first keeps only labels the previous one kept, and an innermost without is folded over the series rows level by level in the proxy, under the without marker main's fold uses (the outermost level's operator, series rows, the limit on the folded answer). On a sliding window the proxy evaluates the first stats pipe, the series of the innermost level, and folds every level over them: the fold of the vector aggregations also takes a nest of sums and a static without level, the evaluator names the series by the innermost level's labels (they took the outermost level's by position), and a nest spelled sum(...) by (l) reads its range function from the text as written. The shapes the open without rows pinned (max(sum without ...), sum by (ns) (sum without ...), sum without (sum by ...)) now answer Loki's series and values; their pinned answers are updated, and the two rows are fixed.
… a tumbling window
…n chain was checked on
…r-by-native-routes
…r-by-native-routes
Adds the nested-aggregation set to bench/ab/shapes.json: 15 shapes of a vector aggregation over another one, on the generator's data. N1-N8 are range queries over tumbling windows (range == step via $__auto): two-level nests of sum, max, min, avg and count, a three-level chain, a `| json` inner level with an explicit extraction, a `| logfmt` inner level, and a `without` over a level that names its labels. N9-N13 are the same forms as instant queries, N13 with an innermost `without`. N14-N15 are sliding windows (range 5m over the step), one of them a nest of sums. An innermost `without` over a tumbling window has no range shape: its series rows are folded in the proxy exactly as the single-level `avg without (pod)` already is on main (about 1 s at 6h and a 10 s step on both builds), so a shape there would only repeat that cost against a base that answered the wrong thing. The selector leaves out the OTel-instrumented streams and the otel-collector: Loki carries their structured metadata (trace_id, span_id) in the series, so an inner level grouped by pod counts per line there, a series identity difference that predates the nest fix. Checked on a 90 minute seed against Loki first (all 200, non-empty): origin/main differs from Loki on N1-N13 and N15 (one series, or the wrong series count); this branch is the same as Loki on all 15. The registry item's `area` now names the source files with their paths (internal/translator/nested_vector_chain.go, internal/proxy/nested_vector_fold.go, internal/proxy/vector_agg_fold.go), so selection.py ties a change to them to the set; generated performance evidence regenerated.
…'s fold decodes once
A nest of vector aggregations with an innermost `without` kept a row per stream
and bucket and folded every level in the proxy: `avg without (pod)
(count_over_time({env="production"}[10s]))` over 6h at a 10 s step took 0.8 to
1.2 s on main, and the same `without` under `min by (app)` 1.44 s on this branch.
- A `<op> without (...)` over a tumbling window (range equal to the step), alone
or as the innermost level of a nest, is cut in VictoriaLogs when the excluded
labels are plain names (letters and digits; not level, detected_level,
service_name or a label the service name is derived from; no custom field
mapping renames them): the labels of a series are the pairs of its `_stream`
text, so the pairs are cut out of it between the stats pipe that counts a
series and the one that combines the series now sharing a stream, and the
levels above read their labels as columns of the pipes
(translator.PushDownWithout). A parser, a sliding window, an instant query and
every other name keep the proxy's fold. Measured on a seeded stack, 6h, 10 s
step: the single-level form 0.8 to 1.2 s on main, 25 to 70 ms warm and 175 ms
cold here; the nest 14 ms against Loki's 45 ms (178 ms cold, Loki 1.26 s).
- The proxy's fold of series rows (a sliding window, an instant query, a parser,
the names above) decodes the answer once, folds every level over the series
and encodes it once (typed_fold.go), where it parsed and wrote the JSON again
at every level: 47 ms and 89k allocations against 164 ms and 3.1M on 200
series of 2160 points. The sliding `sum by (app) (max by (app, pod)
(count_over_time(...[5m])))` is 28% (6h) and 66% (1h) faster.
- A query with one aggregation no longer parses for a nest: the benchmarks of
the control queries show the allocations of main (translate: 38 -> 43 per
query before, 38 now; handler: +17 to +34 before, identical now).
Differential: TestWithoutPushdownMatchesFoldAndLoki (320 random label sets, a
label on some streams only, excluded names the cut takes and does not take,
values with quotes, commas, backslashes and pair-like text) holds both routes to a
reference of Loki's evaluator and to each other; TestWithoutPushdownDirectedStreams
covers streams that differ only by the excluded label, first, in the middle and
last of the pairs, for every operator and both functions;
TestTypedFoldMatchesGenericFold holds the typed fold to the generic one.
TestCompat_NestedVectorAggregation gains single-level `without` forms; it and the
controls pass on VictoriaLogs v1.49.0, v1.50.0, v1.51.1, v1.52.0 and v1.53.0
(979 subtests on the floor and the newest, 391 on the others).
TestWithoutFoldRowBudgetIsNotASeriesLimit no longer expects the row-limit error
for a tumbling `sum without` of count_over_time: VictoriaLogs folds it and the
two series it answers fit, as Loki answers it.
3132c63 to
2dd7a48
Compare
…tree Under 32 concurrent clients the proxy's peak RSS with the typed fold was 3.53, 3.78, 3.93 and 3.73 GB in four runs against 3.17, 2.88, 3.16 and 3.20 GB for main (same stack, same queries, ABBA). The heap profile taken at each of those peaks held 1.5 GB in fastjson.(*cache).getValue under decodeTypedBody: a document tree of a sliding nest's answer (millions of samples, one allocated value per number, string and array), alive while the fold ran, on top of the evaluator's own matrices that main also holds. decodeTypedBody now scans the bytes into the series directly (16 bytes a sample) and falls back to the generic fold for anything it does not read. On 200 series of 2160 points: 36 ms, 28 MB and 7.3k allocations against 47 ms, 67 MB and 89k for the tree and 143 ms, 99 MB and 3.1M for the generic fold. Re-run, same method, eight slots ABBA: peak RSS 2.87, 3.06, 2.93 and 2.81 GB for the branch against 2.93, 2.88, 3.32 and 2.91 GB for main, and 2672 requests against 2501 in 150 s per slot. TestTypedScannerReadsWhatTheDocumentTreeReads holds the scanner to encoding/json's tree over 800 generated bodies (indented or compact, extra keys and nested stats, escapes in labels, string and number values, null and short samples, result before resultType).
gosec v2.29.0 (Security / static) flagged G103 at typed_fold.go:220, where the scanner handed strconv.ParseFloat an unsafe.String over the body bytes to avoid a string conversion per number. main has no use of unsafe and gosec is clean on it. The scanner now reads a whole-second timestamp (up to 15 digits, which a float64 holds exactly) straight from the bytes, and passes anything else to strconv.ParseFloat through a plain string conversion that does not escape. BenchmarkFoldWithoutMatrix/typed, GOMAXPROCS=1, 20 iterations x3: 35.4-36.9 ms with unsafe.String, 42.6-43.6 ms with only the string conversion, 29.6-33.5 ms with the digit fast path; 28.0 MB and 7310 allocations in all three. TestScannerNumberMatchesParseFloat holds the scanner to strconv.ParseFloat, bit for bit, on integers at and past the 15-digit edge, fractions, signs, exponents and malformed numbers.
…ies identity explore-nested-aggregation-outer-by. On visual-smoke for 0f0ece6 the PR answered the 15 service_name series Loki answers (1365 and 5415 non-zero points on both at 15m and 1h; main answered one series {}), and the first differing series was checkout-svc: 2 against Loki's 1 at 15m, 5 against 1 at 1h. checkout-svc is one of the generator's OTel-instrumented services, whose lines carry trace_id and span_id as structured metadata. Loki puts structured metadata into a sample's labels (pkg/logql/log/metrics_extraction.go:104,109), so count_over_time there is one series per line, each counting 1, and the avg by (service_name, level) over them is 1; VictoriaLogs counts the stream. That is a series identity difference the single-level query has on main too, and the bench/ab nested-aggregation set leaves those streams out for the same reason. The page now uses the set's selector: {env="production", instrumentation!="otel", app!="otel-collector"}. main still collapses the nest on it (its translation ends in an ungrouped `stats min(...)` with either selector) and the PR keeps `stats by (service_name)`. explore-without-nested-under-aggregation. `sum without (service_name)` merges no series on the generator's data (every stream's app equals its service_name), so main's answer, the max over the unmerged streams, was already Loki's: base and PR were identical. The page now asks max(sum without (pod) (count_over_time({app="payment-service"}[5m]))): pods change every generator batch (10 s apart), so a 5m window sums several pods per level, and main reads the max over single pods. Through the handlers on emulated VictoriaLogs, main answers 5 where Loki's evaluation of the same rows is 20 (instant) and the PR 20; on query_range main answers two series {ns=x},{ns=y} where Loki answers one {} = 8,16,20. A tumbling $__auto window would not show it at 15m and 1h: their steps hold one batch each. The payment-service streams carry no structured metadata, which also leaves out the per-line OTel series that kept Loki from settling the old query at 1h.
… the timeout perf-smoke on #746 was cancelled at its 30-minute limit. Of the 1658 s the shapes ran, W8 (sum without (pod) (count_over_time({env="production"}[1m]))) took 1019 s: Loki timed out at 90 s on all 4 runs of the first pass and on all 7 runs of the confirm pass the PR's speedup on W8 (0.58 s against 0.80 s) started. Loki timed out on W8 in the perf-smoke runs of #739 and #734 as well (they had no confirm pass on W8). Under a `without`, Loki keeps the structured metadata of the OTel-instrumented streams (trace_id, span_id) in the series, one series per line; in the daily run its 1h answer filled 80.9 MB of results cache against the 41 series the proxy answers. W8 now uses the selector the nested-aggregation set uses ({env="production", instrumentation!="otel", app!="otel-collector"}), as W1-W4 moved to `| logfmt amount` for the same reason. It still measures the stream-label `sum without` fold. The other 172 selected shapes ran at about 5 s each, so the selection of 20 sets should finish in about 18 to 20 minutes with the stack and seed.
The visual fix proof page of semantics/without-nested-under-aggregation now
asks max(sum without (pod) (count_over_time({app="payment-service"}[5m]))).
TestWithoutNestedAggregationMatchesLoki held max over a without only for the
unwrap rate; it now also holds this shape, range and instant, to Loki's
evaluation of the emulator's rows (one series {} = 8,16,20; instant 20). On
origin/main the same request answers {ns=x}=2,3,5 {ns=y}=2,4,5 and, at an
instant, 5: the max over single pods.














Summary
Registry:
semantics/nested-aggregation-outer-by-native-routes(fixed). Also fixes two rows that were open on main:semantics/without-over-inner-aggregation-collapsesandsemantics/without-nested-under-aggregation.The problem. A nested vector aggregation lost the grouping of its outer levels.
min by (zone) (avg by (pod, zone) (count_over_time(...)))answered one unlabelled series on an instant query and on a tumbling window (range == step). The same happened tosum by (zone) (sum by (pod, zone) (...))and to the middle level of a three-level nest. Awithoutbelow the outermost level was also mishandled: only the first list was kept, and it was folded once with the outermost operator.Cause. The translator's recursive branch called
applyOuterAggregation, which takes no grouping argument.What Loki does. It folds innermost first, and each level keeps exactly its own labels (
pkg/logql/evaluator.go:435-630,VectorAggEvaluator.Next).Fix: VictoriaLogs first.
tryTranslateVectorChain(internal/translator/nested_vector_chain.go) handles 2 or more levels of sum/avg/min/max/count over a range aggregation.by, for examplestats by (pod,zone,_stream,level) count() as a | stats by (pod,zone) avg(a) as b | stats by (zone) min(b).bylabel that the inner level lacks is cut to the intersection (Loki ends up with the same single series). Awithoutover a named label set becomes a staticby.withoutnames the series' own labels, which VictoriaLogs cannot list. That query keeps the series rows (thewithoutmarker with the outermost level's operator, as main's fold does for a singlewithout) and the proxy folds every level innermost first (applyWithoutChain,internal/proxy/nested_vector_fold.go), reusingapplyWithoutGrouping.level,detected_level,service_nameand its source labels; no custom field mapping renames them), VictoriaLogs folds it too (PushDownWithout,internal/translator/without_pushdown.go): the labels of a series are the pairs of its_streamtext, so the pairs of the excluded labels are cut out of that text between the stats pipe that counts a series and the one that combines the series that now share a stream,stats by (_stream, level) count() as a | replace_regexp(...) at _stream | stats by (_stream, level) avg(a). The levels above read their labels as columns of the pipes. Sliding windows, instant queries, parsers and every other name keep the proxy's fold.internal/proxy/typed_fold.go), where it parsed and wrote the JSON again at every level.HasNestedVectorKeywords).withoutlevel (seriesAggregationChain,foldByGrouping), the evaluator names the series by the innermost level's labels (innermostByLabels; they took the outermost level's by position), and a nest spelledsum(sum(rate(...)) by (pod, zone)) by (zone)reads its range function from the text as written (nestedVectorRange; it was read as a count, not divided by the window).Rebase onto main
Main moved a lot (#734, #739, #860, #855, #857, #862); the branch was CONFLICTING.
What conflicted and how it was resolved:
CHANGELOG.md: main's taken, this PR's entry re-inserted once under## [Unreleased]/### Fixed; released sections are byte-identical to main (check_changelog_pr.pyok).internal/translator/translator.go:tryTranslateMetricQueryWithoutgainedfoldWithout(series rows only for the<op> withoutthe proxy folds), and thewithoutmarker gained the operator (__without__:<op>:labels). The chain is tried first intranslateLogQLFull; for an innermostwithoutit asks for series rows (hasWithoutandfoldWithouttrue) and returns the marker with the outermost level's operator, so main'swithoutFoldOptreats the request as a fold (withWithoutFold: raw-row budget, the series limit on the folded answer, parsed group labels). Staticwithoutlevels leave no marker.internal/proxy/metric_agg.go,proxy.go: the four post-processing sites callapplyWithoutChain(body, labels, query, withoutOp), which folds the chain when the query is one (also undertopk,sort,label_replace:translator.PeelWithoutWrappers) and otherwise is main'sapplyWithoutGrouping(..., withoutOp)unchanged.bench/visual/spec.json: main's taken, this PR's entry re-added.conformance/registry/generated/*,conformance/reports/*): main's taken, regenerated with the five generators.How it composes with the rest of main:
foldVectorAggregations): same call sites on the sliding path, combined as described above. Sums-only nests and staticwithoutlevels go through the fold now;withoutat the innermost level stays on the marker path.statsExtentQueryCacheablerelies on. The differential runs with the cache at its default.withoutfold, over-time unwrap): the fold scope tableTestWithoutFoldIsLimitedToTheOperatorsItProvesand the extraction-list goldens pinned the shapes of two open rows. They are updated for the shapes this PR changes (below); every other golden is main's.Goldens and pinned answers changed (only entries this PR's behaviour changes):
TestWithoutFoldIsLimitedToTheOperatorsItProves:max(sum without (pod) (...))overmax_over_timeand overrate,sum by (ns) (sum without ...)(both),topk(2, max(sum without ...))andsort(max(sum without ...)). The answers are now a series perns/ the largest per-nssum (and limit 1 answers 400 only where the folded answer has more than one series). The rate range values are rates (they were counts: 8 -> 8/300).TestExtractionListOutsideWithoutKeepsItsGrouping: the 9 goldens ofmin(sum without ...),max(sum without ...),sum by (ns) (sum without ...)(instant, tumbling, sliding). Checked against a hand-computed reference of the fixture (instant: 15, 183, {x: 525, y: 425}).TestWithoutShapesStillDifferFromLoki: the cases of the two flipped rows moved toTestWithoutNestedAggregationMatchesLoki, which asserts Loki's answer on an instant query and a range.TestCompat_UnwrapSumWithoutUnderMaxStillDiffers(pinned the proxy's old, wrong answer) is removed;TestCompat_UnwrapSumWithoutUnderAnotherAggregationnow also holdsmax,min,avgandcountoversum without (route)to Loki.Tests
TestNestedVectorAggregationEmitsOneStatsPipePerLevel: 17 emitted chains.TestNestedVectorAggregationLeavesOtherShapesAlone,TestSplitVectorLevel,TestVectorLabelSetAlgebra.TestNestedVectorAggregationDifferentialandTestNestedVectorAggregationOverSeriesWithout, against a separately written Loki-semantics reference (1223 subtests pass, 202 of them sliding). They cover: all 25 operator pairs, absent labels,by (), no grouping,withoutover abylevel, 3-level chains, innermostwithout; instant, tumbling and sliding windows; parser "" and logfmt; count_over_time and rate; VictoriaLogs ""/v1.52.0/v1.40.0 (sliding: v1.52.0). Every case asserts that no raw rows are read on the instant and tumbling routes. The sliding mode asserts count_over_time and rate; the window values of an over-time unwrap function on a sliding window are the open rowsemantics/unwrap-over-time-range-windows.TestNestedVectorRangeSpecMatchesTheSingleLevelSpec: the original range metric spec of a nest equals the single-level one field by field, over 16 pipelines (line filters, regex matchers, parsers, drop, unwrap conversions, offset).TestWithoutNestedAggregationMatchesLoki,TestWithoutOverInnerAggregationRateRangeMatchesLoki,TestNestedVectorAggregationOverSeriesWithout,TestNestedVectorAggregationUnderBinaryScalar,TestApplyWithoutChain*.Tests that build a proxy shut it down and close the cache they create.
e2e vs Loki:
TestCompat_NestedVectorAggregation, 33 forms x count_over_time/rate x tumbling, instant and two sliding shapes (range 5m / step 1m, range 1m / step 2m); thewithoutover abylevel and the innermostwithoutforms are in the list.Pushdown:
TestWithoutPushdownMatchesFoldAndLoki(320 random label sets: a label on some streams only, excluded names the cut takes and does not take, values with quotes, commas, backslashes and pair-like text, 0 to 2 levels above, both functions; each held to a reference of Loki's evaluator and to the proxy's fold),TestWithoutPushdownDirectedStreams(streams that differ only by the excluded label, first, in the middle and last of the sorted pairs, absent from some streams; every operator, both functions, alone and under one or twobylevels),TestWithoutPushdownOnlyForTumblingWindows,TestWithoutPushdownLeavesMappedLabelsToTheProxyFold,TestPushDownWithoutEmitsTheFoldAsStatsPipes,TestPushDownWithoutLeavesTheFoldToTheProxyWhenNotExact. Removing the first-pair cut or its comma makes the directed test fail.Typed fold:
TestTypedFoldMatchesGenericFold(600 random matrices and vectors, 1 to 3 levels ofbyandwithout, held to the generic fold),TestTypedScannerReadsWhatTheDocumentTreeReads(800 generated bodies in other layouts, with extra keys, escapes, null and short samples, held toencoding/json's tree),TestJSONNumberAndTextMatchEncodingJSON,BenchmarkFoldWithoutMatrix.TestHasNestedVectorKeywords: every spelling of a nest is recognised, andtryTranslateVectorChainallocates nothing for a query that is not one.e2e:
TestCompat_NestedVectorAggregationgains seven single-level andwithout (baz)forms.TestWithoutFoldRowBudgetIsNotASeriesLimit: a tumblingsum withoutofcount_over_timeis folded by VictoriaLogs and its two series fit, so it answers 200 as Loki does (it expected the row-limit error of the proxy's fold).Proofs on this branch (rebased)
go build ./...,go vet ./...,go vet -tags e2e ./test/e2e-compat/,gofmt -l: clean.GOMEMLIMIT=3GiB GOMAXPROCS=4 -short -p 1 ./cmd/... ./internal/...): all packages ok, peak RSS of the run 2.46 GB.internal/proxytest binary, peak RSS (-test.short,GOMEMLIMIT=3GiB GOMAXPROCS=4): origin/main 2.07 and 2.08 GB (two runs); this branch 2.20 and 2.01 GB. AtGOMEMLIMIT=1GiB: main 1.33 GB, branch 1.39 GB. (An earlier iteration with the sliding mode on three VictoriaLogs versions measured 2.3-2.75 GB: the extra proxies of the matrix; the sliding mode now runs on one version.)-raceonTestWithoutPushdown*,TestTypedFold*,TestNestedVector*,TestApplyWithoutChain*,TestWithoutNested*,TestWithoutFold*and the translator nested and pushdown tests: pass.check_conformance.py: gate ok.check_changelog_pr.py --base origin/main --head HEAD: ok.selection.py --check, bench/ab and bench/visual tests: ok.Proofs
e2e against Loki
TestCompat_NestedVectorAggregationplus the controls (NonSumVectorAggregationRange,UnwrapSumWithoutKeepsSeries,UnwrapSumWithoutExtractionList,UnwrapSumWithoutUnderAnotherAggregation), final head: 979 of 979 subtests on VictoriaLogs v1.49.0 (the floor) and v1.53.0;TestCompat_NestedVectorAggregationalone 391 of 391 on v1.50.0, v1.51.1 and v1.52.0.Targeted A/B:
nested-aggregationset (16 shapes, final head)Base
dfa8d041against this branch against Loki, 1h and 6h, 4 runs, refresh pass on, one fresh stack: 27 shape x range, 25 within noise, 2 faster, 0 slower; results against Loki 27 same. On main 22 of the 27 differ from Loki (one series without labels, or the wrong series count); on this branch none does. The shapes are tumbling ([$__auto]), instant and sliding nests of sum, max, min, avg and count, a three-level chain,| jsonand| logfmtinner levels, awithoutover a named level (N8), an instant innermostwithout(N13) and the innermostwithouton a tumbling window (N16). The selector leaves out the OTel-instrumented streams and the otel-collector: Loki carries their structured metadata in the series, so a level grouped by pod counts per line there, a difference main already had.N16 (innermost
without, tumbling) was 1.44 s at 6h before the VictoriaLogs-side fold; it is 14 ms now (cold 178 ms; Loki 45 ms, cold 1.26 s). The single-levelavg without (pod) (count_over_time({env="production"}[10s]))at 6h and a 10 s step: main 0.8 to 1.2 s, this branch 25 to 70 ms warm and 175 ms cold. N14 (sliding) is slow on both builds, the evaluator's own cost and not this change's; the typed fold makes it 28% (6h) and 66% (1h) faster. Several tiny shapes read 1.3x to 2.5x Loki at a few tens of ms, inside the 100 ms noise floor, on a host whose load average was 8 to 22.Broad A/B
Earlier head, all 248 shape x range: 248 within noise, results against Loki 188 same, 0 new differences, 60 that main already had. Final head, in two parts on a fresh stack each (refresh pass on, 1h and 6h, 4 runs):
2dd7a487; the later commit changes only how the typed fold reads its input.W8 sum without pod, count_over_time (production)at 6h (a sliding window, so the proxy's fold, not the VictoriaLogs-side one) had read slower on the branch in an interrupted run (9.6 and 8.8 s against 5.8 s) that Loki's own timeouts and a stalling host had disturbed. In this run it is 4.63/4.43, 5.40/5.96, 6.94/9.55, 11.25/12.67 s (main/branch, the later runs slowed by the abandoned Loki queries) and 8.62 s against 8.28 s after the re-measure. On a quiet stack with Loki idle, 14 interleaved pairs on the final head: main median 6.43 s (5.31 to 7.62), branch median 5.92 s (4.97 to 7.38), the branch slower in 3 of 14 pairs. No regression.Throughput under load, and what was wrong
The three first load runs (single runs, main first) showed the branch behind main. Causes found: (1) real: the nest detection cost 5 allocations per translation and 17 to 34 per request on queries that are not nests; a keyword gate now skips it (
HasNestedVectorKeywords). Handler benchmarks of eight control queries and translations of eight,benchstat-count=10 -cpu 1, interleaved: allocs/op and B/op identical to main (before the gate +1.2% allocs). The translations of all 192 non-nested queries of every A/B set are byte-identical on main and the branch, so VictoriaLogs sees the same queries and the caches the same keys. (2) harness:$__autowas sent uninterpolated and instant shapes as 6h range queries (18 of 80 queries answered 400). (3) host: main alone ranged from 544 to 855 requests per 150 s in one stack as the load average went from 6 to 42. CPU profiles taken mid-run show the nest functions under 0.05% of CPU; mutex, block and goroutine profiles and VictoriaLogs calls per request match.Harness fixed, 32 clients, 150 s per build, final head, ABBA order, load average recorded before each run (14 to 23, other work on the host), so only the relative numbers matter:
/labelsprobeTotals: nested set excluded, main 1231 and branch 1233 requests; full set, branch 1287 and main 1250. VictoriaLogs and Loki restarts 0 in every run. One at a time on an idle stack the 80 queries take 59.6 s on main and 60.6 s on the branch.
Peak memory under load (the 4.3 GB peak)
In the first runs the branch's peak RSS (4.3 GB, against 3.7 to 3.8 GB for main) looked like variance. It was not. Method: the full set (nested shapes added), 32 clients, 150 s per slot, ABBA, RSS sampled every second,
/debug/pprof/heaptaken when RSS first crossed 3.5 GB (and again every 250 MB higher), plus mid-run and late.2dd7a4872dd7a487The ranges do not overlap: +0.64 GB on average. The heap profile at each branch peak held 1.5 GB in
fastjson.(*cache).getValue, reached fromdecodeTypedBody: the typed fold parsed the sliding nest's answer (millions of samples) into a document tree, one allocated value per number, string and array, and kept it alive while it folded. Main's profile at its peaks holds the evaluator's own matrices (buildHitsRangeMetricMatrix, 2.2 GB in one profile) and no tree. Fix (0f0ece61):decodeTypedBodyscans the bytes into the series directly, 16 bytes a sample, and falls back to the generic fold for anything it does not read. 200 series of 2160 points: 36 ms, 28 MB and 7.3k allocations, against 47 ms, 67 MB and 89k for the tree and 143 ms, 99 MB and 3.1M for the generic fold.Re-run on the fixed head, same method, load average 8 to 24 before each slot:
No branch heap crossed 3.5 GB, so no peak profile was taken; the branch is no longer above main.
/labelsprobes: 0 not 200 in all eight slots (max 0.2 to 1.7 s), VictoriaLogs and Loki restarts 0.Visual fix proof (Grafana, main and branch next to Loki)
Static window, 15m and 1h, three pages from
bench/visual/spec.json, captured on2dd7a487(stack: the compose VictoriaLogs and Loki, both proxies, Grafana):explore-nested-aggregation-outer-byservice_nameexplore-without-over-inner-aggregationlevelexplore-without-nested-under-aggregationData to data, branch against Loki:
explore-without-over-inner-aggregation4 of 4 series, every point identical (360 per series at 15m, 1440 at 1h);explore-without-nested-under-aggregation15m identical (the 1h Loki capture did not settle);explore-nested-aggregation-outer-by10 of 15 series identical point for point and the same timestamps in all 15. The other 5 arecheckout-svc,inventory-svc,notification-svc,pricing-svcandotel-collector, the OTel streams described above (the same difference exists for the single-levelcount_over_timeon main). Grafana showed no panel error or "No data" on any of the 18 loads. The pages now also run in CI through the spec; screenshots aremain.png,pr.pngandloki.pngper page and range.Still open
buildHitsRangeMetricMatrixloops every step for every series); the typed fold removed the re-parse between levels, the evaluator is the follow-up.sum without (l) (rate(...))alone answers one series; agroup/stddev/topklevel inside a nest; the over-time unwrap window values on a sliding window inside a nest; the VictoriaLogs-side fold for underscore-named and parser-extracted excluded labels.CI fixes after review
unsafe.String. It now parses whole-number timestamps of up to 15 digits straight from the bytes (exact in a float64) and everything else withstrconv.ParseFloat(string(...)), which does not escape.TestScannerNumberMatchesParseFloatholds it bit for bit tostrconv.ParseFloat.BenchmarkFoldWithoutMatrix/typed: 30–33 ms against 35–37 ms withunsafe, allocations unchanged.explore-nested-aggregation-outer-bynow selects{env="production", instrumentation!="otel", app!="otel-collector"}. On the old selector the five OTel-instrumented services differed from Loki on main and on this branch alike, because Loki addstrace_idandspan_idto the sample labels of those lines (pkg/logql/log/metrics_extraction.go:104,109), so the page measured that unrelated difference instead of the nest.explore-without-nested-under-aggregationnow usesmax(sum without (pod) (count_over_time({app="payment-service"}[5m]))): the old query merged no series on the generator's data, so main already matched Loki there. The new shape is a row ofTestWithoutNestedAggregationMatchesLoki.sum without (pod) (count_over_time({env="production"}[1m]))) took 1,019 s because Loki timed out at 90 s on every run, including a seven-run confirm pass. This pull request edits W8: it now uses the same OTel-excluding selector, so Loki answers it, as W1–W4 were changed in fix: sum without over an unwrap range function keeps a series per remaining label set, like Loki #739. The next daily Δ for W8 is a measurement change, not a code change.