Skip to content

feat: add hidden closure environment ABI - #2255

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

feat: add hidden closure environment ABI#2255
cpunion wants to merge 15 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
  • use libffi’s Go ABI directly where compatible, retain TLS/trampoline fallbacks where required, and use explicit env for unsupported targets
  • 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

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

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

572a29fca97d | 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) 336.611 ms +8.1% (worse) 1.261 ms +18.0% (worse)
Linux fmtprintf 1830744 B -17.4% (better) 3.456 s +28.4% (worse) 3.295 ms +56.3% (worse)
Linux println 67720 B -5.3% (better) 343.369 ms +20.3% (worse) 1.591 ms +19.8% (worse)
macOS cprintf 84672 B +0.0% 518.945 ms -10.9% (better) 2.980 ms +0.9% (worse)
macOS fmtprintf 1869328 B -20.8% (better) 3.240 s +7.4% (worse) 15.466 ms -21.0% (better)
macOS println 121200 B -3.6% (better) 374.792 ms -25.7% (better) 5.011 ms -6.6% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs main
Linux BenchmarkLookupPCRandom 13.440 ns/op +41.5% (worse)
Linux BenchmarkMergeCompilerFlags 152.700 ns/op +36.6% (worse)
Linux BenchmarkMergeLinkerFlags 95.740 ns/op +31.4% (worse)
Linux BenchmarkChannelBuffered 39.570 ns/op +40.8% (worse)
Linux BenchmarkChannelHandoff 28369 ns/op +48.1% (worse)
Linux BenchmarkDefer 43.870 ns/op +30.4% (worse)
Linux BenchmarkDirectCall 1.568 ns/op +14.8% (worse)
Linux BenchmarkGlobalRead 1.559 ns/op +14.3% (worse)
Linux BenchmarkGlobalWrite 2.492 ns/op +14.5% (worse)
Linux BenchmarkGoroutine 32435 ns/op +8.2% (worse)
Linux BenchmarkInterfaceCall 7.797 ns/op +14.3% (worse)
Linux BenchmarkRuntimeGetG 1.868 ns/op +37.0% (worse)
macOS BenchmarkLookupPCRandom 14.220 ns/op +22.0% (worse)
macOS BenchmarkMergeCompilerFlags 111.500 ns/op +1.5% (worse)
macOS BenchmarkMergeLinkerFlags 96.490 ns/op +38.8% (worse)
macOS BenchmarkChannelBuffered 22.050 ns/op -8.4% (better)
macOS BenchmarkChannelHandoff 6957 ns/op -20.0% (better)
macOS BenchmarkDefer 25.870 ns/op -22.7% (better)
macOS BenchmarkDirectCall 1.045 ns/op -4.2% (better)
macOS BenchmarkGlobalRead 1.014 ns/op -6.4% (better)
macOS BenchmarkGlobalWrite 1.321 ns/op +21.1% (worse)
macOS BenchmarkGoroutine 24505 ns/op -29.9% (better)
macOS BenchmarkInterfaceCall 4.410 ns/op -6.5% (better)
macOS BenchmarkRuntimeGetG 2.029 ns/op -4.9% (better)

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