Fix SIGSEGV: release prediction input arrays with the GIL held - #2827
Open
kasper0406 wants to merge 1 commit into
Open
Fix SIGSEGV: release prediction input arrays with the GIL held#2827kasper0406 wants to merge 1 commit into
kasper0406 wants to merge 1 commit into
Conversation
`PybindCompatibleArray` keeps the caller's numpy array alive by storing a
`py::array` ivar inside an Objective-C object, so that Core ML can use the
numpy buffer without a copy. The lifetime of that Objective-C object is
controlled by Core ML, not by Python, and Core ML does not necessarily
release the input feature values before `-predictionFromFeatures:...`
returns: the MLE5 engine leaves its input ports bound after a prediction
and unbinds them asynchronously about a second later, from
`-[MLE5ExecutionStream resetAfterLingering:]` running on
com.apple.coreml.MLE5ExecutionStream.resetQueue.
When that happens the compiler generated `.cxx_destruct` destroys the
`py::array` ivar -- i.e. calls `Py_DECREF` on the numpy array -- from a
thread that holds neither the GIL nor a Python thread state. That races
with the interpreter and corrupts the Python heap, usually faulting inside
`_PyObject_Free` either immediately or during some later, unrelated
prediction:
Thread 3 com.apple.coreml.MLE5ExecutionStream.resetQueue
_PyObject_Free libpython3.12.dylib
-[PybindCompatibleArray .cxx_destruct] libcoremlpython.so
object_cxxDestructFromClass libobjc.A.dylib
_objc_rootDealloc libobjc.A.dylib
-[MLFeatureValue dealloc] CoreML
-[MLE5InputPortBinder reset] CoreML
-[MLE5InputPort reset] CoreML
-[MLE5ExecutionStreamOperation reset] CoreML
-[MLE5ExecutionStream _reset] CoreML
__43-[MLE5ExecutionStream resetAfterLingering:]_block_invoke
EXC_BAD_ACCESS (SIGSEGV)
Any prediction whose input array is dropped by Python before Core ML lets
go of it is exposed. That is the common case: `predict()` builds the input
dict locally, and `_update_float16_multiarray_input_to_float32` replaces
float16 inputs with fresh float32 temporaries, so the Objective-C wrapper
is frequently the sole owner. It shows up most often when an application
alternates between several MLModel instances -- e.g. sharing one MLState
across the functions of a multifunction model -- because that leaves each
execution stream idle long enough for the linger timer to fire.
Fix: give `PybindCompatibleArray` a `-dealloc` that drops the Python
reference with the GIL held, leaving `.cxx_destruct` a no-op. Reproduced
with a 1.8 KiB single-op mlprogram: one `predict()`, drop the input, idle
one second.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXBmYgwYbJMbruk25LLG7b
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MLModel.predictcan corrupt the Python heap and segfault, because Core ML releases the prediction's inputMLMultiArrays on one of its own dispatch queues, andPybindCompatibleArray's compiler-generated.cxx_destructthen runsPy_DECREFon the wrapped numpy array without the GIL (and without any Python thread state).PybindCompatibleArraykeeps the caller's numpy buffer alive by storing apy::arrayivar inside an Objective-C object whose lifetime belongs to Core ML. Core ML does not necessarily release input feature values before-predictionFromFeatures:returns: the MLE5 engine leaves its input ports bound and unbinds them ~1 second later, from-[MLE5ExecutionStream resetAfterLingering:]oncom.apple.coreml.MLE5ExecutionStream.resetQueue. When the ObjC wrapper is the sole surviving owner of the numpy object — the common case, sincepredict()builds its input dict locally and_update_float16_multiarray_input_to_float32substitutes fresh temporaries — that background unbind drops the last reference off-thread. The result is a race against the interpreter: a crash in_PyObject_Free, either immediately or during some later, unrelated prediction.In practice this bites applications that alternate between several
MLModelinstances (e.g. per-function instances of a multifunction model, sharing anMLState): each instance's execution stream reliably idles past the linger timeout while other instances predict. That is how we found it — it initially looked like an MLState bug.Fix
Give
PybindCompatibleArraya-deallocthat drops the Python reference with the GIL held (PyGILState_Ensure/Py_DECREF/PyGILState_Release, with aPy_IsInitializedguard for interpreter shutdown), leaving.cxx_destructa no-op. The file builds with ARC, so the early returns are safe. This is the only place a Python reference is stored in an ObjC-lifetime object; the__block py::objectuses inCoreMLPythonUtils.mmare synchronous handler blocks and are unaffected.Deadlock consideration:
Model::predictcurrently holds the GIL for the duration of the prediction, so Core ML's reset queue now waits inPyGILState_Ensureuntil the predict returns. Stress-tested (33 multi-second predictions across 3 lingering instances, ~280 s): no deadlock — the prediction path never waits on the reset queue. Releasing the GIL around-predictionFromFeatures:would remove even that stall and is a natural follow-up, left out here to keep the change minimal.Reproduction
No MLState, no multifunction model, no large weights: a 1.8 KiB single-
addmlprogram, one instance, onepredict(), drop the input array, idle one second. Two variants —detect(an ndarray subclass reports its__del__running 1.06 s after predict on a_DummyThread; deterministic) andcrash(plain numpy + allocation churn on the main thread; segfaults unpatched). On this machine (macOS 26.4, Apple Silicon, Python 3.12, coremltools 9.0 wheel and this repo's main): unpatched →detectreports the off-main-thread free andcrashdies with SIGSEGV in round 0; patched → both pass, and the free happens on the main thread with the GIL.repro script (self-contained)
Verification
crashsegfaults unpatched.MLStateshared across per-size function instances) now runs all sequences clean, and state values verifiably carry (warm-vs-fresh logits diff matches the Swift reference behavior).test_api_examples.pystateful tests,test_modelpackage.py(49 passed, 1 skipped),test_model.py+test/blob(44 tests) — all green.One related observation (not addressed here): Core ML keeps the raw
mutable_data()pointer of the input array bound to its port for that same ~1 s window, so a caller mutating an input array in place immediately afterpredict()returns is racing Core ML. The retained reference makes it memory-safe, but it may deserve a note in the docs.🤖 Generated with Claude Code