How Pass by Value vs Pass by Pointer Works Internally: An Assembly Deep Dive
It is common knowledge among systems engineers that passing large structs by pointer is significantly faster than passing them by value. But how fast is it really? Does passing a pointer execute in constant O(1) time regardless of size? What physically happens in hardware and memory when a struct is passed by value?
To answer these questions, we can design a Go benchmark, disable compiler optimizations to observe raw execution paths, and inspect the generated x86-64 assembly instructions.
1. Setting Up the Micro-Benchmark in Go
Consider an experiment comparing function calls with a large payload. We define a struct holding a 64 KB byte array (216=65,536 bytes) and instantiate a global variable obj:
package main
type BigStruct struct {
buff [1 << 16]byte // 65,536 bytes (64 KB)
}
var obj BigStruct
// Pass by Value: accepts BigStruct directly
func f_pbv(b BigStruct) {}
// Pass by Pointer: accepts a pointer to BigStruct
func f_pbp(b *BigStruct) {}
// Caller function to invoke both
func foo() {
f_pbp(&obj)
f_pbv(obj)
}
Using Go’s built-in testing framework, we write benchmarks for both functions:
func BenchmarkPBP(b *testing.B) {
for i := 0; i < b.N; i++ {
f_pbp(&obj)
}
}
func BenchmarkPBV(b *testing.B) {
for i := 0; i < b.N; i++ {
f_pbv(obj)
}
}
2. Benchmark Execution and Compiler Optimizations
When running naive benchmarks with go test -bench=. -count=1, unexpected results may emerge:
BenchmarkPBP-32 1000000000 0.170 ns/op
BenchmarkPBV-32 1000000000 0.200 ns/op
Both functions take nearly identical time (~0.17–0.20 ns/op) despite passing 64 KB of data.
Why Naive Benchmarks Lie: Compiler Optimizations
Because f_pbv and f_pbp have empty bodies and do not modify or escape the data, the Go compiler analyzes the data flow and elides the redundant copy operation or inlines the calls entirely.
To inspect true pass-by-value cost without compiler interference, compiler optimizations and inlining must be disabled using the -gcflags parameter:
go test -bench=. -gcflags="-N -l" -count=1
-N: Disables optimizations.
-l: Disables inlining.
Realistic Benchmark Results Across Buffer Sizes
Once optimizations are turned off, the real execution times emerge:
| Buffer Size | Pass by Pointer (f_pbp) | Pass by Value (f_pbv) | Relative Difference |
|---|
| 64 KB (216 B) | ~0.74 ns/op | ~1157.0 ns/op | ~1560× slower |
| 32 KB (215 B) | ~0.70 ns/op | ~314.0 ns/op | ~450× slower |
| 16 KB (214 B) | ~0.70 ns/op | ~134.0 ns/op | ~190× slower |
Key Takeaways from Benchmarking
- Pass by Pointer is O(1) constant time: Regardless of whether the payload is 16 KB, 32 KB, or 64 KB,
f_pbp takes ~0.7 ns per invocation.
- Pass by Value scales linearly with size: As data size doubles, the time taken by
f_pbv scales upward, demonstrating an underlying memory copy operation.
3. Dissecting the Assembly Instructions
To see what the CPU executes during these calls, compile the Go code directly to assembly without optimizations or inlining:
go tool compile -N -S ptr.go
Focusing on the generated instructions for the foo() caller function reveals the stark difference between pointer and value semantics.
A. Pass by Pointer Assembly (f_pbp(&obj))
LEAQ main.obj(SB), AX
MOVQ AX, (SP)
CALL main.f_pbp(SB)
flowchart LR
A[LEAQ main.obj -> AX] --> B[Push AX to SP]
B --> C[CALL main.f_pbp]
LEAQ main.obj(SB), AX: Load Effective Address Quadword. Computes the 64-bit memory address of the global variable obj and stores that 8-byte pointer directly into the AX register.
MOVQ AX, (SP): Copies the 8-byte address from register AX to the top of the stack frame.
CALL main.f_pbp(SB): Transfers execution to f_pbp.
Only 8 bytes (the size of a 64-bit memory address) are moved, regardless of whether main.obj is 8 bytes or 8 gigabytes. Hence, it executes in constant time.
B. Pass by Value Assembly (f_pbv(obj))
When compiling f_pbv(obj), the compiler must guarantee value semantics: the callee receives an independent snapshot of obj on its call stack.
MOVQ SP, DI
LEAQ main.obj(SB), SI
MOVL $8192, CX
REP MOVSQ
CALL main.f_pbv(SB)
flowchart TD
InitDI["MOVQ SP, DI (Destination = Top of Stack)"] --> InitSI["LEAQ main.obj, SI (Source = obj Address)"]
InitSI --> InitCX["MOVL $8192, CX (Loop Counter = 8192)"]
InitCX --> RepLoop{"REP MOVSQ (Repeat CX Times)"}
RepLoop -- "Copy 8 Bytes SI -> DI" --> DecrCX["CX = CX - 1"]
DecrCX -- "CX > 0" --> RepLoop
DecrCX -- "CX == 0" --> Call["CALL main.f_pbv"]
Breaking down each instruction:
MOVQ SP, DI:
- Loads the current Stack Pointer (
SP) into the Destination Index register (DI).
- Sets the destination address of the copy to the local function call stack.
LEAQ main.obj(SB), SI:
- Loads the address of
main.obj into the Source Index register (SI).
MOVL $8192, CX:
- Loads the literal decimal value
8192 into the Counter register (CX).
REP MOVSQ (The Bulk Memory Copy Loop):
MOVSQ (Move String Quadword) copies 8 bytes (a quadword) from the memory location pointed to by SI to the memory location pointed to by DI, then increments both pointers by 8.
REP (Repeat) is an x86 instruction prefix that repeats the following string operation CX times, decrementing CX after each transfer until CX == 0.
CALL main.f_pbv(SB):
- Invokes the function once the full data copy on the stack is complete.
The Math Behind 8192
Why did the compiler load 8192 into the counter register?
Total Bytes=Iterations (CX)×Transfer Size (Quadword)
Total Bytes=8192×8 bytes=65,536 bytes=64 KB
The compiler literally injected a hardware-level loop to copy all 65,536 bytes onto the stack before triggering the function call.
4. Architectural Comparison: Pointer vs. Value
| Attribute | Pass by Pointer | Pass by Value |
|---|
| Time Complexity | O(1) constant time | O(N) linear with payload size |
| x86 Instructions | Simple register load (LEAQ, MOVQ) | Loop setup + REP MOVSQ |
| Stack Allocation | 8 bytes (pointer address) | Exact byte size of the underlying struct |
| Cache Impact | Minimal stack writes, but potential cache miss on dereference | Heavy cache line eviction and high L1/L2 write traffic during copy |
| Mutability Risk | Callee can mutate caller state (shared reference) | Callee gets an isolated copy (caller state protected) |
| GC & Escape Analysis | May force payload to escape to the heap if pointer outlives stack frame | Payload remains stack-allocated if non-escaping |
5. Engineering Trade-offs & Practical Guidelines
While passing by pointer prevents bulk data copying, it is not always the best choice:
- Small Structs (≤64 bytes / 8 quadwords):
- Passing small structs (e.g., coordinates, UUIDs, small time values) by value is often faster than passing by pointer.
- Pointers introduce indirection. Accessing data through a pointer requires a memory dereference, which can lead to CPU cache misses.
- Escape Analysis and Heap Allocation:
- Passing pointers can cause Go’s escape analysis to move memory from the fast goroutine stack to the managed heap.
- Heap allocations increase Garbage Collector (GC) cycle overhead and latency jitter.
- Concurrency and Immutability:
- Pass by value guarantees that functions operate on independent data frames, eliminating race conditions without mutexes.
Summary Rule of Thumb
- Use Pass by Pointer for large structs (>128−256 bytes) or when the receiver function explicitly needs to modify the original instance.
- Use Pass by Value for small primitives, small structs, or when immutability and memory locality are paramount.