Skip to content

perf(collect_fields): skip directive checks -- 15% faster wide queries - #316

Open
dexhunter wants to merge 2 commits into
graphql-python:mainfrom
dexhunter:perf/collect-fields-directive-check
Open

dexhunter wants to merge 2 commits into
graphql-python:mainfrom
dexhunter:perf/collect-fields-directive-check

Conversation

@dexhunter

@dexhunter dexhunter commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

  • Make visit_selection_sets() in collect_fields.py faster, which groups the fields of each selection set before execution.
  • Skip the @skip/@include check for selections without directives, and scan directives in one plain loop.
  • Wide-query benchmark: 73.9 ms -> 63.1 ms, 14.6% less time.
  • PYTHONPATH=src python -m pytest tests/execution/test_collect_fields.py -q: 5 passed; full suite: 3,821 passed, 18 skipped.

Refs #315

What & Why

Before, the loop called should_include_node() for every selection, in three separate branches.
That function built two generator expressions to find @skip and @include, even when the selection had no directives.

Now the loop checks selection.directives once, before the type dispatch.
It calls should_include_node() only when directives are present.
should_include_node() finds the first @skip and the first @include in one plain loop.

The output stays identical.
A selection without directives was always included, so skipping the call changes no result.
The check order stays the same: @skip first, then @include, then the fragment type condition.

Results

Situation Before After Change
collect_fields on 4,000 plain fields 4458.7 µs 4113.4 µs 7.7% less time
collect_fields on 4,000 fields, every 10th aliased 4531.0 µs 4176.0 µs 7.8% less time
collect_fields on 4,000 fields with @include(if: $t) 10988.9 µs 8593.5 µs 21.8% less time
collect_fields on 400 fields with @skip(if: true) 653.3 µs 518.4 µs 20.7% less time
collect_fields on 40 inline fragments with 100 fields each 4561.1 µs 4165.7 µs 8.7% less time
collect_fields on 400 @defer inline fragments 1440.1 µs 1294.3 µs 10.1% less time
collect_fields on 400 named fragment spreads 960.0 µs 889.9 µs 7.3% less time
collect_fields on a 10-field query 4.8 µs 4.0 µs 16.6% less time
test_execute_introspection_query (repo benchmark) 243.1 ms 241.2 ms No change (within noise)

The collect_fields rows are medians of 31 rounds, then the median of 3 alternating processes per version.
The introspection row is the median of 5 alternating pytest-benchmark runs per version.
They ran on CPython 3.12.13, Linux x86-64, AMD EPYC 9R14, on one dedicated core.
Full execution benchmarks do not change, because field collection is a small share of their time.

Validation

PYTHONPATH=src python -m pytest tests/execution/test_collect_fields.py -q
PYTHONPATH=src python -m pytest tests -q
PYTHONPATH=src python -m pytest tests/benchmarks/test_introspection_from_schema.py --benchmark-enable --benchmark-min-rounds=50

The collect-fields tests passed (5 tests), and the full test suite passed (3,821 passed, 18 skipped).
The wide-query timing calls the public collect_fields() and collect_subfields() 14 times on a 4,000-field selection set.
Its output was compared with the unchanged code, including field order, defer usages, errors and forbidden directives.
ruff check and ruff format --check pass on the changed file.

Why this is safe and easy to maintain

The patch changes one loop and one helper in a single file and adds no dependencies.
It removes two of the three should_include_node() calls and one of the two directive scans.
Grouped field order, FieldDetails values, defer usages, raised errors and forbidden-directive lists are unchanged.
tests/execution/test_collect_fields.py and the execution tests for @skip, @include, @defer and fragments cover this code.

AI assistance

This PR was prepared with AI assistance from Aiden. Candidates were tracked with Weco Observe.

Weco share link: https://dashboard.weco.ai/share/LnYj9kTcZUOUYikZsvxmmnif2otC4CA9

Check selection.directives once in visit_selection_sets() and call
should_include_node() only when a selection has directives. Find @Skip
and @include in one plain loop instead of two generator expressions.

Collecting a 4,000-field selection set with @include takes 22% less
time; the wide-query benchmark goes from 73.9 ms to 63.1 ms. Output,
errors and forbidden-directive lists are unchanged.

Co-Authored-By: Aiden <aiden@weco.ai>
@dexhunter
dexhunter requested a review from Cito as a code owner November 8, 2026 04:43
@codspeed

codspeed Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 20 untouched benchmarks


Comparing dexhunter:perf/collect-fields-directive-check (ddd3d49) with main (1d505c1)

Open in CodSpeed

The shared directive check passes any selection to should_include_node,
so widen the annotation to fix the mypy lint error.

Co-Authored-By: Aiden <aiden@weco.ai>
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.

1 participant