Repository navigation
proposal: runtime/jit: user frame support for JIT compilers and VMs #78189
Description
Last updated: 2026-10-09
Abstract
We propose an experimental runtime/jit package that lets cooperative JIT
compilers and virtual machines register dynamically generated executable code
with the Go runtime. Registered frames can participate in stack unwinding,
panic/recover, precise garbage collection, stack growth, tracebacks, and
cooperative preemption without routing every transition through cgo.
The public package is gated by GOEXPERIMENT=jit. In the current patch, the
internal runtime hooks are compiled even when the experiment is disabled.
Without registered regions, they do not traverse generated frames. Complete
compile-time gating of those hooks and their cost for ordinary Go programs
remain matters for runtime review.
Motivation
The Go runtime normally assumes that every program counter on a goroutine
stack belongs to Go text described by moduledata. A Go function called from
dynamically generated machine code therefore leaves an unknown frame in the
stack. A traceback, panic, garbage collection, or stack growth can then fail
with an unknown return PC. The garbage collector also has no precise map of Go
pointers held by the generated frame.
This affects pure-Go database JITs, WebAssembly engines, language VMs, and
other code generators. Treating cooperative generated code as an opaque cgo
call avoids some runtime assumptions, but adds a transition and toolchain
model intended for foreign code that the runtime cannot inspect. A cooperative
JIT can instead publish exact safepoints and follow the Go stack convention.
Related reports include #20123 and #29857.
Goals
- Safely unwind through one or more consecutive generated frames.
- Precisely mark and relocate Go pointers held in generated frames.
- Preserve panic/recover and useful tracebacks across registered frames.
- Allow generated code to use Go-compatible split-stack checks.
- Allow long-running generated loops to observe cooperative preemption.
- Keep the default Go runtime behavior unchanged when the experiment is off.
- Avoid executing user callbacks from GC stack scanning or stack relocation.
Proposed API
The current implementation exposes these concepts from runtime/jit.
Regions and unwind modes
Register(Region) Handle registers an executable address range and rejects
overlap with previously registered regions.
Handle.Unregister removes it after the caller has ensured that no goroutine
can still execute or retain a frame from the region.
A region selects one of three behaviors:
UnwindStopends a traceback at the generated frame boundary.UnwindSkipunwinds through generated frames using declarative recipes.UnwindDeclarealso exposes logical generated frames through
runtime.Stackandruntime.CallersFramesusing an optionalDescribe
callback.
One region can cover a code arena containing many generated functions. The
runtime performs a lock-free lookup in an immutable region snapshot.
UnwindStop is a traceback barrier, not a substitute for complete unwind
metadata when live Go callers must be scanned or relocated. Traversable regions
must supply an unwind recipe at every published safepoint so the runtime can
reach all enclosing Go frames, including across consecutive generated frames.
Declarative stack maps
Each StackMap identifies a safepoint by its exact return-PC offset from
Region.Start and describes:
- the frame range covered by a precise pointer bitmap;
- where the caller PC, SP, and BP can be recovered;
- an optional dynamic unwind-base adjustment for variable-sized frames.
The runtime copies all maps and masks before publishing them. Unwind-recipe
interpretation, GC marking, and stack relocation use this metadata directly
without calling JIT-owned Go code. Symbolization remains separate:
UnwindDeclare may call Describe when formatting a traceback or expanding
runtime.CallersFrames. That callback must not allocate Go memory.
The pointer bitmap has one bit per pointer-sized frame word, in
least-significant-bit-first order. Every live Go pointer held in a generated
frame at a safepoint must be described accurately; missing or incorrect
metadata can compromise GC or stack relocation.
Maps may be supplied with Region.StackMaps or appended through
Handle.AddStackMaps. PC offsets must be strictly increasing, including across
append operations. UnwindSkip and UnwindDeclare require HasUnwind at every
published safepoint. A JIT must publish the metadata after writing its machine
code and before making that code reachable by another goroutine.
The same precise map is used both to mark Go pointers and to relocate pointers
when a goroutine stack grows. Declaratively stored caller frame pointers are
relocated as well.
Stack checks and preemption
StackCheckConfig returns the process-wide values needed to emit the same
split-stack check as a Go function. Generated code follows the platform Go ABI,
calls the supplied morestack entry point when necessary, and retries its
prologue after growth.
Preempt reports whether the current goroutine should yield. Generated loops
that do not regularly call Go code check it at safepoints and return to Go when
requested. Generated code that frequently enters ordinary Go functions already
reaches their stack/preemption checks.
Generated code remains on its goroutine and must reach these safepoints
promptly. Preempt reports a request; it does not itself yield. This contract
requires cooperation from the generated code and does not provide arbitrary
asynchronous preemption inside JIT code. Opaque or blocking foreign calls still
need appropriate scheduler coordination, such as the cgo call infrastructure.
Precisely typed variable-tail allocations
Some JITs need heap objects containing a fixed Go struct prefix followed by a
compile-time-selected number of same-typed capture slots. Allocating such an
object as raw bytes would hide pointers from the garbage collector.
TailTypeFor[T](count) prepares a GC type for a struct T whose final field is
[0]E; TailType.Alloc then allocates one zeroed object with count elements
of E. Preparation is cached and intended to happen while compiling generated
code, avoiding reconstruction of the allocation layout for each object.
Allocation uses the Go allocator and includes the normal costs of allocating
and zeroing an object of the requested size.
This facility is used by the production consumer for variable-sized JIT
closure environments. It is included in the current implementation because it
solves the corresponding precise-GC allocation requirement. Reviewer feedback
is requested on whether it should remain in this proposal or move to a
separate follow-up proposal and change.
Runtime implementation
The implementation adds:
- a copy-on-write registry whose immutable snapshots support lock-free runtime
lookups; - unwinder integration for initial and nested registered PCs;
- precise GC scanning and stack-growth relocation from declarative maps;
- frame-pointer relocation for Go epilogues that restore SP through BP;
- validation and copying of registered metadata before publication;
- architecture-specific test trampolines for supported Go architectures.
The exported package retains the optional Describe closure in a Go-visible
registry so its captured values remain live. The runtime copies stack maps and
bitmaps into runtime-owned storage; GC scanning and stack relocation do not
depend on JIT-owned callbacks or the lifetime of the input slices.
Alternatives
cgo transitions
runtime.cgocall is appropriate for opaque foreign code that may block and
cannot cooperate with the scheduler or garbage collector. Dynamically
generated code covered by this proposal is produced by a Go component, follows
known frame conventions, publishes precise pointer metadata, and exposes
safepoints. Requiring cgo would add a C toolchain and a foreign-call transition
to otherwise pure-Go programs.
Synthesizing moduledata
moduledata, pclntab, funcdata, and compiler stack maps are internal contracts
that change with the compiler and runtime. Requiring independent JITs to
synthesize those structures would expose substantially more unstable runtime
implementation detail than this explicit experimental API.
Self-describing frames
A self-describing frame convention can reduce registration metadata, but still
requires runtime agreement on how an unknown PC is recognized, how pointers
are scanned and relocated, and how generated code reaches stack growth and
preemption. The declarative maps in this proposal make those contracts explicit
and validate them before code becomes reachable.
Faults at generated PCs
The current change fixes unwinding of registered return PCs and tests panic,
recover, nested frames, GC, and forced stack growth. It does not yet specify a
recoverable synchronous hardware fault whose interrupted PC itself lies in a
registered region. That use case was raised in this discussion for guard-page
WebAssembly bounds checks. It appears compatible with the design, but should
not become an accidental compatibility promise. We propose either adding a
separate specification and debug.SetPanicOnFault test after runtime review,
or explicitly leaving it to a follow-up proposal.
Compatibility
Programs must be compiled with the JIT experiment enabled to import
runtime/jit. Official Go toolchains do not yet contain this experiment; the
maintained fork is required today. A fork built with GOEXPERIMENT=jit uses
that setting by default, so GOEXPERIMENT=nojit is used to explicitly test the
disabled configuration.
The current patch still compiles the internal registry, unwinder, and GC hooks
when JIT is disabled. Ordinary programs do not register regions through the
public API in that configuration, but this is not a claim that the hooks add
zero code size or execution cost. While experimental, the API may change based
on production experience and runtime review.
Implementation and validation
- Upstream merge candidate: https://go.dev/cl/826384
- Maintained fork: https://github.com/launix-de/go
- Reproducible Go 1.27 consumer toolchain:
https://github.com/launix-de/go/tree/jit-foreign-frames-go1.27.0 - Production consumer: https://github.com/launix-de/memcp
On 2026-10-09, the merge candidate was rebased onto official Go master
a3b72a4a0d; the published patch-branch head is 9f48359e5a:
https://github.com/launix-de/go/tree/jit-foreign-frames-master
The toolchain builds using the Go 1.27 JIT fork as bootstrap. The following
checks pass on linux/amd64:
GOEXPERIMENT=jit go test runtime/jit runtime cmd/apiGOEXPERIMENT=jit go test -race runtime/jitGOEXPERIMENT=nojit go test runtime
The JIT tests cover concurrent registration, precise pointer retention, nested
generated frames, panic/recover, tracebacks, stack-map publication concurrent
with GC, repeated stack growth, and caller frame-pointer relocation. The
runtime/jit test package also cross-compiles for Linux arm64, 386, and riscv64,
and Windows amd64, arm64, and 386. These cross-compilation checks do not imply
that the tests were executed on those targets.
MemCP has used this integration in production for several months, where it has
proven stable in its native Scheme and query-plan workloads. This provides
operating experience alongside the targeted runtime tests.
When the integration was introduced, a self-recursion microbenchmark improved
by roughly 30 to 33 times. The complete Scheme and SQL regression workload
improved from approximately 11 to 13 minutes to 8 minutes 31 seconds. These are
consumer measurements rather than Go project benchmarks; the runtime tests are
the correctness evidence for this proposal.
Updates #78189.
Activity
runtime/jit: add user frame support for JIT compilers and VMs
runtime/jit: add user frame support for JIT compilers and VMs
gabyhelp commentedon Mar 17, 2026
Related Issues
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
prattmic commentedon Mar 19, 2026
The most critical aspect of traceback is that it precisely traces all Go frames and jumps past foreign frames in the case of calls from Go to foreign code and back then back to Go. Without this, the GC cannot scan all Go stack frames.
It's not clear to me how your proposal achieves this (looking at your implementation is it that Region.Next is required to correctly report the Go caller into the region?).
The second important aspect of calling foreign synchronization is coordination with the Go scheduler to ensure we can promptly preempt goroutines running foreign code. Your proposal doesn't cover this at all. It is a bit of a separate concern, but it is a prerequisite for safely running foreign code.
The cgo call infrastructure in the Go runtime (
runtime.cgocall,runtime.cgocallback) already handles both of these cases. Despitecin the name, the runtime support is not specific to C, it is really about calling any foreign.From that perspective, I don't see C code and JIT code as fundamentally different. Both are foreign to the runtime, and in my opinion should go through the same interface.
For symbolization, once you are using cgo we already have an interface for symbolization via
runtime.SetCgoCallback, though the API is admittedly awkward.The final hurdle I see is that today actually calling something via
runtime.cgocallwithout reaching into the runtime does require usingimport "C"and thus thecgoportion of the toolchain, which is a bit annoying if you don't actually otherwise need C. We have recently been thinking more about an ability to call foreign functions without the cgo toolchain, along the lines of #38917. That is quite hard for dynamically linked C code, but completely standalone JIT'd code would presumably be more straightforward.carli2 commentedon Mar 19, 2026
Thanks for the detailed feedback, @prattmic!
On traceback precision and
Region.Next:Yes,
Region.Nextis exactly the mechanism that reports the Go caller. When the unwinder encounters a PC inside a registered region, it callsNext(pc, sp)which returns the caller's PC/SP — typically read from a shadow stack or a known location where the JIT trampoline saved the return address. The unwinder then continues from that Go frame as usual. This means every Go frame is precisely traced and every foreign frame is skipped, same guarantee as cgocall.On scheduler preemption:
You're right that the current proposal doesn't cover this, and I agree it's a prerequisite. I've added preemption support — a lightweight cooperative
Preempt() boolfunction that JIT code calls at safepoints (loop back-edges, function entries). It readsgp.preemptdirectly from the current goroutine with no synchronization overhead. When it returns true, JIT returns to Go, and the function prologue handles the actual yield.The goroutine stays in
_Grunningwhile executing JIT code. The GC/scheduler setgp.preempt = truebefore sending async preemption signals, so the flag is always visible toPreempt(). JIT code that frequently calls Go functions (like MemCP's per-row callbacks) doesn't need explicit checks — the Go function prologues already handle preemption.On using cgocall instead:
I considered this path but don't think it's the right fit for JIT. The key differences:
JIT code is not a black box. C code is opaque to the runtime — cgocall must assume the worst (save signal masks, release P, switch stacks). JIT code is generated by Go code and can cooperate with the runtime: it can embed safepoints, follow known calling conventions, and report pointer maps via callbacks. Treating it as opaque foreign code leaves performance on the table.
cgocall can't scale to thousands of functions. A JIT compiler generates thousands of specialized functions at runtime (e.g., one per query plan, one per table schema). With cgocall, each of these would need to be wrapped as a cgo-callable function, and every Go callback from JIT code would need a corresponding cgocallback stub. The
runtime/jitapproach registers one region for the entire code arena and lets the JIT handle per-function dispatch internally.The overhead is prohibitive for our use case. MemCP JIT-compiles SQL query scans that call back into Go per row for column access. At millions of rows per second, the ~40ns per cgocall/cgocallback transition adds up fast.
runtime/jitadds zero overhead when no regions are registered (single atomic load returning nil) and a single atomic load + binary search when regions exist — no syscalls, no P release, no stack switch.The cgo toolchain is unnecessary baggage. The JIT code is generated entirely in Go via mmap + direct machine code emission. Requiring
import "C"and a C compiler just to satisfy the runtime's foreign-call interface adds build complexity for no benefit. You mentioned proposal: cmd/cgo: add support for pre-compiled cgo packages #38917 as a possible path, but that's been open since 2020 without a concrete design, and solving it for dynamically linked C is much harder than the JIT case.I see
runtime/jitand cgocall as complementary, not competing: cgocall is the right interface for calling opaque pre-compiled C/foreign libraries.runtime/jitis the right interface for cooperative dynamically generated code where the generator and the runtime can work together.Updated implementation: The proposal and implementation now include
Preempt()with tests. See the updated proposal and implementation.glycerine commentedon Apr 15, 2026
I'm working on a JIT similar to https://github.com/libriscv/libriscv (C++) but in Go; so I am interested in support for JIT interposed code; and in avoiding CGO overhead.
This is the hardest part of the proposal for me to grasp. First because it is almost impossible to avoid allocating in Go code while doing anything interesting. Second because the restriction to the system thread seems both hard to do and rather harshly restrictive. I'm not sure what this condition has to do with helping stack scanning runtime code; nor how to do anything interesting from JIT code calling back to Go code under this restrictive covenant.
glycerine commentedon Apr 16, 2026
@carli2 I went looking for your Prempt() proposal, but it 404s (https://github.com/carli2/go/blob/jit-foreign-frames/proposal.md) can you update? thanks!
carli2 commentedon Apr 16, 2026
Proposal: runtime/jit — User Frame Support for JIT Compilers and VMs
Author(s): Carl-Philip Hänsch
Last updated: 2026-03-19
Discussion at https://go.dev/issue/78189.
Abstract
We propose a new standard library package
runtime/jitthat allowsprograms to register regions of dynamically generated executable code
(JIT-compiled code, WASM engines, embedded VMs) with the Go runtime.
This enables Go's stack unwinder, panic/recover mechanism, and garbage
collector to handle frames from such code gracefully instead of crashing
with "unknown pc" or "unexpected return pc".
Background
Go compiles ahead of time (AOT). The runtime's stack unwinder
(
traceback.go) assumes every PC on the stack belongs to a Go functionregistered in
moduledataat link time. When the unwinder encounters aPC that does not belong to any known module — for example, because a Go
function was called from JIT-compiled code — it throws a fatal error:
This affects any Go program that:
native code at runtime)
Today, the only workaround is to avoid calling Go functions from
dynamically generated code entirely, or to use intermediate C frames
via cgo (which adds overhead and requires the cgo toolchain).
This problem has been reported in #20123
("runtime: unexpected return pc with JIT-generated code").
Existing mechanisms
Go already has partial support for non-Go frames via
SetCgoTraceback,which registers callbacks for C frames in cgo binaries. However, this
mechanism is specific to cgo: it relies on the
cgocallbackmarkerfunction and the
g.cgoCtxtcontext stack. It cannot be used forarbitrary dynamically generated code.
Proposal
Add a new package
runtime/jitwith the following API:Runtime changes
Internally, the runtime maintains a sorted registry of user frame
regions using a copy-on-write snapshot scheme:
via
persistentallocand publish it with an atomic pointer store.findUserFrameRegion, called from the unwinder) loadthe snapshot pointer atomically. No lock is needed, which is critical
because the unwinder runs on the system stack during signals and panics.
The unwinder (
traceback.go) is patched in two places:initAt(): When the initial PC is not in any Go module, checkif it belongs to a user frame region. If the region provides a
Nextcallback, use it to resolve the caller's PC/SP and continue unwinding
from there.
next(): When the return address (frame.lr) points into auser frame region, use
Nextto skip past the user frame andcontinue with the Go caller above it.
Both paths fall back gracefully: if
Nextis nil or returnsok=false,the unwinder stops without crashing.
Scheduler coordination
When a goroutine runs JIT-compiled code, the Go scheduler must be able
to preempt it for garbage collection and scheduling. Unlike opaque
foreign code (C via cgo), JIT-compiled code is generated by Go code and
can cooperate with the runtime. The
Preemptfunction enables thiscooperation.
How it works:
The goroutine remains in
_Grunningstatus while executing JIT code.It keeps its P and is visible to the scheduler and GC like any other
running goroutine.
When the GC or scheduler wants to preempt the goroutine, it sets
gp.preempt = trueandgp.stackguard0 = stackPreempt, then sendsan async preemption signal. These flags are set before the signal
is delivered.
JIT code checks these flags via
Preempt()at regular safepoints(loop back-edges, function entries). When
Preempt()returns true,the JIT code should return to its Go caller promptly.
Once back in Go code, the function prologue's stack check detects
stackPreemptand yields the goroutine to the scheduler.When Preempt is not needed:
JIT code that frequently calls Go functions does not need explicit
Preempt()checks. Each Go function call includes a prologue thatchecks for preemption. For example, a JIT-compiled SQL scan that calls
Go functions per row is implicitly preemptible at each callback.
Preempt()is primarily needed for tight JIT loops that do not callback into Go — for example, a pure-compute kernel that processes a
batch of rows before returning.
Why not cgocall:
The
runtime.cgocallmechanism transitions the goroutine to_Gsyscallstatus, which releases the P, saves/restores the signal mask, and
switches stacks. This is appropriate for opaque C functions that may
block and that the runtime cannot inspect. It costs ~40ns per transition.
JIT code is different: it is generated by Go code, runs on the
goroutine's behalf, and can cooperate with the runtime via
Preempt().The cgocall overhead is unjustified, and its requirement for the cgo
toolchain is unnecessary since JIT code is produced entirely at runtime.
Arena-based design for efficiency
A JIT compiler typically allocates code in large arenas (e.g., 2 MB
mmap'd pages). Each arena is registered as one
Region. The runtimeonly searches a small sorted list of arenas (typically < 10), not
individual functions. Per-function metadata is the JIT's responsibility,
accessed through the
NextandDescribecallbacks.Rationale
Why not extend SetCgoTraceback?
SetCgoTracebackis tightly coupled to cgo: it hooks into thecgocallbackmarker function and assumes the C/Go boundary followscgo's calling convention. JIT code does not use cgo and does not
produce
cgocallbackframes.Why not use moduledata?
moduledatais an internal ABI contract between the Go compiler,linker, and runtime. It encodes
pclntab,funcdata,stackmaps, andother structures whose format changes between Go versions. Requiring JIT
compilers to produce valid
moduledatawould be extremely fragile andwould couple them to Go's internal implementation details.
Our approach uses high-level callbacks instead, letting the runtime
evolve its internals freely while the JIT provides frame information
through a stable public API.
Why three unwind modes?
crashes without requiring the JIT to provide any frame metadata.
which is essential for production use.
function names, which is essential for debugging and profiling.
Why callbacks instead of static tables?
JIT compilers generate code at runtime and maintain their own metadata
in various formats. A callback-based API lets each JIT provide frame
information in whatever way is natural for its design, without imposing
a specific table format.
The callbacks have strict constraints (must not allocate, must not grow
the stack) because they run on the system stack during unwinding.
Compatibility
This proposal adds a new package with no changes to existing public
APIs. Existing programs are unaffected.
The
runtime/jitpackage would be covered by the Go 1 compatibilityguarantee once released. The API is deliberately minimal: one type
(
Region), one enum (UnwindMode), one pair of operations(
Register/Unregister), and one handle type (Handle).The internal runtime changes (unwinder patches) are additive: when no
user frame regions are registered, the code path is a single atomic
load returning nil, adding negligible overhead.
Implementation
The implementation is complete and available at:
https://github.com/carli2/go/tree/jit-foreign-frames
It has been validated in production use by the MemCP database engine,
which uses runtime/jit to register JIT-compiled SQL query functions.
Summary of changes
runtime/userframe.goruntime/traceback.goinitAt(),next(), andtraceback2()for Describeruntime/mgcmark.goruntime/jit/jit.goruntime/jit/jit_test.goruntime/jit/trampoline_*_test.gointernal/goexperiment/flags.goapi/next/20123.txtArchitecture support
The runtime code (
userframe.go,traceback.go) is pure Go with noarchitecture-specific logic. Tests include JIT trampolines for all
architectures supported on Linux: amd64, arm64, 386, arm, riscv64,
loong64, s390x, ppc64, ppc64le, mips64, mips64le, mips, mipsle.
Gating
The feature is gated behind
GOEXPERIMENT=jit, following theestablished experiment pattern (
internal/goexperiment/flags.go).All
runtime/jitsource files require//go:build goexperiment.jit.Without the experiment flag, the package is completely excluded from
builds — no runtime overhead, no binary size increase.
The runtime-internal code (
userframe.go,traceback.gopatches,mgcmark.gohook) is always compiled but has negligible cost whenno regions are registered: a single atomic load returning nil.
Release plan
GOEXPERIMENT=jitfor early adoptersOpen issues
Async preemption in JIT regions: The current cooperative
preemption via
Preempt()requires JIT code to check for preemptionat regular intervals. If JIT code runs a tight loop without checking,
GC stop-the-world is delayed until the next check. A future
enhancement could extend the async preemption signal handler to
recognize JIT regions and preempt them directly, similar to how Go
code can be async-preempted at any safe point. This would require
the JIT to mark unsafe instruction sequences (analogous to the
compiler's unsafe-point annotations).
Signal safety: The
NextandDescribecallbacks shouldideally be callable from signal handlers (for SIGQUIT stack dumps).
Currently, signal-context tracebacks that encounter user frames
fall back to
UnwindStopbehavior because the callbacks may notbe async-signal-safe. A future enhancement could add a
SignalSafeflag to
Region.Profiling integration: pprof currently does not know about
user frames. Frames skipped by
UnwindSkipare invisible to theprofiler. A future enhancement could emit synthetic pprof locations
for
UnwindDeclareregions.Windows/Plan9 test coverage: The current tests use
syscall.MmapwithMAP_ANONYMOUS, which is Unix-only. Testsneed platform-specific allocation for Windows (
VirtualAlloc)and may not be feasible on Plan9. The runtime code itself is
platform-independent.
glycerine commentedon Apr 29, 2026
I've drafted an alternative which I think is simpler and more general. I've requested early feedback on golang-dev to flush out any blindspots in the design.
https://groups.google.com/g/golang-dev/c/mABYStr5aYs
I don't want to hijack this thread any more than this mention, but I would appreciate @carli2 your thoughts there on whether the self-describing frame would work for your needs. I think it is strictly more general, while also exposing just a single extra API call in the runtime package, rather than an entirely new runtime/jit package.
carli2 commentedon Apr 30, 2026
Have the golang maintainers already reviewed your proposal? I would switch to whatever variant which gets streamlined into official go. Having a JIT engine means >200% more performance on memcp (https://github.com/launix-de/memcp). But we can only activate this feature when go does not crash during GC runs.
carli2 commentedon May 17, 2026
What's the general plan to integrate this approach or a similar functionality? We need streamline golang integration for this feature ASAP. Otherwise we cannot publish our JIT feature in https://github.com/launix-de/memcp
glycerine commentedon Jun 3, 2026
Hi @carli2 Carl-Philip, I did not get any feedback on my pre-proposal, so I don't have any further information at this time.
jerbob92 commentedon Aug 19, 2026
Strong +1 to this proposal, with a production use case and measurements, plus one capability I'd like to see explicitly in scope: recovering a synchronous hardware fault whose PC is inside a registered JIT region.
Use case
I maintain go-pdfium, which has a WebAssembly runtime based on wazero (a pure-Go wasm runtime with a JIT compiler), running a ~5 MB PDFium module. Hardware-fault-based wasm runtimes (wasmtime, V8, JSC) eliminate per-access memory bounds checks with virtual memory: reserve an 8 GiB
PROT_NONEregion per guest memory so that anyu32 base + u32 offset + sizelands inside it, commit only the actual memory, and let out-of-bounds accesses fault. The trap costs nothing until it happens.A pure-Go JIT cannot do this today, because a fault at a JIT PC is fatal:
debug.SetPanicOnFaultonly covers faults at Go PCs. And there is no signal-forwarding escape hatch:sigfwdgoforwards synchronous signals to a pre-installed C handler only when the fault occurs on a non-Go thread or inside a cgo call. JIT code executing on an ordinary goroutine fails that test, so even a constructor-installed C handler is never consulted.The measured cost
Implementing guard-page memory in wazero and benchmarking PDFium-compiled-to-wasm over a corpus of real PDF documents (Apple M5, benchstat n=6, interleaved): removing per-access bounds checks is worth −22% geomean render time, up to −37% on image-heavy documents, moving the workload from ~2.04× to ~1.6× the speed of the equivalent native library. SSA-level check elision can't recover most of this: the hot accesses are dynamic
base + induction-variableaddresses in pixel loops, fresh values every iteration.We have a working proof of concept of the only route available today (jerbob92/wazero#2, with the full design, benchmarks, and rationale): an opt-in cgo package whose C handler, installed after runtime init (interposing in front of the Go runtime and chaining back to it, per the os/signal rules), classifies the fault and rewrites the ucontext PC to a JIT-compiled trap-exit sequence. It works (the full WebAssembly spec test suite passes, with every out-of-bounds trap assertion going through the hardware fault path), but it isn't something we're eager to actually ship: it drags cgo into otherwise pure-Go programs and it's exactly the kind of runtime-version-sensitive machinery this proposal could obsolete. It's also hard to see it ever landing upstream: wazero's headline promise is literally "It has zero dependencies, and doesn't rely on CGO", so a cgo-based handler runs against the project's core identity even as an opt-in. Native runtime support would make the cgo approach unnecessary before it ever reaches users.
The ask: specify (and test) fault-at-JIT-PC recovery, which the design may already cover
Having read the design doc: the fatal error in our scenario occurs in the panic unwinder, which this proposal patches. Concretely, with
debug.SetPanicOnFaultenabled, a SIGSEGV at a JIT PC today does getsigpanicinjected; the process then dies in(*_panic).nextFrame→unwinder.nexton the JIT return PC, i.e. exactly thetraceback.gopath the proposal fixes. So the compositionshould work with the proposal as written, no new API needed. For wazero,
Nextis straightforward and allocation-free: our entry trampoline records the Go caller's PC/SP in a per-call execution context, soNext(pc, sp)is a lookup keyed by the (separate) wasm stack the fault occurred on. And panic-based recovery is fully sufficient here: an out-of-bounds trap is the guest's error path, so its cost is irrelevant.What I'd ask is that this becomes specified behavior rather than an emergent one, since today it hangs on incidental details:
Nextcontract saysspis "the frame pointer of the Go callee", but in the fault case there is no Go callee; the innermost frame is the interrupted JIT frame (withsigpanicsynthesized above it). It would help to specify whatpc/spaRegionreceives in that case (the interrupted PC and SP, presumably).sigpanicinjection happens at all for a non-Go PC is, as far as I can tell, not documented behavior. The proposal could state: a synchronous memory fault at a PC inside a registered region, withSetPanicOnFaultenabled, produces a recoverableruntime.Errorpanic (and keep today's fatal behavior for unregistered PCs, so wild jumps still crash loudly).recoverin the Go caller below) would pin the behavior. Happy to contribute it: the wazero guard-page PR (jerbob92/wazero#2) is a complete, working consumer (currently via the cgo handler) that we could port toGOEXPERIMENT=jitto validate the whole path end to end, with the benchmark corpus above.carli2 commentedon Aug 31, 2026
Implementation/status update from MemCP
The maintained implementation now lives in the project organization rather than only in a personal fork:
The runtime side is implemented, not merely a design sketch. The branch provides registered foreign/JIT frame regions, precise stack-map scanning for pointers held by JIT frames, cooperative preemption state, and a dynamically growing region registry. On the published branch,
GOEXPERIMENT=jit go test runtime/jitandGOEXPERIMENT=jit go test runtimeboth pass with the patchedgo1.27.0-X:jittoolchain.MemCP now uses this facility for native Scheme code generation, including query-planner code loaded through its normal import path and native self recursion. Current measurements on the MemCP integration show:
These are deliberately two different measurements: the microbenchmark shows the attainable gain once interpretation and call overhead disappear, while the full-suite result includes startup, storage, HTTP, parsing, and tests that are not yet JIT-dominated. The remaining MemCP work is expanding JIT coverage and fusing parser/query pipelines, not proving that the runtime API can work.
The integration is safely gated. MemCP's runtime integration is selected only by the
goexperiment.jitbuild constraint. A build with the official unpatched Go compiler remains valid and uses the interpreter; JIT code is inactive. SettingGOEXPERIMENT=jitrequires the patched fork today because vanilla Go does not yet define the experiment. This is exactly why landing the already implemented runtime support behind an official Go experiment would be valuable: real consumers could test and iterate without maintaining a compiler fork, while default Go builds would remain unaffected.carli2 commentedon Aug 31, 2026
Follow-up: the upstream merge candidate is now based on current
golang/go master, not on the Go 1.27 release snapshot: #81237. Thejit-foreign-frames-go1.27.0branch remains only the reproducible compiler branch used by MemCP;jit-foreign-frames-masteris the reviewed/mergeable line. Porting to master also exposed and fixed caller-frame-pointer relocation during JIT stack growth, and the master-basedruntime/jitandruntimetest suites pass.runtime/jit: add user frame support for JIT compilers and VMs
gopherbot commentedon Sep 2, 2026
Change https://go.dev/cl/826384 mentions this issue:
runtime/jit: add user frame support for JIT compilers and VMsruntime/jit: harden nested frame and tail layouts
runtime/jit: add user frame support for JIT compilers and VMs
runtime/jit: harden nested frame and tail layouts
carli2 commentedon Sep 6, 2026
The proposal body has now been rewritten to match the current implementation.
In particular, the old
NextandScanStackcallback design has been replacedby copied, declarative
StackMapmetadata, so GC stack scanning and stackrelocation do not call JIT-owned code. The text now also covers split-stack
checks, cooperative preemption, nested generated frames, and the current
production consumer.
The merge candidate has been rebased onto current Go master. On linux/amd64,
GOEXPERIMENT=jit go test runtime/jit,GOEXPERIMENT=jit go test runtime, andGOEXPERIMENT=jit go test cmd/apipass. Theruntime/jittest package alsocross-compiles for all architectures covered by its trampolines on Linux and
for 386, amd64, and arm64 on Windows.
Two scope questions are called out explicitly in the revised proposal:
JIT closure environments should stay in this proposal or be split out; and
be specified and tested here or in a follow-up.
The code review remains at https://go.dev/cl/826384.
runtime/jit: add user frame support for JIT compilers and VMs
runtime/jit: harden nested frame and tail layouts
carli2 commentedon Oct 9, 2026
@prattmic, following up on your earlier concerns: MemCP has now used this JIT integration in production for several months, and it has proven stable in that workload.
The implementation has evolved since your feedback. Generated code now publishes declarative unwind recipes and precise pointer maps. The runtime uses these to reach enclosing Go frames, scan generated frames, and relocate their pointers during stack growth, including consecutive JIT frames.
Scheduler coordination is cooperative: generated loops check
Preemptat safepoints and return to Go when requested; calls into Go also reach its stack/preemption checks. This relies on the JIT honoring those safepoint obligations.The updated implementation is in https://go.dev/cl/826384 (GitHub PR #81237). The branch has just been rebased onto current Go master. On linux/amd64, the
runtime/jit,runtime(with and without JIT),cmd/api, andruntime/jitrace tests pass.Would these contracts address your concerns, or are additional guarantees or tests needed?