Skip to content

perf: result path memory allocation optimizations (mostly memory savings, 10's of ns improvements) - #805

Draft
mykaul wants to merge 11 commits into
scylladb:masterfrom
mykaul:perf/result-path-cleanup
Draft

perf: result path memory allocation optimizations (mostly memory savings, 10's of ns improvements)#805
mykaul wants to merge 11 commits into
scylladb:masterfrom
mykaul:perf/result-path-cleanup

Conversation

@mykaul

@mykaul mykaul commented Apr 7, 2026

Copy link
Copy Markdown

Summary

Optimize the result message processing hot path (_set_result, ResultMessage) for memory and speed. Each commit is an independent, focused optimization.

Commits

# Commit Description
1 7df233481 Remove dead and duplicate class attributes in ResultMessage
2 3185a0bdd Skip redundant None attribute assignments in decode_message
3 7b0191c09 Avoid throwaway ResultSet creation in fetch_next_page
4 3256340b9 Add __slots__ to _MessageType, ResultMessage, and FastResultMessage
5 cacc76328 Streamline tablet payload parsing in _set_result
6 4f7c4103b Check isinstance(ResultMessage) first for direct attribute access
7 bd4cb8f16 Cache column_names/column_types on PreparedStatement
8 4104aa6a1 Reorder RESULT_KIND dispatch and replace getattr with direct access
9 e76de060a Cache session.cluster as local in _set_result
10 6106d61 Add __repr__ to ResultMessage for slotted attribute display
11 9aa01fc Fix pre-existing bugs: _set_keyspace_for_all_pools errors dict, wait_for_schema_agreement scope validation

Details

Commits 1-9 (original PR)

See individual commit messages for details.

Commit 10: __repr__ for ResultMessage

ResultMessage.__slots__ eliminates __dict__, which broke the inherited _MessageType.__repr__ (it only iterates __dict__). Added a __repr__ override that walks type(self).__mro__ to collect all __slots__ from the full class hierarchy (handles FastResultMessage which has __slots__ = ()). Only displays non-None, non-False attributes for clean debug output.

Commit 11: Pre-existing bug fixes

Two bugs found on origin/master:

  • _set_keyspace_for_all_pools: callback(host_errors) passed only the last pool's errors instead of the accumulated errors dict
  • wait_for_schema_agreement: Missing scope validation despite the docstring promising ValueError

Benchmarks

All benchmarks: Python 3.14, pure-Python .py (not Cython .so).

column_names / column_types extraction

Columns Uncached Cached (PreparedStatement) Saving
5 223 ns 31 ns 7.1x
10 341 ns 32 ns 10.6x
20 567 ns 32 ns 18.0x
50 1281 ns 31 ns 41.5x

_set_result dispatch (2M iters)

Optimization Before After Saving
RESULT_KIND reorder (ROWS=1st) 39.8 ns 27.8 ns −11.9 ns (1.43x)
getattr → direct attribute 38.6 ns 20.3 ns −18.3 ns (1.90x)

session.cluster caching (5M iters)

Access pattern Time Saving
3× self.session.cluster 62.3 ns
1× local + 3× local 37.5 ns −24.8 ns (1.66x)

Net impact per query

  • decode_message (commit 2): ~15 ns saved (skip 3 redundant None assignments)
  • _set_result dispatch (commits 5-6): ~49 ns saved (streamlined tablet parsing + isinstance-first)
  • ResultSet allocation (commit 3): ~300 ns saved per page fetch (avoids throwaway ResultSet)
  • Memory (commit 4): ~120–200 bytes/ResultMessage saved (per-instance __dict__ eliminated)

Total per-query savings on the hot path: ~65 ns latency + ~200 bytes memory.

Test results

  • 671 unit tests pass, 8 skipped — all green
  • ResultSet.__slots__ removed from the PR to avoid API breakage for users who subclass (no memory regression vs origin/master__slots__ wasn't on origin/master either)
  • All benchmarks verified against claimed numbers
  • 2 pre-existing origin/master bugs fixed (see commit 11)

@mykaul mykaul changed the title perf: result path memory allocation optimizations perf: result path memory allocation optimizations (mostly memory savings) Apr 7, 2026
@mykaul
mykaul force-pushed the perf/result-path-cleanup branch from 709beae to 2818aef Compare April 9, 2026 12:50
@mykaul mykaul changed the title perf: result path memory allocation optimizations (mostly memory savings) perf: result path memory allocation optimizations (mostly memory savings, 10's of ns improvements) Apr 9, 2026
@mykaul

mykaul commented Apr 10, 2026

Copy link
Copy Markdown
Author

Follow-up commit: cache column_names/column_types on PreparedStatement

Commit: 580b4556cperf: cache column_names/column_types on PreparedStatement to avoid per-result list comprehensions

Change

For prepared statements with skip_meta=True (the common case), column_metadata is the same object every time, yet column_names and column_types lists are rebuilt via list comprehension on every result set in recv_results_rows.

This commit:

  1. Pre-computes _result_col_names and _result_col_types on PreparedStatement at prepare time
  2. In _set_result, uses the cached lists directly instead of the per-response lists
  3. Invalidates the cache when result_metadata is updated during re-prepare

Benchmark results (benchmarks/bench_col_names_cache.py)

Python 3.14.3
  5 cols:  226 ns -> 30 ns (7.4x)
  10 cols: 340 ns -> 28 ns (12.2x)
  20 cols: 589 ns -> 31 ns (18.9x)
  50 cols: 1160 ns -> 29 ns (39.6x)

Tests

611 unit tests passed, 0 failures.

@mykaul

mykaul commented Apr 10, 2026

Copy link
Copy Markdown
Author

Follow-up: Reorder RESULT_KIND dispatch and replace getattr with direct access

Commit: 5bbbc90

What changed

Two micro-optimizations in _set_result() hot path:

  1. Reorder RESULT_KIND dispatch: RESULT_KIND_ROWS is now checked first (was third). RESULT_KIND_VOID is second. SET_KEYSPACE and SCHEMA_CHANGE (rare) are last. Since ROWS is by far the most common result kind, this avoids 2 unnecessary comparisons per query.

  2. Replace getattr with direct access: Added continuous_paging_options = None class attribute to _QueryMessage in protocol.py, enabling self.message.continuous_paging_options instead of getattr(self.message, 'continuous_paging_options', None).

Benchmark results (Python 3.14, 2M iterations)

Optimization Before After Saving Speedup
RESULT_KIND reorder (ROWS hit) 35.5 ns 24.3 ns 11.2 ns 1.46x
getattr → direct attribute 32.0 ns 18.3 ns 13.7 ns 1.75x
Combined per-query ~25 ns

Testing

  • 611 unit tests passed (10.0s)
  • No regressions (pre-existing test_eq failure due to stale Cython resolved by rebuild)

@mykaul
mykaul force-pushed the perf/result-path-cleanup branch from 5bbbc90 to 30e01b8 Compare April 11, 2026 10:30
@mykaul

mykaul commented Apr 11, 2026

Copy link
Copy Markdown
Author

v3: CI fix — added continuous_paging_options = None to BatchMessage

The previous push caused 11 CI failures with:

AttributeError: 'BatchMessage' object has no attribute 'continuous_paging_options'

Root cause: The direct attribute access optimization (self.message.continuous_paging_options instead of getattr(...)) was added in cluster.py, and the class attribute was declared on _QueryMessage — but BatchMessage inherits from _MessageType directly, not from _QueryMessage.

Fix: Added continuous_paging_options = None class attribute to BatchMessage in protocol.py, matching the interface of _QueryMessage.

All 611 unit tests pass. Benchmarks unchanged:

  • RESULT_KIND dispatch reorder: 1.62x (25.6 ns saving)
  • Direct attr access vs getattr: 1.93x (21.7 ns saving)

@mykaul

mykaul commented Apr 11, 2026

Copy link
Copy Markdown
Author

v4: Added session.cluster local caching in _set_result

New commit: cache session = self.session and cluster = session.cluster once at the top of the ResultMessage branch, eliminating repeated double-lookups.

Changes:

  • self.session.cluster accessed 3 times in the tablet routing block → now uses cluster local
  • self.session in SET_KEYSPACE path → reuses session local (also removes redundant getattr(self, 'session', None))
  • self.session.submit + self.session.cluster in SCHEMA_CHANGE path → uses session/cluster locals

Benchmark (5M iters):

  • 3x self.session.cluster (old): 66.2 ns
  • 1x local + 3x local (new): 39.9 ns
  • Saving: 26.3 ns (1.66x)

All 611 unit tests pass.

@mykaul
mykaul force-pushed the perf/result-path-cleanup branch 3 times, most recently from d73e202 to 48fe3a3 Compare April 11, 2026 16:28
@mykaul

mykaul commented Jun 29, 2026

Copy link
Copy Markdown
Author

I kinda remember some collision between slots and something... Unsure now.

@mykaul
mykaul force-pushed the perf/result-path-cleanup branch from 48fe3a3 to 9aa01fc Compare June 29, 2026 19:56
@coderabbitai

coderabbitai Bot commented Jun 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: 90d87334-e5de-4505-b0e2-2eb42423091a

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 force-pushed the perf/result-path-cleanup branch from 9aa01fc to f4bb4c2 Compare July 29, 2026 17:43
Copilot AI review requested due to automatic review settings July 29, 2026 17:43
@mykaul

mykaul commented Jul 29, 2026

Copy link
Copy Markdown
Author

Rebased onto current master (was ~21 commits behind) and fixed a real bug the rebase surfaced.

Bug found and fixed: the __slots__ commit unconditionally initialized result_metadata_id = None in ResultMessage.__init__. Master's newer test_recv_results_metadata_no_metadata_flag_skips_metadata_id (from the SCYLLA_USE_METADATA_ID work) asserts result_metadata_id must not exist as an attribute unless the server actually sent one — recv_results_metadata only sets it when _METADATA_ID_FLAG is present, and code elsewhere (_set_result's getattr(response, 'result_metadata_id', None)) already relies on that "may be absent" contract to distinguish "no id sent" from "id explicitly None". Eagerly initializing it broke that distinction. Fix: stopped initializing result_metadata_id in __init__ (kept it in __slots__, just not eagerly set), and made __repr__ tolerate the now-possibly-unset slot via getattr(self, s, None) instead of unconditional getattr(self, s). This matches the "I kinda remember some collision between __slots__ and something..." concern raised in the comments above — that was it.

Also had to reconcile the column-name/type caching optimization (PreparedStatement._result_col_names/_result_col_types) with master's newer atomic (result_metadata, result_metadata_id) pair design (update_result_metadata()/read-only properties, added for the metadata-id extension): the cache is now refreshed from inside update_result_metadata() itself, so it can never go stale relative to the metadata it was derived from.

Verified: the compiled Cython FastResultMessage path still works correctly with the slotted layout (no __dict__, attribute access and __repr__ behave as expected). Ran the full tests/unit/ suite (720 passed, 88 skipped — all skips are pre-existing libev/PYTHON-912 environment gaps, unrelated to this change) plus the three micro-benchmark scripts, which still show the claimed savings.

No unresolved review threads found via the API. Still a draft.

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

Optimizes result processing for lower latency and memory use while adding related correctness fixes.

Changes:

  • Adds slotted result messages and streamlines response handling.
  • Caches prepared-result metadata and avoids temporary paged ResultSet objects.
  • Updates tests and adds focused microbenchmarks.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
cassandra/cluster.py Optimizes result dispatch, metadata access, and paging.
cassandra/protocol.py Adds slots and streamlined message decoding.
cassandra/query.py Caches prepared-result columns.
tests/unit/test_resultset.py Updates paging tests.
tests/unit/test_response_future.py Updates response and metadata tests.
benchmarks/micro/bench_session_cluster_cache.py Benchmarks cluster lookup caching.
benchmarks/micro/bench_result_kind_dispatch.py Benchmarks dispatch and attribute access.
benchmarks/micro/bench_col_names_cache.py Benchmarks metadata caching.

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

Comment thread cassandra/cluster.py Outdated
Comment thread tests/unit/test_response_future.py
Comment thread cassandra/cluster.py
Comment thread benchmarks/micro/bench_result_kind_dispatch.py Outdated
Comment thread benchmarks/micro/bench_col_names_cache.py Outdated
Comment thread benchmarks/micro/bench_session_cluster_cache.py
mykaul added 6 commits July 30, 2026 23:34
Remove 'results = None' (never assigned or read in production code),
duplicate 'kind = None' (declared twice), and duplicate 'paging_state = None'
(declared at lines 663 and 681). The canonical declarations at lines 672-685
are kept.
When trace_id, custom_payload, and warnings are None (the common case
for non-traced, non-warned messages), skip the instance attribute
assignment. The class-level defaults on _MessageType already provide
None, so reading these attributes returns the correct value without
the per-instance __dict__ write.
Extract _wait_for_result() from result() to return the raw result
without wrapping in a ResultSet. Use it in fetch_next_page() to
avoid creating a full ResultSet object just to extract _current_rows.

For paged queries with N pages, this eliminates N-1 throwaway
ResultSet allocations (each with 6 attributes).
…sage

Add __slots__ to eliminate per-instance __dict__ for ResultMessage,
the most common response type. ResultMessage has 19 instance attributes;
eliminating the __dict__ saves ~200-400 bytes per decoded result message.

Changes:
- _MessageType: __slots__ = () to signal slots-awareness to subclasses
- ResultMessage: full __slots__ with all 19 instance attributes initialized
  in __init__ (replaces class-level defaults that are incompatible with slots)
- FastResultMessage: __slots__ = () to inherit parent slots without __dict__
- _get_params: handle slotted objects gracefully for __repr__
- Remove dead 'response.results' assignment from test mock
- Replace double dict lookup ('in' + .get()) with single .get() + None check
- Use tuple unpacking instead of three separate indexing operations
- Guard add_tablet with 'if tablet is not None' (from_row can return None
  for empty replicas — previously this was silently passed through)
- Inline single-use locals (protocol, keyspace, table)

Saves ~13 ns on the tablet-hit path (noise-level, but cleaner code).
… attr access

Move the isinstance(response, ResultMessage) check to the top of
_set_result so the hot path (successful query results) uses direct
attribute access instead of getattr() with defaults.

ResultMessage has trace_id, warnings, and custom_payload in __slots__,
always initialised in __init__, so direct access is safe and avoids the
overhead of 3 getattr() calls + 1 isinstance() that was previously done
unconditionally before the type dispatch.

For the cold ErrorMessage path, getattr() is still used because
ErrorMessage does not declare trace_id in __slots__ or __init__.
ConnectionException and generic Exception branches explicitly clear
_warnings/_custom_payload to avoid stale values from prior retries.

Benchmark (best-of-7, 500k iterations, Python 3.14, pure-Python):
  _set_result ROWS hot path (no tablets, no tracing):
    Before: 326 ns
    After:  271 ns  (-55 ns, 1.20x)
Copilot AI review requested due to automatic review settings July 30, 2026 20:58
@mykaul
mykaul force-pushed the perf/result-path-cleanup branch from f4bb4c2 to 56ca73e Compare July 30, 2026 20:58

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 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread cassandra/cluster.py Outdated
mykaul added 5 commits July 31, 2026 16:32
…er-result list comprehensions

For prepared statements with skip_meta=True (the common case),
column_metadata is the same object every time, yet column_names and
column_types lists are rebuilt via list comprehension on every result set.

Pre-compute and cache these lists on PreparedStatement at prepare time.
In _set_result, use the cached lists directly instead of the per-response
lists. The cache is invalidated when result_metadata is updated during
re-prepare.

The cached column names/types are stored together with result_metadata
and result_metadata_id in one atomically-replaced tuple
(PreparedStatement.result_metadata_snapshot), and a ResponseFuture reads
that whole tuple in one shot at construction time, alongside the existing
_bound_result_metadata snapshot. _set_result then uses the ResponseFuture's
own snapshotted column names/types rather than a fresh read of
prepared_statement's live cache: otherwise, if a concurrent in-flight
response for the same prepared statement replaces the cache (e.g. via a
METADATA_CHANGED reprepare) between this response being decoded and its
callback running, the rows -- decoded against this future's own metadata
snapshot -- could be paired with column names/types derived from a
different schema version.

_set_result must still prefer the bound snapshot only when the response
itself is metadata-less. When a response DOES carry its own column_metadata
(recv_results_rows only sets it when the server actually sent metadata on
the wire, i.e. the METADATA_CHANGED case or the first response for a
statement with no cached metadata yet), that response's rows were decoded
against that fresh metadata, not against whatever this ResponseFuture had
bound at construction time. Preferring the stale bound snapshot there paired
freshly-decoded rows with the old column names/types; after a schema change
that altered the column count, this made the row factory raise TypeError
(wrong number of positional arguments). Now the response's own
column_names/column_types -- derived from that same fresh column_metadata --
are used whenever the response provides them, and the bound snapshot is used
only for the metadata-less/skip_meta case it was designed for.

Benchmark (column_names + column_types extraction):
  5 cols:  226 ns -> 30 ns (7.4x)
  10 cols: 340 ns -> 28 ns (12.2x)
  20 cols: 589 ns -> 31 ns (18.9x)
  50 cols: 1160 ns -> 29 ns (39.6x)
…cess

Two micro-optimizations in _set_result() hot path:

1. Reorder the RESULT_KIND if/elif chain to check ROWS first (was third),
   since it is by far the most common result type.  VOID is second.
   SET_KEYSPACE and SCHEMA_CHANGE (rare) are now last.

2. Add continuous_paging_options = None class attribute to _QueryMessage,
   allowing direct attribute access instead of
   getattr(self.message, 'continuous_paging_options', None).

Benchmark (2M iters, Python 3.14):
  RESULT_KIND reorder: 35.5 -> 24.3 ns (1.46x, -11.2 ns/dispatch)
  getattr -> direct:   32.0 -> 18.3 ns (1.75x, -13.7 ns/access)
  Combined: ~25 ns saved per query
… double-lookup

In the ResultMessage hot path, self.session.cluster was accessed 3 times
in the tablet routing block plus additional times in SET_KEYSPACE and
SCHEMA_CHANGE branches.  Cache session = self.session and
cluster = session.cluster once at entry to eliminate redundant
attribute-chain lookups.

Also reuse the cached 'session' local for the SET_KEYSPACE and
SCHEMA_CHANGE branches instead of re-reading self.session.

Keep updating this response's own connection's keyspace directly in the
SET_KEYSPACE branch, in addition to (not instead of) propagating to session
pools via _set_keyspace_for_all_pools: that call only updates
session.keyspace and pooled connections, so without the direct update, a
control-connection fallback (pool is None, no pooled connection available)
would leave its connection's keyspace stale and subsequent fallback queries
on it would run against the wrong keyspace.

Benchmark (5M iters):
  3x self.session.cluster (old): 66.2 ns
  1x local + 3x local (new):     39.9 ns
  Saving: 26.3 ns (1.66x)
…vior

The _set_keyspace_for_all_pools error-reporting fix and the
wait_for_schema_agreement scope validation this commit originally
carried are now already present on master (independently landed as
26c201a and 03c4c96). Rebasing onto master leaves this commit
with only test updates.

Two mocks needed correcting rather than adding to:

- test_control_connection_fallback_reprepares_prepared_statement's
  prepared_statement Mock no longer needs _result_col_names/
  _result_col_types: the column-name/type cache these referred to has
  since been redesigned as one atomic tuple exposed via
  PreparedStatement.result_metadata_snapshot, and _set_result now reads
  a ResponseFuture-level snapshot of it rather than these attributes
  directly, so setting them on the mock no longer serves any purpose.

- test_control_connection_fallback_updates_connection_keyspace's
  set_keyspace_for_all_pools stub must NOT update `connection.keyspace`
  itself: the real Session._set_keyspace_for_all_pools only ever updates
  session.keyspace and pooled connections, never an arbitrary connection
  passed in by the caller. A stub that updates `connection` directly
  would mask a regression where _set_result stops updating a fallback
  control connection's keyspace on its own -- exactly the class of bug
  this test exists to catch.
Copilot AI review requested due to automatic review settings July 31, 2026 13:34
@mykaul
mykaul force-pushed the perf/result-path-cleanup branch from 56ca73e to 5044031 Compare July 31, 2026 13:34

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 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (2)

cassandra/cluster.py:5261

  • The cached lists are now shared by every ResponseFuture for this prepared statement, but they are later passed directly to the user-supplied row factory and exposed as mutable ResultSet.column_names/column_types (ResultSet.__init__, lines 5834-5835). Mutating either result set (or mutating col_names in a custom row factory) will therefore corrupt the prepared statement's cache and change subsequent or already-created result sets. Preserve the previous per-response isolation by copying the cached lists before they leave this internal cache, or use an immutable internal representation while retaining the existing external list behavior.
                        col_names = self._bound_col_names
                        col_types = self._bound_col_types

cassandra/protocol.py:760

  • Collecting slots in a set makes the new debug representation's field order hash-dependent, so the same message can produce different repr output across processes. The inherited implementation followed insertion order; preserve declaration/MRO order while deduplicating so logs and diagnostics remain stable.
        slots = set()
        for cls in type(self).__mro__:
            slots.update(getattr(cls, '__slots__', ()))

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