Skip to content

purego: avoid reflection for uintptr callbacks - #562

Open
Asutorufa wants to merge 5 commits into
ebitengine:mainfrom
yuhaiin:codex/optimize-callback-dispatch
Open

Asutorufa wants to merge 5 commits into
ebitengine:mainfrom
yuhaiin:codex/optimize-callback-dispatch

Conversation

@Asutorufa

@Asutorufa Asutorufa commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

What issue is this addressing?

Unix NewCallback currently constructs reflection arguments and calls
reflect.Value.Call on every invocation. For callbacks called repeatedly from C,
this introduces allocation and dispatch costs even when the Go signature consists
entirely of machine words.

What type of issue is this addressing?

Performance.

What this PR does | solves

Select a typed invoker once when registering an unnamed Go function with zero
through nine uintptr arguments and a single uintptr result. Such callbacks
read full-width integer slots from the existing saved C argument frame and call
the original Go function directly through an inlinable word accessor. No Go
function type is reinterpreted, and no argument scratch array is needed.

Publish one immutable invoker per callback through an atomic pointer. Slot
allocation remains serialized at registration; dispatch only loads and invokes
the published function. Reflection callbacks are wrapped at registration so the
hot path needs neither a mutex nor a path-selection branch. Both dispatch paths
share integer-register addressing, and the unused intFrame hooks are removed.

The existing reflection path handles every other signature, including named
function/argument types, pointers, floating-point values, narrow integers,
CDecl, void results, and larger arities. Windows continues to use the runtime's
callback implementation. Public API behavior and callback slot limits remain
unchanged.

Add native C-to-Go tests for all specialized arities, high-bit argument/return
values, register/stack boundaries, allocation growth, foreign pthreads, GC,
concurrent registration/nested callbacks, and fallback signatures. Allocation
coverage compares 0 and 64 iterations and requires no growth. Test callbacks are
cached for the process lifetime so repeated runs do not consume more slots.
The new C helpers live in a separate pthread-linked fixture, also prebuilt for
Android; existing fixture sources and builder flags remain unchanged. Batched
serial and parallel benchmarks amortize outgoing FFI costs over 256 callbacks.

Benchmarks

Linux amd64, AMD Ryzen 5 5600G, Go 1.27.3, CGO_ENABLED=0,
GOMAXPROCS=1, pinned to CPU 2; medians of six 200ms samples. Baseline is
ebitengine/purego main at 7fd5298cee0ac33e76d0e40c2ce6fcf066d6a2fa,
with only the new C fixture and benchmark copied into that checkout.

ns/callback = elapsed ns / (benchmark operations * 256). The table takes
medians of the reported ns/callback samples, then rounds to two decimal places
using decimal rounding (ties up). Time reduction is
(before median - after median) / before median * 100%, calculated before rounding.
The parallel table below uses the same median and rounding rules.

uintptr arguments Before ns/callback After ns/callback Time reduction
0 208.45 52.59 74.8%
1 270.45 52.88 80.4%
2 335.60 54.82 83.7%
3 385.30 56.20 85.4%
4 438.75 56.67 87.1%
5 481.55 59.38 87.7%
6 525.15 56.88 89.2%
7 568.50 57.08 90.0%
8 633.10 58.51 90.8%
9 667.65 60.29 91.0%

Each benchmark operation invokes 256 callbacks from one C loop. Allocations per
operation fall from 517 (0/1 arguments) or 773 (2–9 arguments) to 5. The remaining
5 allocations belong to the one outgoing RegisterFunc call, rather than each
C-to-Go callback. The callback body itself performs no heap allocation.

Existing int64 callback benchmarks remain on reflection, with unchanged
allocation counts. Median time changes in the same six-sample setup were -2.7%
(1 argument), -0.5% (2), +4.7% (3), -0.6% (5), -1.0% (10), +0.5% (14), and
+1.5% (15). These local samples do not establish cross-platform timings.

CGO_ENABLED=0 GOMAXPROCS=1 taskset -c 2 go test \
  -run '^$' -bench '^BenchmarkWordCallback$' \
  -benchtime=200ms -count=6 -benchmem .

The parallel benchmark below compares an earlier revision of this PR, 03ef423,
with the revised implementation using the same new test fixture and benchmark in both checkouts.
Same host and Go version; six 200ms samples, pinned to logical CPUs 2–9.
Numbers express aggregate throughput across workers, not individual latency.

GOMAXPROCS Earlier revision ns/callback Revised ns/callback
1 63.54 58.91
2 42.23 29.45
4 38.80 14.87
8 61.58 9.72
CGO_ENABLED=0 taskset -c 2-9 go test \
  -run '^$' -bench '^BenchmarkWordCallbackParallel$' \
  -benchtime=200ms -count=6 -cpu=1,2,4,8 -benchmem .

Validation

This branch is rebased onto main after #563, which fixed the ARM trampoline
spacing separately. The word callback code now builds on illumos as well, with
the same build constraint as syscall_unix.go.

On the rebased head:

  • Complete Linux amd64 suite with CGO disabled (three shuffled repetitions) and
    with CGO and the race detector enabled (three shuffled repetitions of the root
    package).
  • Complete linux/arm suites under QEMU for ARMv7 and ARMv5, with and without Cgo,
    three shuffled repetitions each, built and run the same way as CI.
  • go vet for both CGO configurations, and with CGO disabled for every target
    CI cross-compiles: Linux 386/amd64/arm/arm64/loong64/ppc64le/riscv64/s390x,
    Darwin amd64/arm64, FreeBSD amd64/arm64, NetBSD amd64/arm64, illumos amd64, and
    Windows 386/amd64/arm64. Cross-compilation alone does not validate calling
    conventions.

On the earlier revision, before the rebase:

  • Complete Linux amd64 suite, including 16 shuffled repetitions with CGO
    disabled and two repetitions with race/cgocheck2 enabled.
  • Complete Linux 386 and minimum-version Go 1.25 suites with CGO disabled.
  • Targeted tests with checkptr and compiler optimization/inlining disabled.
  • 100 shuffled repetitions of the new callback tests.
  • Negative check: the allocation test detects the original reflection path's
    allocations.
  • Both C fixtures linked with -Wl,-z,defs, and the new callback tests executed
    through PUREGO_TEST_PREBUILT_LIBDIR.

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Claude (Claude Code) on behalf of hajimehoshi. All of these are suggestions, and none is a confirmed failure. The PR's code was not built or run for this review; the findings come from reading the code and the CI results.

Comment thread callback_test.go Resolved
Comment thread callback_arm_test.go Resolved
Comment thread callback_word_test.go Resolved
Comment thread callback_word_test.go Resolved
Comment thread callback_word.go Resolved
Comment thread callback_word.go Resolved
Comment thread syscall_unix.go Resolved
Comment thread syscall_unix.go Resolved
Comment thread testdata/libcbtest/callback_test.c Resolved
@hajimehoshi

Copy link
Copy Markdown
Member

Can you split this change into two PRs? 1) optimizing callbacks, and 2) the ARM trampoline fix. Thanks,

@Asutorufa

Copy link
Copy Markdown
Contributor Author

Can you split this change into two PRs? 1) optimizing callbacks, and 2) the ARM trampoline fix. Thanks,

Sure, I can split them.

I included the ARM fix because repeated callback registration during testing exposed a pre-existing ARMv5 trampoline bug. The assembler can insert literal pools when loading larger callback indices, breaking the assumed fixed entry size and causing an illegal instruction. Without that fix, the callback tests failed.

I'll submit the ARM fix with its regression test as a separate PR, then rebase the callback optimization once that fix is merged.

Select typed dispatch once when registering callbacks with zero through nine uintptr arguments and a uintptr result. Read arguments from the existing register and stack frames without casting Go function types or changing the assembly trampolines. Preserve reflection dispatch for all other signatures and the existing Windows runtime implementation.

Add native C callback tests for full-width values, register and stack arguments, foreign threads, GC, concurrent and nested calls, fallback signatures, and allocation growth. Add a batched C-to-Go callback benchmark so registration and outgoing FFI costs do not dominate measurements.
Store one immutable invoker per callback in an atomic slot and wrap reflection callbacks at registration. Read word arguments through an inlinable accessor and share integer-register addressing with reflection dispatch. Remove the unused intFrame hooks, whose implementations all returned nil.
Require zero allocation growth across native callbacks, and reuse callback slots across repeated tests. Exercise concurrent publication and measure parallel callback throughput. Move the pthread fixture into its own library and prebuild it separately for Android.
@Asutorufa
Asutorufa force-pushed the codex/optimize-callback-dispatch branch from ab71280 to 21c6c5e Compare November 7, 2026 08:16
syscall_unix.go builds on illumos since illumos/amd64 support was added,
but callback_word.go did not, so compileWordCallback was undefined there.
Use the same build constraint as syscall_unix.go, and run the word
callback tests on illumos like the other callback tests.
@Asutorufa Asutorufa changed the title Avoid reflection for uintptr callbacks and fix ARM trampoline spacing purego: avoid reflection for uintptr callbacks Nov 7, 2026
@Asutorufa

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #563 is merged. The ARM trampoline commit is dropped, so this PR now contains only the callback optimization:

  • 3c5299e purego: avoid reflection for uintptr callbacks
  • ce2d640 purego: publish callback invokers without a read lock
  • 21c6c5e purego: strengthen callback tests and isolate pthread fixtures
  • f2b936d purego: build word callbacks on illumos

The changes to the old callback_arm_test.go are gone, since main already covers them in callback_linux_arm_test.go. f2b936d is new: since the illumos/amd64 support in #555, syscall_unix.go builds on illumos, so callback_word.go now uses the same build constraint, and the word callback tests run there too.

I updated the title and description to match. CI passes: https://github.com/ebitengine/purego/actions/runs/42354983651

Comment thread callback_word_test.go Resolved
Put each field of the fallback callback test cases on its own line, and
expand the function literals whose bodies are statements, so they read
like the rest of the code.

@hajimehoshi hajimehoshi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@TotallyGamerJet PTAL

@TotallyGamerJet

Copy link
Copy Markdown
Collaborator

Do we want to do this? My thoughts are

  • this makes the code significantly different from Go's runtime version
  • it complicates the code quite a lot with new files
  • how important is the performance gain when Cgo-free is currently being worked on at Google?

@hajimehoshi

Copy link
Copy Markdown
Member

this makes the code significantly different from Go's runtime version

Which part are you talking about? The existing files are not changed so much.

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.

3 participants