Skip to content

perf(strawberry-shake): index type descriptor lookups to remove O(N²) mapping#20

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

perf(strawberry-shake): index type descriptor lookups to remove O(N²) mapping#20
cliedeman wants to merge 1 commit into
fork-ci-basefrom
perf/ss-type-mapper

Conversation

@cliedeman

Copy link
Copy Markdown

Root cause

TypeDescriptorMapper (StrawberryShake C# generator) scaled super-linearly with operation count: profiled at ~1.9s for 200 ops and ~4.3s for 275 ops on a real 672-op client. Three field resolutions performed linear scans over collections that grow with every operation:

  • AddPropertiesGetFieldTypeDescriptor looped over every operation and then did typeDescriptors.Values.First(t => t.Model == fieldType), an O(types) reference scan per field. Profiling showed each call scanned ~110–160 operations on average (1.4M–3.4M operation scans in total) — the dominant cost.
  • AddInputTypePropertiesGetInputTypeDescriptor did typeDescriptors.Values.First(t => t.Model.Type.Name == …) per input field.

Fix

Build dictionaries once per Map call and resolve in constant time:

  • descriptorsByModel maps each output type model to its descriptor (reference keyed), replacing the First() scan in the operation-scanning fallback.
  • interfaceDescriptorsBySelectionSet maps an interface output type's selection-set node to its descriptor. A field whose result is an interface is resolved directly by the selection set its syntax node points at, which uniquely identifies the registered interface descriptor — eliminating the per-field operation loop. The original loop is retained as a fallback for any field whose selection set is not in the index, so behavior is unchanged.
  • descriptorsByTypeName maps input descriptors by GraphQL type name (first insertion wins, mirroring the prior First() enumeration order).

Selection-set nodes and output type models are compared by reference (the analyzer interns selection sets; the models define no custom equality), so the indexes return the identical descriptor instances the scans returned.

Correctness (generated output byte-identical)

Verified the broad test slice (StarWars*, ResultType*, Operation*, Entity*, DataType*) on this machine produces the exact same pass/fail set and byte-identical generated sources with and without the change (identical SHA over all generated files). An empirical equivalence check over the full 672-op client confirmed the selection-set index returns the same descriptor as the operation-scanning loop for all 33,781 field resolutions (0 misses, 0 mismatches). The 51 pre-existing snapshot failures on this machine are an unrelated BOM/encoding environment issue present on main; the change neither adds nor removes any.

Before / after — TypeDescriptorMapper.Map (672-op ILOPS client)

N (ops) Before After Speedup
200 1933 ms 454 ms 4.3×
275 4307 ms 435 ms 9.9×

The mapper is now flat with operation count instead of super-linear.

Relates to #17

— Claude
🤖 Generated with Claude Code

…) mapping

TypeDescriptorMapper scaled super-linearly with operation count (profiled at
~1.9s for 200 ops and ~4.3s for 275 ops on a 672-op client). Three field
resolutions did linear scans over collections that grow with every operation:

- AddProperties -> GetFieldTypeDescriptor looped over every operation and then
  did typeDescriptors.Values.First(t => t.Model == fieldType), an O(types)
  reference scan per field. Profiling showed each call scanned ~110-160
  operations on average (1.4M-3.4M operation scans total), the dominant cost.
- AddInputTypeProperties -> GetInputTypeDescriptor did
  typeDescriptors.Values.First(t => t.Model.Type.Name == ...) per input field.

Fix: build dictionaries once per Map call and resolve in constant time.

- descriptorsByModel maps each output type model to its descriptor (reference
  keyed), replacing the First() scan in the operation-scanning fallback.
- interfaceDescriptorsBySelectionSet maps an interface output type's selection
  set node to its descriptor. A field whose result is an interface is resolved
  directly by the selection set its syntax node points at, which uniquely
  identifies the registered interface descriptor and avoids the per-field
  operation loop entirely. The original loop is retained as a fallback for any
  field whose selection set is not in the index, so behavior is unchanged.
- descriptorsByTypeName maps input descriptors by GraphQL type name (first
  insertion wins, mirroring the prior First() enumeration order).

Selection set nodes and output type models are compared by reference (the
analyzer interns selection sets; models have no custom equality), so the
indexes return the identical descriptor instances the scans returned.

Correctness: generated output is byte-identical. Verified the broad test slice
(StarWars*, ResultType*, Operation*, Entity*, DataType*) produces exactly the
same pass/fail set and byte-identical generated sources with and without the
change (identical SHA over all generated files).

Before/after TypeDescriptorMapper.Map on the 672-op ILOPS client:
  N=200: 1933ms -> 454ms (4.3x)
  N=275: 4307ms -> 435ms (9.9x), now flat with operation count

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