feat: add hidden closure environment ABI - #2248
Conversation
There was a problem hiding this comment.
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:envvisibility —hasClosureEnvDirective(cl/compile.go:690) only sees the directive whenf.Syntax()is an*ast.FuncDecl. A function imported from an already-compiled package has no syntax, sofuncTo/funcOf(cl/instr.go:669) would build a no-env prototype (NewFuncEx) for it. Today's only users (typehashet al. inruntime/internal/runtime/alg.go) arelinkonceand re-emitted per package from source, so the directive is always observed and this is not triggered in practice. Worth documenting that//llgo:envis intra-package-only (and ideally rejecting a cross-package reference), since a mismatch here would miscompile silently rather than hit the per-packageNeedsEnv()guard inssa/decl.go:291. -
Two sources of truth for ABI selection —
closureEnvABIForTarget(ssa/closure_abi.go) classifies by LLVM triple (recognizingthumb*,aarch64_be,arm64_32,i486/i586/i686), while theruntime/internal/ffi/call_llgo_*.gobuild 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) redostrings.ToLower/Cut/Contains(and sometimestarget.Spec()triple construction) on every env-bearing function and call site — 2+ times per dynamic call incallClosure. The target is fixed per compilation, so this could be memoized onaProgram. Compiler-throughput only, no effect on generated code. -
Signal-safety of the FFI env context (minor) —
llgo_ffi_call_with_envwrites the per-threadllgo_ffi_call_currentthen callsffi_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.
| - 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 |
There was a problem hiding this comment.
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."
There was a problem hiding this comment.
Fixed in 2b7837e: the document now scopes this statement to the closure funcval path.
| strings.Contains(triple, "windows") || strings.Contains(triple, "win32") { | ||
| return closureEnvExplicit | ||
| } | ||
| switch { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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))) | |||
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
Fixed in 2b7837e: removed the non-Go tvos/watchos build constraints and added a synchronization note beside the runtime partition.
LLGo baseline benchmarks
Program measurements
Core language and compiler benchmarks
No main baseline exists yet; all metrics are marked Warning
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
a008bd1 to
2b7837e
Compare
2b7837e to
46155aa
Compare
|
Temporarily closing while the updated commit completes fork-side CI in cpunion#96. I will reopen this draft after that matrix is green. |
Summary
llssa.Functionnestorswiftselfon validated native targets; Wasm and portable fallbacks use exact typed env/no-env edges__llgo_stubcall layersret, callback-wrapper, and C ABI loweringCallWithEnvbridge; no libffi rebuild is required{fn, env}with nil meaning no physical env, while eliding provably zero-sized lexical environmentsBenefits
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, andsret. 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