Skip to content

perf: add Cython metadata parser using BytesIOReader (100's-1000's of ns improvements, x1.5-4 speedup) - #814

Draft
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:perf/cython-metadata-parsing
Draft

perf: add Cython metadata parser using BytesIOReader (100's-1000's of ns improvements, x1.5-4 speedup)#814
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:perf/cython-metadata-parsing

Conversation

@mykaul

@mykaul mykaul commented Apr 10, 2026

Copy link
Copy Markdown

Summary

Add cassandra/metadata_parser.pyx with zero-copy metadata parsing using BytesIOReader, eliminating per-read bytes allocation that dominates recv_results_metadata and read_type cost in the Cython path.

Changes

  • New file: cassandra/metadata_parser.pyx — Cython cdef inline functions (read_int_br, read_short_br, read_string_br, read_type_br) operating on BytesIOReader instead of Python BytesIO. A factory function make_recv_results_metadata() returns a closure that captures the type-code map and type objects once.

  • Modified: cassandra/row_parser.pyx — Creates a single BytesIOReader from the full remaining buffer and reuses it for both metadata parsing and row parsing (previously: Python BytesIO for metadata, then a separate BytesIOReader for rows). Error recovery path correctly saves/restores reader position.

  • Modified: cassandra/protocol.pycython_protocol_handler() imports the new metadata parser and passes it to make_recv_results_rows().

Benchmarks

All measurements: taskset -c 0, Python 3.14.3, Cython compiled, origin/master vs this PR.

recv_results_metadata only (isolated)

Scenario Before After Speedup
3 simple cols 3,022 ns 582 ns 5.2x
10 simple cols 6,082 ns 1,265 ns 4.8x
50 simple cols 26,324 ns 4,882 ns 5.4x
NO_METADATA 541 ns 215 ns 2.5x
10 cols w/ collections 28,163 ns 21,832 ns 1.3x

Collections are limited by apply_parameters cost (addressed separately in #794).

recv_results_rows end-to-end (old Cython vs new Cython)

Scenario origin/master This PR Speedup
3 cols, 0 rows 5,661 ns 2,687 ns 2.04x
10 cols, 0 rows 11,068 ns 5,652 ns 1.96x
50 cols, 0 rows 44,904 ns 20,569 ns 2.18x
10 cols, 10 rows 29,590 ns 24,759 ns 1.20x
10 cols, 100 rows 181,053 ns 173,446 ns 1.04x
10 cols, 1000 rows 1,798,441 ns 1,821,257 ns 1.00x
NO_METADATA, 0 rows 5,733 ns 5,075 ns 1.13x

The improvement is entirely in metadata parsing (~2x for 0-row cases). As row count increases, metadata cost becomes a smaller fraction of total time, so the speedup dilutes. The 1000-row case is dominated by row deserialization and shows no change.

Tests

231 passed, 1 skipped — identical to baseline.

Notes

  • The prepared path (recv_prepared_metadata) is unchanged — it still uses Python BytesIO inherited from ResultMessage. Prepared statements are parsed once per PREPARE, not on the hot path.
  • setup.py automatically picks up the new .pyx file via the existing cassandra/*.pyx glob.
  • Flag constants are duplicated as compile-time DEF in the .pyx file (documented with a sync warning comment).

@mykaul
mykaul force-pushed the perf/cython-metadata-parsing branch from 48538ac to 3ec7841 Compare April 10, 2026 08:18
@mykaul

mykaul commented Apr 10, 2026

Copy link
Copy Markdown
Author

I'm slowly but surely converting the Python driver to a C driver.... Unsure about this direction. :-/

@mykaul mykaul changed the title perf: add Cython metadata parser using BytesIOReader perf: add Cython metadata parser using BytesIOReader (100's of ns improvements, x1.5-4 speedup) Apr 10, 2026
@mykaul mykaul changed the title perf: add Cython metadata parser using BytesIOReader (100's of ns improvements, x1.5-4 speedup) perf: add Cython metadata parser using BytesIOReader (100's-1000's of ns improvements, x1.5-4 speedup) Apr 10, 2026
@mykaul
mykaul force-pushed the perf/cython-metadata-parsing branch 5 times, most recently from 713bcc5 to 1f3e38f Compare April 10, 2026 09:19
@mykaul
mykaul force-pushed the perf/cython-metadata-parsing branch from 1f3e38f to 5854f6a Compare July 29, 2026 20:51
Copilot AI review requested due to automatic review settings July 29, 2026 20:51
@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: 0f414179-fa11-4892-9956-59fce3181c85

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 (now on top of the SCYLLA_USE_METADATA_ID extension, DRIVER-153) and pushed a few hardening fixes into the same commit:

Consistency with #745 (schema-parsing SELECT trimming / _RowView/_row_factory): verified compatible. metadata_parser.pyx parses the CQL wire-level column metadata (keyspace/table/column/type) generically from whatever colcount/columns the server actually returns — it has no hardcoded assumption about which or how many columns are present. #745's trimmed system_schema.columns SELECT and _RowView/_row_factory operate one layer up, on result.column_names/result.parsed_rows by name, which are themselves derived from this parser's output. No incompatibility either way. Also confirmed _METADATA_ID_FLAG handling here is unaffected by the new SCYLLA_USE_METADATA_ID extension (that extension only changes EXECUTE/PREPARE framing, not the recv_results_metadata wire format read here), and that skip_meta's column_metadata = self.column_metadata or result_metadata fallback is preserved in row_parser.pyx.

Buffer-bounds safety: found and fixed a real gap in read_binary_longstring_br() (used for paging_state/result_metadata_id). It read a signed int32_t length straight off the wire and passed it to reader.read()/pointer-slicing without validating it against the CQL protocol's [bytes] semantics. BytesIOReader.read() treats any negative length as "read to end of buffer" instead of raising, and slicing a char* with that same unvalidated length isn't bounds-checked — reproduced this concretely: a crafted negative length currently raises an opaque SystemError: Negative size passed to PyBytes_FromStringAndSize instead of a clean, catchable error. Fixed by rejecting negative lengths with a ValueError, mirroring the existing raw_val_size <= 0 check in get_buf() (ioutils.pyx) and size < 0 check in slice_buffer() (buffer.pxd). read_string_br/read_binary_string_br use an unsigned uint16_t length so they were never at risk. Added a regression test (tests/unit/cython/test_metadata_parser.py) that fails with the old SystemError if the fix is reverted.

Circular-import anti-pattern: make_recv_results_metadata() used to do from cassandra.protocol import ResultMessage, CUSTOM_TYPE internally. Since cassandra.metadata_parser is imported from inside cython_protocol_handler(), which itself runs while cassandra.protocol's own module body is still executing, that import is self-referential into a partially-initialized module — the same class of fragility that separately broke cassandra.cython_deps.HAVE_CYTHON detection depending on import order elsewhere. It happened to work here only because ResultMessage/CUSTOM_TYPE are defined earlier in protocol.py's source than cython_protocol_handler() is invoked. Changed make_recv_results_metadata() to take these (and the cqltypes classes) as parameters instead — cython_protocol_handler() already has them all as module-level names, no import needed there at all.

Testing: rebuilt Cython extensions from a clean build/ and ran the full suite: tests/unit/test_metadata.py (55 passed) and tests/unit/ in full (774 passed, 38 skipped, 0 failed), including the new tests/unit/cython/test_metadata_parser.py (4 tests: negative-length rejection, valid paging_state round-trip, NO_METADATA_FLAG handling, and parity with the pure-Python recv_results_metadata for both global-tables-spec and per-column wire layouts). Also manually exercised the real FastResultMessage path end-to-end (scalar columns and a list<int> column) to confirm the recursive type parser still works correctly after the parameter-passing refactor.

All changes amended into the existing commit (no new commits added); force-pushed with --force-with-lease.

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

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR introduces a Cython BytesIOReader-based metadata parser to reduce per-read allocations during RESULT_KIND_ROWS decoding, improving the hot-path performance for result metadata parsing.

Changes:

  • Added cassandra/metadata_parser.pyx implementing zero-copy recv_results_metadata and type parsing on BytesIOReader
  • Updated cassandra/row_parser.pyx to reuse a single BytesIOReader for both metadata+rows and adjust error recovery
  • Updated cassandra/protocol.py to wire the new metadata parser into the Cython protocol handler; added unit tests for the new parser

Reviewed changes

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

Show a summary per file
File Description
cassandra/metadata_parser.pyx New Cython metadata/type parser using BytesIOReader to avoid per-read bytes allocations
cassandra/row_parser.pyx Reuses a single BytesIOReader across metadata and row parsing; adjusts exception rewind logic
cassandra/protocol.py Constructs and injects the new metadata parser closure into make_recv_results_rows()
cassandra/ioutils.pyx Adds a Cython read_short(BytesIOReader) primitive used by the new parser
tests/unit/cython/test_metadata_parser.py Adds correctness and malformed-frame tests for the new metadata parser

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

Comment thread cassandra/row_parser.pyx
Comment thread cassandra/metadata_parser.pyx
Comment thread cassandra/metadata_parser.pyx
Add cassandra/metadata_parser.pyx with zero-copy metadata parsing that
uses BytesIOReader instead of Python BytesIO, eliminating per-read bytes
allocation in recv_results_metadata and read_type.

Modify row_parser.pyx to create a single BytesIOReader from the full
remaining buffer and reuse it for both metadata parsing and row parsing,
instead of using Python BytesIO for metadata then a separate BytesIOReader
for rows.

Benchmarks (taskset -c 0, Python 3.14, Cython):

  recv_results_metadata only (10 simple cols): 6082 -> 1265 ns (4.8x)
  recv_results_rows (10 cols, 0 rows):        11068 -> 5652 ns (1.96x)
  recv_results_rows (10 cols, 10 rows):       29590 -> 24759 ns (1.20x)
  recv_results_rows (10 cols, 100 rows):     181053 -> 173446 ns (1.04x)
  recv_results_rows (50 cols, 0 rows):        44904 -> 20569 ns (2.18x)

Harden read_binary_longstring_br() against a malformed/corrupted frame
carrying a negative [bytes] length for paging_state or result_metadata_id.
BytesIOReader.read() treats any negative n as "read to end of buffer"
instead of raising, and slicing a char* with that same unvalidated
(possibly very negative) length is not bounds-checked; on CPython this
currently surfaces as an opaque SystemError from
PyBytes_FromStringAndSize rather than a clean, catchable error. Reject
negative lengths explicitly with a ValueError, mirroring the existing
raw_val_size <= 0 check in get_buf() (ioutils.pyx) and the size < 0 check
in slice_buffer() (buffer.pxd) used elsewhere in the Cython layer.
read_string_br/read_binary_string_br read an unsigned uint16_t length so
they cannot go negative; only the signed int32_t path needed this.

Make make_recv_results_metadata() take its type objects (type_codes_map,
CUSTOM_TYPE, ListType, SetType, MapType, TupleType, UserType,
lookup_casstype) as parameters instead of importing them itself from
cassandra.protocol/cassandra.cqltypes. cassandra.metadata_parser is
imported from inside cython_protocol_handler(), which itself runs while
cassandra.protocol's own module body is still executing; a module-level
"from cassandra.protocol import ResultMessage, CUSTOM_TYPE" from within
metadata_parser is therefore a self-referential import back into a
partially-initialized module. It happened to work only because
ResultMessage/CUSTOM_TYPE are defined earlier in protocol.py's source
than cython_protocol_handler() is invoked -- the same class of
import-order fragility that separately broke
cassandra.cython_deps.HAVE_CYTHON detection. cython_protocol_handler()
already has all of these as module-level names with no import needed, so
passing them in removes the dependency on that ordering entirely.

Verified this PR's generic wire-level column metadata parsing is
unaffected by, and compatible with, the separate schema-parsing changes
that trim system_schema.columns to a narrower SELECT and replace
dict_factory with _RowView/_row_factory (name-keyed access via
result.column_names, which is derived from column_metadata regardless of
how many columns the server returns) -- and with the newly landed
SCYLLA_USE_METADATA_ID extension, which does not change the
recv_results_metadata wire format or flag bits this parser reads.

Add tests/unit/cython/test_metadata_parser.py: negative-length rejection
(reproduces the SystemError without the fix), NO_METADATA_FLAG handling,
and parity between the Cython and pure-Python recv_results_metadata for
both the global-tables-spec and per-column wire layouts.

Address review feedback:

- BytesIOReader gains an explicit seek(new_pos) method, and row_parser.pyx's
  exception-recovery rewind (reader.pos = rows_start_pos) now goes through
  it instead of assigning reader.pos directly. Investigated the claim that
  this rewind was incomplete because BytesIOReader also tracks a `buf_ptr`
  cursor: read() always computes `buf_ptr + pos`, and `buf_ptr` is set once
  in __init__ and never reassigned anywhere in the codebase (confirmed via
  git history back to bytesio.pyx's introduction), so `pos` is the only
  mutable cursor and there is no actual desync -- the pre-PIN code's
  "reader.buf_ptr = reader.buf" was a redundant self-assignment, not a
  necessary reset (it existed because that code built a fresh, row-data-only
  reader each time, so buf_ptr and pos=0 were simply restating the reader's
  initial state). seek() still improves on the old direct-field-access
  pattern by giving callers a single, explicit entry point. Regression test
  in tests/unit/cython/test_row_parser.py exercises recv_results_rows'
  exception-recovery path with a ColumnParser that partially consumes the
  reader before failing, and asserts the re-parse lands on the exact same
  bytes; tests/unit/cython/test_bytesio.py/bytesio_testhelper.pyx add a
  direct seek() unit test.

- make_recv_results_metadata()/_read_type_br() now take NotSupportedError
  as an injected parameter, the same way CUSTOM_TYPE/ListType/etc. are
  already injected, instead of doing "from cassandra.protocol import
  NotSupportedError" inside _read_type_br's unknown-type-code branch. That
  runtime import contradicted this PR's own stated rationale for injecting
  the other type objects (avoiding self-referential imports back into
  cassandra.protocol while it is still initializing). cython_protocol_handler()
  now passes its own module-level NotSupportedError through.
  tests/unit/cython/test_metadata_parser.py's new
  test_unknown_type_code_raises_injected_exception passes in a stand-in
  exception class and asserts *that* class is raised, proving the raise
  goes through the injected closure rather than a hidden import.

- _read_type_br()'s UserType branch no longer special-cases num_fields == 0
  into empty names/types tuples. Verified empirically that
  ResultMessage.read_type() (the pure-Python path) raises ValueError for
  this same input, because zip(*()) unpacked into two variables raises when
  there are zero fields -- and a real UDT always has at least one field, so
  num_fields == 0 only ever occurs for malformed/corrupted wire data. Removing
  the special case lets the Cython path fail the same way instead of
  silently returning a UDT with no fields, closing the divergence from the
  pure-Python path for this malformed input.
  tests/unit/cython/test_metadata_parser.py's new
  test_zero_field_user_type_matches_pure_python drives both paths with the
  same zero-field UserType wire bytes and asserts both raise ValueError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mykaul
mykaul force-pushed the perf/cython-metadata-parsing branch from 5854f6a to 908cad0 Compare July 31, 2026 16:43
Copilot AI review requested due to automatic review settings July 31, 2026 16:43

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

Suppressed comments (2)

tests/unit/cython/test_row_parser.py:94

  • This test still uses _no_op_recv_results_metadata_br, which explicitly consumes no bytes, so the saved rows_start_pos remains 0. Valid prefix rows move the cursor before the failure, but they do not exercise restoring the newly important nonzero metadata boundary claimed by the docstring. Use the real metadata parser with a metadata prefix, or a Cython helper that consumes such a prefix, so this regression test actually verifies a nonzero rewind target.
        recv_results_rows = make_recv_results_rows(ListParser(), _no_op_recv_results_metadata_br)

cassandra/metadata_parser.pyx:99

  • The new recursive collection decoding is not exercised by the added tests: the parity case only uses scalar type 0x0009. A regression in subtype recursion or apply_parameters() for list/set/map would therefore pass CI. Add wire-format parity cases against ResultMessage.read_type() for these collection branches (including nesting).
    if typeclass is ListType or typeclass is SetType:
        subtype = _read_type_br(reader, type_codes_map, user_type_map,
                                ListType, SetType, MapType, TupleType, UserType,
                                CUSTOM_TYPE, lookup_casstype, NotSupportedError)
        typeclass = typeclass.apply_parameters((subtype,))
    elif typeclass is MapType:

Comment thread cassandra/bytesio.pyx
into `pos` (and, in older code, the always-redundant `buf_ptr`)
directly at the call site.
"""
self.pos = new_pos
Comment on lines +199 to +214
cdef list column_metadata = [None] * colcount

if flags & _FLAGS_GLOBAL_TABLES_SPEC:
ksname = read_string_br(reader)
cfname = read_string_br(reader)
for i in range(colcount):
colname = read_string_br(reader)
coltype = read_type_br_closure(reader, user_type_map)
column_metadata[i] = (ksname, cfname, colname, coltype)
else:
for i in range(colcount):
ksname = read_string_br(reader)
cfname = read_string_br(reader)
colname = read_string_br(reader)
coltype = read_type_br_closure(reader, user_type_map)
column_metadata[i] = (ksname, cfname, colname, coltype)
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