Skip to content

Commit 3ca6857

Browse files
authored
[mypyc] Use generator helper fast path on resume (#21954)
Extend the direct helper method fast path for statically known native generators and coroutines to resumes after suspension. This avoids generic send dispatch and StopIteration handling on completion. The savings compound in deep native coroutine chains, where each await frame would otherwise pay this overhead. In an (unrealistic) microbenchmark this made a long await chain 1.8x faster. With event loop overhead included and an `asyncio.sleep(0)`, call, the improvement in modified microbenchmark was about 10% when using uvloop. I used coding agent assist.
1 parent fbb46ae commit 3ca6857

2 files changed

Lines changed: 137 additions & 18 deletions

File tree

mypyc/irbuild/statement.py

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,30 +1351,46 @@ def emit_yield_from_or_await(
13511351

13521352
stop_block, main_block, done_block = BasicBlock(), BasicBlock(), BasicBlock()
13531353

1354-
if isinstance(iter_reg.type, RInstance) and iter_reg.type.class_ir.has_method(helper_method):
1355-
# Second fast path optimization: call helper directly (see also comment above).
1356-
#
1357-
# Calling a generated generator, so avoid raising StopIteration by passing
1358-
# an extra PyObject ** argument to helper where the stop iteration value is stored.
1359-
fast_path = True
1354+
fast_path = isinstance(iter_reg.type, RInstance) and iter_reg.type.class_ir.has_method(
1355+
helper_method
1356+
)
1357+
1358+
# Register where a native child stores its return value instead of raising
1359+
# StopIteration (only used on the fast path).
1360+
stop_iter_val = Register(object_rprimitive) if fast_path else None
1361+
1362+
def native_step(sent: Value) -> Value:
1363+
"""Advance a native generator/coroutine child by calling its helper directly.
1364+
1365+
Returns the value yielded by the child, or NULL if the child completed or
1366+
raised. On normal completion the return value is stored in stop_iter_val,
1367+
which is set to the error value if a real exception was raised instead.
1368+
"""
1369+
assert stop_iter_val is not None
13601370
obj = builder.read(iter_reg, line)
13611371
nn = builder.none_object()
1362-
stop_iter_val = Register(object_rprimitive)
13631372
err = builder.add(LoadErrorValue(object_rprimitive, undefines=True))
13641373
builder.assign(stop_iter_val, err, line)
13651374
ptr = builder.add(LoadAddress(object_pointer_rprimitive, stop_iter_val))
1366-
m = MethodCall(obj, helper_method, [nn, nn, nn, nn, ptr], line)
1375+
m = MethodCall(obj, helper_method, [nn, nn, nn, sent, ptr], line)
13671376
# Generators have custom error handling, so disable normal error handling.
13681377
m.error_kind = ERR_NEVER
1369-
_y_init = builder.add(m)
1378+
return builder.add(m)
1379+
1380+
if fast_path:
1381+
# Second fast path optimization: call helper directly (see also comment above).
1382+
#
1383+
# Calling a generated generator, so avoid raising StopIteration by passing
1384+
# an extra PyObject ** argument to helper where the stop iteration value is stored.
1385+
_y_init = native_step(builder.none_object())
13701386
else:
1371-
fast_path = False
13721387
_y_init = builder.call_c(next_raw_op, [builder.read(iter_reg, line)], line)
13731388

13741389
builder.add(Branch(_y_init, stop_block, main_block, Branch.IS_ERROR))
13751390

13761391
builder.activate_block(stop_block)
13771392
if fast_path:
1393+
assert stop_iter_val is not None
13781394
builder.primitive_op(propagate_if_error_op, [stop_iter_val], line)
13791395
builder.assign(result, stop_iter_val, line)
13801396
else:
@@ -1423,11 +1439,18 @@ def except_body() -> None:
14231439
builder.nonlocal_control[-1].gen_break(builder, line)
14241440

14251441
def else_body() -> None:
1426-
# Do a next() or a .send(). It will return NULL on exception
1427-
# but it won't automatically propagate.
1428-
_y = builder.call_c(
1429-
send_op, [builder.read(iter_reg, line), builder.read(received_reg, line)], line
1430-
)
1442+
# This path runs when the parent's yield is resumed normally via next() or send().
1443+
# An exception injected via throw() or close() takes the except_body path instead.
1444+
if fast_path:
1445+
# Reuse the direct helper call on resumes as well, so that native-to-native
1446+
# completion doesn't have to go through .send() and StopIteration.
1447+
_y = native_step(builder.read(received_reg, line))
1448+
else:
1449+
# Do a next() or a .send(). It will return NULL on exception
1450+
# but it won't automatically propagate.
1451+
_y = builder.call_c(
1452+
send_op, [builder.read(iter_reg, line), builder.read(received_reg, line)], line
1453+
)
14311454
ok, stop = BasicBlock(), BasicBlock()
14321455
builder.add(Branch(_y, stop, ok, Branch.IS_ERROR))
14331456

@@ -1436,10 +1459,17 @@ def else_body() -> None:
14361459
builder.assign(to_yield_reg, _y, line)
14371460
builder.nonlocal_control[-1].gen_continue(builder, line)
14381461

1439-
# Try extracting a return value from a StopIteration and return it.
1440-
# If it wasn't, this rereaises the exception.
14411462
builder.activate_block(stop)
1442-
builder.assign(result, builder.call_c(check_stop_op, [], line), line)
1463+
if fast_path:
1464+
assert stop_iter_val is not None
1465+
# The child either returned a value through the out pointer, or raised
1466+
# a real exception (in which case this propagates it).
1467+
builder.primitive_op(propagate_if_error_op, [stop_iter_val], line)
1468+
builder.assign(result, stop_iter_val, line)
1469+
else:
1470+
# Try extracting a return value from a StopIteration and return it.
1471+
# If it wasn't, this rereaises the exception.
1472+
builder.assign(result, builder.call_c(check_stop_op, [], line), line)
14431473
builder.nonlocal_control[-1].gen_break(builder, line)
14441474

14451475
builder.push_loop_stack(loop_block, done_block)

mypyc/test-data/run-async.test

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2409,3 +2409,92 @@ async def test_borrow_final_attr_across_await_after_comprehension() -> None:
24092409
async def sleep(t: float) -> None: ...
24102410

24112411
[typing fixtures/typing-full.pyi]
2412+
2413+
[case testRunAsyncResumedNativeAwait]
2414+
from typing import Any, Generator
2415+
2416+
from testutil import assertRaises
2417+
2418+
class MyError(Exception):
2419+
pass
2420+
2421+
class Suspend:
2422+
"""Awaitable that suspends the given number of times before returning."""
2423+
2424+
def __init__(self, n: int) -> None:
2425+
self.n = n
2426+
2427+
def __await__(self) -> Generator[Any, Any, int]:
2428+
i = 0
2429+
while i < self.n:
2430+
yield i
2431+
i += 1
2432+
return self.n
2433+
2434+
async def child(n: int) -> int:
2435+
total = await Suspend(n)
2436+
return total + 1
2437+
2438+
async def parent(n: int) -> int:
2439+
# Statically known native child that suspends before completing, so the
2440+
# await loop must resume it after a suspension.
2441+
return await child(n) + 10
2442+
2443+
async def grandparent(n: int) -> int:
2444+
return await parent(n) + 100
2445+
2446+
async def tuple_child(n: int) -> tuple[int, str]:
2447+
await Suspend(n)
2448+
return (n, "x")
2449+
2450+
async def tuple_parent(n: int) -> tuple[int, str]:
2451+
return await tuple_child(n)
2452+
2453+
async def raising_child(n: int) -> int:
2454+
await Suspend(n)
2455+
raise MyError()
2456+
2457+
async def raising_parent(n: int) -> int:
2458+
return await raising_child(n)
2459+
2460+
async def catching_parent(n: int) -> int:
2461+
try:
2462+
return await raising_child(n)
2463+
except MyError:
2464+
return -1
2465+
2466+
def drive(coro: Any) -> Any:
2467+
"""Drive a coroutine to completion without an event loop."""
2468+
steps = 0
2469+
while True:
2470+
try:
2471+
coro.send(None)
2472+
except StopIteration as e:
2473+
return e.value
2474+
steps += 1
2475+
assert steps < 100
2476+
2477+
def test_resumed_native_await() -> None:
2478+
for n in range(5):
2479+
assert drive(parent(n)) == n + 11
2480+
assert drive(grandparent(n)) == n + 111
2481+
assert drive(tuple_parent(n)) == (n, "x")
2482+
2483+
def test_resumed_native_await_exception() -> None:
2484+
for n in range(5):
2485+
with assertRaises(MyError):
2486+
drive(raising_parent(n))
2487+
assert drive(catching_parent(n)) == -1
2488+
2489+
def test_throw_into_suspended_native_child() -> None:
2490+
coro = parent(3)
2491+
coro.send(None)
2492+
with assertRaises(MyError):
2493+
coro.throw(MyError())
2494+
2495+
def test_close_suspended_native_child() -> None:
2496+
coro = parent(3)
2497+
coro.send(None)
2498+
coro.close()
2499+
2500+
[typing fixtures/typing-full.pyi]

0 commit comments

Comments
 (0)