Skip to content

Commit 5a52c4f

Browse files
authored
[mypyc] Create function wrappers of nested coroutines lazily (#22048)
Each nested async def instance used to get it's own freshly allocated function wrapper object, which is only needed relatively rarely for introspection (e.g. `__name__`). This was slow to construct. Now we construct this lazily. On free-threaded builds we need to deal with other threads racing as well. Top-level coroutines and methods are unaffected. Their wrappers were already created once at import time. Microbenchmark results (Python 3.14, 1M iterations): * create a nested coroutine closure: 197 ns -> 16 ns * create it and run the coroutine: 713 ns -> 526 ns
1 parent bdef3aa commit 5a52c4f

4 files changed

Lines changed: 157 additions & 9 deletions

File tree

‎mypyc/codegen/emitclass.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1498,7 +1498,20 @@ def success() -> None:
14981498
wrapper_name = emit_instance(cl.methods["__call__"], cl.coroutine_name)
14991499
struct_name = cl.struct_name(emitter.names)
15001500
attr = emitter.attr(CPYFUNCTION_NAME)
1501-
emitter.emit_line(f"(({struct_name} *)type)->{attr} = {wrapper_name};")
1501+
if IS_FREE_THREADED:
1502+
emitter.emit_line(
1503+
f"CPy_InitAttrRefIfNull(type, (PyObject **)&(({struct_name} *)type)->{attr}, "
1504+
f"{wrapper_name});"
1505+
)
1506+
else:
1507+
# Creating the wrapper may run arbitrary code (e.g. via GC), which could
1508+
# have initialized the attribute already.
1509+
field = f"(({struct_name} *)type)->{attr}"
1510+
emitter.emit_line(f"if ({field} == NULL) {{")
1511+
emitter.emit_line(f"{field} = {wrapper_name};")
1512+
emitter.emit_line("} else {")
1513+
emitter.emit_line(f"Py_DECREF({wrapper_name});")
1514+
emitter.emit_line("}")
15021515
return success()
15031516

15041517
if not any(fn.decl.is_coroutine for fn in cl.methods.values()):

‎mypyc/irbuild/callable_class.py‎

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,26 @@
66

77
from __future__ import annotations
88

9-
from mypyc.common import CPYFUNCTION_NAME, ENV_ATTR_NAME, PROPSET_PREFIX, SELF_NAME
9+
from mypyc.common import (
10+
CPYFUNCTION_NAME,
11+
ENV_ATTR_NAME,
12+
IS_FREE_THREADED,
13+
PROPSET_PREFIX,
14+
SELF_NAME,
15+
)
1016
from mypyc.ir.class_ir import ClassIR
1117
from mypyc.ir.func_ir import FuncDecl, FuncIR, FuncSignature, RuntimeArg
12-
from mypyc.ir.ops import BasicBlock, Call, GetAttr, Integer, Register, Return, SetAttr, Value
18+
from mypyc.ir.ops import (
19+
BasicBlock,
20+
Branch,
21+
Call,
22+
GetAttr,
23+
Integer,
24+
Register,
25+
Return,
26+
SetAttr,
27+
Value,
28+
)
1329
from mypyc.ir.rtypes import RInstance, c_pointer_rprimitive, int_rprimitive, object_rprimitive
1430
from mypyc.irbuild.builder import IRBuilder
1531
from mypyc.irbuild.context import FuncInfo, ImplicitClass
@@ -119,7 +135,24 @@ def add_coroutine_properties(
119135
line = builder.fn_info.fitem.line
120136

121137
def get_func_wrapper() -> Value:
122-
return builder.add(GetAttr(builder.self(), CPYFUNCTION_NAME, line))
138+
# The wrapper is created lazily on first introspection, since creating it
139+
# (including a code object) for every instance is expensive.
140+
self_reg = builder.self()
141+
init, done = BasicBlock(), BasicBlock()
142+
cur = builder.add(
143+
GetAttr(
144+
self_reg,
145+
CPYFUNCTION_NAME,
146+
line,
147+
borrow=not IS_FREE_THREADED,
148+
allow_error_value=True,
149+
)
150+
)
151+
builder.add(Branch(cur, init, done, Branch.IS_ERROR))
152+
builder.activate_block(init)
153+
builder.add_coroutine_setup_call(callable_class_ir.name, self_reg)
154+
builder.goto_and_activate(done)
155+
return builder.add(GetAttr(self_reg, CPYFUNCTION_NAME, line))
123156

124157
for name, primitive in properties.items():
125158
with builder.enter_method(callable_class_ir, name, object_rprimitive, internal=True):
@@ -243,9 +276,6 @@ def instantiate_callable_class(builder: IRBuilder, fn_info: FuncInfo) -> Value:
243276
# happens before the callable can escape.
244277
set_env.mark_as_initializer()
245278
builder.add(set_env)
246-
# Initialize function wrapper for callable classes. As opposed to regular functions,
247-
# each instance of a callable class needs its own wrapper because they might be instantiated
248-
# inside other functions.
249-
if not fn_info.in_non_ext and fn_info.is_coroutine:
250-
builder.add_coroutine_setup_call(fn_info.callable_class.ir.name, func_reg)
279+
# Callable classes of coroutines need their own function wrapper per instance
280+
# (for introspection), but it's created lazily by the property getters/setters.
251281
return func_reg

‎mypyc/lib-rt/pythonsupport.h‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ static inline void CPy_SetAttrRef(PyObject *owner, PyObject **field, PyObject *v
129129
Py_XDECREF(old);
130130
}
131131

132+
// Publish a lazily created attribute once. Concurrent initializers may create
133+
// separate values; only the first is installed, and the others are discarded.
134+
// Decref outside the owner's critical section in case it runs Python code.
135+
static inline void CPy_InitAttrRefIfNull(PyObject *owner, PyObject **field, PyObject *value) {
136+
Py_BEGIN_CRITICAL_SECTION(owner);
137+
if (_Py_atomic_load_ptr_relaxed(field) == NULL) {
138+
_Py_atomic_store_ptr_release(field, value);
139+
value = NULL;
140+
}
141+
Py_END_CRITICAL_SECTION();
142+
Py_XDECREF(value);
143+
}
144+
132145
// Initialize a native attribute that is known to be previously undefined (NULL),
133146
// stealing the reference to 'value'.
134147
//

‎mypyc/test-data/run-async.test‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2162,6 +2162,98 @@ from typing import Any, Generator
21622162

21632163
def run(x: object) -> object: ...
21642164

2165+
[case testNestedCoroutineLazyFunctionWrapper]
2166+
import asyncio
2167+
import inspect
2168+
from typing import Any
2169+
2170+
def make(n: int) -> Any:
2171+
async def inner(x: int = 5) -> int:
2172+
return x + n
2173+
2174+
return inner
2175+
2176+
def test_set_name_first() -> None:
2177+
f = make(1)
2178+
# The first introspection access is a setter.
2179+
f.__name__ = "renamed"
2180+
assert f.__name__ == "renamed"
2181+
assert inspect.iscoroutinefunction(f)
2182+
assert asyncio.run(f()) == 6
2183+
2184+
def test_instances_are_independent() -> None:
2185+
a = make(1)
2186+
b = make(2)
2187+
assert asyncio.run(a(1)) == 2
2188+
a.__name__ = "a"
2189+
assert b.__name__ == "inner"
2190+
assert a.__name__ == "a"
2191+
assert asyncio.run(b(1)) == 3
2192+
2193+
def test_freed_instance_reuse() -> None:
2194+
for i in range(5):
2195+
f = make(i)
2196+
assert f.__name__ == "inner"
2197+
f.__name__ = f"name{i}"
2198+
assert f.__code__ is not None
2199+
del f
2200+
# Never introspected, so the wrapper is never created.
2201+
g = make(i)
2202+
assert asyncio.run(g(0)) == i
2203+
del g
2204+
2205+
[file asyncio/__init__.pyi]
2206+
def run(x: object) -> object: ...
2207+
2208+
[case testNestedCoroutineConcurrentFirstIntrospection]
2209+
from typing import Any
2210+
2211+
def make() -> Any:
2212+
async def inner() -> None:
2213+
pass
2214+
2215+
return inner
2216+
2217+
[file driver.py]
2218+
import gc
2219+
import threading
2220+
2221+
from native import make
2222+
2223+
workers = 16
2224+
rounds = 300
2225+
start = threading.Barrier(workers + 1)
2226+
done = threading.Barrier(workers + 1)
2227+
current = [None]
2228+
2229+
def worker():
2230+
for _ in range(rounds):
2231+
start.wait()
2232+
f = current[0]
2233+
assert f.__name__ == "inner"
2234+
done.wait()
2235+
2236+
threads = [threading.Thread(target=worker) for _ in range(workers)]
2237+
for thread in threads:
2238+
thread.start()
2239+
2240+
f = make()
2241+
assert f.__name__ == "inner"
2242+
wrapper_type = type(f.__cpyfunction__)
2243+
del f
2244+
gc.collect()
2245+
before = sum(type(obj) is wrapper_type for obj in gc.get_objects())
2246+
for _ in range(rounds):
2247+
current[0] = make()
2248+
start.wait()
2249+
done.wait()
2250+
current[0] = None
2251+
for thread in threads:
2252+
thread.join()
2253+
gc.collect()
2254+
after = sum(type(obj) is wrapper_type for obj in gc.get_objects())
2255+
assert after == before, (before, after)
2256+
21652257
[case testCPyFunctionVariableNameConflict]
21662258
class C:
21672259
async def operation(self) -> int:

0 commit comments

Comments
 (0)