ADR 0010: ONNX Runtime is loaded with purego, not a second cgo package
- Status: accepted (WP-71)
- Date: 2026-09-26
Context
Section titled “Context”Face and object detection (WP-71, ROADMAP open question 7: YuNet, MIT, and YOLOX-s, Apache-2.0)
runs ONNX models on ONNX Runtime (MIT), which the full base image ships as the official
prebuilt libonnxruntime.so.1 (1.30.0, WP-70). The slim image has no ONNX Runtime and must
keep building, starting and passing its tests, and CGO_ENABLED=0 builds of the module must
keep compiling.
Non-negotiable 3 (CLAUDE.md) says internal/vips is the only package that imports "C", and
scripts/check-cgo.sh enforces it. Linking ONNX Runtime through cgo would need a second cgo
package behind a build tag and an amendment to that rule, plus a link-time dependency that the
slim image cannot satisfy (a libonnxruntime build tag and two binaries).
The ONNX Runtime C API is a single exported symbol, OrtGetApiBase, returning a struct whose
GetApi(version) hands out a table of function pointers (OrtApi). Every call we need takes and
returns pointers, integers and C strings: no struct is passed by value and no callback is
required. github.com/ebitengine/purego (Apache-2.0, v0.11.1 released 2026-09-18, no
dependencies) dlopens a library and binds C function pointers to typed Go functions
(RegisterFunc) without cgo.
Spike (2026-09-26)
Section titled “Spike (2026-09-26)”internal/detect/ort binds the 41 OrtApi entries it uses with purego.RegisterFunc. The
table indices (offsetof(OrtApi, f) / sizeof(void*)) were printed by a C program compiled
against onnxruntime_c_api.h 1.30 in the full builder; ORT only appends to the table, so they
hold for every version from the one requested (17) up. In the full builder, under
go test -race:
Load("libonnxruntime.so.1")reports version 1.30.0; a missing path returnsErrUnavailablewithout crashing.- Sessions for both shipped models report their real shapes: YOLOX-s input
[1,3,640,640], outputoutput [1,8400,85]; YuNet 2023mar input[1,3,640,640](fixed, not dynamic), outputscls/obj/bbox/kps_{8,16,32}with 6400/1600/400 anchors. - A zero tensor created over a
vips.Buffer(C memory fromvips_tracked_malloc) runs through both models; a wrong shape comes back as an ORT status (INVALID_ARGUMENT), not a crash. - End to end, detections match OpenCV’s
FaceDetectorYNand the official YOLOX demo post-processing on the fixtures with IoU 0.98–1.0, and 200 detections leave libvips’ tracked memory, live objects and the process RSS flat.
No blocker was found, so the cgo fallback (a build-tagged internal/detect/ort cgo package with
an amendment to rule 3) is not needed.
Decision
Section titled “Decision”internal/detect/ortloads ONNX Runtime with purego:purego.DlopenofXI_DETECT_LIBRARY(defaultlibonnxruntime.so.1) at startup, wheninternal/appcallsdetect.New(never at init), thenOrtGetApiBase()->GetApi(17)andRegisterFuncper table entry. The library is neverdlclosed.- Non-negotiable 3 stands unchanged:
internal/vipsstays the only package that imports"C";scripts/check-cgo.shkeeps passing. purego is confined tointernal/detect/ortby a depguard rule (purego-only-in-ort). - The purego implementation builds only with
cgo && (linux || darwin): with cgo, purego uses the runtime’s owndlopenand thread setup instead of itsfakecgoshim. Other builds (includingCGO_ENABLED=0) compile a stub whoseLoadreturnsErrUnavailable. - Memory rule 6 holds: ORT never keeps a pointer to Go memory. Input tensors are created over
C memory (
vips.Buffer); names are ORT-allocated C strings kept per session; shapes, name arrays and out-parameters are Go memory ORT reads or writes only during the call (the cgo rules for transient pointers). Outputs are ORT-owned and released after decoding. - On any image without the library or the models (slim),
detect.Newreturnsdetect.ErrUnavailable(kindUnsupported); withXI_DETECT_ENABLED=auto(the default) the app logs it and runs without detection, withtruestartup fails.
Consequences
Section titled “Consequences”- One binary for both images; no build tag and no link-time dependency on ONNX Runtime.
- The C API signatures are declared by hand in Go, with no compiler check against the header.
Mitigations: the indices come from
offsetofon the real header (the method is recorded inort_purego.go); the session tests assert the shipped models’ input and output shapes, so a wrong index or signature fails CI in the full builder; the table is append-only upstream. RegisterFuncdispatch goes through reflection (a few microseconds per call); a detection makes about ten calls against 20–400 ms of inference.- New dependency:
github.com/ebitengine/puregov0.11.1, Apache-2.0, credited inNOTICE(go-standards §10).