Skip to content

[Python] Cache __hash__, fast_name, and _TypedMetricName on MetricName - #41271

Draft
damccorm wants to merge 2 commits into
apache:masterfrom
damccorm:perf/py-metric-name-cache-hash
Draft

damccorm wants to merge 2 commits into
apache:masterfrom
damccorm:perf/py-metric-name-cache-hash

Conversation

@damccorm

@damccorm damccorm commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Note: this PR was auto-generated by tooling. Before moving it out of draft, the author has done a full review of the PR, specifically evaluating it on the following criteria:

  • Is the description accurate? In particular, are the benchmarks correct, meaningful, and reproducible.
  • Does the PR avoid doing harm (changes should be non-functional, perf only)?
  • (Judgement) Is the code change (and cost of reviewing) worth the performance improvement?

Full details of the methodology of generation and review can be found here - Apache Beam Python SDK Performance PRs Methodology & Branches Impact Ranking

If you have concerns with the PR or with the underlying methodology used to generate this, please comment below - they will be addressed by the author not the tooling :)

Summary of changes:

Optimizes MetricName in sdks/python/apache_beam/metrics/metricbase.py:

  1. Cache _cached_fast_name and _cached_hash on MetricName, and fast-path self is other in __eq__:
    • Previously, hash(metric_name) built and hashed (self.namespace, self.name, self.urn) + tuple(self.labels.items()) on every MetricsContainer dictionary lookup, and fast_name() formatted strings on every call.
  2. Initialize _typed_metric_names dict on MetricName (and reset cached fields in __getstate__/__setstate__).

Dataflow benchmarks showing improvement:

  • Before the change: 2026-11-12_13_27_21-13885815863092652873
  • After the change: 2026-11-12_13_27_21-16378524632386198380
  • Summary of perf improvement: In a single-worker (n1-standard-8, --autoscaling_algorithm=NONE --num_workers=1 --max_num_workers=1) Cloud Dataflow pipeline running 40,000,000 elements (240,000,000 metric ops) · 2× ParDo(Metrics.counter / MetricName.__hash__ & fast_name), built-in transform stage execution time improved from 2,255,354 ms -> 893,556 ms (2.52x faster, -60.4%) (total single-worker job vCPU time: 3441s -> 1945s).

Additional benchmarks

  • Strict Single-PR Isolated Microbenchmark (upstream/master vs upstream/master + this PR alone): bench_30_metric_name_cache_hash (c2feb961eef) (on branch perf/py-benchmarks-suite) — 1,000,000 hash(metric_name) and fast_name() calls improved from 540.16 ms -> 129.57 ms (4.17x faster, -76.0%, -410.59 ms) in strict isolation.
  • Per-Change Attribution (Most to Least Impact, 1,000,000 calls each in strict isolation):
    1. Most impact (65.6% of savings, -275.43 ms): Caching _cached_fast_name on MetricName — 325.42 ms -> 49.99 ms (6.51x faster, -84.6%).
    2. Second (34.4% of savings, -144.49 ms): Caching _cached_hash on MetricName — 263.74 ms -> 119.25 ms (2.21x faster, -54.8%).
  • On-Worker Cloud Dataflow n2-standard-4 Benchmark (p28_ms:12_metric_name_hash_fastname_1m & subbench_ms:03_metrics_container_lookup_ms): On a Cloud Dataflow n2-standard-4 worker, 1,000,000 hash(mn) + mn.fast_name() calls improved from 1,056 ms -> 334 ms (3.16x faster, -68.4%), and 512,000 MetricsContainer.get_counter(MetricName).inc() updates improved from 517 ms -> 180 ms (2.87x faster).

Testing

  • Verified with pytest sdks/python/apache_beam/metrics/metricbase_test.py sdks/python/apache_beam/metrics/execution_test.py.

@damccorm

Copy link
Copy Markdown
Contributor Author

/gemini review

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

Copy link
Copy Markdown
Contributor Author

/gemini review

@codecov

codecov Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 57.64%. Comparing base (d3078c1) to head (5cd4e05).
⚠️ Report is 111 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master   #41271      +/-   ##
============================================
+ Coverage     57.48%   57.64%   +0.15%     
  Complexity     2492     2492              
============================================
  Files          1204     1198       -6     
  Lines        171265   171360      +95     
  Branches       2103     2103              
============================================
+ Hits          98456    98784     +328     
+ Misses        70157    69924     -233     
  Partials       2652     2652              
Flag Coverage Δ
python 80.51% <100.00%> (+0.29%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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 optimizes MetricName by caching __hash__ and fast_name(), adding an identity fast-path in __eq__, and defining __getstate__/__setstate__ to avoid pickling cached attributes. However, _typed_metric_names is initialized on every MetricName instance without being used in this PR, and __setstate__ can silently corrupt attributes if unpickling a dict state from a prior version.

🔍 General Feedback

  • Split/Unused State (_typed_metric_names): Both the PR title and MetricName.__init__/__setstate__ introduce self._typed_metric_names = {}, which is unused in this PR (its usage appears to be in #41275). This adds an unnecessary dict allocation to every MetricName unless paired with its consumer.
  • Test Coverage: Please add unit tests in sdks/python/apache_beam/metrics/metric_test.py covering MetricName pickling round-trips (pickle.loads(pickle.dumps(metric_name))) and verifying cached hash() / fast_name() behavior (note that metricbase_test.py referenced in the PR description does not exist).

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

self.labels = labels if labels else {}
self._cached_fast_name: Optional[str] = None
self._cached_hash: Optional[int] = None
self._typed_metric_names: dict = {}

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: self._typed_metric_names is initialized here and in __setstate__ (line 129), and _TypedMetricName is mentioned in the PR title, but _typed_metric_names is never read or populated anywhere in the codebase in this PR. Unless the MetricsContainer changes from #41275 are included here, this allocates an unused empty dict on every MetricName instance and should be moved to the PR that consumes it.

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 5cd4e05: removed self._typed_metric_names = {} from MetricName.__init__ and __setstate__ (it is lazily initialized in #41275).

return (self.namespace, self.name, self.urn, self.labels)

def __setstate__(self, state):
self.namespace, self.name, self.urn, self.labels = state

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: Previously, MetricName did not define __getstate__, so its pickled representation was its 4-key instance __dict__ ({'namespace': ..., 'name': ..., 'urn': ..., 'labels': ...}). Because a 4-key dict is an iterable of length 4, unpacking state directly when unpickling a previously-serialized MetricName will silently succeed and assign the literal key strings 'namespace', 'name', 'urn', and 'labels' as the attribute values (subsequently crashing in fast_name()/__hash__() with AttributeError: 'str' object has no attribute 'items'). Handling dict states as well avoids silent data corruption across pickled states.

Suggested change
self.namespace, self.name, self.urn, self.labels = state
self.namespace, self.name, self.urn, self.labels = (state['namespace'], state['name'], state['urn'], state['labels']) if isinstance(state, dict) else state

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 5cd4e05: added isinstance(state, dict) handling in MetricName.__setstate__ and added NameTest.test_metric_name_pickle_and_legacy_dict_state.

@damccorm

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 optimizes MetricName by lazily caching the results of __hash__() and fast_name(), adding a self is other fast-path to __eq__(), and avoiding unnecessary tuple concatenation in __hash__() when labels is empty. Custom __getstate__ and __setstate__ methods are also provided (including backward-compatible handling of legacy dict pickle states) along with unit test coverage.

🔍 General Feedback

  • The implementation preserves existing hash and fast_name semantics while significantly reducing per-lookup overhead in MetricsContainer.
  • Backward compatibility with previously pickled MetricName instances (which serialized __dict__) is properly handled and tested in metric_test.py.
  • Minor note: the PR title and summary bullet #2 still mention _TypedMetricName / _typed_metric_names, which was removed in the second commit.

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

@damccorm damccorm added the ready-for-human Draft PR with all checks passing and all review comments resolved, ready for human review label Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance python ready-for-human Draft PR with all checks passing and all review comments resolved, ready for human review size/s S: 10-49 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant