-
Notifications
You must be signed in to change notification settings - Fork 46
Description
Summary
We have two proposed approaches for passing closure context in llgo:
- PR ssa: align closure stubs with ctx ABI #1495:
__llgo_stubwrapper approach - ctx passed as explicit first parameter - PR Register-based closure context with fallbacks #1496: Register-based approach - ctx passed via dedicated callee-saved register
- 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_localfor 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, arg2. 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 = addStub 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 | ||
| Signature | ❌ Modified | ✅ Original | ✅ Original |
| C interop | ✅ Clean | ||
| Debug/trace | ✅ Visible ctx param | ||
| IR complexity | ✅ Simple | N/A (not LLVM) | |
| ABI alignment | ❌ Custom | ✅ Go-like | ✅ Go ABI |
Questions for Discussion
- Should llgo aim for full compatibility with Go's ABI (register approach) or prioritize simplicity (stub approach)?
- For arm/wasm: Is stub's uniform handling better than register's fallback mechanism?
- Is the stub wrapper overhead acceptable in hot paths?
- How important is C interop without signature modification?
Related PRs:
- ssa: align closure stubs with ctx ABI #1495 (Stub approach)
- Register-based closure context with fallbacks #1496 (Register approach)