Skip to content

perf: optimize Tablet memory layout and per-query lookup speed - #812

Draft
mykaul wants to merge 8 commits into
scylladb:masterfrom
mykaul:perf/tablets-memory-and-lookup
Draft

perf: optimize Tablet memory layout and per-query lookup speed#812
mykaul wants to merge 8 commits into
scylladb:masterfrom
mykaul:perf/tablets-memory-and-lookup

Conversation

@mykaul

@mykaul mykaul commented Apr 9, 2026

Copy link
Copy Markdown

Summary

Five incremental optimizations to the tablets hot path, each in a separate commit:

  1. __slots__ on Tablet — eliminates per-instance __dict__ allocation
  2. tuple replicas — replicas are immutable after creation; use tuple instead of list
  3. Cached _replica_dict — build {host_id: shard_id} dict once at construction, use it in both per-query hot paths (policies.py and pool.py). Also fixes a latent iterator-consumption bug where passing a generator to Tablet() would silently produce an empty _replica_dict.
  4. Streamline from_row — inline the _is_valid_tablet check to eliminate a staticmethod descriptor lookup, an extra function call, and a redundant is not None guard
  5. Parallel token index lists — maintain _first_tokens and _last_tokens as plain list[int] dicts alongside _tablets, so bisect_left runs entirely in C on native ints instead of calling an attrgetter callback per comparison. Follow-up to PR perf: use stdlib bisect and attrgetter in tablets.py (100's of ns, 1.5-5.6x speedup) #757 which identified this opportunity in its own benchmarks.

Benchmarks (Python 3.14, best-of-5 rounds)

Memory per Tablet (deep, pympler.asizeof, 3 replicas)

State Deep size Change vs baseline
Baseline (no __slots__, list, no dict) 1856 B
After commit 1 (__slots__) 1400 B -456 B
After commit 2 (tuple) 1384 B -472 B
After commit 3 (_replica_dict) 1616 B -240 B

Commits 1+2 save 472 bytes/tablet. Commit 3 spends 232 bytes back on the _replica_dict cache. Net: 240 bytes saved per tablet (13%). Commit 5 adds 16 bytes/tablet (two ints in parallel lists) — negligible.

Shallow breakdown (sys.getsizeof)
Component Before After Change
instance shell 48 B 64 B +16 B (slots have fixed overhead)
__dict__ 296 B 0 B -296 B
replicas container 88 B (list) 72 B (tuple) -16 B
_replica_dict (3 entries) 224 B +224 B

get_tablet_for_key (hit — the primary per-query hot path)

Tablets Before After Saved Speedup
10 293 ns 216 ns 78 ns 1.36x
100 351 ns 233 ns 118 ns 1.51x
1,000 448 ns 267 ns 181 ns 1.68x
10,000 537 ns 282 ns 255 ns 1.90x

Miss path (N=1000): 458 ns -> 229 ns (2.0x).

Other per-query hot paths

Path Before After Speedup
policies.py: set(map(lambda r: r[0], tablet.replicas)) 372 ns 18 ns (tablet._replica_dict) 20.7x
pool.py: linear scan for shard_id 199 ns 73 ns (dict.get) 2.7x
replica_contains_host_id O(n) linear O(1) dict in

Construction (Tablet.from_row)

State Time Change
Original (master) 143 ns
After commit 3 (_replica_dict + old from_row) 465 ns +322 ns
After commit 4 (streamlined from_row) 410 ns +267 ns

Commit 4 recovers ~54 ns (12%) of the construction regression. The remaining +267 ns vs master is the irreducible cost of building tuple() + dict() at construction time (~250 ns), which pays for itself on every query.

Tests

All 223 unit tests pass (tablets, policies, pool, metadata, cluster, response_future). 7 new tests added for _replica_dict behavior, including an iterator edge-case regression test.

@mykaul
mykaul force-pushed the perf/tablets-memory-and-lookup branch from 2640c18 to 1d25663 Compare April 9, 2026 11:18
@mykaul
mykaul marked this pull request as draft April 9, 2026 11:35
@mykaul
mykaul force-pushed the perf/tablets-memory-and-lookup branch from 3d5cfbd to 601f065 Compare April 21, 2026 08:57
@mykaul
mykaul force-pushed the perf/tablets-memory-and-lookup branch from 601f065 to 6c9a4b0 Compare June 29, 2026 22:07
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a8e5817a-d00d-4d58-9651-e33ca1e249fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@mykaul
mykaul force-pushed the perf/tablets-memory-and-lookup branch 2 times, most recently from b5a23f2 to 364ad45 Compare June 30, 2026 14:23
Copilot AI review requested due to automatic review settings July 29, 2026 20:44
@mykaul
mykaul force-pushed the perf/tablets-memory-and-lookup branch from 364ad45 to f91d7d9 Compare July 29, 2026 20:44
@mykaul

mykaul commented Jul 29, 2026

Copy link
Copy Markdown
Author

Rebased onto current master (no real conflicts — master's tablet-related work so far is limited to a TABLETS_ROUTING_V2 mention in the changelog; no code in tablets.py/policies.py yet).

Replica-ordering check (relevant to the LWT Paxos-leader routing fix in #782 / fix/lwt-paxos-leader-routing): verified this PR does not change the order or semantics of tablet.replicas:

  • Tablet.__init__ now does self.replicas = tuple(replicas), which preserves the exact iteration order of whatever is passed in (covered by test_replica_dict_from_iterator, which asserts order for a generator input). The order still ultimately comes from the server's tablets-routing-v1 payload in cluster.py, untouched by this PR.
  • The new _replica_dict ({host_id: shard_id}) is only used for O(1) membership/shard lookups (replica_contains_host_id, shard-aware connection selection); it never feeds back into .replicas or replaces it as a source of order.
  • TokenAwarePolicy.make_query_plan's tablet branch still derives host order from the child policy's round-robin plan (child_plan), filtered by _replica_dict membership — same as the pre-existing set(map(...)) behavior, just O(1) instead of O(n) per check. This PR doesn't touch the LWT-specific ordering that Fix LWT routing: preserve Paxos leader order in TokenAwarePolicy #782 introduces, so the two are compatible; when Fix LWT routing: preserve Paxos leader order in TokenAwarePolicy #782 lands it will just need to rebase its make_query_plan diff onto the _replica_dict/query._tablet version added here.

Bug found and fixed during self-review: the "avoid redundant tablet lookup" commit stashed query._tablet in make_query_plan() for reuse in shard-aware connection selection, but never cleared it when a later call found no tablet. Since Statement/BoundStatement objects can be rebound and re-executed (prepared.bind() mutates and returns the same instance), a tablet looked up for one routing key could leak into shard selection for a later, unrelated routing key on the same reused statement object — picking a wrong/stale shard. Fixed by clearing query._tablet = None whenever the current lookup finds no tablet (and in the no-routing-key/no-keyspace early-return path). Added two regression tests (test_stale_tablet_not_reused_across_query_plans, test_stale_tablet_not_reused_when_no_routing_key) that fail against the pre-fix code and pass now. Amended into the existing "perf: avoid redundant tablet lookup..." commit rather than adding a new one.

Tests: tests/unit/test_tablets.py (20), tests/unit/test_policies.py (86, incl. 2 new), and the full tests/unit/ suite (732 passed, 88 skipped, 0 failed) all green locally after the rebase + fix.

CI: all checks were green on the pre-rebase HEAD (364ad457) — Integration tests (asyncio/libev/asyncore x Python 3.11-3.14t), wheel builds, docs build, CodeRabbit, Snyk. No unresolved review threads exist on the PR.

Force-pushed the rebased + amended branch; still a draft.

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

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Optimizes the tablet-related hot paths by reducing Tablet memory overhead and speeding up per-query tablet/replica lookups, while plumbing a cached tablet through query planning into shard-aware connection selection.

Changes:

  • Add __slots__, store replicas as tuples, and cache a host→shard lookup dict on Tablet
  • Speed up tablet lookups with parallel first_token/last_token index lists and streamline from_row
  • Reuse planned tablet during connection selection and update/extend unit tests accordingly

Reviewed changes

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

Show a summary per file
File Description
cassandra/tablets.py Core optimizations: slots, replica dict cache, token index lists, faster bisect-based lookup and updates
cassandra/policies.py Stashes/clears query._tablet to enable reuse downstream and avoids extra replica mapping work
cassandra/pool.py Accepts an optional tablet to skip redundant tablet lookups and do O(1) shard selection
cassandra/cluster.py Plumbs query._tablet into pool.borrow_connection()
tests/unit/test_tablets.py Adds tests for _replica_dict, iterator replicas, tuple storage, and token index sync on drops
tests/unit/test_policies.py Adds regression tests ensuring stale tablets aren’t reused across executions/query plans
tests/unit/test_response_future.py Updates mocks to match the new borrow_connection(..., tablet=...) call shape

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

Comment thread cassandra/tablets.py Outdated
Comment thread cassandra/tablets.py Outdated
Comment thread tests/unit/test_tablets.py Outdated
mykaul added 8 commits July 31, 2026 19:53
Add __slots__ to the Tablet class, removing the per-instance __dict__
allocation. Tablets are created frequently (one per token range per table)
and are long-lived, so the cumulative memory savings are significant.

Before: 416 bytes/tablet (48 instance + 96 __dict__ + 80 replicas + 192 tuples)
After:  328 bytes/tablet (56 instance +  0 __dict__ + 80 replicas + 192 tuples)
Saving: 88 bytes/tablet (21%)

Scale impact (3 replicas/tablet):
  12,800 tablets (100 tables x 128): saves 1.1 MB
 128,000 tablets (1000 tables x 128): saves 10.7 MB
 256,000 tablets (1000 tables x 256): saves 21.5 MB

Tablet.from_row construction also improves:
  Before: 186 ns/call
  After:  147 ns/call (1.27x faster, -21%)
Replicas are never mutated after Tablet construction; convert to tuple
in __init__ to save 8 bytes per tablet (list overallocates for future
appends that never happen) and communicate immutability.

Before: 328 bytes/tablet (replicas container: 80 bytes as list)
After:  320 bytes/tablet (replicas container: 72 bytes as tuple)
Saving: 8 bytes/tablet (2.4%)

Combined with __slots__ (commit 1), total savings so far: 96 bytes/tablet.

Scale impact (3 replicas/tablet):
  128,000 tablets: saves ~1.0 MB (tuple) + 10.7 MB (slots) = 11.7 MB total
  256,000 tablets: saves ~2.0 MB (tuple) + 21.5 MB (slots) = 23.5 MB total
Build a {host_id: shard_id} dict once at Tablet construction time so
that policies.py and pool.py can replace set(map(lambda ...)) and
linear scans with O(1) dict operations.

- Add _replica_dict to __slots__
- Build dict from the materialized tuple (not the raw replicas arg)
  to avoid double-consuming a one-shot iterator
- Update DCAwareRoundRobinPolicy to use tablet._replica_dict keys
- Update HostConnection to use tablet._replica_dict.get() for shard
- Rewrite replica_contains_host_id() to use dict membership
- Add 7 unit tests covering dict construction, lookup, host membership,
  tuple storage, and the iterator edge case

Add a public get_replica_shard_id() accessor alongside
replica_contains_host_id(), and rewrite TabletReplicaDictTest to assert
through that public API instead of poking at the private _replica_dict
cache directly (per review feedback: reaching into the private field
makes future refactors of the internal representation unnecessarily
fragile). One minimal test (test_replica_dict_populated_as_expected) is
kept to directly assert on _replica_dict's shape, since the public API
alone can't prove the O(1) cache is actually populated as expected.
Remove the _is_valid_tablet staticmethod indirection and replace the
two-step from_row -> _is_valid_tablet -> Tablet() chain with a single
truthiness guard and direct construction.  Saves ~54 ns/call (12%)
by eliminating a staticmethod descriptor lookup, an extra function
call, and redundant 'is not None' check (replicas from CQL
deserialization is always a list or None).

Fix a real correctness regression introduced by this same change: the
inlined `if not replicas:` truthiness check is always False for a
one-shot iterator/generator, even an empty one (iterators have no
__len__/__bool__ so the default 'always truthy' rule applies). The
previous _is_valid_tablet() helper used len(replicas) != 0, which would
at least raise a TypeError for a generator rather than silently
constructing a Tablet with empty replicas/_replica_dict instead of
returning None. Materialize replicas into a tuple once and check that
for emptiness, then hand the already-materialized tuple to Tablet()
so it isn't consumed twice. Add TabletFromRowTest covering the empty
list/generator/None cases (return None) and non-empty list/generator
cases (Tablet is built correctly, including from a single-use
generator).
Maintain parallel _first_tokens and _last_tokens dicts alongside
_tablets, each mapping (keyspace, table) to a plain list[int].  This
lets bisect_left run entirely in C on native ints instead of calling
an attrgetter callback on every comparison during binary search.

Follow-up to PR scylladb#757 which identified the opportunity: its own
benchmarks showed bisect_left without key= is 2.7-5.7x faster than
with key=attrgetter.

Results (best-of-5, Python 3.14):

  get_tablet_for_key (hit):
  Tablets    Before    After    Saved   Speedup
       10    293ns    216ns     78ns     1.36x
      100    351ns    233ns    118ns     1.51x
    1,000    448ns    267ns    181ns     1.68x
   10,000    537ns    282ns    255ns     1.90x

All three dicts are kept in sync by add_tablet, drop_tablets, and
drop_tablets_by_host_id.  The attrgetter imports are no longer needed
and have been removed.

Also drop the mutable {} class-level defaults this same change added
(_first_tokens, _last_tokens), plus the pre-existing _tablets = {} they
were modeled on: leaving mutable dicts at class scope is a latent
shared-state hazard (e.g. if a future alternative constructor bypassed
__init__), even though __init__ already reassigns them per instance
today. All three are now instance-only, initialized solely in
__init__. Add TabletsInstanceStateTest to lock this in: one check that
the class body itself carries no such attributes, one check that two
instances never observe each other's state.
Replace the per-tablet reversed pop() loop (O(k*n) for each of three
parallel lists) with a single-pass index filter that rebuilds the
lists once.  This avoids repeated list element shifting and scales
better when many tablets are dropped at once.

Benchmark (3 replicas/tablet, ~33% dropped):
  Tablets   Old (triple-pop)   New (batch-filter)   Speedup
     100          123 us             128 us           ~1.0x
   1,000        1,375 us           1,113 us           1.24x
  10,000       25,429 us          13,079 us           1.94x

Add 3 unit tests for drop_tablets_by_host_id covering matching,
None host_id, and nonexistent host_id.
When tablets are in use, get_tablet_for_key() was called twice per
request: once in TokenAwarePolicy.make_query_plan() to find the replica,
and again in HostConnection._get_connection_for_routing_key() to
determine the shard.

Stash the tablet found during query planning on the query object
(query._tablet) and pass it through to borrow_connection(), which
skips the second lookup when a tablet is already available. This
eliminates redundant bisect_left calls and associated dict lookups.

A Statement (e.g. BoundStatement) can be rebound and re-executed by the
caller, so the same query object may be passed to make_query_plan()
again with a different routing key. Clear query._tablet whenever the
current lookup finds no tablet (or there is no routing key at all), so
a tablet stashed for an earlier, unrelated routing key can't leak into
shard-aware connection selection for the new one.
The 6th perf commit added a tablet= keyword argument to
borrow_connection. Update the 6 mock assertions in
test_response_future.py to expect the new parameter.
Copilot AI review requested due to automatic review settings July 31, 2026 16:59
@mykaul
mykaul force-pushed the perf/tablets-memory-and-lookup branch from f91d7d9 to 49fa146 Compare July 31, 2026 16:59

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

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

cassandra/tablets.py:80

  • get_tablet_for_key() now combines three independently mutable structures without taking _lock. Writers update them in separate steps: for example, drop_tablets_by_host_id() replaces _tablets[key] at line 112 before replacing the token lists, while drop_tablets() can remove the token-list keys after this method has captured last_tokens. A concurrent topology update can therefore return a tablet from a different range or raise KeyError/IndexError. Keep each table's tablets and indexes in one snapshot that writers replace atomically, or synchronize reads with all writer updates.
        if id < len(last_tokens) and token_value > self._first_tokens[key][id]:
            return self._tablets[key][id]

Comment thread cassandra/policies.py
# Stash the tablet so that downstream shard-aware
# connection selection can reuse it instead of
# repeating the bisect lookup.
query._tablet = tablet
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.

2 participants