Skip to content

fix(bigtable): resolve O(n^2) bottleneck in ReadRowsQuery.add_range - #18706

Open
parthea wants to merge 24 commits into
mainfrom
fix-bigtable-20261015
Open

parthea wants to merge 24 commits into
mainfrom
fix-bigtable-20261015

Conversation

@parthea

@parthea parthea commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

This PR resolves a severe client-side O(n^2) CPU performance bottleneck inside ReadRowsQuery.add_range. This regression was introduced when deduplication checks were added to prevent duplicate row ranges in read requests.

Fixes b/573522284
Fixes #18708

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes the performance of ReadRowsQuery.add_range from O(N) to O(1) by making RowRange hashable and maintaining a _range_signatures set. It also fixes a typo from slots to __slots__ to ensure proper memory optimization, and adds corresponding unit tests. However, the proposed self-healing logic in add_range compares the lengths of the _range_signatures set and the _row_set.row_ranges list. If the query is initialized with duplicate ranges, these lengths will never match, causing the cache to rebuild on every call and introducing an O(N^2) bottleneck. It is recommended to remove this self-healing check entirely.

Comment thread packages/google-cloud-bigtable/google/cloud/bigtable/data/read_rows_query.py Outdated
@parthea
parthea force-pushed the fix-bigtable-20261015 branch from c486b34 to f6a2fa6 Compare October 15, 2026 19:55
@parthea
parthea force-pushed the fix-bigtable-20261015 branch from f6a2fa6 to f8d3b11 Compare October 15, 2026 20:03
@parthea
parthea marked this pull request as ready for review October 15, 2026 20:15
@parthea
parthea requested a review from a team as a code owner October 15, 2026 20:15
Comment thread packages/google-cloud-bigtable/tests/unit/data/test_read_rows_query.py Outdated

@ohmayr ohmayr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for picking this up. Using a set is the right fix. One blocking issue (inline): __hash__ and __eq__ disagree for the legacy row_set.RowRange, so mixed legacy/base ranges no longer dedup. The rest are small.

Separately, add_key and ReadRowsQuery.__eq__ have the same O(n²) pattern: 10k add_key calls take ~11.6s, and __eq__ on two 2k-range queries takes ~50s. These can go in a follow-up PR, but please don't close b/573522284 until they're tracked, since key-heavy workloads are still slow.

self.end_is_inclusive,
)

def __hash__(self) -> int:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

__eq__ compares _pb, but __hash__ hashes self._key(). The legacy subclass google.cloud.bigtable.row_set.RowRange overrides _key() with a different field order, so legacy and base ranges compare equal but hash differently. That breaks set membership for mixed types, and dedup regresses vs main:

from google.cloud.bigtable.row_set import RowRange as LegacyRowRange

b, l = RowRange(b"a", b"b"), LegacyRowRange(b"a", b"b")
b == l               # True
hash(b) == hash(l)   # False

q = ReadRowsQuery(row_ranges=[b])
q.add_range(l)
len(q.row_ranges)    # 2 on this branch, 1 on main

Suggest hashing the same data __eq__ compares:

def __hash__(self) -> int:
    pb = self._pb
    return hash(
        (pb.start_key_closed, pb.start_key_open, pb.end_key_closed, pb.end_key_open)
    )

and dropping __hash__ from row_set.RowRange so it inherits this one (_key() then isn't needed here). I tried this locally: mixed legacy/base dedup works, the bigtable unit tests pass, and 10k add_range calls still take ~0.23s. Could you add a test along these lines?

def test_row_range_hash_matches_legacy(self):
    from google.cloud.bigtable.data.read_rows_query import RowRange
    from google.cloud.bigtable.row_set import RowRange as LegacyRowRange

    b, l = RowRange(b"a", b"b"), LegacyRowRange(b"a", b"b")
    assert b == l
    assert hash(b) == hash(l)
    query = self._make_one(row_ranges=[b])
    query.add_range(l)
    assert len(query.row_ranges) == 1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ef09fca.

RowRange.__hash__ now hashes the 4 proto fields (via _signature()), row_set.RowRange.__hash__ is removed so it inherits the base implementation, and I added test_row_range_hash_matches_legacy.

Comment on lines +265 to +266
self._range_signatures = set(
RowRange._from_pb(r) for r in self._row_set.row_ranges

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

row_ranges is already a list of RowRange here, so set(row_ranges) should be enough and avoids re-wrapping from the proto. Naming nit: this holds RowRange objects, not signatures. Maybe _row_range_set?

Heads-up: ReadRowsQuery declares slots = (...) (not __slots__), which is the only reason adding a new attribute works. If that typo gets fixed later, this attribute needs to be added there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in f9b35b1 and 213fa5b: __init__ now calls self.add_range(r) directly, _range_signatures stores the immutable row_range._signature() tuple, and slots is now __slots__ with the new attributes included.

# Verify that all ranges were successfully committed to the underlying protobuf
assert len(query._row_set.row_ranges) == 100

def test_add_range_deduplication_behavior(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for adding this. One gap: the new set seeding in __init__ isn't covered. Could we add a case where a range is passed to the constructor and an equal one is then added?

query = self._make_one(row_ranges=[RowRange("a", "b")])
query.add_range(RowRange("a", "b"))
assert len(query.row_ranges) == 1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in bff7b0b

row_range: a range of row keys to add to this query
"""
if row_range not in self.row_ranges:
# Utilize pure O(1) set-membership checking

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I'd drop this comment. The code already says what it does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f9b35b1

def _from_pb(cls, data: RowRangePB) -> RowRange:
"""
Creates a RowRange from a protobuf
Creates a RowRange from a protobuf without allocating temporary unused objects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: cls.__new__(cls) is a good change, but the docstring now describes implementation. I'd keep the original wording and add a # comment on why __init__ is skipped if needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 183df93

@parthea parthea self-assigned this Oct 16, 2026
@parthea

parthea commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for picking this up. Using a set is the right fix. One blocking issue (inline): __hash__ and __eq__ disagree for the legacy row_set.RowRange, so mixed legacy/base ranges no longer dedup. The rest are small.

Separately, add_key and ReadRowsQuery.__eq__ have the same O(n²) pattern: 10k add_key calls take ~11.6s, and __eq__ on two 2k-range queries takes ~50s. These can go in a follow-up PR, but please don't close b/573522284 until they're tracked, since key-heavy workloads are still slow.

I've addressed both points directly in this PR (we can fully resolve b/573522284):

Fixed in ef09fca (and updated _range_signatures to store the tuple directly in f9b35b1).

I also fixed the performance issue in 213fa5b

@parthea

parthea commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes the performance of ReadRowsQuery and RowRange by introducing __slots__ to reduce memory overhead, implementing O(1) deduplication for row keys and ranges using internal sets (_key_set and _range_signatures), and optimizing equality checks to avoid materializing wrapper objects. It also updates type annotations and adds comprehensive unit tests to verify these performance improvements and deduplication behaviors. Feedback suggests optimizing the __copy__ method of ReadRowsQuery by using the native protobuf CopyFrom method via the public API type(value).pb(value) instead of copy.deepcopy on the private _pb attribute to further improve performance and avoid accessing private fields.

@parthea

parthea commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes ReadRowsQuery and RowRange by introducing O(1) set-based deduplication for row keys and ranges, enforcing __slots__ to reduce memory allocation, and optimizing object copying and instantiation. The review feedback suggests simplifying the nested conditional logic in _signature_from_pb by using the protobuf WhichOneof method to improve readability and idiomatic handling of the oneof fields.

Comment on lines +97 to +134
# Fast path: non-empty keys bypass HasField and proto-plus overhead
start_closed = raw_pb.start_key_closed
if start_closed:
start_key: bytes | None = start_closed
start_is_inclusive = True
else:
start_open = raw_pb.start_key_open
if start_open:
start_key = start_open
start_is_inclusive = False
elif raw_pb.HasField("start_key_closed"):
start_key = b""
start_is_inclusive = True
elif raw_pb.HasField("start_key_open"):
start_key = b""
start_is_inclusive = False
else:
start_key = None
start_is_inclusive = True

end_open = raw_pb.end_key_open
if end_open:
end_key: bytes | None = end_open
end_is_inclusive = False
else:
end_closed = raw_pb.end_key_closed
if end_closed:
end_key = end_closed
end_is_inclusive = True
elif raw_pb.HasField("end_key_open"):
end_key = b""
end_is_inclusive = False
elif raw_pb.HasField("end_key_closed"):
end_key = b""
end_is_inclusive = True
else:
end_key = None
end_is_inclusive = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The current nested conditional logic with HasField checks is complex and prone to errors when handling edge cases like empty bytes (b""). Since start_key and end_key are defined as oneof fields in the protobuf definition, we can use the standard WhichOneof method on the underlying protobuf message. This simplifies the logic significantly, improves readability, and is more idiomatic.

        start_oneof = raw_pb.WhichOneof("start_key")
        if start_oneof == "start_key_closed":
            start_key = raw_pb.start_key_closed
            start_is_inclusive = True
        elif start_oneof == "start_key_open":
            start_key = raw_pb.start_key_open
            start_is_inclusive = False
        else:
            start_key = None
            start_is_inclusive = True

        end_oneof = raw_pb.WhichOneof("end_key")
        if end_oneof == "end_key_open":
            end_key = raw_pb.end_key_open
            end_is_inclusive = False
        elif end_oneof == "end_key_closed":
            end_key = raw_pb.end_key_closed
            end_is_inclusive = True
        else:
            end_key = None
            end_is_inclusive = False

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping the current structure here intentionally as a hot-path optimization.

See analysis below:

In the common case (non-empty start_key_closed and end_key_open), checking truthiness directly on raw_pb.start_key_closed and raw_pb.end_key_open avoids extra WhichOneof method call and string comparison overhead on every add_range call, only falling back to HasField when a bound is empty (b"") or unset (None).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IIUC, we shouldn't need the HasField fallback case at all. Bigtable treats falsy values as unbounded keys, and the handwritten classes collapse them into None

# ReadRowsQuery defines __slots__, which gives it a C-level memory layout
# disjoint from dict. Consequently, `query` must be explicitly typed as
# `ReadRowsQuery | dict[str, object]` in the signature above; otherwise,
# mypy flags this runtime `isinstance(query, dict)` branch as unreachable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Is this comment really useful? It seems like it would feel out of place after the PR is merged

@staticmethod
def _signature_from_pb(
pb: RowRangePB,
) -> tuple[bytes | None, bool, bytes | None, bool]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: if we do keep this, we should probably add a type alias for the signature

def add_range(
self,
row_range: RowRange,
row_range: RowRange | RowRangePB,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this necessary? Bigtable wanted to hide protobufs from the public interface

return (start_key, start_is_inclusive, end_key, end_is_inclusive)

def _signature(self) -> tuple[bytes | None, bool, bytes | None, bool]:
return self._signature_from_pb(self._pb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_signature_from_pb seems very complex. Isn't this doing the same job as the legacy client's _key()? Can't we just move _key() and __hash__() into the base class here, and avoid the complex parsing logic?

(Also, are you sure we need both _signature and _signature_from_pb? I only see _signature_from_pb being used to handle PB inputs, but I don't think we'd actually want to expose that publicly)

row_keys = [key.encode() if isinstance(key, str) else key for key in row_keys]
self._row_set = RowSetPB(
row_keys=row_keys, row_ranges=[r._pb for r in row_ranges]
self._row_set = RowSetPB()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems like we're duplicating some state now, if we are tracking both row_set, and keys/ranges separately. Lots of room for those to get out of sync

Could we remove row_set, turn range_signatures into a set of full Range instances, and just re-construct the row_set inside to_pb? That seems like it could simplify the data model. Or am I misunderstanding?

Comment on lines +97 to +134
# Fast path: non-empty keys bypass HasField and proto-plus overhead
start_closed = raw_pb.start_key_closed
if start_closed:
start_key: bytes | None = start_closed
start_is_inclusive = True
else:
start_open = raw_pb.start_key_open
if start_open:
start_key = start_open
start_is_inclusive = False
elif raw_pb.HasField("start_key_closed"):
start_key = b""
start_is_inclusive = True
elif raw_pb.HasField("start_key_open"):
start_key = b""
start_is_inclusive = False
else:
start_key = None
start_is_inclusive = True

end_open = raw_pb.end_key_open
if end_open:
end_key: bytes | None = end_open
end_is_inclusive = False
else:
end_closed = raw_pb.end_key_closed
if end_closed:
end_key = end_closed
end_is_inclusive = True
elif raw_pb.HasField("end_key_open"):
end_key = b""
end_is_inclusive = False
elif raw_pb.HasField("end_key_closed"):
end_key = b""
end_is_inclusive = True
else:
end_key = None
end_is_inclusive = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IIUC, we shouldn't need the HasField fallback case at all. Bigtable treats falsy values as unbounded keys, and the handwritten classes collapse them into None

This branch has not been deployed

No deployments
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.

bug(bigtable): Performance regression in ReadRowsQuery.add_range

3 participants