Skip to content

Commit 0f15c8f

Browse files
dmitriplotnikovcopybara-github
authored andcommitted
Enable free-threaded Python and improve concurrency in CEL Python.
- Declare free-threading compatibility on pybind11 modules (`mod_gil_not_used`) and BUILD targets (`freethreading_compatible`). - Introduce `FreeThreadingMutex`, a zero-cost abstraction that compiles away in GIL-enabled builds and uses `absl::Mutex` in free-threaded mode (`Py_GIL_DISABLED`). - Protect lazy `cel_program_` compilation in `PyCelExpression` with `FreeThreadingMutex`. - Protect Python object caching and lazy element resolution in `PyCelValue` and list/map accessors with `FreeThreadingMutex`. - Protect `PyMessageFactory::message_classes_` with `FreeThreadingMutex`. - Extract `BuildCompiler` and `BuildRuntime` to construct compiler/runtime objects without holding mutex locks, using double-checked locking in `GetCompiler` and `GetRuntime`. - Eagerly resolve `variable_types_` during `PyCelEnvInternal` initialization, making `GetVariableType` lock-free and thread-safe. - Release GIL during CPU-bound policy compilation and YAML parsing. - Avoid unconditional `gil_scoped_acquire` across destructors and error conversions when GIL state is already held (`PyGILState_Check`). - Initialize pybind11 internals at module import time to prevent DSO internals races in multi-threaded environments. - Prevent reference leaks in custom function invocation by releasing arguments and temporary results. - Add `test_freethreaded.sh` script and concurrency tests in `cel_parallel_test.py` and `cel_test.py`. ### Performance Benchmarks (`cel_parallel_test`, `-c opt`) | Benchmark Stage / Test Case | Standard Mode (GIL) | Free-Threaded Mode (No GIL) | Speedup / Impact | | :--- | :---: | :---: | :---: | | **Multi-Threaded Compilation** (1,000 items, 8 threads) | 75.74 ms | 23.14 ms | **3.27x faster** (69.5% faster) | | **Sequential Compilation** (1,000 items) | 61.77 ms | 63.28 ms | 0.98x | | **Multi-Threaded Evaluation** (10,000 items, 8 threads) | 234.79 ms | 138.88 ms | **1.69x faster** (40.9% faster) | | **Sequential Evaluation** (10,000 items) | 90.68 ms | 112.74 ms | 0.80x | PiperOrigin-RevId: 969972475
1 parent 6041cbb commit 0f15c8f

20 files changed

Lines changed: 589 additions & 164 deletions

cel_expr_python/BUILD

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pybind_library(
2929
"py_message_factory.cc",
3030
],
3131
hdrs = [
32+
"free_threading_mutex.h",
3233
"py_cel_activation.h",
3334
"py_cel_arena.h",
3435
"py_cel_env.h",
@@ -46,6 +47,7 @@ pybind_library(
4647
"py_descriptor_database.h",
4748
"py_message_factory.h",
4849
],
50+
tags = ["freethreading_compatible"],
4951
visibility = [":__subpackages__"],
5052
deps = [
5153
":cel_extension",
@@ -113,6 +115,7 @@ pybind_extension(
113115
"//visibility:public",
114116
],
115117
deps = [
118+
":cel_extension",
116119
":cel_pybind_lib",
117120
],
118121
)
@@ -127,6 +130,7 @@ pybind_library(
127130
"cel_extension.h",
128131
"py_error_status.h",
129132
],
133+
tags = ["freethreading_compatible"],
130134
visibility = ["//visibility:public"],
131135
deps = [
132136
":status_macros",
@@ -161,6 +165,7 @@ py_test(
161165
"//testing:proto2_test_all_types_py_pb2",
162166
"@com_google_absl_py//absl/testing:absltest",
163167
"@com_google_protobuf//:protobuf",
168+
"@com_google_protobuf//:protobuf_python",
164169
] + select({
165170
"@platforms//os:windows": [],
166171
"//conditions:default": [":cel"],

cel_expr_python/cel_extension.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,14 @@ class CelExtension {
8080
// CEL_EXTENSION_MODULE(sample_cel_ext, SampleCelExtension);
8181
//
8282
#define CEL_EXTENSION_MODULE(module_name, class_name) \
83-
PYBIND11_MODULE(module_name, m) { \
83+
PYBIND11_MODULE(module_name, m, pybind11::mod_gil_not_used()) { \
8484
pybind11::module_::import(CEL_MODULE_NAME); \
8585
pybind11::class_<class_name, cel_python::CelExtension>(m, #class_name) \
8686
.def(pybind11::init<>()); \
8787
}
8888

8989
#define CEL_VERSIONED_EXTENSION_MODULE(module_name, class_name) \
90-
PYBIND11_MODULE(module_name, m) { \
90+
PYBIND11_MODULE(module_name, m, pybind11::mod_gil_not_used()) { \
9191
pybind11::module_::import(CEL_MODULE_NAME); \
9292
pybind11::class_<class_name, cel_python::CelExtension>(m, #class_name) \
9393
.def(pybind11::init<>()) \

cel_expr_python/cel_parallel_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,47 @@ def testMultiThreadedCompilation(self):
199199
def testSequentialCompilation(self):
200200
self._test_compile(multi_threaded=False)
201201

202+
def testSharedValueConcurrentAccess(self):
203+
expr_scalar = self.env.compile("var_int * 2")
204+
expr_list = self.env.compile("[var_int, var_int + 1, var_int + 2]")
205+
expr_map = self.env.compile("{'key': var_str, 'value': var_int}")
206+
207+
def run_concurrent_value_test(n: int):
208+
val_scalar = expr_scalar.eval(data={"var_int": n})
209+
val_list = expr_list.eval(data={"var_int": n})
210+
val_map = expr_map.eval(data={"var_str": f"k_{n}", "var_int": n})
211+
212+
def read_values(_):
213+
# Concurrently access plain_value, value, type, and repr
214+
# on shared instances
215+
self.assertEqual(val_scalar.plain_value(), n * 2)
216+
self.assertEqual(val_scalar.value(), n * 2)
217+
self.assertEqual(val_scalar.type(), cel.Type.INT)
218+
self.assertNotEmpty(str(val_scalar))
219+
220+
plain_list = val_list.plain_value()
221+
self.assertEqual(plain_list, [n, n + 1, n + 2])
222+
list_accessors = val_list.value()
223+
for idx, item in enumerate(list_accessors):
224+
self.assertEqual(item.plain_value(), n + idx)
225+
self.assertEqual(item.value(), n + idx)
226+
self.assertEqual(item.type(), cel.Type.INT)
227+
self.assertNotEmpty(str(item))
228+
229+
plain_map = val_map.plain_value()
230+
self.assertEqual(plain_map, {"key": f"k_{n}", "value": n})
231+
map_accessors = val_map.value()
232+
self.assertEqual(map_accessors["key"].plain_value(), f"k_{n}")
233+
self.assertEqual(map_accessors["key"].value(), f"k_{n}")
234+
self.assertEqual(map_accessors["value"].plain_value(), n)
235+
self.assertEqual(map_accessors["value"].value(), n)
236+
237+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
238+
list(executor.map(read_values, range(50)))
239+
240+
for i in range(10):
241+
run_concurrent_value_test(i)
242+
202243

203244
if __name__ == "__main__":
204245
absltest.main()

cel_expr_python/cel_test.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import sys
2222
from typing import Any
2323

24+
from google.protobuf import message_factory
2425
from google.protobuf import duration_pb2 as duration_pb
2526
from google.protobuf import timestamp_pb2 as timestamp_pb
2627
from absl.testing import absltest
@@ -337,6 +338,32 @@ def testProto_unexpectedType(self):
337338
r" .*. \(Expected cel.expr.conformance.proto2.TestAllTypes\)",
338339
)
339340

341+
def testProto_cannotFindMessageClass(self):
342+
orig_get_message_class = message_factory.GetMessageClass
343+
344+
def failing_get_message_class(descriptor):
345+
del descriptor
346+
raise TypeError(
347+
"Couldn't build proto class because dependency X is missing"
348+
)
349+
350+
try:
351+
message_factory.GetMessageClass = failing_get_message_class
352+
env = cel.NewEnv(options=self.options)
353+
expr = env.compile(
354+
"cel.expr.conformance.proto2.TestAllTypes{single_string: 'hello'}"
355+
)
356+
res = expr.eval(env.Activation())
357+
with self.assertRaisesRegex(
358+
TypeError, "Couldn't find message class for type"
359+
):
360+
res.plain_value()
361+
del res
362+
del expr
363+
del env
364+
finally:
365+
message_factory.GetMessageClass = orig_get_message_class
366+
340367
def testEvalList(self):
341368
res: cel.Value = self._eval(
342369
"[1, 'CEL', true]", expected_return_type=cel.Type.LIST
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#ifndef THIRD_PARTY_CEL_PYTHON_FREE_THREADING_MUTEX_H_
16+
#define THIRD_PARTY_CEL_PYTHON_FREE_THREADING_MUTEX_H_
17+
18+
#include <Python.h> // IWYU pragma: keep - Needed for Py_GIL_DISABLED
19+
20+
#include "absl/base/attributes.h"
21+
#include "absl/base/thread_annotations.h"
22+
23+
#ifndef Py_GIL_DISABLED
24+
#include "absl/log/absl_check.h"
25+
#else
26+
#include "absl/synchronization/mutex.h"
27+
#endif
28+
29+
namespace cel_python {
30+
31+
// Zero-cost mutex wrapper that compiles away to nothing in standard GIL builds,
32+
// and uses absl::Mutex in free-threaded builds (Py_GIL_DISABLED).
33+
class ABSL_LOCKABLE ABSL_ATTRIBUTE_WARN_UNUSED FreeThreadingMutex {
34+
public:
35+
FreeThreadingMutex() = default;
36+
FreeThreadingMutex(const FreeThreadingMutex&) = delete;
37+
FreeThreadingMutex& operator=(const FreeThreadingMutex&) = delete;
38+
39+
#ifndef Py_GIL_DISABLED
40+
// In GIL-enabled builds, this mutex compiles away to zero-cost no-ops while
41+
// retaining thread-safety annotations for Clang static analysis.
42+
//
43+
// Relationship with the GIL:
44+
// - For operations accessing Python state or cached PyObjects (such as
45+
// PyCelValue::Value() or PyMessageFactory::GetMessageClass()), mutual
46+
// exclusion between threads is provided by the Python GIL itself.
47+
// - For pure C++ operations (such as PyCelEnv::Compile() or deserialization),
48+
// the GIL is intentionally released (via py::gil_scoped_release) to enable
49+
// parallel execution and prevent deadlocks with DescriptorPool's mutex.
50+
// Objects may therefore be constructed or moved while the GIL is NOT held.
51+
// - Consequently, Lock() and Unlock() do not assert PyGILState_Check(),
52+
// allowing lock acquisition and move semantics to function safely whether
53+
// the GIL is currently held or released. Call sites that strictly require
54+
// the GIL explicitly verify or acquire it themselves.
55+
void Lock() ABSL_EXCLUSIVE_LOCK_FUNCTION() {}
56+
void Unlock() ABSL_UNLOCK_FUNCTION() {}
57+
#else
58+
// Free-threaded build: real mutex
59+
void Lock() ABSL_EXCLUSIVE_LOCK_FUNCTION() { mutex_.Lock(); }
60+
void Unlock() ABSL_UNLOCK_FUNCTION() { mutex_.Unlock(); }
61+
62+
private:
63+
absl::Mutex mutex_;
64+
#endif
65+
};
66+
67+
// RAII lock guard for FreeThreadingMutex.
68+
class ABSL_SCOPED_LOCKABLE FreeThreadingLockGuard {
69+
public:
70+
explicit FreeThreadingLockGuard(FreeThreadingMutex& mutex)
71+
ABSL_EXCLUSIVE_LOCK_FUNCTION(mutex)
72+
: mutex_(mutex) {
73+
mutex_.Lock();
74+
}
75+
~FreeThreadingLockGuard() ABSL_UNLOCK_FUNCTION() { mutex_.Unlock(); }
76+
77+
FreeThreadingLockGuard(const FreeThreadingLockGuard&) = delete;
78+
FreeThreadingLockGuard& operator=(const FreeThreadingLockGuard&) = delete;
79+
80+
private:
81+
FreeThreadingMutex& mutex_;
82+
};
83+
84+
} // namespace cel_python
85+
86+
#endif // THIRD_PARTY_CEL_PYTHON_FREE_THREADING_MUTEX_H_

cel_expr_python/py_cel_env.cc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ PyCelExpression PyCelEnv::Compile(const std::string& cel_expr,
212212
}
213213

214214
PyCelExpression PyCelEnv::Deserialize(const std::string& serialized_expr) {
215+
py::gil_scoped_release gil_release;
215216
return ThrowIfError(PyCelExpression::Deserialize(env_, serialized_expr));
216217
}
217218

0 commit comments

Comments
 (0)