Skip to content

[MLIR][Python] Fix GIL handling in verification and printing - #215848

Open
hahalfx wants to merge 2 commits into
llvm:mainfrom
hahalfx:fix/mlir-python-gil-verification-printing
Open

[MLIR][Python] Fix GIL handling in verification and printing#215848
hahalfx wants to merge 2 commits into
llvm:mainfrom
hahalfx:fix/mlir-python-gil-verification-printing

Conversation

@hahalfx

@hahalfx hahalfx commented Aug 12, 2026

Copy link
Copy Markdown

Summary

I encountered this issue while running an MLIR-based outlining workflow and reduced it to the standalone reproducer reported in #215781.

When verification runs in a multithreaded MLIRContext, MLIR may verify nested IsolatedFromAbove operations on worker threads. If that verification reaches a Python-defined DynamicOpTrait verifier, the worker can enter CPython without holding the GIL, causing a native segmentation fault.

Acquiring the GIL only in the callback is insufficient: if the originating Python thread still holds the GIL while waiting for the MLIR worker, the callback can deadlock while trying to acquire it. This change therefore handles both sides of the affected call paths:

  • release the GIL around Python Operation.verify() and printing/AsmState entry points that may invoke verification internally;
  • ensure the GIL is held before accessing Python objects in callbacks reachable from those paths, including dynamic operation verifiers, diagnostic callbacks and capture paths, and print accumulators;
  • acquire the GIL in the Python-backed MemoryEffectsOpInterface getEffects callback because parent Transform dialect operation verifiers can query the effects of nested transform operations.

Regression tests added by this change cover successful and failing multithreaded Python verification, diagnostics emitted by native verifiers, printing, dumping, and AsmState construction. The Python-backed getEffects path is also exercised by existing calls to named_seq.verify() in transform_op_interface.py; without the callback-side GIL acquisition, that test segfaults after verification releases the GIL.

Fixes #215781.

Scope

This change is intentionally limited to Python Operation.verify() and printing/AsmState entry points that may invoke verification internally. It does not attempt to establish GIL safety for every C++-to-Python callback in the MLIR Python bindings.

PassManager.run is left unchanged in this PR. Releasing the GIL around pass execution would require auditing the Python callbacks reachable from that path, including external Python passes; consequently, multithreaded post-pass verification that reaches a Python callback may still deadlock.

Testing

  • MLIRPythonModules
  • check-mlir-python
  • Existing transform_op_interface.py coverage of Python-backed getEffects callbacks reached from named_seq.verify()
  • Repeated stress testing of multithreaded verification, diagnostics, operation/value/block printing, dumping, and AsmState construction

Recursive verification may process isolated operations on MLIR worker
threads. When such a traversal invokes a Python-defined verifier,
diagnostic handler, or memory-effects callback without the GIL, the
process can crash.

Release the GIL in Python entry points that perform verification or
create and use printing state, and reacquire it in the Python callback
trampolines reachable from those paths.

Add regression coverage for valid and invalid multithreaded
verification, diagnostic propagation, printing, and AsmState creation.

PassManager execution and unrelated Python callback trampolines remain
outside the scope of this change and require a separate audit.
@github-actions

Copy link
Copy Markdown

Hello @hahalfx 👋

Thank you for submitting a Pull Request (PR) to the LLVM Project. Since this is your first PR, here are a few useful links covering our main contribution policies and review practices.

  • All contributions to LLVM must follow our LLVM AI Tool Use Policy. In particular, if you used AI while working on this PR, remember to add a note to the PR description.
  • The LLVM Code-Review Policy and Practices document contains practical information about the PR process, including how patches are reviewed and accepted, and who can review a PR.
  • Our LLVM Developer Policy describes our expectations for code quality, commit summaries and contains notes on our CI system.

Please reply to this message to confirm that you have read these policies, especially the LLVM AI Tool Use Policy, and that any AI tool usage has been noted in the PR description.


Frequently asked questions

How do I add reviewers?

This PR will be automatically labeled, and the relevant teams will be notified. For some parts of the project, reviewers may also be added automatically.

You can also add reviewers manually using the Reviewers section on this page. If you cannot use that section, it is probably because you do not have write permissions for the repository. In that case, you can request a review by tagging reviewers in a comment using @ followed by their GitHub username.

What if there are no comments?

If you have not received any comments on your PR after a week, you can request a review by pinging the PR with a comment such as “Ping”. The common courtesy ping rate is once a week. Please remember that you are asking for volunteer time from other developers.

Are any special GitHub settings required to contribute to LLVM?

We only require contributors to have a public email address associated with their GitHub commits, see this section of LLVM Developer Policy for details.


If you have questions, feel free to leave a comment on this PR, or ask on LLVM Discord or LLVM Discourse.

Thank you,
The LLVM Community

@llvmorg-github-actions llvmorg-github-actions Bot added mlir:python MLIR Python bindings mlir labels Aug 12, 2026
@llvmorg-github-actions

Copy link
Copy Markdown

@llvm/pr-subscribers-mlir

Author: Lei Fengxiang (hahalfx)

Changes

Summary

I encountered this issue while running an MLIR-based outlining workflow and reduced it to the standalone reproducer reported in #215781.

When verification runs in a multithreaded MLIRContext, MLIR may verify nested IsolatedFromAbove operations on worker threads. If that verification reaches a Python-defined DynamicOpTrait verifier, the worker can enter CPython without holding the GIL, causing a native segmentation fault.

Acquiring the GIL only in the callback is insufficient: if the originating Python thread still holds the GIL while waiting for the MLIR worker, the callback can deadlock while trying to acquire it. This change therefore handles both sides of the affected call paths:

  • release the GIL around Python Operation.verify() and printing/AsmState entry points that may invoke verification internally;
  • ensure the GIL is held before accessing Python objects in callbacks reachable from those paths, including dynamic operation verifiers, diagnostic callbacks and capture paths, and print accumulators;
  • acquire the GIL in the Python-backed MemoryEffectsOpInterface getEffects callback because parent Transform dialect operation verifiers can query the effects of nested transform operations.

Regression tests added by this change cover successful and failing multithreaded Python verification, diagnostics emitted by native verifiers, printing, dumping, and AsmState construction. The Python-backed getEffects path is also exercised by existing calls to named_seq.verify() in transform_op_interface.py; without the callback-side GIL acquisition, that test segfaults after verification releases the GIL.

Fixes #215781.

Scope

This change is intentionally limited to Python Operation.verify() and printing/AsmState entry points that may invoke verification internally. It does not attempt to establish GIL safety for every C++-to-Python callback in the MLIR Python bindings.

PassManager.run is left unchanged in this PR. Releasing the GIL around pass execution would require auditing the Python callbacks reachable from that path, including external Python passes; consequently, multithreaded post-pass verification that reaches a Python callback may still deadlock.

Testing

  • MLIRPythonModules
  • check-mlir-python
  • Existing transform_op_interface.py coverage of Python-backed getEffects callbacks reached from named_seq.verify()
  • Repeated stress testing of multithreaded verification, diagnostics, operation/value/block printing, dumping, and AsmState construction

Full diff: https://github.com/llvm/llvm-project/pull/215848.diff

6 Files Affected:

  • (modified) mlir/include/mlir/Bindings/Python/IRCore.h (+5-2)
  • (modified) mlir/include/mlir/Bindings/Python/NanobindUtils.h (+1)
  • (modified) mlir/lib/Bindings/Python/IRCore.cpp (+59-26)
  • (modified) mlir/lib/Bindings/Python/IRInterfaces.cpp (+2)
  • (modified) mlir/test/python/dialects/ext.py (+69-1)
  • (modified) mlir/test/python/ir/exception.py (+21)
diff --git a/mlir/include/mlir/Bindings/Python/IRCore.h b/mlir/include/mlir/Bindings/Python/IRCore.h
index 3314e0b2a8fcf..47d579d586e30 100644
--- a/mlir/include/mlir/Bindings/Python/IRCore.h
+++ b/mlir/include/mlir/Bindings/Python/IRCore.h
@@ -1690,8 +1690,11 @@ class MLIR_PYTHON_API_EXPORTED PyConcreteValue : public PyValue {
     cls.def("__str__", [](PyValue &self) {
       PyPrintAccumulator printAccum;
       printAccum.parts.append(std::string(DerivedTy::pyClassName) + "(");
-      mlirValuePrint(self.get(), printAccum.getCallback(),
-                     printAccum.getUserData());
+      {
+        nanobind::gil_scoped_release gil;
+        mlirValuePrint(self.get(), printAccum.getCallback(),
+                       printAccum.getUserData());
+      }
       printAccum.parts.append(")");
       return printAccum.join();
     });
diff --git a/mlir/include/mlir/Bindings/Python/NanobindUtils.h b/mlir/include/mlir/Bindings/Python/NanobindUtils.h
index ea43356d2cf54..594fc31f9f47b 100644
--- a/mlir/include/mlir/Bindings/Python/NanobindUtils.h
+++ b/mlir/include/mlir/Bindings/Python/NanobindUtils.h
@@ -177,6 +177,7 @@ struct PyPrintAccumulator {
 
   MlirStringCallback getCallback() {
     return [](MlirStringRef part, void *userData) {
+      nanobind::gil_scoped_acquire gil;
       PyPrintAccumulator *printAccum =
           static_cast<PyPrintAccumulator *>(userData);
       nanobind::str pyPart(part.data,
diff --git a/mlir/lib/Bindings/Python/IRCore.cpp b/mlir/lib/Bindings/Python/IRCore.cpp
index 75cfd2a0a1c0b..1faff0b827048 100644
--- a/mlir/lib/Bindings/Python/IRCore.cpp
+++ b/mlir/lib/Bindings/Python/IRCore.cpp
@@ -516,23 +516,21 @@ nb::object PyMlirContext::attachDiagnosticHandler(nb::object callback) {
   // guaranteed to be known to pybind.
   auto handlerCallback =
       +[](MlirDiagnostic diagnostic, void *userData) -> MlirLogicalResult {
+    // Since this can be called from arbitrary C++ contexts, always get the
+    // GIL before creating any Python objects.
+    nb::gil_scoped_acquire gil;
     PyDiagnostic *pyDiagnostic = new PyDiagnostic(diagnostic);
     nb::object pyDiagnosticObject =
         nb::cast(pyDiagnostic, nb::rv_policy::take_ownership);
 
     auto *pyHandler = static_cast<PyDiagnosticHandler *>(userData);
     bool result = false;
-    {
-      // Since this can be called from arbitrary C++ contexts, always get the
-      // gil.
-      nb::gil_scoped_acquire gil;
-      try {
-        result = nb::cast<bool>(pyHandler->callback(pyDiagnostic));
-      } catch (std::exception &e) {
-        fprintf(stderr, "MLIR Python Diagnostic handler raised exception: %s\n",
-                e.what());
-        pyHandler->hadError = true;
-      }
+    try {
+      result = nb::cast<bool>(pyHandler->callback(pyDiagnostic));
+    } catch (std::exception &e) {
+      fprintf(stderr, "MLIR Python Diagnostic handler raised exception: %s\n",
+              e.what());
+      pyHandler->hadError = true;
     }
 
     pyDiagnostic->invalidate();
@@ -564,6 +562,7 @@ MlirLogicalResult PyMlirContext::ErrorCapture::handler(MlirDiagnostic diag,
       MlirDiagnosticSeverity::MlirDiagnosticError)
     return mlirLogicalResultFailure();
 
+  nb::gil_scoped_acquire gil;
   self->errors.emplace_back(PyDiagnostic(diag).getInfo());
   return mlirLogicalResultSuccess();
 }
@@ -1051,8 +1050,12 @@ void PyOperationBase::print(std::optional<int64_t> largeElementsLimit,
     mlirOpPrintingFlagsPrintNameLocAsPrefix(flags);
 
   PyFileAccumulator accum(fileObject, binary);
-  mlirOperationPrintWithFlags(operation, flags, accum.getCallback(),
-                              accum.getUserData());
+  // Printing creates an AsmState that may recursively invoke Python verifiers.
+  {
+    nb::gil_scoped_release gil;
+    mlirOperationPrintWithFlags(operation, flags, accum.getCallback(),
+                                accum.getUserData());
+  }
   mlirOpPrintingFlagsDestroy(flags);
 }
 
@@ -1177,7 +1180,15 @@ bool PyOperationBase::isBeforeInBlock(PyOperationBase &other) {
 bool PyOperationBase::verify() {
   PyOperation &op = getOperation();
   PyMlirContext::ErrorCapture errors(op.getContext());
-  if (!mlirOperationVerify(op.get()))
+  bool verified;
+  {
+    // Recursive verification may invoke Python callbacks on MLIR worker
+    // threads. Release the GIL here; Python callbacks reachable during
+    // verification reacquire it.
+    nb::gil_scoped_release gil;
+    verified = mlirOperationVerify(op.get());
+  }
+  if (!verified)
     throw MLIRError("Verification failed", errors.take());
   return true;
 }
@@ -1758,7 +1769,10 @@ PyAsmState::PyAsmState(MlirValue value, bool useLocalScope) {
   // associate lifetime with the state.
   if (useLocalScope)
     mlirOpPrintingFlagsUseLocalScope(flags);
-  state = mlirAsmStateCreateForValue(value, flags);
+  {
+    nb::gil_scoped_release gil;
+    state = mlirAsmStateCreateForValue(value, flags);
+  }
 }
 
 PyAsmState::PyAsmState(PyOperationBase &operation, bool useLocalScope) {
@@ -1767,7 +1781,11 @@ PyAsmState::PyAsmState(PyOperationBase &operation, bool useLocalScope) {
   // associate lifetime with the state.
   if (useLocalScope)
     mlirOpPrintingFlagsUseLocalScope(flags);
-  state = mlirAsmStateCreateForOperation(operation.getOperation().get(), flags);
+  {
+    nb::gil_scoped_release gil;
+    state =
+        mlirAsmStateCreateForOperation(operation.getOperation().get(), flags);
+  }
 }
 
 //------------------------------------------------------------------------------
@@ -2549,6 +2567,7 @@ void PyOpAdaptor::bind(nb::module_ &m) {
 
 static MlirLogicalResult verifyTraitByMethod(MlirOperation op, void *userData,
                                              const char *methodName) {
+  nb::gil_scoped_acquire gil;
   nb::handle targetObj(static_cast<PyObject *>(userData));
   if (!nb::hasattr(targetObj, methodName))
     return mlirLogicalResultSuccess();
@@ -3890,6 +3909,7 @@ void populateIRCore(nb::module_ &m) {
       .def(
           "dump",
           [](PyModule &self) {
+            nb::gil_scoped_release gil;
             mlirOperationDump(mlirModuleGetOperation(self.get()));
           },
           kDumpDocstring)
@@ -4637,8 +4657,11 @@ void populateIRCore(nb::module_ &m) {
           [](PyBlock &self) {
             self.checkValid();
             PyPrintAccumulator printAccum;
-            mlirBlockPrint(self.get(), printAccum.getCallback(),
-                           printAccum.getUserData());
+            {
+              nb::gil_scoped_release gil;
+              mlirBlockPrint(self.get(), printAccum.getCallback(),
+                             printAccum.getUserData());
+            }
             return printAccum.join();
           },
           "Returns the assembly form of the block.")
@@ -5034,7 +5057,11 @@ void populateIRCore(nb::module_ &m) {
           },
           "Context in which the value lives.")
       .def(
-          "dump", [](PyValue &self) { mlirValueDump(self.get()); },
+          "dump",
+          [](PyValue &self) {
+            nb::gil_scoped_release gil;
+            mlirValueDump(self.get());
+          },
           kDumpDocstring)
       .def_prop_ro(
           "owner",
@@ -5084,8 +5111,11 @@ void populateIRCore(nb::module_ &m) {
           [](PyValue &self) {
             PyPrintAccumulator printAccum;
             printAccum.parts.append("Value(");
-            mlirValuePrint(self.get(), printAccum.getCallback(),
-                           printAccum.getUserData());
+            {
+              nb::gil_scoped_release gil;
+              mlirValuePrint(self.get(), printAccum.getCallback(),
+                             printAccum.getUserData());
+            }
             printAccum.parts.append(")");
             return printAccum.join();
           },
@@ -5105,11 +5135,14 @@ void populateIRCore(nb::module_ &m) {
               mlirOpPrintingFlagsUseLocalScope(flags);
             if (useNameLocAsPrefix)
               mlirOpPrintingFlagsPrintNameLocAsPrefix(flags);
-            MlirAsmState valueState =
-                mlirAsmStateCreateForValue(self.get(), flags);
-            mlirValuePrintAsOperand(self.get(), valueState,
-                                    printAccum.getCallback(),
-                                    printAccum.getUserData());
+            MlirAsmState valueState;
+            {
+              nb::gil_scoped_release gil;
+              valueState = mlirAsmStateCreateForValue(self.get(), flags);
+              mlirValuePrintAsOperand(self.get(), valueState,
+                                      printAccum.getCallback(),
+                                      printAccum.getUserData());
+            }
             mlirOpPrintingFlagsDestroy(flags);
             mlirAsmStateDestroy(valueState);
             return printAccum.join();
diff --git a/mlir/lib/Bindings/Python/IRInterfaces.cpp b/mlir/lib/Bindings/Python/IRInterfaces.cpp
index d16015acaf3dc..57695aaf15643 100644
--- a/mlir/lib/Bindings/Python/IRInterfaces.cpp
+++ b/mlir/lib/Bindings/Python/IRInterfaces.cpp
@@ -496,6 +496,8 @@ class PyMemoryEffectsOpInterface
     callbacks.getEffects = [](MlirOperation op,
                               MlirMemoryEffectInstancesList effects,
                               void *userData) {
+      // Parent transform op verifiers query effects of nested transform ops.
+      nb::gil_scoped_acquire gil;
       nb::handle pyClass(static_cast<PyObject *>(userData));
 
       // Get the 'get_effects' method from the Python class.
diff --git a/mlir/test/python/dialects/ext.py b/mlir/test/python/dialects/ext.py
index 6e83da4a4a78a..3be17794c25fb 100644
--- a/mlir/test/python/dialects/ext.py
+++ b/mlir/test/python/dialects/ext.py
@@ -1,7 +1,7 @@
 # RUN: %PYTHON %s 2>&1 | FileCheck %s
 
 from mlir.ir import *
-from mlir.dialects import arith
+from mlir.dialects import arith, func
 from mlir.dialects.ext import *
 from mlir.rewrite import *
 from mlir import ir
@@ -637,6 +637,74 @@ class NoTermOp(TestRegion.Operation, name="no_term", traits=[NoTerminatorTrait])
             print(e)
 
 
+# CHECK: TEST: testDynamicOpTraitMultithreadedVerification
+@run
+def testDynamicOpTraitMultithreadedVerification():
+    class TestParallelVerify(Dialect, name="ext_parallel_verify"):
+        pass
+
+    class ValidOp(TestParallelVerify.Operation, name="valid"):
+        def verify_invariants(self) -> bool:
+            return True
+
+    class InvalidOp(TestParallelVerify.Operation, name="invalid"):
+        def verify_invariants(self) -> bool:
+            self.location.emit_error("parallel Python verifier failed")
+            return False
+
+    def add_function(module, name, op_type, add_result=False):
+        i32 = IntegerType.get_signless(32)
+        with InsertionPoint(module.body):
+            result_types = [i32] if add_result else []
+            function = func.FuncOp(name, ([], result_types))
+            block = function.add_entry_block()
+        with InsertionPoint(block):
+            value = None
+            return_values = []
+            if add_result:
+                value = arith.constant(i32, 0)
+                return_values = [value]
+            op_type()
+            func.ReturnOp(return_values)
+        return function, value
+
+    context = Context()
+    context.enable_multithreading(True)
+    with context, Location.unknown():
+        TestParallelVerify.load()
+
+        module = Module.create()
+        first_function, value = add_function(
+            module, "first_valid", ValidOp, add_result=True
+        )
+        add_function(module, "second_valid", ValidOp, add_result=True)
+        assert module.operation.verify()
+        assert "ext_parallel_verify.valid" in str(module)
+        # AsmState construction implicitly verifies the parent operation.
+        AsmState(module.operation)
+        AsmState(value)
+        assert value.get_name()
+        assert "ext_parallel_verify.valid" in str(first_function.body.blocks[0])
+        assert "arith.constant" in str(value)
+        assert "arith.constant" in str(Value(value))
+        module.dump()
+        value.dump()
+        # CHECK: parallel verification succeeded
+        print("parallel verification succeeded")
+
+        module = Module.create()
+        add_function(module, "first_invalid", InvalidOp)
+        add_function(module, "second_invalid", InvalidOp)
+        try:
+            module.operation.verify()
+        except MLIRError as e:
+            assert "parallel Python verifier failed" in str(e)
+            # CHECK: parallel verification failure captured
+            print("parallel verification failure captured")
+        else:
+            raise AssertionError("expected parallel verification to fail")
+
+
 # CHECK: TEST: testIsIsolatedFromAboveTrait
 @run
 def testIsIsolatedFromAboveTrait():
diff --git a/mlir/test/python/ir/exception.py b/mlir/test/python/ir/exception.py
index 74085cd349643..7d323282c25ea 100644
--- a/mlir/test/python/ir/exception.py
+++ b/mlir/test/python/ir/exception.py
@@ -2,6 +2,7 @@
 
 import gc
 from mlir.ir import *
+from mlir.dialects import func
 
 
 def run(f):
@@ -93,3 +94,23 @@ def handler(d):
         print(f"emit_error_diagnostics=True:")
         print(f"e.error_diagnostics: {[str(diag) for diag in e.error_diagnostics]}")
         print(f"handler_diags: {handler_diags}")
+
+
+# CHECK-LABEL: TEST: test_multithreaded_native_verifier_diagnostics
+@run
+def test_multithreaded_native_verifier_diagnostics():
+    context = Context()
+    context.enable_multithreading(True)
+    with context, Location.unknown():
+        module = Module.create()
+        with InsertionPoint(module.body):
+            func.FuncOp("first_native_invalid", ([], [])).add_entry_block()
+            func.FuncOp("second_native_invalid", ([], [])).add_entry_block()
+        try:
+            module.operation.verify()
+        except MLIRError as e:
+            assert str(e).count("empty block: expect at least a terminator") == 2
+            # CHECK: parallel native diagnostics captured
+            print("parallel native diagnostics captured")
+        else:
+            raise AssertionError("expected native verification to fail")

@makslevental makslevental left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Highly suspect this much GIL manipulation is actually necessary. Not blocking indefinitely but just flagging we need to take a much closer look than just spamming nb::gil_scoped_acquire. mistake

@makslevental makslevental left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Highly suspect this much GIL manipulation is actually necessary. Not blocking indefinitely but just flagging we need to take a much closer look than just spamming nb::gil_scoped_acquire. mistake

@makslevental
makslevental self-requested a review August 12, 2026 17:13

@makslevental makslevental left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Highly suspect this much GIL manipulation is actually necessary. Not blocking indefinitely but just flagging we need to take a much closer look than just spamming nb::gil_scoped_acquire. mistake

@makslevental makslevental left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Highly suspect this much GIL manipulation is actually necessary. Not blocking indefinitely but just flagging we need to take a much closer look than just spamming nb::gil_scoped_acquire.

@PragmaTwice

PragmaTwice commented Aug 13, 2026

Copy link
Copy Markdown
Member

I think this problem can be modelled as:

python code on T0
// T0 already owns the GIL.

python -> c++ call on T0 {
  // T0 continues to own the GIL.

  c++ code on T0;

  c++ thread pool launch {
    // MLIR multithreading enabled.
    // T1/T2 do NOT inherit T0's GIL ownership or PyThreadState.

    worker T1/T2 {
      c++ -> python call {
        // Current implementation does NOT acquire the GIL here.

        python code / Python C API
        // BOOM:
        // GIL is owned by T0, not by this worker.
        // T1/T2 illegally enter CPython without the GIL.
      }
    }
  }

  wait for thread pool;
  // T0 is waiting for T1/T2 while still owning the GIL.
}

// Return to Python.
// T0 still owns the GIL; returning from C++ does not itself release it.

And the fix can be modelled as:

python code on T0
// T0 already owns the GIL.

python -> c++ call on T0 {
  // T0 continues to own the GIL.

  prepare Python/C++ arguments and state;

  {
    gil_scoped_release on T0;
    // T0 temporarily releases the GIL before starting/waiting
    // for native work that may call back into Python.

    c++ thread pool launch {
      // MLIR multithreading enabled.

      worker T1/T2 {
        c++ work;

        {
          gil_scoped_acquire on this worker;
          // The worker attaches a Python thread state and waits
          // until it owns the GIL.
          // This guard must precede hasattr, refcounting, object
          // construction, and every other Python API operation.

          c++ -> python call {
            python code;
            // Safe: this worker currently owns the GIL.
          }

        } // Worker releases/restores its GIL state.

        continue c++ work;
      }
    }

    wait for thread pool;
    // Safe: T0 is not holding the GIL while waiting.
  }
  // gil_scoped_release is destroyed here.
  // T0 reacquires the GIL before continuing.

  convert results / construct Python exception;
  // Safe: T0 owns the GIL again.
}

// Return to Python.
// T0 still owns the GIL.

So I think this fix can indeed solve the issue. Though I don't know if this is the best fix.

This is a very general problem than just for operation verification. It exists for any pattern of python code -> call c++ code -> spawn threads in c++ -> call python code in these c++ threads. So I agree that we should think carefully about this.

One issue I saw for such fix is that we need to be careful that gil_scoped_release should be put in all of the boundary of python -> c++ call. Deadlock will happen for any missing path.

@hahalfx

hahalfx commented Aug 13, 2026

Copy link
Copy Markdown
Author

Thanks @PragmaTwice — that matches my understanding as well.

And thanks @makslevental for pushing back on the amount of GIL handling here. I went back to the smallest fix first, then re-tested the remaining changes against failures I could reproduce independently.

1. Starting with the original Operation.verify() issue

In a local build, I first reduced the change to one paired boundary:

  • Release the GIL around mlirOperationVerify() in PyOperationBase::verify().
  • Acquire it at the beginning of verifyTraitByMethod(), before touching Python.

On an unmodified upstream build, the #215781 reproducer crashes with SIGSEGV. With only this pair, the explicit Operation.verify() case succeeds.

So the minimal pair is enough to fix the original #215781 reproducer.

2. Printing turned out to reach the same verifier

After the minimal verify() fix worked, I tried removing the remaining printing-related changes.

Using the same IR and Python DynamicOpTrait:

  • Operation.verify() succeeded.
  • str(module), Operation.print(), and AsmState(operation) timed out.
  • Operation.print(assume_verified=True) succeeded.

Ordinary printing constructs an AsmState that may run verification, so this is consistent with the same caller-holds-GIL / callback-needs-GIL deadlock.

Releasing the GIL around ordinary operation printing made str() and Operation.print() succeed. Direct AsmState(operation) still timed out until I handled that entry point separately.

This is how printing entered the investigation: I found it while trying to remove those changes, not because the original Operation.verify() reproducer was still failing.

Verification/printing reproducer and raw results
import io
import sys

from mlir import ir
from mlir.dialects import func
from mlir.dialects.ext import Dialect


class ReproDialect(Dialect, name="repro_matrix"):
    pass


class CheckedOp(ReproDialect.Operation, name="checked"):
    def verify_invariants(self) -> bool:
        return True


def add_function(module: ir.Module, name: str) -> None:
    with ir.InsertionPoint(module.body):
        function = func.FuncOp(name, ([], []))
        block = function.add_entry_block()

    with ir.InsertionPoint(block):
        CheckedOp()
        func.ReturnOp([])


mode = sys.argv[1]

with ir.Context() as context, ir.Location.unknown():
    context.enable_multithreading(True)
    ReproDialect.load()

    module = ir.Module.create()
    add_function(module, "first")
    add_function(module, "second")

    print(
        f"case={mode} threads={context.get_num_threads()} before",
        flush=True,
    )

    if mode == "verify":
        module.operation.verify()
    elif mode == "str":
        assert "repro_matrix.checked" in str(module)
    elif mode == "print":
        output = io.StringIO()
        module.operation.print(file=output)
        assert "repro_matrix.checked" in output.getvalue()
    elif mode == "asm-state":
        ir.AsmState(module.operation)
    elif mode == "print-assume-verified":
        output = io.StringIO()
        module.operation.print(file=output, assume_verified=True)
        assert "repro_matrix.checked" in output.getvalue()
    else:
        raise ValueError(mode)

    print(f"case={mode} succeeded", flush=True)

Unmodified upstream:

CASE verify
case=verify threads=8 before
RESULT: SIGSEGV (subprocess return code -11)

CASE str
case=str threads=8 before
RESULT: SIGSEGV (subprocess return code -11)

CASE print
case=print threads=8 before
RESULT: SIGSEGV (subprocess return code -11)

CASE asm-state
case=asm-state threads=8 before
RESULT: SIGSEGV (subprocess return code -11)

CASE print-assume-verified
case=print-assume-verified threads=8 before
case=print-assume-verified succeeded
RESULT: exit_code=0

With only the minimal verification pair:

CASE verify
case=verify threads=8 before
case=verify succeeded
RESULT: exit_code=0

CASE str
RESULT: TIMEOUT after 5s
case=str threads=8 before

CASE print
RESULT: TIMEOUT after 5s
case=print threads=8 before

CASE asm-state
RESULT: TIMEOUT after 5s
case=asm-state threads=8 before

CASE print-assume-verified
case=print-assume-verified threads=8 before
case=print-assume-verified succeeded
RESULT: exit_code=0

After adding the ordinary printing release:

CASE verify
case=verify threads=8 before
case=verify succeeded
RESULT: exit_code=0

CASE str
case=str threads=8 before
case=str succeeded
RESULT: exit_code=0

CASE print
case=print threads=8 before
case=print succeeded
RESULT: exit_code=0

CASE asm-state
RESULT: TIMEOUT after 5s
case=asm-state threads=8 before

After also handling AsmState(operation):

CASE verify
case=verify threads=8 before
case=verify succeeded
RESULT: exit_code=0

CASE str
case=str threads=8 before
case=str succeeded
RESULT: exit_code=0

CASE print
case=print threads=8 before
case=print succeeded
RESULT: exit_code=0

CASE asm-state
case=asm-state threads=8 before
case=asm-state succeeded
RESULT: exit_code=0

3. The first printing approach was too fine-grained

The first printing implementation reacquired the GIL in every Python writer callback.

For a 5,000-operation module, I counted 90,006 Python write() callbacks. Since that implementation acquired the GIL in every callback, those callbacks would also pass through 90,006 acquisition scopes.

So @makslevental was right to question this part of the implementation.

Printing callback-count reproducer and result
from mlir import ir


class CountingWriter:
    def __init__(self):
        self.calls = 0
        self.characters = 0

    def write(self, chunk):
        self.calls += 1
        self.characters += len(chunk)


for operation_count in (1, 10, 100, 1000, 5000):
    text = (
        "module {\n"
        + '  "test.op"() : () -> ()\n' * operation_count
        + "}\n"
    )

    with ir.Context() as context:
        context.allow_unregistered_dialects = True
        module = ir.Module.parse(text)

        writer = CountingWriter()
        module.operation.print(file=writer, assume_verified=True)

        print(
            f"ops={operation_count:5d} "
            f"python_write_callbacks={writer.calls:6d} "
            f"characters={writer.characters:7d}"
        )
ops=    1 python_write_callbacks=    24 characters=     36
ops=   10 python_write_callbacks=   186 characters=    261
ops=  100 python_write_callbacks=  1806 characters=   2511
ops= 1000 python_write_callbacks= 18006 characters=  25011
ops= 5000 python_write_callbacks= 90006 characters= 125011

4. I am trying a buffered printing variant

Locally, I am trying a coarser boundary:

release the GIL once
  -> verify and print into native storage
restore the GIL once
  -> call the Python writer once

In the same 5,000-operation test, this reduced the number of Python writes from 90,006 to 1. The native print callback still runs for each fragment, but it only appends to a native std::string and does not touch Python.

In my local tests, printing with a pre-created AsmState did not trigger another verification, so that overload may be able to keep the GIL and preserve its current streaming behavior. Native file paths can also continue writing directly while the GIL is released.

This is only an experiment. It changes Python file-like output from incremental writes to one complete write and uses memory proportional to the output size, so I am not assuming that it is the right design.

Local implementation sketch and test result

The local prototype changes the print accumulator from Python objects to native storage:

struct PyPrintAccumulator {
  std::string parts;

  void *getUserData() { return this; }

  MlirStringCallback getCallback() {
    return [](MlirStringRef part, void *userData) {
      auto *accumulator =
          static_cast<PyPrintAccumulator *>(userData);
      accumulator->parts.append(part.data, part.length);
    };
  }

  nanobind::str join() {
    return nanobind::str(parts.data(), parts.length());
  }
};

For a Python-backed writer, printing is performed into the native buffer while the GIL is released. The complete output is written after the release scope ends:

PyFileAccumulator accumulator(fileObject, binary);

if (!accumulator.isPythonBacked()) {
  nb::gil_scoped_release gil;
  mlirOperationPrintWithFlags(operation, flags,
                              accumulator.getCallback(),
                              accumulator.getUserData());
} else {
  PyPrintAccumulator nativeBuffer;
  {
    nb::gil_scoped_release gil;
    mlirOperationPrintWithFlags(operation, flags,
                                nativeBuffer.getCallback(),
                                nativeBuffer.getUserData());
  }

  // The GIL is held again here.
  accumulator.write(nativeBuffer.parts);
}

The helper writes the complete native buffer to the Python target while the caller holds the GIL:

bool isPythonBacked() const {
  return writeTarget.index() == 0;
}

void write(const std::string &contents) {
  MlirStringRef output =
      mlirStringRefCreate(contents.data(), contents.length());
  getCallback()(output, getUserData());
}

The observed callback counts were:

ops=    1 buffered_callbacks=     1 streamed_callbacks=    24
ops=   10 buffered_callbacks=     1 streamed_callbacks=   186
ops=  100 buffered_callbacks=     1 streamed_callbacks=  1806
ops= 1000 buffered_callbacks=     1 streamed_callbacks= 18006
ops= 5000 buffered_callbacks=     1 streamed_callbacks= 90006

The prototype also preserved the behavior covered by these small probes:

writer exception propagated
native path output succeeded

5. Another boundary found during the investigation

I also found a PassManager.run() timeout consistent with the same deadlock pattern. It currently holds the GIL across mlirPassManagerRunOnOp(), and a small test with a Python DynamicOpTrait timed out before entering the Python verifier.

I have not made a local PassManager.run() change. Releasing the GIL around the whole pass execution appears to require understanding other Python callback paths as well, such as external Python passes.

I mention it because I am not yet sure where the complete boundary of this problem is.

A few questions

The main thing I am unsure about is where you would draw the boundary for this change.

  1. Would it make more sense to keep this PR to the minimal Operation.verify() fix and report printing / AsmState (and probably PassManager.run()) separately, or should the known callers of the same Python verifier callback be handled together?

  2. If printing stays in scope, does native buffering followed by one Python write() seem like a reasonable way to avoid per-fragment GIL acquisition? Another possibility would be to release the GIL only while constructing the verification-capable AsmState, but I have not found a clean way to do that without duplicating printer internals or extending an API.

I am also not sure whether there is already an established MLIR Python pattern for keeping Python-backed verification off worker threads that I have missed.

This is my first upstream contribution involving these ownership boundaries, so I may be missing some project-specific conventions. Happy to adjust the patch based on what fits the project best.

@makslevental

Copy link
Copy Markdown
Contributor

@hahalfx thanks for the investigation. I still haven't had time to look at this closely (though I did skim your current summary). Just gonna paste here what I told @PragmaTwice offline:

The problem is the c++ back to python call isn't the common cse. In this specific case obviously most users don't have verifiers written in python. So if you put explicitly do GIL manipulation you're pessimizing the common path. My preference would be to just warn people about the sharp edge ehre: if you have Python callbacks then you need to turn off multithreading. Or at minimum make the GIL optional in those APIs (via a param/flag).

But I'll come back soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mlir:python MLIR Python bindings mlir

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[MLIR][Python] DynamicOpTrait verifier crashes when parallel verification calls Python without the GIL

3 participants