Why v8go Needed a CPU Profiler

v8go is a Go/C++ bridge that runs JavaScript in Google V8 isolates at native speed via Cgo bindings. It offers Go developers access to V8's APIs, but one prominent gap was the V8 CPU Profiler. Adding it opens the door to measuring JavaScript execution performance, including V8 internals like garbage collection, compilation, and optimization — not just the executing JavaScript itself.

The design goals for this addition were: an API that\u2019s simple for Go users, extensible enough for future profiler features, closely mapped to the native V8 API, and as fast as possible. That last point drove an interesting engineering story — a first implementation that looked less optimal on paper, a rewrite to match that theory, and then benchmark results that contradicted expectations until a larger test case provided the full picture.

The profiler works by sampling call stacks at a set interval while JavaScript runs. When stopped, it returns a top-down call tree populated with nodes, each with function and script metadata, sample counts, and parent/child pointers. Here's a JavaScript snippet the profiler can analyze:

In v8go, you create an isolate, context, and CPU profiler; tell it to start profiling; run your code; then stop the profiler to collect the profile. Each line in the resulting top-down view corresponds to a node that includes:

  • The function name (empty for anonymous functions)
  • Script id, name, line, and column numbers
  • A flag for whether the script is available cross-origin
  • Sample count and the node's children and parent

The library intentionally stays unopinionated about formatting and visualization — the focus is delivering a performant, idiomatic API for generating profiles.

Iteration One: Lazy Loading

The first approach mirrored V8's API as closely as possible, introducing Go structs corresponding to CPUProfiler, CPUProfile, and CPUProfileNode, each backed by Cgo calls into native C++. Getting profile data was a call-per-property model. To stop profiling and return a profile pointer:

On the Go side, each call to get a property or walk to a child node meant its own Cgo call and C++ function invocation, so traversing a large tree required many crossings of the Go/C border.

The Hidden Cost of Cgo Transitions

The overhead is not theoretical. Sean Allen's Gophercon 2018 talk advises batching Cgo calls: cross once and do as much as possible on the other side. Dave Cheney's "cgo is not go" is equally blunt — jumping from Go to C forces stack and calling-convention switches that never come for free. Benchmarks from another v8go contributor showed roughly 54 ns/op for Go-to-C calls and 149 ns/op for C-to-Go calls.

Under a lazy-loading model with per-property accessors, a profile with N nodes generates at least kN Cgo calls, where k is the number of properties queried. That overhead scales linearly with tree size and grows as more properties get exposed.

Iteration Two: Eager Loading

The proposed alternative moved the heavy lifting into C++. When the profiler stops, C++ traverses the whole call graph immediately — extracting all nodes and their properties into C data structures — then hands Go a single pointer. Go rebuilds its own graph and returns that to the user. The getters are still there but now return private struct fields instead of hitting Cgo every time.

The rewrite cut the number of Cgo calls to just a handful: start profiling, one call to stop and retrieve the fully-built graph, and build-time traversals on the Go side. Everything else — reading node names, sample counts, walking children — is pure Go.

Benchmarks: Theory vs. Reality

To test the two designs, I ran a JavaScript program with a small-ish call tree repeatedly. The results:

  1. Lazy: ~20 microseconds average to build the profile tree
  2. Eager: ~25 microseconds average — slower, not faster

That contradicted the original theory. On a tiny profile, the overhead of traversing the tree three times (build, then read in Go, then print) costs more than making Cgo calls during a single print traversal.

But a small input isn't representative. Switching to a much larger profile — built from Shopify's Hydrogen starter template — told a different story:

  1. Lazy: ~90 microseconds average
  2. Eager: ~60 microseconds average — now clearly ahead

The crossover happens as the tree grows. With enough nodes, the eager approach's up-front traversal cost pays off versus the repeated overhead of many Cgo crossings. The graph of expected growth curves illustrates the tradeoff cleanly:

Simple graph with time to build profile on the y axis and size of javascript on x axis. 2 lines indicating eager and lazy are plotted on the graph with lazy being higher

Had I only benchmarked the small case, the conclusion would have been wrong. The right lesson isn't "eager loading wins," it's that benchmark inputs need to reflect realistic workload sizes before you flip an implementation.

A Hybrid Path Ahead

Because the final API keeps getters instead of exposing raw public fields, there's room to offer both loading modes later. Users with small profiles or unusual access patterns might prefer lazily pulling data only when needed. Users walking a very large profile would pick eager loading for speed. The getter-based design keeps that choice open without breaking callers.

Engineering Takeaways from a Profiler Integration

Integrating the V8 CPU profiler into the v8go library was a deep dive into the performance characteristics of Cgo, C++, and Go's runtime. Working at this boundary forces a level of understanding about memory management that typical Go development rarely requires. The process itself surfaced two valuable lessons about performance engineering.

Benchmarks matter more than intuition

When performance is critical, it's tempting to rely on gut feelings or conventional wisdom about where bottlenecks live. In cross-language boundaries like this one, those instincts are often wrong. Building an actual benchmark provides hard data that can overturn assumptions about where time is spent. The effort required to create a complete alternative code path for fair comparison is substantial, but the discoveries along the way usually justify the investment.

Even when a benchmark confirms expectations, the exercise of writing one leads to a better mental model of the system. The process of isolating variables and testing hypotheses about performance is inherently educational.

Benchmark design shapes outcomes

A benchmark is only as good as its realism. If the test variables don't reflect how the code is typically used in production, the results can mislead rather than inform. Putting careful thought into the design parameters—what workloads to simulate, how to structure the test loops, and what metrics to collect—determines whether you walk away with useful insight or just numbers that look precise but aren't meaningful.

The difficulty of designing a benchmark is not arithmetic; it's judgment. There is no guidebook that can tell you all of the edge cases that exist in performance profiling, only iteration and a healthy skepticism of every result.