Skip to content

perf(strawberryshake): index data-type components by runtime name#18

Open
cliedeman wants to merge 1 commit into
fork-ci-basefrom
perf/ss-datatype-mapper
Open

perf(strawberryshake): index data-type components by runtime name#18
cliedeman wants to merge 1 commit into
fork-ci-basefrom
perf/ss-datatype-mapper

Conversation

@cliedeman

Copy link
Copy Markdown

Root cause

DataTypeDescriptorMapper scaled super-quadratically with operation count and was the worst non-formatting cost in CSharpGenerator.Generate.

For every component of every data type it resolved the matching descriptor with a full linear scan over the entire type list:

dataTypeInfo.Components
    .Select(name => context.Types.Single(t => t.RuntimeType.Name.Equals(name)))
    .OfType<ComplexTypeDescriptor>()
    .ToList();

context.Types grows with the number of operations (33k entries at 200 ops, 48k at 275 ops for the real client used here), and Enumerable.Single scans the whole list and never short-circuits (it must verify uniqueness). The total cost was O(sum-of-components × |Types|). Because component count, data-type count, and the type list all grow together as ops increase, the wall time exploded.

The fix

src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/DataTypeDescriptorMapper.cs

Build a Dictionary<string, RuntimeTypeMatch> keyed on RuntimeType.Name once (DataTypeDescriptorMapper.cs:34-48), then resolve each component via an O(1) lookup (:97, helper at :105-125). The whole pass is now near-linear in the number of types.

RuntimeTypeMatch (:127-146) stores the first match plus a count, so the original Single(...) semantics are preserved exactly: an absent runtime name and a duplicated runtime name both still throw InvalidOperationException. Names that appear in the type list but are never used as a data-type component are indexed but never trigger a throw, matching the original lazy behaviour. The struct holds only first + count (no per-duplicate list), keeping the index allocation-light.

Correctness: generated output is byte-identical

This is a pure performance change. Verified across the full CSharp generator test suite (114 tests) by capturing snapshot output with the fix stashed vs. applied and diffing:

diff -rq <baseline mismatch> <fixed mismatch>   →   no differences (111/111 files identical)

The suite has pre-existing failures in this environment (Roslyn formatter / SDK drift, unrelated to this mapper); the failure counts are identical with and without the change (114 total, 111 fail, 2 pass, 1 skip both ways), and the generated sources themselves are byte-for-byte equal.

Before / after

Measured on the real 672-op ILOPS client (schema reused, warm) via the file-based CSharpGenerator.Generate pipeline. The DataTypeDescriptorMapper.Map(...) call was temporarily wrapped in a Stopwatch (instrumentation removed before commit; the commit is the clean fix only).

N types DataTypeDescriptorMapper.Map before after speedup
200 33,215 2,804 ms 40 ms ~70×
275 48,189 11,376 ms 34 ms ~335×

Full generate step (single-file, includes Roslyn formatting which dominates and is out of scope here):

N generate before generate after
200 58,730 ms 55,296 ms
275 224,092 ms 177,738 ms

The mapper itself went from a super-quadratic curve (2,804 → 11,376 ms, ~4× for 1.375× more ops) to flat (~34-40 ms regardless of N). The remaining generate cost is Roslyn whitespace normalization / Formatter.Format, a separate hotspot.

Relates to #17

— Claude
🤖 Generated with Claude Code

DataTypeDescriptorMapper scaled super-quadratically with operation count
because, for every component of every data type, it resolved the matching
descriptor with `context.Types.Single(t => t.RuntimeType.Name.Equals(name))`.
`context.Types` grows with the number of operations (tens of thousands of
entries for large clients), and `Single` performs a full linear scan that
never short-circuits. Total cost was O(sum-of-components x |Types|), and
since component count, data-type count, and the type list all grow together,
the wall time exploded as ops increased.

Fix: build a `Dictionary<string, RuntimeTypeMatch>` keyed on `RuntimeType.Name`
once, then resolve each component via an O(1) lookup. `RuntimeTypeMatch` keeps
the first match plus a count so the original `Single(...)` semantics are
preserved exactly: an absent name and a duplicated name both still throw. The
overall pass is now near-linear in the number of types.

Generated output is byte-for-byte unchanged: across the CSharp generator test
suite (114 tests), the produced sources are identical with and without this
change (verified by diffing snapshot output captured with the fix stashed vs.
applied; the suite's pre-existing failures in this environment are formatter
drift unrelated to this mapper and have identical counts either way).

Measured on the real 672-op ILOPS client (schema reused, warm), isolating
DataTypeDescriptorMapper.Map:
  N=200 (types=33,215): 2,804 ms -> 40 ms  (~70x)
  N=275 (types=48,189): 11,376 ms -> 34 ms (~335x)
The mapper is now flat across N instead of super-quadratic.

Relates to #17

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant