Skip to content

Commit 7cd6e2a

Browse files
fix(py): restore push send throughput by avoiding per-send GIL release
- remove py.allow_threads from single-message PyRuntime send path - add regression test for push burst GIL interleaving behavior - add push regression benchmark script for cross-commit ARM/x86 comparison Co-authored-by: Iris Seravelle <iris.seravelle@gmail.com>
1 parent 5659f70 commit 7cd6e2a

4 files changed

Lines changed: 216 additions & 3 deletions

File tree

src/py/pool.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ mod tests {
212212
Arc::new(AtomicU64::new(0)),
213213
Arc::new(rt.clone()),
214214
)
215-
.expect("max_threads=0 should force shared pool mode without strict error");
215+
.expect("max_threads=0 should force shared pool mode without strict error");
216216
assert!(ch.is_none());
217217
});
218218
}

src/py/runtime.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,9 +1080,11 @@ impl PyRuntime {
10801080
}
10811081

10821082
fn send(&self, py: Python, pid: u64, data: &PyAny) -> PyResult<bool> {
1083+
let _ = py;
10831084
let msg = py_any_to_bytes(data)?;
1084-
let rt = self.inner.clone();
1085-
Ok(py.allow_threads(move || rt.send_user(pid, msg).is_ok()))
1085+
// Keep GIL for single-message sends so hot push-actor loops do not
1086+
// interleave callback execution on every call.
1087+
Ok(self.inner.send_user(pid, msg).is_ok())
10861088
}
10871089

10881090
/// Batch-send bytes-like payloads to a PID.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import argparse
2+
import time
3+
from statistics import mean
4+
5+
import iris
6+
7+
8+
def spawn_push_actors(rt, count: int, budget: int):
9+
pids = []
10+
t0 = time.perf_counter()
11+
12+
def no_op_handler(_msg):
13+
return None
14+
15+
for _ in range(count):
16+
pids.append(rt.spawn(no_op_handler, budget=budget, release_gil=False))
17+
18+
elapsed = time.perf_counter() - t0
19+
return pids, elapsed
20+
21+
22+
def send_one_per_actor(rt, pids, payload: bytes):
23+
t0 = time.perf_counter()
24+
for pid in pids:
25+
ok = rt.send(pid, payload)
26+
if not ok:
27+
raise RuntimeError(f"send failed for pid={pid}")
28+
elapsed = time.perf_counter() - t0
29+
return elapsed
30+
31+
32+
def spawn_pull_actors(rt, count: int, budget: int):
33+
pids = []
34+
t0 = time.perf_counter()
35+
36+
def mailbox_handler(mailbox):
37+
while True:
38+
msg = mailbox.recv()
39+
if msg is None:
40+
break
41+
42+
for _ in range(count):
43+
pids.append(rt.spawn_with_mailbox(mailbox_handler, budget=budget))
44+
45+
elapsed = time.perf_counter() - t0
46+
return pids, elapsed
47+
48+
49+
def cleanup(rt, pids):
50+
for pid in pids:
51+
rt.stop(pid)
52+
53+
54+
def fmt_rate(total: int, secs: float) -> str:
55+
if secs <= 0:
56+
return "inf"
57+
return f"{total / secs:,.0f}"
58+
59+
60+
def main():
61+
parser = argparse.ArgumentParser(
62+
description="One-shot benchmark for push actor send regression checks (ARM-friendly)."
63+
)
64+
parser.add_argument("--count", type=int, default=100_000, help="Number of actors/messages")
65+
parser.add_argument("--budget", type=int, default=10, help="Actor budget")
66+
parser.add_argument(
67+
"--payload-size",
68+
type=int,
69+
default=4,
70+
help="Payload size in bytes",
71+
)
72+
parser.add_argument(
73+
"--rounds",
74+
type=int,
75+
default=3,
76+
help="How many rounds to run for the push path",
77+
)
78+
parser.add_argument(
79+
"--include-pull",
80+
action="store_true",
81+
help="Also benchmark pull/mailbox actors in same run",
82+
)
83+
args = parser.parse_args()
84+
85+
payload = b"p" * args.payload_size
86+
87+
print("--- Iris Push Regression Benchmark ---")
88+
print(f"version={iris.version()}")
89+
print(
90+
f"count={args.count:,}, budget={args.budget}, payload_size={args.payload_size}, rounds={args.rounds}"
91+
)
92+
print("Tip: run this script on two commits and compare send msg/s.")
93+
94+
push_spawn_times = []
95+
push_send_times = []
96+
97+
for i in range(args.rounds):
98+
rt = iris.Runtime()
99+
pids = []
100+
try:
101+
pids, spawn_t = spawn_push_actors(rt, args.count, args.budget)
102+
send_t = send_one_per_actor(rt, pids, payload)
103+
push_spawn_times.append(spawn_t)
104+
push_send_times.append(send_t)
105+
106+
print(
107+
f"round {i + 1} push: spawn={spawn_t:.3f}s ({fmt_rate(args.count, spawn_t)} actors/s) | "
108+
f"send={send_t:.3f}s ({fmt_rate(args.count, send_t)} msg/s)"
109+
)
110+
finally:
111+
cleanup(rt, pids)
112+
113+
print("\n--- Push Summary (mean) ---")
114+
mean_spawn = mean(push_spawn_times)
115+
mean_send = mean(push_send_times)
116+
print(f"spawn: {mean_spawn:.3f}s ({fmt_rate(args.count, mean_spawn)} actors/s)")
117+
print(f"send : {mean_send:.3f}s ({fmt_rate(args.count, mean_send)} msg/s)")
118+
119+
if args.include_pull:
120+
rt = iris.Runtime()
121+
pids = []
122+
try:
123+
pids, pull_spawn_t = spawn_pull_actors(rt, args.count, args.budget)
124+
pull_send_t = send_one_per_actor(rt, pids, payload)
125+
print("\n--- Pull (mailbox) One Shot ---")
126+
print(
127+
f"spawn={pull_spawn_t:.3f}s ({fmt_rate(args.count, pull_spawn_t)} actors/s) | "
128+
f"send={pull_send_t:.3f}s ({fmt_rate(args.count, pull_send_t)} msg/s)"
129+
)
130+
finally:
131+
cleanup(rt, pids)
132+
133+
134+
if __name__ == "__main__":
135+
main()

tests/pyo3_runtime.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,82 @@ async fn py_send_many_accepts_bytes_like_objects() {
215215
assert_eq!(msgs, vec![b"one".to_vec(), b"two".to_vec(), b"three".to_vec()]);
216216
}
217217

218+
#[tokio::test]
219+
async fn py_send_push_loop_does_not_yield_gil_mid_burst_regression() {
220+
let (rt_py, pid, list_obj, old_switch): (PyObject, u64, PyObject, f64) = Python::with_gil(
221+
|py| {
222+
let module = iris::py::make_module(py).expect("make_module");
223+
let runtime_type = module
224+
.as_ref(py)
225+
.getattr("PyRuntime")
226+
.expect("no PyRuntime type");
227+
let rt = runtime_type.call0().expect("construct PyRuntime");
228+
229+
let locals = pyo3::types::PyDict::new(py);
230+
py.run(
231+
"import sys\nold = sys.getswitchinterval()\nsys.setswitchinterval(1.0)\nitems = []\ndef cb(b):\n items.append(b)\n",
232+
Some(locals),
233+
Some(locals),
234+
)
235+
.unwrap();
236+
237+
let cb = locals.get_item("cb").unwrap().unwrap();
238+
let list_obj: PyObject = locals.get_item("items").unwrap().unwrap().into_py(py);
239+
let old_switch: f64 = locals.get_item("old").unwrap().unwrap().extract().unwrap();
240+
241+
let pid: u64 = rt
242+
.call_method1("spawn_py_handler", (cb, 100usize, false))
243+
.unwrap()
244+
.extract()
245+
.unwrap();
246+
247+
for _ in 0..2000usize {
248+
let ok: bool = rt
249+
.call_method1("send", (pid, PyBytes::new(py, b"x")))
250+
.unwrap()
251+
.extract()
252+
.unwrap();
253+
assert!(ok);
254+
}
255+
256+
let len_during_burst: usize = list_obj
257+
.as_ref(py)
258+
.downcast::<pyo3::types::PyList>()
259+
.unwrap()
260+
.len();
261+
assert_eq!(
262+
len_during_burst, 0,
263+
"callbacks ran during send burst (send likely yielded GIL)"
264+
);
265+
266+
(rt.into_py(py), pid, list_obj, old_switch)
267+
},
268+
);
269+
270+
tokio::time::sleep(Duration::from_millis(250)).await;
271+
272+
Python::with_gil(|py| {
273+
let rt = rt_py.as_ref(py);
274+
let _ = rt; // Keep runtime alive while assertions run.
275+
276+
let items = list_obj
277+
.as_ref(py)
278+
.downcast::<pyo3::types::PyList>()
279+
.unwrap();
280+
assert!(
281+
items.len() > 0,
282+
"expected messages to be eventually processed after burst"
283+
);
284+
285+
let sys = py.import("sys").unwrap();
286+
sys.call_method1("setswitchinterval", (old_switch,))
287+
.unwrap();
288+
289+
// Cleanup spawned actor.
290+
let _ = rt.call_method1("stop", (pid,));
291+
});
292+
}
293+
218294
#[tokio::test]
219295
async fn py_runtime_spawn_and_send() {
220296
// create a single PyRuntime instance and keep it alive across await points

0 commit comments

Comments
 (0)