Skip to content

perf: replace RelationProxy.__getattribute__ with explicit count/clear#1650

Merged
collerek merged 1 commit intocheck-optimisations-and-rsfrom
perf/relationproxy-getattribute
May 5, 2026
Merged

perf: replace RelationProxy.__getattribute__ with explicit count/clear#1650
collerek merged 1 commit intocheck-optimisations-and-rsfrom
perf/relationproxy-getattribute

Conversation

@collerek
Copy link
Copy Markdown
Collaborator

@collerek collerek commented May 5, 2026

Summary

Profile-driven removal of the per-attribute-access Python override on RelationProxy. The previous __getattribute__ existed solely to redirect two list-method names (count, clear) to the QuerysetProxy versions; every other attribute access paid the cost of a Python-level __getattribute__ call only to fall through to super().

cProfile (all_with_related, 40 000 row hydrations): __getattribute__ was 0.354 s tottime / 6.2 % of scenario time, with 420 000 calls. After this change it is no longer in the top 25 by tottime — attribute access goes through the C-level lookup path.

Changes

  • Define count() and clear() as explicit async methods directly on RelationProxy. They shadow list.count / list.clear by virtue of MRO and delegate to queryset_proxy after self._initialize_queryset().
  • Delete the __getattribute__ override and the _QUERYSET_PROXY_METHODS frozenset.

The observable async semantics are unchanged: callers always did await proxy.count(...) / await proxy.clear(...). The previous override returned a bound async method from QuerysetProxy; the new methods are themselves async — both produce the same coroutine. Same signatures (distinct=True / keep_reversed=True defaults), same delegation path, same QuerysetProxy initialization trigger.

Benchmark deltas (median, pytest-benchmark, --warmup=on, 10+ rounds)

Test Before After Δ
test_get_all_with_related_models[10] 2 449 µs 2 027 µs −17.2 %
test_get_all_with_related_models[20] 3 999 µs 3 373 µs −15.6 %
test_get_all_with_related_models[40] 7 106 µs 6 561 µs −7.7 %
test_get_all[250] 5 764 µs 5 010 µs −13.1 %
test_get_all[500] 10 345 µs 9 317 µs −9.9 %
test_get_all[1000] 19 187 µs 17 972 µs −6.4 %
test_iterate[*] within noise
single-row get_one / first I/O-dominated, ±15 % noise

Test plan

  • make pre-commit — clean (one # type: ignore[override] for count since signature differs from Sequence.count)
  • make coverage — 628 passed, 25 skipped, 100.00 % coverage
  • tests/test_relations/, tests/test_queries/test_queryproxy_on_m2m_models.py, test_aggr_functions.py, test_reverse_fk_queryset.py all green
  • cProfile on all_with_related confirms __getattribute__ no longer in top-25 by tottime

🤖 Generated with Claude Code

Profile-driven removal of the per-attribute-access Python override on
RelationProxy. The previous __getattribute__ existed solely to redirect
two list-method names ("count", "clear") to the QuerysetProxy versions;
every other attribute access paid the cost of a Python-level
__getattribute__ call only to fall through to super.

cProfile (all_with_related, 40 000 row hydrations): __getattribute__
was 0.354 s tottime / 6.2 % of scenario time, with 420 000 calls. After
this change it is no longer in the top 25 by tottime — attribute access
goes through the C-level lookup path.

Replacement: define count() and clear() as async methods directly on
RelationProxy. They shadow list.count / list.clear by virtue of MRO and
delegate to queryset_proxy after self._initialize_queryset(). The
observable async semantics are unchanged: callers always did
``await proxy.count(...)`` / ``await proxy.clear(...)``; the previous
override returned a bound async method, the new methods are themselves
async — both produce the same coroutine.

Behavior unchanged: same signatures (distinct=True / keep_reversed=True
defaults), same delegation path, same QuerysetProxy initialization
trigger. 628 tests pass at 100 % coverage.

Benchmark deltas (median, pytest-benchmark, --warmup=on, 10+ rounds):
- test_get_all_with_related_models[10]   -17.2 %
- test_get_all_with_related_models[20]   -15.6 %
- test_get_all_with_related_models[40]    -7.7 %
- test_get_all[250]                      -13.1 %
- test_get_all[500]                       -9.9 %
- test_get_all[1000]                      -6.4 %
- test_iterate[*]                         within noise
- single-row get_one / first              I/O-dominated, ±15 % noise

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@collerek collerek merged commit a4dd606 into check-optimisations-and-rs May 5, 2026
2 checks passed
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