Skip to content

Commit 0ff707d

Browse files
authored
[mypyc] Ensure a generator can't be entered while it's being executed (#21939)
This matches Python semantics -- an exception is now raised if there is an attempt to enter generator/coroutine while it's running. This also allows unsynchronized access of generator attributes in free-threaded builds, since there can't be concurrent accesses (in simple cases where we use a merged generator and environment). This has a big performance impact on free-threaded-builds. Some microbenchmarks were 1.5x+ faster with this optimization, as synchronized attribute access is quite inefficient, and it was being used for all registers in generators and async defs. I used coding agent assist but reviewed changes manually.
1 parent 89b3730 commit 0ff707d

10 files changed

Lines changed: 220 additions & 8 deletions

File tree

mypyc/codegen/emitclass.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
NATIVE_PREFIX,
3535
PREFIX,
3636
REG_PREFIX,
37+
RUNNING_FIELD,
3738
short_id_from_name,
3839
)
3940
from mypyc.ir.class_ir import ClassIR, VTableEntries
@@ -264,7 +265,7 @@ def generate_class(cl: ClassIR, module: str, emitter: Emitter) -> None:
264265
fields: dict[str, str] = {"tp_name": f'"{name}"'}
265266

266267
generate_full = not cl.is_trait and not cl.builtin_base
267-
needs_getseters = cl.needs_getseters or not cl.is_generated or cl.has_dict
268+
needs_getseters = cl.needs_getseters_table
268269

269270
if not cl.builtin_base:
270271
fields["tp_new"] = new_name
@@ -484,6 +485,13 @@ def generate_object_struct(cl: ClassIR, emitter: Emitter) -> None:
484485
lines += ["typedef struct {", "PyObject_HEAD", "CPyVTableItem *vtable;"]
485486
if cl.has_method("__call__"):
486487
lines.append("vectorcallfunc vectorcall;")
488+
# The flag affects attribute offsets, so concrete bases must agree on its presence.
489+
assert all(
490+
base.has_running_flag == cl.has_running_flag for base in cl.base_mro
491+
), f"{cl.name} disagrees with a base class about the running flag"
492+
if cl.has_running_flag:
493+
# This implementation field is intentionally absent from the IR attributes.
494+
lines.append(f"uint32_t {RUNNING_FIELD};")
487495
bitmap_attrs = []
488496
for base in reversed(cl.base_mro):
489497
if not base.is_trait:

mypyc/codegen/emitfunc.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
IS_FREE_THREADED,
1919
NATIVE_PREFIX,
2020
REG_PREFIX,
21+
RUNNING_FIELD,
2122
)
2223
from mypyc.ir.class_ir import ClassIR
2324
from mypyc.ir.func_ir import FUNC_CLASSMETHOD, FUNC_STATICMETHOD, FuncDecl, FuncIR, all_values
@@ -129,12 +130,18 @@ def native_function_header(fn: FuncDecl, emitter: Emitter) -> str:
129130

130131

131132
def generate_native_function(
132-
fn: FuncIR, emitter: Emitter, source_path: str, module_name: str
133+
fn: FuncIR,
134+
emitter: Emitter,
135+
source_path: str,
136+
module_name: str,
137+
running_flag_class: ClassIR | None = None,
133138
) -> None:
134139
declarations = Emitter(emitter.context)
135140
names = generate_names_for_ir(fn.arg_regs, fn.blocks)
136141
body = Emitter(emitter.context, names)
137-
visitor = FunctionEmitterVisitor(body, declarations, source_path, module_name)
142+
visitor = FunctionEmitterVisitor(
143+
body, declarations, source_path, module_name, running_flag_class
144+
)
138145

139146
declarations.emit_line(f"{native_function_header(fn.decl, emitter)} {{")
140147
body.indent()
@@ -183,6 +190,10 @@ def generate_native_function(
183190
if not is_next_block or is_problematic_op:
184191
fn.blocks[target.label].referenced = True
185192

193+
if running_flag_class is not None:
194+
# Place this before all labels so it runs on resume, but not on internal jumps.
195+
visitor.emit_claim_running_flag(fn)
196+
186197
common = frequently_executed_blocks(fn.blocks[0])
187198

188199
for i in range(len(blocks)):
@@ -209,13 +220,21 @@ def generate_native_function(
209220

210221
class FunctionEmitterVisitor(OpVisitor[None]):
211222
def __init__(
212-
self, emitter: Emitter, declarations: Emitter, source_path: str, module_name: str
223+
self,
224+
emitter: Emitter,
225+
declarations: Emitter,
226+
source_path: str,
227+
module_name: str,
228+
running_flag_class: ClassIR | None = None,
213229
) -> None:
214230
self.emitter = emitter
215231
self.names = emitter.names
216232
self.declarations = declarations
217233
self.source_path = source_path
218234
self.module_name = module_name
235+
# Set while emitting a generator helper protected by its running flag.
236+
self.running_flag_class = running_flag_class
237+
self.running_flag_ptr: str | None = None
219238
self.literals = emitter.context.literals
220239
self.rare = False
221240
# Next basic block to be processed after the current one (if any), set by caller
@@ -291,8 +310,28 @@ def visit_branch(self, op: Branch) -> None:
291310

292311
self.emit_lines("} else", " goto %s;" % self.label(false))
293312

313+
def emit_claim_running_flag(self, fn: FuncIR) -> None:
314+
"""Claim the generator's running flag or raise ValueError."""
315+
cl = self.running_flag_class
316+
assert cl is not None
317+
struct = cl.struct_name(self.names)
318+
self_str = self.reg(fn.arg_regs[0])
319+
self.running_flag_ptr = f"&(({struct} *){self_str})->{RUNNING_FIELD}"
320+
flag = self.running_flag_ptr
321+
is_coroutine = 1 if cl.has_method("__await__") else 0
322+
self.emit_line(f"if (unlikely(!CPyGen_TryEnter({flag}))) {{")
323+
self.emit_line(f"return CPyGen_AlreadyExecutingError({is_coroutine});")
324+
self.emit_line("}")
325+
326+
def emit_release_running_flag(self) -> None:
327+
"""Release the flag; every helper exit is represented by Return."""
328+
assert self.running_flag_ptr is not None
329+
self.emit_line(f"CPyGen_Exit({self.running_flag_ptr});")
330+
294331
def visit_return(self, op: Return) -> None:
295332
value_str = self.reg(op.value)
333+
if self.running_flag_class is not None:
334+
self.emit_release_running_flag()
296335
self.emit_line("return %s;" % value_str)
297336

298337
def visit_tuple_set(self, op: TupleSet) -> None:
@@ -418,9 +457,15 @@ def emit_load_attr_take_ref(
418457
for attributes safe to borrow on free-threaded builds (Final and vec attrs -- see
419458
transform_member_expr in irbuild), whose values live as long as their container.
420459
The default (GIL) build always takes the plain-load path and increfs separately.
460+
461+
Thread-confined attributes also use plain loads; see
462+
ClassIR.attrs_are_thread_confined.
421463
"""
422464
use_get_attr_ref = (
423-
IS_FREE_THREADED and is_simple_refcounted_pointer(attr_rtype) and not op.is_borrowed
465+
IS_FREE_THREADED
466+
and is_simple_refcounted_pointer(attr_rtype)
467+
and not op.is_borrowed
468+
and not cl.attrs_are_thread_confined()
424469
)
425470
if use_get_attr_ref and cl.is_final_attr(op.attr):
426471
self.emitter.emit_line(f"{dest} = CPy_GetAttrRefFinal((PyObject **)&{attr_expr});")
@@ -578,7 +623,11 @@ def visit_set_attr(self, op: SetAttr) -> None:
578623
)
579624
self.emit_line(f"{dest} = 1;")
580625
self.emitter.emit_error_check(tmp, ret_type, f"{dest} = 0;")
581-
elif IS_FREE_THREADED and is_simple_refcounted_pointer(attr_rtype):
626+
elif (
627+
IS_FREE_THREADED
628+
and is_simple_refcounted_pointer(attr_rtype)
629+
and not cl.attrs_are_thread_confined()
630+
):
582631
# In free-threaded builds, publishing a single reference-counted
583632
# 'PyObject *' field must be atomic so a concurrent reader (see
584633
# CPy_GetAttrRef) never observes a torn pointer or a freed value.

mypyc/codegen/emitmodule.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
from mypyc.ir.rtypes import RType
7676
from mypyc.irbuild.main import build_ir
7777
from mypyc.irbuild.mapper import Mapper
78-
from mypyc.irbuild.prepare import load_type_map
78+
from mypyc.irbuild.prepare import GENERATOR_HELPER_NAME, load_type_map
7979
from mypyc.namegen import NameGenerator, exported_name
8080
from mypyc.options import CompilerOptions
8181
from mypyc.transform.copy_propagation import do_copy_propagation
@@ -708,12 +708,19 @@ def generate_c_for_modules(self) -> list[tuple[str, str]]:
708708
if cl.is_ext_class:
709709
generate_class(cl, module_name, emitter)
710710

711+
running_flag_classes = {cl.name: cl for cl in module.classes if cl.has_running_flag}
712+
711713
# Generate Python extension module definitions and module initialization functions.
712714
self.generate_module_def(emitter, module_name, module)
713715

714716
for fn in module.functions:
715717
emitter.emit_line()
716-
generate_native_function(fn, emitter, self.source_paths[module_name], module_name)
718+
running_flag_class = None
719+
if fn.decl.name == GENERATOR_HELPER_NAME and fn.class_name is not None:
720+
running_flag_class = running_flag_classes.get(fn.class_name)
721+
generate_native_function(
722+
fn, emitter, self.source_paths[module_name], module_name, running_flag_class
723+
)
717724
if fn.name != TOP_LEVEL_NAME and not fn.internal:
718725
emitter.emit_line()
719726
if is_fastcall_supported(fn, emitter.capi_version):

mypyc/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
GENERATOR_ATTRIBUTE_PREFIX: Final = "__mypyc_generator_attribute__"
2929
CPYFUNCTION_NAME = "__cpyfunction__"
3030

31+
# Omits the prefix added to user attribute fields, so it cannot collide with one.
32+
RUNNING_FIELD: Final = "mypyc_running"
33+
3134
# Max short int we accept as a literal is based on 32-bit platforms,
3235
# so that we can just always emit the same code.
3336

mypyc/ir/class_ir.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,12 @@ def __init__(
253253
# Name of the function if this a callable class representing a coroutine.
254254
self.coroutine_name: str | None = None
255255

256+
# Does this generator or coroutine helper serialize execution using an instance flag?
257+
self.has_running_flag = False
258+
259+
# Does this generator object contain its merged environment?
260+
self.has_merged_generator_env = False
261+
256262
def __repr__(self) -> str:
257263
return (
258264
"ClassIR("
@@ -305,6 +311,25 @@ def is_final_attr(self, name: str) -> bool:
305311
return False
306312
return False
307313

314+
@property
315+
def needs_getseters_table(self) -> bool:
316+
"""Do we generate a tp_getset table exposing the attributes to Python?"""
317+
return self.needs_getseters or not self.is_generated or self.has_dict
318+
319+
def attrs_are_thread_confined(self) -> bool:
320+
"""Can these attributes safely use plain access in free-threaded builds?
321+
322+
This requires locals to live directly in the generator object, execution to be
323+
serialized by its running flag, and no Python getseters exposing the attributes.
324+
A separate environment does not qualify because captured locals may be accessed
325+
by nested functions.
326+
"""
327+
return (
328+
self.has_merged_generator_env
329+
and self.has_running_flag
330+
and not self.needs_getseters_table
331+
)
332+
308333
def class_final_attr_details(self, name: str) -> tuple[RType, ClassIR] | None:
309334
"""Look up a (possibly inherited) class-body Final attribute.
310335
@@ -495,6 +520,8 @@ def serialize(self) -> JsonDict:
495520
"init_self_leak": self.init_self_leak,
496521
"env_user_function": self.env_user_function.id if self.env_user_function else None,
497522
"reuse_freed_instance": self.reuse_freed_instance,
523+
"has_running_flag": self.has_running_flag,
524+
"has_merged_generator_env": self.has_merged_generator_env,
498525
"is_acyclic": self.is_acyclic,
499526
"is_enum": self.is_enum,
500527
"is_coroutine": self.coroutine_name,
@@ -561,6 +588,8 @@ def deserialize(cls, data: JsonDict, ctx: DeserMaps) -> ClassIR:
561588
ctx.functions[data["env_user_function"]] if data["env_user_function"] else None
562589
)
563590
ir.reuse_freed_instance = data["reuse_freed_instance"]
591+
ir.has_running_flag = data["has_running_flag"]
592+
ir.has_merged_generator_env = data["has_merged_generator_env"]
564593
ir.is_acyclic = data.get("is_acyclic", False)
565594
ir.is_enum = data["is_enum"]
566595
ir.coroutine_name = data["is_coroutine"]

mypyc/irbuild/generator.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,11 @@ def setup_generator_class(builder: IRBuilder) -> ClassIR:
170170
mapper = builder.mapper
171171
assert isinstance(builder.fn_info.fitem, FuncDef), builder.fn_info.fitem
172172
generator_class_ir = mapper.fdef_to_generator[builder.fn_info.fitem]
173+
generator_class_ir.has_running_flag = True
173174
if builder.fn_info.can_merge_generator_and_env_classes():
174175
builder.fn_info.env_class = generator_class_ir
176+
# The merged environment can be thread-confined; see attrs_are_thread_confined.
177+
generator_class_ir.has_merged_generator_env = True
175178
else:
176179
generator_class_ir.attributes[ENV_ATTR_NAME] = RInstance(builder.fn_info.env_class)
177180
if not builder.fn_info.fitem.is_coroutine:

mypyc/lib-rt/CPy.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,7 @@ static inline PyObject *CPy_TYPE(PyObject *obj) {
991991

992992
PyObject *CPy_CalculateMetaclass(PyObject *type, PyObject *o);
993993
PyObject *CPy_GetCoro(PyObject *obj);
994+
PyObject *CPyGen_AlreadyExecutingError(int is_coroutine);
994995
PyObject *CPyIter_Send(PyObject *iter, PyObject *val);
995996
int CPy_YieldFromErrorHandle(PyObject *iter, PyObject **outp);
996997
PyObject *CPy_FetchStopIterationValue(void);

mypyc/lib-rt/misc_ops.c

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ PyObject *CPy_GetCoro(PyObject *obj)
2222
}
2323
}
2424

25+
// Raise CPython-compatible errors after a failed CPyGen_TryEnter.
26+
PyObject *CPyGen_AlreadyExecutingError(int is_coroutine)
27+
{
28+
PyErr_SetString(PyExc_ValueError,
29+
is_coroutine ? "coroutine already executing"
30+
: "generator already executing");
31+
return NULL;
32+
}
33+
2534
PyObject *CPyIter_Send(PyObject *iter, PyObject *val)
2635
{
2736
// Do a send, or a next if second arg is None.

mypyc/lib-rt/pythonsupport.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,37 @@ static inline void CPy_SetAttrRef(PyObject **field, PyObject *value) {
166166
static inline void CPy_InitAttrRef(PyObject **field, PyObject *value) {
167167
_Py_atomic_store_ptr_relaxed(field, value);
168168
}
169+
170+
#endif
171+
172+
// Generated generator and coroutine helpers claim this flag while executing,
173+
// rejecting reentrant or concurrent resumes.
174+
//
175+
// On free-threaded builds, the atomic exchange provides mutual exclusion and acquire
176+
// ordering; the release store publishes body writes to the next resume. This also
177+
// permits plain access to private generator attributes. Claiming must be a single
178+
// atomic operation, or two threads could both observe a clear flag and enter. Under
179+
// the GIL, plain accesses suffice.
180+
#ifdef Py_GIL_DISABLED
181+
static inline int CPyGen_TryEnter(uint32_t *running) {
182+
return _Py_atomic_exchange_uint32(running, 1) == 0;
183+
}
184+
185+
static inline void CPyGen_Exit(uint32_t *running) {
186+
_Py_atomic_store_uint32_release(running, 0);
187+
}
188+
#else
189+
static inline int CPyGen_TryEnter(uint32_t *running) {
190+
if (*running) {
191+
return 0;
192+
}
193+
*running = 1;
194+
return 1;
195+
}
196+
197+
static inline void CPyGen_Exit(uint32_t *running) {
198+
*running = 0;
199+
}
169200
#endif
170201

171202
PyObject* update_bases(PyObject *bases);

mypyc/test-data/run-generators.test

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,3 +1002,75 @@ def test_borrow_across_yield_from() -> None:
10021002
assert False
10031003

10041004
[typing fixtures/typing-full.pyi]
1005+
1006+
[case testReentrantResume]
1007+
from typing import Any, Iterator, Optional
1008+
from testutil import assertRaises
1009+
1010+
box: list[Optional[Iterator[int]]] = [None]
1011+
1012+
def self_resuming() -> Iterator[int]:
1013+
g = box[0]
1014+
assert g is not None
1015+
yield next(g)
1016+
1017+
def test_reentrant_next() -> None:
1018+
g = self_resuming()
1019+
box[0] = g
1020+
with assertRaises(ValueError, "generator already executing"):
1021+
next(g)
1022+
# The generator was left in a completed state by the propagating exception.
1023+
with assertRaises(StopIteration):
1024+
next(g)
1025+
1026+
cbox: list[Any] = [None]
1027+
1028+
async def self_sending() -> int:
1029+
cbox[0].send(None)
1030+
return 1
1031+
1032+
def test_reentrant_send_to_coroutine() -> None:
1033+
c: Any = self_sending()
1034+
cbox[0] = c
1035+
with assertRaises(ValueError, "coroutine already executing"):
1036+
c.send(None)
1037+
1038+
[case testReentrantResumeWithSeparateEnvironment]
1039+
from typing import Any, Iterator, Optional
1040+
from testutil import assertRaises
1041+
1042+
def outer() -> int:
1043+
box: list[Any] = []
1044+
1045+
def nested() -> Iterator[int]:
1046+
# Captures 'box', so the environment is a separate class and the locals
1047+
# are not private to the generator object. The running flag still applies.
1048+
yield next(box[0])
1049+
1050+
g = nested()
1051+
box.append(g)
1052+
return next(g)
1053+
1054+
def test_reentrant_next_in_nested_generator() -> None:
1055+
with assertRaises(ValueError, "generator already executing"):
1056+
outer()
1057+
1058+
class Base:
1059+
def gen(self) -> Iterator[int]:
1060+
yield 1
1061+
1062+
class Derived(Base):
1063+
def gen(self) -> Iterator[int]:
1064+
g = box[0]
1065+
assert g is not None
1066+
yield next(g)
1067+
1068+
box: list[Optional[Iterator[int]]] = [None]
1069+
1070+
def test_generator_method_override_still_works() -> None:
1071+
assert list(Base().gen()) == [1]
1072+
b: Base = Derived()
1073+
g = b.gen()
1074+
box[0] = g
1075+
with assertRaises(ValueError, "generator already executing"):
1076+
next(g)

0 commit comments

Comments
 (0)