feat(function): add onnx_run builtin to evaluate ONNX models from SQL#26053
feat(function): add onnx_run builtin to evaluate ONNX models from SQL#26053fengttt wants to merge 10 commits into
Conversation
Add onnx_run(model, input, input_shape, output_shape), a builtin that
runs an ONNX model over each row and returns the output as JSON.
- model: varbinary bytes, or a datalink to a model file in a stage
- input: JSON flat array for the input tensor
- input_shape / output_shape: JSON '{"dim":[...],"dtype":"float32"}';
a NULL output_shape returns non-tensor outputs (sequence/map), keyed
by output name (e.g. sklearn ZipMap probabilities)
Implementation:
- pkg/mlai/onnx isolates all onnxruntime_go usage (cgo-free; the library
is dlopen'd at runtime). The environment is initialized lazily and at
most once; if the shared library cannot be loaded, every onnx_run call
fails cleanly while the database still starts normally.
- The onnxruntime shared library (CPU, v1.26.0) is fetched per-platform
by the thirdparties/Makefile onnxruntime target into
thirdparties/install/lib and resolved beside mo-service at runtime.
- The operator caches one onnxruntime session per expression instance
and reuses it across rows, closing it when the expression is released
(the serial/serial_full newOpWithFree lifecycle).
- Supported dtypes: int8/16/32/64, uint variants, float32/64, bool, and
float16 (via CustomDataTensor + IEEE-754 half conversion).
Tests: pkg/mlai/onnx unit tests plus a BVT case
(test/distributed/cases/function/onnx_run) covering sum_and_difference,
non_tensor_outputs (sklearn random forest on iris), per-row inference
over the checked-in iris dataset, mnist / mnist_float16, and error paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
Review — focus: error handling & efficiencyOverall: sound architecture (lazy fail-safe init, serial-style session lifecycle, clean layer separation). Two real error-handling bugs, two worthwhile efficiency items, and one test-coverage gap that hides bug 2. 🔴 High — error handling1. Pre-allocated output tensor leaks when The cleanup outputs[0] = outTensor // C-allocated
if err := s.s.Run(...); err != nil {
return nil, moerr... // <- outputs[0] leaks
}
defer func() { for _, o := range outputs { o.Destroy() } }() // too lateEvery failed inference with a declared 2. Integer input conversion silently swallows errors and truncates — v, _ := strconv.ParseInt(string(num), 10, 64) // error discarded
out[i] = T(v) // unchecked narrowingTwo silent-wrong-answer paths for all 8 integer dtypes:
The float path correctly propagates via 3. (Low) Trailing garbage after the input array is ignored —
🟡 Medium — efficiency4. Triple JSON serialization per row
5. Per-row constant re-work in the eval loop —
6. (Nit) Model parsed twice per session build —
Test coverage
VerdictFix 1 and 2 before merge (both are silent-failure bugs: a C-memory leak and wrong query results), plus the integer-dtype test. 4/5 are worthwhile in-PR follow-ups. |
|
Pushed 25de48f addressing all review findings:
Verification: all unit tests pass (8/8, incl. 3 new); BVT regenerated and passes 15/15; diff of the |
Error handling:
- Fix a C-memory leak in Session.Run: the pre-allocated output tensor was
destroyed by a defer registered after the Run error return, so every
failed inference with a declared output_shape leaked one ORT tensor.
The cleanup defer is now registered before Run.
- Integer input conversion no longer swallows errors: non-integer input
for an int dtype (e.g. 1.5 as int32) and out-of-range values (e.g. 300
as int8) now return clear errors instead of silently producing 0 or a
wrapped value. Parsing uses the declared bit width per dtype.
- Reject trailing garbage after the input json array ("[1,2]junk").
Efficiency:
- Eliminate the triple json serialization per row: Session.Run now
returns a value tree of ByteJson-native scalars (int64/uint64/float64/
bool/string; float32 widened via its shortest decimal repr so rendered
output is unchanged), and the operator builds the T_json result with
bytejson.CreateByteJSON directly - one encoding pass instead of
marshal -> parse -> marshal. NaN/Inf still error as before.
- Cache parsed input/output shapes across rows (they are almost always
constant literals) instead of re-parsing json per row.
- Skip the per-row O(model size) bytes.Equal and the model-argument copy
when the model vector is const (the common datalink-literal case).
Tests:
- New unit tests: integer dtype validation (valid / non-integer /
out-of-range / negative-for-unsigned), trailing-garbage rejection,
corrupt model bytes.
- New BVT error cases for int32 non-integer input and int8 out-of-range
input; all previous BVT output verified byte-identical (15/15 pass).
Review: matrixorigin#26053 (comment)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the SCA (gofmt) failure on CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve function id conflict: upstream took 549-553 (JSON_OVERLAPS + narrow-vector base64 decoders); ONNX_RUN renumbered 549 -> 554, FUNCTION_END_NUMBER 555. onnx_run is referenced by name only, so the renumber is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gouhongshen
left a comment
There was a problem hiding this comment.
Codex automated review
The prior review findings are addressed, but four blocking resource/supply-chain issues remain.
P1 - Make inference observe query cancellation (pkg/mlai/onnx/session.go:124)
Run calls the synchronous s.s.Run API without RunOptions and Session.Run receives no context, so proc.Ctx cancellation never reaches an in-flight model. A model containing a very large Loop or otherwise expensive graph continues occupying the executor and ONNX worker threads after KILL, timeout, or client disconnect. Use RunWithOptions and terminate the run when the query context is canceled, with synchronization that cannot race session destruction.
P1 - Bound peak conversion memory rather than only raw tensor bytes (pkg/mlai/onnx/helpers.go:56)
MaxTensorBytes limits only elementCount*dtypeWidth. An accepted 64 MiB int8/bool tensor contains 67,108,864 elements, after which intsToAny/boolsToAny allocates a []any of that length—at least 1 GiB on 64-bit Go—before ByteJSON adds another large allocation. Input decoding has similar amplification through []json.Number. These allocations are outside the query mpool, so one valid call can still OOM the CN despite the new cap. The limit must account for the complete peak representation or the conversion must stream/encode directly with query-memory accounting.
P1 - Apply the tensor bound to auto-allocated outputs (pkg/mlai/onnx/session.go:124)
When output_shape is NULL, outputs consists entirely of nil slots. DynamicAdvancedSession.Run therefore asks ONNX Runtime to allocate every model output with no size validation or query-memory accounting; ParseShape/MaxTensorBytes is never involved. A small model can generate an arbitrarily large tensor or sequence and exhaust the CN before valueToJSON can inspect it. NULL output_shape is required for the advertised sequence/map path, so this needs an enforceable runtime/output-memory bound rather than a post-allocation length check.
P1 - Pin and verify the downloaded ONNX Runtime binary (thirdparties/Makefile:169)
Every clean build downloads a native shared library from the mutable matrixorigin/thirdparty main branch and installs it without checking a digest. The same MatrixOne commit can therefore silently produce different binaries, and a changed or compromised mirror artifact becomes native code loaded into every CN. Pin an immutable source revision and verify an architecture-specific SHA-256 before installing the library.
Address the second review round: - thirdparties/Makefile: the onnxruntime download is now pinned to an immutable matrixorigin/thirdparty commit and verified against a per-platform SHA-256 before install, so the same MatrixOne commit always yields the same native library and a changed mirror artifact fails the build (verified: tampered hash aborts, nothing installed). - Auto-allocated outputs (NULL output_shape) never pass through ParseShape, so the raw-tensor-byte cap is now also applied in anyTensorFlat before conversion (conservative single check at MaxTensorBytes/8 elements), with a regression test. Reviewed and declined by maintainer decision: - query-cancellation plumbing for in-flight inference (not necessary for the small CPU models this targets), and - peak conversion-memory accounting beyond the raw tensor byte caps (raw tensor bytes is good enough). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@gouhongshen response to the second review round (fixes in c0172fd): P1 — pin and verify the downloaded runtime: fixed. The Makefile now downloads from an immutable P1 — apply the tensor bound to auto-allocated outputs: fixed. P1 — query cancellation for in-flight inference: declined (maintainer decision). Not necessary for the small, fast CPU models this feature targets; the plumbing (RunOptions/Terminate watcher per row) isn't warranted at this point. Can be revisited if long-running models become a real use case. P1 — bound peak conversion memory: declined (maintainer decision). The raw tensor byte caps (64 MB per tensor, on declared shapes, model outputs, and the 1 GiB datalink model cap) are the intended bound; conversion-layer accounting beyond that is not needed for tensors this size. |
There was a problem hiding this comment.
Codex automated review
History assessment at c0172fdc5b: the runtime pin/checksum, volatility, and earlier error-handling findings are fixed. The new auto-output check limits Go-side conversion after inference, but it does not bound ONNX Runtime allocation or intermediate tensors. The author explicitly declined cancellation and conversion-memory changes as maintainer decisions; both remain blocking because the SQL API accepts arbitrary model bytes and the intended small-model usage is not enforced by a permission, model constraint, execution limit, or memory boundary.
P1 - Enforce a memory limit during ONNX execution (pkg/mlai/onnx/session.go:124)
Author response: The new anyTensorFlat element check is intended to cap model-produced outputs before conversion; the response also acknowledges that the C-side allocation occurs inside ONNX Runtime and cannot be pre-bounded through the current Go binding.
Why this remains blocking: That check runs only after DynamicAdvancedSession.Run returns. By then ONNX Runtime has already allocated the output, and the binding has copied automatic tensor data into Go memory. Model intermediates never pass through anyTensorFlat at all. An arbitrary uploaded model can therefore use Expand, ConstantOfShape, or a large intermediate followed by a small reduction to exhaust CN memory before the new check executes. The intended small-model scope is not enforced by the API.
To close this finding, enforce or query-account a memory limit during ORT execution, or introduce an enforced trust/model contract that rejects graphs capable of unbounded allocation before Run. A post-run output check alone cannot close the allocation path.
P1 - Propagate query cancellation into inference (pkg/sql/plan/function/func_builtin_onnx.go:221)
Author response: Cancellation plumbing was declined because this feature targets small, fast CPU models and a per-row RunOptions/termination watcher was considered unwarranted.
Why this remains blocking: The implemented SQL function accepts arbitrary model bytes and does not enforce a complexity, operator, duration, or privilege boundary corresponding to that intended usage. proc.Ctx is discarded before synchronous ORT execution, so a model containing a large Loop can continue consuming executor and ORT worker resources after query timeout, KILL, or client disconnect.
To close this finding, propagate cancellation through RunWithOptions/RunOptions.Terminate with lifecycle synchronization, or enforce a concrete model/execution boundary that makes long-running inference unreachable.
P1 - Bound conversion peak rather than only raw tensor bytes (pkg/mlai/onnx/helpers.go:56)
Author response: Conversion-memory work was declined because the 64 MiB raw-tensor cap is the intended bound and was considered sufficient for supported tensor sizes.
Why this remains blocking: Raw bytes are not the peak representation. A valid 64 MiB bool or int8 output can contain 67,108,864 elements; converting it to []any allocates roughly 1 GiB for the interface slice alone on a 64-bit CN, before reshape structures, ByteJSON construction, serialization copies, and the final mpool-backed result. The new MaxTensorBytes/8 check protects only the NULL-output_shape path; declared narrow outputs still reach this amplification path.
To close this finding, cap element count according to peak conversion/serialization cost, encode without the []any expansion, or account the complete conversion against query memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
UT failure on the last run was a data race in |
XuPeng-SH
left a comment
There was a problem hiding this comment.
Deep review of exact head 325242048126e1f93914bdc243956d69251ba0bf.
The normal execution/lifecycle paths look solid, and I verified the ONNX package, its race suite, the focused SQL-function race suite, the full SQL-function package, build, and vet with the pinned ORT 1.26 runtime. However, three P1 resource-control paths remain open, so I cannot approve this SQL surface yet.
-
Bound memory during ORT execution, not after it (
pkg/mlai/onnx/session.go:124,pkg/mlai/onnx/helpers.go:135). The NULL-shape guard runs only afterDynamicAdvancedSession.Runreturns. In the exactonnxruntime_gov1.31.0 dependency, auto-allocated outputs are also copied from ORT memory into Go tensors beforeRunWithOptionsreturns. A model can therefore allocate an oversized output, or a large intermediate followed by a small output, before this check is reachable. Since arbitrary model bytes are accepted and there is no trust/privilege/operator-complexity boundary, this remains a CN OOM path. Please enforce/account a runtime memory limit, or add an enforced trust/model contract that makes unbounded graphs unreachable. -
Propagate query cancellation into inference (
pkg/sql/plan/function/func_builtin_onnx.go:221).proc.Ctxis not passed toSession.Run; a model with a largeLoopcan keep the executor and ORT workers busy after query timeout, KILL, or disconnect. The pinned binding already exposesRunWithOptionsplusRunOptions.Terminate. Please add lifecycle-safe cancellation, or enforce a concrete execution/model boundary that makes long-running inference unreachable. -
Cap peak conversion cost for declared narrow outputs (
pkg/mlai/onnx/output.go:25,pkg/mlai/onnx/helpers.go:55).ParseShapepermits 64 MiB ofint8/bool, i.e. 67,108,864 elements.flatDataexpands that to[]any, whose interface backing array alone is roughly 1 GiB on a 64-bit CN, before nested reshape slices, ByteJSON construction, marshal copying, and the mpool-backed result. TheMaxTensorBytes/8element guard applies only to NULLoutput_shape, so the declared-output path still allows this amplification. Please cap by complete conversion/serialization peak, stream/encode without[]any, or account it against query memory.
The intended “small, fast CPU models” scope is reasonable, but it must be enforced in code before it can serve as the safety boundary for a generally callable SQL function.
gouhongshen
left a comment
There was a problem hiding this comment.
Codex automated review
The clean merge since c017 adds no authored ONNX delta. Fixed: tensor cleanup, integer/trailing-JSON validation, declared-shape and datalink pre-read bounds, volatility, and runtime pin/checksum. Withdrawn: none. No previously blocking risk is demonstrably non-blocking: the author declined execution-memory, cancellation, and conversion-peak controls, and all remain reachable.
P1 - ONNX Runtime allocations remain unbounded during Run (pkg/mlai/onnx/session.go:124)
Author response: the post-run anyTensorFlat check was presented as the auto-output bound, while acknowledging that ORT allocates before that check; the intended small, fast CPU-model scope was used to decline further controls.
Why this remains blocking: with NULL output_shape, outputs contains nil slots and line 124 calls DynamicAdvancedSession.Run. The pinned binding first lets ORT allocate output values, then copies non-string tensors into a Go slice before Run returns; only afterwards does this code reach valueToJSON/anyTensorFlat. Thus an Expand/ConstantOfShape output can exhaust native and Go memory before the new check executes. A large intermediate followed by a small result never reaches that check at all. Small arbitrary ONNX models are accepted through the varbinary overload, with no enforced model-complexity, privilege, or execution-memory boundary. Enforce/account a memory limit during ORT execution, or enforce a model trust/validation contract that rules out such allocations.
P1 - Query cancellation never reaches ONNX execution (pkg/sql/plan/function/func_builtin_onnx.go:221)
Author response: cancellation plumbing was declined because the feature is intended for small, fast CPU models.
Why this remains blocking: line 221 invokes Session.Run without proc.Ctx; Session.Run then invokes the synchronous s.s.Run API. A query timeout, KILL, or disconnect therefore cannot interrupt a valid model containing a large Loop, leaving the executor and ORT workers occupied. The API accepts arbitrary model bytes and enforces no duration, operator, or permission boundary corresponding to the intended usage. The pinned binding exposes RunWithOptions and RunOptions.Terminate. Propagate proc.Ctx cancellation through those options, with synchronization against Session.Close/Reset, or add an enforced execution/model boundary.
P1 - 64 MiB narrow outputs expand to multi-GiB Go allocations (pkg/mlai/onnx/helpers.go:55)
Author response: the author declined conversion-memory accounting, treating the 64 MiB raw-tensor cap as sufficient.
Why this remains blocking: ParseShape deliberately accepts 67,108,864 int8 elements at the cap. For a declared int8/bool output of that shape, tensorToNested reaches intsToAny/boolsToAny, whose make([]any, len(s)) allocates at least 1 GiB for the interface backing array on this 64-bit target before reshaping, ByteJSON construction, marshaling, and the result vector. These allocations are on the Go heap, not proc.Mp; ByteJSON then allocates its own per-element representation. A small model can Expand a scalar to this output, so the input need not be large. Cap element count by complete conversion/result cost, encode directly with query-memory accounting, or otherwise enforce a peak-memory bound.
What
Adds a builtin scalar function
onnx_runthat evaluates an ONNX model over each row and returns the output as JSON.varbinarymodel bytes, or adatalinkto a model file in a stage.'{"dim":[1,1,4],"dtype":"float32"}'. A NULLoutput_shapemeans the output is not a plain tensor: the model's outputs (sequence/map, e.g. sklearn ZipMap probabilities) are returned as a JSON object keyed by output name.Example:
How
pkg/mlai/onnxisolates allonnxruntime_gousage. It is cgo-free — the onnxruntime shared library isdlopen'd at runtime. The environment is initialized lazily and at most once; if the library cannot be loaded, everyonnx_runcall fails cleanly while the database still starts normally.thirdparties/Makefileonnxruntimetarget intothirdparties/install/lib, and resolved at runtime via$MO_ONNXRUNTIME_LIB, besidemo-service, or underthirdparties/install/lib. GPU is intentionally out of scope for now.func_builtin_onnx.go) caches one onnxruntime session per expression instance and reuses it across rows, closing it when the expression is released — the samenewOpWithFreelifecycle asserial/serial_full.int8/16/32/64,uintvariants,float32/64,bool, andfloat16(viaCustomDataTensor+ IEEE-754 half conversion).Tests
pkg/mlai/onnx(shape/dtype parsing, half conversion, and real inference on the checked-in models).test/distributed/cases/function/onnx_run— passes 13/13 — covering:sum_and_difference(both the datalink and varbinary overloads),non_tensor_outputs(sklearn random forest, NULLoutput_shape),json_array; predictions match labels),mnistandmnist_float16(bonus),test/distributed/resources/onnx/(small files; the generating Python scripts are intentionally not included).Notes
pkg/mlai/onnx.NewSessionseam leaves room for a later GPU (CUDA execution provider) path behind a config flag.🤖 Generated with Claude Code