When developers want to improve the performance of a Go application, they often start by changing loops, replacing functions, or rewriting code they believe is slow. Sometimes these changes help. In other cases, they make the code harder to maintain without producing a measurable improvement.
A better approach is to measure the application first and use real performance data to guide optimization.
Go supports Profile-Guided Optimization (PGO), which allows the compiler to use information collected from a running application when building a new binary. This information helps the compiler make better decisions about which functions to inline and how to optimize frequently executed code.
Instead of relying only on general optimization rules, the compiler can use a profile that represents how the application behaves under a particular workload.
In this article, you will learn how PGO works in Go, how to collect a CPU profile, how to use it during compilation, and how to measure whether the optimized binary actually performs better.
What Is Profile-Guided Optimization in Go?
Profile-Guided Optimization is a compiler technique that uses runtime profiling information to guide optimization decisions.
A normal Go build uses the compiler's existing optimization rules. The compiler examines the code and applies optimizations based on its analysis.
With PGO, the compiler also receives a profile collected from a representative execution of the application. This profile provides information about which functions consume CPU time and how frequently different parts of the program execute.
For example, consider an API service with the following functions:
HandleRequest() processes incoming requests.ValidateRequest() checks request data.CalculatePrice() calculates prices.WriteAuditLog() records activity.
If profiling shows that ValidateRequest() and CalculatePrice() account for much of the application's CPU work, the compiler can use that information when making optimization decisions.
This does not mean that the developer must manually optimize those functions. PGO allows the compiler to use the collected profile to make more informed decisions.
How PGO Works
A typical PGO workflow contains four stages.
- Build and run the application under a representative workload.
- Collect a profile, commonly a CPU profile.
- Provide the profile to the Go compiler during the next build.
- Benchmark the new binary and compare its performance with the original build.
The profile is an input to compilation. It does not automatically change the source code or guarantee that the resulting binary will be faster.
The compiler uses the available information to decide whether particular optimizations are worthwhile.
Why Real Workloads Matter
A profile is only useful when it represents how the application behaves in practice.
Imagine an application that receives different types of requests:
| Request type | Percentage of requests | Typical CPU cost |
|---|
| Read product details | 60% | Low |
| Search products | 25% | Medium |
| Generate reports | 10% | High |
| Export data | 5% | High |
If you profile only report generation, the resulting profile may emphasize functions that are not frequently used during normal production traffic.
Using that profile for a general API build may not provide the best results for the application's most common requests.
A representative workload should reflect important production characteristics, such as request distribution, input sizes, and the functions used during normal processing.
The goal is not to collect the largest possible profile. The goal is to collect a profile that accurately represents the workload you want to optimize.
Step 1: Create a Go Application
Consider a small HTTP service that calculates a response for each request.
Create a project:
mkdir go-pgo-demo
cd go-pgo-demo
go mod init example.com/go-pgo-demo
Create a file named main.go.
package main
import (
"encoding/json"
"log"
"net/http"
"strconv"
)
type Response struct {
Value int `json:"value"`
}
func calculateValue(input int) int {
result := 0
for i := 1; i <= input; i++ {
result += i % 7
}
return result
}
func handleCalculate(w http.ResponseWriter, r *http.Request) {
inputValue := r.URL.Query().Get("n")
input, err := strconv.Atoi(inputValue)
if err != nil || input < 0 || input > 100000 {
http.Error(w, "Invalid input", http.StatusBadRequest)
return
}
result := calculateValue(input)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(Response{
Value: result,
}); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
func main() {
http.HandleFunc("/calculate", handleCalculate)
log.Println("Server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
This example provides a simple HTTP endpoint that performs CPU work and returns the result as JSON.
The calculation is intentionally straightforward so that the example remains easy to understand. It is not intended to represent a realistic business algorithm.
For an actual service, the profile should be collected from its normal application workload rather than from an artificial function alone.
Run the application:
go run .
Send a request to the endpoint:
curl "http://localhost:8080/calculate?n=50000"
The service returns a JSON response containing the calculated value.
Step 2: Collect a CPU Profile
Go includes the runtime/pprof package for collecting profiling information.
For an HTTP service, the net/http/pprof package provides profiling endpoints that can be enabled in a controlled environment.
Create a profiling-enabled version of the application by importing net/http/pprof in a separate administrative listener.
For example, add the following imports:
import (
"log"
"net/http"
_ "net/http/pprof"
)
Then start the profiling server on a separate, restricted port:
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
Add this code in main() before starting the main application server.
The profiling listener is bound to the loopback address so that it is accessible only from the local machine. Do not expose profiling endpoints publicly without appropriate access controls.
With the application running, generate a workload that resembles the traffic you want to optimize. Then collect a CPU profile:
go tool pprof -http=:8081 \
http://127.0.0.1:6060/debug/pprof/profile?seconds=30
This command collects a CPU profile for approximately 30 seconds and opens a local profiling interface.
The profile contains information about CPU activity observed during the collection period. It is not a complete record of every operation the application performed.
For a production service, collect profiles in a controlled environment or use an approved profiling process that accounts for the impact of profiling itself.
What Does a CPU Profile Show?
A CPU profile helps identify where the application spends its processing time.
For example, it may show that a particular function accounts for a large percentage of sampled CPU activity.
However, CPU profiles do not directly measure every kind of performance problem. An application waiting on a database or network connection may have high response times without spending much CPU time.
Use CPU profiling when CPU consumption is the problem you want to investigate. Use other measurements when the bottleneck involves memory allocation, garbage collection, network latency, database queries, or synchronization.
Step 3: Save a Profile for the Build
Go's PGO workflow commonly uses a file named default.pgo in the main package directory.
If you collect a profile in a file named cpu.pprof, you can use it as the PGO input by copying it to the expected location:
cp cpu.pprof default.pgo
The profile must be in a format supported by the Go compiler's PGO implementation. A compatible Go CPU profile produced through the supported profiling workflow can be used for this purpose.
For a profile collected through an HTTP endpoint, you can save the raw profile directly instead of opening it in the interactive viewer.
For example:
curl -o default.pgo \
"http://127.0.0.1:6060/debug/pprof/profile?seconds=30"
Run this command while the service is receiving representative traffic.
The profile should correspond to the program you intend to optimize. If the application has changed significantly since the profile was collected, review whether the profile still represents the current workload.
Step 4: Build the Application With PGO
Once default.pgo is present in the main package directory, build the application:
go build -o app-pgo .
In supported Go toolchains, the compiler automatically looks for default.pgo in the main package directory when PGO is enabled by default.
You can also specify a profile explicitly:
go build -pgo=default.pgo -o app-pgo .
To build without a profile for comparison, disable PGO explicitly:
go build -pgo=off -o app-baseline .
Keep both binaries so that you can compare their behavior under the same test conditions.
The compiler may use profile information for optimizations such as inlining decisions. Whether a specific function benefits depends on the profile, the source code, and the compiler's optimization decisions.
Do not assume that adding a profile will always produce a smaller executable or a faster application.
Step 5: Benchmark the Results
A PGO build should be evaluated with measurements rather than assumptions.
Go's testing package provides benchmarking support. You can add a benchmark for the calculation function in a file named main_test.go.
package main
import "testing"
func BenchmarkCalculateValue(b *testing.B) {
for i := 0; i < b.N; i++ {
calculateValue(50000)
}
}
Run the benchmark:
go test -run=^$ -bench=BenchmarkCalculateValue -count=5
This runs the benchmark multiple times to help reveal variation between measurements.
For a basic comparison, build the baseline and PGO versions separately and run the same benchmark procedure against each build. The benchmark must exercise the compiled implementation you are evaluating; simply building two binaries and running the same test command does not automatically benchmark both binaries.
For a real HTTP service, use a load-testing tool or a repeatable workload and compare measurements such as:
| Metric | What it tells you |
|---|
| Requests per second | How much traffic the service can process |
| Average response time | Typical request latency |
| p95 latency | Latency experienced by the slower 5% of requests |
| CPU usage | Processing resources required |
| Memory usage | Memory consumed under the workload |
| Error rate | Whether the service remains reliable under load |
Run both versions under the same conditions, including the same machine, input data, request distribution, and concurrency level.
A useful benchmark should also include enough repetitions to reduce the influence of temporary system activity.
Understanding PGO's Benefits and Limitations
PGO can improve performance when the profile reveals important runtime behavior that helps the compiler make better optimization decisions.
For example, a frequently executed function may become a better candidate for inlining. Inlining can remove some function-call overhead and give the compiler more opportunities to optimize the surrounding code.
However, the actual outcome depends on the workload and the compiler version.
| Area | Potential benefit | Limitation |
|---|
| CPU-intensive functions | Better optimization decisions based on frequently executed code | Improvements depend on the workload |
| Inlining | Frequently used functions may become better inlining candidates | Inlining can increase code size |
| Production services | Profiles can represent real request patterns | Production workloads may change |
| Build process | Profile collection can be automated | Profiles must be maintained and validated |
| Runtime performance | Some workloads may run faster | PGO does not fix every bottleneck |
PGO is most useful when the application has a repeatable workload and performance measurements show that compiler-level optimization may help.
Best Practices for Production Applications
1. Profile Representative Traffic
Collect profiles from workloads that reflect the application behavior you want to improve.
Include common request types and realistic input sizes. Avoid relying on a profile from a single unusual operation unless that operation is the specific target of optimization.
2. Measure Before and After
Record baseline performance before enabling PGO.
Compare the optimized build using the same workload and environment. If the difference is within normal measurement variation, repeat the benchmark before drawing a conclusion.
3. Keep Profiles Relevant
Profiles can become less useful when the application changes significantly.
Refresh them after major changes to request patterns, business logic, or dependencies. A stale profile may still be accepted by the compiler, but it may no longer represent the workload accurately.
4. Protect Profiling Endpoints
CPU profiling endpoints can expose operational information and consume resources.
Keep them restricted to trusted users and controlled environments. Do not leave an unauthenticated profiling server publicly accessible.
5. Automate the Build Process
A repeatable build process makes it easier to compare PGO and non-PGO binaries.
Store approved profile artifacts securely, record the profile's origin, and use consistent build settings during benchmarking.
6. Optimize the Actual Bottleneck
PGO is not a substitute for application-level performance work.
If the service spends most of its time waiting for a slow database query, changing compiler optimization decisions may have little effect on end-to-end response time.
Use profiling and tracing tools to identify the actual bottleneck before choosing an optimization strategy.
Common Mistakes and Troubleshooting
The PGO Build Does Not Improve Performance
The profile may not represent the real workload, or the compiler may have little opportunity to make additional useful optimizations.
Compare the profile with the application's actual behavior and check whether CPU execution is responsible for the performance problem.
The Profile Is Not Found
Confirm that the profile file is named default.pgo and is located in the main package directory, or specify its path using -pgo.
Also verify that the profile was collected in a supported format.
The Application Is Slow but CPU Usage Is Low
The bottleneck may be caused by database access, network latency, locks, or other waiting operations.
Collect additional measurements appropriate to the suspected bottleneck rather than assuming that PGO will solve it.
Benchmark Results Vary Significantly
Background processes, CPU frequency changes, thermal throttling, and inconsistent workloads can affect benchmark results.
Run multiple measurements under controlled conditions and compare distributions rather than relying on one result.
Advantages and Disadvantages
Advantages
- Uses runtime profiling information to guide compiler optimizations.
- Can improve performance without requiring source-code changes.
- Supports optimization based on realistic application behavior.
- Integrates with the Go build process.
- Provides a repeatable way to compare profile-guided and ordinary builds.
Disadvantages
- Requires representative profiles.
- Profile collection and maintenance add work to the development process.
- Performance improvements are workload-dependent.
- Stale or unrepresentative profiles may provide little benefit.
- Benchmarking requires care to distinguish real improvements from measurement noise.
Summary
Go Profile-Guided Optimization allows the compiler to use runtime profiling information when building an application. Instead of relying entirely on general optimization rules, the compiler can make better-informed decisions based on how the application executes.
The process involves collecting a representative CPU profile, providing it to the Go build, and measuring the resulting binary against a baseline.
PGO is not a replacement for profiling, good application design, or performance testing. It is another tool for improving software when measurements show that compiler-level optimizations may help.
For production applications, the most reliable approach is to collect representative profiles, keep them relevant, benchmark every important change, and deploy the optimized build only after confirming that it meets your performance and reliability requirements.
Join the conversation! Your thoughts help the community grow.