Repository navigation
Conversation
There was a problem hiding this comment.
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.
c486b34 to
f6a2fa6
Compare
f6a2fa6 to
f8d3b11
Compare
ohmayr
left a comment
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
__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 mainSuggest 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) == 1There was a problem hiding this comment.
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.
| self._range_signatures = set( | ||
| RowRange._from_pb(r) for r in self._row_set.row_ranges |
There was a problem hiding this comment.
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.
| # 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): |
There was a problem hiding this comment.
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| 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 |
There was a problem hiding this comment.
nit: I'd drop this comment. The code already says what it does.
| def _from_pb(cls, data: RowRangePB) -> RowRange: | ||
| """ | ||
| Creates a RowRange from a protobuf | ||
| Creates a RowRange from a protobuf without allocating temporary unused objects. |
There was a problem hiding this comment.
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.
…and deduplicate in __init__
I've addressed both points directly in this PR (we can fully resolve Fixed in ef09fca (and updated I also fixed the performance issue in 213fa5b |
|
/gemini review |
There was a problem hiding this comment.
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.
|
/gemini review |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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 = FalseThere was a problem hiding this comment.
Keeping the current structure here intentionally as a hot-path optimization.
See analysis below:
In the common case (non-empty
start_key_closedandend_key_open), checking truthiness directly onraw_pb.start_key_closedandraw_pb.end_key_openavoids extraWhichOneofmethod call and string comparison overhead on everyadd_rangecall, only falling back toHasFieldwhen a bound is empty (b"") or unset (None).
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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]: |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
_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() |
There was a problem hiding this comment.
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?
| # 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 |
There was a problem hiding this comment.
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 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