From 91f76fd2d900d8eaab5a1208f6e81d430f2ee4ce Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 21 Jul 2026 21:53:31 +0000 Subject: [PATCH] Add Fusion result-composition hot path micro benchmarks --- .../AbstractTypeResolutionMemoBenchmark.cs | 485 ++++++++++++ .../CompositeObjectCreateTemplateBenchmark.cs | 700 ++++++++++++++++++ .../EnumScalarRowReuseBenchmark.cs | 380 ++++++++++ .../MetaDbGetBenchmark.cs | 380 ++++++++++ .../ResultSelectionSetLookupBenchmark.cs | 566 ++++++++++++++ .../SourcePathTranscodeHoistBenchmark.cs | 292 ++++++++ 6 files changed, 2803 insertions(+) create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/AbstractTypeResolutionMemoBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CompositeObjectCreateTemplateBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/EnumScalarRowReuseBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/MetaDbGetBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ResultSelectionSetLookupBenchmark.cs create mode 100644 src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/SourcePathTranscodeHoistBenchmark.cs diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/AbstractTypeResolutionMemoBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/AbstractTypeResolutionMemoBenchmark.cs new file mode 100644 index 00000000000..22ffc1e7dc6 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/AbstractTypeResolutionMemoBenchmark.cs @@ -0,0 +1,485 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; +using HotChocolate.Buffers; +using HotChocolate.Fusion.Execution.Results; +using HotChocolate.Fusion.Logging; +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.Text.Json; +using HotChocolate.Fusion.Types; +using HotChocolate.Types; + +#nullable enable + +namespace HotChocolate.Fusion.Execution.Benchmarks; + +/// +/// Measures abstract-type resolution (__typename to object type) for list elements when the +/// abstract type has more than FusionInterfaceTypeDefinition.MaxTypeNameLookupTypes (4) +/// implementers. On that path ValueCompletion.TryResolveType declines and the current +/// code pays, per element, an AssertString() (UTF-16 transcode plus string allocation) +/// and a string-keyed dictionary lookup via schema.Types.GetType<IObjectTypeDefinition>. +/// The candidate keeps a single-entry memo of the last resolved +/// and hits it with +/// TryGetRawStringValue + Ascii.Equals, which is allocation free. +/// Lists are typically homogeneous, so the memo hits after the first element; the mixed +/// payload alternates two type names every element, which is the worst case for the memo +/// (100 percent misses) and shows the miss-path parity. +/// +[MemoryDiagnoser] +[Config(typeof(BenchmarkConfig))] +public class AbstractTypeResolutionMemoBenchmark +{ + /// + /// BenchmarkDotNet 0.15.8 has no RuntimeMoniker for the net11.0 preview host and + /// this project pins TargetFramework to net11.0, so out-of-process toolchains can + /// neither validate nor build a child process here. The job therefore runs in + /// process with the intended 3 warmup and 10 measurement iterations. + /// + private sealed class BenchmarkConfig : ManualConfig + { + public BenchmarkConfig() + => AddJob( + Job.Default + .WithWarmupCount(3) + .WithIterationCount(10) + .WithToolchain(InProcessEmitToolchain.Instance)); + } + + private const int ElementCount = 1000; + + // Field types mirror ValueCompletion: _schema is ISchemaDefinition + // (ValueCompletion.cs line 21), so Types/GetType go through the same + // interface dispatch the product pays. + private ISchemaDefinition _schema = null!; + private ITypeDefinition _abstractType = null!; + private MemoryArena _arena = null!; + private SourceResultDocument _homogeneousDoc = null!; + private SourceResultDocument _mixedDoc = null!; + + // The candidate memo: a single-entry cache of the last type resolved through + // the global-name fallback. In the product this would be an instance field of + // ValueCompletion, which only runs under FetchResultStore's lock. + private FusionObjectTypeDefinition? _lastResolvedObjectType; + + // The two-entry MRU memo: slot A is the most recently used type, slot B the + // one before it. A hit on slot B swaps the slots so the most recent stays + // first; a miss installs the newly resolved type as slot A and demotes the + // old A to B. This keeps the alternating two-type payload on the hit path. + private FusionObjectTypeDefinition? _memoTypeA; + private FusionObjectTypeDefinition? _memoTypeB; + + [GlobalSetup] + public void Setup() + { + var fusionSchema = ComposeSchema(); + _schema = fusionSchema; + + var interfaceType = fusionSchema.Types.GetType("SearchResult"); + + // Mirrors OperationCompiler.cs lines 333-338: the compiler prepares the + // __typename lookup table once per interface. With six implementers the + // prepared table stays empty, so TryResolveType declines for every element + // and both variants exercise the global-name fallback under test. + // TypeNameLookupTypes and PrepareTypeNameLookupTypes are internal to the + // HotChocolate.Fusion.Execution.Types assembly, which grants no + // InternalsVisibleTo to this benchmark assembly, so this one-time setup + // step is replayed through reflection. Reflection runs only inside + // GlobalSetup; the measured code calls the real TryResolveType. + var typeNameLookupTypesProperty = typeof(FusionInterfaceTypeDefinition).GetProperty( + "TypeNameLookupTypes", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException( + "FusionInterfaceTypeDefinition.TypeNameLookupTypes was not found."); + var prepareTypeNameLookupTypesMethod = typeof(FusionInterfaceTypeDefinition).GetMethod( + "PrepareTypeNameLookupTypes", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException( + "FusionInterfaceTypeDefinition.PrepareTypeNameLookupTypes was not found."); + + var lookupTypes = (ImmutableArray) + typeNameLookupTypesProperty.GetValue(interfaceType)!; + + if (lookupTypes.IsDefault) + { + prepareTypeNameLookupTypesMethod.Invoke( + interfaceType, + [fusionSchema.GetPossibleTypes(interfaceType)]); + + lookupTypes = (ImmutableArray) + typeNameLookupTypesProperty.GetValue(interfaceType)!; + } + + if (!lookupTypes.IsEmpty) + { + throw new InvalidOperationException( + "The interface must have more than 4 implementers so that " + + "TryResolveType declines and the fallback path is measured."); + } + + _abstractType = interfaceType; + + _arena = new MemoryArena(); + _homogeneousDoc = ParsePayload(BuildPayload(mixed: false)); + _mixedDoc = ParsePayload(BuildPayload(mixed: true)); + + VerifyEquivalence(_homogeneousDoc); + VerifyEquivalence(_mixedDoc); + } + + [GlobalCleanup] + public void Cleanup() + { + _arena.Dispose(); + } + + [Benchmark(Baseline = true)] + public int Baseline_Homogeneous() + { + return RunBaseline(_homogeneousDoc); + } + + [Benchmark] + public int Memoized_Homogeneous() + { + return RunMemoized(_homogeneousDoc); + } + + [Benchmark] + public int Baseline_MixedTwoTypes() + { + return RunBaseline(_mixedDoc); + } + + [Benchmark] + public int Memoized_MixedTwoTypes() + { + return RunMemoized(_mixedDoc); + } + + [Benchmark] + public int Memoized2_Homogeneous() + { + return RunMemoized2(_homogeneousDoc); + } + + [Benchmark] + public int Memoized2_MixedTwoTypes() + { + return RunMemoized2(_mixedDoc); + } + + private int RunBaseline(SourceResultDocument doc) + { + var total = 0; + + foreach (var element in doc.Root.EnumerateArray()) + { + total += ResolveTypeBaseline(element).Name.Length; + } + + return total; + } + + private int RunMemoized(SourceResultDocument doc) + { + // A fresh ValueCompletion instance is created per request, so each + // invocation starts with an empty memo: the first element misses and + // every later element of a homogeneous list hits. + _lastResolvedObjectType = null; + + var total = 0; + + foreach (var element in doc.Root.EnumerateArray()) + { + total += ResolveTypeMemoized(element).Name.Length; + } + + return total; + } + + private int RunMemoized2(SourceResultDocument doc) + { + // Same per-request reset as RunMemoized: both slots start empty. + _memoTypeA = null; + _memoTypeB = null; + + var total = 0; + + foreach (var element in doc.Root.EnumerateArray()) + { + total += ResolveTypeMemoized2(element).Name.Length; + } + + return total; + } + + /// + /// Byte-faithful benchmark-local copy of the abstract branch of the private + /// ValueCompletion.GetType, mirroring + /// src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs + /// lines 1297-1309 (the object-type and isOpaque early exits at lines 1284-1295 + /// never apply here because the selection type is the abstract interface). + /// TryResolveType is the real product method (internal static, same file + /// lines 1313-1342), reachable through InternalsVisibleTo. + /// + private IObjectTypeDefinition ResolveTypeBaseline(SourceResultElement data) + { + var typeNameElement = data.GetProperty(IntrospectionFieldNames.TypeNameSpan); + + if (ValueCompletion.TryResolveType(typeNameElement, _abstractType, out var resolvedType)) + { + return resolvedType; + } + + var typeName = typeNameElement.AssertString(); + return _schema.Types.GetType(typeName); + } + + /// + /// Candidate variant: identical to except a + /// single-entry memo fronts the global-name fallback. A memo hit compares the + /// raw UTF-8 __typename bytes against the memoized type's name, skipping the + /// string allocation and the dictionary lookup. When TryGetRawStringValue + /// declines (escaped or chunk-spanning value) the existing fallback runs + /// unchanged, preserving semantics exactly. + /// + private IObjectTypeDefinition ResolveTypeMemoized(SourceResultElement data) + { + var typeNameElement = data.GetProperty(IntrospectionFieldNames.TypeNameSpan); + + if (ValueCompletion.TryResolveType(typeNameElement, _abstractType, out var resolvedType)) + { + return resolvedType; + } + + if (_lastResolvedObjectType is { } last + && typeNameElement.TryGetRawStringValue(out var rawTypeName) + && Ascii.Equals(rawTypeName, last.Name)) + { + return last; + } + + var typeName = typeNameElement.AssertString(); + var objectType = _schema.Types.GetType(typeName); + + if (objectType is FusionObjectTypeDefinition fusionObjectType) + { + _lastResolvedObjectType = fusionObjectType; + } + + return objectType; + } + + /// + /// Two-entry MRU variant: the raw UTF-8 __typename is read once per element + /// and compared against both memo slots, most recent first. A slot B hit + /// swaps the slots so the just-seen type is checked first on the next + /// element, which keeps an alternating two-type list entirely on the hit + /// path. A miss runs the unchanged fallback and installs the result as + /// slot A, demoting the old A to B. When TryGetRawStringValue declines + /// (escaped or chunk-spanning value) the fallback runs unchanged, + /// preserving semantics exactly. + /// + private IObjectTypeDefinition ResolveTypeMemoized2(SourceResultElement data) + { + var typeNameElement = data.GetProperty(IntrospectionFieldNames.TypeNameSpan); + + if (ValueCompletion.TryResolveType(typeNameElement, _abstractType, out var resolvedType)) + { + return resolvedType; + } + + if (typeNameElement.TryGetRawStringValue(out var rawTypeName)) + { + if (_memoTypeA is { } typeA && Ascii.Equals(rawTypeName, typeA.Name)) + { + return typeA; + } + + if (_memoTypeB is { } typeB && Ascii.Equals(rawTypeName, typeB.Name)) + { + _memoTypeB = _memoTypeA; + _memoTypeA = typeB; + return typeB; + } + } + + var typeName = typeNameElement.AssertString(); + var objectType = _schema.Types.GetType(typeName); + + if (objectType is FusionObjectTypeDefinition fusionObjectType) + { + _memoTypeB = _memoTypeA; + _memoTypeA = fusionObjectType; + } + + return objectType; + } + + private void VerifyEquivalence(SourceResultDocument doc) + { + var baselineTypes = new List(ElementCount); + + foreach (var element in doc.Root.EnumerateArray()) + { + var type = ResolveTypeBaseline(element); + + if (!element.GetProperty(IntrospectionFieldNames.TypeNameSpan).ValueEquals(type.Name)) + { + throw new InvalidOperationException( + $"Baseline resolved '{type.Name}' for an element whose " + + "__typename does not match."); + } + + baselineTypes.Add(type); + } + + _lastResolvedObjectType = null; + var memoizedTypes = new List(ElementCount); + + foreach (var element in doc.Root.EnumerateArray()) + { + memoizedTypes.Add(ResolveTypeMemoized(element)); + } + + _memoTypeA = null; + _memoTypeB = null; + var memoized2Types = new List(ElementCount); + + foreach (var element in doc.Root.EnumerateArray()) + { + memoized2Types.Add(ResolveTypeMemoized2(element)); + } + + if (baselineTypes.Count != ElementCount + || memoizedTypes.Count != ElementCount + || memoized2Types.Count != ElementCount) + { + throw new InvalidOperationException( + $"Expected {ElementCount} resolved types but baseline produced " + + $"{baselineTypes.Count}, memoized produced {memoizedTypes.Count} " + + $"and memoized2 produced {memoized2Types.Count}."); + } + + for (var i = 0; i < ElementCount; i++) + { + if (!ReferenceEquals(baselineTypes[i], memoizedTypes[i])) + { + throw new InvalidOperationException( + $"Type mismatch at element {i}: baseline resolved " + + $"'{baselineTypes[i].Name}' but memoized resolved " + + $"'{memoizedTypes[i].Name}' (or a different instance)."); + } + + if (!ReferenceEquals(baselineTypes[i], memoized2Types[i])) + { + throw new InvalidOperationException( + $"Type mismatch at element {i}: baseline resolved " + + $"'{baselineTypes[i].Name}' but memoized2 resolved " + + $"'{memoized2Types[i].Name}' (or a different instance)."); + } + } + } + + private SourceResultDocument ParsePayload(byte[] payload) + { + return SourceResultDocument.Parse(_arena, payload, payload.Length); + } + + private static byte[] BuildPayload(bool mixed) + { + var json = new StringBuilder(ElementCount * 64); + json.Append('['); + + for (var i = 0; i < ElementCount; i++) + { + if (i > 0) + { + json.Append(','); + } + + var typeName = mixed && i % 2 == 1 ? "Article" : "Product"; + json.Append("{\"__typename\":\"").Append(typeName).Append("\","); + json.Append("\"id\":\"id-").Append(i).Append("\","); + json.Append("\"title\":\"Item ").Append(i).Append("\"}"); + } + + json.Append(']'); + return Encoding.UTF8.GetBytes(json.ToString()); + } + + /// + /// Composes a minimal fusion schema whose SearchResult interface has six + /// implementers, which exceeds FusionInterfaceTypeDefinition.MaxTypeNameLookupTypes (4) + /// so TypeNameLookupTypes stays empty and every element takes the global-name + /// fallback that the candidate targets. Composition recipe follows + /// FusionBenchmarkBase.CreateFusionSchema. + /// + private static FusionSchemaDefinition ComposeSchema() + { + List sourceSchemas = + [ + new SourceSchemaText( + "search", + """ + type Query { + searchContent(query: String!): [SearchResult!]! + } + + interface SearchResult { + id: ID! + title: String! + } + + type Product implements SearchResult { + id: ID! + title: String! + } + + type Article implements SearchResult { + id: ID! + title: String! + } + + type Brand implements SearchResult { + id: ID! + title: String! + } + + type Category implements SearchResult { + id: ID! + title: String! + } + + type Promotion implements SearchResult { + id: ID! + title: String! + } + + type Vendor implements SearchResult { + id: ID! + title: String! + } + """) + ]; + + var compositionLog = new CompositionLog(); + var composerOptions = new SchemaComposerOptions(); + var composer = new SchemaComposer(sourceSchemas, composerOptions, compositionLog); + var result = composer.Compose(); + + if (!result.IsSuccess) + { + throw new InvalidOperationException(result.Errors[0].Message); + } + + return FusionSchemaDefinition.Create(result.Value.ToSyntaxNode()); + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CompositeObjectCreateTemplateBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CompositeObjectCreateTemplateBenchmark.cs new file mode 100644 index 00000000000..6ec91abcf30 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/CompositeObjectCreateTemplateBenchmark.cs @@ -0,0 +1,700 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; +using HotChocolate.Buffers; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Fusion.Execution.Rewriters; +using HotChocolate.Fusion.Text.Json; +using HotChocolate.Language; +using HotChocolate.Types; +using Microsoft.Extensions.ObjectPool; +using DbRow = HotChocolate.Fusion.Text.Json.CompositeResultDocument.DbRow; +using ElementFlags = HotChocolate.Fusion.Text.Json.CompositeResultDocument.ElementFlags; +using OperationReferenceType = HotChocolate.Fusion.Text.Json.CompositeResultDocument.OperationReferenceType; +using FusionOperation = HotChocolate.Fusion.Execution.Nodes.Operation; + +namespace HotChocolate.Fusion.Execution.Benchmarks; + +/// +/// Compares two strategies for materializing the MetaDb rows that +/// CompositeResultDocument.CreateObject writes for one object instance +/// (CompositeResultDocument.cs lines 427-461): +/// +/// - AppendLoop (baseline): a byte-faithful local copy of the current per-row +/// append sequence: AppendStartObject, then per selection either +/// AppendEmptyPropertyWithNullValue or the synthesized __typename pair +/// (AppendEmptyProperty + Append(String)), then AppendEndObject, each with +/// its own capacity checks, plus the per-selection introspection check and +/// GetPropertyFlags recompute against the real compiled Selection objects. +/// +/// - TemplateBlockCopy (candidate): a (2N + 2)-row template pre-built once per +/// selection set, block-copied per instance with one whole-block capacity +/// check, then only the parent-pointer ints are stamped into the copied rows. +/// +/// Both variants write instances of the same +/// 10-field Product selection set (one synthesized __typename among them) into a +/// pre-allocated 128 KB chunk, simulating a steady-state chunk of the real +/// MetaDb. GlobalSetup proves the baseline copy byte-identical to the real +/// CreateObject on a real CompositeResultDocument and proves both variants +/// produce byte-identical chunks. +/// +[MemoryDiagnoser] +[Config(typeof(BenchmarkConfig))] +public class CompositeObjectCreateTemplateBenchmark : FusionBenchmarkBase +{ + /// + /// BenchmarkDotNet 0.15.8 has no RuntimeMoniker for the net11.0 preview host and + /// this project pins TargetFramework to net11.0, so out-of-process toolchains can + /// neither validate nor build a child process here. The job therefore runs in + /// process with the intended 3 warmup and 10 measurement iterations. + /// + private sealed class BenchmarkConfig : ManualConfig + { + public BenchmarkConfig() + => AddJob( + Job.Default + .WithWarmupCount(3) + .WithIterationCount(10) + .WithToolchain(InProcessEmitToolchain.Instance)); + } + + private const string OperationId = "123456789101112"; + private const int InstanceCount = 100; + private const int FieldCount = 10; + + // A steady-state MetaDb chunk (ChunkSize.Size128K => 1 << (10 + 7) bytes) + // holds 6553 rows; 100 instances x 22 rows = 2200 rows fit comfortably. + private const int ChunkBytes = 128 * 1024; + + // The document under test is created with neither @include/@skip variables + // nor @defer, matching CompositeResultDocument._includeFlags/_deferFlags = 0. + private const ulong IncludeFlags = 0; + private const ulong DeferFlags = 0; + +#pragma warning disable IDE0370 // Remove unnecessary suppression + private FusionOperation _operation = null!; + private SelectionSet _selectionSet = null!; + private byte[] _baselineChunk = null!; + private byte[] _templateChunk = null!; + private byte[] _template = null!; + private int[] _parents = null!; +#pragma warning restore IDE0370 // Remove unnecessary suppression + private int _rowsPerInstance; + private MetaDbClone _baselineDb; + private MetaDbClone _templateDb; + + [GlobalSetup] + public void Setup() + { + var schema = CreateFusionSchema(); + var documentRewriter = new DocumentRewriter(schema); + var operationDefinition = documentRewriter + .RewriteDocument( + Utf8GraphQLParser.Parse( + """ + { + productById(id: "1") { + __typename + id + name + description + price + dimension { height width } + estimatedDelivery + secondDescription: description + secondPrice: price + secondName: name + } + } + """)) + .Definitions + .OfType() + .Single(); + var fieldMapPool = new DefaultObjectPool< + OrderedDictionary>>( + new DefaultPooledObjectPolicy< + OrderedDictionary>>()); + var operationCompiler = new OperationCompiler(schema, fieldMapPool); + _operation = operationCompiler.Compile( + OperationId, + OperationId, + operationDefinition); + + var productSelection = _operation.RootSelectionSet.Selections[0]; + _selectionSet = _operation.GetSelectionSet(productSelection); + + var selections = _selectionSet.Selections; + + if (selections.Length != FieldCount) + { + throw new InvalidOperationException( + $"Expected the Product selection set to contain {FieldCount} selections " + + $"but found {selections.Length}."); + } + + var typeNameCount = 0; + + foreach (var selection in selections) + { + if (selection.Field.IsIntrospectionField + && selection.Field.Name == IntrospectionFieldNames.TypeName) + { + typeNameCount++; + } + } + + if (typeNameCount != 1) + { + throw new InvalidOperationException( + $"Expected exactly one __typename selection but found {typeNameCount}."); + } + + _rowsPerInstance = (selections.Length * 2) + 2; + + _baselineChunk = new byte[ChunkBytes]; + _templateChunk = new byte[ChunkBytes]; + _baselineDb = MetaDbClone.Create(_baselineChunk); + _templateDb = MetaDbClone.Create(_templateChunk); + + // Deterministic 29-bit parent cursor values, one per instance, mirroring + // the distinct array-element value slots a list merge would stamp. + var rng = new Random(42); + _parents = new int[InstanceCount]; + + for (var i = 0; i < InstanceCount; i++) + { + _parents[i] = rng.Next(0, 0x1FFFFFFF); + } + + // Pre-build the row-block template once: the append loop rendered at + // start cursor 0 with parent 0. Every byte except the parent ints is + // invariant per (document, selectionSet); the parent ints are stamped + // per instance by AppendTemplateBlock (the EndObject parent stays 0). + _template = new byte[_rowsPerInstance * DbRow.Size]; + var templateDb = MetaDbClone.Create(_template); + CreateObjectWithAppendLoop(ref templateDb, parentValue: 0); + + VerifyAppendLoopMatchesRealCreateObject(); + VerifyVariantsProduceIdenticalRows(); + } + + [Benchmark(Baseline = true, OperationsPerInvoke = InstanceCount)] + public void CreateObject_AppendLoop() + { + _baselineDb.Reset(0); + var parents = _parents; + + for (var i = 0; i < InstanceCount; i++) + { + CreateObjectWithAppendLoop(ref _baselineDb, parents[i]); + } + } + + [Benchmark(OperationsPerInvoke = InstanceCount)] + public void CreateObject_TemplateBlockCopy() + { + _templateDb.Reset(0); + var template = _template; + var parents = _parents; + + for (var i = 0; i < InstanceCount; i++) + { + _templateDb.AppendTemplateBlock(template, parents[i]); + } + } + + /// + /// Byte-faithful local copy of CompositeResultDocument.CreateObject + /// (CompositeResultDocument.cs lines 427-461). The CompositeObjectContext + /// and CompositeResultElement constructions at lines 454-460 are plain + /// struct initializations and are not replicated. + /// + private int CreateObjectWithAppendLoop(ref MetaDbClone db, int parentValue) + { + var selectionSet = _selectionSet; + var selections = selectionSet.Selections; + + // WriteStartObject (CompositeResultDocument.cs lines 568-570). + var startObjectCursor = db.AppendStartObject( + parentValue, + selectionSet.Id, + selections.Length, + ElementFlags.None); + + // Per-selection loop (CompositeResultDocument.cs lines 435-450). + foreach (var selection in selections) + { + if (selection.Field.IsIntrospectionField + && selection.Field.Name == IntrospectionFieldNames.TypeName) + { + // WriteTypeNameProperty (CompositeResultDocument.cs lines 589-600). + var propertyCursor = db.AppendEmptyProperty( + startObjectCursor, + selection.Id, + GetPropertyFlags(selection)); + + db.Append( + ElementTokenType.String, + location: selectionSet.Id, + parentRow: propertyCursor); + } + else + { + // WriteEmptyProperty (CompositeResultDocument.cs lines 579-584). + db.AppendEmptyPropertyWithNullValue( + startObjectCursor, + selection.Id, + GetPropertyFlags(selection)); + } + } + + db.AppendEndObject(); + + return startObjectCursor; + } + + /// + /// Byte-faithful local copy of CompositeResultDocument.GetPropertyFlags + /// (CompositeResultDocument.cs lines 602-628) with the document's + /// includeFlags/deferFlags fixed to zero. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static ElementFlags GetPropertyFlags(Selection selection) + { + var flags = ElementFlags.None; + + if (selection.IsInternal) + { + flags = ElementFlags.IsInternal; + } + + if (!selection.IsIncluded(IncludeFlags) || selection.IsDeferred(DeferFlags)) + { + flags |= ElementFlags.IsExcluded; + } + + if (selection.Type.Kind is not TypeKind.NonNull) + { + flags |= ElementFlags.IsNullable; + } + + if (selection.IsEnumValue) + { + flags |= ElementFlags.IsEnumValue; + } + + return flags; + } + + /// + /// Proves the local append-loop copy is byte-identical to the real + /// CreateObject: builds a real CompositeResultDocument from the compiled + /// operation, creates one object for the same selection set, and compares + /// all of its MetaDb rows against the local writer's output for the same + /// parent and start cursor values. + /// + private void VerifyAppendLoopMatchesRealCreateObject() + { + using var arena = new MemoryArena(); + using var document = new CompositeResultDocument(arena, _operation, includeFlags: 0); + + // Row 2 is the value slot of the root productById property, which is + // exactly where a real merge would hang the created object. + var parentCursor = document.Data.Cursor.AddRows(2); + var startCursor = document._metaDb.NextCursor; + var element = document.CreateObject(parentCursor, _selectionSet); + + if (element.Cursor != startCursor) + { + throw new InvalidOperationException( + "The created object did not start at the expected cursor."); + } + + if (startCursor.Chunk != 0) + { + throw new InvalidOperationException( + "Expected the created object to be written into chunk 0."); + } + + var expected = new byte[_rowsPerInstance * DbRow.Size]; + + for (var i = 0; i < _rowsPerInstance; i++) + { + var row = document._metaDb.Get(startCursor.AddRows(i)); + MemoryMarshal.Write(expected.AsSpan(i * DbRow.Size, DbRow.Size), in row); + } + + // Replay the local writer at the same start cursor value with the same + // parent value; within chunk 0 the cursor value equals the row index. + var chunk = new byte[1024]; + var db = MetaDbClone.Create(chunk); + db.Reset(startCursor.Value); + CreateObjectWithAppendLoop(ref db, parentCursor.Value); + + var actual = chunk.AsSpan( + startCursor.Value * DbRow.Size, + _rowsPerInstance * DbRow.Size); + + if (!actual.SequenceEqual(expected)) + { + for (var i = 0; i < actual.Length; i++) + { + if (actual[i] != expected[i]) + { + throw new InvalidOperationException( + "The local append-loop copy diverged from the real CreateObject " + + $"rows at byte {i} (row {i / DbRow.Size}, offset {i % DbRow.Size})."); + } + } + } + } + + /// + /// Runs both benchmark bodies once and compares the full written regions of + /// both chunks byte for byte. + /// + private void VerifyVariantsProduceIdenticalRows() + { + CreateObject_AppendLoop(); + CreateObject_TemplateBlockCopy(); + + var length = InstanceCount * _rowsPerInstance * DbRow.Size; + var baseline = _baselineChunk.AsSpan(0, length); + var template = _templateChunk.AsSpan(0, length); + + if (!baseline.SequenceEqual(template)) + { + for (var i = 0; i < length; i++) + { + if (baseline[i] != template[i]) + { + throw new InvalidOperationException( + "Baseline and template variants produced different bytes at " + + $"offset {i} (row {i / DbRow.Size}, offset {i % DbRow.Size})."); + } + } + } + } + + /// + /// Single-chunk stand-in for CompositeResultDocument.MetaDb + /// (CompositeResultDocument.MetaDb.cs). Cursor values equal row indexes + /// because everything lives in one chunk, so the cursor advance is a plain + /// integer increment instead of Cursor.AddRows; this simplification applies + /// to both variants. Every append method mirrors the corresponding MetaDb + /// method byte for byte, including the branch structure of its capacity + /// checks; branches the benchmark sizes away from taking throw instead of + /// rolling to a new chunk. + /// + private struct MetaDbClone + { + // Cursor.RowBits = 13 (CompositeResultDocument.Cursor.cs lines 25-29). + private const int RowMask = (1 << 13) - 1; + + private byte[][] _chunks; + private int _segmentOffset; + private int _chunkBytes; + private int _next; + + public static MetaDbClone Create(byte[] chunk) + => new MetaDbClone + { + _chunks = [chunk], + _segmentOffset = 0, + _chunkBytes = chunk.Length, + _next = 0 + }; + + public void Reset(int nextValue) => _next = nextValue; + + /// + /// Mirrors MetaDb.ReserveRow (CompositeResultDocument.MetaDb.cs lines + /// 340-379): capacity guard, chunk-boundary check, chunk-table bounds + /// check, and chunk-buffer null check. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private (byte[] Chunk, int ByteOffset, int Cursor) ReserveRow() + { + var next = _next; + + // Mirrors the next == Cursor.End guard (MetaDb.cs line 344). + if (next == int.MaxValue) + { + throw new InvalidOperationException("The benchmark chunk is exhausted."); + } + + var byteOffset = (next & RowMask) * DbRow.Size; + + // Mirrors the chunk roll (MetaDb.cs lines 352-359); the benchmark + // buffer is sized so this branch is never taken. + if (byteOffset + DbRow.Size > _chunkBytes) + { + throw new InvalidOperationException( + "The row does not fit into the benchmark chunk."); + } + + // Mirrors the chunk-table fetch and rent checks (MetaDb.cs lines 361-376). + var chunks = _chunks.AsSpan(); + + if (chunks.Length < 1) + { + throw new InvalidOperationException("The chunk table is empty."); + } + + var chunk = chunks[0]; + + if (chunk is null) + { + throw new InvalidOperationException("The chunk buffer is not rented."); + } + + return (chunk, byteOffset, next); + } + + /// + /// Mirrors MetaDb.Append (CompositeResultDocument.MetaDb.cs lines 49-78) + /// using the real DbRow constructor for the packing. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Append(ElementTokenType tokenType, int location, int parentRow) + { + var (chunk, byteOffset, cursor) = ReserveRow(); + + var row = new DbRow(tokenType, location: location, parentRow: parentRow); + + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref dest, _segmentOffset + byteOffset), row); + + _next = cursor + 1; + return cursor; + } + + /// + /// Mirrors MetaDb.AppendNull (CompositeResultDocument.MetaDb.cs lines 81-98). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int AppendNull(int parentRow) + { + var (chunk, byteOffset, cursor) = ReserveRow(); + + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); + ref var row = ref Unsafe.Add(ref dest, _segmentOffset + byteOffset); + + // int 0: parent cursor value; ints 1..4 stamped zero via one vector store. + Unsafe.WriteUnaligned(ref row, parentRow); + Vector128.Zero.StoreUnsafe(ref Unsafe.Add(ref row, 4)); + + _next = cursor + 1; + return cursor; + } + + /// + /// Mirrors MetaDb.AppendEmptyProperty (CompositeResultDocument.MetaDb.cs + /// lines 101-132). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int AppendEmptyProperty(int parentRow, int selectionId, ElementFlags flags) + { + var (chunk, byteOffset, cursor) = ReserveRow(); + + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); + ref var row = ref Unsafe.Add(ref dest, _segmentOffset + byteOffset); + + // int 0: parent cursor value + Unsafe.WriteUnaligned(ref row, parentRow); + + // int 1: selectionId + opRefType=Selection + flags + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row, 4), + selectionId + | ((int)OperationReferenceType.Selection << 15) + | (((int)flags & DbRow.FlagsMask) << DbRow.FlagsShift)); + + // ints 2..3 must be zero (int 4 is written directly below) + Unsafe.InitBlockUnaligned(ref Unsafe.Add(ref row, 8), 0, 8); + + // int 4: PropertyName token + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row, 16), + (int)ElementTokenType.PropertyName << 15); + + _next = cursor + 1; + return cursor; + } + + /// + /// Mirrors MetaDb.AppendEmptyPropertyWithNullValue + /// (CompositeResultDocument.MetaDb.cs lines 135-179) including the + /// two-row fast-path capacity check. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int AppendEmptyPropertyWithNullValue(int parentRow, int selectionId, ElementFlags flags) + { + var next = _next; + var byteOffset = (next & RowMask) * DbRow.Size; + var chunks = _chunks; + + // Fast path: both rows fit in the current chunk (MetaDb.cs lines 146-173). + if (byteOffset + (DbRow.Size * 2) <= _chunkBytes + && 0 < (uint)chunks.Length + && chunks[0] is { } buffer) + { + ref var dest = ref MemoryMarshal.GetArrayDataReference(buffer); + ref var row0 = ref Unsafe.Add(ref dest, _segmentOffset + byteOffset); + + // Row 0: PropertyName (MetaDb.cs lines 153-164) + Unsafe.WriteUnaligned(ref row0, parentRow); + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row0, 4), + selectionId + | ((int)OperationReferenceType.Selection << 15) + | (((int)flags & DbRow.FlagsMask) << DbRow.FlagsShift)); + Unsafe.InitBlockUnaligned(ref Unsafe.Add(ref row0, 8), 0, 8); + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row0, 16), + (int)ElementTokenType.PropertyName << 15); + + // Row 1: None value with parent = cursor value of Row 0 + // (MetaDb.cs lines 166-169) + ref var row1 = ref Unsafe.Add(ref row0, DbRow.Size); + Unsafe.WriteUnaligned(ref row1, next); + Vector128.Zero.StoreUnsafe(ref Unsafe.Add(ref row1, 4)); + + _next = next + 2; + return next; + } + + // Slow path (MetaDb.cs lines 175-178); never taken here. + var propCursor = AppendEmptyProperty(parentRow, selectionId, flags); + AppendNull(propCursor); + return propCursor; + } + + /// + /// Mirrors MetaDb.AppendStartObject (CompositeResultDocument.MetaDb.cs + /// lines 182-217). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int AppendStartObject(int parentRow, int selectionSetId, int propertyCount, ElementFlags flags) + { + var (chunk, byteOffset, cursor) = ReserveRow(); + + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); + ref var row = ref Unsafe.Add(ref dest, _segmentOffset + byteOffset); + + // int 0: parent cursor value + Unsafe.WriteUnaligned(ref row, parentRow); + + // int 1: selectionSetId + SelectionSet + flags + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row, 4), + selectionSetId + | ((int)OperationReferenceType.SelectionSet << 15) + | (((int)flags & DbRow.FlagsMask) << DbRow.FlagsShift)); + + // int 2: sizeOrLength = property count + Unsafe.WriteUnaligned(ref Unsafe.Add(ref row, 8), propertyCount); + + // int 3: numberOfRows = (count * 2) + 1 + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row, 12), + ((propertyCount * 2) + 1) & 0x1FFFFFFF); + + // int 4: StartObject token (sourceDocumentId = 0) + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row, 16), + (int)ElementTokenType.StartObject << 15); + + _next = cursor + 1; + return cursor; + } + + /// + /// Mirrors MetaDb.AppendEndObject (CompositeResultDocument.MetaDb.cs + /// lines 253-269). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int AppendEndObject() + { + var (chunk, byteOffset, cursor) = ReserveRow(); + + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); + ref var row = ref Unsafe.Add(ref dest, _segmentOffset + byteOffset); + + // int 0: no parent + Unsafe.WriteUnaligned(ref row, 0); + // ints 1..3 zeroed (int 4 is written directly below) + Unsafe.InitBlockUnaligned(ref Unsafe.Add(ref row, 4), 0, 12); + // int 4: EndObject token + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref row, 16), + (int)ElementTokenType.EndObject << 15); + + _next = cursor + 1; + return cursor; + } + + /// + /// The candidate optimization: one whole-block capacity check, one + /// block copy of the pre-built (2N + 2)-row template, then stamp only + /// the parent-pointer ints. Row 0 gets the caller's parent, property + /// rows get the start cursor value, value rows get their property + /// row's cursor value, and the EndObject parent stays 0 (already 0 in + /// the template). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendTemplateBlock(byte[] template, int parentRow) + { + var next = _next; + var byteOffset = (next & RowMask) * DbRow.Size; + var chunks = _chunks; + + if (byteOffset + template.Length <= _chunkBytes + && 0 < (uint)chunks.Length + && chunks[0] is { } buffer) + { + ref var dest = ref Unsafe.Add( + ref MemoryMarshal.GetArrayDataReference(buffer), + _segmentOffset + byteOffset); + + Unsafe.CopyBlockUnaligned( + ref dest, + ref MemoryMarshal.GetArrayDataReference(template), + (uint)template.Length); + + // Stamp the StartObject parent. + Unsafe.WriteUnaligned(ref dest, parentRow); + + // Stamp the property/value parents; the trailing EndObject row + // keeps its template parent of 0. + var rows = template.Length / DbRow.Size; + + for (var rowOffset = 1; rowOffset < rows - 1; rowOffset += 2) + { + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref dest, rowOffset * DbRow.Size), + next); + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref dest, (rowOffset + 1) * DbRow.Size), + next + rowOffset); + } + + _next = next + rows; + return; + } + + // The real implementation would fall back to the per-row append + // loop here (chunk boundary or unrented chunk); the benchmark is + // sized so this cannot happen. + throw new InvalidOperationException( + "The template block does not fit into the benchmark chunk."); + } + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/EnumScalarRowReuseBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/EnumScalarRowReuseBenchmark.cs new file mode 100644 index 00000000000..b09e3c34698 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/EnumScalarRowReuseBenchmark.cs @@ -0,0 +1,380 @@ +using System; +using System.Text; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; +using HotChocolate.Buffers; +using HotChocolate.Fusion.Text.Json; +using HotChocolate.Fusion.Types; +using HotChocolate.Fusion.Types.Collections; + +namespace HotChocolate.Fusion.Execution.Benchmarks; + +/// +/// Isolates the MetaDb row-decode cost of enum and scalar list-element completion on the +/// FetchResultStore.SaveSafeResult -> ValueCompletion.BuildResult -> TryCompleteList hot path. +/// +/// The baseline replays the current per-element decode sequence +/// (ValueCompletion.cs line 979 element dispatch, lines 1058-1079 CompleteEnumValue, plus the +/// SetLeafValue-internal source.GetValueRow() at CompositeResultDocument.cs lines 514-516): +/// an enum-typed element decodes the same source row four times (ValueKind for the list +/// dispatch, ValueKind again inside CompleteEnumValue, ValueSpan, and GetValueRow inside +/// SetLeafValue) and a scalar element decodes it twice (ValueKind, then GetValueRow inside +/// SetLeafValue). +/// +/// The row-reuse variant decodes the row once via GetValueRow and derives the token type and +/// the raw value bytes from that row, standing in for passing the row through CompleteEnumValue +/// and into the existing SetLeafValue(source, row) overload (CompositeResultElement.cs +/// line 1045). The enum-membership check (FusionEnumValueCollection.ContainsName) runs +/// identically in both variants, so the variants differ only in row decode counts. The +/// selection.NamedType type test of CompleteEnumValue is factored out of both variants because +/// it never touches the source row. +/// +[MemoryDiagnoser] +[Config(typeof(BenchmarkConfig))] +public class EnumScalarRowReuseBenchmark +{ + /// + /// BenchmarkDotNet 0.15.8 has no RuntimeMoniker for the net11.0 preview host and + /// this project pins TargetFramework to net11.0, so out-of-process toolchains can + /// neither validate nor build a child process here. The job therefore runs in + /// process with the intended 3 warmup and 10 measurement iterations. + /// + private sealed class BenchmarkConfig : ManualConfig + { + public BenchmarkConfig() + => AddJob( + Job.Default + .WithWarmupCount(3) + .WithIterationCount(10) + .WithToolchain(InProcessEmitToolchain.Instance)); + } + + private const int ElementCount = 1000; + + private static readonly string[] s_memberNames = ["RED", "GREEN", "BLUE"]; + +#pragma warning disable IDE0370 // Remove unnecessary suppression + private MemoryArena _arena = null!; + private SourceResultDocument _document = null!; + private SourceResultElement[] _enumElements = null!; + private SourceResultElement[] _numberElements = null!; + private FusionEnumValueCollection _enumValues = null!; +#pragma warning restore IDE0370 // Remove unnecessary suppression + + [GlobalSetup] + public void Setup() + { + _arena = new MemoryArena(); + + var payload = new StringBuilder(); + payload.Append("{\"data\":{\"colors\":["); + + for (var i = 0; i < ElementCount; i++) + { + if (i > 0) + { + payload.Append(','); + } + + // Every 10th value is not a member of the composite enum so the masking + // branch of CompleteEnumValue (SetNullValue, no further row decode) is + // exercised as well. + var name = i % 10 == 9 ? "PURPLE" : s_memberNames[i % 3]; + payload.Append('"').Append(name).Append('"'); + } + + payload.Append("],\"numbers\":["); + + for (var i = 0; i < ElementCount; i++) + { + if (i > 0) + { + payload.Append(','); + } + + // 1 to 5 digit numbers so value lengths vary. + payload.Append(i * 37); + } + + payload.Append("]}}"); + + // The payload is far below the 128 KiB single-chunk limit, so the document is + // parsed in place over one chunk and every ValueSpan read stays allocation-free. + var json = Encoding.UTF8.GetBytes(payload.ToString()); + _document = SourceResultDocument.Parse(_arena, json, json.Length); + + var data = _document.Root.GetProperty("data"u8); + + _enumElements = MaterializeArrayElements(data.GetProperty("colors"u8)); + _numberElements = MaterializeArrayElements(data.GetProperty("numbers"u8)); + + _enumValues = new FusionEnumValueCollection( + [ + new FusionEnumValue("RED", null, false, null, false), + new FusionEnumValue("GREEN", null, false, null, false), + new FusionEnumValue("BLUE", null, false, null, false) + ]); + + VerifyVariantsProduceIdenticalResults(); + } + + [GlobalCleanup] + public void Cleanup() + { + _document.Dispose(); + _arena.Dispose(); + } + + /// + /// Current product decode sequence, using only real product internals. Per enum element + /// the source row is decoded four times, per number element twice. GetValueRow stands in + /// for target.SetLeafValue(source), whose AssignSourceValue overload performs exactly + /// that decode (CompositeResultDocument.cs lines 514-516) and then consumes the row's + /// TokenType, Location and SizeOrLength, which the checksum consumes here. + /// + [Benchmark(Baseline = true)] + public long CurrentDecodeSequence() + { + var checksum = 0L; + var enumElements = _enumElements; + var enumValues = _enumValues; + + for (var i = 0; i < enumElements.Length; i++) + { + var element = enumElements[i]; + + // TryCompleteList element dispatch, ValueCompletion.cs line 979: first row decode. + var elementValueKind = element.ValueKind; + + if (elementValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + checksum--; + continue; + } + + // CompleteEnumValue, ValueCompletion.cs lines 1069-1078: the ValueKind re-check + // is the second row decode and ValueSpan is the third (full MetaDb Get plus + // ReadRawValue). + if (element.ValueKind is JsonValueKind.String) + { + var valueSpan = element.ValueSpan; + + if (enumValues.ContainsName(valueSpan)) + { + // target.SetLeafValue(source), ValueCompletion.cs line 1073, performs the + // fourth decode through AssignSourceValue -> source.GetValueRow(). + var row = element.GetValueRow(); + checksum += (int)row.TokenType + row.Location + row.SizeOrLength + + valueSpan.Length + (int)elementValueKind; + } + else + { + // target.SetNullValue() does not decode the source row. + checksum--; + } + } + else + { + checksum--; + } + } + + var numberElements = _numberElements; + + for (var i = 0; i < numberElements.Length; i++) + { + var element = numberElements[i]; + + // TryCompleteList element dispatch, ValueCompletion.cs line 979: first row decode. + var elementValueKind = element.ValueKind; + + if (elementValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + checksum--; + continue; + } + + // Scalar case, ValueCompletion.cs line 1008: targetElement.SetLeafValue(element) + // performs the second decode through AssignSourceValue -> source.GetValueRow(). + var row = element.GetValueRow(); + checksum += (int)row.TokenType + row.Location + row.SizeOrLength + + (int)elementValueKind; + } + + return checksum; + } + + /// + /// Candidate optimization: one GetValueRow per element; the token type and the raw value + /// bytes derive from that row without touching the MetaDb again. Production shape: pass + /// the row into CompleteEnumValue and the existing SetLeafValue(source, row) overload. + /// + [Benchmark] + public long RowReuseSingleDecode() + { + var checksum = 0L; + var document = _document; + var enumElements = _enumElements; + var enumValues = _enumValues; + + for (var i = 0; i < enumElements.Length; i++) + { + var element = enumElements[i]; + + // Single row decode; everything below derives from this row. + var row = element.GetValueRow(); + var elementValueKind = row.TokenType.ToValueKind(); + + if (elementValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + checksum--; + continue; + } + + if (elementValueKind is JsonValueKind.String) + { + var valueSpan = ReadUnquotedRawValue(document, row); + + if (enumValues.ContainsName(valueSpan)) + { + // The row is already in hand, so SetLeafValue(source, row) + // (CompositeResultElement.cs line 1045) needs no further decode. + checksum += (int)row.TokenType + row.Location + row.SizeOrLength + + valueSpan.Length + (int)elementValueKind; + } + else + { + checksum--; + } + } + else + { + checksum--; + } + } + + var numberElements = _numberElements; + + for (var i = 0; i < numberElements.Length; i++) + { + var element = numberElements[i]; + + // Single row decode replacing both ValueKind and the SetLeafValue-internal read. + var row = element.GetValueRow(); + var elementValueKind = row.TokenType.ToValueKind(); + + if (elementValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + checksum--; + continue; + } + + checksum += (int)row.TokenType + row.Location + row.SizeOrLength + + (int)elementValueKind; + } + + return checksum; + } + + // Benchmark-local copy of the private SourceResultDocument.ReadRawValue(DbRow, bool) with + // includeQuotes: false, mirrored from + // src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.cs + // lines 365-378. Strings are stored quote-inclusive, so the unquoted value slices one byte + // in on each side; the chunk read is the internal ReadRawValue(int, int) that the private + // overload delegates to, so the returned bytes are identical to ValueSpan. + private static ReadOnlySpan ReadUnquotedRawValue( + SourceResultDocument document, + in SourceResultDocument.DbRow row) + { + if (row.TokenType is JsonTokenType.String or JsonTokenType.PropertyName) + { + return document.ReadRawValue(row.Location, row.SizeOrLength)[1..^1]; + } + + return document.ReadRawValue(row.Location, row.SizeOrLength); + } + + private static SourceResultElement[] MaterializeArrayElements(SourceResultElement array) + { + var elements = new SourceResultElement[array.GetArrayLength()]; + var i = 0; + + foreach (var element in array.EnumerateArray()) + { + elements[i++] = element; + } + + if (i != elements.Length) + { + throw new InvalidOperationException("Array materialization is incomplete."); + } + + return elements; + } + + private void VerifyVariantsProduceIdenticalResults() + { + var document = _document; + + for (var i = 0; i < _enumElements.Length; i++) + { + var element = _enumElements[i]; + + var baselineKind = element.ValueKind; + var baselineSpan = element.ValueSpan; + var baselineRow = element.GetValueRow(); + var baselineIsMember = _enumValues.ContainsName(baselineSpan); + + var row = element.GetValueRow(); + var optimizedKind = row.TokenType.ToValueKind(); + var optimizedSpan = ReadUnquotedRawValue(document, row); + var optimizedIsMember = _enumValues.ContainsName(optimizedSpan); + + if (baselineKind != optimizedKind + || !baselineSpan.SequenceEqual(optimizedSpan) + || baselineIsMember != optimizedIsMember + || baselineRow.TokenType != row.TokenType + || baselineRow.Location != row.Location + || baselineRow.SizeOrLength != row.SizeOrLength) + { + throw new InvalidOperationException( + $"Enum element {i} decodes differently between the baseline and the " + + "row-reuse variant."); + } + } + + for (var i = 0; i < _numberElements.Length; i++) + { + var element = _numberElements[i]; + + var baselineKind = element.ValueKind; + var baselineRow = element.GetValueRow(); + + var row = element.GetValueRow(); + var optimizedKind = row.TokenType.ToValueKind(); + + if (baselineKind != optimizedKind + || baselineRow.TokenType != row.TokenType + || baselineRow.Location != row.Location + || baselineRow.SizeOrLength != row.SizeOrLength) + { + throw new InvalidOperationException( + $"Number element {i} decodes differently between the baseline and the " + + "row-reuse variant."); + } + } + + var baselineChecksum = CurrentDecodeSequence(); + var optimizedChecksum = RowReuseSingleDecode(); + + if (baselineChecksum != optimizedChecksum) + { + throw new InvalidOperationException( + $"Checksum mismatch: baseline {baselineChecksum} vs " + + $"row-reuse {optimizedChecksum}."); + } + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/MetaDbGetBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/MetaDbGetBenchmark.cs new file mode 100644 index 00000000000..97a307d2deb --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/MetaDbGetBenchmark.cs @@ -0,0 +1,380 @@ +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; +using HotChocolate.Buffers; +using HotChocolate.Fusion.Text.Json; + +namespace HotChocolate.Fusion.Execution.Benchmarks; + +/// +/// Compares the current SourceResultDocument.MetaDb.Get and +/// GetJsonTokenType read path (chunk table indexing plus span slice plus +/// MemoryMarshal.Read) against a candidate that reads the same chunked row +/// layout through MemoryMarshal.GetArrayDataReference and +/// Unsafe.ReadUnaligned, the pattern the class already uses for writes. +/// +/// Each invocation decodes every MetaDb row of a realistic parsed subgraph payload +/// (150 product objects with 8 scalar properties each, about 2,700 rows spanning +/// six chunks of the geometric chunk schedule), performing one full row read and +/// one token-type read per row, which mirrors the read mix of the +/// BuildResult/TryComplete recursion. +/// +/// The baseline calls the real product methods on a real MetaDb instance. +/// The parsed document keeps its own MetaDb in a private field, so the baseline +/// instance is populated by replaying the parsed rows through the real +/// MetaDb.Append, which writes bit-identical row bytes. The benchmark-local +/// variants (faithful copy and optimized candidate) read a replica chunk table +/// built with the exact same chunk schedule and row bytes; GlobalSetup verifies +/// every row decoded by every variant against the parsed document. +/// +[MemoryDiagnoser] +[Config(typeof(BenchmarkConfig))] +public class MetaDbGetBenchmark +{ + /// + /// BenchmarkDotNet 0.15.8 has no RuntimeMoniker for the net11.0 preview host and + /// this project pins TargetFramework to net11.0, so out-of-process toolchains can + /// neither validate nor build a child process here. The job therefore runs in + /// process with the intended 3 warmup and 10 measurement iterations. + /// + private sealed class BenchmarkConfig : ManualConfig + { + public BenchmarkConfig() + => AddJob( + Job.Default + .WithWarmupCount(3) + .WithIterationCount(10) + .WithToolchain(InProcessEmitToolchain.Instance)); + } + + private const int ItemCount = 150; + +#pragma warning disable IDE0370 // Remove unnecessary suppression + private MemoryArena _arena = null!; + private SourceResultDocument _document = null!; + private SourceResultDocument.MetaDb _metaDb; + private MemorySegment[] _chunks = null!; + private SourceResultDocument.Cursor[] _cursors = null!; + private SourceResultDocument.DbRow[] _expectedRows = null!; +#pragma warning restore IDE0370 // Remove unnecessary suppression + + public long Consumed; + + [GlobalSetup] + public void Setup() + { + _arena = new MemoryArena(); + + var payload = CreatePayload(); + _document = SourceResultDocument.Parse(_arena, payload, payload.Length); + + // The root StartObject row spans the whole document, so its row count is the + // total number of MetaDb rows including the trailing EndObject row. + var totalRows = _document.GetDbRow(SourceResultDocument.Cursor.Zero).NumberOfRows; + + _cursors = new SourceResultDocument.Cursor[totalRows]; + _expectedRows = new SourceResultDocument.DbRow[totalRows]; + + var cursor = SourceResultDocument.Cursor.Zero; + + for (var i = 0; i < totalRows; i++) + { + _cursors[i] = cursor; + _expectedRows[i] = _document.GetDbRow(cursor); + cursor += 1; + } + + // The parsed document holds its MetaDb in the private _parsedData field with no + // internal accessor, so the baseline replays the parsed rows into a MetaDb owned + // by this benchmark. Append constructs and writes the exact DbRow bytes + // (SourceResultDocument.MetaDb.cs lines 99-113), and the DbRow constructor + // round-trips (TokenType, Location, SizeOrLength, NumberOfRows, HasComplexChildren) + // bit-identically, which Verify() checks row by row. + _metaDb = SourceResultDocument.MetaDb.CreateForEstimatedRows(_arena, totalRows); + + for (var i = 0; i < totalRows; i++) + { + var row = _expectedRows[i]; + _metaDb.Append( + row.TokenType, + row.Location, + row.SizeOrLength, + row.NumberOfRows, + row.HasComplexChildren); + } + + // Replica of the MetaDb chunk table for the benchmark-local Get variants. The + // chunk sizes follow the exact schedule MetaDb uses when renting chunks + // (SourceResultDocument.MetaDb.cs lines 55 and 168) and the rows are written + // exactly as Append writes them (lines 108-110). + var chunkCount = _cursors[totalRows - 1].Chunk + 1; + _chunks = new MemorySegment[chunkCount]; + + for (var i = 0; i < chunkCount; i++) + { + _chunks[i] = _arena.Rent(1 << (10 + (int)SourceResultDocument.Cursor.ChunkSizeFor(i))); + } + + for (var i = 0; i < totalRows; i++) + { + var target = _cursors[i]; + var chunk = _chunks[target.Chunk]; + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk.Buffer); + Unsafe.WriteUnaligned( + ref Unsafe.Add(ref dest, chunk.Offset + target.ByteOffset), + _expectedRows[i]); + } + + Verify(); + } + + [GlobalCleanup] + public void Cleanup() + { + _document.Dispose(); + _metaDb.Dispose(); + _arena.Dispose(); + } + + /// + /// Current product behavior: the real MetaDb.Get and + /// MetaDb.GetJsonTokenType called through internals on a real MetaDb. + /// + [Benchmark(Baseline = true)] + public long GetRows_Product() + { + var cursors = _cursors; + var sum = 0L; + + for (var i = 0; i < cursors.Length; i++) + { + var cursor = cursors[i]; + var row = _metaDb.Get(cursor); + sum += row.Location + row.SizeOrLength + row.NumberOfRows + (int)row.TokenType; + sum += (int)_metaDb.GetJsonTokenType(cursor); + } + + Consumed = sum; + return sum; + } + + /// + /// Byte-faithful benchmark-local copy of the current read path running over the + /// replica chunk table. Its delta against the optimized variant isolates the + /// candidate on identical memory; its delta against the product baseline shows + /// the replica is equivalent. + /// + [Benchmark] + public long GetRows_SpanReadCopy() + { + var cursors = _cursors; + var chunks = _chunks; + var sum = 0L; + + for (var i = 0; i < cursors.Length; i++) + { + var cursor = cursors[i]; + var row = GetRowSpanRead(chunks, cursor); + sum += row.Location + row.SizeOrLength + row.NumberOfRows + (int)row.TokenType; + sum += (int)GetJsonTokenTypeSpanRead(chunks, cursor); + } + + Consumed = sum; + return sum; + } + + /// + /// Candidate optimization: chunk table and chunk buffer accessed through + /// MemoryMarshal.GetArrayDataReference plus Unsafe.ReadUnaligned, + /// removing the bounds checks, the span construction, and the second length + /// guard of MemoryMarshal.Read. + /// + [Benchmark] + public long GetRows_ArrayDataRefReadUnaligned() + { + var cursors = _cursors; + var chunks = _chunks; + var sum = 0L; + + for (var i = 0; i < cursors.Length; i++) + { + var cursor = cursors[i]; + var row = GetRowArrayDataRef(chunks, cursor); + sum += row.Location + row.SizeOrLength + row.NumberOfRows + (int)row.TokenType; + sum += (int)GetJsonTokenTypeArrayDataRef(chunks, cursor); + } + + Consumed = sum; + return sum; + } + + // Byte-faithful copy of SourceResultDocument.MetaDb.Get, + // src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.MetaDb.cs + // lines 183-192. AssertValidCursor is [Conditional("DEBUG")] and compiles away in the + // release builds benchmarks run under, so it is omitted here. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static SourceResultDocument.DbRow GetRowSpanRead( + MemorySegment[] chunks, + SourceResultDocument.Cursor cursor) + { + var chunk = chunks[cursor.Chunk]; + var dataPos = chunk.Buffer.AsSpan(chunk.Offset + cursor.ByteOffset); + + return MemoryMarshal.Read(dataPos); + } + + // Byte-faithful copy of SourceResultDocument.MetaDb.GetJsonTokenType, + // src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.MetaDb.cs + // lines 194-205 (same DEBUG-only assert note as above). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static JsonTokenType GetJsonTokenTypeSpanRead( + MemorySegment[] chunks, + SourceResultDocument.Cursor cursor) + { + // _numberOfRowsAndTypeUnion is the third int in the row. + var chunk = chunks[cursor.Chunk]; + var dataPos = chunk.Buffer.AsSpan(chunk.Offset + cursor.ByteOffset + 8); + + var union = MemoryMarshal.Read(dataPos); + return (JsonTokenType)(union >> 28); + } + + // Optimized variant of MetaDb.Get: the pattern MetaDb.Append (lines 109-110) and + // SourceResultDocument.ReadRawValue (lines 390-400) already use on their hot paths. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static SourceResultDocument.DbRow GetRowArrayDataRef( + MemorySegment[] chunks, + SourceResultDocument.Cursor cursor) + { + ref readonly var chunk = ref Unsafe.Add( + ref MemoryMarshal.GetArrayDataReference(chunks), + cursor.Chunk); + + return Unsafe.ReadUnaligned( + ref Unsafe.Add( + ref MemoryMarshal.GetArrayDataReference(chunk.Buffer), + chunk.Offset + cursor.ByteOffset)); + } + + // Optimized variant of MetaDb.GetJsonTokenType: reads only the third int of the row. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static JsonTokenType GetJsonTokenTypeArrayDataRef( + MemorySegment[] chunks, + SourceResultDocument.Cursor cursor) + { + ref readonly var chunk = ref Unsafe.Add( + ref MemoryMarshal.GetArrayDataReference(chunks), + cursor.Chunk); + + var union = Unsafe.ReadUnaligned( + ref Unsafe.Add( + ref MemoryMarshal.GetArrayDataReference(chunk.Buffer), + chunk.Offset + cursor.ByteOffset + 8)); + + return (JsonTokenType)(union >> 28); + } + + private void Verify() + { + for (var i = 0; i < _cursors.Length; i++) + { + var cursor = _cursors[i]; + var expected = _expectedRows[i]; + + AssertRowEqual(expected, _metaDb.Get(cursor), i, "product MetaDb.Get"); + AssertRowEqual(expected, GetRowSpanRead(_chunks, cursor), i, "span-read copy Get"); + AssertRowEqual(expected, GetRowArrayDataRef(_chunks, cursor), i, "optimized Get"); + + var productToken = _metaDb.GetJsonTokenType(cursor); + var copyToken = GetJsonTokenTypeSpanRead(_chunks, cursor); + var optimizedToken = GetJsonTokenTypeArrayDataRef(_chunks, cursor); + + if (productToken != expected.TokenType + || copyToken != expected.TokenType + || optimizedToken != expected.TokenType) + { + throw new InvalidOperationException( + $"Token type mismatch at row {i}: expected {expected.TokenType}, " + + $"product {productToken}, copy {copyToken}, optimized {optimizedToken}."); + } + } + + var productSum = GetRows_Product(); + var copySum = GetRows_SpanReadCopy(); + var optimizedSum = GetRows_ArrayDataRefReadUnaligned(); + + if (productSum != copySum || productSum != optimizedSum) + { + throw new InvalidOperationException( + $"Checksum mismatch: product {productSum}, copy {copySum}, " + + $"optimized {optimizedSum}."); + } + } + + private static void AssertRowEqual( + in SourceResultDocument.DbRow expected, + in SourceResultDocument.DbRow actual, + int rowIndex, + string variant) + { + if (expected.TokenType != actual.TokenType + || expected.Location != actual.Location + || expected.SizeOrLength != actual.SizeOrLength + || expected.NumberOfRows != actual.NumberOfRows + || expected.HasComplexChildren != actual.HasComplexChildren) + { + throw new InvalidOperationException( + $"Row {rowIndex} mismatch in {variant}: expected " + + $"({expected.TokenType}, {expected.Location}, {expected.SizeOrLength}, " + + $"{expected.NumberOfRows}, {expected.HasComplexChildren}) but got " + + $"({actual.TokenType}, {actual.Location}, {actual.SizeOrLength}, " + + $"{actual.NumberOfRows}, {actual.HasComplexChildren})."); + } + } + + private static byte[] CreatePayload() + { + var json = new StringBuilder(32 * 1024); + json.Append("{\"data\":{\"products\":{\"nodes\":["); + + for (var i = 0; i < ItemCount; i++) + { + if (i > 0) + { + json.Append(','); + } + + json.Append("{\"id\":\"prod-").Append(i).Append('"'); + json.Append(",\"name\":\"Product ").Append(i).Append('"'); + json.Append(",\"price\":").Append(i).Append(".99"); + json.Append(",\"inStock\":").Append(i % 3 == 0 ? "false" : "true"); + json.Append(",\"quantity\":").Append(i % 50); + json.Append(",\"rating\":4.5"); + + if (i % 7 == 0) + { + json.Append(",\"description\":null"); + } + else if (i % 10 == 0) + { + // Escaped content marks the string row with HasComplexChildren. + json.Append(",\"description\":\"A \\\"quoted\\\" description\""); + } + else + { + json.Append(",\"description\":\"Plain description for item ").Append(i).Append('"'); + } + + json.Append(",\"sku\":\"SKU-").Append(i).Append("\"}"); + } + + json.Append("]}}}"); + return Encoding.UTF8.GetBytes(json.ToString()); + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ResultSelectionSetLookupBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ResultSelectionSetLookupBenchmark.cs new file mode 100644 index 00000000000..576040fde83 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/ResultSelectionSetLookupBenchmark.cs @@ -0,0 +1,566 @@ +using System; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Language; +using HotChocolate.Types; + +#nullable enable + +namespace HotChocolate.Fusion.Execution.Benchmarks; + +/// +/// Measures and its type-filtered +/// overload against a benchmark-local flattened variant. +/// +/// The product implementation splits direct-child lookup across an abstract base +/// (ResultSelectionSet) and two subclasses (SmallResultSelectionSet with a linear scan, +/// LargeResultSelectionSet with a dictionary), so every lookup pays a virtual dispatch +/// on TryGetDirectChild and always enters the fragment loop, even for fragment-free sets. +/// The flattened variant is a single sealed class holding both the selections array and +/// the optional dictionary, with a fragments.Length == 0 early exit before the fragment +/// loop. Same lookup semantics for every input. +/// +/// The workload is 1,000 lookups per invocation cycling through a fixed probe list that +/// mixes direct hits, hits inside inline fragments, null-child hits, and misses across a +/// small set (4 fields, linear scan), a large set (12 fields, dictionary), and a set with +/// 2 type-conditioned inline fragments. +/// +[MemoryDiagnoser] +[Config(typeof(BenchmarkConfig))] +public class ResultSelectionSetLookupBenchmark : FusionBenchmarkBase +{ + /// + /// BenchmarkDotNet 0.15.8 has no RuntimeMoniker for the net11.0 preview host and + /// this project pins TargetFramework to net11.0, so out-of-process toolchains can + /// neither validate nor build a child process here. The job therefore runs in + /// process with the intended 3 warmup and 10 measurement iterations. + /// + private sealed class BenchmarkConfig : ManualConfig + { + public BenchmarkConfig() + => AddJob( + Job.Default + .WithWarmupCount(3) + .WithIterationCount(10) + .WithToolchain(InProcessEmitToolchain.Instance)); + } + + private const int ProbeCount = 1000; + + // 4 direct fields, no fragments. Produces a SmallResultSelectionSet (linear scan). + private const string SmallSource = + """ + { + id + name + price + dimension { height width } + } + """; + + // 12 direct fields, no fragments. Produces a LargeResultSelectionSet (dictionary). + private const string LargeSource = + """ + { + id + name + description + price + averageRating + title + content + publishedAt + tags + dimension { height width } + author { id displayName } + reviews { nodes { id body stars } } + } + """; + + // 3 direct fields plus 2 inline fragments with type conditions resolved + // against the composed fusion schema. + private const string FragmentSource = + """ + { + id + title + description + ... on Product { + name + price + dimension { height width } + } + ... on Article { + content + publishedAt + author { id displayName } + } + } + """; + + // Expected number of non-null results across one pass over the distinct probe + // list below. Guards against probe typos silently turning the workload into + // an all-miss loop. + private const int ExpectedDistinctProbeHits = 9; + + private ResultSelectionSet[] _productSets = null!; + private FlatResultSelectionSet[] _flatSets = null!; + private string[] _probeNames = null!; + private IComplexTypeDefinition?[] _probeTypes = null!; + + [GlobalSetup] + public void Setup() + { + var schema = CreateFusionSchema(); + + var smallNode = Utf8GraphQLParser.Syntax.ParseSelectionSet(SmallSource); + var largeNode = Utf8GraphQLParser.Syntax.ParseSelectionSet(LargeSource); + var fragmentNode = Utf8GraphQLParser.Syntax.ParseSelectionSet(FragmentSource); + + var smallProduct = ResultSelectionSet.Create(smallNode, schema); + var largeProduct = ResultSelectionSet.Create(largeNode, schema); + var fragmentProduct = ResultSelectionSet.Create(fragmentNode, schema); + + // The candidate claim is linear-scan vs dictionary behavior per shape, + // so the shapes must keep mapping to the expected product classes. + if (smallProduct is not SmallResultSelectionSet) + { + throw new InvalidOperationException( + "The 4-field shape no longer produces a SmallResultSelectionSet."); + } + + if (largeProduct is not LargeResultSelectionSet) + { + throw new InvalidOperationException( + "The 12-field shape no longer produces a LargeResultSelectionSet."); + } + + var smallFlat = FlatResultSelectionSet.Create(smallNode, schema); + var largeFlat = FlatResultSelectionSet.Create(largeNode, schema); + var fragmentFlat = FlatResultSelectionSet.Create(fragmentNode, schema); + + if (!schema.Types.TryGetType("Product", out var productType)) + { + throw new InvalidOperationException("Product type not found in the composed schema."); + } + + if (!schema.Types.TryGetType("Article", out var articleType)) + { + throw new InvalidOperationException("Article type not found in the composed schema."); + } + + var productSets = new List(); + var flatSets = new List(); + var names = new List(); + var types = new List(); + + void Add( + ResultSelectionSet productSet, + FlatResultSelectionSet flatSet, + string name, + IComplexTypeDefinition? type) + { + productSets.Add(productSet); + flatSets.Add(flatSet); + names.Add(name); + types.Add(type); + } + + // Small set: non-null hit, two null-child hits, miss. + Add(smallProduct, smallFlat, "dimension", null); + Add(smallProduct, smallFlat, "id", null); + Add(smallProduct, smallFlat, "name", null); + Add(smallProduct, smallFlat, "missing", null); + + // Large set: dictionary hits (non-null and null-child) and a miss. + Add(largeProduct, largeFlat, "dimension", null); + Add(largeProduct, largeFlat, "author", null); + Add(largeProduct, largeFlat, "reviews", null); + Add(largeProduct, largeFlat, "id", null); + Add(largeProduct, largeFlat, "tags", null); + Add(largeProduct, largeFlat, "missing", null); + + // Fragment set, type-unaware overload: direct null-child hits, hits that + // resolve inside the first and second fragment, a leaf-in-fragment probe + // (behaves like a miss because fragment hits only count with a non-null + // child), and a full miss. + Add(fragmentProduct, fragmentFlat, "id", null); + Add(fragmentProduct, fragmentFlat, "description", null); + Add(fragmentProduct, fragmentFlat, "dimension", null); + Add(fragmentProduct, fragmentFlat, "author", null); + Add(fragmentProduct, fragmentFlat, "name", null); + Add(fragmentProduct, fragmentFlat, "missing", null); + + // Fragment set, type-filtered overload: matching and non-matching type + // conditions, plus a typed miss. + Add(fragmentProduct, fragmentFlat, "dimension", productType); + Add(fragmentProduct, fragmentFlat, "dimension", articleType); + Add(fragmentProduct, fragmentFlat, "author", articleType); + Add(fragmentProduct, fragmentFlat, "missing", productType); + + // Type-filtered lookups on fragment-free sets: this is where the + // fragments.Length == 0 early exit applies. + Add(smallProduct, smallFlat, "dimension", productType); + Add(largeProduct, largeFlat, "missing", productType); + + VerifyEquivalence(productSets, flatSets, names, types); + + _productSets = new ResultSelectionSet[ProbeCount]; + _flatSets = new FlatResultSelectionSet[ProbeCount]; + _probeNames = new string[ProbeCount]; + _probeTypes = new IComplexTypeDefinition?[ProbeCount]; + + for (var i = 0; i < ProbeCount; i++) + { + var j = i % names.Count; + _productSets[i] = productSets[j]; + _flatSets[i] = flatSets[j]; + _probeNames[i] = names[j]; + _probeTypes[i] = types[j]; + } + } + + private static void VerifyEquivalence( + List productSets, + List flatSets, + List names, + List types) + { + var hits = 0; + + for (var i = 0; i < names.Count; i++) + { + var name = names[i]; + var type = types[i]; + + var expected = type is null + ? productSets[i].TryGetChild(name) + : productSets[i].TryGetChild(name, type); + + var actual = type is null + ? flatSets[i].TryGetChild(name) + : flatSets[i].TryGetChild(name, type); + + if ((expected is null) != (actual is null)) + { + throw new InvalidOperationException( + $"Probe '{name}' (type: {type?.Name ?? "none"}): product returned " + + $"{(expected is null ? "null" : "a child")} but the flattened variant " + + $"returned {(actual is null ? "null" : "a child")}."); + } + + if (expected is not null && actual is not null) + { + var expectedSyntax = expected.ToSelectionSetNode().ToString(indented: false); + var actualSyntax = actual.ToSelectionSetNode().ToString(indented: false); + + if (!string.Equals(expectedSyntax, actualSyntax, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Probe '{name}' (type: {type?.Name ?? "none"}): child selection sets " + + $"differ.\nProduct: {expectedSyntax}\nFlattened: {actualSyntax}"); + } + + hits++; + } + } + + if (hits != ExpectedDistinctProbeHits) + { + throw new InvalidOperationException( + $"Expected {ExpectedDistinctProbeHits} non-null probe results " + + $"but observed {hits}. The probe list no longer matches the shapes."); + } + } + + /// + /// Current product behavior: TryGetChild on the abstract ResultSelectionSet + /// dispatches virtually to Small/LargeResultSelectionSet.TryGetDirectChild + /// and always runs the fragment loop on a direct miss. + /// + [Benchmark(Baseline = true)] + public int TwoClassVirtual_TryGetChild() + { + var sets = _productSets; + var names = _probeNames; + var types = _probeTypes; + var hits = 0; + + for (var i = 0; i < names.Length; i++) + { + var child = types[i] is { } objectType + ? sets[i].TryGetChild(names[i], objectType) + : sets[i].TryGetChild(names[i]); + + if (child is not null) + { + hits++; + } + } + + return hits; + } + + /// + /// Candidate optimization: single sealed class, non-virtual inlined scan or + /// dictionary lookup, fragments.Length == 0 early exit before the fragment loop. + /// + [Benchmark] + public int FlattenedSealed_TryGetChild() + { + var sets = _flatSets; + var names = _probeNames; + var types = _probeTypes; + var hits = 0; + + for (var i = 0; i < names.Length; i++) + { + var child = types[i] is { } objectType + ? sets[i].TryGetChild(names[i], objectType) + : sets[i].TryGetChild(names[i]); + + if (child is not null) + { + hits++; + } + } + + return hits; + } + + /// + /// Represents a direct field selection in the flattened variant. Mirrors + /// ResultSelection.cs lines 6-10. + /// + private readonly struct FlatSelection(string responseName, FlatResultSelectionSet? child) + { + public string ResponseName { get; } = responseName; + public FlatResultSelectionSet? Child { get; } = child; + } + + /// + /// Represents an inline fragment in the flattened variant. Mirrors + /// ResultFragment.cs lines 8-12. + /// + private readonly struct FlatFragment(ITypeDefinition? typeCondition, FlatResultSelectionSet body) + { + public ITypeDefinition? TypeCondition { get; } = typeCondition; + public FlatResultSelectionSet Body { get; } = body; + } + + /// + /// Benchmark-local flattened variant of the candidate optimization. It merges + /// ResultSelectionSet.cs lines 51-67 and 104-138 (TryGetChild and the fragment + /// walks), SmallResultSelectionSet.cs lines 16-29 (linear scan), and + /// LargeResultSelectionSet.cs lines 20-31 (dictionary lookup) into one sealed + /// class with no virtual TryGetDirectChild and an early fragments.Length == 0 + /// exit. Lookup semantics are byte-identical to the product code, including the + /// rule that a direct hit with a null child short-circuits the fragment walk and + /// that fragment hits only count when the resolved child is non-null. + /// + private sealed class FlatResultSelectionSet + { + // Mirrors ResultSelectionSet.SmallThreshold (ResultSelectionSet.cs line 18) + // and the Create split at lines 316-329. + private const int SmallThreshold = 8; + + private readonly FlatSelection[] _selections; + private readonly FlatFragment[] _fragments; + private readonly Dictionary? _childLookup; + + private FlatResultSelectionSet(FlatSelection[] selections, FlatFragment[] fragments) + { + _selections = selections; + _fragments = fragments; + + if (selections.Length >= SmallThreshold) + { + var lookup = new Dictionary( + selections.Length, + StringComparer.Ordinal); + + for (var i = 0; i < selections.Length; i++) + { + lookup[selections[i].ResponseName] = selections[i].Child; + } + + _childLookup = lookup; + } + } + + public FlatResultSelectionSet? TryGetChild(string responseName) + { + if (_childLookup is null) + { + var selections = _selections; + + for (var i = 0; i < selections.Length; i++) + { + if (string.Equals( + selections[i].ResponseName, + responseName, + StringComparison.Ordinal)) + { + return selections[i].Child; + } + } + } + else if (_childLookup.TryGetValue(responseName, out var child)) + { + return child; + } + + var fragments = _fragments; + + if (fragments.Length == 0) + { + return null; + } + + for (var i = 0; i < fragments.Length; i++) + { + if (fragments[i].Body.TryGetChild(responseName) is { } result) + { + return result; + } + } + + return null; + } + + public FlatResultSelectionSet? TryGetChild( + string responseName, + IComplexTypeDefinition objectType) + { + if (_childLookup is null) + { + var selections = _selections; + + for (var i = 0; i < selections.Length; i++) + { + if (string.Equals( + selections[i].ResponseName, + responseName, + StringComparison.Ordinal)) + { + return selections[i].Child; + } + } + } + else if (_childLookup.TryGetValue(responseName, out var child)) + { + return child; + } + + var fragments = _fragments; + + if (fragments.Length == 0) + { + return null; + } + + for (var i = 0; i < fragments.Length; i++) + { + ref readonly var fragment = ref fragments[i]; + + if (fragment.TypeCondition?.IsAssignableFrom(objectType) == false) + { + continue; + } + + var result = fragment.Body.TryGetChild(responseName, objectType); + + if (result is not null) + { + return result; + } + } + + return null; + } + + /// + /// Builds the flattened set from the same AST the product Create consumes. + /// Mirrors the lookup-relevant parts of ResultSelectionSet.Create + /// (ResultSelectionSet.cs lines 232-330): response name from alias or name, + /// recursive child sets, and fragment type conditions resolved through the + /// schema. Source alias mappings and opaque-element marking are omitted + /// because they do not participate in TryGetChild. + /// + public static FlatResultSelectionSet Create( + SelectionSetNode selectionSet, + ISchemaDefinition? schema) + { + var selections = new List(); + var fragments = new List(); + + foreach (var selection in selectionSet.Selections) + { + switch (selection) + { + case FieldNode field: + var name = field.Alias?.Value ?? field.Name.Value; + + FlatResultSelectionSet? child = null; + if (field.SelectionSet is { } childSet) + { + child = Create(childSet, schema); + } + + selections.Add(new FlatSelection(name, child)); + break; + + case InlineFragmentNode inlineFragment: + ITypeDefinition? typeCondition = null; + if (inlineFragment.TypeCondition is not null) + { + schema?.Types.TryGetType( + inlineFragment.TypeCondition.Name.Value, + out typeCondition); + } + + var body = Create(inlineFragment.SelectionSet, schema); + fragments.Add(new FlatFragment(typeCondition, body)); + break; + } + } + + return new FlatResultSelectionSet(selections.ToArray(), fragments.ToArray()); + } + + /// + /// Reconstructs the selection set syntax for the GlobalSetup equivalence + /// check. Mirrors ResultSelectionSet.ToSelectionSetNode (ResultSelectionSet.cs + /// lines 143-196) without the source response name mapping branch, which is + /// never active here because the sets are built without source aliases. + /// + public SelectionSetNode ToSelectionSetNode() + { + var selections = new List(); + + foreach (var selection in _selections) + { + selections.Add(new FieldNode( + selection.ResponseName, + selection.Child?.ToSelectionSetNode())); + } + + foreach (var fragment in _fragments) + { + selections.Add(new InlineFragmentNode( + null, + fragment.TypeCondition is not null + ? new NamedTypeNode(fragment.TypeCondition.Name) + : null, + [], + fragment.Body.ToSelectionSetNode())); + } + + return new SelectionSetNode(selections); + } + } +} diff --git a/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/SourcePathTranscodeHoistBenchmark.cs b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/SourcePathTranscodeHoistBenchmark.cs new file mode 100644 index 00000000000..8a8718d0273 --- /dev/null +++ b/src/HotChocolate/Fusion/benchmarks/Fusion.Execution.Benchmarks/SourcePathTranscodeHoistBenchmark.cs @@ -0,0 +1,292 @@ +using System; +using System.Text; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; +using HotChocolate.Buffers; +using HotChocolate.Fusion.Text.Json; + +#nullable enable + +namespace HotChocolate.Fusion.Execution.Benchmarks; + +/// +/// Measures the per-result source-path walk of FetchResultStore.GetDataElement +/// (FetchResultStore.cs lines 1969-2023). Today every field segment calls the string +/// overload of SourceResultElement.TryGetProperty, which lands in +/// SourceResultDocument.TryGetNamedPropertyValue(ReadOnlySpan<char>) +/// (SourceResultDocument.TryGetProperty.cs lines 10-97): per call it pays +/// Encoding.GetMaxByteCount, a zeroed 256-byte stackalloc (no [SkipLocalsInit]), +/// and Encoding.GetBytes before the UTF-8 name scan. In the batch merge loops +/// (FetchResultStore.cs lines 127-140 and 239-242) the same plan-static +/// sourcePath is re-walked once per result, so the identical transcode repeats +/// N results times path depth per merge. +/// +/// The candidate transcodes each segment name to UTF-8 once per merge and walks every +/// result with the ReadOnlySpan<byte> overload. Both overload families +/// converge on TryGetNamedPropertyValueCore, so the resolved elements are +/// identical; the byte overload skips only the per-call transcode preamble (the Core +/// scan itself still zeroes its own 256-byte unescape buffer in both variants). +/// +/// The workload parses 500 independent instances, +/// one per simulated SourceSchemaResult, rather than one document with a +/// 500-element array: in the real batch loop each result carries its own document and +/// GetDataElement starts at that document's own root, so per-result documents +/// reproduce the real walk (root object row scan per document) exactly. Each document +/// is shaped like {"data":{"productById":{...,"reviews":[...]}}} and the walked path +/// is data -> productById -> reviews (3 field segments). +/// +[MemoryDiagnoser] +[Config(typeof(BenchmarkConfig))] +public class SourcePathTranscodeHoistBenchmark +{ + /// + /// BenchmarkDotNet 0.15.8 has no RuntimeMoniker for the net11.0 preview host and + /// this project pins TargetFramework to net11.0, so out-of-process toolchains can + /// neither validate nor build a child process here. The job therefore runs in + /// process with the intended 3 warmup and 10 measurement iterations. + /// + private sealed class BenchmarkConfig : ManualConfig + { + public BenchmarkConfig() + => AddJob( + Job.Default + .WithWarmupCount(3) + .WithIterationCount(10) + .WithToolchain(InProcessEmitToolchain.Instance)); + } + + private const int ResultCount = 500; + private const int SegmentCount = 3; + + // data(4) + productById(11) + reviews(7) = 22 UTF-8 bytes; 64 leaves headroom + // and stays a trivial stack cost. Setup verifies the names actually fit. + private const int Utf8BufferSize = 64; + + // The plan-static source path GetDataElement walks per result. In the product + // these are SelectionPathSegment.Name values of Field segments. + private static readonly string[] s_segmentNames = ["data", "productById", "reviews"]; + + private MemoryArena _arena = null!; + private SourceResultDocument[] _documents = null!; + + public long Consumed; + + [GlobalSetup] + public void Setup() + { + _arena = new MemoryArena(); + _documents = new SourceResultDocument[ResultCount]; + + for (var i = 0; i < ResultCount; i++) + { + var payload = BuildPayload(i); + _documents[i] = SourceResultDocument.Parse(_arena, payload, payload.Length); + } + + VerifyEquivalence(); + } + + [GlobalCleanup] + public void Cleanup() + { + foreach (var document in _documents) + { + document.Dispose(); + } + + _arena.Dispose(); + } + + /// + /// Current product behavior: per result, every path segment goes through the + /// string overload of TryGetProperty, exactly like GetDataElement line 1993, + /// paying the transcode preamble N results x depth times per merge. + /// + [Benchmark(Baseline = true)] + public long Baseline_StringPerResult() + { + var documents = _documents; + var segmentNames = s_segmentNames; + var sum = 0L; + + for (var i = 0; i < documents.Length; i++) + { + var element = WalkString(documents[i].Root, segmentNames); + sum += element.GetArrayLength(); + } + + Consumed = sum; + return sum; + } + + /// + /// Candidate: the segment names are transcoded to UTF-8 once per merge into a + /// stack buffer (the product would use a stack or pooled buffer at the top of + /// AddPartialResults), then every result is walked with the real + /// ReadOnlySpan<byte> overload of TryGetProperty. + /// + [Benchmark] + public long Hoisted_Utf8OncePerMerge() + { + var documents = _documents; + var segmentNames = s_segmentNames; + + // Once per merge: transcode each segment name, recording exclusive end + // offsets so segment s spans utf8Names[ends[s - 1]..ends[s]]. + Span utf8Names = stackalloc byte[Utf8BufferSize]; + Span ends = stackalloc int[SegmentCount]; + var offset = 0; + + for (var s = 0; s < segmentNames.Length; s++) + { + offset += Encoding.UTF8.GetBytes(segmentNames[s], utf8Names[offset..]); + ends[s] = offset; + } + + var sum = 0L; + + for (var i = 0; i < documents.Length; i++) + { + var element = WalkUtf8(documents[i].Root, utf8Names, ends); + sum += element.GetArrayLength(); + } + + Consumed = sum; + return sum; + } + + /// + /// Byte-faithful copy of the field-segment loop of GetDataElement + /// (FetchResultStore.cs lines 1976-1998 and 2022): the not-an-object guard with + /// null propagation, then TryGetProperty with the segment name string. The + /// InlineFragment branch is out of scope here; the walked path has only field + /// segments, matching the common entity fan-out source path. + /// + private static SourceResultElement WalkString(SourceResultElement data, string[] segmentNames) + { + var current = data; + + for (var i = 0; i < segmentNames.Length; i++) + { + if (current.ValueKind != JsonValueKind.Object) + { + return current.ValueKind is JsonValueKind.Null ? current : default; + } + + if (!current.TryGetProperty(segmentNames[i], out current)) + { + return default; + } + } + + return current; + } + + /// + /// Identical walk with the pre-transcoded names: the only difference is that + /// TryGetProperty receives ReadOnlySpan<byte>, entering + /// TryGetNamedPropertyValueCore directly without the per-call transcode. + /// + private static SourceResultElement WalkUtf8( + SourceResultElement data, + ReadOnlySpan utf8Names, + ReadOnlySpan ends) + { + var current = data; + var start = 0; + + for (var i = 0; i < ends.Length; i++) + { + if (current.ValueKind != JsonValueKind.Object) + { + return current.ValueKind is JsonValueKind.Null ? current : default; + } + + if (!current.TryGetProperty(utf8Names[start..ends[i]], out current)) + { + return default; + } + + start = ends[i]; + } + + return current; + } + + private void VerifyEquivalence() + { + var utf8Names = new byte[Utf8BufferSize]; + var ends = new int[SegmentCount]; + var offset = 0; + + for (var s = 0; s < s_segmentNames.Length; s++) + { + var count = Encoding.UTF8.GetByteCount(s_segmentNames[s]); + + if (offset + count > Utf8BufferSize) + { + throw new InvalidOperationException( + "The segment names do not fit the transcode buffer; " + + "increase Utf8BufferSize."); + } + + offset += Encoding.UTF8.GetBytes(s_segmentNames[s], utf8Names.AsSpan(offset)); + ends[s] = offset; + } + + for (var i = 0; i < _documents.Length; i++) + { + var baseline = WalkString(_documents[i].Root, s_segmentNames); + var hoisted = WalkUtf8(_documents[i].Root, utf8Names, ends); + + // Same value identity: same parent document instance and same MetaDb + // cursor mean both walks resolved the exact same row. + if (!ReferenceEquals(baseline._parent, hoisted._parent) + || !baseline._cursor.Equals(hoisted._cursor)) + { + throw new InvalidOperationException( + $"Result {i}: baseline and hoisted walks resolved different elements."); + } + + if (baseline.ValueKind != JsonValueKind.Array || baseline.GetArrayLength() < 1) + { + throw new InvalidOperationException( + $"Result {i}: the walk did not land on the non-empty reviews array " + + "(the workload would silently measure the miss path)."); + } + } + } + + private static byte[] BuildPayload(int index) + { + // Sibling properties before and after the walked segments keep the reverse + // linear property scan of TryGetNamedPropertyValueCore realistic. + var reviewCount = 1 + (index % 5); + var json = new StringBuilder(512); + + json.Append("{\"data\":{\"productById\":{"); + json.Append("\"id\":\"product-").Append(index).Append("\","); + json.Append("\"name\":\"Product ").Append(index).Append("\","); + json.Append("\"reviews\":["); + + for (var i = 0; i < reviewCount; i++) + { + if (i > 0) + { + json.Append(','); + } + + json.Append("{\"id\":\"review-").Append(index).Append('-').Append(i).Append("\","); + json.Append("\"body\":\"Review body ").Append(i).Append("\","); + json.Append("\"stars\":").Append(1 + (i % 5)).Append('}'); + } + + json.Append("],"); + json.Append("\"sku\":\"SKU-").Append(index).Append("\"}}}"); + + return Encoding.UTF8.GetBytes(json.ToString()); + } +}