β‘ Exercise 4: Compiler Inlining Parameters - Tuning for Binary Size Control
π Want to learn more? Read The IR on Internals for Interns for a deep dive into Go’s intermediate representation, including how function inlining decisions are made.
In this exercise, you’ll explore and modify Go’s inlining parameters to see their dramatic effects on binary size! ποΈ This will teach you how Go’s compiler decides when to inline functions and how tweaking these parameters can significantly change your compiled programs.
π― Learning Objectives
By the end of this exercise, you will:
- β Understand Go’s inlining budget system and parameters
- β Know where inlining decisions are made in the compiler
- β Modify inlining thresholds to control optimization behavior
- β Measure the impact on binary size
π§ Background: Function Inlining in Go
Function inlining is a compiler optimization where function calls are replaced by the actual function body. This trades binary size for performance:
Benefits:
- β‘ Eliminates call overhead
- π― Enables further optimizations at call site
- π Better instruction pipeline utilization
Costs:
- π¦ Larger binary size
- πΎ Increased memory usage (for the program)
Go uses a sophisticated budget system to decide when inlining is profitable!
π Step 1: Understanding Go’s Inlining Budget
Let’s examine the current inlining parameters:
cd go/src/cmd/compile/internal/inline
Open inl.go and look at the key parameters around lines 49-85:
ποΈ Key Inlining Parameters
From go/src/cmd/compile/internal/inline/inl.go:49-85:
const (
inlineMaxBudget = 80
inlineExtraAppendCost = 0
inlineExtraCallCost = 57 // benchmarked to provide most benefit
inlineParamCallCost = 17 // calling a parameter costs less
inlineExtraPanicCost = 1 // do not penalize inlining panics
inlineExtraThrowCost = inlineMaxBudget // inlining runtime.throw does not help
inlineBigFunctionNodes = 5000 // Functions with this many nodes are "big"
inlineBigFunctionMaxCost = 20 // Max cost when inlining into a "big" function
inlineClosureCalledOnceCost = 10 * inlineMaxBudget // if a closure is called once, inline it
)
var (
// ...
// Budget increased due to hotness (PGO).
inlineHotMaxBudget int32 = 2000
)
π Note: inlineHotMaxBudget is a var, not a const, because it’s used with PGO (Profile Guided Optimization) and may be modified at runtime.
π¬ How the Budget System Works
Each Go statement/expression has a cost:
- Simple statements: 1 point
- Function calls: 57+ points
- Loops, conditions: 1 point each
- Complex expressions: Variable points
The compiler sums up costs and compares against the budget!
π Step 2: Use Go Compiler Binary for Size Comparison
Instead of creating toy programs, let’s use the Go compiler binary itself as our test subject! The Go compiler (bin/go) is perfect for demonstrating inlining effects because:
- ποΈ Large codebase - Shows meaningful size differences
- π§ Real-world code - Contains the actual patterns we’re optimizing
- π― Workshop relevance - We’re building it throughout the exercises
- π Dramatic results - Large enough to show significant inlining impact
π― Test Different Inlining Settings on Go Binary
Let’s rebuild the entire Go toolchain with different inlining settings and compare the bin/go binary sizes:
cd go/src
π Baseline Build - Default Settings
First, let’s build with default inlining settings and backup the binary:
# Build with default settings
./make.bash
# Copy the default Go binary for comparison
cp ../bin/go ../bin/go-default
# Check the size
ls -lh ../bin/go-default
wc -c ../bin/go-default
π¬ Check Current Inlining Impact on Go Compiler Build
We can examine how inlining affects the Go compiler itself during compilation:
# See inlining decisions when compiling the Go compiler
# This shows how inlining parameters affect the compiler's own build process
cd cmd/compile
../../bin/go build -gcflags="-m" . 2>&1 | grep "can inline" | wc -l
echo "Functions that can be inlined during Go compiler build"
βοΈ Step 3: Modify Inlining Parameters
Now let’s modify the inlining parameters to see their effects!
π§ Experiment 1: Aggressive Inlining
Edit go/src/cmd/compile/internal/inline/inl.go around line 50:
const (
inlineMaxBudget = 95 // Increased from 80
inlineExtraCallCost = 40 // Decreased from 57
inlineBigFunctionMaxCost = 30 // Increased from 20
)
β οΈ Note: Be careful not to increase these values too much! In Go 1.26.1, the runtime has strict write barrier constraints, and increasing the inlining budget beyond ~95 causes the compiler to inline functions into contexts where write barriers are prohibited, breaking the build. This is itself a great lesson about the delicate balance of compiler parameters.
Rebuild the compiler:
cd go/src
./make.bash
Test aggressive inlining on Go binary:
# Copy the aggressively-inlined Go binary
cp ../bin/go ../bin/go-aggressive
# Compare sizes
echo "Default size: $(wc -c < ../bin/go-default)"
echo "Aggressive size: $(wc -c < ../bin/go-aggressive)"
# Calculate size difference
default_size=$(wc -c < ../bin/go-default)
aggressive_size=$(wc -c < ../bin/go-aggressive)
echo "Size difference: $(($aggressive_size - $default_size)) bytes"
echo "Percentage increase: $(echo "scale=2; ($aggressive_size - $default_size) * 100 / $default_size" | bc)%"
π§ Experiment 2: Conservative Inlining
Now try conservative settings. Edit the parameters:
const (
inlineMaxBudget = 40 // Decreased from 80
inlineExtraCallCost = 100 // Increased from 57
inlineBigFunctionMaxCost = 5 // Decreased from 20
)
Rebuild and test:
cd go/src
./make.bash
# Copy the conservatively-inlined Go binary
cp ../bin/go ../bin/go-conservative
# Compare all three Go binaries
echo "Conservative size: $(wc -c < ../bin/go-conservative)"
echo "Default size: $(wc -c < ../bin/go-default)"
echo "Aggressive size: $(wc -c < ../bin/go-aggressive)"
π Step 4: Comprehensive Binary Size Analysis
Let’s test extreme inlining settings to see dramatic effects on the Go compiler binary:
π§ Experiment 3: No Inlining At All
For comparison, let’s disable inlining entirely:
const (
inlineMaxBudget = 0 // No inlining budget
inlineExtraCallCost = 1000 // Prohibitive call cost
inlineBigFunctionMaxCost = 0 // No big function inlining
)
cd go/src
./make.bash
# Copy the no-inlining Go binary
cp ../bin/go ../bin/go-no-inline
π§ Experiment 4: Extreme Inlining - Breaking Point Demonstration
Let’s try extremely aggressive settings to see what happens when we push inlining too far:
const (
inlineMaxBudget = 500 // Very high budget
inlineExtraCallCost = 5 // Very low call cost
inlineBigFunctionMaxCost = 200 // Very high big function budget
)
cd go/src
./make.bash
β οΈ Expected Result: This will fail to compile! You’ll see “write barrier prohibited by caller” errors. This happens because the compiler inlines runtime functions into contexts where write barriers are not allowed, creating illegal call chains.
If it fails (which is expected), you’ll learn that:
- Extreme inlining causes write barrier violations in the runtime
- The Go runtime has //go:nowritebarrierrec annotations that prohibit write barriers in certain call chains
- When inlining exposes these chains, the compiler correctly rejects the build
- The default parameters are carefully balanced for good reason
π Step 5: Analyze Results
Compare the Go compiler binary sizes:
cd go
echo "=== GO COMPILER BINARY SIZE COMPARISON ==="
echo "No Inlining: $(wc -c < bin/go-no-inline) bytes"
echo "Conservative: $(wc -c < bin/go-conservative) bytes"
echo "Default: $(wc -c < bin/go-default) bytes"
echo "Aggressive: $(wc -c < bin/go-aggressive) bytes"
echo ""
echo "=== SIZE DIFFERENCES ==="
no_inline_size=$(wc -c < bin/go-no-inline)
conservative_size=$(wc -c < bin/go-conservative)
default_size=$(wc -c < bin/go-default)
aggressive_size=$(wc -c < bin/go-aggressive)
echo "No-inline vs Default: $(($default_size - $no_inline_size)) bytes difference"
echo "Default vs Aggressive: $(($aggressive_size - $default_size)) bytes difference"
echo "Full Range (No-inline to Aggressive): $(($aggressive_size - $no_inline_size)) bytes difference"
# Calculate percentages
echo ""
echo "=== PERCENTAGE DIFFERENCES ==="
echo "Aggressive vs Default: $(echo "scale=2; ($aggressive_size - $default_size) * 100 / $default_size" | bc)%"
echo "Default vs No-inline: $(echo "scale=2; ($default_size - $no_inline_size) * 100 / $no_inline_size" | bc)%"
π§ Understanding What We Modified
ποΈ Key Parameter Functions
| Parameter | Purpose | Impact |
|---|---|---|
inlineMaxBudget |
Maximum cost for any inlined function | Higher = more inlining |
inlineExtraCallCost |
Penalty for function calls inside inlined functions | Lower = more aggressive |
inlineBigFunctionMaxCost |
Max cost when inlining into large functions | Higher = more inlining in big funcs |
inlineBigFunctionNodes |
Threshold for “big” function detection | Lower = more functions considered “big” |
π Typical Results You Should See
With the Go compiler binary, you should observe noticeable size differences:
- No Inlining: Smallest binary
- Conservative: Slightly smaller than default
- Default: Balanced size
- Aggressive: Larger binary than default
Key Insights:
- Even modest inlining parameter changes produce measurable binary size differences
- The range from no-inlining to aggressive shows the impact of this optimization
- More aggressive values are limited by runtime constraints (write barriers)
The exact sizes depend on your system, but you should see similar dramatic differences!
π What We Learned
- ποΈ Budget System: How Go uses cost-based analysis for inlining decisions
- π Parameter Impact: How different settings affect binary size and performance
- π¬ Measurement Techniques: Using debug flags to understand compiler decisions
- βοΈ Trade-offs: The fundamental tension between binary size and performance
- π οΈ Compiler Tuning: How to modify compiler behavior for specific needs
π‘ Extension Ideas
Try these additional experiments: π
- π Create a script to automate testing different parameter combinations
- π― Test with real-world Go programs (like building Go itself!)
- π Measure compilation time differences with various settings
- π‘οΈ Experiment with PGO (Profile-Guided Optimization) parameters
- π¬ Analyze assembly output differences between inlined and non-inlined calls
β‘οΈ Next Steps
Excellent work! π You’ve learned how to tune Go’s inlining behavior and seen its real-world impact on binary size and performance. In the next exercises, we’ll explore modifying the gofmt tool.
π§Ή Cleanup
To restore original inlining parameters and clean up test binaries:
cd go/src/cmd/compile/internal/inline
git checkout inl.go
cd ../../../../
# Rebuild with original parameters
cd src
./make.bash
# Clean up test binaries
rm -f ../bin/go-default ../bin/go-aggressive ../bin/go-conservative ../bin/go-no-inline
π Key Takeaways
- Inlining is a Trade-off: More inlining = larger binaries but potentially faster execution
- Budget System: Go uses sophisticated cost analysis to make inlining decisions
- Parameter Impact: Small parameter changes can have significant effects on output
- Debug Tools: Go provides excellent tools for understanding compiler decisions
- Real-World Relevance: These parameters affect every Go program you compile!
The Go compiler team has carefully tuned these defaults through extensive benchmarking - but now you understand how to adjust them for your specific needs! β‘π―
Continue to Exercise 5 or return to the main workshop