Speed up model field reads ~4.6x (FieldDescriptor) - #753
Open
wbarnha wants to merge 1 commit into
Open
Conversation
FieldDescriptor defines both __get__ and __set__, so it is a data
descriptor: every read of every field of every model instance goes through
it. Two changes, one of which turns out to matter more than the Cython.
First, in pure Python: lazy_coercion and related_models were mode
cached_property. That decorator defines __set__, which makes it a *data*
descriptor, so it intercepts every read even after the value is cached --
and lazy_coercion is read on every field access. Both are now ordinary
attributes assigned by on_model_attached(), which is where _expr becomes
available. Reading one on its own goes 167ns -> 32ns.
Note this had to be fixed in *two* places: FieldDescriptor declared them,
but so did FieldDescriptorT in faust/types/models.py. Removing only the
subclass's copy changed nothing measurable, because the base class's data
descriptor still intercepted the read.
Second, faust/models/_cython/fields.pyx carries __get__ on an extension
type, so the attributes it needs are C struct members rather than dict
lookups. Deliberately minimal: only __get__ moves, and only the four
attributes it touches are declared on the type. Everything else stays an
ordinary Python attribute, so as_dict()/clone(), tests that set attributes
directly, FieldDescriptor[str] subscripting and isinstance checks against
FieldDescriptorT are all unaffected.
Medians of 5 interleaved runs, with a raw __dict__ read as a control:
metric master pure-py +cython py vs m cy vs m
1 field read 321ns 171ns 70ns 1.88x 4.59x
5 field reads 1620ns 857ns 287ns 1.89x 5.65x
from_data 4227ns 3865ns 4287ns 1.09x 0.99x
raw (control) 60ns 64ns 64ns
Reads land essentially on the raw-dict floor. Construction is the honest
caveat: the pure-Python change alone makes from_data 1.09x faster, and the
extension gives that back, because `cdef public` compiles to getset
descriptors and the still-Python write path reads those attributes. Moving
__set__ into the extension as well was tried and measured worse, so it was
reverted. Net for a model whose fields are read at least once, it is ahead;
for one constructed and never read, it is a wash.
Tests cover both implementations and include a regression test that neither
attribute resolves to a cached_property anywhere in the MRO.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oX4oGCQGgvPJHabjwfaBk
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #753 +/- ##
==========================================
- Coverage 96.06% 96.04% -0.02%
==========================================
Files 103 103
Lines 11072 11076 +4
Branches 1191 1191
==========================================
+ Hits 10636 10638 +2
- Misses 345 347 +2
Partials 91 91 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
FieldDescriptordefines both__get__and__set__, so it is a data descriptor: every read of every field of every model instance goes through it. This is two changes, and the pure-Python one turns out to matter more than the Cython.1. Two
cached_propertydeclarations on the read path (pure Python)lazy_coercionandrelated_modelsweremode.utils.objects.cached_property. That decorator defines__set__, which makes it a data descriptor — so it intercepts every read even once the value is cached.lazy_coercionis read on every field access:Both are now ordinary attributes, assigned by
on_model_attached()where_exprbecomes available.related_modelsis only read at class-definition time; neither appears inas_dict(), soclone()is unaffected.Worth flagging for review: this had to be fixed in two places.
FieldDescriptordeclared them, but so didFieldDescriptorTinfaust/types/models.py. Removing only the subclass's copy changed nothing measurable, because the base class's data descriptor still intercepted the read — an easy thing to half-fix.2.
faust/models/_cython/fields.pyx—__get__on an extension typeFollows the pattern already in the tree: pure-Python implementation stays and is used whenever the extension could not be built or
NO_CYTHONis set.Deliberately minimal. Only
__get__moves, and only the four attributes it touches are declared on the extension type. Everything else remains an ordinary Python attribute in__dict__, soas_dict()/clone(), tests that set attributes directly,FieldDescriptor[str]subscripting andisinstance(x, FieldDescriptorT)are all unaffected.Benchmarks
Medians of 5 interleaved runs on CPython 3.11, with a raw
__dict__read as a control for machine noise:from_data__dict__(control)Reads land essentially on the raw-dict floor.
The honest caveat is construction. The pure-Python change alone makes
from_data1.09x faster; the extension gives that back, becausecdef publiccompiles to getset descriptors and the still-Python write path (__set__,should_coerce) reads those attributes through them — measurably slower than a plain__dict__hit (45.7 ns vs 33.4 ns fordescriptor.field).I tried moving
__set__into the extension as well so both hot paths would use C-level attribute access. It measured worse, so it was reverted rather than kept on the theory that it should have helped.Net: for a model whose fields are read at least once, this is comfortably ahead (5847 ns → 4574 ns for construct-plus-read-all); for one constructed and never read, it is a wash. If reviewers would rather have the construction win than the read win, dropping the
.pyxand keeping change 1 is a coherent option.Testing
clone().cached_propertyanywhere in the MRO — that is the actual invariant, rather than where the value happens to be stored (the extension type keepslazy_coercionas a C struct member, the Python one as an instance attribute).NO_CYTHON=1.flake8/black/isortclean; sdist ships the new.pyx.Relationship to other PRs
Independent of #751 (offset/scheduler/sensor accelerators) — different files, no overlap. This one was identified there as the remaining promising candidate and deferred as too large for that PR.
🤖 Generated with Claude Code
https://claude.ai/code/session_019oX4oGCQGgvPJHabjwfaBk
Generated by Claude Code