[DO NOT MERGE] (Improvement) improve performance of Vector type parsing - #689
[DO NOT MERGE] (Improvement) improve performance of Vector type parsing#689mykaul wants to merge 17 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request implements comprehensive performance optimizations for VectorType deserialization across multiple layers of the Python driver stack. The changes introduce optimized deserialization paths using struct.unpack for small vectors, numpy.frombuffer for large vectors (when NumPy is available), and a new Cython DesVectorType deserializer that uses low-level C operations with ntohl/ntohs intrinsics for efficient byte-swapping.
Changes:
- Added Cython-based DesVectorType deserializer with optimized paths for float, double, int32, int64, and int16 vector types
- Enhanced Python-level VectorType deserialization with struct.unpack and numpy.frombuffer optimizations
- Extended NumpyParser to create 2D arrays for vector types, enabling efficient batch processing
- Optimized low-level byte-swap operations using ntohl/ntohs intrinsics and simplified varint_unpack using int.from_bytes
- Removed slice_buffer function in favor of simpler from_ptr_and_size for direct pointer manipulation
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_types.py | Adds comprehensive tests for Cython DesVectorType deserializer covering float, double, int32, int64, and int16 vectors |
| tests/unit/test_numpy_parser.py | New test suite for NumPy parser 2D array support for vectors with mixed column types and large dimensions |
| cassandra/cqltypes.py | Python-level VectorType optimizations using struct.unpack and numpy.frombuffer, plus improved variable-size vector handling |
| cassandra/deserializers.pyx | New Cython DesVectorType class with type-specific optimized deserialization methods |
| cassandra/numpy_parser.pyx | Enhanced to create 2D NumPy arrays for VectorType columns and pre-allocate arrays list |
| cassandra/cython_marshal.pyx | Optimized unpack_num to use ntohl/ntohs intrinsics and simplified varint_unpack |
| cassandra/ioutils.pyx | Optimized read_int to use ntohl directly |
| cassandra/marshal.py | Simplified varint_unpack using int.from_bytes |
| cassandra/buffer.pxd | Replaced slice_buffer with simpler from_ptr_and_size function |
| benchmarks/vector_deserialize.py | New comprehensive benchmark suite comparing different optimization strategies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Per discussion earlier, Iv'e changed to optimize only for float/double/int, since those are the more frequently used and are not troublesome protocol-wise. I'll submit a fixed version of this series. |
|
Improvements in μs are not worth pursuing right now. Perhaps in the future, I'll extract some parts of it. |
866984c to
7829b6c
Compare
…bytes Replace the manual string-formatting hex conversion in varint_unpack() and the byte-by-byte bytearray loop in varint_pack() with Python 3 builtins int.from_bytes() and int.to_bytes(). varint_unpack used '%02x' formatting per byte, str.join, then int(..., 16) to parse back — O(n) string allocations. int.from_bytes is a single C-level call. varint_pack used a while loop appending individual bytes to a bytearray, then reversing. int.to_bytes computes the result in one C call. Also fixes the Cython path in cython_marshal.pyx which had the same slow pattern with a TODO comment to optimize. Adapted from PR scylladb#689 (varint_unpack) with new varint_pack implementation. varint_pack medium: 643 -> 90 ns/call (7.1x faster) varint_pack large: 1109 -> 96 ns/call (11.6x faster) varint_unpack medium: 1086 -> 115 ns/call (9.4x faster) varint_unpack large: 1940 -> 146 ns/call (13.3x faster)
…bytes Replace the manual string-formatting hex conversion in varint_unpack() and the byte-by-byte bytearray loop in varint_pack() with Python 3 builtins int.from_bytes() and int.to_bytes(). varint_unpack used '%02x' formatting per byte, str.join, then int(..., 16) to parse back — O(n) string allocations. int.from_bytes is a single C-level call. varint_pack used a while loop appending individual bytes to a bytearray, then reversing. int.to_bytes computes the result in one C call. Also fixes the Cython path in cython_marshal.pyx which had the same slow pattern with a TODO comment to optimize. Adapted from PR scylladb#689 (varint_unpack) with new varint_pack implementation. varint_pack medium: 643 -> 90 ns/call (7.1x faster) varint_pack large: 1109 -> 96 ns/call (11.6x faster) varint_unpack medium: 1086 -> 115 ns/call (9.4x faster) varint_unpack large: 1940 -> 146 ns/call (13.3x faster)
…bytes Replace the manual string-formatting hex conversion in varint_unpack() and the byte-by-byte bytearray loop in varint_pack() with Python 3 builtins int.from_bytes() and int.to_bytes(). varint_unpack used '%02x' formatting per byte, str.join, then int(..., 16) to parse back — O(n) string allocations. int.from_bytes is a single C-level call. varint_pack used a while loop appending individual bytes to a bytearray, then reversing. int.to_bytes computes the result in one C call. Also fixes the Cython path in cython_marshal.pyx which had the same slow pattern with a TODO comment to optimize. Adapted from PR scylladb#689 (varint_unpack) with new varint_pack implementation. varint_pack medium: 643 -> 90 ns/call (7.1x faster) varint_pack large: 1109 -> 96 ns/call (11.6x faster) varint_unpack medium: 1086 -> 115 ns/call (9.4x faster) varint_unpack large: 1940 -> 146 ns/call (13.3x faster)
….from_bytes Performance improvements to serialization/deserialization hot paths: 1. unpack_num(): Use ntohs()/ntohl() for 16-bit and 32-bit integer types instead of byte-by-byte swapping loop. These compile to single bswap instructions on x86, providing more predictable performance. 2. read_int(): Simplify to use ntohl() directly instead of going through unpack_num() with a temporary Buffer. 3. varint_unpack(): Replace hex string conversion with int.from_bytes(). This eliminates string allocations and provides 4-18x speedup for the function itself (larger gains for longer varints). 4. Remove slice_buffer() and replaced with direct assignment 5. _unpack_len() is now implemented similar to read_int() Also removes unused 'start' and 'end' variables from unpack_num(). End-to-end benchmark shows ~4-5% improvement in row throughput. Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
Use hardware byte-swap intrinsic for float unmarshaling instead of manual 4-iteration loop, providing 4-8x speedup on little-endian systems. All tests passing (609 total) [see next commit for a fix for existing Cython related issue!] Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
Refactor deserializers.pyx to use from_ptr_and_size() consistently instead of manual Buffer field assignment for better code clarity and maintainability. Changes: - cassandra/deserializers.pyx: Refactor 4 locations to use helper Tests: All Cython tests compile and pass (5 tests) Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
Add comprehensive benchmark comparing different deserialization strategies for VectorType with various numeric types and vector sizes. The benchmark measures: - Current element-by-element baseline - struct.unpack bulk deserialization - numpy frombuffer with tolist() - numpy frombuffer zero-copy approach Tested with common ML/AI embedding dimensions: - Small vectors: 3-4 elements - Medium vectors: 128-384 elements - Large vectors: 768-1536 elements Usage: export CASS_DRIVER_NO_CYTHON=1 # Test pure Python implementation python benchmarks/vector_deserialize.py Includes CPU pinning for consistent measurements and result verification to ensure correctness of all optimization approaches. Baseline Performance (per-operation deserialization time): Vector<float, 3> : 0.88 μs Vector<float, 4> : 0.78 μs Vector<float, 128> : 4.72 μs Vector<float, 384> : 15.38 μs Vector<float, 768> : 32.43 μs Vector<float, 1536> : 63.74 μs Vector<double, 128> : 4.83 μs Vector<int, 128> : 2.27 μs Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
…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 ('>Nh' format)
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. 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>
…izer Addded DesVectorType Cython deserializer with C-level optimizations for improved performance in row parsing for vectors. The deserializer uses: - Direct C byte swapping (ntohl, ntohs) for numeric types - Memory operations without Python object overhead - Unified numpy path for large vectors (≥32 elements) - struct.unpack fallback for small vectors (<32 elements) Performance improvements: - Small vectors (3-4 elements): 4.4-4.7x faster - Medium vectors (128 elements): 1.0-1.5x faster - Large vectors (384-1536 elements): 0.9-1.0x (marginal) The Cython deserializer is automatically used by the row parser when available via find_deserializer(). Includes unit tests and benchmark code. Follow-up commits will try to get Numpy arrays, and perhaps more. Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
Extend NumpyParser to handle VectorType columns by creating 2D NumPy arrays (rows × vector_dimension) instead of object arrays. This enables zero-copy parsing for vector embeddings in ML/AI workloads. Features: - Detects VectorType via vector_size and subtype attributes - Creates 2D masked arrays for numeric vector subtypes (float, double, int32, int64, int16) - Falls back to object arrays for unsupported vector subtypes - Handles endianness conversion for both 1D and 2D arrays - Pre-allocates result arrays for efficiency Supported vector types: - Vector<float> → 2D float32 array - Vector<double> → 2D float64 array - Vector<int> → 2D int32 array - Vector<bigint> → 2D int64 array - Vector<smallint> → 2D int16 array Adds comprehensive test coverage for all supported vector types, mixed column queries, and large vector dimensions (384-element embeddings). Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
Replace POSIX-specific arpa/inet.h with conditional compilation that uses winsock2.h on Windows and arpa/inet.h on POSIX systems. This ensures the driver can be compiled on Windows without modification. Changes: - cassandra/cython_marshal.pyx: Add platform detection for ntohs/ntohl - cassandra/ioutils.pyx: Add platform detection for ntohl Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
…fallback ShortType (smallint) vector elements are vint-length-prefixed on the wire in real Cassandra (AbstractType.valueLengthIfFixed() / ShortType.java do not override the variable-length default), not fixed 2-byte values. It was incorrectly listed in VectorType._struct_format_map / _numpy_dtype_map as a fixed-width fast-path candidate: - In cqltypes.py this was dead code: VectorType.deserialize/serialize gate on subtype.serial_size() (which correctly returns None for ShortType) before ever consulting _vector_struct, so the fast path was never actually reachable for smallint vectors -- they always took the variable-size (vint-prefixed) path regardless. - In numpy_parser.pyx's make_array(), however, this was a live bug: VectorType subtype dtype resolution reused _cqltype_to_numpy (the dict for fixed-width *scalar column* types, where smallint genuinely is always 2 bytes) instead of the vector-element-specific _struct_format_map, so Vector<smallint, N> columns would incorrectly get allocated a fixed 2-byte-per-element 2D array and misparse the actual vint-prefixed wire data. Given real-world usage doesn't justify adding vint-handling to the optimized path, ShortType is removed from both maps instead. Vector<smallint> now correctly falls back to the generic/object-array path everywhere. Updates the affected tests accordingly (round-trip via serialize/deserialize instead of assuming fixed-width wire format; numpy_parser test now checks the object-array fallback allocation rather than a nonexistent fixed 2D array). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Follow-up: removed ShortType (smallint) from the VectorType fast-path optimization. Root cause: smallint is intentionally variable-length (vint-length-prefixed) on the wire in real Cassandra --
Since real-world usage doesn't justify adding vint-handling to the optimized path, ShortType is removed from both maps rather than patching in variable-length support. Rebuilt the Cython extensions and re-ran |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (3)
cassandra/numpy_parser.pyx:156
- Allocating fixed-width vectors as numeric arrays routes them through the raw
memcpyinunpack_row, which copies the server-suppliedbuf.sizeinto a row whose capacity isarr.stridewithout validating equality. An oversized vector can overwrite adjacent rows or heap memory, while an undersized one leaves uninitialized values. Validatebuf.size == arr.stridebefore copying.
if subtype in cqltypes.VectorType._struct_format_map:
dtype = _cqltype_to_numpy[subtype]
a = np.ma.empty((array_size, vector_size), dtype=dtype)
tests/unit/test_types.py:550
- These calls use
VectorType.deserializerather than thedes_floatinstance obtained above, so the test never executes the new Cython float implementation; the double, int32, and int64 sections repeat the same bypass. Invoke eachDesVectorType.deserialize_bytesmethod so regressions in the Cython fast paths are covered.
data_float = struct.pack('>4f', 1.0, 2.0, 3.0, 4.0)
result_float = vt_float.deserialize(data_float, 5)
cassandra/numpy_parser.pyx:195
- The new row-wide mask handling is not exercised by any added test: all parsed vectors are non-null, and the ShortType test only checks allocation. Add a NumPy parser case containing a null vector row and assert that the complete row is masked without masking neighboring rows.
memset(<char *>arr.mask_ptr, 1, arr.mask_stride)
…serializer find_deserializer() previously selected DesVectorType unconditionally for any VectorType column, regardless of subtype. DesVectorType only actually supports the fixed-width subtypes in VectorType._struct_format_map (float/double/int32/bigint); for anything else (text, smallint, varint, etc.) it only discovered it couldn't handle the data after construction, via a ValueError raised deep in _deserialize_generic. Nothing in the row parsing call chain (from_binary, TupleRowParser/ListRowParser/NumpyParser) catches that error, so real queries against e.g. Vector<text, N> or (post ShortType removal) Vector<smallint, N> columns would crash during normal row parsing. Make the dispatch subtype-aware, mirroring the pattern already used for every other type in this function: DesVectorType only for subtypes in _struct_format_map, GenericDeserializer (which correctly delegates to the pure-Python VectorType.deserialize()) otherwise. No exception is raised in the normal flow anymore, and no fallback-catching machinery is needed elsewhere. The ValueError inside DesVectorType._deserialize_generic is left exactly as-is; after this fix it's unreachable through normal dispatch and now serves as an internal invariant check (verified directly in the new test_desvectortype_rejects_variable_size_subtype). Updates test_vector_cython_deserializer (ShortType section) and test_vector_cython_deserializer_variable_size_subtype, which asserted the old unconditional-DesVectorType dispatch. Adds test_find_deserializer_vector_dispatch to lock in the dispatch behavior directly for both fixed-width and variable-size subtypes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Follow-up: fixed the dispatch, not just the symptom, for the "variable-length vector subtype crashes in production" issue from the previous comment. Root cause: elif issubclass(cqltype, cqltypes.VectorType):
cls = DesVectorType
Fix: made the dispatch subtype-aware, mirroring the pattern already used for every other type in this function: elif issubclass(cqltype, cqltypes.VectorType):
cls = DesVectorType if cqltype.subtype in cqltypes.VectorType._struct_format_map else GenericDeserializer
Updated Rebuilt the Cython extensions and re-ran the full |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (6)
cassandra/deserializers.pyx:475
- This bounds check can itself overflow because both operands are C
intvalues. For example, anelemlenofINT32_MAXat offset 8 can wrap the sum negative, pass the check, and create an out-of-bounds buffer view from a malformed server response. Compare against the remaining size using subtraction instead.
if offset[0] + elemlen <= buf.size:
cassandra/numpy_parser.pyx:156
- This fixed 2D row has only
arr.stridebytes available, butunpack_rowcopies the server-providedbuf.sizewithout checking it against that stride. An undersized vector returns uninitialized elements, while an oversized length writes past the row allocation. Validate that non-null numeric values have exactly the expected stride beforememcpy.
a = np.ma.empty((array_size, vector_size), dtype=dtype)
cassandra/deserializers.pyx:582
- This accepts every negative length as
None, including values below-2that the new length protocol handling above explicitly classifies as invalid. A malformed tuple/UDT field with length-3is therefore silently converted to null instead of rejected.
elif itemlen < 0:
# NULL value, item stays None
pass
else:
raise IndexError("Tuple item length %d at offset %d exceeds buffer size %d" % (itemlen, p, buf.size))
cassandra/marshal.py:43
int.from_bytes(b'', signed=True)returns0, whereas the previous implementation rejected an empty varint. Zero is canonically encoded asb'\x00'byvarint_pack, so this now makes malformed varints—and decimals containing only the four-byte scale—silently deserialize as zero. Preserve the empty-input validation before usingfrom_bytes.
return int.from_bytes(term, byteorder='big', signed=True)
cassandra/cython_marshal.pyx:88
- The Cython path has the same empty-input regression as the Python implementation:
int.from_bytes(b'', signed=True)returns zero, so a decimal payload containing only its scale is accepted even though zero varints are encoded asb'\x00'. Retain the prior rejection of empty input.
return int.from_bytes(term, byteorder='big', signed=True)
tests/unit/test_types.py:531
- Despite this stated purpose, the test only checks the selected class and then calls
vt_*.deserialize, which exercises the pure-PythonVectorTypeimplementation. None of the float/double/int32/int64 Cython routines (or their wrong-size checks) run here. Invoke eachdes_* .deserialize_bytes(...)instance instead so the new Cython implementation is actually covered.
Test that VectorType uses the Cython DesVectorType deserializer
and correctly deserializes vectors of supported numeric types.
…mismatch, numpy_parser bounds check - setup.py: link ws2_32 on Windows for the cassandra/*.pyx extension glob. cython_marshal.pyx declares ntohs/ntohl via winsock2.h on Windows, and it is textually included into deserializers.pyx (directly) and into ioutils.pyx (in turn included by numpy_parser.pyx, obj_parser.pyx and row_parser.pyx), so every one of those extensions needs the link library, not just a single module. - cassandra/deserializers.pyx: DesCompositeType.deserialize stored the wire's unsigned 16-bit element length in a signed int16_t, so any element length above 32767 wrapped negative, corrupting the bounds check and the subsequent buffer-advancing pointer arithmetic. Changed to uint16_t to match the actual wire type (also fixes DesDynamicCompositeType, which is an alias of DesCompositeType). - cassandra/numpy_parser.pyx: unpack_row's memcpy trusted the wire-provided buf.size against the destination array's fixed stride with no validation, allowing an oversized server response to overflow into adjacent NumPy array memory. Added a buf.size != arr.stride check that raises ValueError before the memcpy. Also traced make_arrays()/ make_array() to confirm the mask_ptr == 0 case only ever coincides with is_object arrays (handled by an earlier branch), so the memset branch is not reachable with a NULL mask pointer; added a comment documenting that invariant. Added regression tests: two NumPy bounds-check tests (oversized/ undersized fixed-width values) in tests/unit/test_numpy_parser.py, and a Cython-level composite-length test in tests/unit/cython/types_testhelper.pyx exercising a >32767-byte composite element end-to-end through DesCompositeType. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Fixed 3 confirmed CodeRabbit findings from this PR's review (commit f2bdc13):
Tests: Added two regression tests in Resolved the 3 corresponding CodeRabbit review threads. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (10)
cassandra/deserializers.pyx:36
cython_depsdeterminesHAVE_CYTHONby importingrow_parser, whose dependency chain imports this module. Re-importingHAVE_NUMPYfrom the still-initializingcython_depsmodule raisesImportError, causing the probe to report that Cython is unavailable and disabling all optimized protocol handlers. This dependency must not point back tocython_deps; probe NumPy locally (or move the NumPy probe to a dependency-free module).
# Import numpy availability flag and conditionally import numpy
from cassandra.cython_deps import HAVE_NUMPY
if HAVE_NUMPY:
import numpy as np
cassandra/deserializers.pyx:475
- This bounds check can itself overflow because
offset[0]andelemlenare Cints. For example, an element length ofINT32_MAXat offset 4 can wrap the sum negative, pass the check against a small buffer, and create an out-of-boundsBufferview. Compare against the remaining size instead so malformed length fields cannot bypass the validation.
if offset[0] + elemlen <= buf.size:
tests/unit/test_types.py:560
- This assertion exercises the pure-Python
VectorType.deserialize, not the newly selected Cython deserializer. Usedes_double.deserialize_bytesso the double byte-swapping path is actually tested.
result_double = vt_double.deserialize(data_double, 5)
tests/unit/test_types.py:569
- Calling the type class directly bypasses
des_int32, leaving the new Cython int32 path untested despite the test's stated purpose. Drive theDesVectorTypewrapper here.
result_int32 = vt_int32.deserialize(data_int32, 5)
tests/unit/test_types.py:578
- This uses the pure-Python deserializer and therefore never checks
_deserialize_int64. Invoke the Cython deserializer instance selected above to cover its 64-bit byte swapping.
result_int64 = vt_int64.deserialize(data_int64, 5)
tests/unit/test_types.py:602
- The malformed-size check also bypasses
DesVectorType, so it cannot catch a regression in the Cythonexpected_sizevalidation added by this PR. Calldes_float.deserialize_byteshere as well.
vt_float.deserialize(struct.pack('>3f', 1.0, 2.0, 3.0), 5) # 3 floats instead of 4
cassandra/numpy_parser.pyx:208
- The new row-wide mask write is not exercised by the added NumPy parser tests: every fixed-width vector row contains a value. Add a case with a
-1vector length between non-null rows and assert that all elements of only that vector row are masked; this verifies bothmask_strideand pointer advancement and guards against masking adjacent rows.
memset(<char *>arr.mask_ptr, 1, arr.mask_stride)
cassandra/deserializers.pyx:483
- Creating a negative-length
Bufferworks forfrom_binary, but not for every caller ofsubelem. In_deserialize_map, a NULL key is subsequently passed toto_bytes(&key_buf)at line 540;to_bytesslicesbuf.ptr[:buf.size], so a NULL pointer with size-1raises instead of producing theNoneserialized key that the pure-Python map deserializer supports (cqltypes.py:883-900). Handle negative key buffers before callingto_bytes; otherwise maps containing NULL keys still fail on the Cython path.
elif elemlen == -1 or elemlen == -2:
from_ptr_and_size(NULL, elemlen, elem_buf)
return 0
tests/unit/test_types.py:550
- This calls
VectorType.deserialize, so it bypasses theDesVectorTypeinstance selected above and does not test the new Cython float implementation. Invoke the deserializer's Python wrapper so failures in_deserialize_float/the NumPy branch are covered.
This issue also appears in the following locations of the same file:
- line 560
- line 569
- line 578
- line 602
result_float = vt_float.deserialize(data_float, 5)
setup.py:336
- This link setting only applies to extensions matched by
cassandra/*.pyx. The Windows Cython tests compiletests/unit/cython/types_testhelper.pyxseparately viapyximport; that helper includesioutils.pyx, callsget_buf/read_int, and now referencesntohlwithout linkingws2_32, so its import will fail with an unresolved symbol on Windows. Ensure the pyximport build receives the same library (or implement the byte swaps without Winsock linkage).
# cython_marshal.pyx (included by deserializers.pyx, and by ioutils.pyx which
# is in turn included by numpy_parser.pyx/obj_parser.pyx/row_parser.pyx) declares
# ntohs/ntohl via winsock2.h on Windows. Those symbols are implemented in
# ws2_32.lib, so every *.pyx extension needs to link against it on Windows.
platform_libraries = ['ws2_32'] if is_windows else []
cassandra.cython_deps determines HAVE_CYTHON by importing cassandra.row_parser. row_parser pulls in deserializers.pyx (which imports cqltypes), and both cassandra/deserializers.pyx and cassandra/cqltypes.py used to import HAVE_NUMPY back out of cassandra.cython_deps. When cython_deps was the first cassandra module touched in a process, Python registers it in sys.modules before running its body, so that re-entrant "from cassandra.cython_deps import HAVE_NUMPY" hit the partially initialized module and raised ImportError -- which cython_deps' own except clause swallowed, silently and permanently marking Cython unavailable even though the compiled extensions were fine. Confirmed empirically: a fresh process running `python3 -c "from cassandra.cython_deps import HAVE_CYTHON, HAVE_NUMPY; print(HAVE_CYTHON)"` printed False before this change. Traced the actual failure with a temporary debug print to cqltypes.py's unconditional import, which fires before deserializers.pyx's own (deserializers imports cqltypes before touching cython_deps itself) -- so both files needed the fix, not just deserializers.pyx. Both now determine numpy availability independently via their own try/except import, so nothing on row_parser's import chain touches cython_deps anymore. Re-verified for every import order (cython_deps, row_parser, deserializers, cqltypes, protocol, cluster first): HAVE_CYTHON is now consistently True. This was silently causing the "Cython is not available" wheel-build CI failures on this PR -- not flaky external infra as originally suspected -- since which cassandra module a test happens to import first is what determined whether the bug manifested. Also verified a separately reported concern about DesTupleType.deserialize's bounds check (`p + itemlen <= buf.size`) as a false positive: `p` is Py_ssize_t (64-bit) and `itemlen` is int32_t, so C's usual arithmetic conversions promote itemlen to 64-bit before the addition, and the comparison cannot wrap even when itemlen == INT32_MAX. Added tests: - tests/unit/cython/test_cython_deps.py: subprocess-based regression test spawning a fresh interpreter for several import orders, asserting HAVE_CYTHON is reported consistently. Confirmed it fails (not skips) if the bug is reintroduced. - tests/unit/cython/types_testhelper.pyx: test_tuple_itemlen_int32_max_no_overflow, confirming the tuple bounds check correctly rejects an INT32_MAX declared item length against an undersized buffer instead of silently overflowing. Verified the test is meaningful by temporarily forcing 32-bit arithmetic in the check and confirming the test then fails. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Circular import fix: this was causing today's "Cython is not available" wheel-build CI failuresPushed a fix for a real, confirmed circular import bug: Root cause: Confirmed empirically: ...on a build with working compiled extensions, when This explains why the failures showed up inconsistently across CI runs: whichever module a given test file (or its collection order) happened to import first determined whether the bug manifested -- not flaky external infra as originally suspected. Fix: both Added I also verified the reported |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
cassandra/cqltypes.py:1470
- Using
issubclasshere changes the behavior of registered custom numeric subtypes.CassandraTypeTyperegisters subclasses andlookup_casstypeaccepts them, but the cached struct path bypasses their overriddenserialize/deserializemethods in both directions. Restrict this optimization to the exact built-in subtype, as the NumPy and Cython dispatch paths already do, so custom subclasses retain their conversion semantics.
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
setup.py:348
- This links the production
cassandra/*.pyxextensions, but not the test extension built bypyximportintests/unit/cython/utils.py:29-32.types_testhelper.pyxincludesioutils.pyx, which now includes the newntohl/ntohscalls, so on Windows that helper will fail to link with unresolved Winsock symbols. Please also supplyws2_32when building the pyximport test helper (or use a byte-swap implementation that has no external linker dependency).
NoPatchExtension("*", ["cassandra/*.pyx"], extra_compile_args=compile_args,
libraries=platform_libraries),
tests/unit/test_types.py:531
- This test never exercises
DesVectorType: every successful and error-path assertion callsvt_*.deserialize, which is the pure-Python classmethod, after only checking the Cython deserializer's class name. Consequently the new Cython float/double/int32/int64 implementations can regress while this test still passes. Drive these cases through eachdes_*.deserialize_bytes(...)instance instead.
def test_vector_cython_deserializer(self):
"""
Test that VectorType uses the Cython DesVectorType deserializer
and correctly deserializes vectors of supported numeric types.
…bytes Replace the manual string-formatting hex conversion in varint_unpack() and the byte-by-byte bytearray loop in varint_pack() with Python 3 builtins int.from_bytes() and int.to_bytes(). varint_unpack used '%02x' formatting per byte, str.join, then int(..., 16) to parse back — O(n) string allocations. int.from_bytes is a single C-level call. varint_pack used a while loop appending individual bytes to a bytearray, then reversing. int.to_bytes computes the result in one C call. Also fixes the Cython path in cython_marshal.pyx which had the same slow pattern with a TODO comment to optimize. Adapted from PR scylladb#689 (varint_unpack) with new varint_pack implementation. varint_pack medium: 643 -> 90 ns/call (7.1x faster) varint_pack large: 1109 -> 96 ns/call (11.6x faster) varint_unpack medium: 1086 -> 115 ns/call (9.4x faster) varint_unpack large: 1940 -> 146 ns/call (13.3x faster)
…y large vector deserialization Add test_vector_cython_deserializer_variable_size_subtype to verify find_deserializer()'s current dispatch for a VectorType with a variable-size subtype (e.g. UTF8Type). cassandra.deserializers has no dedicated Des* class for VectorType today, and VectorType is not a subclass of any of the collection types find_deserializer() special-cases, so it always falls through to GenericDeserializer -- regardless of whether the subtype has a fixed serialized size or not. GenericDeserializer simply delegates to the pure Python VectorType.deserialize(), which correctly round-trips variable-size subtypes; the test asserts both the dispatch and the round-trip. A Cython fast-path deserializer for VectorType (DesVectorType), with dispatch that fast-paths fixed-size subtypes while leaving variable-size subtypes on GenericDeserializer, is being developed in companion PRs (scylladb#689, scylladb#732). Neither has merged, so DesVectorType does not exist on this branch or on master; this test intentionally does not depend on it. Once one of those PRs lands, this test should be revisited to also assert the Cython dispatch/behavior for fixed-size subtypes. Add test_vector_numpy_large_deserialization to exercise VectorType deserialization for vectors with >= 32 elements across all supported fixed-size numeric types (float, double, int32, int64). VectorType has no numpy-specific fast path today; the test name/threshold is forward-looking and documents that, guarding correctness of the existing implementation at representative embedding sizes in the meantime. Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
…y large vector deserialization Add test_vector_cython_deserializer_variable_size_subtype to verify find_deserializer()'s current dispatch for a VectorType with a variable-size subtype (e.g. UTF8Type). cassandra.deserializers has no dedicated Des* class for VectorType today, and VectorType is not a subclass of any of the collection types find_deserializer() special-cases, so it always falls through to GenericDeserializer -- regardless of whether the subtype has a fixed serialized size or not. GenericDeserializer simply delegates to the pure Python VectorType.deserialize(), which correctly round-trips variable-size subtypes; the test asserts both the dispatch and the round-trip. A Cython fast-path deserializer for VectorType (DesVectorType), with dispatch that fast-paths fixed-size subtypes while leaving variable-size subtypes on GenericDeserializer, is being developed in companion PRs (scylladb#689, scylladb#732). Neither has merged, so DesVectorType does not exist on this branch or on master; this test intentionally does not depend on it. Once one of those PRs lands, this test should be revisited to also assert the Cython dispatch/behavior for fixed-size subtypes. Add test_vector_numpy_large_deserialization to exercise VectorType deserialization for vectors with >= 32 elements across all supported fixed-size numeric types (float, double, int32, int64). VectorType has no numpy-specific fast path today; the test name/threshold is forward-looking and documents that, guarding correctness of the existing implementation at representative embedding sizes in the meantime. Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
…y large vector deserialization Add test_vector_cython_deserializer_variable_size_subtype to verify find_deserializer()'s current dispatch for a VectorType with a variable-size subtype (e.g. UTF8Type). cassandra.deserializers has no dedicated Des* class for VectorType today, and VectorType is not a subclass of any of the collection types find_deserializer() special-cases, so it always falls through to GenericDeserializer -- regardless of whether the subtype has a fixed serialized size or not. GenericDeserializer simply delegates to the pure Python VectorType.deserialize(), which correctly round-trips variable-size subtypes; the test asserts both the dispatch and the round-trip. A Cython fast-path deserializer for VectorType (DesVectorType), with dispatch that fast-paths fixed-size subtypes while leaving variable-size subtypes on GenericDeserializer, is being developed in companion PRs (scylladb#689, scylladb#732). Neither has merged, so DesVectorType does not exist on this branch or on master; this test intentionally does not depend on it. Once one of those PRs lands, this test should be revisited to also assert the Cython dispatch/behavior for fixed-size subtypes. Add test_vector_numpy_large_deserialization to exercise VectorType deserialization for vectors with >= 32 elements across all supported fixed-size numeric types (float, double, int32, int64). VectorType has no numpy-specific fast path today; the test name/threshold is forward-looking and documents that, guarding correctness of the existing implementation at representative embedding sizes in the meantime. Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
…bytes Replace the manual string-formatting hex conversion in varint_unpack() and the byte-by-byte bytearray loop in varint_pack() with Python 3 builtins int.from_bytes() and int.to_bytes(). varint_unpack used '%02x' formatting per byte, str.join, then int(..., 16) to parse back — O(n) string allocations. int.from_bytes is a single C-level call. varint_pack used a while loop appending individual bytes to a bytearray, then reversing. int.to_bytes computes the result in one C call. Also fixes the Cython path in cython_marshal.pyx which had the same slow pattern with a TODO comment to optimize. Adapted from PR scylladb#689 (varint_unpack) with new varint_pack implementation. varint_pack medium: 643 -> 90 ns/call (7.1x faster) varint_pack large: 1109 -> 96 ns/call (11.6x faster) varint_unpack medium: 1086 -> 115 ns/call (9.4x faster) varint_unpack large: 1940 -> 146 ns/call (13.3x faster)
Multiple partially/mostly independent commits (if needed, most can be extracted from this series) to improve the parsing of vector arrays.
Across Python, Cython and even Numpy array creation, this series includes both test and a benchmark to improve the deserialization of vectors.
There's some prerequisite and a bug fix (that I've extracted to its own PR), but otherwise the series is mostly complete.
I think in a follow-up or following commits I'll add the same/similar to serialization of vector types.
Pre-review checklist
./docs/source/.Fixes:annotations to PR description.