Skip to content

Speed up model field reads ~4.6x (FieldDescriptor) - #753

Open
wbarnha wants to merge 1 commit into
masterfrom
claude/faust-cython-field-descriptor
Open

Speed up model field reads ~4.6x (FieldDescriptor)#753
wbarnha wants to merge 1 commit into
masterfrom
claude/faust-cython-field-descriptor

Conversation

@wbarnha

@wbarnha wbarnha commented Aug 4, 2026

Copy link
Copy Markdown
Member

Description

FieldDescriptor defines 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_property declarations on the read path (pure Python)

lazy_coercion and related_models were mode.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_coercion is read on every field access:

cached_property read : 166.9 ns
plain attribute read :  32.9 ns

Both are now ordinary attributes, assigned by on_model_attached() where _expr becomes available. related_models is only read at class-definition time; neither appears in as_dict(), so clone() is unaffected.

Worth flagging for review: 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 — an easy thing to half-fix.

2. faust/models/_cython/fields.pyx__get__ on an extension type

Follows the pattern already in the tree: pure-Python implementation stays and is used whenever the extension could not be built or NO_CYTHON is 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__, so as_dict()/clone(), tests that set attributes directly, FieldDescriptor[str] subscripting and isinstance(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:

metric master pure-py only +cython py vs master cython vs master
1 field read 321 ns 171 ns 70 ns 1.88x 4.59x
5 field reads 1620 ns 857 ns 287 ns 1.89x 5.65x
from_data 4227 ns 3865 ns 4287 ns 1.09x 0.99x
raw __dict__ (control) 60 ns 64 ns 64 ns

Reads land essentially on the raw-dict floor.

The honest caveat is construction. The pure-Python change alone makes from_data 1.09x faster; the extension gives that back, because cdef public compiles 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 for descriptor.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 .pyx and keeping change 1 is a coherent option.

Testing

  • New tests exercise the read path against both implementations: class vs instance access, lazy coercion of a nested model (including that the coerced value is cached), optional fields, arbitrary attribute assignment and clone().
  • A regression test asserts neither attribute resolves to a cached_property anywhere in the MRO — that is the actual invariant, rather than where the value happens to be stored (the extension type keeps lazy_coercion as a C struct member, the Python one as an instance attribute).
  • Full suite passes both ways: 2220 with the extension, 2214 with NO_CYTHON=1.
  • flake8 / black / isort clean; 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

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

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.04%. Comparing base (803c7a4) to head (562b623).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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