
## Introduction

Profile-Guided Optimization (PGO) became officially GA from Go 1.21, and you can apply PGO either by placing `default.pgo` in the main package or by specifying a PGO profile with the `-pgo` flag. However, in environments where Bazel is used for building, these mechanisms do not work, so a different setup using `rules_go` is required.

This article explains how to perform PGO builds for Go using Bazel (rules_go).

## Prerequisites

This article assumes the following versions.

- Go: 1.25
- Bazel: 8.2.1
- Bazelisk: 1.28.1
- rules_go: 0.60.0

The sample code used in this article is published in the following repository.

[workspace2026/bazel_go_pgo_build](https://github.com/ucpr/workspace2026/tree/main/bazel_go_pgo_build)

## What is PGO (Profile-Guided Optimization)?

PGO is a technique that feeds runtime profile information of a program back to the compiler and performs compile-time optimizations based on that information. Typical compiler optimizations are performed based on static analysis of source code, but PGO takes the actual execution patterns into account, enabling more effective optimization.

[Profile-guided optimization in Go 1.21 - The Go Blog](https://go.dev/blog/pgo)

In Go, PGO was introduced as a preview in 1.20 and became officially GA in 1.21. According to the official Go documentation, [PGO has been reported to bring performance improvements of about 2–7%](https://go.dev/blog/pgo#:~:text=The%20new%20version%20is%20around%203.8%25%20faster!%20In%20Go%201.21%2C%20workloads%20typically%20get%20between%202%25%20and%207%25%20CPU%20usage%20improvements%20from%20enabling%20PGO). Since the rate of improvement depends on the characteristics of the application, the actual effect varies by workload.

[Profile-guided optimization (PGO) considered for production use (Japanese article)](https://zenn.dev/knowledgework/articles/0574f01f54306e)

### Optimizations performed by PGO

In Go's PGO, the following optimizations are mainly performed.

**Inlining**

Functions determined to be hot from the profile are inlined more aggressively than usual. Not only is the overhead of function calls reduced, but inlining also enables further optimizations at the call site (e.g., constant propagation and improved escape analysis).

**Devirtualization**

For indirect calls on interface values, the dominant concrete type is identified from the profile information and converted into a direct call.

Specifically, the following kind of transformation takes place.

```go
// Before PGO (indirect call)
var r io.Reader = f
r.Read(b)

// After PGO (direct call with runtime guard)
if f, ok := r.(*os.File); ok {
    f.Read(b)  // direct call → further inlining is possible
} else {
    r.Read(b)  // fallback
}
```

When the profile indicates that the concrete type of `r` is `*os.File` with high probability, it is converted into a guarded direct call using a type assertion. The direct call then becomes a candidate for further inlining, which can lead to chained optimizations.

## Collecting profiles

To perform a PGO build, you first need to collect a CPU profile of the target application. In Go, the `net/http/pprof` package allows you to obtain profiles via an HTTP endpoint.

The following is an example of a server with the pprof endpoint enabled.

```go:cmd/server/main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	_ "net/http/pprof" // registers the /debug/pprof/ endpoints
)

func main() {
	http.HandleFunc("/health", handleHealth)
	// ... omitted ...

	log.Println("server listening on :8080")
	log.Println("pprof available at /debug/pprof/")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

By importing `_ "net/http/pprof"`, various profiling endpoints are automatically registered under `/debug/pprof/`. The CPU profile can be obtained from the following endpoint.

```bash
$ curl -o profile/default.pgo "http://localhost:8080/debug/pprof/profile?seconds=30"
```

> [!NOTE]
> When collecting profiles in production, it is important to collect them while a representative workload is running. With a biased profile, you may not get the full benefit of optimization.

If you are already collecting profiles with Grafana Pyroscope, Datadog, or similar tools, you can also export PGO profiles using the CLIs provided by those products. When considering production use, we recommend checking whether the observability tool you use supports it.

- Pyroscope: [grafana/pyroscope/cmd/profilecli](https://github.com/grafana/pyroscope/tree/main/cmd/profilecli)
- Datadog: [DataDog/datadog-pgo](https://github.com/DataDog/datadog-pgo)

## Performing PGO builds with Bazel

### Defining the profile as a Bazel target

To use the PGO profile as a Bazel build target, define `exports_files` in `profile/BUILD.bazel`.

```python:profile/BUILD.bazel
exports_files(
    ["default.pgo"],
    visibility = ["//visibility:public"],
)
```

### How to specify PGO

In rules_go, there are two ways to specify a PGO profile.

#### 1. The `pgoprofile` attribute of `go_binary`

You specify the label of the profile file in the `pgoprofile` attribute of the `go_binary` rule.

```python:cmd/server/BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary", "go_library")

go_library(
    name = "server_lib",
    srcs = ["main.go"],
    importpath = "github.com/ucpr/workspace2026/bazel_go_pgo_build/cmd/server",
    visibility = ["//visibility:private"],
)

go_binary(
    name = "server",
    embed = [":server_lib"],
    pgoprofile = "//profile:default.pgo",
    visibility = ["//visibility:public"],
)
```

With this method, the profile specified in the `pgoprofile` attribute is also applied to the compilation of all `go_library` targets the binary depends on. Internally, this is implemented using Bazel's configuration transition, so the profile specification is propagated through the entire dependency graph.

#### 2. Command-line flag

You can also specify the PGO profile via a command-line flag at build time.

```bash
$ bazel build //cmd/server \
    --@rules_go//go/config:pgoprofile=//profile:default.pgo
```

This method can also be used as a permanent setting by writing the configuration in `.bazelrc`.

```bash:.bazelrc
build --@rules_go//go/config:pgoprofile=//profile:default.pgo
```

### Behavior when the profile does not exist

If the PGO profile specified by the `pgoprofile` attribute or the command-line flag does not exist, the build fails with a `missing input file` error.

```bash
$ bazel build //cmd/server
ERROR: missing input file '//profile:default.pgo'
ERROR: Build did NOT complete successfully
```

If you want to temporarily disable PGO, you need to either comment out the `pgoprofile` attribute or remove the command-line flag specification.

### How to verify a PGO build

When applying PGO with a regular `go build`, you can check for the presence of the `-pgo` flag using `debug.ReadBuildInfo()` or `go version -m`.

```bash
$ go version -m /path/to/binary
# ...
# build	-pgo=/path/to/default.pgo
```

To check from within the program, look up the `-pgo` key in `debug.ReadBuildInfo()`'s Settings as follows.

```go
func isPGOEnabled() bool {
	info, ok := debug.ReadBuildInfo()
	if ok {
		for _, bs := range info.Settings {
			if bs.Key == "-pgo" {
				return bs.Value != ""
			}
		}
	}
	return false
}
```

However, when built with Bazel, `debug.ReadBuildInfo()` does not return build information (it returns `false`), so neither method above can be used for verification.

To check whether PGO has been applied to a binary built with Bazel, use the `--subcommands` flag and inspect the build log.

```bash
$ bazel build //cmd/server --subcommands 2>&1 | grep pgoprofile
```

> [!NOTE]
> `--subcommands` does not output build commands when there is a cache hit. When verifying, run `bazel clean` first and then build.

If the build log contains `-pgoprofile profile/default.pgo`, you can confirm that PGO has been applied.

```bash
# PGO is applied to the stdlib build
bazel-out/.../builder stdlib ... -pgoprofile profile/default.pgo)
# PGO is applied to the compilation of application code
bazel-out/.../builder compilepkg ... -pgoprofile profile/default.pgo)
```

## Conclusion

This article explained an overview of Go's PGO and how to perform PGO builds using Bazel (rules_go).

In rules_go, you can use PGO via two methods: the `pgoprofile` attribute of `go_binary` and a command-line flag. As Go versions advance, the scope of PGO optimizations is also expanding, so it is worth considering adopting PGO in performance-critical applications.

If you find any errors or inaccurate expressions in this article, I would appreciate it if you could let me know.

## References

- [Profile-guided optimization - The Go Programming Language](https://go.dev/doc/pgo)
- [Profile-guided optimization in Go 1.21 - The Go Blog](https://go.dev/blog/pgo)
- [rules_go - go_binary rule](https://github.com/bazel-contrib/rules_go/blob/master/docs/go/core/rules.md)
- [rules_go - PGO support PR #3641](https://github.com/bazel-contrib/rules_go/pull/3641)
- [How to use PGO and Grafana Pyroscope to optimize Go applications](https://grafana.com/docs/pyroscope/latest/view-and-analyze-profile-data/profile-cli/)
