Skip to content

(improvement) unit tests for benchmarking query planning. - #653

Open
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:query_plan_opt_perf
Open

(improvement) unit tests for benchmarking query planning.#653
mykaul wants to merge 1 commit into
scylladb:masterfrom
mykaul:query_plan_opt_perf

Conversation

@mykaul

@mykaul mykaul commented Jan 24, 2026

Copy link
Copy Markdown

Not a very scientific one, but reasonable to get some measurements in terms of how different optimizations work. Example run (on #650 branch):


  warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET))
============================================================================================================ test session starts ============================================================================================================= platform linux -- Python 3.14.2, pytest-8.3.5, pluggy-1.6.0 rootdir: /home/ykaul/github/python-driver
configfile: pyproject.toml
plugins: asyncio-1.1.0, anyio-4.12.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 4 items

tests/unit/test_policy_performance.py Pinned to CPU 0 ....

=== Performance Benchmarks ===
Policy                         | Ops        | Time (s)   | Kops/s
----------------------------------------------------------------------
DCAware                        | 100000     | 0.2328     | 429
RackAware                      | 100000     | 0.3637     | 274
TokenAware(DCAware)            | 100000     | 1.5884     | 62
TokenAware(RackAware)          | 100000     | 1.6816     | 59
----------------------------------------------------------------------

Pre-review checklist

  • I have split my patch into logically separate commits.
  • All commit messages clearly explain what they change and why.
  • I added relevant tests for new features and bug fixes.
  • All commits compile, pass static checks and pass test.
  • PR description sums up the changes and reasons why they should be introduced.
  • I have provided docstrings for the public items that I want to introduce.
  • I have adjusted the documentation in ./docs/source/.
  • I added appropriate Fixes: annotations to PR description.

@mykaul

mykaul commented Jan 25, 2026

Copy link
Copy Markdown
Author

Latest commit add Host policy. Now it looks like this:

ykaul@ykaul:~/github/python-driver$ pytest -s tests/unit/test_policy_performance.py 
/usr/lib/python3.14/site-packages/pytest_asyncio/plugin.py:211: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset.
The event loop scope for asynchronous fixtures will default to the fixture caching scope. Future versions of pytest-asyncio will default the loop scope for asynchronous fixtures to function scope. Set the default fixture loop scope explicitly in order to avoid unexpected behavior in the future. Valid fixture loop scopes are: "function", "class", "module", "package", "session"

  warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET))
==================================================================================================================================================== test session starts =====================================================================================================================================================
platform linux -- Python 3.14.2, pytest-8.3.5, pluggy-1.6.0
rootdir: /home/ykaul/github/python-driver
configfile: pyproject.toml
plugins: asyncio-1.1.0, anyio-4.12.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 6 items                                                                                                                                                                                                                                                                                                            

tests/unit/test_policy_performance.py Pinned to CPU 0
......

=== Performance Benchmarks ===
Policy                         | Ops        | Time (s)   | Kops/s    
----------------------------------------------------------------------
DCAware                        | 100000     | 0.0989     | 1010      
Default(DCAware)               | 100000     | 0.1532     | 652       
HostFilter(DCAware)            | 100000     | 0.3303     | 302       
RackAware                      | 100000     | 0.1149     | 870       
TokenAware(DCAware)            | 100000     | 0.2112     | 473       
TokenAware(RackAware)          | 100000     | 0.2249     | 444       
----------------------------------------------------------------------

@dkropachev

Copy link
Copy Markdown

@mykaul , thanks looks great, can you implement them in pytest and mark with @pytest.mark.benchmark and register such a flag in pytest.ini

@mykaul
mykaul force-pushed the query_plan_opt_perf branch from a797262 to d850f81 Compare February 6, 2026 09:03
@mykaul

mykaul commented Feb 6, 2026

Copy link
Copy Markdown
Author

@mykaul , thanks looks great, can you implement them in pytest and mark with @pytest.mark.benchmark and register such a flag in pytest.ini

There is no pytest.ini, so I've added one. The tests were already in pytest, now they are in the right dir, in a follow-up commit.

@Lorak-mmk

Copy link
Copy Markdown

There is benchmarks folder. Maybe its a better place for such tests?

@dkropachev dkropachev 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.

I love it, have some comments though.
Also can you please take a look at pytest-benchmark, it looks like exactly what we need.

Comment thread tests/unit/test_policy_performance.py Outdated
Comment thread tests/performance/test_policy_performance.py Outdated
Comment thread tests/performance/test_policy_performance.py Outdated
connections = [c for holders in cluster.get_connection_holders() for c in holders.get_connections()]

# make sure requests were sent on all connections
# Note: connections can be replaced in shard-aware environments, so we skip

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please create a separate pr for this change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to, first wanted to see that it solves the issue - but it doesn't:

FAILED tests/integration/standard/test_cluster.py::ClusterTests::test_idle_heartbeat - assert not True
 +  where True = any(<generator object ClusterTests.test_idle_heartbeat.<locals>.<genexpr> at 0x7f63c0292d40>)

I feel like I should git bisect it - if I can reproduce this locally first.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, it passes locally. Over and over, consistently. Not a single failure.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since other PR doesn't break like that, would assume it's the new benchmark tests which are running together with all of the other unittests

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They all do - #686 as an example. So either I managed to sneak in something that fails everywhere, or something else is going on. I tend to blame me, but I have no proof yet, and it's very annoying it doesn't reproduce on my laptop.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests/integration/standard/test_cluster.py change this thread is anchored on is no longer part of this PR's diff at all (dropped in a later push), so the original ask to split it into a separate PR is moot for this PR as it stands today.

Re: the test_idle_heartbeat flakiness discussed below — I confirmed the test is still present and unmodified on current master. Per @fruch's and @mykaul's discussion, this reads as a pre-existing, likely repo-wide intermittent failure (also observed on #686) rather than something specific to this PR, and it wasn't reproducible locally even by the PR author. I'm treating further bisection/root-causing of that flakiness as out of scope for fixing up this PR; leaving this thread open in case a maintainer wants to track it separately.

@mykaul

mykaul commented Apr 9, 2026

Copy link
Copy Markdown
Author

gevent issue?

Add micro-benchmarks measuring query plan generation throughput for
DCAwareRoundRobinPolicy, RackAwareRoundRobinPolicy, TokenAwarePolicy,
DefaultLoadBalancingPolicy, and HostFilterPolicy.

Uses pytest-benchmark for accurate timing and statistical reporting
with a simulated 45-node cluster topology (5 DCs x 3 racks x 3 nodes)
and 100,000 deterministic queries.

The mock cluster's token-range/replica map (vnode_cluster) and the
generated query load (benchmark_queries) are independent, module-scoped
fixtures sized via separate TOKEN_RANGE_COUNT / QUERY_COUNT constants, so
either can be tuned without touching the other.

Also rename tests/integration/standard/column_encryption/test_policies.py
to test_encrypted_policies.py to avoid module name conflicts when running
the full test suite.

Run with: pytest -m benchmark tests/performance/

Benchmark results comparing master vs PR scylladb#651 optimizations
(Python 3.14.3, pytest-benchmark 5.2.3, GC disabled, median):

Policy                    | master (Kops/s) | PR#651 (Kops/s) | Speedup
--------------------------|-----------------|-----------------|--------
DCAware                   |             833 |            1898 |   2.3x
RackAware                 |             542 |            1589 |   2.9x
TokenAware(DCAware)       |             135 |             572 |   4.2x
TokenAware(RackAware)     |             123 |             539 |   4.4x
Default(DCAware)          |             674 |            1257 |   1.9x
HostFilter(DCAware)       |             394 |             579 |   1.5x
Copilot AI review requested due to automatic review settings July 29, 2026 17:33
@mykaul
mykaul force-pushed the query_plan_opt_perf branch from 6760c35 to 04ad714 Compare July 29, 2026 17:33
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mykaul, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b7107f2b-efb0-42ae-a375-2baed8728ad3

📥 Commits

Reviewing files that changed from the base of the PR and between b8b714c and 04ad714.

📒 Files selected for processing (5)
  • .gitignore
  • pyproject.toml
  • tests/integration/standard/column_encryption/test_encrypted_policies.py
  • tests/performance/conftest.py
  • tests/performance/test_policy_performance.py

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 and went through the four open review threads (@dkropachev). Summary:

  1. "Is it a real commit? there is a real change here." — This was pointing at a dangling, non-functional string literal ("A micro-bechmark for performance of policies", mid-file, not an actual docstring) that had zero effect at runtime. That literal is gone; the file now opens with a proper module docstring. The squashed commit message also now includes a real results table (100k queries, 45-node mock cluster) showing measured speedups from PR (improvement)perf: Optimize DCAware/RackAware/TokenAware/HostFilter policies with host distance caching and overall perf. improvements (100's to 1000's of ns reduction, x1.1-2.9 improvement!) #651 (1.5x–4.4x across the six benchmarked policy configurations), so this is exercising and reporting a genuine, measurable difference rather than a no-op. Verified and resolved.

  2. Fixture design (decoupling cluster setup from query generation) — The file already had vnode_cluster and benchmark_queries split into two independent, module-scoped fixtures rather than one combined benchmark_setup, so the structural coupling was gone. But both still hardcoded the same 100_000 for token-range count and query count, keeping the spirit of the original complaint. I pulled those into two independent constants, TOKEN_RANGE_COUNT and QUERY_COUNT, so either can be retuned without touching the other fixture; query routing keys now wrap modulo TOKEN_RANGE_COUNT so QUERY_COUNT can also exceed it. Pushed as part of the amended commit.

  3. "Can you please merge these two commits" — The branch is now a single commit, and there's no leftover moved/duplicated code (only tests/performance/test_policy_performance.py exists; no tests/unit/test_policy_performance.py remnant). Verified and resolved.

  4. "Could you please create a separate PR" / test_idle_heartbeat flakiness — The tests/integration/standard/test_cluster.py change this thread was anchored on is no longer part of this PR's diff at all, so the original ask is moot as things stand. The test_idle_heartbeat flakiness sub-discussion (with @fruch) looks like a pre-existing, likely repo-wide intermittent failure also seen on (Improvement) Remove CQL binary protocol v3 #686, and wasn't reproducible locally by the PR author. Per the task scope, I did not attempt to bisect/reproduce/fix that — flagging it for a maintainer to track separately if still relevant. Left this thread unresolved for a human call.

Testing done:

  • pytest -m benchmark tests/performance/ — all 6 benchmarks pass (verified both with --benchmark-disable for correctness and with real timing enabled).
  • Combined collection of tests/unit + tests/performance — 814 tests collected, no module-name conflicts.
  • Full tests/unit run (excluding libev/gevent/eventlet reactors not buildable in this sandbox) — 720 passed, 50 skipped (libev unavailable locally), 0 failures.

CI: Rebase triggered a fresh run. Previously-failing test asyncio (3.14t) / test libev (3.14t) were failing due to gevent==26.4.0 publishing no wheel for free-threaded 3.14 — an upstream/environment issue unrelated to this PR's diff (master's build at the same base commit had passed those checks in an earlier run, so it looks transient/dependency-driven rather than code-driven). Will confirm once the fresh run lands.

Pushed the fix amended into the existing single commit (no new commits) to mykaul:query_plan_opt_perf.

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

Adds pytest-based micro-benchmarks for load-balancing query-plan generation and supporting benchmark configuration.

Changes:

  • Adds benchmarks for DC-aware, rack-aware, token-aware, default, and host-filter policies.
  • Registers the benchmark marker and documents/suppresses saved benchmark results.
  • Adds an unrelated, skipped column-encryption integration test suite.

Reviewed changes

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

Show a summary per file
File Description
tests/performance/test_policy_performance.py Adds simulated-cluster policy benchmarks.
tests/performance/conftest.py Documents benchmark result comparison.
tests/integration/standard/column_encryption/test_encrypted_policies.py Adds skipped column-encryption integration tests.
pyproject.toml Registers the pytest benchmark marker.
.gitignore Ignores saved benchmark output.

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

Comment on lines +146 to +147
h = Host(MockEndPoint(address), SimpleConvictionPolicy, host_id=h_id)
h.set_location_info(dc, rack)
Comment on lines +217 to +218
@pytest.mark.benchmark
def test_dc_aware(benchmark, vnode_cluster, benchmark_queries):
mykaul added a commit to mykaul/python-driver that referenced this pull request Jul 29, 2026
test_idle_heartbeat snapshots each connection's request_ids keyed by
id(connection), sleeps for a couple of heartbeat intervals, then looks
the connections back up by id() to validate the heartbeats. If a
connection object is replaced in between, the lookup raises KeyError.

master already fixed the main source of this: wait_for_all_pools() only
waits for the first connection per host, so shard-aware connections to
the remaining shards could still be opening (and replacing placeholders)
during the sleep window. 454f727 and f348637 added
_wait_for_all_shard_connections(), called before the snapshot is taken,
and deliberately dropped an earlier "skip unknown connections" band-aid
so the assertions stay meaningful once the pool is known to be stable.

That fix only guarantees connection *count* has stabilized, though. It
doesn't rule out an existing connection being swapped for a new object
in the same shard slot while the count stays constant:
  - HostConnection._open_connection_to_missing_shard() replaces a shard's
    connection once orphaned_threshold_reached, independent of pool size.
  - HostConnection.return_connection() -> _replace() does the same when a
    connection is found defunct or closed.
Both can fire at any time, including during this test's own sleep, and
are more likely to fire under CPU/IO contention (e.g. other tests
running concurrently delaying heartbeat responses) - which lines up with
the "why does this pop now, and only intermittently" puzzle from the
scylladb#653 review discussion (with @dkropachev and @fruch) about this same
test's flakiness.

Guard against the resulting KeyError by filtering to connections that
are still present in the snapshot, but without silently weakening the
test: in the common case (no replacement) the filtered list is identical
to the full connection list, so behavior is unchanged from master. Only
when the filter actually drops something do we log a warning (so a
recurrence remains visible in CI) and we still hard-fail if every single
connection was replaced, since at that point nothing was validated.

Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
mykaul added a commit to mykaul/python-driver that referenced this pull request Jul 31, 2026
test_idle_heartbeat snapshots each connection's request_ids keyed by
id(connection), sleeps for a couple of heartbeat intervals, then looks
the connections back up by id() to validate the heartbeats. If a
connection object is replaced in between, the lookup raises KeyError.

master already fixed the main source of this: wait_for_all_pools() only
waits for the first connection per host, so shard-aware connections to
the remaining shards could still be opening (and replacing placeholders)
during the sleep window. 454f727 and f348637 added
_wait_for_all_shard_connections(), called before the snapshot is taken,
and deliberately dropped an earlier "skip unknown connections" band-aid
so the assertions stay meaningful once the pool is known to be stable.

That fix only guarantees connection *count* has stabilized, though. It
doesn't rule out an existing connection being swapped for a new object
in the same shard slot while the count stays constant:
  - HostConnection._open_connection_to_missing_shard() replaces a shard's
    connection once orphaned_threshold_reached, independent of pool size.
  - HostConnection.return_connection() -> _replace() does the same when a
    connection is found defunct or closed.
Both can fire at any time, including during this test's own sleep, and
are more likely to fire under CPU/IO contention (e.g. other tests
running concurrently delaying heartbeat responses) - which lines up with
the "why does this pop now, and only intermittently" puzzle from the
scylladb#653 review discussion (with @dkropachev and @fruch) about this same
test's flakiness.

Guard against the resulting KeyError by filtering to connections that
are still present in the snapshot, but without silently weakening the
test: in the common case (no replacement) the filtered list is identical
to the full connection list, so behavior is unchanged from master. Only
when the filter actually drops something do we log a warning (so a
recurrence remains visible in CI) and we still hard-fail if every single
connection was replaced, since at that point nothing was validated.

Compare both directions of the snapshot/current intersection, not just
"every current connection is known". return_connection() has a genuine
asynchronous gap: it pops a defunct/closed connection out of the pool's
dict synchronously, but the replacement is inserted later by _replace(),
which runs on another thread via session.submit(). A snapshot taken
during that gap sees fewer current connections than were recorded, and
every one of them is still "known" (nothing new has landed yet) - so
"all current connections are known" is trivially true even though fewer
connections than snapshotted are being validated. Tracking
missing-from-current (snapshotted connections no longer present) in
addition to unknown-in-current (new connections not in the snapshot)
makes that partial-removal case surface as a warning too, per review
feedback from @copilot-pull-request-reviewer.

Signed-off-by: Yaniv Kaul <yaniv.kaul@scylladb.com>
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.

5 participants