Add three Cython accelerators: offset commit, record scheduler, sensor fan-out - #751
Add three Cython accelerators: offset commit, record scheduler, sensor fan-out#751wbarnha wants to merge 5 commits into
Conversation
Faust already ships optional Cython implementations of the window types,
the stream iterator and the topic conductor. This extends that to three
more hot paths, found by profiling the per-message and per-commit work.
Each follows the existing pattern: the pure-Python implementation stays
and is used whenever the extension could not be built or NO_CYTHON is
set, so this is purely additive.
faust/utils/_cython/functional.pyx
first_consecutive_run(), a dedicated helper for the one thing
Consumer._new_offset actually needs from consecutive_numbers(): the
first run. The groupby() version builds a tuple, calls a Python key
function and creates a group generator per acked offset, and runs
once per assigned partition on every commit. Because it blocks the
event loop, the win shows up as latency: for 100k un-committed
offsets, 2.71ms -> 0.55ms.
faust/transport/_cython/scheduler.pyx
A C-level cursor replacing the round-robin generator returned by
DefaultSchedulingStrategy.records_iterator, which also takes over
TopicBuffer's inner per-partition generator. This runs once per
record fetched from the broker: ~300-490ns -> ~50-70ns.
faust/sensors/_cython/base.pyx
A cdef base class carrying the four sensor hooks that fire on every
message; SensorDelegate subclasses it and keeps its ~20 occasional
hooks in plain Python. on_stream_event_in, which builds a dict per
event, goes 374ns -> 182ns.
Behaviour is preserved rather than approximated. Both scheduler
implementations re-read the topic index and each topic's buffer map at
every pass and pop drained entries from them, so mutating either
mid-iteration gives the same result; a TopicBuffer subclass is still
driven through next(); map_from_records and records_iterator remain
overridable. first_consecutive_run stops consuming as soon as the run
ends, so a shared iterator is left where the pure-Python version leaves
it, and it re-reads the list length while scanning because a custom
__sub__ can resize the list underneath it. The sensor set is read live
rather than snapshotted, so direct mutation behaves identically.
Tests run every new code path against both implementations, including a
randomised differential test over 200 scheduler topologies. The full
suite passes with and without NO_CYTHON.
extra/tools/benchmark_cython.py times each accelerator against its
pure-Python counterpart in the same interpreter.
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 #751 +/- ##
=======================================
Coverage 96.06% 96.06%
=======================================
Files 103 103
Lines 11072 11094 +22
Branches 1191 1193 +2
=======================================
+ Hits 10636 10658 +22
Misses 345 345
Partials 91 91 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The copartitioned-assignor property tests run on PyPy since the PyPy skips were removed. They carried a fixed 4000ms Hypothesis deadline, but PyPy's JIT warmup makes a single example's timing vary by over a second, so runs intermittently exceed it and Hypothesis reports DeadlineExceeded / FlakyFailure -- failing the (non-required) PyPy leg and turning master CI red even though the assignment produced is valid. These tests assert assignment correctness, not performance, so set deadline=None (as the DeadlineExceeded message itself recommends). The property assertions are unchanged and the job timeout still guards against a real hang. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHPL4VFWQRQPpjR1gXSKyL
|
The The failure was Evidence it isn't caused by this branch:
Hypothesis shrinks to the same slow example every run, so it was not going to clear on its own. Everything else is green, and Generated by Claude Code |
Follows the "faster code via static typing" guidance: declare return types on cdef functions, and type parameters and locals rather than leaving them implicit. The substantive change is on the two _start_pass methods, which had no declared return type. They are now `cdef int ... except -1`. Both call into Python (dict.pop, .items(), the _TopicCursor constructor) and so can raise, and the except clause states that rather than depending on a compiler default -- Cython 3 propagates from a bare cdef, but Cython 0.x swallowed unless an except clause was given, and legacy_implicit_noexcept restores that behaviour. Verified both ways: an exception raised inside _start_pass now propagates identically through the Cython and pure-Python iterators. Also types the len() results as Py_ssize_t instead of letting them box into Python ints, and types the two sensor-delegate parameters that were bare. Note this is a correctness and clarity change, not a speed one: the scheduler benchmark is unmoved at 5.8-7.7x over pure Python, because the loops were already fully typed. Parameters that face callers are deliberately left as `object` -- typing them to a builtin would add an exact-type runtime check, which is what made an OrderedDict fail against a `cdef dict` local earlier in this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019oX4oGCQGgvPJHabjwfaBk
Reported in review of #751. _start_pass skipped rebuilding its snapshot when the mapping's length was unchanged since the last pass. That check cannot tell "unchanged" from "swapped": replacing index["foo"] with a new TopicBuffer under the same key leaves the length identical, so the shortcut was taken, the stale cursor kept being used, and every record in the replacement was silently dropped. The `cursor.source is not buffer` check that exists to catch exactly this lived inside the rebuild, which the shortcut skipped. Reproduced against the pure-Python iterator, which re-reads the mapping every pass and so gets it right: python: [1, 10, 11] cython: [1, 2] <- 10 and 11 never delivered The same flaw was present one level down in _TopicCursor, where an iterator swapped in under an existing TP left the partition cursor stale. The report only named the topic level; both are fixed. Both shortcuts are replaced by _snapshot_is_current(), which compares the cached snapshot against the live mapping entry by entry, by identity. It walks the mapping but allocates nothing, which is what still makes reusing the snapshot worthwhile. This costs real throughput, and the PR body is updated rather than left overstating it: the scheduler was 5.7-7.7x over pure Python and is now 2.2-2.8x. A pass must consult the live mapping to be correct, and a single-partition topic reaches a pass boundary on every record. An unconditional rebuild was measured too, at 1.6-2.1x, so the identity check is worth keeping over simply rebuilding. Adds regression tests for both levels, run against both implementations. Swapping in a topic under a *different* name is deliberately not covered: the pure-Python generator iterates the live dict, so CPython raises "dictionary keys changed during iteration" -- undefined behaviour in Python itself rather than a guarantee to pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019oX4oGCQGgvPJHabjwfaBk
|
Confirmed and fixed in Reproduced before touching anything, against the pure-Python iterator as the reference: The length check could not distinguish "unchanged" from "swapped", so the shortcut was taken and the stale cursor kept draining the old buffer. The The same flaw was present one level down, which the report did not mention: in On the suggested remedies — I took the second one, but not by rebuilding unconditionally. Both shortcuts are now
So the fix costs most of the speedup — the scheduler was 5.7–7.7x over pure Python and is now 2.2–2.8x. A pass has to consult the live mapping to be correct, and a single-partition topic reaches a pass boundary on every record. I have corrected the benchmark table in the PR description rather than leave it overstating the result. Both of your regression scenarios are now tests, parametrised over both implementations, plus the partition-level analogue. The randomised differential test is at 4000/4000. One scenario I deliberately did not pin: replacing a topic under a different name. The pure-Python generator iterates the live dict, so CPython raises Generated by Claude Code |
Description
Faust already ships optional Cython implementations of the window types (
faust/_cython/windows.pyx), the stream iterator (faust/_cython/streams.pyx) and the topic conductor (faust/transport/_cython/conductor.pyx). This adds three more, found by profiling the per-message and per-commit work.Each follows the pattern already in the tree: the pure-Python implementation stays and is used whenever the extension could not be built or
NO_CYTHONis set. Nothing here is required for Faust to run.What was added
faust/utils/_cython/functional.pyx—first_consecutive_run()Consumer._new_offsetonly needs the first run fromconsecutive_numbers(), but thegroupby()implementation builds a tuple, calls a Python key function and creates a group generator for every acked offset. It runs once per assigned partition on every commit, and because it blocks the event loop the cost shows up as latency rather than throughput.faust/transport/_cython/scheduler.pyx—records_iterator()A C-level cursor replacing the round-robin generator returned by
DefaultSchedulingStrategy.records_iterator, which also takes overTopicBuffer's inner per-partition generator. Two generator frames were being resumed for every record fetched from the broker.faust/sensors/_cython/base.pyx—SensorDelegateBaseA
cdefbase class carrying the four sensor hooks that fire on every message.SensorDelegatesubclasses it and keeps its ~20 occasional hooks in plain Python, so only the hot quarter of the class moved.Benchmarks
extra/tools/benchmark_cython.py(new) times each accelerator against its pure-Python counterpart in the same interpreter. On CPython 3.11:first_consecutive_run/ 100 offsetsfirst_consecutive_run/ 10k offsetsfirst_consecutive_run/ 100k offsetsrecords_iterator/ 1 topic × 1 partrecords_iterator/ 4 topics × 8 partsrecords_iterator/ 8 topics × 16 partsSensorDelegate.on_stream_event_in/ 1 sensorSensorDelegateall 4 hooks / 1 sensorThree honest caveats:
records_iteratorfigures were previously 5.7–7.7x here. That version skipped rebuilding its snapshot when the mapping length was unchanged, which turned out to drop records when a buffer was replaced under an existing key (see below). Correctness cost most of the win; these numbers are the fixed implementation. An unconditional rebuild was also measured, at 1.6–2.1x, so the current identity check is worth keeping over simply rebuilding.first_consecutive_run, part of the win is just droppinggroupby— the new pure-Python helper is already ~2.6x faster than the old expression, and Cython adds ~4-5x on top of that. The table compares the two new implementations, not old-vs-new.Monitorattached, the sensor bodies dominate and the relative win is smaller.Behaviour
Preserved rather than approximated. Specifically:
TopicBuffer._buffersis drained identically.TopicBuffersubclass is still driven throughnext(), so an overridden__iter__/__next__is honoured.map_from_recordsandrecords_iteratorremain overridable, so a customConsumerScheduleris unaffected.first_consecutive_runstops consuming as soon as the run ends, so a shared iterator is left exactly where the pure-Python version leaves it. It re-reads the list length while scanning, because a custom__sub__can resize the list underneath it._sensorsdirectly behaves the same in both.Not covered deliberately: swapping in a topic under a different name mid-iteration. The pure-Python generator iterates the live dict, so CPython raises
dictionary keys changed during iteration— undefined behaviour in Python itself rather than a guarantee to pin.Testing
NO_CYTHON=1(2305 passed), and extensions absent entirely.flake8/black/isortclean; sdist ships all six.pyxfiles and rebuilds from a clean tree.Evaluated and rejected
Recorded here so the ground does not get re-covered:
Message/ConsumerMessageascdefclasses —cdef publiccompiles to getset descriptors, which CPython 3.11+ cannot specialize the way it specializes the__slots__member descriptorsMessagealready uses. Most readers are pure Python, so attribute reads would likely regress. Only worth it bundled with atuples.pxdso the existing.pyxmodules cancimportit.Event—EventTisGeneric[T], AsyncContextManager, socdef class Event(EventT)does not compile. TheEventT.register(Event)workaround routes the per-messageisinstancechecks throughABCMeta.__instancecheck__, costing more than the constructor saves.Registry(serializers) — real dispatch waste, but most of it is recoverable in pure Python; Cython's marginal contribution is small.Schema,Codec,Table/Collection,Monitor,Consumer— all blocked byService/ABC bases, documented subclassing, or both.Recordcodegen andfaust/models/typing.py— generated viaexec()at class-definition time; Cython cannot apply.faust/utils/iso8601.py— already delegates tociso8601when installed.FieldDescriptor(faust/models/fields.py) is the one genuinely promising candidate left — ~30 descriptor calls per typed-model message — but it needs a hybridcdefbase plus converting twocached_propertyattributes, which is a larger change than this PR. Left as follow-up.Note on the assignor commit
2f04895is a cherry-pick of the existing fix onclaude/fix-pypy-hypothesis-deadline(which has no open PR). Without it thepypy3.11leg fails on a hypothesis deadline flake intest_copartitioned_assignor.pythat is unrelated to this diff — see the comment below for the evidence. Happy to drop it if that branch lands separately.🤖 Generated with Claude Code
https://claude.ai/code/session_019oX4oGCQGgvPJHabjwfaBk