Skip to content

Discussion: Closure Context Passing - Comparing Stub, Register, and Official Go Approaches #1497

@cpunion

Description

@cpunion

Summary

We have two proposed approaches for passing closure context in llgo:

  1. PR ssa: align closure stubs with ctx ABI #1495: __llgo_stub wrapper approach - ctx passed as explicit first parameter
  2. PR Register-based closure context with fallbacks #1496: Register-based approach - ctx passed via dedicated callee-saved register
  3. Official Go: Reference implementation for comparison

Go Official ABI Specification

Reference: https://go.dev/src/cmd/compile/abi-internal

Closure Definition

A func value (e.g., var x func()) is a pointer to a closure object. A closure object begins with a pointer-sized program counter representing the entry point of the function, followed by zero or more bytes containing the closed-over environment.

Context Register Mechanism

Closure calls follow the same conventions as static function and method calls, with one addition. Each architecture specifies a closure context pointer register and calls to closures store the address of the closure object in the closure context pointer register prior to the call.

Official Register Assignments

Architecture Closure Context Register Notes
amd64 DX Closure pointer before call
arm64 R26 Reserved for closure context
riscv64 X27 (S11) Callee-saved register

Architecture Overview

Aspect Stub (PR #1495) Register (PR #1496) Official Go
Context passing Explicit __llgo_ctx parameter Dedicated CPU register Closure context register
Function signature fn(ctx, args...) fn(args...) fn(args...)
Closure representation {fn, ctx} {fn, ctx} funcval{fn, env...}

Platform Register Support

Requirements for Ctx Register

Requirement Description
1. Callee-saved Register must be preserved across function calls
2. Not C ABI Not used for C calling convention parameters/returns
3. LLVM constraint Must be usable in LLVM inline asm {regname} constraint

Platform Details

Platform llgo Register Go Official Status Reason
amd64 R12 DX Callee-saved, not in C ABI params
arm64 X26 R26 Same as Go, reservable via +reserve-x26
386 ESI - Callee-saved in cdecl
riscv64 X27 X27 Same as Go official
arm32 - - ❌ Fallback LLVM {rN} only supports r0-r7
wasm - N/A ❌ Fallback Stack machine, no registers

Fallback Mechanism

@__llgo_closure_ctx = internal thread_local global ptr null
  • TLS-enabled platforms (linux, darwin, windows, etc.): Uses thread_local for thread safety
  • Bare-metal/wasm: Uses plain global (typically single-threaded)

Detailed Scenario Comparison

1. Anonymous Closure (with free variables)

Go Code:

func outer(x int) func(int) int {
    return func(y int) int {
        return x + y
    }
}

Stub Approach (PR #1495):

; Closure struct: {fn, ctx}
%closure = { ptr @__llgo_stub.outer$1, ptr %ctx }

; Wrapper function with explicit ctx param
define i64 @__llgo_stub.outer$1(ptr %ctx, i64 %y) {
  %x = load i64, ptr %ctx
  %result = add i64 %x, %y
  ret i64 %result
}

; Caller
%fn = extractvalue %closure, 0
%ctx = extractvalue %closure, 1
%result = call i64 %fn(ptr %ctx, i64 %arg)

Register Approach (PR #1496):

; Closure struct: {fn, ctx}
%closure = { ptr @outer$1, ptr %ctx }

; Direct function (no ctx param)
define i64 @outer$1(i64 %y) {
  %ctx = call ptr asm "", "={r12}"()
  %x = load i64, ptr %ctx
  %result = add i64 %x, %y
  ret i64 %result
}

; Caller
%fn = extractvalue %closure, 0
%ctx = extractvalue %closure, 1
call void asm "", "{r12}"(ptr %ctx)  ; write ctx to register
%result = call i64 %fn(i64 %arg)

Official Go (amd64 asm):

; funcval layout: [fn_ptr | x]
; Caller sets DX = funcval address, loads fn, calls
MOVQ funcval_addr, DX
MOVQ (DX), AX       ; load fn ptr
CALL AX

; Callee reads DX to access captured x
MOVQ 8(DX), BX      ; x = funcval[1]
ADDQ BX, arg

2. Method Value (Pointer Receiver)

Go Code:

type S struct { v int }
func (s *S) Add(x int) int { return s.v + x }

func use() {
    s := &S{v: 10}
    f := s.Add  // method value
    f(5)
}

Stub Approach:

; Bound wrapper with ctx param
define i64 @"(*S).Add$bound"(ptr %ctx, i64 %x) {
  %receiver = load ptr, ptr %ctx
  %result = call i64 @"(*S).Add"(ptr %receiver, i64 %x)
  ret i64 %result
}

; Call: fn(ctx, arg)
%result = call i64 @"(*S).Add$bound"(ptr %s, i64 5)

Register Approach:

; Bound wrapper reads ctx from register
define i64 @"(*S).Add$bound"(i64 %x) {
  %ctx = call ptr asm "", "={r12}"()
  %receiver = load ptr, ptr %ctx
  %result = call i64 @"(*S).Add"(ptr %receiver, i64 %x)
  ret i64 %result
}

; Call: set register, call fn(arg)
call void asm "", "{r12}"(ptr %s)
%result = call i64 @"(*S).Add$bound"(i64 5)

Official Go:

; funcval: [fn_ptr | receiver_ptr]
MOVQ funcval_addr, DX
MOVQ (DX), AX
CALL AX
; Inside bound: receiver = *(DX+8)

3. Interface Method Value

Go Code:

type Adder interface { Add(int) int }

func use(a Adder) {
    f := a.Add
    f(5)
}

Stub Approach:

define i64 @"interface{Add(int)int}.Add$bound"(ptr %ctx, i64 %x) {
  %iface = load {ptr, ptr}, ptr %ctx
  %itab = extractvalue %iface, 0
  %data = extractvalue %iface, 1
  %fn_ptr = getelementptr ptr, ptr %itab, i64 3
  %fn = load ptr, ptr %fn_ptr
  %result = call i64 %fn(ptr %data, i64 %x)
  ret i64 %result
}

Register Approach:

define i64 @"interface{Add(int)int}.Add$bound"(i64 %x) {
  %ctx = call ptr asm "", "={r12}"()
  %iface = load {ptr, ptr}, ptr %ctx
  %itab = extractvalue %iface, 0
  %data = extractvalue %iface, 1
  %fn_ptr = getelementptr ptr, ptr %itab, i64 3
  %fn = load ptr, ptr %fn_ptr
  %result = call i64 %fn(ptr %data, i64 %x)
  ret i64 %result
}

Official Go:
Same pattern - ctx contains interface value, vtable lookup at call time.


4. Defer Closure

Go Code:

func foo() {
    x := 10
    defer func() { println(x) }()
}

Stub Approach:

; Store closure in defer frame
%defer = call ptr @runtime.DeferAlloc(...)
store ptr @__llgo_stub.foo$1, %defer.fn
store ptr %ctx, %defer.ctx

; At defer execution
%fn = load ptr, %defer.fn
%ctx = load ptr, %defer.ctx
call void %fn(ptr %ctx)

Register Approach:

; Store closure in defer frame (fn without ctx param)
%defer = call ptr @runtime.DeferAlloc(...)
store ptr @foo$1, %defer.fn
store ptr %ctx, %defer.ctx

; At defer execution
%fn = load ptr, %defer.fn
%ctx = load ptr, %defer.ctx
call void asm "", "{r12}"(ptr %ctx)
call void %fn()

Official Go:
ctx stored in _defer struct, restored to DX at execution.


5. Go Statement with Closure

Go Code:

func foo() {
    x := 10
    go func() { println(x) }()
}

Stub Approach:

; Wrapper for goroutine entry
define void @_llgo_routine$1(ptr %ctx) {
  call void @foo$1(ptr %ctx)
  ret void
}

call void @runtime.CreateThread(ptr @_llgo_routine$1, ptr %ctx)

Register Approach:

; Wrapper sets ctx register before calling closure
define void @_llgo_routine$1(ptr %ctx) {
  call void asm "", "{r12}"(ptr %ctx)
  call void @foo$1()
  ret void
}

call void @runtime.CreateThread(ptr @_llgo_routine$1, ptr %ctx)

Official Go:
runtime.newproc creates new goroutine, ctx passed via dedicated mechanism.


6. Nested Closure

Go Code:

func outer(x int) func(int) func(int) int {
    return func(y int) func(int) int {
        return func(z int) int {
            return x + y + z
        }
    }
}

Stub Approach:

; Each level has explicit ctx param
define ptr @outer$1(ptr %ctx1, i64 %y) {
  %ctx2 = alloc { ptr, i64 }  ; {ctx1, y}
  ; ...
  ret { ptr @outer$2, ptr %ctx2 }
}

define i64 @outer$2(ptr %ctx2, i64 %z) {
  %inner = load { ptr, i64 }, ptr %ctx2
  ; access x via inner.ctx1, y via inner.y
}

Register Approach:

; Each level reads from register
define ptr @outer$1(i64 %y) {
  %ctx1 = call ptr asm "", "={r12}"()
  %ctx2 = alloc { ptr, i64 }
  ; ...
  ret { ptr @outer$2, ptr %ctx2 }
}

define i64 @outer$2(i64 %z) {
  %ctx2 = call ptr asm "", "={r12}"()
  ; access x and y from ctx2
}

Official Go:
Nested funcvals, each level points to outer closure object.


7. Global Function as Closure

Go Code:

func add(x, y int) int { return x + y }
var f = add

Stub Approach:

; Wrapper ignores ctx
define i64 @__llgo_stub.add(ptr %ctx, i64 %x, i64 %y) {
  %result = call i64 @add(i64 %x, i64 %y)
  ret i64 %result
}

Register Approach:

; Direct call, nil ctx in register
call void asm "", "{r12}"(ptr null)
%result = call i64 @add(i64 %x, i64 %y)

Official Go:
No wrapper needed for regular functions.


8. Closure as Parameter

Go Code:

func apply(f func(int) int, x int) int {
    return f(x)
}

Stub Approach:

define i64 @apply({ptr, ptr} %closure, i64 %x) {
  %fn = extractvalue %closure, 0
  %ctx = extractvalue %closure, 1
  %result = call i64 %fn(ptr %ctx, i64 %x)
  ret i64 %result
}

Register Approach:

define i64 @apply({ptr, ptr} %closure, i64 %x) {
  %fn = extractvalue %closure, 0
  %ctx = extractvalue %closure, 1
  call void asm "", "{r12}"(ptr %ctx)
  %result = call i64 %fn(i64 %x)
  ret i64 %result
}

Official Go:
Caller sets DX = funcval, calls fn.


Closure Representation Comparison

Approach Representation Notes
Stub {fn_ptr, ctx_ptr} fn points to stub wrapper
Register {fn_ptr, ctx_ptr} fn points to actual function
Go gc funcval{fn_ptr, captured...} fn at offset 0, ctx=funcval address

Pros & Cons Summary

Aspect Stub Register Go gc
Overhead ❌ Wrapper call ✅ None (inline asm) ✅ None
Cross-platform ✅ Uniform ⚠️ Fallback for arm/wasm ⚠️ Per-arch
Signature ❌ Modified ✅ Original ✅ Original
C interop ⚠️ Signature mismatch ✅ Clean ⚠️ Needs cgo
Debug/trace ✅ Visible ctx param ⚠️ Hidden in register ⚠️ Hidden in register
IR complexity ✅ Simple ⚠️ Inline asm N/A (not LLVM)
ABI alignment ❌ Custom ✅ Go-like ✅ Go ABI

Questions for Discussion

  1. Should llgo aim for full compatibility with Go's ABI (register approach) or prioritize simplicity (stub approach)?
  2. For arm/wasm: Is stub's uniform handling better than register's fallback mechanism?
  3. Is the stub wrapper overhead acceptable in hot paths?
  4. How important is C interop without signature modification?

Related PRs:

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationproposalProposal

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions