Skip to content

feat: add hidden closure environment ABI - #2255

Open
cpunion wants to merge 17 commits into
xgo-dev:mainfrom
cpunion:codex/closure-env-2170-v2
Open

feat: add hidden closure environment ABI#2255
cpunion wants to merge 17 commits into
xgo-dev:mainfrom
cpunion:codex/closure-env-2170-v2

Conversation

@cpunion

@cpunion cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • keep Go/go-types signatures free of closure env and store physical entry metadata on llssa.Function
  • use nest or swiftself on validated native targets; Wasm and portable fallbacks use exact typed env/no-env edges
  • keep native dynamic funcval calls branch-free with an instruction-free identity barrier
  • point plain Go and C funcvals directly at their real entries and remove the __llgo_stub call layer
  • preserve env attributes through aggregate, sret, callback-wrapper, and C ABI lowering
  • call closures through stock libffi with a final-hop CallWithEnv bridge; no libffi rebuild is required
  • preserve {fn, env} with nil meaning no physical env, while eliding provably zero-sized lexical environments
  • restore the Go-required anonymous-to-named closure conversion for synthetic rangefunc yield arguments

Benefits

  • removes one generated call layer, its symbols, metadata, and runtime lookup support
  • keeps ordinary compiler code and Go signatures independent of closure transport
  • keeps native dynamic calls on one hot edge without an env-nil branch
  • preserves exact C ABI for C functions, variadics, callbacks, and exported functions
  • gives closure, method-value, reflection, and FFI paths one backend env model
  • reduces unnecessary FFI trampoline/assembly work on compatible targets

Platform selection

OS Arch Entry env ABI libffi final hop
non-Windows x86 / x86_64 nest in ECX / R10 use ffi_call_go when FFI_GO_CLOSURES is available; otherwise use public ffi_call + TLS trampoline
any supported OS RISC-V nest in t2/x7 direct ffi_call_go; libffi Go closures are required
Linux and other X18-available targets AArch64 nest in X18 direct ffi_call_go; libffi Go closures are required
Apple / Android AArch64 swiftself in X20 public ffi_call + TLS trampoline because libffi's Go ABI uses X18
non-Windows ARM32 swiftself in R10 ffi_call_go + register bridge from libffi R12 to R10
Windows x86 / x86_64 nest direct ffi_call_go when available; LLGo does not yet support Windows
Windows ARM32 / AArch64 swiftself entry ABI is fixed, but the FFI final hop remains TODO
Wasm wasm32 explicit env parameter exact typed env/no-env call edges; no hidden-register bridge
other unvalidated targets other explicit env fallback exact typed env/no-env call edges

Selection is based on the physical LLVM target and libffi's FFI_GO_CLOSURES
capability, rather than a hard-coded package GOOS/GOARCH pair. The build uses
pkg-config libffi plus -lffi; no Homebrew-specific libffi path or libffi
rebuild is required.

Review follow-up

  • parse //llgo:env and // llgo:env through the shared directive parser
  • use intrinsic opcode 0x48 as the next available opcode; no reserved gap is retained
  • document the RISC-V LLVM nest t2/x7 transport and its match with the libffi bridge; ESP32-C3 exercises the riscv32 path
  • retain the whole libffi closure rather than an interior field pointer
  • document the balanced normal-return requirement around the TLS/register bridge

Validation

  • [Based on #2255] runtime/ffi: use direct ffi_call_go where compatible cpunion/llgo#136 passed Linux/macOS benchmark jobs and its full CI at 3d73ab8dad1794f9b352a6a3c9758b74199129ca
  • ci: publish benchmarks in repository Pages #2268 passed all 42 checks and was merged into main
  • after rebasing onto current xgo-dev/main (4e1aa4fb):
    • go test ./ssa
    • go test ./internal/cabi
    • go test ./internal/build
    • git diff --check xgo-dev/main...HEAD
  • compile and run the Apple SDK x86_64 ffi_call + TLS fallback under Rosetta, verifying that the target receives env in R10
  • coverage includes native nest/swiftself, Wasm explicit transport, plain Go/C funcvals, captured and zero-sized closures, nil/typed-nil method values, direct interface calls, reflection/libffi, variadics, aggregates, and sret

Supersedes #2248, whose closed head cannot be reopened after the fork branch was force-pushed.

Closes #2170

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

443369e2baf6 | workflow run | long-term charts

Program measurements

Platform Workload File size vs main Build vs main Run vs main
Linux cprintf 18344 B -1.1% (better) 348.581 ms +12.0% (worse) 1.335 ms +24.9% (worse)
Linux fmtprintf 1830744 B -17.4% (better) 3.323 s +23.4% (worse) 3.104 ms +47.2% (worse)
Linux println 67720 B -5.3% (better) 350.949 ms +23.0% (worse) 1.642 ms +23.7% (worse)
macOS cprintf 84672 B +0.0% 526.000 ms -9.7% (better) 3.388 ms +14.8% (worse)
macOS fmtprintf 1869328 B -20.8% (better) 3.076 s +2.0% (worse) 12.582 ms -35.7% (better)
macOS println 121200 B -3.6% (better) 467.295 ms -7.4% (better) 4.080 ms -23.9% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs main
Linux BenchmarkLookupPCRandom 13.230 ns/op +39.3% (worse)
Linux BenchmarkMergeCompilerFlags 152 ns/op +36.0% (worse)
Linux BenchmarkMergeLinkerFlags 96.300 ns/op +32.2% (worse)
Linux BenchmarkChannelBuffered 39.560 ns/op +40.7% (worse)
Linux BenchmarkChannelHandoff 26253 ns/op +37.0% (worse)
Linux BenchmarkDefer 43.670 ns/op +29.9% (worse)
Linux BenchmarkDirectCall 1.561 ns/op +14.3% (worse)
Linux BenchmarkGlobalRead 1.556 ns/op +14.1% (worse)
Linux BenchmarkGlobalWrite 2.489 ns/op +14.3% (worse)
Linux BenchmarkGoroutine 42560 ns/op +42.0% (worse)
Linux BenchmarkInterfaceCall 7.793 ns/op +14.3% (worse)
Linux BenchmarkRuntimeGetG 1.867 ns/op +36.9% (worse)
macOS BenchmarkLookupPCRandom 14.410 ns/op +23.6% (worse)
macOS BenchmarkMergeCompilerFlags 108.100 ns/op -1.5% (better)
macOS BenchmarkMergeLinkerFlags 68.780 ns/op -1.1% (better)
macOS BenchmarkChannelBuffered 32.660 ns/op +35.7% (worse)
macOS BenchmarkChannelHandoff 11211 ns/op +28.9% (worse)
macOS BenchmarkDefer 39.550 ns/op +18.2% (worse)
macOS BenchmarkDirectCall 1.128 ns/op +3.4% (worse)
macOS BenchmarkGlobalRead 1.156 ns/op +6.7% (worse)
macOS BenchmarkGlobalWrite 1.506 ns/op +38.0% (worse)
macOS BenchmarkGoroutine 23367 ns/op -33.1% (better)
macOS BenchmarkInterfaceCall 5.539 ns/op +17.5% (worse)
macOS BenchmarkRuntimeGetG 2.631 ns/op +23.3% (worse)

Compared only with the latest matching platform in the main series.

@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cpunion
cpunion marked this pull request as ready for review August 1, 2026 16:01

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Reviewed the hidden closure-environment ABI change across the compiler (ssa/, cl/), the C libffi bridge, and the reflect runtime. The design is careful and well-commented; the env-vs-no-env distinction, the zero-sized sentinel, and the reentrancy handling in llgo_ffi_call_with_env (save/restore of previous) all check out for the normal synchronous paths. The docs (doc/closure.md) match the implementation.

A few concrete findings are left as inline comments. Cross-cutting notes that don't map to a single line:

Performance (worth a benchmark vs. the base commit):

  • runtime/internal/clite/ffi/_wrap/libffi.c — the ffi_call_go fast path is only weakly linked and is disabled on Apple targets (LLGO_FFI_GO_ABI_MATCHES excludes __APPLE__). Per the code's own comment, common system libffi builds omit that symbol, so on typical Linux and all macOS the slow trampoline is the default path for every env-bearing FFI call. That path spills/reloads the whole argument register file, marshals arguments twice, and adds an extra out-of-line call. Since reflect.Value.call now routes through ffi.CallWithEnv, this is on the reflect hot path — worth measuring the regression.
  • runtime/internal/lib/reflect/makefunc.go makeMethodValue — each method value now allocates a libffi closure (ffi_closure_alloc, mmap-backed executable memory) plus a heap funcval and an extra indirection per call, versus the previous direct funcval. If method values are created in loops this is a real allocation/perf cost; consider caching per (type, method).

Memory-safety (low severity, documentation-only asks):

  • runtime/internal/clite/ffi/_wrap/libffi.c — the aarch64 Apple/Android and arm32 trampolines stash callee-saved registers/return address in the single _Thread_local context and restore them only after the real target returns normally (matching the previous restore in llgo_ffi_call_with_env). A non-local exit that unwinds across the ffi_call frame (longjmp-style) would skip both restores, leaving callee-saved registers clobbered and the TLS record stale. Synchronous nesting and balanced signal-handler nesting are safe. Recommend a comment asserting the "target must return normally / every entry goes through the balanced save/restore" invariant next to the struct definition.
  • runtime/internal/lib/reflect/makefunc.go:74keepAlive stores the interior pointer &closure.Fn to retain the whole *ffi.Closure. This relies on interior-pointer retention in the current GC; storing closure directly would be more robust. No live bug.

View job run

Comment thread cl/compile.go Outdated
Comment thread cl/import.go Outdated
Comment thread ssa/closure_abi.go Outdated
@cpunion
cpunion force-pushed the codex/closure-env-2170-v2 branch from a009e26 to 5b379e0 Compare August 1, 2026 16:41
@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up is pushed on top of current main (5b379e062):

  • accepted both closure-env directive spellings through the shared parser
  • documented the reserved intrinsic slot and RISC-V transport basis
  • retained the complete libffi closure rather than an interior pointer
  • documented the normal-return invariant for balanced register/TLS restoration

I also folded the independently reproduced range4.go failure into this closure-ABI PR instead of opening a conflicting PR. The new counterexample verifies direct and rangefunc-synthetic anonymous-to-named closure arguments; the official case passes on Go 1.24.11, 1.25.7, and 1.26.5.

The review performance concern already has same-machine A/B data in the PR body. Caching reflect method-value closures is a separate allocation/lifetime design change and is intentionally not included here.

}

func makeFunc(typ Type, method bool, fn func(args []Value) (results []Value)) Value {
func makeFunc(typ Type, fn func(args []Value) (results []Value)) Value {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里不再需要 makeFunc 函数,可以合并到 MakeFunc 函数。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在后续提交中将 makeFunc 内联到 MakeFunc;rebase 后该修复仍保留在 fbce776

@cpunion
cpunion force-pushed the codex/closure-env-2170-v2 branch from b407f0e to 572a29f Compare August 2, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: C-compatible closure-context ABI for two-word LLGo funcvals

2 participants