Skip to content

(improvement) Optimize VectorType deserialization with struct.unpack and numpy (us level improvements - 2-13x speedup - Python path only!) - #730

Open
mykaul wants to merge 3 commits into
scylladb:masterfrom
mykaul:vector-struct-numpy-deser
Open

(improvement) Optimize VectorType deserialization with struct.unpack and numpy (us level improvements - 2-13x speedup - Python path only!)#730
mykaul wants to merge 3 commits into
scylladb:masterfrom
mykaul:vector-struct-numpy-deser

Conversation

@mykaul

@mykaul mykaul commented Mar 7, 2026

Copy link
Copy Markdown

Summary

  • Replace element-by-element VectorType deserialization with bulk struct.unpack for known numeric types (float, double, int32, int64, short), caching a struct.Struct object at type-creation time
  • Add numpy fast-path (np.frombuffer().tolist()) for vectors with >= 32 elements
  • Cache serial_size() results to eliminate per-call method dispatch overhead
  • Fix exception handling in variable-size vector path: remove dead KeyError catch, wrap subtype.deserialize failures with element context and proper exception chaining

Performance (pure Python, best of 5)

Deserialization:

Vector Config Master PR #730 Speedup
Vector<float, 4> 1.12 us 0.22 us 5.1x
Vector<float, 16> 3.23 us 0.35 us 9.2x
Vector<float, 128> 23.46 us 1.91 us 12.3x
Vector<float, 768> 146.07 us 11.22 us 13.0x
Vector<float, 1536> 293.27 us 21.98 us 13.3x

Serialization:

Vector Config Master PR #730 Speedup
Vector<float, 4> 0.55 us 0.16 us 3.4x
Vector<float, 16> 1.67 us 0.24 us 7.0x
Vector<float, 128> 11.15 us 1.01 us 11.0x
Vector<float, 768> 62.53 us 5.12 us 12.2x
Vector<float, 1536> 123.69 us 10.82 us 11.4x

serial_size() overhead:

Master PR #730 Speedup
serial_size() call (768-dim) 104 ns 50 ns 2.1x

Details

Commit 1 -- struct.unpack optimization + variable-size path fixes:

  • At apply_parameters() time, cache a struct.Struct('>Nf') for the vector's subtype+dimension
  • deserialize() calls list(struct.unpack(byts)) -- single C-level bulk unpack
  • Also optimizes serialization via struct.pack(*v)
  • Fallback for non-numeric fixed-size types uses pre-allocated result list + cached method reference
  • Variable-size path: remove dead KeyError from except clause (uvint_unpack only raises IndexError), wrap subtype.deserialize failures in ValueError with element index and proper exception chaining (from e)

Commit 2 -- numpy for large vectors:

  • For vectors >= 32 elements with a known numeric dtype, use np.frombuffer(byts, dtype='>f4', count=N).tolist()
  • numpy avoids intermediate Python object creation during unpacking; .tolist() batch-converts with better cache locality
  • Threshold of 32 chosen empirically: below this, struct.unpack is faster due to lower fixed overhead
  • _numpy_dtype cached on the class at type-creation time (no per-call dict construction)

Commit 3 -- serial_size caching:

  • Cache subtype.serial_size() result as _subtype_serial_size and the full vector serial size as _serial_size during apply_parameters()
  • serial_size() returns cached value directly (no method dispatch chain)
  • serialize() and deserialize() use cls._subtype_serial_size instead of calling cls.subtype.serial_size() each time
  • Eliminates ~50ns overhead per serialize/deserialize call

All three commits modify only cassandra/cqltypes.py. No Cython dependency.

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 optimizes VectorType (de)serialization in cassandra/cqltypes.py by introducing bulk numeric (de)serialization via a cached struct.Struct, and an optional numpy-based deserialization fast path for larger vectors.

Changes:

  • Cache a per-parameterized-vector struct.Struct to bulk unpack/pack common numeric vector subtypes.
  • Add an optional numpy frombuffer(...).tolist() deserialization fast-path for vectors with vector_size >= 32.
  • Refactor variable-size vector deserialization to a fixed-iteration loop with stricter bounds checks.

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

Comment thread cassandra/cqltypes.py
Comment thread cassandra/cqltypes.py
Comment thread cassandra/cqltypes.py
@mykaul
mykaul force-pushed the vector-struct-numpy-deser branch from c417e73 to 0535ecd Compare April 2, 2026 10:52
@mykaul mykaul self-assigned this Apr 2, 2026
@mykaul
mykaul marked this pull request as ready for review April 2, 2026 12:22
@mykaul mykaul changed the title (improvement) Optimize VectorType deserialization with struct.unpack and numpy (improvement) Optimize VectorType deserialization with struct.unpack and numpy (us level improvements - 2-13x speedup - Python path only) Apr 7, 2026
@mykaul mykaul changed the title (improvement) Optimize VectorType deserialization with struct.unpack and numpy (us level improvements - 2-13x speedup - Python path only) (improvement) Optimize VectorType deserialization with struct.unpack and numpy (us level improvements - 2-13x speedup - Python path only!) Apr 7, 2026
@Lorak-mmk

Copy link
Copy Markdown

@mykaul This is not a draft, but review was not requested. Please either change to draft, or request review.

@mykaul

mykaul commented May 20, 2026

Copy link
Copy Markdown
Author

@mykaul This is not a draft, but review was not requested. Please either change to draft, or request review.

It's an improvement, not a fix. I believe it's ready, but I don't want to disrupt the team. I'm not sure what to do (and I do it for fun anyway). If there's anything that I see as important - I'm not shy.

mykaul added 3 commits July 29, 2026 23:22
…ct.unpack

Add bulk deserialization using struct.unpack for common numeric vector types
instead of element-by-element deserialization. This provides significant
performance improvements, especially for small vectors and integer types.

Optimized types:
- FloatType  ('>Nf' format)
- DoubleType ('>Nd' format)
- Int32Type  ('>Ni' format)
- LongType   ('>Nq' format)

ShortType (smallint) and ByteType (tinyint) are intentionally NOT included,
even though they have a fixed in-memory representation: real Cassandra 5.0
does not treat them as fixed-width for vector serialization
(AbstractType.valueLengthIfFixed() defaults to variable-length, and neither
ShortType.java nor ByteType.java override it), so their vector elements are
vint-length-prefixed on the wire like any other variable-size type. Treating
them as fixed-width here would produce a wire format a real server can't
parse.

Performance improvements (measured with CASS_DRIVER_NO_CYTHON=1):

Small vectors (3-4 elements):
  Vector<float, 3>  : 0.88 μs → 0.25 μs  (3.58x faster)
  Vector<float, 4>  : 0.78 μs → 0.28 μs  (2.79x faster)

Medium vectors (128 elements):
  Vector<float, 128>  : 4.72 μs → 4.06 μs  (1.16x faster)
  Vector<double, 128> : 4.83 μs → 4.01 μs  (1.20x faster)
  Vector<int, 128>    : 2.27 μs → 1.25 μs  (1.82x faster)

Large vectors (384-1536 elements):
  Vector<float, 384>  : 15.38 μs → 14.67 μs  (1.05x faster)
  Vector<float, 768>  : 32.43 μs → 30.72 μs  (1.06x faster)
  Vector<float, 1536> : 63.74 μs → 63.24 μs  (1.01x faster)

The optimization is most effective for:
- Small vectors (3-4 elements): 2.8-3.6x speedup
- Integer vectors: 1.8x speedup
- Medium-sized float/double vectors: 1.2-1.3x speedup

For very large vectors (384+ elements), the benefit is minimal as the
deserialization time is dominated by data copying rather than function
call overhead.

Variable-size subtypes and other numeric types continue to use the
element-by-element fallback path.

Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
For vectors with 32 or more elements, use numpy.frombuffer() which provides
1.3-1.5x speedup for large vectors (128+ elements) compared to struct.unpack.

The hybrid approach:
- Small vectors (< 32 elements): struct.unpack (2.8-3.6x faster than baseline)
- Large vectors (>= 32 elements): numpy.frombuffer().tolist() (1.3-1.5x faster than struct.unpack)

Threshold of 32 elements balances code complexity with performance gains.
_numpy_dtype_map has no entry for ShortType ('h'), matching
_struct_format_map: smallint vectors are variable-length on the wire on
real Cassandra 5.0, so they never take this fast path.

Probe for numpy directly (try/except import) instead of importing HAVE_NUMPY
from cassandra.cython_deps. cassandra.cython_deps imports cassandra.row_parser
(Cython row parser), which imports cassandra.deserializers, which imports
this module (cqltypes) back. If cqltypes is what first pulls in
cassandra.cython_deps, and cassandra.cython_deps (or cassandra.row_parser)
happens to be the first "cassandra.*" submodule imported in the process,
that cycle closes on a partially-initialized cassandra.cython_deps module
that hasn't set HAVE_CYTHON/HAVE_NUMPY yet, causing an uncaught ImportError
that cython_deps' own try/except then swallows -- permanently (and
incorrectly) recording HAVE_CYTHON as False for the rest of the process
even when Cython is available. Verified end-to-end: e.g. `import
cassandra.cython_deps` (or `tests.unit.cython.utils`, which does the same
thing) as the first cassandra import in a process previously left
HAVE_CYTHON False; with this change it correctly reports True.

Benchmark results:
- float[128]:  2.15 μs → 1.87 μs (1.15x faster)
- float[384]:  6.17 μs → 4.44 μs (1.39x faster)
- float[768]: 12.25 μs → 8.45 μs (1.45x faster)
- float[1536]: 24.44 μs → 15.77 μs (1.55x faster)

Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
…ated method dispatch

Cache subtype.serial_size() and the full vector serial_size() as class
attributes (_subtype_serial_size, _serial_size) during apply_parameters().
This eliminates per-call method dispatch overhead in serialize(),
deserialize(), and serial_size() hot paths.

serial_size() call: 99ns -> 46ns (2.2x faster)
Attribute access: 54ns -> 17ns (3.2x faster)

While here, compute subtype_ss before building the struct/numpy fast-path
cache and gate that cache on `subtype_ss is not None` as a second line of
defense: only subtypes with a genuine fixed serial_size() (FloatType,
DoubleType, Int32Type, LongType) may populate _vector_struct/_numpy_dtype.
This guards against ShortType/ByteType (or any future variable-length type
mistakenly added to _struct_format_map) ever taking the fixed-width fast
path -- real Cassandra 5.0 vint-length-prefixes smallint/tinyint vector
elements, so treating them as fixed-width would misparse real vector data.

Add a regression test (test_short_and_byte_vectors_use_variable_length_wire_format)
that asserts ShortType/ByteType vectors serialize using vint-length-prefixed
elements, not a flat fixed-width encoding.

Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
Copilot AI review requested due to automatic review settings July 29, 2026 20:34
@mykaul
mykaul force-pushed the vector-struct-numpy-deser branch from 75b9b75 to c9219e2 Compare July 29, 2026 20:34
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mykaul, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 40df0b4e-d2fc-4c8f-99f4-e290b40e7e7a

📥 Commits

Reviewing files that changed from the base of the PR and between b8b714c and c9219e2.

📒 Files selected for processing (2)
  • cassandra/cqltypes.py
  • tests/unit/test_types.py

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 (no conflicts) and did a focused audit + fix pass, based on lessons learned from the smallint/tinyint VectorType issue found on #689 during Cassandra 5.0 wire-format verification.

ShortType/ByteType fixed-width bug: present, now fixed

_struct_format_map included ShortType: 'h' (and _numpy_dtype_map had a matching 'h': '>i2'), implying smallint is fixed-width for the struct/numpy fast path. Per the real Cassandra 5.0 source (AbstractType.valueLengthIfFixed() defaults to variable-length; neither ShortType.java nor ByteType.java override it), smallint/tinyint vector elements are vint-length-prefixed on the wire, not fixed-width. Encoding/decoding them via struct/numpy as fixed 2-byte values would produce a wire format a real server can't parse.

In practice this PR's own gating (serialized_size is not None, derived from subtype.serial_size(), which is None for ShortType) already prevented the fast path from actually firing for smallint — so it wasn't a live bug in this PR, just latent/dead-code that misrepresented smallint as fixed-width and could easily be reintroduced by a future refactor (e.g. simplifying the gating to check only _vector_struct is not None). Fixed by:

  • Removing ShortType/'h' from both maps, with an explanatory NOTE (mirroring the existing TimeType comment on the same subject).
  • Restructuring apply_parameters() to only ever populate _vector_struct/_numpy_dtype when subtype.serial_size() is not None — a structural second line of defense, not just relying on the maps being curated correctly.
  • Added a regression test, test_short_and_byte_vectors_use_variable_length_wire_format, asserting the actual serialized byte length for Vector<smallint,4>/Vector<tinyint,4> matches vint-prefixed encoding (not flat fixed-width).

Second bug found: real cython_deps/row_parser circular import

The new module-level from cassandra.cython_deps import HAVE_NUMPY in cqltypes.py closes an import cycle: cqltypes -> cython_deps -> row_parser -> deserializers -> cqltypes. Verified empirically (with Cython extensions actually built) that whenever cassandra.cython_deps (or anything upstream of it, e.g. cassandra.row_parser, or tests/unit/cython/utils.py in isolation) is the first cassandra.* submodule imported in a process, the cycle closes on a partially-initialized cassandra.cython_deps — its own from cassandra.row_parser import ... is still executing, so HAVE_NUMPY/HAVE_CYTHON aren't set yet, cqltypes.py's plain import raises an uncaught ImportError, and cython_deps's own try/except swallows it, permanently (and incorrectly) recording HAVE_CYTHON = False for the rest of the process — even though Cython is actually available. Normal import cassandra.cluster/cassandra.cqltypes entry points happen to dodge this because cqltypes gets fully loaded before cython_deps in that ordering, but it's a real footgun (e.g. tests/unit/cython/utils.py would hit it if not for tests/__init__.py's own import side effects masking it). Fixed by having cqltypes.py probe for numpy directly (try: import numpy; except ImportError:) instead of importing HAVE_NUMPY from cython_deps, which removes the cycle entirely. Verified before/after with the actual compiled extensions.

Other checks

  • CI on the pre-rebase tip was green (build, all test asyncio/asyncore/libev matrix jobs, snyk). No infra flakiness observed.
  • All 3 existing review threads (Copilot: numpy import overhead, exception-handling gap, missing numpy-path test) were already resolved; nothing unresolved to act on. Confirmed still resolved after the force push.
  • No redundant/overlapping VectorType work on mastermaster's VectorType is still the original element-by-element implementation, so no conflict with this PR's optimizations.
  • tests/unit/test_types.py (VectorType tests + new regression test) and the full tests/unit/ suite pass: 771 passed / 38 skipped (all pre-existing, unrelated skips), plus tests/unit/cython/ (6 passed) with Cython extensions actually built and HAVE_CYTHON verified True.

All fixes were amended into the original 3 commits (not new commits) and force-pushed.

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

Comments suppressed due to low confidence (3)

cassandra/cqltypes.py:1456

  • apply_parameters() uses cls._numpy_dtype_map.get(...), but the class definition doesn’t define _numpy_dtype_map (it’s only assigned later at module scope). To avoid potential AttributeError during unusual import ordering / partial initialization (and to make the class’ expected attributes explicit), initialize _numpy_dtype_map = {} alongside _struct_format_map in the class body.
    _vector_struct = None  # Cached struct.Struct for bulk deserialization
    _struct_format_map = {}  # Populated after FloatType etc. are defined
    _numpy_dtype = None  # Cached numpy dtype string for large vector deserialization

tests/unit/test_types.py:419

  • This test appears to live in a unittest.TestCase-style suite, but it uses bare assert statements. Bare asserts can be skipped with Python optimizations (-O) and typically produce less helpful failure output compared to self.assert* methods. Recommend switching to self.assertIsNone(...) / self.assertEqual(...) for consistency and clearer diagnostics.
            assert ctype.subtype.serial_size() is None
            assert ctype.serial_size() is None

tests/unit/test_types.py:427

  • This test appears to live in a unittest.TestCase-style suite, but it uses bare assert statements. Bare asserts can be skipped with Python optimizations (-O) and typically produce less helpful failure output compared to self.assert* methods. Recommend switching to self.assertIsNone(...) / self.assertEqual(...) for consistency and clearer diagnostics.
            assert len(data_bytes) == len(data) * (1 + packed_value_size)
            assert ctype.deserialize(data_bytes, 0) == data

Comment thread cassandra/cqltypes.py
Comment on lines +1472 to +1474
subtype_ss = subtype.serial_size()
vec_ss = vsize * subtype_ss if subtype_ss is not None else None
# Cache a struct.Struct for bulk deserialization of known numeric types.
Comment thread cassandra/cqltypes.py
Comment on lines +1494 to 1496
serialized_size = cls._subtype_serial_size
if serialized_size is not None:
expected_byte_size = serialized_size * cls.vector_size
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.

3 participants