Perf/eager collection bulk extend - #13416
#13416Perf/eager collection bulk extend#13416ollz272 wants to merge 7 commits intosqlalchemy:mainsqlalchemy/sqlalchemy:mainfrom ollz272:perf/eager-collection-bulk-extendollz272/sqlalchemy:perf/eager-collection-bulk-extendCopy head branch name to clipboard
Conversation
…/eager-collection-bulk-extend
setting _sa_trivial on the generated append/add wrappers follows the existing wrapper._sa_instrumented idiom; mypy needs the same type: ignore[attr-defined] annotation
ashm-dev
left a comment
There was a problem hiding this comment.
1. _sa_trivial bypass via functools.wraps
The getattr(appender, "_sa_trivial", None) check is fooled if a custom override uses @functools.wraps(InstrumentedList.append): wraps copies the wrapped function's __dict__, leaking both _sa_instrumented and _sa_trivial into the subclass override.
Result: the subclass incorrectly gets _sa_bulk_appender = list.extend and its override is silently skipped on eager loads, while event-ful appends still call it. Split behavior within one class. Same for the set variant; wrapt/decorator-style wrappers leak the same way.
Fix: keep a module-level WeakSet of the wrappers generated in _list_decorators/_set_decorators and check membership in _set_collection_attributes, instead of relying on a copyable function attribute. Add a regression test with a wraps-decorated override.
2. Vacuous asserts in test_ordering_list_positions_on_eager_load
The fixture writes position = 0, 1, 2, and with the default reorder_on_append=False OrderingList assigns nothing for non-NULL positions anyway. The test does fail under a forced-bulk mutant, but only via the internal is_none(_sa_bulk_appender) assert; the behavioral asserts (positions/data) pass regardless, so they verify nothing.
Fix: write position=None in the fixture so the positions assert becomes a real behavioral check.
3. Untested lazy loader
The PR description claims fast-path support for lazy loading, but tests only cover selectin/subquery/joined.
Fix: add lazy to the loader test matrix.
4. Process
- Missing linked issue. The changelog file cannot be named without a ticket number.
- Missing changelog entry under
doc/build/changelog/unreleased_21/. - Clean up the history: rebase to remove the merge commit.
5. Adjustments
- "Exactly equivalent" overstates the set case: the old wrapper hashes each value twice (
value not in self+set.add),set.updatehashes once. State is identical, side-effect counts are not. Soften the comments. _sa_bulk_appendernext tobulk_appender()is confusing. Rename the former to_sa_raw_bulk_appendor add cross-references.- Benchmarks: the exact invocation isn't given (script defaults to 20 repeats, table says 1000), environment details are missing, and in-memory SQLite inflates the percentages vs real databases. Please include the command line and the
--jsonoutput.
The core mechanism otherwise checks out: with _sa_initiator=False the fast path is equivalent for untouched list/set, and ordinary customizations (subclass overrides, @collection.appender, internally_instrumented, KeyFuncDict, OrderingList) are gated correctly.
| self._refuse_empty() | ||
| self._data()._sa_appender(item, _sa_initiator=False) | ||
| data = self._data() | ||
| raw_appender = data._sa_raw_appender |
There was a problem hiding this comment.
is it possible to name the "preferred" appender on the collection up front rather than testing each time?
Description
What changed
Every eagerly-loaded child paid two wasted Python frames: the generated
instrumentation wrapper (
append/add) plus its__setno-op shell, eventhough with
_sa_initiator=Falsethe wrapper's entire body reduces to theraw built-in operation.
append/addwrapper istagged
_sa_trivialwhen — and only when — it wraps exactlylist.append/set.add._set_collection_attributesthen computes twoclass attributes:
_sa_bulk_appender(list.extend/set.update/None) and_sa_raw_appender(list.append/set.add/None).CollectionAdapter.append_multiple_without_event(the choke point forselectin / subquery / immediate / lazy collection loads via
set_committed_value) uses the bound bulk op when present.CollectionAdapter.append_without_event(the per-row path of the joinedcollection eager loader) uses the raw single op.
subclass
append/addoverrides,@collection.appender,@collection.internally_instrumented, dict-based collections(
attribute_keyed_dict/KeyFuncDict),ext.orderinglist.OrderingList(assigns positions per append), and set classes with custom
__contains__.Performance (
bench_hydration.py, 1000 repeats, min / median vs main)Win pattern matches the mechanism exactly: concentrated where collection
appends occur; controls flat.
Testing
16 gate unit tests (
TrivialAppenderGateTest), 9 loader integration testsincl. observability tests proving the instrumented appender is NOT called
on the fast path (
BulkAppendLoaderTest), and 6 gating-armor tests provingeach excluded collection type still gets per-item appender calls
(
BulkAppendGatingTest). 680 tests green across collection + eager-loader +orderinglist + associationproxy suites.
Benchmark harness:
bench_hydration.pyChecklist
This pull request is:
must include a complete example of the issue. one line code fixes without an
issue and demonstration will not be accepted.
Fixes: #<issue number>in the commit messageinclude a complete example of how the feature would look.
Fixes: #<issue number>in the commit messageHave a nice day!