Skip to content

feat: add hidden closure environment ABI - #2248

Closed
cpunion wants to merge 1 commit into
xgo-dev:mainfrom
cpunion:codex/closure-env-2170-v2
Closed

feat: add hidden closure environment ABI#2248
cpunion wants to merge 1 commit into
xgo-dev:mainfrom
cpunion:codex/closure-env-2170-v2

Conversation

@cpunion

@cpunion cpunion commented Jul 31, 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

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

Validation

Fork validation is green in cpunion/llgo#96: 39 successful checks on final commit eca0053a2997.

Coverage includes Linux amd64/arm64, macOS arm64/Intel, Go 1.24/1.26, Wasm explicit transport, nest, swiftself, O0/O2/LTO, plain Go and C funcvals, captured and zero-sized closures, nil and typed-nil method values, direct interface calls, reflection/libffi, variadics, aggregates, and sret. The existing ESP32/ESP32-C3 suites pass; the new full-reflect runtime test is precisely skipped there because the same test does not compile on the baseline embedded runtime.

Same-machine A/B on Apple M4 Max measured plain dynamic funcval calls improving from about 1.027 ns/op to 0.770 ns/op, captured closures remaining flat, and representative executable sizes decreasing.

Closes #2170

@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: hidden closure environment ABI

Reviewed the core closure-env feature (SSA lowering, cl compile path, the libffi trampoline, and the reflect/FFI bridges). The design is sound and well-tested: the nil-env / typed-nil transport is consistent across the three layers, zero-sized captured envs stay distinguishable from no-env entries, and hideClosureCodeIdentity cleanly prevents devirtualization across the env/no-env prototypes.

No runtime performance regressions or memory-safety/ABI defects were found on the native (nest/swiftself) paths. The per-arch naked-asm trampolines in runtime/internal/clite/ffi/_wrap/libffi.c were checked for register save/restore completeness and stack alignment (x86_64/i386/aarch64/arm/riscv32/riscv64) and are correct; env is routed to the arch's nest/swiftself register consistently with the Go build tags. Removing the old __llgo_stub wrapper from the closure path is a net win over the prior extra call edge.

Inline comments cover the concrete items. A few cross-cutting notes below.

Notes (no reliable inline anchor)

  • Cross-package //llgo:env visibilityhasClosureEnvDirective (cl/compile.go:690) only sees the directive when f.Syntax() is an *ast.FuncDecl. A function imported from an already-compiled package has no syntax, so funcTo/funcOf (cl/instr.go:669) would build a no-env prototype (NewFuncEx) for it. Today's only users (typehash et al. in runtime/internal/runtime/alg.go) are linkonce and re-emitted per package from source, so the directive is always observed and this is not triggered in practice. Worth documenting that //llgo:env is intra-package-only (and ideally rejecting a cross-package reference), since a mismatch here would miscompile silently rather than hit the per-package NeedsEnv() guard in ssa/decl.go:291.

  • Two sources of truth for ABI selectionclosureEnvABIForTarget (ssa/closure_abi.go) classifies by LLVM triple (recognizing thumb*, aarch64_be, arm64_32, i486/i586/i686), while the runtime/internal/ffi/call_llgo_*.go build tags classify by canonical GOARCH. They agree today, but a future edit to one side can silently skew the compiler ABI from the runtime ABI (a link/run mismatch, not a build error). Consider a shared table or a cross-referencing comment.

  • Compile-time recomputation (minor)Program.closureEnvABI() / closureEnvAttribute() (ssa/closure_abi.go:68-96) redo strings.ToLower/Cut/Contains (and sometimes target.Spec() triple construction) on every env-bearing function and call site — 2+ times per dynamic call in callClosure. The target is fixed per compilation, so this could be memoized on aProgram. Compiler-throughput only, no effect on generated code.

  • Signal-safety of the FFI env context (minor)llgo_ffi_call_with_env writes the per-thread llgo_ffi_call_current then calls ffi_call; the trampoline reads it back on the same thread. This is thread-safe and reentrancy-safe for normal use (no user code runs between the write and the read), but not async-signal-safe: an env-bearing FFI call issued from a signal handler on the same thread would clobber the outer call's target/env. Worth a comment noting env-bearing FFI calls must not be issued from signal handlers.

Comment thread doc/closure.md Outdated
- Direct interface invocation remains a transient `(method entry, receiver)`
operation. Its receiver is an ordinary ABI argument; first-class interface
method values are lowered through the normal bound-wrapper closure path.
- No `__llgo_stub`, `__llgo_func_adapter`, or `__llgo_imethod_adapter` is

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.

This overstates the removal. __llgo_stub is still an actively generated prefix outside the closure path: closureStubPrefix = "__llgo_stub." in internal/build/funcinfo_table.go:59 (consumed at :168/:956), runtimeClosureStubPrefix in runtime/internal/lib/runtime/symtab.go:292, stubPrefix in internal/pclnpost/binary.go, and it still wraps type-descriptor equality funcs (memequal32/strequal) — see the generated out.ll fixtures. Only __llgo_func_adapter/__llgo_imethod_adapter are gone entirely. The removed doc text even acknowledged this ("the only remaining use of the __llgo_stub. prefix"). Suggest scoping this to the closure path, e.g. "the closure path no longer generates a __llgo_stub wrapper."

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.

Fixed in 2b7837e: the document now scopes this statement to the closure funcval path.

Comment thread ssa/closure_abi.go
strings.Contains(triple, "windows") || strings.Contains(triple, "win32") {
return closureEnvExplicit
}
switch {

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.

Fragile control flow: the arm64/aarch64 case (lines 32-33) is intentionally empty so control falls through to the post-switch AArch64 policy at lines 50-53, while every other case returns. Adding a default: return closureEnvExplicit inside the switch later would silently break AArch64 selection. Consider handling AArch64 inside the switch (case ...arm64...: if aarch64PlatformReservesX18(triple, goos) { return closureEnvSwiftSelf }; return closureEnvNest) and dropping the trailing fall-through block.

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.

Fixed in 2b7837e: AArch64 now returns its policy directly inside the switch case.

@@ -0,0 +1,24 @@
//go:build llgo && !wasm && !windows && (arm || (arm64 && (darwin || ios || tvos || watchos || android)))

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.

tvos and watchos are not standard Go GOOS values (Apple TV/Watch build as GOOS=ios), so (arm64 && (darwin || ios || tvos || watchos || android)) can never match on tvos/watchos — dead constraints that read as if watchOS/tvOS were separately supported. Same in call_llgo_nest.go. Either drop them or add a comment that they are forward-looking placeholders. (Note: the SSA side in closure_abi.go:57-65 covers these correctly via the apple/darwin triple substring check, so only the build tags are misleading.)

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.

Fixed in 2b7837e: removed the non-Go tvos/watchos build constraints and added a synchronization note beside the runtime partition.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

2b7837e4e371 | workflow run | long-term charts

Program measurements

Platform Workload File size vs main Build vs main Run vs main
Linux cprintf 18344 B new 327.818 ms new 1.402 ms new
Linux fmtprintf 1832616 B new 3.225 s new 3.118 ms new
Linux println 67720 B new 331.273 ms new 1.564 ms new
macOS cprintf 84672 B new 373.572 ms new 2.349 ms new
macOS fmtprintf 1868000 B new 2.995 s new 13.333 ms new
macOS println 121200 B new 407.342 ms new 6.093 ms new
Core language and compiler benchmarks
Platform Benchmark ns/op vs main
Linux BenchmarkLookupPCRandom 13.230 ns/op new
Linux BenchmarkMergeCompilerFlags 150.400 ns/op new
Linux BenchmarkMergeLinkerFlags 94.310 ns/op new
Linux BenchmarkChannelBuffered 34.590 ns/op new
Linux BenchmarkChannelHandoff 30392 ns/op new
Linux BenchmarkDefer 45.700 ns/op new
Linux BenchmarkDirectCall 1.556 ns/op new
Linux BenchmarkGlobalRead 1.556 ns/op new
Linux BenchmarkGlobalWrite 2.486 ns/op new
Linux BenchmarkGoroutine 32981 ns/op new
Linux BenchmarkInterfaceCall 7.797 ns/op new
Linux BenchmarkRuntimeGetG 1.868 ns/op new
macOS BenchmarkLookupPCRandom 11.970 ns/op new
macOS BenchmarkMergeCompilerFlags 103.100 ns/op new
macOS BenchmarkMergeLinkerFlags 66.140 ns/op new
macOS BenchmarkChannelBuffered 20.790 ns/op new
macOS BenchmarkChannelHandoff 6507 ns/op new
macOS BenchmarkDefer 24.760 ns/op new
macOS BenchmarkDirectCall 0.949 ns/op new
macOS BenchmarkGlobalRead 0.950 ns/op new
macOS BenchmarkGlobalWrite 0.969 ns/op new
macOS BenchmarkGoroutine 19562 ns/op new
macOS BenchmarkInterfaceCall 4.077 ns/op new
macOS BenchmarkRuntimeGetG 1.900 ns/op new

No main baseline exists yet; all metrics are marked new.

Warning

  • The rendered benchmark data could not be pushed.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

@cpunion
cpunion force-pushed the codex/closure-env-2170-v2 branch 2 times, most recently from a008bd1 to 2b7837e Compare July 31, 2026 19:53
@cpunion
cpunion marked this pull request as draft July 31, 2026 21:16
@cpunion
cpunion force-pushed the codex/closure-env-2170-v2 branch from 2b7837e to 46155aa Compare August 1, 2026 08:36
@cpunion

cpunion commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Temporarily closing while the updated commit completes fork-side CI in cpunion#96. I will reopen this draft after that matrix is green.

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

1 participant