(improvement) (Cython only) row_parser: cache ParseDesc for prepared statements (us improvements - 20-100x speedups!) - #742
Conversation
e355833 to
eb3ae98
Compare
There was a problem hiding this comment.
Pull request overview
This PR optimizes the Cython row parsing fast-path for prepared statements by caching the ParseDesc built in recv_results_rows(), reducing repeated per-response construction overhead.
Changes:
- Add a module-level cache in
cassandra/row_parser.pyxkeyed byid(column_metadata)to reuseParseDesc,column_names, andcolumn_typesfor prepared statement executions. - Expose
clear_parse_desc_cache()and call it fromCluster.shutdown(). - Add a benchmark/correctness benchmark module to measure and validate the caching behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| cassandra/row_parser.pyx | Introduces ParseDesc caching and adds a public cache-clear helper. |
| cassandra/cluster.py | Clears the module-level ParseDesc cache during Cluster.shutdown(). |
| benchmarks/test_parse_desc_cache_benchmark.py | Adds benchmarks and cache-behavior checks for the new caching approach. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ee3c279 to
9f2f3ff
Compare
There was a problem hiding this comment.
Pull request overview
This PR improves the Cython result-row parsing hot path by caching the ParseDesc constructed in recv_results_rows() for repeated executions of the same prepared statement (stable result_metadata object), reducing repeated metadata processing and deserializer construction.
Changes:
- Add a bounded module-level cache in
cassandra/row_parser.pyxkeyed byid(column_metadata)with identity + session-settings validation. - Add cache management helpers (
clear_parse_desc_cache(),get_parse_desc_cache_size()) intended for test/benchmark visibility. - Add a new benchmark module to measure ParseDesc construction and end-to-end parsing impact.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| cassandra/row_parser.pyx | Introduces ParseDesc caching for prepared-statement row decoding and adds cache clear/size helpers. |
| benchmarks/test_parse_desc_cache_benchmark.py | Adds benchmarks (and some correctness assertions) for cached vs uncached ParseDesc construction/parsing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
9f2f3ff to
203080e
Compare
Cache the ParseDesc object constructed in recv_results_rows() so that repeated executions of the same prepared statement skip the list comprehensions, ColDesc construction, and make_deserializers() call. The cache is keyed by id(column_metadata). For prepared statements the result_metadata list is stored on PreparedStatement and reused, so id() is stable. On cache hit we verify object identity (cached_ref is column_metadata) and that session-level settings (column_encryption_policy, protocol_version) still match. Implementation details: - _get_or_build_parse_desc: cached path, used only when column_metadata comes from result_metadata (prepared statements with stable id()). - _build_parse_desc: uncached path, used for inline metadata from non-prepared queries that creates a fresh list every execution. - Cache is bounded to 256 entries; cleared entirely when full. - Returns only (desc, column_names, column_types) to the caller, avoiding exposure of internal cache fields. - Thread-safety: dict get/set are atomic in CPython (GIL), but concurrent cache misses may cause redundant construction (benign). A clear_parse_desc_cache() and get_parse_desc_cache_size() function are exposed for testing. ## Benchmark results (median, pytest-benchmark) ### ParseDesc construction only (reference benchmarks) | Columns | **Before** (original) | **After** (with cache) | |---------|-----------------------|------------------------| | 5 cols | 3,966 ns | 191 ns | | 10 cols | 5,730 ns | 175 ns | | 20 cols | 9,266 ns | 166 ns | | 50 cols | 19,388 ns | 193 ns | ### Full pipeline integration (recv_results_rows through Cython) | Scenario | **Before** (original) | **After** (with cache) | |-------------------|-----------------------|------------------------| | 1 row x 10 col | 40,867 ns | 2,977 ns | | 100 rows x 5 col | 145,584 ns | 73,206 ns | | 1000 rows x 5 col | 1,099,825 ns | 999,517 ns | For small result sets (single-row lookups common with prepared statements), ParseDesc construction is a large fraction of the total response-path cost. Caching eliminates it entirely after the first execution. All 623 unit tests pass (16 skipped - pre-existing).
- Fix copyright header in benchmark file (DataStax -> ScyllaDB) - Add pytest.importorskip guard for pytest-benchmark in benchmark file - Add unit tests for ParseDesc cache under tests/unit/cython: cache hit, miss, protocol version invalidation, clear, bounded eviction, correctness
203080e to
d0549f7
Compare
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
Rebased onto current Cache-invalidation correctness on schema change / re-preparation (the main thing I scrutinized): verified safe. Summary:
I added one small doc-only change (amended into the first commit, no new commit): a comment in CI / review status: all 20 checks were green on the pre-rebase commit; no unresolved review threads (Copilot's comments — shutdown-hook global side effect, memory retention, missing unit tests, copyright header — were all addressed in the second commit or by removing the Testing performed locally (Cython extensions rebuilt in-place):
Force-pushed the rebased branch (2 commits, same as before, amended — no new commits added). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (11)
tests/unit/cython/test_parse_desc_cache.py:117
- If the Cython
cassandra.row_parserimport fails (_HAS_ROW_PARSER == False), these tests can still be collected and then fail withNameError/TypeErrorwhen callingget_parse_desc_cache_size()or_recv_results_rows. Consider skipping the entire test class when_HAS_ROW_PARSERis false (e.g.,setUp()callsself.skipTest(...)when unavailable, or a@unittest.skipUnless(_HAS_ROW_PARSER, ...)decorator).
class ParseDescCacheTest(unittest.TestCase):
"""Tests for the Cython ParseDesc cache in row_parser.pyx."""
def setUp(self):
if _HAS_ROW_PARSER:
clear_parse_desc_cache()
def tearDown(self):
if _HAS_ROW_PARSER:
clear_parse_desc_cache()
@cythontest
def test_cache_hit_returns_same_objects(self):
cassandra/row_parser.pyx:63
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
cdef inline tuple _get_or_build_parse_desc(object column_metadata, object column_encryption_policy, int protocol_version):
cassandra/row_parser.pyx:86
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
# Cache miss -- build everything
cdef list column_names = [md[2] for md in column_metadata]
cdef list column_types = [md[3] for md in column_metadata]
cdef object desc = ParseDesc(
column_names, column_types, column_encryption_policy,
[ColDesc(md[0], md[1], md[2]) for md in column_metadata],
make_deserializers(column_types), protocol_version)
cassandra/row_parser.pyx:100
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
cdef inline tuple _build_parse_desc(object column_metadata, object column_encryption_policy, int protocol_version):
cassandra/row_parser.pyx:110
- The ParseDesc construction logic is duplicated between
_get_or_build_parse_desc()(miss path) and_build_parse_desc(). To avoid drift/bugs when this construction changes in the future, consider factoring the shared build logic into a single internal helper (e.g.,_construct_parse_desc(...)returning(desc, names, types)), and have both functions call it.
cdef list column_names = [md[2] for md in column_metadata]
cdef list column_types = [md[3] for md in column_metadata]
cdef object desc = ParseDesc(
column_names, column_types, column_encryption_policy,
[ColDesc(md[0], md[1], md[2]) for md in column_metadata],
make_deserializers(column_types), protocol_version)
cassandra/row_parser.pyx:88
- Clearing the entire cache at capacity can cause sudden churn if an application legitimately uses slightly more than 256 prepared statements (repeated full rebuilds after each threshold crossing). A low-overhead alternative is evicting a single entry (e.g., oldest insertion-order key) on overflow, which preserves most cached entries while still avoiding complex LRU bookkeeping on the hot path (eviction only happens on misses).
# Simple bounded eviction: if the cache is too large, clear it entirely.
cassandra/row_parser.pyx:93
- Clearing the entire cache at capacity can cause sudden churn if an application legitimately uses slightly more than 256 prepared statements (repeated full rebuilds after each threshold crossing). A low-overhead alternative is evicting a single entry (e.g., oldest insertion-order key) on overflow, which preserves most cached entries while still avoiding complex LRU bookkeeping on the hot path (eviction only happens on misses).
if len(_parse_desc_cache) >= _PARSE_DESC_CACHE_MAX_SIZE:
_parse_desc_cache.clear()
cassandra/row_parser.pyx:115
- These
deffunctions become part of the publiccassandra.row_parserimport surface. If they are intended strictly for tests/debugging, consider marking them as internal (e.g., prefix with_) and/or adding explicit documentation that they are not a stable public API. This avoids committing the project to long-term support of testing hooks as user-facing API.
def clear_parse_desc_cache():
"""Clear the ParseDesc cache. Exposed for testing."""
cassandra/row_parser.pyx:120
- These
deffunctions become part of the publiccassandra.row_parserimport surface. If they are intended strictly for tests/debugging, consider marking them as internal (e.g., prefix with_) and/or adding explicit documentation that they are not a stable public API. This avoids committing the project to long-term support of testing hooks as user-facing API.
def get_parse_desc_cache_size():
"""Return the current number of entries in the ParseDesc cache. Exposed for testing."""
benchmarks/test_parse_desc_cache_benchmark.py:43
- Module-level
pytest.importorskip('pytest_benchmark')skips all tests in this file (including the non-benchmark correctness tests), which can unintentionally reduce coverage in environments that runpytestwithout the plugin. Consider limiting the skip to only the benchmark-dependent tests (or splitting correctness checks intotests/and leaving only benchmarks underbenchmarks/).
# Skip the entire module when pytest-benchmark is not installed.
benchmarks/test_parse_desc_cache_benchmark.py:48
- Module-level
pytest.importorskip('pytest_benchmark')skips all tests in this file (including the non-benchmark correctness tests), which can unintentionally reduce coverage in environments that runpytestwithout the plugin. Consider limiting the skip to only the benchmark-dependent tests (or splitting correctness checks intotests/and leaving only benchmarks underbenchmarks/).
pytest.importorskip("pytest_benchmark")
Cache the ParseDesc object constructed in recv_results_rows() so that repeated executions of the same prepared statement skip the list comprehensions, ColDesc construction, and make_deserializers() call.
The cache is keyed by id(column_metadata). For prepared statements the result_metadata list is stored on PreparedStatement and reused, so id() is stable. On cache hit we verify object identity (cached_ref is column_metadata) and that session-level settings (column_encryption_policy, protocol_version) still match.
A clear_parse_desc_cache() function is exposed for testing.
Benchmark results (median, pytest-benchmark)
ParseDesc construction only
Full pipeline (ParseDesc + row parsing)
For small result sets (single-row lookups common with prepared statements), ParseDesc construction is a large fraction of the total response-path cost. Caching eliminates it entirely after the first execution.
All 116 unit tests pass (1 skipped — pre-existing test_datetype issue).
Pre-review checklist
./docs/source/.Fixes:annotations to PR description.