Skip to content

Cache program counters to frames - #62

Merged
suhailpatel merged 2 commits into
masterfrom
terrors-symbolise-optimisations
Aug 12, 2026
Merged

suhailpatel merged 2 commits into
masterfrom
terrors-symbolise-optimisations

Conversation

@suhailpatel

Copy link
Copy Markdown
Contributor

This PR adds an in memory cache for symbolisation. This is helpful since for long running code, we have terrors errors defined in fixed locations and spend useless CPU time symbolising the same things over and over

There's a few other tweaks including

  • Adding a pooled buffer for the program counter to avoid an allocation
  • We don't need the FuncForPC as we already pass the fully qualified function name

Benchmarks Before

BenchmarkBuildStack-14            221820              5377 ns/op            2888 B/op         34 allocs/op

Benchmarks After

BenchmarkBuildStack-14           1221847               918.8 ns/op          1488 B/op          2 allocs/op

@suhailpatel suhailpatel self-assigned this Aug 12, 2026

@cstorey-monzo cstorey-monzo 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.

👍

Comment thread stack/stack.go
// returned Frame only affects their own Stack and can never corrupt the
// shared cache (Frame is all strings and ints, so a struct copy is fully
// independent).
symCache sync.Map // map[uintptr][]Frame

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.

Should we consider using something with a bounded size, or that expires entries? While the number of pc addresses might be statically determined, I'm not sure there's much value in keeping them around forever.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Given we'll only populate the cache with frames from a Terror being constructed, I'm not overly worried about eviction?

Having constructed a Terror from that frame is probably a good signal that we'll eventually do it again in this process (or the process will go away, in which case freeing the memory doesn't matter).

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 talked about this here too: #62 (comment)

@cstorey-monzo

Copy link
Copy Markdown
Contributor

Oh, sorry, for some reason I thought you'd posted this in #tooling-club.

@EddShaw EddShaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Have we measured the memory impact this might have on a typical service? I don't think I have a good idea of how many unique PCs we'd expect Terrors to see.

Looks good to me! I've left a few non-blocking comments, but I'm happy to ship this as is.

Comment thread stack/stack.go
name := fn.Name()
end := strings.LastIndex(name, string(os.PathSeparator))
// Function names are always '/'-separated regardless of the host OS.
end := strings.LastIndex(name, "/")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good thing we don't support Windows I guess. 🙈

Comment thread stack/stack.go
Comment on lines +100 to +102
// A single PC can expand into multiple frames due to inlining, so we still
// have to iterate CallersFrames. Processing one PC at a time yields the same
// frames as processing the whole slice at once (verified in the tests).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thank you for the detailed comments!

Comment thread stack/stack.go
return &b
},
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I've always known this operation as "symbolication", but I have seen "symbolisation" used elsewhere.

This probably doesn't matter, but I wonder if go uses one over the other? If so, it might be marginally easier to find relevant documentation if we're consistent. 👀

... Ok, it appears go uses neither, so never mind! 😅

Comment thread stack/stack.go
// returned Frame only affects their own Stack and can never corrupt the
// shared cache (Frame is all strings and ints, so a struct copy is fully
// independent).
symCache sync.Map // map[uintptr][]Frame

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Given we'll only populate the cache with frames from a Terror being constructed, I'm not overly worried about eviction?

Having constructed a Terror from that frame is probably a good signal that we'll eventually do it again in this process (or the process will go away, in which case freeing the memory doesn't matter).

Comment thread stack/stack.go

// LoadOrStore keeps a single canonical slice per PC even if two goroutines
// race to symbolise the same one concurrently.
actual, _ := symCache.LoadOrStore(pc, frames)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not blocking!

I'm a little anxious about turning this on for all terrors-using code all at once. It might be nice to feature-gate the cache? I wonder if we could hook up some feature-gating mechanism from bedrock?

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 guess the biggest concern is the cache sizing. I'm not concerned about this at all. We have a finite number of callsites which we can cache

The number of callsites we would symbolise in the pathological case is under 100,000, and even then we're looking at a couple megabytes of space usage

Comment thread stack/stack.go
// returned Frame only affects their own Stack and can never corrupt the
// shared cache (Frame is all strings and ints, so a struct copy is fully
// independent).
symCache sync.Map // map[uintptr][]Frame

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We do have a typed sync map in wearedev in monzosync!

... Which we can't use because this is not in the monorepo. 😢

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.

We'd also need to worry about potential circular dependencies 🤔

@suhailpatel
suhailpatel merged commit fbdff20 into master Aug 12, 2026
2 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.

3 participants