How Pass by Value vs Pass by Pointer Works Internally: An Assembly Deep Dive

Arpit Bhayani

Arpit Bhayani

Jan 04, 2024 • 7 min read

Play

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)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,5362^{16} = 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 SizePass by Pointer (f_pbp)Pass by Value (f_pbv)Relative Difference
64 KB (2162^{16} B)~0.74 ns/op~1157.0 ns/op~1560× slower
32 KB (2152^{15} B)~0.70 ns/op~314.0 ns/op~450× slower
16 KB (2142^{14} B)~0.70 ns/op~134.0 ns/op~190× slower

Key Takeaways from Benchmarking

  1. Pass by Pointer is O(1)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.
  2. 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]
  1. 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.
  2. MOVQ AX, (SP): Copies the 8-byte address from register AX to the top of the stack frame.
  3. 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:

  1. 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.
  2. LEAQ main.obj(SB), SI:
    • Loads the address of main.obj into the Source Index register (SI).
  3. MOVL $8192, CX:
    • Loads the literal decimal value 8192 into the Counter register (CX).
  4. 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.
  5. 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)\text{Total Bytes} = \text{Iterations (CX)} \times \text{Transfer Size (Quadword)} Total Bytes=8192×8 bytes=65,536 bytes=64 KB\text{Total Bytes} = 8192 \times 8 \text{ bytes} = 65,536 \text{ bytes} = 64 \text{ 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

AttributePass by PointerPass by Value
Time ComplexityO(1)O(1) constant timeO(N)O(N) linear with payload size
x86 InstructionsSimple register load (LEAQ, MOVQ)Loop setup + REP MOVSQ
Stack Allocation8 bytes (pointer address)Exact byte size of the underlying struct
Cache ImpactMinimal stack writes, but potential cache miss on dereferenceHeavy cache line eviction and high L1/L2 write traffic during copy
Mutability RiskCallee can mutate caller state (shared reference)Callee gets an isolated copy (caller state protected)
GC & Escape AnalysisMay force payload to escape to the heap if pointer outlives stack framePayload 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:

  1. Small Structs (64\le 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.
  2. 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.
  3. 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 (>128256> 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.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses