|
| 1 | +# Copyright DataStax, 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 | +Benchmarks for ParseDesc construction with and without caching. |
| 17 | +
|
| 18 | +The ParseDesc is built on every response in recv_results_rows(). For prepared |
| 19 | +statements the column_metadata list is the same object every time, so caching |
| 20 | +the ParseDesc (keyed by id(column_metadata)) avoids repeated list |
| 21 | +comprehensions, ColDesc construction, and make_deserializers() calls. |
| 22 | +
|
| 23 | +Run with: |
| 24 | + pytest benchmarks/test_parse_desc_cache_benchmark.py -v |
| 25 | +""" |
| 26 | + |
| 27 | +import struct |
| 28 | +import pytest |
| 29 | + |
| 30 | +from cassandra import cqltypes |
| 31 | +from cassandra.policies import ColDesc |
| 32 | +from cassandra.parsing import ParseDesc |
| 33 | +from cassandra.deserializers import make_deserializers |
| 34 | +from cassandra.bytesio import BytesIOReader |
| 35 | +from cassandra.obj_parser import ListParser |
| 36 | +from cassandra.row_parser import clear_parse_desc_cache |
| 37 | + |
| 38 | + |
| 39 | +# --------------------------------------------------------------------------- |
| 40 | +# Helpers |
| 41 | +# --------------------------------------------------------------------------- |
| 42 | + |
| 43 | + |
| 44 | +def _build_column_metadata(ncols, cql_type=cqltypes.UTF8Type): |
| 45 | + """Build a column_metadata list like the driver produces.""" |
| 46 | + return [("ks", "tbl", "col_%d" % i, cql_type) for i in range(ncols)] |
| 47 | + |
| 48 | + |
| 49 | +def _build_uncached_parse_desc( |
| 50 | + column_metadata, column_encryption_policy, protocol_version |
| 51 | +): |
| 52 | + """Original uncached ParseDesc construction (baseline).""" |
| 53 | + column_names = [md[2] for md in column_metadata] |
| 54 | + column_types = [md[3] for md in column_metadata] |
| 55 | + desc = ParseDesc( |
| 56 | + column_names, |
| 57 | + column_types, |
| 58 | + column_encryption_policy, |
| 59 | + [ColDesc(md[0], md[1], md[2]) for md in column_metadata], |
| 60 | + make_deserializers(column_types), |
| 61 | + protocol_version, |
| 62 | + ) |
| 63 | + return column_names, column_types, desc |
| 64 | + |
| 65 | + |
| 66 | +def _build_binary_rows(nrows, ncols, col_value=b"hello world"): |
| 67 | + """ |
| 68 | + Build a binary buffer matching the Cassandra row format: |
| 69 | + int32(rowcount) |
| 70 | + for each row: |
| 71 | + for each col: int32(len) + bytes |
| 72 | + """ |
| 73 | + parts = [struct.pack(">i", nrows)] |
| 74 | + col_cell = struct.pack(">i", len(col_value)) + col_value |
| 75 | + row_data = col_cell * ncols |
| 76 | + for _ in range(nrows): |
| 77 | + parts.append(row_data) |
| 78 | + return b"".join(parts) |
| 79 | + |
| 80 | + |
| 81 | +# --------------------------------------------------------------------------- |
| 82 | +# Cached helper — import directly from the Cython module |
| 83 | +# We call _get_or_build_parse_desc indirectly through a thin wrapper because |
| 84 | +# it is a cdef inline function not callable from Python. Instead we exercise |
| 85 | +# the cache through clear_parse_desc_cache + a full parse_rows pass, then |
| 86 | +# measure only the second (cached) call. |
| 87 | +# --------------------------------------------------------------------------- |
| 88 | + |
| 89 | + |
| 90 | +def _cached_parse_desc_via_pipeline( |
| 91 | + column_metadata, column_encryption_policy, protocol_version, binary_buf |
| 92 | +): |
| 93 | + """ |
| 94 | + Exercise the full cached path: ListParser.parse_rows() internally goes |
| 95 | + through _get_or_build_parse_desc via recv_results_rows, but we cannot call |
| 96 | + recv_results_rows directly without a full ResultMessage. Instead we |
| 97 | + measure the ParseDesc construction cost by importing the cdef function |
| 98 | + wrapper. |
| 99 | + """ |
| 100 | + # We cannot directly call the cdef function from Python. |
| 101 | + # The benchmark strategy is: |
| 102 | + # 1. Clear cache, call uncached build (warmup) — not timed |
| 103 | + # 2. Call cached build — timed |
| 104 | + # Since the cdef function isn't directly callable, we replicate the |
| 105 | + # cache logic in pure Python to measure the overhead difference. |
| 106 | + pass |
| 107 | + |
| 108 | + |
| 109 | +# --------------------------------------------------------------------------- |
| 110 | +# Pure-Python cache replica for benchmarking |
| 111 | +# (Mirrors the Cython _parse_desc_cache logic so we can measure the delta) |
| 112 | +# --------------------------------------------------------------------------- |
| 113 | + |
| 114 | +_py_cache = {} |
| 115 | + |
| 116 | + |
| 117 | +def _cached_parse_desc_py(column_metadata, column_encryption_policy, protocol_version): |
| 118 | + """Pure-Python replica of the Cython cache for benchmark comparison.""" |
| 119 | + cache_key = id(column_metadata) |
| 120 | + cached = _py_cache.get(cache_key) |
| 121 | + if cached is not None: |
| 122 | + if ( |
| 123 | + cached[0] is column_metadata |
| 124 | + and cached[1] is column_encryption_policy |
| 125 | + and cached[2] == protocol_version |
| 126 | + ): |
| 127 | + return cached[4], cached[5], cached[3] |
| 128 | + |
| 129 | + column_names = [md[2] for md in column_metadata] |
| 130 | + column_types = [md[3] for md in column_metadata] |
| 131 | + desc = ParseDesc( |
| 132 | + column_names, |
| 133 | + column_types, |
| 134 | + column_encryption_policy, |
| 135 | + [ColDesc(md[0], md[1], md[2]) for md in column_metadata], |
| 136 | + make_deserializers(column_types), |
| 137 | + protocol_version, |
| 138 | + ) |
| 139 | + _py_cache[cache_key] = ( |
| 140 | + column_metadata, |
| 141 | + column_encryption_policy, |
| 142 | + protocol_version, |
| 143 | + desc, |
| 144 | + column_names, |
| 145 | + column_types, |
| 146 | + ) |
| 147 | + return column_names, column_types, desc |
| 148 | + |
| 149 | + |
| 150 | +# --------------------------------------------------------------------------- |
| 151 | +# Benchmark: ParseDesc construction (uncached baseline) |
| 152 | +# --------------------------------------------------------------------------- |
| 153 | + |
| 154 | + |
| 155 | +@pytest.mark.parametrize("ncols", [5, 10, 20]) |
| 156 | +def test_parse_desc_build_uncached(benchmark, ncols): |
| 157 | + """Baseline: build ParseDesc from scratch every time (original code path).""" |
| 158 | + col_meta = _build_column_metadata(ncols) |
| 159 | + |
| 160 | + def run(): |
| 161 | + return _build_uncached_parse_desc(col_meta, None, 4) |
| 162 | + |
| 163 | + result = benchmark(run) |
| 164 | + names, types, desc = result |
| 165 | + assert len(names) == ncols |
| 166 | + assert len(desc.colnames) == ncols |
| 167 | + |
| 168 | + |
| 169 | +@pytest.mark.parametrize("ncols", [5, 10, 20]) |
| 170 | +def test_parse_desc_build_cached(benchmark, ncols): |
| 171 | + """Cached: second and subsequent calls return cached ParseDesc.""" |
| 172 | + col_meta = _build_column_metadata(ncols) |
| 173 | + _py_cache.clear() |
| 174 | + |
| 175 | + # Warm the cache |
| 176 | + _cached_parse_desc_py(col_meta, None, 4) |
| 177 | + |
| 178 | + def run(): |
| 179 | + return _cached_parse_desc_py(col_meta, None, 4) |
| 180 | + |
| 181 | + result = benchmark(run) |
| 182 | + names, types, desc = result |
| 183 | + assert len(names) == ncols |
| 184 | + assert len(desc.colnames) == ncols |
| 185 | + |
| 186 | + |
| 187 | +# --------------------------------------------------------------------------- |
| 188 | +# Benchmark: Full parse_rows pipeline (uncached vs cached ParseDesc) |
| 189 | +# This measures the end-to-end cost including row parsing. |
| 190 | +# --------------------------------------------------------------------------- |
| 191 | + |
| 192 | + |
| 193 | +@pytest.mark.parametrize("nrows,ncols", [(1, 10), (100, 5), (1000, 5)]) |
| 194 | +def test_full_pipeline_uncached(benchmark, nrows, ncols): |
| 195 | + """Full pipeline: build ParseDesc from scratch + parse rows.""" |
| 196 | + col_meta = _build_column_metadata(ncols) |
| 197 | + binary_buf = _build_binary_rows(nrows, ncols) |
| 198 | + parser = ListParser() |
| 199 | + |
| 200 | + def run(): |
| 201 | + names, types, desc = _build_uncached_parse_desc(col_meta, None, 4) |
| 202 | + reader = BytesIOReader(binary_buf) |
| 203 | + return parser.parse_rows(reader, desc) |
| 204 | + |
| 205 | + rows = benchmark(run) |
| 206 | + assert len(rows) == nrows |
| 207 | + assert len(rows[0]) == ncols |
| 208 | + |
| 209 | + |
| 210 | +@pytest.mark.parametrize("nrows,ncols", [(1, 10), (100, 5), (1000, 5)]) |
| 211 | +def test_full_pipeline_cached(benchmark, nrows, ncols): |
| 212 | + """Full pipeline: cached ParseDesc + parse rows.""" |
| 213 | + col_meta = _build_column_metadata(ncols) |
| 214 | + binary_buf = _build_binary_rows(nrows, ncols) |
| 215 | + parser = ListParser() |
| 216 | + _py_cache.clear() |
| 217 | + |
| 218 | + # Warm cache |
| 219 | + _cached_parse_desc_py(col_meta, None, 4) |
| 220 | + |
| 221 | + def run(): |
| 222 | + names, types, desc = _cached_parse_desc_py(col_meta, None, 4) |
| 223 | + reader = BytesIOReader(binary_buf) |
| 224 | + return parser.parse_rows(reader, desc) |
| 225 | + |
| 226 | + rows = benchmark(run) |
| 227 | + assert len(rows) == nrows |
| 228 | + assert len(rows[0]) == ncols |
| 229 | + |
| 230 | + |
| 231 | +# --------------------------------------------------------------------------- |
| 232 | +# Benchmark: ParseDesc only (isolated, varying column counts) |
| 233 | +# Shows the raw savings from caching the ParseDesc construction. |
| 234 | +# --------------------------------------------------------------------------- |
| 235 | + |
| 236 | + |
| 237 | +@pytest.mark.parametrize("ncols", [5, 10, 20, 50]) |
| 238 | +def test_parse_desc_only_uncached(benchmark, ncols): |
| 239 | + """Isolated ParseDesc construction — uncached.""" |
| 240 | + col_meta = _build_column_metadata(ncols) |
| 241 | + |
| 242 | + benchmark(_build_uncached_parse_desc, col_meta, None, 4) |
| 243 | + |
| 244 | + |
| 245 | +@pytest.mark.parametrize("ncols", [5, 10, 20, 50]) |
| 246 | +def test_parse_desc_only_cached(benchmark, ncols): |
| 247 | + """Isolated ParseDesc construction — cached (hit path).""" |
| 248 | + col_meta = _build_column_metadata(ncols) |
| 249 | + _py_cache.clear() |
| 250 | + _cached_parse_desc_py(col_meta, None, 4) # warm |
| 251 | + |
| 252 | + benchmark(_cached_parse_desc_py, col_meta, None, 4) |
| 253 | + |
| 254 | + |
| 255 | +# --------------------------------------------------------------------------- |
| 256 | +# Correctness tests |
| 257 | +# --------------------------------------------------------------------------- |
| 258 | + |
| 259 | + |
| 260 | +def test_cached_same_result_as_uncached(): |
| 261 | + """Verify cached path produces identical results.""" |
| 262 | + col_meta = _build_column_metadata(10) |
| 263 | + _py_cache.clear() |
| 264 | + |
| 265 | + names_u, types_u, desc_u = _build_uncached_parse_desc(col_meta, None, 4) |
| 266 | + names_c, types_c, desc_c = _cached_parse_desc_py(col_meta, None, 4) |
| 267 | + |
| 268 | + assert names_u == names_c |
| 269 | + assert types_u == types_c |
| 270 | + assert len(desc_u.colnames) == len(desc_c.colnames) |
| 271 | + assert desc_u.protocol_version == desc_c.protocol_version |
| 272 | + |
| 273 | + # Second call should be cache hit and return the same desc object |
| 274 | + names_c2, types_c2, desc_c2 = _cached_parse_desc_py(col_meta, None, 4) |
| 275 | + assert desc_c2 is desc_c # same object from cache |
| 276 | + |
| 277 | + |
| 278 | +def test_cache_invalidation_on_different_metadata(): |
| 279 | + """Different column_metadata list should produce a new ParseDesc.""" |
| 280 | + _py_cache.clear() |
| 281 | + |
| 282 | + col_meta_a = _build_column_metadata(5) |
| 283 | + col_meta_b = _build_column_metadata(10) |
| 284 | + |
| 285 | + _, _, desc_a = _cached_parse_desc_py(col_meta_a, None, 4) |
| 286 | + _, _, desc_b = _cached_parse_desc_py(col_meta_b, None, 4) |
| 287 | + |
| 288 | + assert desc_a is not desc_b |
| 289 | + assert len(desc_a.colnames) == 5 |
| 290 | + assert len(desc_b.colnames) == 10 |
| 291 | + |
| 292 | + |
| 293 | +def test_cache_invalidation_on_protocol_version_change(): |
| 294 | + """Changed protocol_version should miss the cache.""" |
| 295 | + _py_cache.clear() |
| 296 | + |
| 297 | + col_meta = _build_column_metadata(5) |
| 298 | + _, _, desc_v4 = _cached_parse_desc_py(col_meta, None, 4) |
| 299 | + _, _, desc_v5 = _cached_parse_desc_py(col_meta, None, 5) |
| 300 | + |
| 301 | + assert desc_v4 is not desc_v5 |
| 302 | + |
| 303 | + |
| 304 | +def test_clear_parse_desc_cache(): |
| 305 | + """Verify the Cython cache can be cleared.""" |
| 306 | + clear_parse_desc_cache() # should not raise |
| 307 | + |
| 308 | + |
| 309 | +def test_full_pipeline_correctness(): |
| 310 | + """End-to-end: parse rows with cached ParseDesc produces correct data.""" |
| 311 | + ncols = 5 |
| 312 | + nrows = 3 |
| 313 | + col_meta = _build_column_metadata(ncols) |
| 314 | + binary_buf = _build_binary_rows(nrows, ncols, col_value=b"test_val") |
| 315 | + parser = ListParser() |
| 316 | + |
| 317 | + _py_cache.clear() |
| 318 | + names, types, desc = _cached_parse_desc_py(col_meta, None, 4) |
| 319 | + reader = BytesIOReader(binary_buf) |
| 320 | + rows = parser.parse_rows(reader, desc) |
| 321 | + |
| 322 | + assert len(rows) == nrows |
| 323 | + for row in rows: |
| 324 | + assert len(row) == ncols |
| 325 | + for val in row: |
| 326 | + assert val == "test_val" |
0 commit comments