Skip to content

Commit eddd9da

Browse files
committed
perf: add Cython metadata parser using BytesIOReader
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)
1 parent 5094118 commit eddd9da

3 files changed

Lines changed: 224 additions & 7 deletions

File tree

cassandra/metadata_parser.pyx

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Copyright ScyllaDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Cython-optimized metadata parsing for CQL protocol ResultMessage.
17+
18+
Uses BytesIOReader for zero-copy reads, eliminating per-read bytes allocation
19+
that dominates recv_results_metadata cost.
20+
"""
21+
22+
from libc.stdint cimport uint16_t
23+
from cassandra.buffer cimport Buffer
24+
25+
include "ioutils.pyx"
26+
27+
28+
# ---------- low-level readers on BytesIOReader ----------
29+
# read_int(BytesIOReader) is provided by ioutils.pyx
30+
31+
cdef inline uint16_t read_short_br(BytesIOReader reader) except ?0xFFFE:
32+
cdef Buffer buf
33+
buf.ptr = reader.read(2)
34+
buf.size = 2
35+
return unpack_num[uint16_t](&buf)
36+
37+
cdef inline str read_string_br(BytesIOReader reader):
38+
"""Read a [string]: a [short] n, followed by n bytes of UTF-8."""
39+
cdef uint16_t size = read_short_br(reader)
40+
cdef char *ptr = reader.read(size)
41+
return ptr[:size].decode('utf8')
42+
43+
cdef inline bytes read_binary_string_br(BytesIOReader reader):
44+
"""Read a [short bytes]: a [short] n, followed by n raw bytes."""
45+
cdef uint16_t size = read_short_br(reader)
46+
cdef char *ptr = reader.read(size)
47+
return ptr[:size]
48+
49+
cdef inline bytes read_binary_longstring_br(BytesIOReader reader):
50+
"""Read a [bytes]: an [int] n, followed by n raw bytes."""
51+
cdef int32_t size = read_int(reader)
52+
cdef char *ptr = reader.read(size)
53+
return ptr[:size]
54+
55+
56+
# ---------- flag constants (mirrored from ResultMessage) ----------
57+
# These MUST stay in sync with the class attributes in ResultMessage (protocol.py).
58+
# They are duplicated here as compile-time DEF constants for Cython performance.
59+
60+
DEF _FLAGS_GLOBAL_TABLES_SPEC = 0x0001
61+
DEF _HAS_MORE_PAGES_FLAG = 0x0002
62+
DEF _NO_METADATA_FLAG = 0x0004
63+
DEF _METADATA_ID_FLAG = 0x0008
64+
DEF _CONTINUOUS_PAGING_FLAG = 0x40000000
65+
DEF _CONTINUOUS_PAGING_LAST_FLAG = 0x80000000
66+
67+
68+
# ---------- read_type using BytesIOReader ----------
69+
70+
cdef object _read_type_br(BytesIOReader reader, dict type_codes_map, object user_type_map,
71+
object ListType, object SetType, object MapType,
72+
object TupleType, object UserType, object CUSTOM_TYPE,
73+
object lookup_casstype):
74+
"""
75+
Cython version of ResultMessage.read_type() operating on BytesIOReader.
76+
77+
Parameters are passed in to avoid module-level imports from protocol.py
78+
(which would create circular dependencies). They are captured once in the
79+
closure created by make_recv_results_metadata().
80+
"""
81+
cdef uint16_t optid = read_short_br(reader)
82+
83+
typeclass = type_codes_map.get(optid)
84+
if typeclass is None:
85+
from cassandra.protocol import NotSupportedError
86+
raise NotSupportedError(
87+
"Unknown data type code 0x%04x. Have to skip entire result set." % (optid,))
88+
89+
if typeclass is ListType or typeclass is SetType:
90+
subtype = _read_type_br(reader, type_codes_map, user_type_map,
91+
ListType, SetType, MapType, TupleType, UserType,
92+
CUSTOM_TYPE, lookup_casstype)
93+
typeclass = typeclass.apply_parameters((subtype,))
94+
elif typeclass is MapType:
95+
keysubtype = _read_type_br(reader, type_codes_map, user_type_map,
96+
ListType, SetType, MapType, TupleType, UserType,
97+
CUSTOM_TYPE, lookup_casstype)
98+
valsubtype = _read_type_br(reader, type_codes_map, user_type_map,
99+
ListType, SetType, MapType, TupleType, UserType,
100+
CUSTOM_TYPE, lookup_casstype)
101+
typeclass = typeclass.apply_parameters((keysubtype, valsubtype))
102+
elif typeclass is TupleType:
103+
num_items = read_short_br(reader)
104+
types = tuple(_read_type_br(reader, type_codes_map, user_type_map,
105+
ListType, SetType, MapType, TupleType, UserType,
106+
CUSTOM_TYPE, lookup_casstype)
107+
for _ in range(num_items))
108+
typeclass = typeclass.apply_parameters(types)
109+
elif typeclass is UserType:
110+
ks = read_string_br(reader)
111+
udt_name = read_string_br(reader)
112+
num_fields = read_short_br(reader)
113+
names_and_types = tuple(
114+
(read_string_br(reader),
115+
_read_type_br(reader, type_codes_map, user_type_map,
116+
ListType, SetType, MapType, TupleType, UserType,
117+
CUSTOM_TYPE, lookup_casstype))
118+
for _ in range(num_fields))
119+
names, types = zip(*names_and_types) if num_fields > 0 else ((), ())
120+
specialized_type = typeclass.make_udt_class(ks, udt_name, names, types)
121+
specialized_type.mapped_class = user_type_map.get(ks, {}).get(udt_name)
122+
typeclass = specialized_type
123+
elif typeclass is CUSTOM_TYPE:
124+
classname = read_string_br(reader)
125+
typeclass = lookup_casstype(classname)
126+
127+
return typeclass
128+
129+
130+
# ---------- public factory: creates closures that capture type objects ----------
131+
132+
def make_recv_results_metadata():
133+
"""
134+
Factory that returns a recv_results_metadata function suitable for use
135+
as an unbound method replacement on FastResultMessage.
136+
137+
The closure captures the type-code map and type objects once, so they
138+
don't have to be looked up on every call.
139+
"""
140+
from cassandra.protocol import (
141+
ResultMessage, CUSTOM_TYPE,
142+
)
143+
from cassandra.cqltypes import (
144+
ListType, SetType, MapType, TupleType, UserType, lookup_casstype,
145+
)
146+
147+
# Capture once
148+
cdef dict type_codes_map = ResultMessage.type_codes
149+
150+
def read_type_br_closure(BytesIOReader reader, user_type_map):
151+
return _read_type_br(reader, type_codes_map, user_type_map,
152+
ListType, SetType, MapType, TupleType, UserType,
153+
CUSTOM_TYPE, lookup_casstype)
154+
155+
def recv_results_metadata(self, BytesIOReader reader, user_type_map):
156+
"""
157+
Cython-optimized recv_results_metadata operating on BytesIOReader.
158+
Replaces ResultMessage.recv_results_metadata.
159+
"""
160+
cdef int32_t flags = read_int(reader)
161+
cdef int32_t colcount = read_int(reader)
162+
163+
if flags & _HAS_MORE_PAGES_FLAG:
164+
self.paging_state = read_binary_longstring_br(reader)
165+
166+
if flags & _NO_METADATA_FLAG:
167+
return
168+
169+
if flags & _CONTINUOUS_PAGING_FLAG:
170+
self.continuous_paging_seq = read_int(reader)
171+
self.continuous_paging_last = flags & _CONTINUOUS_PAGING_LAST_FLAG
172+
173+
if flags & _METADATA_ID_FLAG:
174+
self.result_metadata_id = read_binary_string_br(reader)
175+
176+
cdef bint glob_tblspec = flags & _FLAGS_GLOBAL_TABLES_SPEC
177+
cdef str ksname, cfname, colksname, colcfname, colname
178+
cdef int i
179+
180+
if glob_tblspec:
181+
ksname = read_string_br(reader)
182+
cfname = read_string_br(reader)
183+
184+
column_metadata = []
185+
for i in range(colcount):
186+
if glob_tblspec:
187+
colksname = ksname
188+
colcfname = cfname
189+
else:
190+
colksname = read_string_br(reader)
191+
colcfname = read_string_br(reader)
192+
colname = read_string_br(reader)
193+
coltype = read_type_br_closure(reader, user_type_map)
194+
column_metadata.append((colksname, colcfname, colname, coltype))
195+
196+
self.column_metadata = column_metadata
197+
198+
return recv_results_metadata

cassandra/protocol.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,15 +1212,18 @@ def cython_protocol_handler(colparser):
12121212
The default is to use obj_parser.ListParser
12131213
"""
12141214
from cassandra.row_parser import make_recv_results_rows
1215+
from cassandra.metadata_parser import make_recv_results_metadata
1216+
1217+
recv_results_metadata_br = make_recv_results_metadata()
12151218

12161219
class FastResultMessage(ResultMessage):
12171220
"""
12181221
Cython version of Result Message that has a faster implementation of
1219-
recv_results_row.
1222+
recv_results_rows using BytesIOReader for zero-copy metadata + row parsing.
12201223
"""
12211224
# type_codes = ResultMessage.type_codes.copy()
12221225
code_to_type = dict((v, k) for k, v in ResultMessage.type_codes.items())
1223-
recv_results_rows = make_recv_results_rows(colparser)
1226+
recv_results_rows = make_recv_results_rows(colparser, recv_results_metadata_br)
12241227

12251228
class CythonProtocolHandler(_ProtocolHandler):
12261229
"""

cassandra/row_parser.pyx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,27 @@ from cassandra.deserializers import make_deserializers
1919

2020
include "ioutils.pyx"
2121

22-
def make_recv_results_rows(ColumnParser colparser):
22+
def make_recv_results_rows(ColumnParser colparser, recv_results_metadata_br):
23+
"""
24+
Create a recv_results_rows closure that uses:
25+
- recv_results_metadata_br: Cython metadata parser operating on BytesIOReader
26+
- colparser: Cython column parser for row data
27+
28+
A single BytesIOReader is created from the full remaining buffer and used
29+
for both metadata parsing and row parsing, eliminating the per-read bytes
30+
allocation overhead of Python BytesIO.
31+
"""
2332
def recv_results_rows(self, f, int protocol_version, user_type_map, result_metadata, column_encryption_policy):
2433
"""
2534
Parse protocol data given as a BytesIO f into a set of columns (e.g. list of tuples)
2635
This is used as the recv_results_rows method of (Fast)ResultMessage
2736
"""
28-
self.recv_results_metadata(f, user_type_map)
37+
# Create ONE BytesIOReader for the entire remaining buffer.
38+
# This is used for both metadata parsing and row parsing.
39+
reader = BytesIOReader(f.read())
40+
41+
# Use Cython-optimized metadata parsing on BytesIOReader
42+
recv_results_metadata_br(self, reader, user_type_map)
2943

3044
column_metadata = self.column_metadata or result_metadata
3145

@@ -35,14 +49,16 @@ def make_recv_results_rows(ColumnParser colparser):
3549
desc = ParseDesc(self.column_names, self.column_types, column_encryption_policy,
3650
[ColDesc(md[0], md[1], md[2]) for md in column_metadata],
3751
make_deserializers(self.column_types), protocol_version)
38-
reader = BytesIOReader(f.read())
52+
# The reader's position is now right after the metadata;
53+
# row data follows immediately — no need to create a second reader.
54+
# Save position so we can rewind to the start of row data on error.
55+
cdef Py_ssize_t rows_start_pos = reader.pos
3956
try:
4057
self.parsed_rows = colparser.parse_rows(reader, desc)
4158
except Exception as e:
4259
# Use explicitly the TupleRowParser to display better error messages for column decoding failures
4360
rowparser = TupleRowParser()
44-
reader.buf_ptr = reader.buf
45-
reader.pos = 0
61+
reader.pos = rows_start_pos
4662
rowcount = read_int(reader)
4763
for i in range(rowcount):
4864
rowparser.unpack_row(reader, desc)

0 commit comments

Comments
 (0)