Skip to content

perf(store): cache an observation's parsed time in 8 bytes - #2196

Merged
efiten merged 2 commits into
Kpa-clawbot:masterfrom
A13xB0:perf/obs-timestamp-8-bytes
Oct 13, 2026
Merged

efiten merged 2 commits into
Kpa-clawbot:masterfrom
A13xB0:perf/obs-timestamp-8-bytes

Conversation

@A13xB0

@A13xB0 A13xB0 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Refs #2148.

Problem

StoreObs is the store's most numerous struct: 400k of them on a week of a busy regional network. Its lazy timestamp cache (#1481) is a time.Time, a sync.Once and a bool, together 40 bytes. On current master that makes the struct 200 bytes, which puts every observation in the 208-byte size class.

Change

The cache is now a single atomic.Int64:

  • 0 before the first call;
  • the parsed time as UTC nanoseconds after it;
  • or one of two sentinels: one for empty or unparseable strings, and one for times it can't hold exactly (a non-UTC offset, or outside UnixNano's range). Those are re-parsed on each call so their location is preserved.

Concurrent first calls may both parse; they store the same value. StoreObs is now 160 bytes, in the 160-byte size class: 48 bytes × 400k observations ≈ 19 MB less heap. The first parse costs the same; cached reads now build the time.Time from an int.

Tests

  • TestStoreObsFitsIn160Bytes: red commit first.
  • TestStoreObsParsedTimeMatchesParse: Z, nanosecond, offset, SQL, empty, garbage and epoch inputs give the same time and location as time.Parse, on the first and the cached call.
  • TestStoreObsParsedTimeConcurrentFill.
  • The existing storeobs_parsedtime_test.go still passes.
  • go test ./cmd/server passes, and -race passes on the ParsedTime, retransmission and observer analytics tests.

This PR or #2149

#2149 fixes #2148 by reordering these same three fields. The two conflict, so here is how they compare. Sizes are unsafe.Sizeof(StoreObs{}) on each branch, with Go's heap size class beside them:

struct heap per observation saved per observation saved at 400k observations
master 200 B 208 B – –
#2149 (field reorder) 192 B 192 B 16 B ~6.4 MB
this PR (one 8-byte field) 160 B 160 B 48 B ~19 MB
  • perf(server): reduce retained observation allocation size #2149: a pure field reorder with no change in behaviour. It is the smallest, safest change and the easiest to review. It saves a third of what this PR does.
  • This PR: three times the saving, but more code. It adds two sentinel values, and re-parses on each call any timestamp with a non-UTC offset. The store writes UTC (Z), so that path is rare. It is tested against time.Parse on edge cases, concurrently, and under -race.

Options:

  1. Take this PR. perf(server): reduce retained observation allocation size #2149 then has nothing left to reorder.
  2. Take perf(server): reduce retained observation allocation size #2149 now, and this PR after it as a follow-up for the further 32 bytes per observation. I'll rebase onto perf(server): reduce retained observation allocation size #2149 if it lands first.
  3. Take only perf(server): reduce retained observation allocation size #2149 if the smaller, simpler change is preferred.

No config or UI changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H

A13xB0 and others added 2 commits October 13, 2026 04:12
Red: the timestamp cache (time.Time + sync.Once + bool) makes StoreObs
192 bytes. The parse-equivalence and concurrency tests pass today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H
StoreObs cached its parsed timestamp in a time.Time, a sync.Once and a
bool: 40 bytes that made it 192 bytes, the 192-byte size class. A week of
a busy network holds 400k observations.

The cache is now one atomic.Int64: 0 before the first call, the time in
UTC nanoseconds after it, or a sentinel for unparseable strings and for
times it cannot hold exactly (a non-UTC offset, or out of UnixNano's
range), which are parsed again on each call so their location is kept.
Concurrent first calls may both parse; they store the same value.
StoreObs is 160 bytes, one size class down: 32 bytes x 400k = 12.8MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H

@efiten efiten left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving 682c40a1. TestStoreObsFitsIn160Bytes fails on base (200 bytes) and passes here; a per-object alloc bench shows 208 B/op on base and 160 B/op here. Z and space-separated inputs come back as UTC as before, empty or garbage input returns (time.Time{}, false), and offsets and the epoch keep their location through the re-parse sentinel. go vet is clean, go test ./... in cmd/server passes, -race on the touched tests passes, and it merges cleanly with #2183, #2195, #2197, #2198 and #2199.

Notes, not blocking:

  1. The first ParsedTime call is slower than before (BenchmarkComputeRetransmissionPressureColdTimestamps: 165 ms/op on base, 176-180 ms/op here), from t.Year() twice and the time.Unix(0, t.UnixNano()).UTC() rebuild. Computing the year once and returning t would keep "the first parse costs the same" true.
  2. A cached read went from 1.1 ns to 2.4 ns, and an offset timestamp is now re-parsed on every call (50 ns). Only the legacy non-v3 path (store.go:927) can feed those, so this is fine; one sentence in the description would cover it.
  3. The comments in obs_parsed_time_test.go:12-17 and store.go:96-100 say the old struct was in the 192-byte size class; at 200 bytes it was the 208-byte class, as the description says.

This conflicts with #2149 on the same three fields; that one is 34 commits behind and will need a rebase either way.

@efiten
efiten merged commit 8d2ae40 into Kpa-clawbot:master Oct 13, 2026
12 checks passed
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.

perf: avoid allocation-class padding in retained observations

2 participants