Skip to content

(improvement) (python code path only): cache namedtuple class in named_tuple_factory to avoid … (3.x to 100x improvement when cache is used - us improvements!) - #740

Draft
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:perf/cache-named-tuple-factory
Draft

(improvement) (python code path only): cache namedtuple class in named_tuple_factory to avoid … (3.x to 100x improvement when cache is used - us improvements!)#740
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:perf/cache-named-tuple-factory

Conversation

@mykaul

@mykaul mykaul commented Mar 13, 2026

Copy link
Copy Markdown

Cache the Row namedtuple class keyed on tuple(colnames) so Python's namedtuple() (which internally calls exec()) is only invoked once per unique column schema. For prepared statements the column names never change, eliminating redundant class creation on every result set.

Motivation

named_tuple_factory is the default row_factory in the driver. Every call to namedtuple('Row', columns) internally calls exec() to generate a new class -- this is surprisingly expensive. For prepared statements executing the same query repeatedly, the column names never change, yet we pay the namedtuple() + exec() cost on every result set.

Benchmark results

All timings in ns (nanoseconds). 500 iterations, 100 warmup, CPython 3.14.3.

10 columns, 1 row (isolates class creation overhead):

Variant Min (ns) Median (ns) Speedup
Before (original) 31,611 35,488
After (with cache) 330 349 ~96–102x

5 columns, 100 rows:

Variant Min (ns) Median (ns) Speedup
Before (original) 37,158 39,584
After (with cache) 13,889 14,470 ~2.7x

10 columns, 100 rows:

Variant Min (ns) Median (ns) Speedup
Before (original) 48,951 53,134
After (with cache) 16,179 16,926 ~3.0–3.1x

5 columns, 1000 rows:

Variant Min (ns) Median (ns) Speedup
Before (original) 152,892 160,303
After (with cache) 132,164 136,043 ~1.2x

10 columns, 1000 rows:

Variant Min (ns) Median (ns) Speedup
Before (original) 203,571 209,696
After (with cache) 165,537 170,736 ~1.2x

20 columns, 100 rows:

Variant Min (ns) Median (ns) Speedup
Before (original) 74,650 77,290
After (with cache) 20,642 21,165 ~3.6x

The speedup is most dramatic when row count is low (the namedtuple class creation cost dominates). With 1000 rows, the per-row tuple construction dominates and the speedup converges to ~1.2x.

Design notes

  • Cache is a plain dict keyed on tuple(colnames) (raw column names before cleaning)
  • Error handling paths (SyntaxError, Exception) preserved unchanged
  • Cache is naturally bounded by the number of distinct queries

Tests

All existing unit tests pass (46 passed).

Pre-review checklist

  • I have split my patch into logically separate commits.
  • All commit messages clearly explain what they change and why.
  • I added relevant tests for new features and bug fixes.
  • All commits compile, pass static checks and pass test.
  • PR description sums up the changes and reasons why they should be introduced.
  • I have provided docstrings for the public items that I want to introduce.
  • I have adjusted the documentation in ./docs/source/.
  • I added appropriate Fixes: annotations to PR description.

@mykaul mykaul changed the title (improvement) cache namedtuple class in named_tuple_factory to avoid … (improvement) (python code path only): cache namedtuple class in named_tuple_factory to avoid … Mar 13, 2026
@mykaul
mykaul marked this pull request as draft March 13, 2026 10:13
@mykaul
mykaul force-pushed the perf/cache-named-tuple-factory branch 3 times, most recently from 9a76016 to a662281 Compare April 7, 2026 11:04
@mykaul mykaul changed the title (improvement) (python code path only): cache namedtuple class in named_tuple_factory to avoid … (improvement) (python code path only): cache namedtuple class in named_tuple_factory to avoid … (3.x to 100x improvement when cache is used - us improvements!) Apr 7, 2026
@mykaul
mykaul force-pushed the perf/cache-named-tuple-factory branch from a662281 to 06f7051 Compare July 29, 2026 20:20
Copilot AI review requested due to automatic review settings July 29, 2026 20:20
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5584e9dc-1265-41b2-b809-f013cd00ce10

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@mykaul

mykaul commented Jul 29, 2026

Copy link
Copy Markdown
Author

Rebased onto latest origin/master (was based on an older tip) and amended the following into the commit after a correctness/growth review of the caching approach:

Cache-key correctness (verified, no bug found): the key is tuple(colnames) — the exact, ordered, case-sensitive raw column names. Cleaning/sanitizing is a pure function of that key, so:

  • different case ("Name" vs "name") → different keys → no incorrect sharing
  • different column order (("a","b") vs ("b","a")) → different keys → no incorrect sharing
  • colliding sanitized names from different raw names is a pre-existing behavior of namedtuple/_sanitize_identifiers, unrelated to this cache, and unaffected by it

Added regression tests for the case- and order-sensitivity guarantees.

Unbounded growth risk (real, fixed): the original version cached forever with no eviction. For a fixed set of prepared statements this is naturally bounded, but an application executing many distinct ad hoc queries against highly variable/generated schemas could grow the cache without bound. Added a cap (_NAMED_TUPLE_CACHE_MAX_SIZE = 10000) with FIFO eviction (oldest entry first, relying on dict insertion order) once full, plus a regression test for the eviction behavior.

Testing: full tests/unit/ suite (720 passed, 88 skipped, no failures) plus the extended correctness suite in benchmarks/test_named_tuple_factory_benchmark.py (17 passed) all green after rebase + fix.

No open review threads to resolve. Still a draft — happy to un-draft on request.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Caches generated Row namedtuple classes to reduce repeated result-processing overhead.

Changes:

  • Adds a bounded FIFO namedtuple-class cache.
  • Adds correctness and performance benchmarks.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
cassandra/query.py Implements namedtuple-class caching and eviction.
benchmarks/test_named_tuple_factory_benchmark.py Adds cache correctness checks and benchmarks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cassandra/query.py Outdated
Comment thread cassandra/query.py
Comment thread benchmarks/test_named_tuple_factory_benchmark.py
…repeated exec() calls

Cache the Row namedtuple class keyed on tuple(colnames) so Python's
namedtuple() (which internally calls exec()) is only invoked once per
unique column schema. For prepared statements the column names never
change, eliminating redundant class creation on every result set.

Cache is a plain dict keyed on tuple(colnames) (raw column names before
cleaning, exact names, exact order). Since cleaning/sanitizing colnames
is a pure function of that key, two schemas only ever share a cached
class when their column names match exactly -- differing case, order,
or contents all produce distinct keys. Error handling paths
(SyntaxError, Exception) preserved unchanged.

The cache is naturally bounded by the number of distinct queries for
typical usage (a fixed set of prepared statements), but applications
that build many ad hoc queries against highly variable or generated
schemas could otherwise grow it without bound. It is now capped at
10000 entries with oldest-first (FIFO) eviction once full, relying on
dict insertion order, to keep worst-case memory bounded.

Address review feedback:

- Thread safety: the miss/evict/insert sequence on a cache miss was not
  synchronized, so one thread's next(iter(_named_tuple_cache)) (picking
  an eviction victim) could race with another thread mutating the same
  dict, raising `RuntimeError: dictionary changed size during
  iteration`; two threads observing the cache under its bound before
  either inserted could also together push it past that bound. This
  driver advertises free-threaded Python support, so the whole
  check-evict-insert sequence is now guarded by a lock
  (_named_tuple_cache_lock), with the cache-HIT path left lock-free.
  Verified with a real pre-fix/post-fix repro under CPython's
  free-threaded (3.14t) build: the pre-fix code reliably raised
  RuntimeError and exceeded its stated bound under concurrent misses,
  the post-fix code did neither across repeated runs. Added a
  regression test (tests/unit/test_row_factories.py,
  TestNamedTupleFactoryCacheThreadSafety) that hammers the cache from
  many threads with distinct column-name sets to force evictions,
  asserting no exception is raised and the bound is respected.

- Test placement: the cache correctness tests (cache-hit, cache-key,
  eviction) were previously only under benchmarks/, which is not run by
  the project's wheel test commands (only tests/unit is). Moved them
  into tests/unit/test_row_factories.py so regressions in the
  production cache are actually caught in CI; benchmarks/ now contains
  only the timing benchmarks.

- Dev dependencies: added pytest-benchmark to the `dev` dependency
  group in pyproject.toml so `pytest benchmarks/` is runnable in the
  documented development environment.
@mykaul
mykaul force-pushed the perf/cache-named-tuple-factory branch from 06f7051 to ef0bb90 Compare July 30, 2026 22:02
Copilot AI review requested due to automatic review settings July 30, 2026 22:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

cassandra/query.py:190

  • This non-reentrant lock remains held while namedtuple creation and the fallback error handling run. The fallback calls log.warning (and the SyntaxError path calls warnings.warn), both of which execute user-configurable handlers; if a handler re-enters this row factory, it blocks forever on the same lock, and cross-thread logging can also create a lock-order deadlock. Build the candidate class and emit diagnostics before acquiring this lock, then lock only to re-check the key and perform eviction/insertion; concurrent misses may do duplicate construction, but callbacks no longer run inside the critical section.
        with _named_tuple_cache_lock:
            try:
                Row = _named_tuple_cache[key]
            except KeyError:

cassandra/query.py:178

  • Materializing colnames only for the cache key consumes one-shot iterables, but the miss path then iterates the original object again. For example, named_tuple_factory(iter(("a", "b")), [(1, 2)]) now builds a zero-field Row and fails, whereas the previous implementation accepted it. Reuse the materialized tuple for the rest of the function so key construction does not change the input semantics.
    key = tuple(colnames)

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