Skip to content

[Python] Optimize BigQueryWriteFn row size estimation and table ref parsing - #41059

Draft
damccorm wants to merge 1 commit into
apache:masterfrom
damccorm:perf/py-bigquery-write-fn-fastpath
Draft

damccorm wants to merge 1 commit into
apache:masterfrom
damccorm:perf/py-bigquery-write-fn-fastpath

Conversation

@damccorm

@damccorm damccorm commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Description

Optimizes two per-element hot paths in BigQueryWriteFn (sdks/python/apache_beam/io/gcp/bigquery.py):

  1. Fast-path row size estimation (_fast_row_and_insert_id_size):
    • BigQueryWriteFn._check_row_size previously called objsize.get_deep_size(row_and_insert_id) on every incoming streaming insert row. objsize.get_deep_size performs a general-purpose gc.get_referents object graph traversal, costing ~170 µs per row.
    • Adds _fast_row_and_insert_id_size to sum sys.getsizeof directly when row_and_insert_id is a flat (dict, insert_id) tuple containing primitive scalar keys and values (str, int, float, bool, bytes, None), falling back to get_deep_size whenever nested containers or non-primitive objects are present (121.09x faster on 50,000 8-field streaming rows: 8.47s -> 0.070s).
  2. Skip redundant parse_table_reference in BigQueryWriteFn.process:
    • Skips resolving self.schema and calling bigquery_tools.parse_table_reference(destination) on every element when self.create_disposition == BigQueryDisposition.CREATE_NEVER or destination is already present in _KNOWN_TABLES (matching the existing early-return guard inside _create_table_if_needed).

Testing

  • Verified with pytest sdks/python/apache_beam/io/gcp/bigquery_test.py sdks/python/apache_beam/io/gcp/bigquery_tools_test.py.

@github-actions github-actions Bot added python io gcp size/s S: 10-49 lines changed labels Nov 6, 2026
@damccorm

damccorm commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@github-actions github-actions 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.

📋 Review Summary

This PR introduces two targeted optimizations in BigQueryWriteFn to reduce per-element overhead during BigQuery streaming inserts: a fast-path row size estimator (_fast_row_and_insert_id_size) that avoids objsize.get_deep_size (gc.get_referents) on flat primitive dicts, and an early guard in BigQueryWriteFn.process to skip schema resolution and parse_table_reference when tables are already in _KNOWN_TABLES or CREATE_NEVER is set.

🔍 General Feedback

  • In _fast_row_and_insert_id_size, objects referenced multiple times within the same row (such as shared string values or interned/cached integers) are summed on every occurrence without tracking seen object IDs, unlike objsize.get_deep_size. For rows containing repeated references to the same large string or bytes object, this can overestimate row size and prematurely trigger batch flushes or DLQ routing.
  • Adding unit tests specifically covering _fast_row_and_insert_id_size (including nested fallback cases and shared object references) is recommended.

💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.

if not (type(k) in _PRIMITIVE_SIZEOF_TYPES and
type(v) in _PRIMITIVE_SIZEOF_TYPES):
return get_deep_size(row_and_insert_id)
total += getsizeof(k) + (

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: objsize.get_deep_size tracks visited object IDs so that objects referenced multiple times within the same row (e.g., multiple columns referencing the same string/bytes object, or cached/interned scalars like True/False, small ints, or "") are only counted once. Here, v is only deduplicated against insert_id, so if multiple keys in row share the same value reference (especially a large str or bytes object), total will count sys.getsizeof(v) multiple times. This could cause a row to be overestimated relative to get_deep_size and falsely exceed _max_insert_payload_size (routing valid rows to FAILED_ROWS).

Consider either falling back to get_deep_size when total >= max_insert_payload_size in _check_row_size before rejecting a row, or tracking seen object IDs when needed.

def _check_row_size(self, row_and_insert_id) -> tuple[int, Optional[str]]:
"""Returns error string when the row estimated size is too big"""
row_byte_size = get_deep_size(row_and_insert_id)
row_byte_size = _fast_row_and_insert_id_size(row_and_insert_id)

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: If _fast_row_and_insert_id_size overestimates a row due to shared object references across fields (which get_deep_size deduplicates), a row near the limit could be routed to the DLQ even though get_deep_size(row_and_insert_id) < self._max_insert_payload_size. Re-checking with get_deep_size(row_and_insert_id) inside the if row_byte_size >= self._max_insert_payload_size: branch would preserve exact backwards-compatible DLQ behavior at zero cost on the normal path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant