Skip to content

(improvement) (cython only) cache deserializer instances in find_deserializer and m… (hundreds of ns improvements - 6-40x faster!) - #741

Draft
mykaul wants to merge 2 commits into
scylladb:masterfrom
mykaul:perf/cache-deserializer-lookup
Draft

(improvement) (cython only) cache deserializer instances in find_deserializer and m… (hundreds of ns improvements - 6-40x faster!)#741
mykaul wants to merge 2 commits into
scylladb:masterfrom
mykaul:perf/cache-deserializer-lookup

Conversation

@mykaul

@mykaul mykaul commented Mar 13, 2026

Copy link
Copy Markdown

Cache find_deserializer() and make_deserializers() results in Cython cdef dict caches keyed on cqltype objects to avoid repeated class lookups and Deserializer object creation on every result set.

Using cqltype objects (not id()) as cache keys holds strong references, preventing GC/id-reuse correctness issues with parameterized types.

Motivation

On every result set, make_deserializers(coltypes) is called from row_parser.pyx:37, which in turn calls find_deserializer() for each column type. These functions perform class name lookups and issubclass() chains, then create fresh Deserializer objects -- all redundant work when the same column types appear repeatedly (which is always the case for prepared statements).

Benchmark results

Benchmarks compare the original code (Before) against the new cached implementation (After).

find_deserializer (single type lookup) -- 6.6x faster:

Variant Min Mean Median Ops/sec Speedup
Before (original) 367.0 ns 416.7 ns 403.0 ns 2.4 Mops/s --
After (with cache) 54.9 ns 62.9 ns 60.5 ns 15.9 Mops/s 6.6x

make_deserializers (5 types) -- 29x faster:

Variant Min Mean Median Ops/sec Speedup
Before (original) 2,983 ns 3,766 ns 3,620 ns 266 Kops/s --
After (with cache) 110.7 ns 128.4 ns 118.6 ns 7,790 Kops/s 29x

make_deserializers (10 types) -- 40x faster:

Variant Min Mean Median Ops/sec Speedup
Before (original) 4,946 ns 5,830 ns 5,864 ns 172 Kops/s --
After (with cache) 127.7 ns 147.3 ns 140.9 ns 6,787 Kops/s 40x

Design notes

  • Caches are cdef dict (C-level, not accessible from Python) for minimal overhead
  • Cache keys are the cqltype objects themselves, not id(cqltype) -- holds strong references preventing GC and id() reuse
  • For prepared statements (the hot path), cache hit rate is effectively 100%
  • Caches are bounded to 256 entries; on overflow the entire cache is cleared (simple and sufficient for real workloads where the number of distinct column-type signatures is typically dozens to low hundreds)
  • The returned array from make_deserializers may be a shared cached object; callers must not modify it
  • Runtime Des* class overrides (e.g. DesBytesType = DesBytesTypeByteArray) require a call to clear_deserializer_caches() to flush stale entries
  • A clear_deserializer_caches() API and get_deserializer_cache_sizes() diagnostic helper are provided

Tests

All existing unit tests pass. New tests cover:

  • Cache hit/miss behaviour for both find_deserializer and make_deserializers
  • Bounded eviction (cache does not exceed 256 entries)
  • clear_deserializer_caches() invalidation
  • get_deserializer_cache_sizes() diagnostic helper
  • Benchmark correctness and performance comparison vs uncached baseline

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 deserializer instances in find_deserializer and m… (improvement) (cython only) cache deserializer instances in find_deserializer and m… Mar 13, 2026
@mykaul
mykaul marked this pull request as draft March 13, 2026 10:13
@mykaul
mykaul requested a review from Copilot March 13, 2026 13:10

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

This PR improves performance in the Cython fast-path by caching deserializer lookups and per-column deserializer arrays, avoiding repeated class-resolution work and repeated Deserializer object creation across result sets.

Changes:

  • Add module-level Cython dict caches for find_deserializer() and make_deserializers() keyed by cqltype objects / tuples of cqltype objects.
  • Update find_deserializer() / make_deserializers() to consult and populate these caches.
  • Add a pytest-based benchmark module to compare cached vs uncached behavior.

Reviewed changes

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

File Description
cassandra/deserializers.pyx Adds caching for deserializer instance resolution and for per-column deserializer arrays.
benchmarks/test_deserializer_cache_benchmark.py Introduces correctness checks + pytest-benchmark-style benchmarks for the new caching behavior.

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

Comment thread cassandra/deserializers.pyx
Comment thread cassandra/deserializers.pyx
Comment thread cassandra/deserializers.pyx
Comment thread benchmarks/test_deserializer_cache_benchmark.py
Comment thread benchmarks/test_deserializer_cache_benchmark.py
@mykaul
mykaul force-pushed the perf/cache-deserializer-lookup branch from 5894e22 to 44cdd0d Compare March 20, 2026 17:31
@mykaul
mykaul force-pushed the perf/cache-deserializer-lookup branch 2 times, most recently from 9dc1ddf to 4a7b199 Compare April 3, 2026 18:53
@mykaul mykaul changed the title (improvement) (cython only) cache deserializer instances in find_deserializer and m… (improvement) (cython only) cache deserializer instances in find_deserializer and m… (hundreds of ns improvements - 6-40x faster!) Apr 7, 2026
@mykaul
mykaul force-pushed the perf/cache-deserializer-lookup branch from 4a7b199 to e5fe679 Compare July 29, 2026 20:25
Copilot AI review requested due to automatic review settings July 29, 2026 20:25
@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: f910be04-011b-40cf-be6b-5f42b5b29738

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 current master (fast, no conflicts — master hasn't touched find_deserializer/make_deserializers since this branch was cut, aside from an unrelated unused-import cleanup) and force-pushed.

VectorType subtype-dispatch correctness (the main thing I scrutinized): master does not currently give VectorType any subtype-aware Cython deserializer at all — every VectorType parameterization falls through to GenericDeserializer regardless of subtype (that subtype-aware DesVectorType dispatch only exists on the separate, unmerged int32_pack branch, PR #689). So there's nothing on master for this cache to conflict with today.

More importantly, the caching design here is safe by construction for when that dispatch does land:

  • VectorType.apply_parameters() (cassandra/cqltypes.py) never interns — every call returns a brand-new class object, even for the "same" logical vector type parsed twice.
  • CassandraType/CassandraTypeType never override __eq__/__hash__, so dict lookups on these classes are identity-based (default object.__hash__).
  • The caches key on the cqltype object itself (not id(), not cqltype.__name__), so two different VectorType subtype parameterizations can never collide in _deserializer_cache / _make_deserializers_cache, and a lookup for one can never return a deserializer resolved for another.

One real gotcha I found and worked around: VectorType.apply_parameters() builds the generated class's __name__ from vector_size only, not the subtype (e.g. both VectorType<float,4> and VectorType<smallint,4> end up named "VectorType(4)"). That's a pre-existing, unrelated quirk in cqltypes.py, but it means a hypothetical name-based cache key would have been unsafe — reinforcing that keying on object identity (as this PR does) is the correct choice. Added regression tests (tests/unit/cython/test_deserializer_cache.py) that construct two distinct-subtype VectorType parameterizations via parse_casstype_args and assert the cache does not conflate them, for both find_deserializer() and make_deserializers().

Testing: Rebuilt the Cython extensions locally (setup.py build_ext --inplace --force, confirmed HAVE_CYTHON is True), then ran:

  • tests/unit/cython/test_deserializer_cache.py — 14/14 passed (11 original + 3 new VectorType regression tests)
  • tests/unit/test_types.py — 62 passed, 1 skipped
  • full tests/unit/ — 784 passed, 38 skipped, 0 failed
  • benchmarks/test_deserializer_cache_benchmark.py correctness subset — 18/18 passed

All 5 existing review threads (from the Copilot review) were already resolved by the prior "Address review" commit and remain resolved.

No unresolved threads to address; CI was green on the pre-rebase head and the rebase introduced no code changes to deserializers.pyx beyond what was already reviewed. Still marked as draft as requested.

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 2 comments.

Comments suppressed due to low confidence (1)

cassandra/deserializers.pyx:464

  • make_deserializers() previously returned a fresh array per call; now it may return a shared cached array, which is a behavioral change for any external callers that (intentionally or accidentally) mutate the returned container. Relying on a docstring warning is fragile because a single mutation can corrupt the cached value for all subsequent consumers. Safer alternatives: (1) cache an immutable representation (e.g., a tuple of deserializers) and return a new array built from it each time, or (2) return an immutable type to callers and update internal callers accordingly. If keeping the shared-array behavior is required for performance, consider restricting/clearly marking make_deserializers as internal-only and ensuring no public API contracts imply mutability.
def make_deserializers(cqltypes):
    """Create an array of Deserializers for each given cqltype in cqltypes.

    The returned array may be a cached object shared across callers.
    Callers must not modify the returned array."""
    cdef tuple key = tuple(cqltypes)
    try:
        return _make_deserializers_cache[key]

Comment thread tests/integration/standard/test_types.py
Comment thread tests/integration/standard/test_types.py Outdated
@mykaul

mykaul commented Jul 30, 2026

Copy link
Copy Markdown
Author

Some logical collision with #690 - need to solve it.

mykaul added 2 commits July 31, 2026 21:31
…ake_deserializers

Cache find_deserializer() and make_deserializers() results in Cython cdef
dict caches keyed on cqltype objects to avoid repeated class lookups and
Deserializer object creation on every result set.

Using cqltype objects (not id()) as cache keys holds strong references,
preventing GC/id-reuse correctness issues with parameterized types.
- Add 256-entry size cap to both _deserializer_cache and
  _make_deserializers_cache to prevent unbounded growth from
  non-interned parameterized types in unprepared queries.
- Add clear_deserializer_caches() public API so that runtime Des*
  class overrides (e.g. DesBytesType = DesBytesTypeByteArray for
  cqlsh) can flush stale cached instances.
- Add get_deserializer_cache_sizes() diagnostic helper.
- Document override/cache interaction in code comments.
- Fix benchmark copyright (DataStax -> ScyllaDB), add
  pytest.importorskip guards for pytest-benchmark and Cython.
- Add 11 unit tests for cache hit/miss, clear, eviction bounds,
  and size reporting.
- Add clear_deserializer_caches() calls to integration test for
  DesBytesType override.
- Add regression tests proving VectorType parameterizations that
  differ only by subtype (e.g. VectorType<float> vs VectorType<smallint>)
  get distinct, non-colliding cache entries: apply_parameters() never
  interns, so each is a fresh class object, and find_deserializer()/
  make_deserializers() key on that object's identity rather than its
  name (VectorType's generated __name__ does not encode the subtype,
  only vector_size, so a name-based cache key would have been unsafe
  here).
@mykaul
mykaul force-pushed the perf/cache-deserializer-lookup branch from e5fe679 to 91549a0 Compare July 31, 2026 18:32
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