Skip to content

Commit 0beaf42

Browse files
dmitriplotnikovcopybara-github
authored andcommitted
Add multi-threaded compilation and evaluation tests and resolve compilation deadlock in CEL Python.
This change adds cel_parallel_test.py to benchmark and validate compiling and evaluating a diverse set of CEL expressions concurrently across multiple worker threads using concurrent.futures.ThreadPoolExecutor as well as sequentially. In addition, this resolves an AB-BA deadlock between the Python GIL and Protobuf DescriptorPool C++ mutex during concurrent compilation by releasing the GIL in PyCelEnv::Compile and acquiring the GIL via py::gil_scoped_acquire on-demand in PyDescriptorDatabase callbacks, while protecting PyCelEnvInternal with a mutex. Test duration metrics: - Multi-threaded compilation (1,000 iterations): ~248 ms - Sequential compilation (1,000 iterations): ~394 ms - Multi-threaded evaluation (10,000 iterations): ~782 ms - Sequential evaluation (10,000 iterations): ~434 ms PiperOrigin-RevId: 968187521
1 parent ce7b742 commit 0beaf42

8 files changed

Lines changed: 256 additions & 17 deletions

cel_expr_python/BUILD

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ pybind_library(
6060
"@com_google_absl//absl/status:statusor",
6161
"@com_google_absl//absl/strings",
6262
"@com_google_absl//absl/strings:str_format",
63+
"@com_google_absl//absl/synchronization",
6364
"@com_google_absl//absl/time",
6465
"@com_google_absl//absl/types:optional",
6566
"@com_google_absl//absl/types:span",
@@ -166,6 +167,21 @@ py_test(
166167
}),
167168
)
168169

170+
py_test(
171+
name = "cel_parallel_test",
172+
srcs = ["cel_parallel_test.py"],
173+
data = [
174+
":cel",
175+
],
176+
deps = [
177+
"//testing:proto2_test_all_types_py_pb2",
178+
"@com_google_absl_py//absl/testing:absltest",
179+
] + select({
180+
"@platforms//os:windows": [],
181+
"//conditions:default": [":cel"],
182+
}),
183+
)
184+
169185
py_test(
170186
name = "cel_env_test",
171187
srcs = ["cel_env_test.py"],
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
"""Multi-threaded tests for cel-python."""
16+
17+
import collections.abc
18+
import concurrent.futures
19+
import dataclasses
20+
import gc
21+
import logging
22+
import time
23+
from typing import Any
24+
25+
from absl.testing import absltest
26+
from cel_expr_python import cel
27+
from cel.expr.conformance.proto2 import test_all_types_pb2 as test_all_types_pb
28+
29+
30+
@dataclasses.dataclass(frozen=True)
31+
class _TestCase:
32+
expr: str
33+
data: collections.abc.Callable[[int], dict[str, Any]]
34+
expected: collections.abc.Callable[[int], Any]
35+
36+
37+
_NUM_EVALUATIONS = 10000
38+
_NUM_COMPILATIONS = 1000
39+
40+
_TEST_MSG = test_all_types_pb.TestAllTypes(single_int64=100)
41+
42+
_TEST_CASES = [
43+
_TestCase(
44+
expr="var_int * var_int",
45+
data=lambda n: {"var_int": n},
46+
expected=lambda n: n * n,
47+
),
48+
_TestCase(
49+
expr="var_str + '_' + string(var_int)",
50+
data=lambda n: {"var_str": "num", "var_int": n},
51+
expected=lambda n: f"num_{n}",
52+
),
53+
_TestCase(
54+
expr="var_int % 2 == 0",
55+
data=lambda n: {"var_int": n},
56+
expected=lambda n: n % 2 == 0,
57+
),
58+
_TestCase(
59+
expr="[var_int, var_int + 1, var_int + 2]",
60+
data=lambda n: {"var_int": n},
61+
expected=lambda n: [n, n + 1, n + 2],
62+
),
63+
_TestCase(
64+
expr="var_int_map[var_int]",
65+
data=lambda n: {"var_int_map": {n: f"val_{n}"}, "var_int": n},
66+
expected=lambda n: f"val_{n}",
67+
),
68+
_TestCase(
69+
expr="var_msg.single_int64 + var_int",
70+
data=lambda n: {"var_msg": _TEST_MSG, "var_int": n},
71+
expected=lambda n: 100 + n,
72+
),
73+
_TestCase(
74+
expr=(
75+
"cel.expr.conformance.proto2.TestAllTypes{"
76+
" single_int64: var_int, single_string: var_str"
77+
"}"
78+
),
79+
data=lambda n: {"var_int": n, "var_str": f"msg_{n}"},
80+
expected=lambda n: test_all_types_pb.TestAllTypes(
81+
single_int64=n, single_string=f"msg_{n}"
82+
),
83+
),
84+
_TestCase(
85+
expr="{'key': var_str, 'value': var_int}",
86+
data=lambda n: {"var_str": f"val_{n}", "var_int": n},
87+
expected=lambda n: {"key": f"val_{n}", "value": n},
88+
),
89+
_TestCase(
90+
expr="[var_int, var_int + 1, var_int + 2].all(x, x >= var_int)",
91+
data=lambda n: {"var_int": n},
92+
expected=lambda n: True,
93+
),
94+
]
95+
96+
97+
class CelParallelTest(absltest.TestCase):
98+
99+
def setUp(self):
100+
super().setUp()
101+
102+
self.env = cel.NewEnv(
103+
variables={
104+
"var_int": cel.Type.INT,
105+
"var_str": cel.Type.STRING,
106+
"var_int_map": cel.Type.Map(cel.Type.INT, cel.Type.STRING),
107+
"var_msg": cel.Type("cel.expr.conformance.proto2.TestAllTypes"),
108+
},
109+
)
110+
self.object_counts_before_test = self._grab_object_counts()
111+
112+
def tearDown(self):
113+
"""Tears down the test environment."""
114+
super().tearDown()
115+
116+
gc.collect()
117+
# Assert that all Arenas have been garbage-collected
118+
self.assertEqual(cel._InternalArena._get_instance_count(), 0)
119+
self._check_for_leaks()
120+
121+
def _grab_object_counts(self) -> dict[str, int]:
122+
gc.collect()
123+
all_objects = gc.get_objects()
124+
type_counts = {}
125+
for obj in all_objects:
126+
obj_type = type(obj)
127+
type_counts[obj_type.__name__] = type_counts.get(obj_type, 0) + 1
128+
return type_counts
129+
130+
def _check_for_leaks(self):
131+
type_counts = self._grab_object_counts()
132+
for key, count in type_counts.items():
133+
if count != self.object_counts_before_test.get(key, 0):
134+
self.fail(
135+
f"Object count for {key} did not match expected count. "
136+
f"Expected: {self.object_counts_before_test.get(key, 0)}, "
137+
f"Actual: {count}",
138+
)
139+
140+
def _test_eval(self, multi_threaded: bool):
141+
compiled_exprs = [self.env.compile(tc.expr) for tc in _TEST_CASES]
142+
143+
def eval_expr(n: int) -> Any:
144+
idx = n % len(_TEST_CASES)
145+
test_case = _TEST_CASES[idx]
146+
expr = compiled_exprs[idx]
147+
data = test_case.data(n)
148+
return expr.eval(data=data).plain_value()
149+
150+
start_time = time.perf_counter()
151+
if multi_threaded:
152+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
153+
results = list(executor.map(eval_expr, range(_NUM_EVALUATIONS)))
154+
else:
155+
results = [eval_expr(n) for n in range(_NUM_EVALUATIONS)]
156+
duration_ms = (time.perf_counter() - start_time) * 1000
157+
158+
mode = "Multi-threaded" if multi_threaded else "Sequential"
159+
logging.info("%s evaluation duration: %.2f ms", mode, duration_ms)
160+
161+
self.assertLen(results, _NUM_EVALUATIONS)
162+
for i, res in enumerate(results):
163+
test_case = _TEST_CASES[i % len(_TEST_CASES)]
164+
self.assertEqual(res, test_case.expected(i))
165+
166+
def testMultiThreadedEval(self):
167+
self._test_eval(multi_threaded=True)
168+
169+
def testSequentialEval(self):
170+
self._test_eval(multi_threaded=False)
171+
172+
def _test_compile(self, multi_threaded: bool):
173+
def compile_expr(n: int) -> cel.Expression:
174+
test_case = _TEST_CASES[n % len(_TEST_CASES)]
175+
return self.env.compile(test_case.expr)
176+
177+
start_time = time.perf_counter()
178+
if multi_threaded:
179+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
180+
results = list(executor.map(compile_expr, range(_NUM_COMPILATIONS)))
181+
else:
182+
results = [compile_expr(n) for n in range(_NUM_COMPILATIONS)]
183+
duration_ms = (time.perf_counter() - start_time) * 1000
184+
185+
mode = "Multi-threaded" if multi_threaded else "Sequential"
186+
logging.info("%s compilation duration: %.2f ms", mode, duration_ms)
187+
188+
self.assertLen(results, _NUM_COMPILATIONS)
189+
for i, expr in enumerate(results):
190+
test_case = _TEST_CASES[i % len(_TEST_CASES)]
191+
data = test_case.data(i)
192+
self.assertEqual(
193+
expr.eval(data=data).plain_value(), test_case.expected(i)
194+
)
195+
196+
def testMultiThreadedCompilation(self):
197+
self._test_compile(multi_threaded=True)
198+
199+
def testSequentialCompilation(self):
200+
self._test_compile(multi_threaded=False)
201+
202+
203+
if __name__ == "__main__":
204+
absltest.main()

cel_expr_python/py_cel_env.cc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ std::shared_ptr<PyCelActivation> PyCelEnv::NewActivation(
196196

197197
PyCelExpression PyCelEnv::Compile(const std::string& cel_expr,
198198
bool disable_check) {
199+
// Release the GIL before entering C++ compilation to prevent lock
200+
// inversion/deadlock with DescriptorPool's internal mutex during concurrent
201+
// multi-threaded compilation.
202+
//
203+
// When DescriptorPool performs a descriptor lookup on a cache miss, it calls
204+
// back into Python via PyDescriptorDatabase (which re-acquires the GIL via
205+
// PyGILState_Ensure). If another thread were to enter Compile() with the GIL
206+
// held, it would block on DescriptorPool's internal C++ mutex while holding
207+
// the GIL, causing an AB-BA deadlock with any thread inside
208+
// PyDescriptorDatabase waiting for the GIL. Releasing the GIL here guarantees
209+
// a strict one-way lock hierarchy (DescriptorPool Mutex -> Python GIL).
210+
py::gil_scoped_release gil_release;
199211
return ThrowIfError(PyCelExpression::Compile(env_, cel_expr, disable_check));
200212
}
201213

cel_expr_python/py_cel_env_internal.cc

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#include "absl/status/status.h"
2626
#include "absl/status/statusor.h"
2727
#include "absl/strings/str_cat.h"
28+
#include "absl/synchronization/mutex.h"
2829
#include "checker/type_checker_builder.h"
2930
#include "common/container.h"
3031
#include "common/function_descriptor.h"
@@ -40,7 +41,6 @@
4041
#include "runtime/runtime.h"
4142
#include "runtime/runtime_builder.h"
4243
#include "runtime/runtime_options.h"
43-
#include "validator/validator.h"
4444
#include "cel_expr_python/cel_extension.h"
4545
#include "cel_expr_python/py_cel_env_config.h"
4646
#include "cel_expr_python/py_cel_function.h"
@@ -232,8 +232,7 @@ PyCelEnvInternal::NewCelEnvInternal(
232232

233233
absl::StatusOr<const cel::Compiler*> PyCelEnvInternal::GetCompiler(
234234
const std::shared_ptr<PyCelEnvInternal>& env) {
235-
ABSL_CHECK(PyGILState_Check());
236-
235+
absl::MutexLock lock(env->mutex_);
237236
if (env->compiler_) {
238237
return env->compiler_.get();
239238
}
@@ -279,6 +278,7 @@ absl::StatusOr<const cel::Compiler*> PyCelEnvInternal::GetCompiler(
279278

280279
absl::StatusOr<const cel::Runtime*> PyCelEnvInternal::GetRuntime(
281280
const std::shared_ptr<PyCelEnvInternal>& env, RuntimeMode runtime_mode) {
281+
absl::MutexLock lock(env->mutex_);
282282
if (auto it = env->runtimes_.find(runtime_mode); it != env->runtimes_.end()) {
283283
return it->second.get();
284284
}
@@ -341,7 +341,7 @@ absl::StatusOr<const cel::Runtime*> PyCelEnvInternal::GetRuntime(
341341

342342
const PyCelType& PyCelEnvInternal::GetVariableType(
343343
const std::string& name) const {
344-
ABSL_CHECK(PyGILState_Check());
344+
absl::MutexLock lock(mutex_);
345345
auto it = variable_types_.find(name);
346346
if (it != variable_types_.end()) {
347347
return it->second;
@@ -363,9 +363,8 @@ CelExtensionHandle::CelExtensionHandle(CelExtensionHandle&& other)
363363

364364
CelExtensionHandle::~CelExtensionHandle() {
365365
if (py_extension_ != nullptr) {
366-
auto gil_state = PyGILState_Ensure();
366+
py::gil_scoped_acquire acquire;
367367
Py_DECREF(py_extension_);
368-
PyGILState_Release(gil_state);
369368
}
370369
}
371370

cel_expr_python/py_cel_env_internal.h

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#include "absl/container/flat_hash_map.h"
2626
#include "absl/status/status.h"
2727
#include "absl/status/statusor.h"
28+
#include "absl/synchronization/mutex.h"
2829
#include "common/container.h"
2930
#include "compiler/compiler.h"
3031
#include "env/env.h"
@@ -117,7 +118,8 @@ class PyCelEnvInternal {
117118
// Use NewCelEnvInternal() to create an instance.
118119
PyCelEnvInternal(
119120
const PyCelEnvConfig& env_config, const PyCelOptions& options,
120-
PyObject* py_descriptor_pool, std::vector<CelExtensionHandle> extensions,
121+
PyObject* py_descriptor_pool,
122+
std::vector<CelExtensionHandle> extension_handles,
121123
absl::flat_hash_map<std::string, py::object>& function_impls);
122124

123125
absl::Status ConfigureStandardExtension(
@@ -138,11 +140,12 @@ class PyCelEnvInternal {
138140
std::shared_ptr<PyMessageFactory> py_message_factory_;
139141
// Synchronized by the GIL.
140142
absl::flat_hash_map<std::string, PyCelType> variable_types_;
143+
mutable absl::Mutex mutex_;
141144
std::vector<CelExtensionHandle> extensions_;
142145
absl::flat_hash_map<std::string, py::object> function_impls_;
143-
std::unique_ptr<cel::Compiler> compiler_;
146+
std::unique_ptr<cel::Compiler> compiler_ ABSL_GUARDED_BY(mutex_);
144147
absl::flat_hash_map<RuntimeMode, std::unique_ptr<const cel::Runtime>>
145-
runtimes_;
148+
runtimes_ ABSL_GUARDED_BY(mutex_);
146149
};
147150

148151
} // namespace cel_python

cel_expr_python/py_cel_expression.cc

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,6 @@ void PyCelExpression::DefinePythonBindings(py::module& m) {
105105
absl::StatusOr<PyCelExpression> PyCelExpression::Compile(
106106
const std::shared_ptr<PyCelEnvInternal>& env, const std::string& cel_expr,
107107
bool disable_check) {
108-
ABSL_CHECK(PyGILState_Check());
109-
110108
CEL_PYTHON_ASSIGN_OR_RETURN(const cel::Compiler* compiler,
111109
PyCelEnvInternal::GetCompiler(env));
112110

0 commit comments

Comments
 (0)