From 1f18192fb48c74ff2895fedc5da53abf0a12f6cf Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 5 Feb 2026 16:41:45 +0200 Subject: [PATCH 1/3] (improvement) cqltypes: Optimize VectorType deserialization with struct.unpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : 0.88 μs → 0.25 μs (3.58x faster) Vector : 0.78 μs → 0.28 μs (2.79x faster) Medium vectors (128 elements): Vector : 4.72 μs → 4.06 μs (1.16x faster) Vector : 4.83 μs → 4.01 μs (1.20x faster) Vector : 2.27 μs → 1.25 μs (1.82x faster) Large vectors (384-1536 elements): Vector : 15.38 μs → 14.67 μs (1.05x faster) Vector : 32.43 μs → 30.72 μs (1.06x faster) Vector : 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 --- cassandra/cqltypes.py | 98 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index 99018eef03..bcd7908425 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -1432,6 +1432,8 @@ class VectorType(_CassandraType): typename = 'org.apache.cassandra.db.marshal.VectorType' vector_size = 0 subtype = None + _vector_struct = None # Cached struct.Struct for bulk deserialization + _struct_format_map = {} # Populated after FloatType etc. are defined @classmethod def serial_size(cls): @@ -1443,7 +1445,14 @@ def apply_parameters(cls, params, names): assert len(params) == 2 subtype = lookup_casstype(params[0]) vsize = params[1] - return type('%s(%s)' % (cls.cass_parameterized_type_with([]), vsize), (cls,), {'vector_size': vsize, 'subtype': subtype}) + # Cache a struct.Struct for bulk deserialization of known numeric types + vector_struct = None + for base_type, fmt_char in cls._struct_format_map.items(): + if subtype is base_type or (isinstance(subtype, type) and issubclass(subtype, base_type)): + vector_struct = struct.Struct(f'>{vsize}{fmt_char}') + break + return type('%s(%s)' % (cls.cass_parameterized_type_with([]), vsize), (cls,), + {'vector_size': vsize, 'subtype': subtype, '_vector_struct': vector_struct}) @classmethod def deserialize(cls, byts, protocol_version): @@ -1454,25 +1463,64 @@ def deserialize(cls, byts, protocol_version): raise ValueError( "Expected vector of type {0} and dimension {1} to have serialized size {2}; observed serialized size of {3} instead"\ .format(cls.subtype.typename, cls.vector_size, expected_byte_size, len(byts))) - indexes = (serialized_size * x for x in range(0, cls.vector_size)) - return [cls.subtype.deserialize(byts[idx:idx + serialized_size], protocol_version) for idx in indexes] + # Optimization: bulk deserialization for common numeric types + # For small vectors: use cached struct.Struct (avoids per-call format string allocation) + # For large vectors with numpy: use numpy.frombuffer (1.3-1.5x faster for 128+ elements) + # Threshold at 32 elements balances simplicity with performance + if cls._vector_struct is not None: + use_numpy = HAVE_NUMPY and cls.vector_size >= 32 + if use_numpy: + _dtype_map = {'f': '>f4', 'd': '>f8', 'i': '>i4', 'q': '>i8'} + fmt_char = cls._vector_struct.format[-1:] + numpy_dtype = _dtype_map.get(fmt_char) + if numpy_dtype is not None: + return np.frombuffer(byts, dtype=numpy_dtype, count=cls.vector_size).tolist() + return list(cls._vector_struct.unpack(byts)) + # Fallback: element-by-element deserialization for other fixed-size types + result = [None] * cls.vector_size + subtype_deserialize = cls.subtype.deserialize + offset = 0 + for i in range(cls.vector_size): + result[i] = subtype_deserialize(byts[offset:offset + serialized_size], protocol_version) + offset += serialized_size + return result + + # Variable-size subtype path + result = [None] * cls.vector_size idx = 0 - rv = [] - while (len(rv) < cls.vector_size): + byts_len = len(byts) + subtype_deserialize = cls.subtype.deserialize + + for i in range(cls.vector_size): + if idx >= byts_len: + raise ValueError("Error reading additional data during vector deserialization after successfully adding {} elements" + .format(i)) + try: size, bytes_read = uvint_unpack(byts[idx:]) - idx += bytes_read - rv.append(cls.subtype.deserialize(byts[idx:idx + size], protocol_version)) - idx += size - except: - raise ValueError("Error reading additional data during vector deserialization after successfully adding {} elements"\ - .format(len(rv))) - - # If we have any additional data in the serialized vector treat that as an error as well - if idx < len(byts): + except IndexError: + raise ValueError("Error reading additional data during vector deserialization after successfully adding {} elements" + .format(i)) + + idx += bytes_read + + if idx + size > byts_len: + raise ValueError("Error reading additional data during vector deserialization after successfully adding {} elements" + .format(i)) + + try: + result[i] = subtype_deserialize(byts[idx:idx + size], protocol_version) + except Exception as e: + raise ValueError("Error deserializing element {} during vector deserialization after successfully adding {} elements" + .format(i, i)) from e + idx += size + + # Check for additional data + if idx < byts_len: raise ValueError("Additional bytes remaining after vector deserialization completed") - return rv + + return result @classmethod def serialize(cls, v, protocol_version): @@ -1483,6 +1531,9 @@ def serialize(cls, v, protocol_version): .format(cls.vector_size, cls.subtype.typename, v_length)) serialized_size = cls.subtype.serial_size() + # Bulk serialization for known numeric types (symmetric with struct.unpack in deserialize) + if cls._vector_struct is not None and serialized_size is not None: + return cls._vector_struct.pack(*v) buf = io.BytesIO() for item in v: item_bytes = cls.subtype.serialize(item, protocol_version) @@ -1494,3 +1545,20 @@ def serialize(cls, v, protocol_version): @classmethod def cql_parameterized_type(cls): return "%s<%s, %s>" % (cls.typename, cls.subtype.cql_parameterized_type(), cls.vector_size) + + +# Populate VectorType._struct_format_map now that all types are defined. +# NOTE: ShortType (smallint) and ByteType (tinyint) are intentionally excluded. +# Although they have a fixed in-memory representation, 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, same as any +# other variable-size type. Only include types here that have a real fixed +# serial_size(); apply_parameters() also gates on subtype.serial_size() is not +# None as a second line of defense. +VectorType._struct_format_map = { + FloatType: 'f', + DoubleType: 'd', + Int32Type: 'i', + LongType: 'q', +} From 530bd8b33d3b143fd2d74df053f12e1a81303276 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Thu, 5 Feb 2026 16:56:52 +0200 Subject: [PATCH 2/3] (improvement) cqltypes: Use numpy for large VectorType deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cassandra/cqltypes.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index bcd7908425..88898948ff 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -51,6 +51,25 @@ vints_pack, vints_unpack, uvint_unpack, uvint_pack) from cassandra import util +# NOTE: deliberately not importing this from cassandra.cython_deps. That +# module imports cassandra.row_parser (the Cython row parser), which in turn +# imports cassandra.deserializers, which imports this module (cqltypes) back. +# If cqltypes is what pulls in cassandra.cython_deps, and cassandra.cython_deps +# (or anything upstream of it, like 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: its own +# `from cassandra.row_parser import ...` is still executing, so it hasn't +# even set HAVE_CYTHON/HAVE_NUMPY yet, and this module's plain `import` +# statement would raise an uncaught ImportError -- which cython_deps' +# try/except then swallows, permanently (mis)recording HAVE_CYTHON as False +# for the rest of the process, even though Cython is actually available. +# Doing our own independent numpy probe here avoids creating that cycle. +try: + import numpy as np + HAVE_NUMPY = True +except ImportError: + HAVE_NUMPY = False + _little_endian_flag = 1 # we always serialize LE import ipaddress @@ -1434,6 +1453,7 @@ class VectorType(_CassandraType): subtype = None _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 @classmethod def serial_size(cls): @@ -1447,12 +1467,14 @@ def apply_parameters(cls, params, names): vsize = params[1] # Cache a struct.Struct for bulk deserialization of known numeric types vector_struct = None + numpy_dtype = None for base_type, fmt_char in cls._struct_format_map.items(): if subtype is base_type or (isinstance(subtype, type) and issubclass(subtype, base_type)): vector_struct = struct.Struct(f'>{vsize}{fmt_char}') + numpy_dtype = cls._numpy_dtype_map.get(fmt_char) break return type('%s(%s)' % (cls.cass_parameterized_type_with([]), vsize), (cls,), - {'vector_size': vsize, 'subtype': subtype, '_vector_struct': vector_struct}) + {'vector_size': vsize, 'subtype': subtype, '_vector_struct': vector_struct, '_numpy_dtype': numpy_dtype}) @classmethod def deserialize(cls, byts, protocol_version): @@ -1469,13 +1491,8 @@ def deserialize(cls, byts, protocol_version): # For large vectors with numpy: use numpy.frombuffer (1.3-1.5x faster for 128+ elements) # Threshold at 32 elements balances simplicity with performance if cls._vector_struct is not None: - use_numpy = HAVE_NUMPY and cls.vector_size >= 32 - if use_numpy: - _dtype_map = {'f': '>f4', 'd': '>f8', 'i': '>i4', 'q': '>i8'} - fmt_char = cls._vector_struct.format[-1:] - numpy_dtype = _dtype_map.get(fmt_char) - if numpy_dtype is not None: - return np.frombuffer(byts, dtype=numpy_dtype, count=cls.vector_size).tolist() + if HAVE_NUMPY and cls.vector_size >= 32 and cls._numpy_dtype is not None: + return np.frombuffer(byts, dtype=cls._numpy_dtype, count=cls.vector_size).tolist() return list(cls._vector_struct.unpack(byts)) # Fallback: element-by-element deserialization for other fixed-size types result = [None] * cls.vector_size @@ -1562,3 +1579,8 @@ def cql_parameterized_type(cls): Int32Type: 'i', LongType: 'q', } + +# Map struct format chars to numpy dtype strings for large vector deserialization. +# Kept in sync with _struct_format_map above -- no entry for 'h' (ShortType), +# since smallint vectors are variable-length on the wire (see the NOTE above). +VectorType._numpy_dtype_map = {'f': '>f4', 'd': '>f8', 'i': '>i4', 'q': '>i8'} From c9219e232fbffb401c9e264a6346a9eb73a397e7 Mon Sep 17 00:00:00 2001 From: Yaniv Michael Kaul Date: Sun, 5 Apr 2026 17:20:43 +0300 Subject: [PATCH 3/3] (improvement) cqltypes: Cache serial_size in VectorType to avoid repeated 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 --- cassandra/cqltypes.py | 35 ++++++++++++++++++++++++----------- tests/unit/test_types.py | 30 ++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/cassandra/cqltypes.py b/cassandra/cqltypes.py index 88898948ff..8cf013a4aa 100644 --- a/cassandra/cqltypes.py +++ b/cassandra/cqltypes.py @@ -1454,31 +1454,44 @@ class VectorType(_CassandraType): _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 + _subtype_serial_size = None # Cached subtype.serial_size() (computed once in apply_parameters) + _serial_size = None # Cached serial_size() for the full vector (subtype_serial_size * vector_size) @classmethod def serial_size(cls): - serialized_size = cls.subtype.serial_size() - return cls.vector_size * serialized_size if serialized_size is not None else None + return cls._serial_size + @classmethod def apply_parameters(cls, params, names): assert len(params) == 2 subtype = lookup_casstype(params[0]) vsize = params[1] - # Cache a struct.Struct for bulk deserialization of known numeric types + # Cache subtype serial_size and full vector serial_size to avoid + # repeated method dispatch in serialize/deserialize hot paths. + 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. + # Only ever consider this for subtypes that genuinely have a fixed + # serialized size (subtype_ss is not None): the wire format for any + # type without a fixed size is vint-length-prefixed per element (e.g. + # smallint/tinyint on Cassandra 5.0, see ShortType/ByteType above), and + # treating it as fixed-width would misparse real vector data. vector_struct = None numpy_dtype = None - for base_type, fmt_char in cls._struct_format_map.items(): - if subtype is base_type or (isinstance(subtype, type) and issubclass(subtype, base_type)): - vector_struct = struct.Struct(f'>{vsize}{fmt_char}') - numpy_dtype = cls._numpy_dtype_map.get(fmt_char) - break + if subtype_ss is not None: + for base_type, fmt_char in cls._struct_format_map.items(): + if subtype is base_type or (isinstance(subtype, type) and issubclass(subtype, base_type)): + vector_struct = struct.Struct(f'>{vsize}{fmt_char}') + numpy_dtype = cls._numpy_dtype_map.get(fmt_char) + break return type('%s(%s)' % (cls.cass_parameterized_type_with([]), vsize), (cls,), - {'vector_size': vsize, 'subtype': subtype, '_vector_struct': vector_struct, '_numpy_dtype': numpy_dtype}) + {'vector_size': vsize, 'subtype': subtype, '_vector_struct': vector_struct, + '_numpy_dtype': numpy_dtype, '_subtype_serial_size': subtype_ss, '_serial_size': vec_ss}) @classmethod def deserialize(cls, byts, protocol_version): - serialized_size = cls.subtype.serial_size() + serialized_size = cls._subtype_serial_size if serialized_size is not None: expected_byte_size = serialized_size * cls.vector_size if len(byts) != expected_byte_size: @@ -1547,7 +1560,7 @@ def serialize(cls, v, protocol_version): "Expected sequence of size {0} for vector of type {1} and dimension {0}, observed sequence of length {2}"\ .format(cls.vector_size, cls.subtype.typename, v_length)) - serialized_size = cls.subtype.serial_size() + serialized_size = cls._subtype_serial_size # Bulk serialization for known numeric types (symmetric with struct.unpack in deserialize) if cls._vector_struct is not None and serialized_size is not None: return cls._vector_struct.pack(*v) diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 11aab2748d..1f869c99dd 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -22,8 +22,8 @@ import cassandra from cassandra import util from cassandra.cqltypes import ( - CassandraType, DateRangeType, DateType, DecimalType, - EmptyValue, LongType, SetType, UTF8Type, + ByteType, CassandraType, DateRangeType, DateType, DecimalType, + EmptyValue, LongType, SetType, ShortType, UTF8Type, cql_typename, int8_pack, int64_pack, int64_unpack, lookup_casstype, lookup_casstype_simple, parse_casstype_args, int32_pack, Int32Type, ListType, MapType, VectorType, @@ -400,6 +400,32 @@ def test_round_trip_basic_types_without_fixed_serialized_size(self): self._round_trip_test([util.Duration(1,1,1), util.Duration(2,2,2), util.Duration(3,3,3)], \ "org.apache.cassandra.db.marshal.VectorType(org.apache.cassandra.db.marshal.DurationType, 3)") + def test_short_and_byte_vectors_use_variable_length_wire_format(self): + # Regression test: smallint (ShortType) and tinyint (ByteType) have a + # fixed in-memory representation, but real Cassandra 5.0 does NOT treat + # them as fixed-width for vector (de)serialization -- AbstractType's + # valueLengthIfFixed() defaults to variable-length, and neither + # ShortType.java nor ByteType.java override it. So, like TimeType + # above, their vector elements must be vint-length-prefixed on the + # wire, not packed as a fixed number of bytes per element. This guards + # against ShortType/ByteType ever being (re-)added to a fixed-width + # struct/numpy fast-path map for VectorType, which would silently + # produce a wire format a real server can't parse. + for subtype, packed_value_size in ((ShortType, 2), (ByteType, 1)): + ctype = parse_casstype_args( + "org.apache.cassandra.db.marshal.VectorType(org.apache.cassandra.db.marshal.%s, 4)" + % subtype.__name__) + assert ctype.subtype.serial_size() is None + assert ctype.serial_size() is None + + data = [3, -2, 100, -100] + data_bytes = ctype.serialize(data, 0) + # 1 vint length byte + the packed value per element -- NOT a flat + # packed_value_size * len(data), which is what a fixed-width fast + # path would (incorrectly) produce. + assert len(data_bytes) == len(data) * (1 + packed_value_size) + assert ctype.deserialize(data_bytes, 0) == data + def test_round_trip_collection_types(self): # List (subtype of fixed size) self._round_trip_test([[1, 2, 3, 4], [5, 6], [7, 8, 9, 10], [11, 12]], \