diff --git a/benchmarks/micro/bench_isinstance_dispatch.py b/benchmarks/micro/bench_isinstance_dispatch.py new file mode 100644 index 0000000000..c7bd8991c2 --- /dev/null +++ b/benchmarks/micro/bench_isinstance_dispatch.py @@ -0,0 +1,107 @@ +# Copyright ScyllaDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Micro-benchmark: isinstance dispatch order in _create_response_future. + +Measures the cost of checking BoundStatement first vs SimpleStatement first +in the isinstance chain that dispatches query types to message constructors. + +For prepared-statement workloads (the perf-critical case), BoundStatement is +the most common type. Checking it first saves one wasted isinstance call. + +Run: + python benchmarks/micro/bench_isinstance_dispatch.py +""" + +import sys +import timeit + +from cassandra.query import SimpleStatement, BoundStatement, BatchStatement, Statement + + +class _FakeGraphStatement(Statement): + """Stand-in for GraphStatement to avoid importing DSE dependencies.""" + pass + + +def make_bound_statement(): + """Create a minimal BoundStatement-like object for benchmarking.""" + # We only need isinstance() to work; no actual prepared statement needed. + bs = object.__new__(BoundStatement) + return bs + + +def make_simple_statement(): + return SimpleStatement("SELECT * FROM t") + + +def make_batch_statement(): + return BatchStatement() + + +def bench(): + bound = make_bound_statement() + simple = make_simple_statement() + batch = make_batch_statement() + graph = _FakeGraphStatement() + + # Simulate typical workload mix: ~80% BoundStatement, ~15% SimpleStatement, + # ~4% BatchStatement, ~1% GraphStatement + queries = ([bound] * 80 + [simple] * 15 + [batch] * 4 + [graph] * 1) + + def dispatch_simple_first(): + """Original order: SimpleStatement checked first.""" + for q in queries: + if isinstance(q, SimpleStatement): + pass + elif isinstance(q, BoundStatement): + pass + elif isinstance(q, BatchStatement): + pass + elif isinstance(q, _FakeGraphStatement): + pass + + def dispatch_bound_first(): + """Optimized order: BoundStatement checked first.""" + for q in queries: + if isinstance(q, BoundStatement): + pass + elif isinstance(q, SimpleStatement): + pass + elif isinstance(q, BatchStatement): + pass + elif isinstance(q, _FakeGraphStatement): + pass + + n = 200_000 + t_simple_first = timeit.timeit(dispatch_simple_first, number=n) + t_bound_first = timeit.timeit(dispatch_bound_first, number=n) + + total_calls = n * len(queries) + print(f"=== isinstance dispatch order (100 queries x {n} iters = {total_calls:,} dispatches) ===") + print(f"SimpleStatement first: {t_simple_first:.3f}s ({t_simple_first / total_calls * 1e9:.1f} ns/dispatch)") + print(f"BoundStatement first: {t_bound_first:.3f}s ({t_bound_first / total_calls * 1e9:.1f} ns/dispatch)") + + if t_bound_first < t_simple_first: + speedup = t_simple_first / t_bound_first + saving_ns = (t_simple_first - t_bound_first) / total_calls * 1e9 + print(f"Speedup: {speedup:.2f}x ({saving_ns:.1f} ns/dispatch saved)") + else: + print(f"No improvement (ratio: {t_simple_first / t_bound_first:.2f}x)") + + +if __name__ == "__main__": + print(f"Python {sys.version}") + bench() diff --git a/cassandra/cluster.py b/cassandra/cluster.py index 88c8d2707a..22627df0f3 100644 --- a/cassandra/cluster.py +++ b/cassandra/cluster.py @@ -3051,16 +3051,9 @@ def _create_response_future(self, query, parameters, trace, custom_payload, # bound statements carry cached result metadata (set in the BoundStatement branch). bound_result_metadata = _NOT_SET - if isinstance(query, SimpleStatement): - query_string = query.query_string - statement_keyspace = query.keyspace if ProtocolVersion.uses_keyspace_flag(self._protocol_version) else None - if parameters: - query_string = bind_params(query_string, parameters, self.encoder) - message = QueryMessage( - query_string, cl, serial_cl, - fetch_size, paging_state, timestamp, - continuous_paging_options, statement_keyspace) - elif isinstance(query, BoundStatement): + if isinstance(query, BoundStatement): + # Check BoundStatement first: prepared-statement execution is the + # most common hot-path case, saving one isinstance() call (~15 ns). prepared_statement = query.prepared_statement # Snapshot metadata and its id as one atomic pair so the message never # carries the id of one schema version alongside a skip_meta decision @@ -3083,6 +3076,15 @@ def _create_response_future(self, query, parameters, trace, custom_payload, and continuous_paging_options is None, continuous_paging_options=continuous_paging_options, result_metadata_id=result_metadata_id) + elif isinstance(query, SimpleStatement): + query_string = query.query_string + statement_keyspace = query.keyspace if ProtocolVersion.uses_keyspace_flag(self._protocol_version) else None + if parameters: + query_string = bind_params(query_string, parameters, self.encoder) + message = QueryMessage( + query_string, cl, serial_cl, + fetch_size, paging_state, timestamp, + continuous_paging_options, statement_keyspace) elif isinstance(query, BatchStatement): if self._protocol_version < 2: raise UnsupportedOperation( @@ -4708,10 +4710,9 @@ class ResponseFuture(object): session = None row_factory = None message = None - default_timeout = None + prepared_statement = None _retry_policy = None - _profile_manager = None _req_id = None _final_result = _NOT_SET @@ -4734,12 +4735,11 @@ class ResponseFuture(object): _spec_execution_plan = NoSpeculativeExecutionPlan() _continuous_paging_session = None _host = None + _continuous_paging_state = None _control_connection_query_attempted = False _TABLET_ROUTING_CTYPE = None _bound_result_metadata = None - _warned_timeout = False - def __init__(self, session, message, query, timeout, metrics=None, prepared_statement=None, retry_policy=RetryPolicy(), row_factory=None, load_balancer=None, start_time=None, speculative_execution_plan=None, continuous_paging_state=None, host=None, @@ -4752,8 +4752,10 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self.query = query self.timeout = timeout self._retry_policy = retry_policy - self._metrics = metrics - self.prepared_statement = prepared_statement + if metrics is not None: + self._metrics = metrics + if prepared_statement is not None: + self.prepared_statement = prepared_statement # Metadata snapshotted alongside the message's result_metadata_id at construction # time (see Session._create_response_future). Decoding a skip_meta response uses # this so the metadata decoded-with always pairs with the id the message sent, @@ -4762,7 +4764,8 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self._bound_result_metadata = [] if bound_result_metadata is _NOT_SET else bound_result_metadata self._callback_lock = Lock() self._start_time = start_time or time.time() - self._host = host + if host is not None: + self._host = host self._control_connection_query_attempted = False self._spec_execution_plan = speculative_execution_plan or self._spec_execution_plan self._make_query_plan() @@ -4772,7 +4775,8 @@ def __init__(self, session, message, query, timeout, metrics=None, prepared_stat self._errbacks = [] self.attempted_hosts = [] self._start_timer() - self._continuous_paging_state = continuous_paging_state + if continuous_paging_state is not None: + self._continuous_paging_state = continuous_paging_state @property def _time_remaining(self):