Pure Go FFI for WebGPU - CGO-free GPU access via wgpu-native bindings
Go
100
131 commits
updated Oct 17, 2026
Pure Go Foreign Function Interface for calling C libraries without CGO. Designed for WebGPU and GPU computing — zero C dependencies, zero per-call allocations, 88–114 ns overhead.
Deep dive: How We Call C Libraries Without a C Compiler — architecture, assembly, callbacks, and ecosystem.
// Load library, prepare once, call many times — no CGO required
handle, _ := ffi.LoadLibrary("wgpu_native.dll")
sym, _ := ffi.GetSymbol(handle, "wgpuCreateInstance")
cif := &types.CallInterface{}
ffi.PrepareCallInterface(cif, types.DefaultCall, returnType, argTypes)
_, _ = ffi.CallFunction(cif, sym, unsafe.Pointer(&result), args)
| Feature | Details | |
|---|---|---|
| Zero CGO | Pure Go | No C compiler needed. go get and build. |
| Fast | 88–114 ns/op | Pre-computed CIF, zero per-call allocations |
| Cross-platform | 8 desktop targets + Android preview | Windows, Linux, macOS, FreeBSD × AMD64 + ARM64; Android arm64/API 29+ candidate pending physical-device startup proof |
| Callbacks | C→Go safe where validated | crosscall2 integration on desktop targets; Android callbacks fail explicitly until a physical-thread proof exists |
| Type-safe | Runtime validation | 5 typed error types with errors.As() support |
| Struct pass/return | Full ABI | Args: INTEGER/SSE classification. Returns: ≤8B (RAX/XMM0), 9–16B (4 modes: RAX/XMM × RAX/XMM), >16B (sret) |
| Variadic | printf/sprintf | PrepareVariadicCallInterface — Apple ARM64 stack-force included |
| errno | Always captured | Thread-safe assembly-level capture — first pure-Go FFI on Linux |
| Context | Timeouts | CallFunctionContext(ctx, ...) cancellation |
| Race detector | -race compatible | CGO_ENABLED=1 go test -race works cleanly |
| Tested | 89% coverage | CI on Linux, Windows, macOS (CGO=0 and CGO=1) |
Android arm64/API 29+ is a preview candidate in both CGO modes. Cross-build, ABI, and ELF probes pass, but physical-device startup proof is still pending; see docs/ANDROID.md for the runtime ABI, NDK probe, and the intentional callback limitation.
go get github.com/go-webgpu/goffi
goffi works under both CGO_ENABLED=0 and CGO_ENABLED=1. The default — CGO_ENABLED=0, no C compiler required — is still the recommended path for the gogpu ecosystem and most users; nothing about that mode has changed.
# CGO_ENABLED=0 (default, no C compiler needed)
CGO_ENABLED=0 go build ./...
# CGO_ENABLED=1 (for binaries that already link other CGO libraries,
# e.g. gocv, libavcodec wrappers, native database drivers)
CGO_ENABLED=1 go build ./...
How? goffi uses Go's
cgo_import_dynamicfor dynamic library loading. UnderCGO_ENABLED=0the cgo runtime is supplied byinternal/fakecgo; underCGO_ENABLED=1the standardruntime/cgois linked in. Both modes share the same FFI fast path and ABIs.
CGO_ENABLED=0 does not imply a fully static ELF when goffi is imported. //go:cgo_import_dynamic for dlopen / libc still records PT_INTERP and DT_NEEDED (libdl.so.2, libc.so.6, libpthread.so.0). This matches purego and is required for host dlopen — the kernel only maps ld.so when PT_INTERP is present. See goffi#74 and gogpu#474.
| Mode | How | ELF shape | LoadLibrary | Typical use |
|---|---|---|---|---|
| Dynamic FFI (default) | CGO_ENABLED=0 go build | dynamic + libdl/libc | yes | desktop GPU/GUI |
| Musl dynamic | build on Alpine / CC=musl-gcc | dynamic vs musl | yes | Alpine containers with GPU/GUI |
| Static no-FFI | CGO_ENABLED=0 go build -tags goffi_static | fully static (no PT_INTERP, no NEEDED) | no (errors.Is(err, ffi.ErrStaticBuild)) | FROM scratch, air-gapped CLI |
# Fully static Linux amd64/arm64 binary (FFI unavailable)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags goffi_static -o app .
file app # statically linked
# Verify: no INTERP / NEEDED
scripts/check-elf-linking.sh --static ./app
Under -tags goffi_static, errno capture is unavailable (always returns 0): ErrnoFnAddr() is a no-op so the assembly trampoline skips __errno_location / __error, which need dynamic libc.
FROM scratch + Vulkan/Wayland/libX11 via host dlopen is not possible without either ld.so or a userspace ELF loader (see docs/ADR-001-userspace-elf-loader.md). Windows is unaffected (LoadLibraryW via ntdll).
package main
import (
"fmt"
"runtime"
"unsafe"
"github.com/go-webgpu/goffi/ffi"
"github.com/go-webgpu/goffi/types"
)
func main() {
// Load platform-specific C library
libName := "libc.so.6"
if runtime.GOOS == "windows" {
libName = "msvcrt.dll"
}
handle, err := ffi.LoadLibrary(libName)
if err != nil {
panic(err)
}
defer ffi.FreeLibrary(handle)
strlen, err := ffi.GetSymbol(handle, "strlen")
if err != nil {
panic(err)
}
// Prepare call interface once — reuse for all subsequent calls
cif := &types.CallInterface{}
err = ffi.PrepareCallInterface(
cif,
types.DefaultCall, // auto-detects platform ABI
types.UInt64TypeDescriptor, // return: size_t
[]*types.TypeDescriptor{types.PointerTypeDescriptor}, // arg: const char*
)
if err != nil {
panic(err)
}
// Call strlen — avalue elements are pointers TO argument values.
// Keep pointer arguments in unsafe.Pointer variables, never uintptr.
testStr := "Hello, goffi!\x00"
strPtr := unsafe.Pointer(unsafe.StringData(testStr))
var length uint64
_, err = ffi.CallFunction(cif, strlen, unsafe.Pointer(&length), []unsafe.Pointer{unsafe.Pointer(&strPtr)})
if err != nil {
panic(err)
}
fmt.Printf("strlen(%q) = %d\n", testStr[:len(testStr)-1], length)
// Output: strlen("Hello, goffi!") = 13
}
Use PrepareVariadicCallInterface instead of PrepareCallInterface for C variadic functions
(printf, sprintf, custom variadic APIs). Specify nfixedargs — the count of fixed parameters
before ... in the C prototype. goffi automatically applies Apple's ARM64 stack-force rule on
darwin/arm64 so the same Go code works correctly on all platforms.
// C prototype: int64_t sum_variadic(int64_t count, ...)
// Call as: sum_variadic(3, 10, 20, 30) → 60
var cif types.CallInterface
err := ffi.PrepareVariadicCallInterface(
&cif,
types.DefaultCall,
1, // nfixedargs: only 'count' is fixed; 10/20/30 are variadic
types.SInt64TypeDescriptor,
[]*types.TypeDescriptor{
types.SInt64TypeDescriptor, // count (fixed)
types.SInt64TypeDescriptor, // arg1 (variadic)
types.SInt64TypeDescriptor, // arg2 (variadic)
types.SInt64TypeDescriptor, // arg3 (variadic)
},
)
count := int64(3)
a1, a2, a3 := int64(10), int64(20), int64(30)
var result int64
_, _ = ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
unsafe.Pointer(&count),
unsafe.Pointer(&a1),
unsafe.Pointer(&a2),
unsafe.Pointer(&a3),
})
// result == 60
A new CIF must be prepared for each unique combination of variadic argument types. The fixed-arg
portion of the CIF can be reused by re-calling PrepareVariadicCallInterface with different
variadic arg type slices.
goffi handles C struct pass-by-value across all ABI size classes. Describe the struct layout
with a TypeDescriptor, then pass struct values directly via unsafe.Pointer(&s).
// C struct: typedef struct { int64_t x; int64_t y; } Point;
pointType := &types.TypeDescriptor{
Kind: types.StructType,
Size: 16, // must match C sizeof(Point)
Alignment: 8, // must match C alignof(Point)
Members: []*types.TypeDescriptor{
types.SInt64TypeDescriptor, // x
types.SInt64TypeDescriptor, // y
},
}
var cif types.CallInterface
ffi.PrepareCallInterface(&cif, types.DefaultCall,
pointType, // return: Point
[]*types.TypeDescriptor{types.SInt64TypeDescriptor, types.SInt64TypeDescriptor},
)
x, y := int64(3), int64(4)
var result Point
_, _ = ffi.CallFunction(&cif, makePointFn,
unsafe.Pointer(&result), // buffer for struct return value
[]unsafe.Pointer{unsafe.Pointer(&x), unsafe.Pointer(&y)},
)
// result.X == 3, result.Y == 4
// Pass struct as argument:
var distCif types.CallInterface
ffi.PrepareCallInterface(&distCif, types.DefaultCall,
types.SInt64TypeDescriptor,
[]*types.TypeDescriptor{pointType, pointType}, // two Point args
)
a := Point{X: 0, Y: 0}
b := Point{X: 3, Y: 4}
var dist int64
_, _ = ffi.CallFunction(&distCif, distFn,
unsafe.Pointer(&dist),
[]unsafe.Pointer{unsafe.Pointer(&a), unsafe.Pointer(&b)}, // pointer to struct data
)
// dist == 25
Structs >16 bytes are returned via hidden pointer (sret) — goffi handles this transparently.
Note: On Windows AMD64, struct arguments containing float fields are not supported due to
syscall.SyscallNlimitations. Use integer-only structs for cross-platform code, or pass float fields as individual arguments.
See examples/struct/ for a complete working example with compile-and-run.
Every avalue[i] must be a pointer TO the argument value. GoFFI dereferences it to read
the actual value passed to C. This follows the libffi convention.
For most cases this is straightforward — unsafe.Pointer(&x) for scalars, unsafe.Pointer(&buf) for
pointers. The non-obvious case is out-pointer parameters (where C writes a result through a pointer):
// C: int get_device(uint32_t index, void **device)
// The function writes *device — an out-pointer parameter.
// WRONG — C receives NULL (GoFFI reads the VALUE of device, which is nil):
var device unsafe.Pointer
_, _ = ffi.CallFunction(&cif, fn, unsafe.Pointer(&ret),
[]unsafe.Pointer{unsafe.Pointer(&index), unsafe.Pointer(&device)})
// CORRECT — C receives &device (GoFFI reads the VALUE of devicePtr, which is &device):
var device unsafe.Pointer
devicePtr := unsafe.Pointer(&device)
_, _ = ffi.CallFunction(&cif, fn, unsafe.Pointer(&ret),
[]unsafe.Pointer{unsafe.Pointer(&index), unsafe.Pointer(&devicePtr)})
// After call: device holds the handle written by C
Rule of thumb: if the C parameter is T *out (C writes through it), you need an intermediate
outPtr := unsafe.Pointer(&out) and pass unsafe.Pointer(&outPtr) in avalue.
FFI overhead: 88–114 ns/op (Windows AMD64, Intel i7-1255U)
| Benchmark | Time | Allocations |
|---|---|---|
Empty function (getpid) | 88 ns | 0 allocs (steady state) |
Integer argument (abs) | 114 ns | 0 allocs (steady state) |
String processing (strlen) | 98 ns | 0 allocs (steady state) |
syscallArgs is heap-allocated via sync.Pool for callback safety (goroutine stack may move during C→Go callbacks). Pool reuse gives 0 allocs/op in steady state.
At 60 FPS with ~50 FFI calls per frame, overhead is 5 µs per frame — 0.03% of the 16.6 ms budget. Unmeasurable in profiling.
See docs/PERFORMANCE.md for detailed analysis, optimization strategies, and when NOT to use goffi.
goffi transitions from Go's managed runtime to C code through three layers:
Go Code
│ ffi.CallFunction()
▼
runtime.cgocall ← Go runtime: system stack switch, GC coordination
│
▼
Assembly Wrapper ← Hand-written: load GP/SSE registers per ABI
│ CALL target_function
▼
C Function ← External library
Three ABIs, hand-written assembly for each:
| ABI | GP Registers | FP Registers | Notes |
|---|---|---|---|
| System V AMD64 | RDI, RSI, RDX, RCX, R8, R9 | XMM0–XMM7 | Linux, macOS, FreeBSD |
| Win64 | RCX, RDX, R8, R9 | XMM0–XMM3 | 32-byte shadow space mandatory |
| AAPCS64 | X0–X7 | D0–D7 | HFA support for ARM64 |
See docs/ARCHITECTURE.md for the full technical deep dive.
WebGPU fires async callbacks from internal Metal/Vulkan threads. These threads have no goroutine — calling Go directly would crash.
goffi uses crosscall2 for safe C→Go transitions from any thread:
cb := ffi.NewCallback(func(status uint32, adapter uintptr, msg uintptr, ud uintptr) {
// Safe even when called from a C thread
result.handle = adapter
close(done)
})
_, _ = ffi.CallFunction(cif, wgpuRequestAdapter, nil, args)
<-done // Wait for GPU driver callback
2000 pre-compiled trampoline entries per process. AMD64: 5 bytes/entry. ARM64: 8 bytes/entry.
Five typed error types for precise diagnostics:
handle, err := ffi.LoadLibrary("nonexistent.dll")
if err != nil {
var libErr *ffi.LibraryError
if errors.As(err, &libErr) {
fmt.Printf("Failed to %s %q: %v\n", libErr.Operation, libErr.Name, libErr.Err)
}
}
| Error Type | When |
|---|---|
InvalidCallInterfaceError | CIF preparation failures |
LibraryError | Library loading / symbol lookup |
CallingConventionError | Unsupported calling convention |
TypeValidationError | Invalid type descriptor |
UnsupportedPlatformError | Platform not supported |
| Feature | goffi | purego | CGO |
|---|---|---|---|
| C compiler required | No | No | Yes |
| API style | libffi-like (prepare once, call many) | reflect-based (RegisterFunc) | Native |
| Per-call allocations | Zero (CIF reusable) | reflect + sync.Pool per call | Zero |
| Struct pass/return | Full (RAX+RDX, sret) | Partial (no Windows structs) | Full |
| Callback struct args | ≤8B, 9-16B, >16B (AMD64) | Not supported (panic) | Full |
| Callback float args/returns | XMM0 / D0 (Unix amd64, arm64; Windows callbacks are uintptr-only) | Args yes, returns not supported (panic) | Full |
| ARM64 HFA detection | Recursive (nested structs) | Partial (bug in nested path) | Full |
| Typed errors | 5 types + errors.As() | Generic | N/A |
| Context support | Timeouts/cancellation | No | No |
| C-thread callbacks | crosscall2 | crosscall2 | Full |
| String/bool/slice args | Raw pointers only | Auto-marshaling | Full |
| Platform breadth | 8 desktop targets + Android preview | 8 GOARCH / 20+ OS×ARCH | All |
| AMD64 overhead | 88–114 ns | Not published | ~140 ns (Go 1.26 claims ~30% reduction) |
Choose goffi for GPU/real-time workloads: struct passing, zero per-call overhead, callback float returns, typed errors.
Choose purego for general-purpose bindings: string auto-marshaling, broad architecture support, less boilerplate.
See also:
purego implemented entirely on top of goffi. It resolves fakecgo linker conflicts without build tags, correctly handles macOS ARM64 variadic stack-packing ABI requirements under the hood, and provides a zero-code-change migration path.Linux: default builds are dynamically linked (#74)
libdl/libc via cgo_import_dynamic even with CGO_ENABLED=0. Use -tags goffi_static for a fully static ELF (no runtime .so loading), or build against musl for Alpine. See Linking modes.Windows: C++ exceptions may crash the program (#12516)
panic=abort.Apple ARM64: variadic args always go on stack
PrepareVariadicCallInterface (not PrepareCallInterface) for variadic C functions on all platforms — goffi handles the Darwin-specific register flush automatically.Struct packing follows System V ABI only
#pragma pack not honored. Manually specify Size/Alignment in TypeDescriptor.No bitfields in struct types.
Unix: duplicate symbol conflict with purego (#22)
CGO_ENABLED=0, the linker reports duplicated definition of symbol _cgo_init. Both libraries include internal/fakecgo which defines identical runtime symbols.-tags nofakecgo to disable goffi's fakecgo, relying on purego's copy:
CGO_ENABLED=0 go build -tags nofakecgo ./...
CGO_ENABLED=1. In cgo mode the real runtime/cgo supplies the symbols and both libraries' fakecgo packages are gated out by //go:build !cgo.pureffi is a 1:1, drop-in API-compatible wrapper over goffi that acts as a transparent replacement for purego via Go's replace directive. It completely eliminates the need for compilation tags, resolves the duplicate symbol conflict, and natively fixes Apple Silicon variadic ABI stack-packing.| Platform | Arch | ABI | Verification |
|---|---|---|---|
| Windows | amd64 | Win64 | CI: tests on a native runner |
| Windows | arm64 | AAPCS64 | CI: tests on a native runner |
| Linux | amd64 | System V | CI: tests on a native runner |
| Linux | arm64 | AAPCS64 | CI: tests on a native runner |
| macOS | amd64 | System V | CI: tests on a native runner |
| macOS | arm64 | AAPCS64 | CI: tests on a native runner |
| FreeBSD | amd64 | System V | CI: tests in FreeBSD 14 and 15 VMs |
| FreeBSD | arm64 | AAPCS64 | CI: cross-compiled build and test build (tests not run yet) |
| Android | arm64 | AAPCS64 (Bionic) | Guarded preview (API 29+), cross-compiled in CI |
Planned work: ROADMAP.md and the milestones. What each release changed: CHANGELOG.md.
go test ./... # all tests
go test -cover ./... # with coverage (89%)
go test -bench=. -benchmem ./ffi # benchmarks
go test -v ./ffi # verbose, auto-detects platform
| Document | Description |
|---|---|
| docs/ARCHITECTURE.md | Technical architecture: assembly, ABIs, callbacks, linking |
| docs/PERFORMANCE.md | Benchmarks, optimization strategies, Go 1.26 |
| docs/ANDROID.md | Android ARM64 preview ABI + build notes |
| docs/ADR-001-userspace-elf-loader.md | Research: optional pure-Go .so loader |
| CHANGELOG.md | Version history, migration guides |
| ROADMAP.md | Development roadmap |
| CONTRIBUTING.md | Contribution guidelines |
| SECURITY.md | Security policy |
| examples/ | Working code examples (simple, struct) |
See CONTRIBUTING.md for guidelines.
feat:, fix:, docs:, test:crosscall2 callback mechanism, fakecgo approach, and assembly trampoline patterns were pioneered by purego. goffi exists because purego cleared the path.runtime.cgocall for GC-safe stack switching, crosscall2 for C→Go transitions.goffi powers an ecosystem of pure Go GPU libraries:
| Project | Description |
|---|---|
| go-webgpu/webgpu | Zero-CGO WebGPU bindings (wgpu-native) |
| born-ml/born | ML framework for Go, GPU-accelerated |
| gogpu | GPU computing platform — dual Rust + Pure Go backends |
| wgpu-native | Native WebGPU implementation (upstream) |
MIT — see LICENSE.
GitHub | pkg.go.dev | Dev.to
165 followers · starred May 2026
107 followers · starred Oct 2026
142 followers · starred Jul 2026
11 followers · starred Apr 2026
Pure Go FFI for WebGPU - CGO-free GPU access via wgpu-native bindings
Go
100
131 commits
updated Oct 17, 2026
Pure Go Foreign Function Interface for calling C libraries without CGO. Designed for WebGPU and GPU computing — zero C dependencies, zero per-call allocations, 88–114 ns overhead.
Deep dive: How We Call C Libraries Without a C Compiler — architecture, assembly, callbacks, and ecosystem.
// Load library, prepare once, call many times — no CGO required
handle, _ := ffi.LoadLibrary("wgpu_native.dll")
sym, _ := ffi.GetSymbol(handle, "wgpuCreateInstance")
cif := &types.CallInterface{}
ffi.PrepareCallInterface(cif, types.DefaultCall, returnType, argTypes)
_, _ = ffi.CallFunction(cif, sym, unsafe.Pointer(&result), args)
| Feature | Details | |
|---|---|---|
| Zero CGO | Pure Go | No C compiler needed. go get and build. |
| Fast | 88–114 ns/op | Pre-computed CIF, zero per-call allocations |
| Cross-platform | 8 desktop targets + Android preview | Windows, Linux, macOS, FreeBSD × AMD64 + ARM64; Android arm64/API 29+ candidate pending physical-device startup proof |
| Callbacks | C→Go safe where validated | crosscall2 integration on desktop targets; Android callbacks fail explicitly until a physical-thread proof exists |
| Type-safe | Runtime validation | 5 typed error types with errors.As() support |
| Struct pass/return | Full ABI | Args: INTEGER/SSE classification. Returns: ≤8B (RAX/XMM0), 9–16B (4 modes: RAX/XMM × RAX/XMM), >16B (sret) |
| Variadic | printf/sprintf | PrepareVariadicCallInterface — Apple ARM64 stack-force included |
| errno | Always captured | Thread-safe assembly-level capture — first pure-Go FFI on Linux |
| Context | Timeouts | CallFunctionContext(ctx, ...) cancellation |
| Race detector | -race compatible | CGO_ENABLED=1 go test -race works cleanly |
| Tested | 89% coverage | CI on Linux, Windows, macOS (CGO=0 and CGO=1) |
Android arm64/API 29+ is a preview candidate in both CGO modes. Cross-build, ABI, and ELF probes pass, but physical-device startup proof is still pending; see docs/ANDROID.md for the runtime ABI, NDK probe, and the intentional callback limitation.
go get github.com/go-webgpu/goffi
goffi works under both CGO_ENABLED=0 and CGO_ENABLED=1. The default — CGO_ENABLED=0, no C compiler required — is still the recommended path for the gogpu ecosystem and most users; nothing about that mode has changed.
# CGO_ENABLED=0 (default, no C compiler needed)
CGO_ENABLED=0 go build ./...
# CGO_ENABLED=1 (for binaries that already link other CGO libraries,
# e.g. gocv, libavcodec wrappers, native database drivers)
CGO_ENABLED=1 go build ./...
How? goffi uses Go's
cgo_import_dynamicfor dynamic library loading. UnderCGO_ENABLED=0the cgo runtime is supplied byinternal/fakecgo; underCGO_ENABLED=1the standardruntime/cgois linked in. Both modes share the same FFI fast path and ABIs.
CGO_ENABLED=0 does not imply a fully static ELF when goffi is imported. //go:cgo_import_dynamic for dlopen / libc still records PT_INTERP and DT_NEEDED (libdl.so.2, libc.so.6, libpthread.so.0). This matches purego and is required for host dlopen — the kernel only maps ld.so when PT_INTERP is present. See goffi#74 and gogpu#474.
| Mode | How | ELF shape | LoadLibrary | Typical use |
|---|---|---|---|---|
| Dynamic FFI (default) | CGO_ENABLED=0 go build | dynamic + libdl/libc | yes | desktop GPU/GUI |
| Musl dynamic | build on Alpine / CC=musl-gcc | dynamic vs musl | yes | Alpine containers with GPU/GUI |
| Static no-FFI | CGO_ENABLED=0 go build -tags goffi_static | fully static (no PT_INTERP, no NEEDED) | no (errors.Is(err, ffi.ErrStaticBuild)) | FROM scratch, air-gapped CLI |
# Fully static Linux amd64/arm64 binary (FFI unavailable)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags goffi_static -o app .
file app # statically linked
# Verify: no INTERP / NEEDED
scripts/check-elf-linking.sh --static ./app
Under -tags goffi_static, errno capture is unavailable (always returns 0): ErrnoFnAddr() is a no-op so the assembly trampoline skips __errno_location / __error, which need dynamic libc.
FROM scratch + Vulkan/Wayland/libX11 via host dlopen is not possible without either ld.so or a userspace ELF loader (see docs/ADR-001-userspace-elf-loader.md). Windows is unaffected (LoadLibraryW via ntdll).
package main
import (
"fmt"
"runtime"
"unsafe"
"github.com/go-webgpu/goffi/ffi"
"github.com/go-webgpu/goffi/types"
)
func main() {
// Load platform-specific C library
libName := "libc.so.6"
if runtime.GOOS == "windows" {
libName = "msvcrt.dll"
}
handle, err := ffi.LoadLibrary(libName)
if err != nil {
panic(err)
}
defer ffi.FreeLibrary(handle)
strlen, err := ffi.GetSymbol(handle, "strlen")
if err != nil {
panic(err)
}
// Prepare call interface once — reuse for all subsequent calls
cif := &types.CallInterface{}
err = ffi.PrepareCallInterface(
cif,
types.DefaultCall, // auto-detects platform ABI
types.UInt64TypeDescriptor, // return: size_t
[]*types.TypeDescriptor{types.PointerTypeDescriptor}, // arg: const char*
)
if err != nil {
panic(err)
}
// Call strlen — avalue elements are pointers TO argument values.
// Keep pointer arguments in unsafe.Pointer variables, never uintptr.
testStr := "Hello, goffi!\x00"
strPtr := unsafe.Pointer(unsafe.StringData(testStr))
var length uint64
_, err = ffi.CallFunction(cif, strlen, unsafe.Pointer(&length), []unsafe.Pointer{unsafe.Pointer(&strPtr)})
if err != nil {
panic(err)
}
fmt.Printf("strlen(%q) = %d\n", testStr[:len(testStr)-1], length)
// Output: strlen("Hello, goffi!") = 13
}
Use PrepareVariadicCallInterface instead of PrepareCallInterface for C variadic functions
(printf, sprintf, custom variadic APIs). Specify nfixedargs — the count of fixed parameters
before ... in the C prototype. goffi automatically applies Apple's ARM64 stack-force rule on
darwin/arm64 so the same Go code works correctly on all platforms.
// C prototype: int64_t sum_variadic(int64_t count, ...)
// Call as: sum_variadic(3, 10, 20, 30) → 60
var cif types.CallInterface
err := ffi.PrepareVariadicCallInterface(
&cif,
types.DefaultCall,
1, // nfixedargs: only 'count' is fixed; 10/20/30 are variadic
types.SInt64TypeDescriptor,
[]*types.TypeDescriptor{
types.SInt64TypeDescriptor, // count (fixed)
types.SInt64TypeDescriptor, // arg1 (variadic)
types.SInt64TypeDescriptor, // arg2 (variadic)
types.SInt64TypeDescriptor, // arg3 (variadic)
},
)
count := int64(3)
a1, a2, a3 := int64(10), int64(20), int64(30)
var result int64
_, _ = ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
unsafe.Pointer(&count),
unsafe.Pointer(&a1),
unsafe.Pointer(&a2),
unsafe.Pointer(&a3),
})
// result == 60
A new CIF must be prepared for each unique combination of variadic argument types. The fixed-arg
portion of the CIF can be reused by re-calling PrepareVariadicCallInterface with different
variadic arg type slices.
goffi handles C struct pass-by-value across all ABI size classes. Describe the struct layout
with a TypeDescriptor, then pass struct values directly via unsafe.Pointer(&s).
// C struct: typedef struct { int64_t x; int64_t y; } Point;
pointType := &types.TypeDescriptor{
Kind: types.StructType,
Size: 16, // must match C sizeof(Point)
Alignment: 8, // must match C alignof(Point)
Members: []*types.TypeDescriptor{
types.SInt64TypeDescriptor, // x
types.SInt64TypeDescriptor, // y
},
}
var cif types.CallInterface
ffi.PrepareCallInterface(&cif, types.DefaultCall,
pointType, // return: Point
[]*types.TypeDescriptor{types.SInt64TypeDescriptor, types.SInt64TypeDescriptor},
)
x, y := int64(3), int64(4)
var result Point
_, _ = ffi.CallFunction(&cif, makePointFn,
unsafe.Pointer(&result), // buffer for struct return value
[]unsafe.Pointer{unsafe.Pointer(&x), unsafe.Pointer(&y)},
)
// result.X == 3, result.Y == 4
// Pass struct as argument:
var distCif types.CallInterface
ffi.PrepareCallInterface(&distCif, types.DefaultCall,
types.SInt64TypeDescriptor,
[]*types.TypeDescriptor{pointType, pointType}, // two Point args
)
a := Point{X: 0, Y: 0}
b := Point{X: 3, Y: 4}
var dist int64
_, _ = ffi.CallFunction(&distCif, distFn,
unsafe.Pointer(&dist),
[]unsafe.Pointer{unsafe.Pointer(&a), unsafe.Pointer(&b)}, // pointer to struct data
)
// dist == 25
Structs >16 bytes are returned via hidden pointer (sret) — goffi handles this transparently.
Note: On Windows AMD64, struct arguments containing float fields are not supported due to
syscall.SyscallNlimitations. Use integer-only structs for cross-platform code, or pass float fields as individual arguments.
See examples/struct/ for a complete working example with compile-and-run.
Every avalue[i] must be a pointer TO the argument value. GoFFI dereferences it to read
the actual value passed to C. This follows the libffi convention.
For most cases this is straightforward — unsafe.Pointer(&x) for scalars, unsafe.Pointer(&buf) for
pointers. The non-obvious case is out-pointer parameters (where C writes a result through a pointer):
// C: int get_device(uint32_t index, void **device)
// The function writes *device — an out-pointer parameter.
// WRONG — C receives NULL (GoFFI reads the VALUE of device, which is nil):
var device unsafe.Pointer
_, _ = ffi.CallFunction(&cif, fn, unsafe.Pointer(&ret),
[]unsafe.Pointer{unsafe.Pointer(&index), unsafe.Pointer(&device)})
// CORRECT — C receives &device (GoFFI reads the VALUE of devicePtr, which is &device):
var device unsafe.Pointer
devicePtr := unsafe.Pointer(&device)
_, _ = ffi.CallFunction(&cif, fn, unsafe.Pointer(&ret),
[]unsafe.Pointer{unsafe.Pointer(&index), unsafe.Pointer(&devicePtr)})
// After call: device holds the handle written by C
Rule of thumb: if the C parameter is T *out (C writes through it), you need an intermediate
outPtr := unsafe.Pointer(&out) and pass unsafe.Pointer(&outPtr) in avalue.
FFI overhead: 88–114 ns/op (Windows AMD64, Intel i7-1255U)
| Benchmark | Time | Allocations |
|---|---|---|
Empty function (getpid) | 88 ns | 0 allocs (steady state) |
Integer argument (abs) | 114 ns | 0 allocs (steady state) |
String processing (strlen) | 98 ns | 0 allocs (steady state) |
syscallArgs is heap-allocated via sync.Pool for callback safety (goroutine stack may move during C→Go callbacks). Pool reuse gives 0 allocs/op in steady state.
At 60 FPS with ~50 FFI calls per frame, overhead is 5 µs per frame — 0.03% of the 16.6 ms budget. Unmeasurable in profiling.
See docs/PERFORMANCE.md for detailed analysis, optimization strategies, and when NOT to use goffi.
goffi transitions from Go's managed runtime to C code through three layers:
Go Code
│ ffi.CallFunction()
▼
runtime.cgocall ← Go runtime: system stack switch, GC coordination
│
▼
Assembly Wrapper ← Hand-written: load GP/SSE registers per ABI
│ CALL target_function
▼
C Function ← External library
Three ABIs, hand-written assembly for each:
| ABI | GP Registers | FP Registers | Notes |
|---|---|---|---|
| System V AMD64 | RDI, RSI, RDX, RCX, R8, R9 | XMM0–XMM7 | Linux, macOS, FreeBSD |
| Win64 | RCX, RDX, R8, R9 | XMM0–XMM3 | 32-byte shadow space mandatory |
| AAPCS64 | X0–X7 | D0–D7 | HFA support for ARM64 |
See docs/ARCHITECTURE.md for the full technical deep dive.
WebGPU fires async callbacks from internal Metal/Vulkan threads. These threads have no goroutine — calling Go directly would crash.
goffi uses crosscall2 for safe C→Go transitions from any thread:
cb := ffi.NewCallback(func(status uint32, adapter uintptr, msg uintptr, ud uintptr) {
// Safe even when called from a C thread
result.handle = adapter
close(done)
})
_, _ = ffi.CallFunction(cif, wgpuRequestAdapter, nil, args)
<-done // Wait for GPU driver callback
2000 pre-compiled trampoline entries per process. AMD64: 5 bytes/entry. ARM64: 8 bytes/entry.
Five typed error types for precise diagnostics:
handle, err := ffi.LoadLibrary("nonexistent.dll")
if err != nil {
var libErr *ffi.LibraryError
if errors.As(err, &libErr) {
fmt.Printf("Failed to %s %q: %v\n", libErr.Operation, libErr.Name, libErr.Err)
}
}
| Error Type | When |
|---|---|
InvalidCallInterfaceError | CIF preparation failures |
LibraryError | Library loading / symbol lookup |
CallingConventionError | Unsupported calling convention |
TypeValidationError | Invalid type descriptor |
UnsupportedPlatformError | Platform not supported |
| Feature | goffi | purego | CGO |
|---|---|---|---|
| C compiler required | No | No | Yes |
| API style | libffi-like (prepare once, call many) | reflect-based (RegisterFunc) | Native |
| Per-call allocations | Zero (CIF reusable) | reflect + sync.Pool per call | Zero |
| Struct pass/return | Full (RAX+RDX, sret) | Partial (no Windows structs) | Full |
| Callback struct args | ≤8B, 9-16B, >16B (AMD64) | Not supported (panic) | Full |
| Callback float args/returns | XMM0 / D0 (Unix amd64, arm64; Windows callbacks are uintptr-only) | Args yes, returns not supported (panic) | Full |
| ARM64 HFA detection | Recursive (nested structs) | Partial (bug in nested path) | Full |
| Typed errors | 5 types + errors.As() | Generic | N/A |
| Context support | Timeouts/cancellation | No | No |
| C-thread callbacks | crosscall2 | crosscall2 | Full |
| String/bool/slice args | Raw pointers only | Auto-marshaling | Full |
| Platform breadth | 8 desktop targets + Android preview | 8 GOARCH / 20+ OS×ARCH | All |
| AMD64 overhead | 88–114 ns | Not published | ~140 ns (Go 1.26 claims ~30% reduction) |
Choose goffi for GPU/real-time workloads: struct passing, zero per-call overhead, callback float returns, typed errors.
Choose purego for general-purpose bindings: string auto-marshaling, broad architecture support, less boilerplate.
See also:
purego implemented entirely on top of goffi. It resolves fakecgo linker conflicts without build tags, correctly handles macOS ARM64 variadic stack-packing ABI requirements under the hood, and provides a zero-code-change migration path.Linux: default builds are dynamically linked (#74)
libdl/libc via cgo_import_dynamic even with CGO_ENABLED=0. Use -tags goffi_static for a fully static ELF (no runtime .so loading), or build against musl for Alpine. See Linking modes.Windows: C++ exceptions may crash the program (#12516)
panic=abort.Apple ARM64: variadic args always go on stack
PrepareVariadicCallInterface (not PrepareCallInterface) for variadic C functions on all platforms — goffi handles the Darwin-specific register flush automatically.Struct packing follows System V ABI only
#pragma pack not honored. Manually specify Size/Alignment in TypeDescriptor.No bitfields in struct types.
Unix: duplicate symbol conflict with purego (#22)
CGO_ENABLED=0, the linker reports duplicated definition of symbol _cgo_init. Both libraries include internal/fakecgo which defines identical runtime symbols.-tags nofakecgo to disable goffi's fakecgo, relying on purego's copy:
CGO_ENABLED=0 go build -tags nofakecgo ./...
CGO_ENABLED=1. In cgo mode the real runtime/cgo supplies the symbols and both libraries' fakecgo packages are gated out by //go:build !cgo.pureffi is a 1:1, drop-in API-compatible wrapper over goffi that acts as a transparent replacement for purego via Go's replace directive. It completely eliminates the need for compilation tags, resolves the duplicate symbol conflict, and natively fixes Apple Silicon variadic ABI stack-packing.| Platform | Arch | ABI | Verification |
|---|---|---|---|
| Windows | amd64 | Win64 | CI: tests on a native runner |
| Windows | arm64 | AAPCS64 | CI: tests on a native runner |
| Linux | amd64 | System V | CI: tests on a native runner |
| Linux | arm64 | AAPCS64 | CI: tests on a native runner |
| macOS | amd64 | System V | CI: tests on a native runner |
| macOS | arm64 | AAPCS64 | CI: tests on a native runner |
| FreeBSD | amd64 | System V | CI: tests in FreeBSD 14 and 15 VMs |
| FreeBSD | arm64 | AAPCS64 | CI: cross-compiled build and test build (tests not run yet) |
| Android | arm64 | AAPCS64 (Bionic) | Guarded preview (API 29+), cross-compiled in CI |
Planned work: ROADMAP.md and the milestones. What each release changed: CHANGELOG.md.
go test ./... # all tests
go test -cover ./... # with coverage (89%)
go test -bench=. -benchmem ./ffi # benchmarks
go test -v ./ffi # verbose, auto-detects platform
| Document | Description |
|---|---|
| docs/ARCHITECTURE.md | Technical architecture: assembly, ABIs, callbacks, linking |
| docs/PERFORMANCE.md | Benchmarks, optimization strategies, Go 1.26 |
| docs/ANDROID.md | Android ARM64 preview ABI + build notes |
| docs/ADR-001-userspace-elf-loader.md | Research: optional pure-Go .so loader |
| CHANGELOG.md | Version history, migration guides |
| ROADMAP.md | Development roadmap |
| CONTRIBUTING.md | Contribution guidelines |
| SECURITY.md | Security policy |
| examples/ | Working code examples (simple, struct) |
See CONTRIBUTING.md for guidelines.
feat:, fix:, docs:, test:crosscall2 callback mechanism, fakecgo approach, and assembly trampoline patterns were pioneered by purego. goffi exists because purego cleared the path.runtime.cgocall for GC-safe stack switching, crosscall2 for C→Go transitions.goffi powers an ecosystem of pure Go GPU libraries:
| Project | Description |
|---|---|
| go-webgpu/webgpu | Zero-CGO WebGPU bindings (wgpu-native) |
| born-ml/born | ML framework for Go, GPU-accelerated |
| gogpu | GPU computing platform — dual Rust + Pure Go backends |
| wgpu-native | Native WebGPU implementation (upstream) |
MIT — see LICENSE.
GitHub | pkg.go.dev | Dev.to
165 followers · starred May 2026
107 followers · starred Oct 2026
142 followers · starred Jul 2026
11 followers · starred Apr 2026