From d92a79e6e40b9f68aa58562ad2b0ae0db5b40b41 Mon Sep 17 00:00:00 2001 From: Ciaran Liedeman Date: Sat, 27 Jun 2026 22:33:54 +0200 Subject: [PATCH] perf(strawberry-shake): index type descriptor lookups to remove O(N^2) 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) --- .../TypeDescriptorMapper.InputTypes.cs | 24 ++++++++-- .../Mappers/TypeDescriptorMapper.cs | 46 +++++++++++++++++-- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.InputTypes.cs b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.InputTypes.cs index 4b053b0721c..595c292f3ce 100644 --- a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.InputTypes.cs +++ b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.InputTypes.cs @@ -2,6 +2,7 @@ using HotChocolate.Utilities; using StrawberryShake.CodeGeneration.Analyzers.Models; using StrawberryShake.CodeGeneration.Descriptors.TypeDescriptors; +using static System.StringComparer; namespace StrawberryShake.CodeGeneration.Mappers; @@ -33,6 +34,21 @@ private static void AddInputTypeProperties( Dictionary typeDescriptors, Dictionary leafTypeDescriptors) { + // Index the descriptors by their GraphQL type name so that resolving a + // field's input type is a constant-time lookup instead of a linear scan + // per field. The descriptors are keyed by class name (Model.Name) which + // can differ from the GraphQL type name (Model.Type.Name) used here, so a + // dedicated index is required. First insertion wins, mirroring the prior + // First() enumeration order. + var descriptorsByTypeName = + new Dictionary(typeDescriptors.Count, Ordinal); + foreach (var typeDescriptorModel in typeDescriptors.Values) + { + descriptorsByTypeName.TryAdd( + typeDescriptorModel.Model.Type.Name, + typeDescriptorModel.Descriptor); + } + foreach (var typeDescriptorModel in typeDescriptors.Values) { var properties = new List(); @@ -50,7 +66,7 @@ private static void AddInputTypeProperties( { fieldType = GetInputTypeDescriptor( field.Type.NamedType(), - typeDescriptors); + descriptorsByTypeName); } properties.Add( @@ -69,10 +85,8 @@ private static void AddInputTypeProperties( private static INamedTypeDescriptor GetInputTypeDescriptor( ITypeDefinition fieldNamedType, - Dictionary typeDescriptors) + Dictionary descriptorsByTypeName) { - return typeDescriptors.Values - .First(t => t.Model.Type.Name.EqualsOrdinal(fieldNamedType.Name)) - .Descriptor; + return descriptorsByTypeName[fieldNamedType.Name]; } } diff --git a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.cs b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.cs index bada4a415c8..e6cfb2900dc 100644 --- a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.cs +++ b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Mappers/TypeDescriptorMapper.cs @@ -385,6 +385,37 @@ private static void AddProperties( Dictionary typeDescriptors, Dictionary leafTypeDescriptors) { + // Index the descriptors so that resolving a field's output type is a + // constant-time lookup instead of a linear scan over every operation. + // + // descriptorsByModel maps each output type model to its descriptor and is + // used by the operation-scanning fallback in GetFieldTypeDescriptor. + // + // interfaceDescriptorsBySelectionSet maps an interface output type's + // selection set to its descriptor. A field whose result is an interface is + // resolved by the selection set its syntax node points at, which uniquely + // identifies the registered interface descriptor. Selection set nodes are + // compared by reference (the analyzer interns them), and the first + // registered descriptor for a given node wins, mirroring the descriptor + // registration order in CollectTypes. + var descriptorsByModel = + new Dictionary( + typeDescriptors.Count); + var interfaceDescriptorsBySelectionSet = + new Dictionary(); + + foreach (var typeDescriptorModel in typeDescriptors.Values) + { + descriptorsByModel[typeDescriptorModel.Model] = typeDescriptorModel.Descriptor; + + if (typeDescriptorModel.Model.IsInterface) + { + interfaceDescriptorsBySelectionSet.TryAdd( + typeDescriptorModel.Model.SelectionSet, + typeDescriptorModel.Descriptor); + } + } + foreach (var typeDescriptorModel in typeDescriptors.Values.ToList()) { var properties = new List(); @@ -399,13 +430,20 @@ private static void AddProperties( { fieldType = leafTypeDescriptors[namedType.Name]; } + else if (field.SyntaxNode.SelectionSet is { } selectionSet + && interfaceDescriptorsBySelectionSet.TryGetValue( + selectionSet, + out var interfaceDescriptor)) + { + fieldType = interfaceDescriptor; + } else { fieldType = GetFieldTypeDescriptor( model, field.SyntaxNode, field.Type.NamedType(), - typeDescriptors); + descriptorsByModel); } var propertyKind = includeOrSkipDirective @@ -468,7 +506,7 @@ private static INamedTypeDescriptor GetFieldTypeDescriptor( ClientModel model, FieldNode fieldSyntax, ITypeDefinition fieldNamedType, - Dictionary typeDescriptors) + Dictionary descriptorsByModel) { foreach (var operation in model.Operations) { @@ -477,9 +515,7 @@ private static INamedTypeDescriptor GetFieldTypeDescriptor( fieldNamedType, out var fieldType)) { - return typeDescriptors.Values - .First(t => t.Model == fieldType) - .Descriptor; + return descriptorsByModel[fieldType]; } }