Skip to content

refactor: cache constant tags - #915

Merged
KowalskiThomas merged 1 commit into
masterfrom
kowalski/refactor-cache-constant-tags
Apr 15, 2026
Merged

KowalskiThomas merged 1 commit into
masterfrom
kowalski/refactor-cache-constant-tags

Conversation

@KowalskiThomas

@KowalskiThomas KowalskiThomas commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

What is this PR?

This PR updates the way we handle constant tags to improve the overall performance of reporting metrics.
Previously, we would compute the string of user tags + constant tags at every report call; now we instead
cache the constant tags part and only compute the string for user tags and the sum of both.

When the DogStatsD instance has even a few constant tags, this can make a significant difference in how
many allocations and processing we have to do.

In practice

  • constant_tags is now a @property with a setter that pre-computes _constant_tags_str (the normalised string)
  • _report does not call _add_constant_tags anymore for metrics.
  • _add_constant_tags is kept for event and service_check paths, but updated to use self._constant_tags.

The gains

  • _add_constant_tags list concatenation — previously allocated a new tags + self.constant_tags list on every call; this is gone.
  • normalize_tags ran regex substitution over every tag on every call. Now constant tags are normalised once at init.
  • ",".join(...) ran over all tags every call. Now constant tags are pre-joined; only user tags need joining at call time.

In order to maintain existing behaviour when using statsd.constant_tags.<mutating_method> (e.g. clear or append), I had to add a TagList class that is a very basic subclass of list and that updates the cached value when it is mutated.
The alternative would have been to make it immutable (e.g. return a tuple) but this would have broken potential existing usages, which doesn't sound worth it.

Benchmark results

Setup: 5M statsd.increment() calls per case (with buffering enabled).
The baseline is the code before my changes, optimised adds the constant tags cache.

Case Baseline (ops/sec) Optimised (ops/sec) Delta
5 const + 3 user tags 1,840k 2,504k +36.1%
5 const, no user tags 1,905k 3,985k +109.2%
3 user, no const tags 2,433k 2,593k +6.6%
No tags 3,831k 4,603k +20.1%
10 const + 2 user tags 1,700k 2,629k +54.6%

The win scales with the number of constant tags and to a lower extent user tags.

@KowalskiThomas KowalskiThomas added the changelog/no-changelog Changes don't appear in changelog label Mar 6, 2026
@KowalskiThomas
KowalskiThomas force-pushed the kowalski/refactor-cache-constant-tags branch 2 times, most recently from 847d554 to bb17d8e Compare March 6, 2026 09:35
@KowalskiThomas
KowalskiThomas marked this pull request as ready for review March 6, 2026 09:42
@KowalskiThomas
KowalskiThomas requested review from a team as code owners March 6, 2026 09:42
@KowalskiThomas
KowalskiThomas force-pushed the kowalski/refactor-cache-constant-tags branch from bb17d8e to e0d59c3 Compare March 6, 2026 09:55
Comment thread datadog/dogstatsd/base.py Outdated
):
# Build the combined tag string from user tags + cached constant tags
if tags:
user_tags_str = ",".join(normalize_tags(tags))

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.

Would it make sense to append user tags and comma separators as individual elements to the parts array instead of building a temporary string?

If we do, can the same be done for constant_tags as well (to avoid the need to build and cache the string)?

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.

Would it make sense to append user tags and comma separators as individual elements to the parts array instead of building a temporary string?

This I would say we can, yes.

If we do, can the same be done for constant_tags as well (to avoid the need to build and cache the string)?

This I wouldn't be so sure, my fear would be that it would make performance worse (better than before, but not as good as this version), as you'd still be building the very long string partly from scratch at the end when you join the parts.

I can give it a go and check what kinds of results I get from it, though.

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.

I tried this and it performs worse than the other version (the one currently on the branch), so I'd advocate against this if that is fine by you.

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.

Just curious, how much worse? Is it better or worse than the unoptimized version?

@KowalskiThomas KowalskiThomas Mar 6, 2026

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.

It depends :) I ran the same benchmark and this is the result (numbers are ops/s):

  • 5 const + 3 user tags: master 2.2M, optimised: 1.7M (-21.3%)
  • 5 const, no user tags: master 2.315M, optimised: 2.6M (+11.9%)
  • 3 user, no const tags: master 2.7M, optimised: 2.5M (-4.5%)
  • No tags, master: 4.8M, optimised: 5.5M (+15.1%)
  • 10 const + 2 user tags, master: 2M, optimised: 1.5M (-28.8%)

Comment thread datadog/dogstatsd/base.py Outdated
@KowalskiThomas
KowalskiThomas requested a review from vickenty March 6, 2026 13:23
Comment thread datadog/dogstatsd/base.py Outdated
@KowalskiThomas
KowalskiThomas requested a review from vickenty March 9, 2026 15:26
Comment thread datadog/dogstatsd/base.py Outdated
Comment thread datadog/dogstatsd/base.py
@KowalskiThomas
KowalskiThomas requested a review from vickenty March 9, 2026 16:35
@KowalskiThomas

Copy link
Copy Markdown
Contributor Author

@vickenty could you give another look please?

@StephenWakely StephenWakely 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.

There's a couple of places where constant tags isn't thread safe. I don't think this was introduced by this PR, but I think it would be worth fixing. Would it be possible to do? Does it affect the benchmarks in any way?

Comment thread datadog/dogstatsd/base.py Outdated

def _add_constant_tags(self, tags):
if self.constant_tags:
if self._constant_tags:

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.

This needs to lock self._config_lock.

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.

Added this -- I don't think this should have any impact on the benchmark results as this is not on the hot path.

Comment thread datadog/dogstatsd/base.py Outdated
parts.append(text(sample_rate))

if tags:
if tags or self._constant_tags_str:

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.

This needs to lock self._config_lock.

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.

I don't think it does if we capture it first?

Like instead of doing if tags or self._constant_tags_str, we do

constant_tags_str = self._constant_tags_str
if tags or constant_tags_str:
  # use constant_tags_str, not self._constant_tags_str

What do you think? Running benchmark shows acquiring the lock here is somewhat costly.
The assignment I do in the proposed code is thread-safe in Python so while it may differ slightly from the-same-code-but-with-a-lock, but at least doing the capture instead of re-reading the attribute avoids the TOCTOU race.

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.

Ok, yes good call. The lock isn't needed. I hadn't realised that these reads are atomic in CPython. I don't think capturing it first is necessary.

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.

I do believe we need capture if we want to be consistent between the conditions and usage, right?

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.

Oh, yes true, it's used afterwards.

@KowalskiThomas
KowalskiThomas force-pushed the kowalski/refactor-cache-constant-tags branch 2 times, most recently from a7b5f48 to 0913ea9 Compare March 20, 2026 16:28
@KowalskiThomas
KowalskiThomas force-pushed the kowalski/refactor-cache-constant-tags branch from 88fcd43 to 6c452c7 Compare April 10, 2026 08:19
@KowalskiThomas
KowalskiThomas force-pushed the kowalski/refactor-cache-constant-tags branch from 179b1fb to 1c9a718 Compare April 13, 2026 15:41
@KowalskiThomas
KowalskiThomas merged commit 158bb01 into master Apr 15, 2026
20 checks passed
@KowalskiThomas
KowalskiThomas deleted the kowalski/refactor-cache-constant-tags branch April 15, 2026 15:19
@StephenWakely StephenWakely mentioned this pull request Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/no-changelog Changes don't appear in changelog

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants