Skip to content

Reduce Execution::Next lookup and step overhead - #5696

Open
ydah wants to merge 3 commits into
rmosolgo:masterfrom
ydah:perf/execution-next-selected-optimizations
Open

Reduce Execution::Next lookup and step overhead#5696
ydah wants to merge 3 commits into
rmosolgo:masterfrom
ydah:perf/execution-next-selected-optimizations

Conversation

@ydah

@ydah ydah commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

This PR reduces three input-size-dependent costs in GraphQL::Execution::Next without adding or changing public APIs:

  • repeated linear scans over large argument collections
  • per-object PrepareObjectStep allocations when no preparation is required
  • per-item LoadArgumentStep allocations for list arguments using loads:

Each optimization is kept in a separate commit and includes coverage for the compatibility-sensitive paths it changes.

Changes

ba4c954 : Index large execution argument nodes

Large argument collections previously performed a linear AST scan for every argument definition, making argument lookup quadratic as the number of arguments increased.

This commit builds a name index when there are at least 32 argument nodes. Smaller collections keep the existing linear lookup to avoid paying for a Hash allocation on common queries.

The index uses ||= so invalid documents executed with validate: false preserve the existing behavior of selecting the first duplicate argument.

ae5dc8d : Skip unnecessary object preparation steps

Concrete, eager composite values previously allocated one PrepareObjectStep per returned object, even when no authorization, type resolution, scoping, post-processing, or finalization was required.

This commit adds a conservative direct-result path for eager concrete objects. The existing preparation path remains in use for:

  • custom object authorization
  • scoped fields
  • lazy values
  • abstract types
  • nested lists
  • post-processors
  • runtime directive finalizers

The direct path preserves authorization trace events expected by schemas configured with lazy resolution.

bb3fc51 : Batch list argument loading steps

A list argument using loads: previously allocated one LoadArgumentStep for every list item.

This commit replaces those per-item step objects with one LoadArgumentsStep state machine. Public loading and authorization hooks are still called once per item and retain their existing order.

The batch step preserves:

  • eager and lazy load completion order
  • error selection and result ordering
  • duplicate ID behavior
  • Dataloader batching
  • object_loaded trace order
  • GraphQL::Current.field during trace hooks
  • rescue_from handling for loading and trace hook failures
  • mutation and subscription early-unsubscribe behavior

Scalar loads: arguments continue using the existing LoadArgumentStep.

Benchmarks

Environment

  • Baseline SHA: 98cf2ab
  • Patch SHA: bb3fc51
  • Ruby: ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM
  • Platform: macOS arm64
  • YJIT: disabled
  • External I/O: none

The benchmark uses pre-parsed documents with validate: false to isolate execution work. Each timing is the median of five samples after warmup. Before timing, the benchmark verifies that the legacy and Execution::Next results match.

Allocation totals were collected with MemoryProfiler.

Argument node indexing

Arguments Before After Improvement Allocated objects Allocated bytes
128 534.74 µs 279.34 µs 47.8% faster 124,500 → 125,128 (+0.5%) 22.40 MB → 24.15 MB (+7.8%)
256 1,562.85 µs 487.00 µs 68.8% faster 188,500 → 189,256 (+0.4%) 39.30 MB → 42.97 MB (+9.4%)

The index intentionally trades a small allocation increase for eliminating the quadratic lookup cost. A zero-argument query changed from 59.41 µs to 59.90 µs (+0.8%), within measurement noise.

Skipping object preparation

This workload returns 1,000 eager concrete objects with 25 scalar fields each.

Case Before After Improvement Allocated objects Allocated bytes
1,000 eager objects 10,287.30 µs 9,741.52 µs 5.3% faster 22,810 → 12,810 (-43.8%) 14.84 MB → 12.95 MB (-12.7%)

Authorization and abstract-type fallback workloads continue using PrepareObjectStep. Repeated measurements of those fallback paths were within 2% of the baseline and had unchanged allocation counts.

Batched list argument loads

List size Before After Improvement
10 172.95 µs 160.76 µs 7.0% faster
100 608.91 µs 526.61 µs 13.5% faster
1,000 4,718.85 µs 4,190.59 µs 11.2% faster

For 1,000 loaded values:

Metric Before After Improvement
Allocated objects 103,750 78,875 24.0% fewer
Allocated bytes 16.18 MB 14.78 MB 8.6% fewer

A follow-up alternating-process run after the error-handling hardening showed the same allocation counts. Runtime varied by less than approximately 1% compared with the pre-hardening implementation.

@ydah
ydah force-pushed the perf/execution-next-selected-optimizations branch from bb3fc51 to ca09137 Compare August 11, 2026 12:25
@ydah
ydah force-pushed the perf/execution-next-selected-optimizations branch from ca09137 to 5b161ce Compare August 11, 2026 12:26
@rmosolgo

Copy link
Copy Markdown
Owner

Hey, thanks so much for tracking down these improvements and sharing your results. This looks like a great win. I thought I had already avoided PrepareObjectStep whenever I could but I must have missed a spot!

I have a couple other big issues to review at the moment but I'll take a close look at this one soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants