Repository navigation
perf(query): LINQ Sum/Average/Min/Max from index keys or raw BSON - #183
mrdevrobot merged 10 commits into
Conversation
ScanAsync, CountScanAsync and the BSON-predicate path of FetchAsync copied every document out of its page (ReadRawBytesAt) before looking at it, and kept every page they had read in a dictionary until the scan ended. VisitRawDocumentsAsync hands single-page documents to the caller straight from the page buffer, reassembling only overflow documents, and keeps at most 64 page buffers, reusing them once the window is full. Documents are still checked against their primary-key entry before they are visited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntities An aggregate with a selector always materialised every matching entity and ran Enumerable over them. The BSON push-down meant to avoid that never ran: it cast ScanAsync's IAsyncEnumerable to IEnumerable, got null and fell through. It also had other semantics than LINQ (0 instead of an exception on empty input, unchecked int sums, Take/Skip ignored). Sum/Average/Min/Max(x => x.Property), applied straight to Where clauses over the collection, now run in DocumentCollection.TryAggregateAsync: - int/long property with a B-Tree index, and no filter or one the same index covers exactly: the values are decoded from the index keys, with no document reads; Min/Max read a single entry. - otherwise, when no index serves the filter: the field is read from the raw BSON of each document in primary-key order, with the filter as a BSON predicate. A missing field, a BSON type the mapper would not read unchanged, a null in a non-nullable property or a renamed property ([Column]/[BsonProperty]/[JsonPropertyName]) gives up, and the query runs the usual way. LinqAggregate<TValue> folds the values like Enumerable: checked int/long sums, float sums in double, first of equal Min/Max values wins (-0.0/0.0, 1.0m/1.00m), NaN smallest, nullable selectors skip nulls, empty input throws or returns null as LINQ does. Index keys come in value order, so an index sum range-checks only the total: it returns the exact sum where LINQ would overflow on an intermediate running total in document order. Any other operator before the aggregate (Take, Skip, OrderBy, Select, ...) keeps the existing path. Decimal and floating-point properties never use the index: decimal keys drop trailing zeros and floating-point sums depend on the order of the values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. The numeric semantics are right: I ran about 8,900 comparisons against LINQ-to-Objects (int/long/double/float/decimal, nullable, all-null, empty, overflow, NaN, ±0, decimal scale, indexed/non-indexed/partial-AND filters, OrderBy/Take/Skip before the aggregate) and they match bit for bit, apart from the cases below. The in-place visitor is sound: the span only reaches a synchronous delegate, rented buffers are returned on every path, overflow documents are reassembled, and transaction visibility is the same as the materialising path. Merged with main, full suite 2475 / 0 / 4 skipped; no conflicts with #180 or #193.
One blocker: the raw-BSON path assumes the field lowercase(Name) holds the property's value (DocumentCollection.cs:3447-3520, TryAggregateAsync, IsRenamedInBson, ToBsonName). That isn't always true, and these all returned the right value on main:
| case | LINQ | this PR |
|---|---|---|
[BsonIgnore] / [NotMapped] property over documents written before it was ignored, Sum |
0 | 510 |
computed int Gone => Count * 10 over documents that stored gone earlier, Sum |
100 | 5010 |
getter with logic, int G { get => _g * 2; set => _g = value; }, Sum (also Min/Max/Average) |
7080 | 3540 |
non-key property named Id next to [Key] int Code, Sum(x => x.Id) |
1000 | 10 |
Please take the field name and "stored verbatim" status from the mapper or generator metadata rather than the naming convention. At minimum, fall back when the property has an ignore attribute, isn't a compiler-generated auto-property with a setter, or is named Id without being the key. Attribute-name sniffing in IsRenamedInBson is fragile for the same reason.
Smaller:
- The index
Sum(exactTotal: true, ~L3534) returns a value where LINQ throwsOverflowException, e.g. {5, int.MinValue, -1} gives -2147483644. You documented it in the PR, but it should be in the public docs, or limit the index path toMin/Max/Average. s_scanProjectorMethod/s_compiledScanMethodsare now used only byTryPushDownSelect, which never runs.AggregateRawFieldAsync: whenTrySeekToFieldfails,SeekFieldrestarts from the advanced position, so the name-based fallback can't find an earlier field.- The index path decodes int/long keys without checking the key length; an assertion would guard against a type mismatch.
…im (EntglDb#183) Address the review of the Sum/Average/Min/Max push-down: - Generated mappers now override a new DocumentMapperBase.GetVerbatimFieldName(property): the BSON field a property is restored from unchanged, or null. A property qualifies when both accessors are compiler-implemented and it has a setter, no converter, is scalar and is not passed to the constructor. Ignored, computed and get-only properties are not mapped at all. The key maps to "_id"; renamed properties map to their attribute name. - TryAggregateAsync takes the field from that metadata instead of lowercase(Name) / "Id" -> "_id", and uses neither the raw field nor an index key without it. IsRenamedInBson (attribute-name sniffing) is gone; renamed properties are now pushed down correctly. - Index Sum/Average of int/long keep the sums of positive and negative values apart. Every running total LINQ computes lies between the two, so when both fit LINQ cannot overflow in any order and the result is exact; otherwise the aggregate runs in the order LINQ sees the entities (raw BSON or materialised), so it throws exactly where LINQ throws. - The index path requires the index selector to be x => x.Property of the aggregated type and rejects keys without a document id after the value. - The raw-field fallback seeks from the first element explicitly when the offset table has no entry for the field. - Remove TryPushDownSelect and its reflection caches: it invoked ScanAsync<T>(projector, ct) with one argument and cast an IAsyncEnumerable to IEnumerable, so it always failed and fell through after compiling a projector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntglDb#183) The data's negative values summed to int.MinValue - 4, so whether the sum came from the index depended on the order the random ObjectIds put the documents in: the index path correctly declined (the outcome depends on the order), and when the raw scan then hit an overflow in that order it declined too, because LINQ throws there. Use values whose positive and negative sums are exactly int.MaxValue and int.MinValue, the edge of the gate, and explicit ascending ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the thorough check. I merged Field name and "stored verbatim" status now come from metadata.
One test per row of your table, each compared with LINQ-to-Objects and each failing without the fix with your numbers (510, 5010, 10):
I also added Index
Key length / type. The index path now requires the index selector to be exactly Full suite on current |
… range The optimizer keeps the constant's type, so r.Count < 9.5 or r.Count > 4L gave a double or long bound that was encoded and compared bytewise against the int keys: Where(r => r.Count < 9.5).Max(r => r.Count) returned 19 instead of 9. Use the keys only when each bound is null or exactly TValue; other filters post-filter the documents as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # src/BLite.Core/Query/BTreeQueryProvider.cs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. Every point from the last review is resolved, and I verified each: verbatim field metadata from the generator, exact-parity index sums via split partial sums, dead push-down code removed, explicit seek-back, and One regression was left on the index-key path, and I pushed a fix to the branch so this can land:
The underlying optimizer issue (bounds not converted to the property type) also affects Full suite on the result: 2673 passed, 0 failed. I'll approve and merge once CI is green. |
LINQ aggregates with a selector (
Sum,Average,Min,Max) materialised every matching entity and ranEnumerableover them. This PR computes them from index keys or raw BSON instead, with the result LINQ would compute over the entities. It also makes BSON scans read documents in place.Independent of #180. Both touch
BTreeQueryProvider, but in different places.Why the existing push-down never ran
TryBsonAggregateinvokedScanAsync<TResult>through reflection and cast the result toIEnumerable.ScanAsyncreturns anIAsyncEnumerable, so the cast producednulland every aggregate fell through to full materialisation. Its semantics also differed from LINQ: it returned 0 instead of throwing on empty input, summedintunchecked, and ignoredTake/Skipbefore the aggregate. This PR removes it.TryPushDownSelecthas the same cast problem; it is not touched here (see follow-ups).What it does
x => x.Propertyaggregates applied straight toWhereclauses over the collection go throughDocumentCollection.TryAggregateAsync:int/longproperty (or nullable) with a B-Tree index, and either no filter or a filter that index covers exactly (same rule as the key-onlyCount). Values are decoded from the[value][id]keys with no document reads.Minreads the first entry andMaxthe last.FetchAsyncalready uses. The attempt gives up, and the query runs the usual way, if a document:nullin a non-nullable property (materialising it throws);[Column]/[BsonProperty]/[JsonPropertyName].Anything else in front of the aggregate (
Take,Skip,OrderBy,Select,Distinct, …) keeps the existing path, because it changes which values are aggregated or their order.LinqAggregate<TValue>folds the values the wayEnumerabledoes:Sumintsums in a checkedint,longin a checkedlong,floatin adoublecast back at the endAverageint/longsum in a checkedlong;floatreturns(float)(sum / n)Min/MaxIComparable<T>, replaces only on strict improvement, so the first of equal values wins (-0.0/0.0,1.0m/1.00m) andNaNis the smallest valuenull; on no valuesSumis 0 and the rest returnnull(nullable) or throwInvalidOperationExceptionThe one difference, index sums only. Index keys come in value order, not document order, so an index
Sumrange-checks only the total. If the exact total fits, it returns it, even where LINQ would have overflowed on an intermediate running total in document order (e.g.int.MaxValue, 1, -2). It never returns a different number. If you'd rather keep LINQ's exception exactly, the index path could be limited toMin/Max/Average.Why not other types on the index path:
decimal: keys drop trailing zeros (1.50becomes1.5), and the first of equalMin/Maxvalues can't be identified.double/float: sums depend on the order of the values, and so do the ties between-0.0and0.0.BSON scans read documents in place
ScanAsync,CountScanAsyncandFetchAsync's BSON-predicate path copied every document out of its page (ReadRawBytesAt) before evaluating it.VisitRawDocumentsAsynchands single-page documents to the caller straight from the page buffer and reassembles only overflow documents. Each page is still read once and cached for the scan, as before.I first tried a bounded 64-page window instead, but it was ~60% slower. Primary-key order isn't page order (ObjectIds created in the same second aren't monotonic), so the window kept re-reading pages.
Numbers
10,000 documents, index on
Count, macOS M-series, Release. The harness is the same code built againstmainand against this branch; each figure is the median of 100 runs after 60 warm-ups.mainmain→ PR)Sum(x => x.Count)(indexed int)Where(Count in range).Sum(x => x.Count)Max(x => x.Count)Sum(x => x.Ratio)(double, no index)Average(x => x.Price)(decimal, no index)Where(x => x.Tag == "b").Sum(x => x.Ratio)Where(x => x.Tag == "b").ToList()(scan only)The new
LinqAggregateBenchmarks(BenchmarkDotNet) compares each path against materialise + LINQ to Objects. With the index:Sum~30× faster, a rangeSum~80×,Max~1,000×.What remains of a raw-BSON aggregate is the per-document scan machinery. That is the async primary-index iteration, plus an
IndexKeyallocation to check each document's_idagainst its entry. It is shared with every BSON scan, so I left it for a separate change.Tests
LinqAggregateTests, 18 tests. Results are compared bit for bit (double/floatbits,decimal.GetBits, exception type) with LINQ to Objects over the same entities in the same order:int/long/double/float/decimal, plain and nullable) × 6 filters (none, non-indexed, covered range, range plus residue, no match, empty range). Each combination also checks whether it was pushed down or correctly left to the usual path.NaN,±0,±∞,1e308overflow to infinity, and afloatset where(float)(sum / n)≠(float)sum / n.nullin a non-nullable property (materialising throws, so the aggregate throws alike); a renamed property whose conventional name is another property's field.Take/Skip/OrderBybefore the aggregate, chainedWhere,SumAsync/AverageAsync; index aggregates after updates and deletes.Each rule was checked by reverting it: all 12 mutations fail at least one test. Full suite on macOS: 2410 passed, 0 failed, 4 skipped.
Found along the way (not changed here)
[Column("x")]/[BsonProperty]/[JsonPropertyName], butBsonSchemaGeneratorregisters only lowercase property names, soWriteElementHeaderthrows "BSON Key 'x' not found in dictionary cache". Filed as Renamed properties ([Column]/[BsonProperty]/[JsonPropertyName]) fail on insert: field name not registered #184.TryPushDownSelectnever runs (sameIAsyncEnumerablecast). Making it run also means fixing the value-type projector, which returnsdefaultfor filtered documents (Select followed by Any / Count(predicate) / Max / Sum returns false or 0: value-type default is taken as the push-down result #181).Whereserved by an index fetches in index order. SoWhere(indexed range).Sum(double)rounds differently from the same sum overToList()in primary-key order. This is unchanged by this PR; such queries still take that path.🤖 Generated with Claude Code