Skip to content

Commit e5f3e3b

Browse files
committed
perf: cache apply_parameters to avoid repeated type() class creation
Cache the results of _CassandraType.apply_parameters() in a class-level dict keyed by (cls, subtypes, names). This avoids the expensive type() metaclass machinery on repeated calls with the same type signature, which is the common case during result-set deserialization. Benchmark: 31.7x speedup (6.48 us/call -> 0.20 us/call) for cached hits. UserType and VectorType define their own apply_parameters() overrides (with their own, independently-keyed caching/lookup) and never read or write this cache, so this change cannot affect UDT or vector<...> dimension/subtype resolution. Fix a crash the cache key introduced for types that accept zero subtypes (num_subtypes == 'UNKNOWN', e.g. CompositeType, ColumnToCollectionType): `tuple(names) if names else names` left an explicit empty `names` list unconverted (`[]` is falsy), producing an unhashable list inside the cache-key tuple and raising TypeError on the dict lookup. Normalize with `is not None` instead, and add regression tests for the empty-names case and for confirming VectorType stays independent of the shared cache. Signed-off-by: Yaniv Michael Kaul <yaniv.kaul@scylladb.com>
1 parent 9b5b037 commit e5f3e3b

3 files changed

Lines changed: 397 additions & 2 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
Micro-benchmark: apply_parameters caching.
17+
18+
Measures the speedup from caching parameterized type creation
19+
in _CassandraType.apply_parameters().
20+
21+
Run:
22+
python benchmarks/bench_cache_apply_parameters.py
23+
"""
24+
import timeit
25+
from cassandra.cqltypes import (
26+
MapType, SetType, ListType, TupleType,
27+
Int32Type, UTF8Type, FloatType, DoubleType, BooleanType,
28+
_CassandraType,
29+
)
30+
31+
32+
def bench_apply_parameters():
33+
"""Benchmark apply_parameters with cache (repeated calls)."""
34+
cache = _CassandraType._apply_parameters_cache
35+
36+
# Warm up the cache
37+
MapType.apply_parameters([UTF8Type, Int32Type])
38+
SetType.apply_parameters([FloatType])
39+
ListType.apply_parameters([DoubleType])
40+
TupleType.apply_parameters([Int32Type, UTF8Type, BooleanType])
41+
42+
calls = [
43+
(MapType, [UTF8Type, Int32Type]),
44+
(SetType, [FloatType]),
45+
(ListType, [DoubleType]),
46+
(TupleType, [Int32Type, UTF8Type, BooleanType]),
47+
]
48+
49+
def run_cached():
50+
for cls, subtypes in calls:
51+
cls.apply_parameters(subtypes)
52+
53+
# Benchmark cached path
54+
n = 100_000
55+
t_cached = timeit.timeit(run_cached, number=n)
56+
print(f"Cached apply_parameters ({len(calls)} types x {n} iters): "
57+
f"{t_cached:.3f}s ({t_cached / (n * len(calls)) * 1e6:.2f} us/call)")
58+
59+
# Benchmark uncached path (clear cache each iteration)
60+
def run_uncached():
61+
# The four calls below use distinct cache keys, so a single clear
62+
# per iteration still leaves every call a cache miss.
63+
cache.clear()
64+
for cls, subtypes in calls:
65+
cls.apply_parameters(subtypes)
66+
67+
t_uncached = timeit.timeit(run_uncached, number=n)
68+
print(f"Uncached apply_parameters ({len(calls)} types x {n} iters): "
69+
f"{t_uncached:.3f}s ({t_uncached / (n * len(calls)) * 1e6:.2f} us/call)")
70+
71+
speedup = t_uncached / t_cached
72+
print(f"Speedup: {speedup:.1f}x")
73+
74+
75+
def main():
76+
"""Run the apply_parameters cache benchmark."""
77+
bench_apply_parameters()
78+
79+
80+
if __name__ == '__main__':
81+
main()

cassandra/cqltypes.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import time
4242
import struct
4343
import sys
44+
import threading
4445
from uuid import UUID
4546

4647
from cassandra.marshal import (int8_pack, int8_unpack, int16_pack, int16_unpack,
@@ -276,6 +277,26 @@ class _CassandraType(object, metaclass=CassandraTypeType):
276277
num_subtypes = 0
277278
empty_binary_ok = False
278279

280+
# Cache of previously-constructed parameterized subtypes, keyed on
281+
# (cls, subtypes, names) -- see apply_parameters() below. This is a
282+
# single dict shared by every subclass that does not override
283+
# apply_parameters() (e.g. ListType, SetType, MapType, TupleType,
284+
# CompositeType). Growth is bounded by the number of distinct
285+
# (type, subtypes, names) combinations actually seen, which in
286+
# practice tracks the number of distinct column/type shapes across
287+
# the schemas a session talks to -- not the number of rows or queries.
288+
# UserType and VectorType define their own apply_parameters()
289+
# overrides (with their own, differently-keyed caches) and never
290+
# populate or consult this dict.
291+
_apply_parameters_cache = {} # noqa: RUF012 # shared cache is intentional
292+
293+
# Guards the get-then-create-then-set sequence in apply_parameters()
294+
# below so that concurrent callers racing on the same (cls, subtypes,
295+
# names) cannot each create their own class and clobber one another
296+
# in the cache. Shared by every subclass using _apply_parameters_cache,
297+
# for the same reason that cache itself is shared.
298+
_apply_parameters_cache_lock = threading.Lock()
299+
279300
support_empty_values = False
280301
"""
281302
Back in the Thrift days, empty strings were used for "null" values of
@@ -373,8 +394,37 @@ def apply_parameters(cls, subtypes, names=None):
373394
if cls.num_subtypes != 'UNKNOWN' and len(subtypes) != cls.num_subtypes:
374395
raise ValueError("%s types require %d subtypes (%d given)"
375396
% (cls.typename, cls.num_subtypes, len(subtypes)))
376-
newname = cls.cass_parameterized_type_with(subtypes)
377-
return type(newname, (cls,), {'subtypes': subtypes, 'cassname': cls.cassname, 'fieldnames': names})
397+
subtypes = tuple(subtypes)
398+
# Use `is not None` (rather than a truthiness check) so an explicit
399+
# empty sequence (`names=[]`, e.g. for a type parameterized with zero
400+
# subtypes) is normalized to a hashable `()` instead of being left as
401+
# an unhashable list, which would raise on the dict lookup below.
402+
# Normalize once and reuse the same value for both the cache key and
403+
# the `fieldnames` stored on the created class -- otherwise, if the
404+
# caller passed a mutable list, the cache key would be a tuple
405+
# snapshot while `fieldnames` aliased the caller's original list, and
406+
# a later mutation of that list would silently corrupt the cached
407+
# class for every subsequent cache hit.
408+
norm_names = tuple(names) if names is not None else None
409+
cache_key = (cls, subtypes, norm_names)
410+
cached = cls._apply_parameters_cache.get(cache_key)
411+
if cached is not None:
412+
return cached
413+
# The get-then-create-then-set sequence above is not atomic, so
414+
# guard the miss path with a lock and re-check the cache once
415+
# inside it: two threads can otherwise race past the check above
416+
# for the same (cls, subtypes, names), each create its own class
417+
# via type(), and stomp on each other's cache entry, leaving some
418+
# callers holding a class that is not the one now cached (breaking
419+
# `is`-based identity assumptions).
420+
with cls._apply_parameters_cache_lock:
421+
cached = cls._apply_parameters_cache.get(cache_key)
422+
if cached is not None:
423+
return cached
424+
newname = cls.cass_parameterized_type_with(subtypes)
425+
result = type(newname, (cls,), {'subtypes': subtypes, 'cassname': cls.cassname, 'fieldnames': norm_names})
426+
cls._apply_parameters_cache[cache_key] = result
427+
return result
378428

379429
@classmethod
380430
def cql_parameterized_type(cls):

0 commit comments

Comments
 (0)