Benchmark suite migration: decimal -> double + generator aggregate-type fix - #297
Merged
Conversation
… session
Phase 1 of benchmark-double-migration, suspended before the typed-marker
rename per user pushback on the "object" sentinel.
What is in this commit:
- ProjectionAnalyzer.ResolveAggregateClrType reordered to consult the
schema-driven column lookup first, then SemanticModel argument type,
then a gated SemanticModel invocation-return-type fallback.
- 6 Sum/Avg call sites changed from the bogus "decimal" default to the
interim "object" sentinel so ChainAnalyzer.BuildProjection's enrichment
pass converts the unresolved type into the real column type.
- 5 new tests in AggregateTypeResolutionTests.cs covering Sum over
double/decimal/int/long columns and Avg over double — all passing.
- Full suite remains green at 3482/3482 (baseline 3477 + 5 new).
What remains in Phase 1:
- Replace the bare "object" sentinel at the 6 aggregate call sites with
a named TypeClassification.UnresolvedTypeMarker constant ("?"), already
recognized by both IsUnresolvedTypeName helpers. Rename refactor only;
behavior unchanged.
See _sessions/benchmark-double-migration/ for the full workflow state.
Introduces TypeClassification.UnresolvedTypeMarker = "?" as the canonical sentinel for an unresolved aggregate CLR type produced by Stage 1 syntax-only analysis. Replaces the bare "object" magic-string at the 8 Sum/Avg call sites in ProjectionAnalyzer (regular, joined, window, and joined-window aggregate paths) with the named constant, removing the ambiguity between the legitimate "object" CLR type and an unresolved-pending-enrichment marker. Behavior preserving — both IsUnresolvedTypeName helpers already recognize "?". Min/Max defaults remain at "object" (broader follow-up #1 in plan). Pairs with the earlier reorder/gate of ResolveAggregateClrType (WIP 892312d) to close Phase 1 of benchmark-double-migration. All 5 AggregateTypeResolutionTests pass; full suite green (146 + 201 + 3143 = 3490 passed, 0 failed).
Phase 2 of benchmark-double-migration. Switches the money-shaped columns on OrderSchema/OrderItemSchema and their EF/DTO mirrors from decimal to double, and updates DatabaseSetup seed literals (10.0m/1.5m/5.0m/2.5m → 10.0/1.5/5.0/2.5) accordingly. The DapperOrderLagDto workaround class is removed; the regular OrderLagDto now uses double/double? so Dapper no longer needs a parallel type. This removes the SQLite GetDecimal(string-parse) per-cell tax from every reader on the benchmark hot path, so the inter-library comparison measures library overhead rather than a driver implementation choice. Quarry.Benchmarks does NOT compile in isolation after this commit (the reader bodies still call GetDecimal/ExecuteScalarAsync<decimal> and three benchmark files remain parked as `.cs.disabled`). Phase 3 restores compilation. Quarry.Tests builds and the full test suite still passes.
Phase 3 of benchmark-double-migration. Restores compilation of Quarry.Benchmarks after the Phase 2 schema/DTO migration: - Replace `reader.GetDecimal(1)` with `reader.GetDouble(1)` across 8 reader benchmarks (CteSimple, CteMulti, CteProjection, ComplexJoinFilterPaginate, JoinInner, JoinThreeTable, WindowLag, WindowRunningSum) — 13 call sites total. - WindowLagBenchmarks.Dapper_Lag now uses the regular OrderLagDto; the DapperOrderLagDto workaround class was removed in Phase 2. - AggregateSumBenchmarks / AggregateAvgBenchmarks switched their Raw/Dapper/EfCore/Quarry/SqlKata methods from `Task<decimal>` / `ExecuteScalarAsync<decimal>` / `Convert.ToDecimal` to `<double>` / `Convert.ToDouble`. Quarry.Benchmarks builds clean.
Phase 4 of benchmark-double-migration. With the schema now using `double` for Total/UnitPrice/LineTotal, the GetDecimal string-parse cost is no longer on the benchmark hot path and the documentation explaining it is stale. Drops: - The canonical 22-line NOTE block at the top of CteSimpleBenchmarks.cs that documented the SqliteValueReader.GetDecimal implementation, Dapper's IL-emitted indexer path, and Quarry's deliberate refusal to use the (decimal)GetDouble trick. - The 7 cross-reference comment blocks in ComplexJoinFilterPaginate, CteMulti, CteProjection, JoinInner, JoinThreeTable, WindowLag, and WindowRunningSum that pointed at the canonical block. The rationale lives in PR and commit history for anyone who needs it.
Full test suite green in Release (3490 / 3490 passed). Benchmark smoke run executed 30 representative benchmarks across CteSimple, AggregateSum, and WindowLag end-to-end with no failures; Quarry tracks the hand-rolled Raw baseline within ~1% on WindowLag (~132µs each), matching the empirical numbers cited in the problem statement. All 5 implementation phases of benchmark-double-migration are complete; advancing to REVIEW.
Addresses review findings #14 (A, low) and #10 (B, medium): - TypeClassification.UnresolvedTypeMarker: public const → internal const (matches the class's accessibility). - Add 3 unit tests for joined-aggregate, single-entity window-aggregate, and joined-window-aggregate paths to AggregateTypeResolutionTests. Writing the joined tests surfaced a latent bug deeper than the original fix scope: ProjectionAnalyzer.AnalyzeJoinedInvocation (the entry point for `(u, o) => Sql.Sum(o.Total)` scalar joined projections) was constructing the aggregate ProjectedColumn without setting TableAlias. Stage 4 ChainAnalyzer.BuildProjection's aggregate enrichment then called TryResolveAggregateTypeFromSql with a null tableAlias, the alias-keyed lookup in perAliasLookup fell through, and the unresolved marker leaked all the way to the carrier (IJoinedQueryBuilder<User, Order, ?>) and reader Func type — uncompilable. The sibling ResolveJoinedAggregate (used for tuple-element joined projections) already extracts and sets TableAlias correctly. The fix mirrors that logic in AnalyzeJoinedInvocation — pull the alias from the first column argument (o.Total → "t1") and pass it through to the ProjectedColumn constructor. Behavior preserving for all previously-working paths. Joined scalar aggregates over non-decimal columns now compile and produce correctly- typed readers. Full suite: 201 + 146 + 3146 = 3493 / 3493 passed. See _sessions/benchmark-double-migration/review.md and workflow.md decision dated 2026-05-19 for full context.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two coupled changes:
Total,UnitPrice,LineTotalcolumns switched fromdecimaltodoubleto removeMicrosoft.Data.Sqlite.GetDecimal's per-celldecimal.Parse(GetString(ordinal), ...)string-parse tax from the benchmark hot path, so the inter-library comparison reflects library overhead rather than a driver implementation choice.ProjectionAnalyzer— surfaced by the migration: aggregate CLR-type resolution silently miscompiledSql.Sum/Sql.Avgover any non-decimalcolumn. Fix has two parts: a reorder + typed-marker change to the aggregate type resolver, plus extending Stage 4 enrichment to reach joined scalar aggregates (latent bug uncovered during REMEDIATE).Reason for Change
Empirical measurements on
CteSimpleBenchmarksandWindowLagBenchmarksshowed Dapper appearing 19–30% faster than the hand-rolled Raw baseline on decimal-column workloads. Tracing identified the cause:Microsoft.Data.Sqlite.SqliteValueReader.GetDecimalis implemented asdecimal.Parse(GetString(ordinal), NumberStyles.Number | AllowExponent, InvariantCulture)— a string allocation + culture-aware parse per cell. Raw / Quarry / SqlKata all hit this path; Dapper's IL-emitted deserializer sidesteps it by reading through theDbDataReaderindexer (boxed double → unbox.any →(decimal)(double)conversion). The benchmark numbers were measuring a SQLite driver characteristic, not library overhead.Migrating to
doubleremoves the slow path. Empirical result: Quarry tracks Raw within ~0.5% (~132µs vs ~132µs on WindowLag), Dapper drops from "fastest" to ~13–31% slower than Raw (boxing tax now dominates), and per-row allocations drop ~35% for Raw/Quarry/SqlKata.The migration exposed a latent generator bug that had to be fixed in the same branch so the benchmark project could compile against
Col<double>columns.Impact
decimalcolumns silently produced wrong CLR types). Schemas aggregating overCol<decimal>continue to resolve todecimal. No silent behavior change for previously-working code.decimal→doubleschema change is internal to theQuarry.Benchmarksconsole project (no package surface).Plan items implemented as specified
ResolveAggregateClrTypeto consult column lookup first, then SemanticModel argument type, then a gated invocation-return-type fallback. IntroduceTypeClassification.UnresolvedTypeMarker = "?"and migrate the 8 Sum/Avg call sites from a"decimal"default to the typed sentinel. Min/Max defaults intentionally left at"object"(deferred to follow-up).OrderSchema.Total,OrderItemSchema.UnitPrice/LineTotal→Col<double>; EF entities and DTOs mirrored;DapperOrderLagDtoworkaround class removed;DatabaseSetupseed literals updated todouble.reader.GetDecimal(N)→reader.GetDouble(N)across 8 benchmark files;AggregateSum/AggregateAvgbenchmark methods switched fromTask<decimal>/ExecuteScalarAsync<decimal>/Convert.ToDecimalto<double>/Convert.ToDouble.CteSimpleBenchmarks.csand 7 cross-reference comment blocks in other benchmarks.CteSimple/AggregateSum/WindowLagend-to-end with no failures.Deviations from plan implemented
ResolveAggregateClrType's try-priority". Implementation showed the reorder alone wasn't sufficient — in Stage 1 (syntax-only discovery)columnLookupis intentionally empty, so the column-lookup branch can never succeed at that point. The actual mechanism is two-stage: Stage 1 returns an unresolved-type sentinel and Stage 4 (ChainAnalyzer.BuildProjection) walks aggregate columns whoseClrTypeis unresolved and enriches them viaTryResolveAggregateTypeFromSql. Min/Max already used"object"(recognized byIsUnresolvedTypeName) and worked via enrichment; Sum/Avg defaulted to"decimal"which Stage 4 treats as a resolved type, skipping enrichment. The minimal correctness fix was to change Sum/Avg defaults from"decimal"to an unresolved sentinel."object"magic-string sentinel was replaced withTypeClassification.UnresolvedTypeMarker = "?"(a named constant already partially recognized by bothIsUnresolvedTypeNamehelpers). Reorder/gate fix kept as future-proofing against (a) joined contexts where column lookup IS populated and (b) Roslyn behavior changes in overload-resolution heuristics against Error-typed arguments.Gaps in original plan implemented
ProjectionAnalyzer.AnalyzeJoinedInvocation(entry point for(u, o) => Sql.Sum(o.Total)scalar joined projections) was constructing the aggregateProjectedColumnwithout settingTableAlias. Stage 4 enrichment then calledTryResolveAggregateTypeFromSqlwith a nulltableAlias, the alias-keyed lookup inperAliasLookupfell through, and the unresolved marker leaked all the way through to the carrier interface (IJoinedQueryBuilder<User, Order, ?>) and readerFunctype — uncompilable. The siblingResolveJoinedAggregate(tuple-element handler) already extracted the alias correctly. The fix mirrors that logic inAnalyzeJoinedInvocation. With the prior"object"default the bug was masked (it emittedFunc<object>and(object)r.GetValue(0)— compilable but semantically wrong); the typed-marker rename made the latent bug observable.Migration Steps
None — internal-only change. The benchmark project rebuilds cleanly; no downstream consumers exist (
Quarry.Benchmarks.csprojisOutputType=Exewith noPackageId).Performance Considerations
Benchmark numbers measured pre-merge (Quarry.Benchmarks
--filter "*WindowLagBenchmarks*", default job):GetDecimaldisappear).Security Considerations
None. Generator fix is a strict bug fix; benchmark migration is internal-only.
Breaking Changes
Col<decimal>remains supported and used in ~30 other files (Samples, tests, GeneratorHarness, docs). The generator fix only changes behavior when prior behavior was demonstrably wrong (CS9144 signature mismatch on aggregates over non-decimalcolumns).TypeClassification.UnresolvedTypeMarkerisinternal conston aninternal static class— not part of any public surface.DapperOrderLagDtoworkaround class removed (only used inside the benchmark project; superseded byOrderLagDtonow usingdouble/double?). Schema shape changes onOrderSchema.Total/OrderItemSchema.UnitPrice/OrderItemSchema.LineTotalfromCol<decimal>toCol<double>are local to the benchmark project.Known follow-ups (out of scope)
Tracked in
_sessions/benchmark-double-migration/plan.md"Known follow-ups":"object"-as-sentinel usage inProjectionAnalyzer(Min/Max defaults) toTypeClassification.UnresolvedTypeMarker.ResolvedClrTypediscriminated union.Col<double>columns.EntityRegistryinto Stage 1 syntax-only analysis so the sentinel becomes unnecessary entirely.Test plan
AggregateTypeResolutionTests(8 NUnit tests covering Sum / Avg overCol<double>/Col<decimal>/Col<int>/Col<long>, plus joined-aggregate, single-entity window-aggregate, and joined-window-aggregate paths)CteSimple/AggregateSum/WindowLag) executed end-to-end without errorsGetDecimal/decimal/DapperOrderLagDtoreferences remain anywhere insrc/Quarry.Benchmarks