Skip to content

usagetracker: skip groups with nothing to remove during Cleanup - #16473

Draft
colega wants to merge 2 commits into
mainfrom
colega/usagetracker-simd-cleanup-scan
Draft

usagetracker: skip groups with nothing to remove during Cleanup#16473
colega wants to merge 2 commits into
mainfrom
colega/usagetracker-simd-cleanup-scan

Conversation

@colega

@colega colega commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What this PR does

The usage-tracker removes idle series once a minute, and trackerStore.cleanup holds a shard mutex for the whole of a shard's Map.Cleanup. That call walked every slot of every group with three unpredictable branches per slot, and the walk, not the removals, was what it spent its time on: 3.5ns per entry at 16M entries per shard even when nothing had expired at all. It scales with the shard, so at 1e9 series per partition a single shard takes ~274ms, and trackSeries sits behind it.

The clock comparison that decides whether an entry expires turns out to be expressible as one unsigned byte range check, which makes it a whole-group test instead of a per-slot one. Cleanup uses it to jump straight past groups that hold nothing to remove. There are three implementations: SSE2 on amd64, NEON on arm64, and a portable SWAR one, which is also what -tags nosimd selects.

Measured at 16M entries per shard, ns per entry, arm64:

expired before portable NEON
0% 3.500 0.571 0.088
1% 3.529 0.740 0.293
5% 3.873 1.397 0.979
25% 4.617 3.915 3.328
100% 6.314 6.258 5.888

The gain narrows as more entries expire, because what is left is the removal itself: it touches three separate cache lines on a map much larger than L3, so it is memory-bound and vectors do not help it. Steady state is the low end of that table, since cleanup runs every minute against a 20 minute idle timeout and only removes what went idle in that minute. End to end, the trackSeries stall behind a shard mutex at 4M entries per shard with 5% expired is 11.6ms, and the whole-shard time at 1e9 series per partition drops from 274ms to 209ms even at an unfavourable 25% expired.

No behaviour change for callers, other than loadSnapshot now dropping timestamps outside the clock face. Those can only come from a corrupt file, and the range check is exact only within it, so without that a corrupt byte could become an entry that never expires.

Group size deliberately stays at 8 rather than widening to 16 the way dolthub/swiss does on its SIMD path. Widening would only help match and matchEmpty, and profiling puts match at ~6% of Put, which is dominated by cache misses. An 8 byte group already fits in one general purpose register, so SWAR is the right tool there.

Worth a careful look at cleanupGroup, which now visits slots from the highest index down so that the slot moved into a hole is always a live entry. TestCleanupMatchesLegacy keeps the old loop in the test file and requires both to agree on contents and counters over random maps, and TestCleanupPreservesLookups covers the probe chains that the reordering could break. The range check itself is verified exhaustively against the clock comparison, and the assembly against the portable implementation.

Tested on arm64/NEON, arm64/nosimd, amd64/SSE2 and amd64/nosimd, on Go 1.26.7 as pinned by the build image.

Which issue(s) this PR fixes or relates to

n/a

Checklist

  • Tests updated.
  • Documentation added.
  • CHANGELOG.md updated - the order of entries should be [CHANGE], [FEATURE], [ENHANCEMENT], [BUGFIX]. If changelog entry is not needed, please add the changelog-not-needed label to the PR.
  • about-versioning.md updated with experimental features.

colega added 2 commits August 25, 2026 17:47
The existing BenchmarkMapCleanup is pinned at 1M entries and allocates one map
per iteration, so it cannot be pushed to the shard sizes that large partitions
reach, and it only ever measures one expired fraction. These benchmarks sweep
both, so we can tell the cost of walking the map apart from the cost of removing
entries from it.

BenchmarkMapRehashRealistic sizes the rehash the way nextSize would, unlike
BenchmarkMapRehash which passes the entry count as a group count and so rehashes
into a map four times larger than the real one.

BenchmarkTrackerStoreCleanupLockStall measures the number that matters
operationally: how long trackSeries blocks while cleanup holds a shard mutex.
Cleanup walked every slot of every group with three unpredictable branches per
slot, and that walk, not the removals, dominated its cost: at 16M entries per
shard it spent 3.5ns per entry even when nothing at all had expired. Since
trackerStore.cleanup holds the shard mutex for the whole call, that lands
directly on trackSeries tail latency, and it grows with the shard. At 1e9 series
per partition a single shard takes ~274ms.

The eviction test becomes a single unsigned range check.
watermark.GreaterOrEqualThan(value) is (watermark-value) mod 120 < 60, so the
values that expire are the 60 minutes ending at the watermark. Under the
xor-encoding that is uint8(x-lo) <= length, with lo = 255-watermark and length
either 59 or 195 depending on whether those 60 minutes wrap the clock face. A
second compare against 1 drops the empty and tombstone markers. Being a
whole-group test, it lets scanExpired skip straight past groups holding nothing
to remove.

There are three implementations: SSE2 on amd64, NEON on arm64, and a portable
SWAR one that is also selectable with -tags nosimd. The unsigned comparisons on
arm64 are written with UMIN and UMAX because the assembler only learned CMHS and
CMHI in Go 1.27.

Group size stays at 8 rather than widening to 16 the way dolthub/swiss does for
its SIMD path. Widening would only help match and matchEmpty, and profiling puts
match at ~6% of Put, which is dominated by cache misses that vectors do not
remove. An 8 byte group already fits one general purpose register.

cleanupGroup now visits slots from the highest index down. Removing a slot fills
the hole with the last occupied slot of the group, and going downwards
guarantees that slot holds a live entry, because every expired slot above it has
already been dealt with.

Measured at 16M entries per shard, ns per entry, arm64:

    expired    before   portable    NEON
         0%     3.500      0.571   0.088
         1%     3.529      0.740   0.293
         5%     3.873      1.397   0.979
        25%     4.617      3.915   3.328
       100%     6.314      6.258   5.888

The gain narrows as more entries expire because what remains is the removal
itself, which touches three separate cache lines on a map far larger than L3 and
is memory-bound. Steady state sits at the low end of that table: cleanup runs
every minute against a 20 minute idle timeout, so it only removes the series
that went idle in that minute. The trackSeries stall behind a shard mutex, at 4M
entries per shard with 5% expired, is 11.6ms.

loadSnapshot now drops timestamps at or above clock.Cycle. They can only come
from a corrupt file, and the range check is exact only within [0, 120), so
without this a corrupt byte could become an entry that never expires.
// scanExpired returns the index of the first group in p[:n] that holds at least one
// occupied, expired slot, or n when there is none.
func scanExpired(p *data, n int, lo, length uint8) int {
for i, d := range unsafe.Slice(p, n) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Semgrep identified a blocking 🔴 issue in your code:
Using the unsafe package in Go gives you low-level memory management and many of the strengths of the C language, but also steps around the type safety of Go and can lead to buffer overflows and possible arbitrary code execution by an attacker. Only use this package if you absolutely know what you're doing.

Why this might be safe to ignore:

This is an intentional use of unsafe.Slice to iterate over an internally managed array in a generic, build-tagged implementation. The broad low-confidence rule does not show attacker-controlled input or an unsafe bound, so this finding is not reasonably exploitable in this context.

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by use-of-unsafe-block.

We're currently testing semgrep's diff-aware PR comment feature on a subset of our repos-- if you run into issues or find this spammy, please reach out to @danny.cooper in slack and give feedback.

For backwards compatability with gosec, its best to use polyglot suppression comments of the following format for false positives:
// #nosec <gosec rule ID> nosemgrep: <semgrep rule ID>

You can view more details about this finding in the Semgrep AppSec Platform.

}

func castUint64Data(d *data) uint64 {
return *(*uint64)(unsafe.Pointer(d))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Semgrep identified a blocking 🔴 issue in your code:
Using the unsafe package in Go gives you low-level memory management and many of the strengths of the C language, but also steps around the type safety of Go and can lead to buffer overflows and possible arbitrary code execution by an attacker. Only use this package if you absolutely know what you're doing.

Why this might be safe to ignore:

This is an intentional performance-oriented cast from a fixed-size data buffer to uint64, used by local byte-lane operations rather than attacker-controlled pointer manipulation. The low-confidence rule broadly flags unsafe usage, but this code does not present a practical memory-safety or code-execution issue in context.

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by use-of-unsafe-block.

We're currently testing semgrep's diff-aware PR comment feature on a subset of our repos-- if you run into issues or find this spammy, please reach out to @danny.cooper in slack and give feedback.

For backwards compatability with gosec, its best to use polyglot suppression comments of the following format for false positives:
// #nosec <gosec rule ID> nosemgrep: <semgrep rule ID>

You can view more details about this finding in the Semgrep AppSec Platform.

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.

1 participant