Skip to content

Commit 203d7a1

Browse files
authored
[mypyc] Keep all compiler spills on generator frames (#21958)
They are internal to the generator, and by moving them to the generator from the environment they can be accessed using faster attribute ops on free-threaded builds. This made a microbenchmark marginally faster on Python 3.14 (free-threaded). I used coding agent assist.
1 parent 4fedc1f commit 203d7a1

4 files changed

Lines changed: 67 additions & 29 deletions

File tree

mypyc/irbuild/builder.py

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ def __init__(
303303
# Whether the current top-level expression contains a suspension point
304304
# (await, yield or yield from). A whole-expression borrow can't span such a
305305
# point, since the borrowed value (and its root) live in registers that are
306-
# not spilled into the generator environment across the suspend.
306+
# not spilled into the generator frame across the suspend.
307307
self.expr_has_suspend = False
308308
# Saved expression state for enclosing functions (see enter()/leave()).
309309
self.expression_depth_stack: list[int] = []
@@ -1046,22 +1046,26 @@ def pop_loop_stack(self) -> None:
10461046
self.nonlocal_control.pop()
10471047

10481048
def make_spill_target(self, type: RType) -> AssignmentTarget:
1049-
"""Moves a given Value instance into the generator class' environment class."""
1050-
name = f"{TEMP_ATTR_NAME}{self.temp_counter}"
1049+
"""Moves a given Value instance into the private generator frame."""
1050+
frame = self.fn_info.generator_class
1051+
# Generator classes for overriding methods can inherit from one another. Include the
1052+
# module-qualified owning class name so unrelated helper spills don't alias an inherited
1053+
# struct field.
1054+
name = f"{TEMP_ATTR_NAME}1_{exported_name(frame.ir.fullname)}_{self.temp_counter}"
10511055
self.temp_counter += 1
1052-
target = self.add_var_to_env_class(Var(name), type, self.fn_info.generator_class)
1056+
target = self.add_var_to_class(Var(name), type, frame.ir, frame.self_reg)
10531057
return target
10541058

10551059
def spill(self, value: Value) -> AssignmentTarget:
1056-
"""Moves a given Value instance into the generator class' environment class."""
1060+
"""Moves a given Value instance into the private generator frame."""
10571061
target = self.make_spill_target(value.type)
10581062
# Shouldn't be able to fail
10591063
self.assign(target, value, NO_TRACEBACK_LINE_NO)
10601064
return target
10611065

10621066
def maybe_spill(self, value: Value) -> Value | AssignmentTarget:
10631067
"""
1064-
Moves a given Value instance into the environment class for generator functions. For
1068+
Moves a given Value instance into the private frame for generator functions. For
10651069
non-generator functions, leaves the Value instance as it is.
10661070
10671071
Returns an AssignmentTarget associated with the Value for generator functions and the
@@ -1073,7 +1077,7 @@ def maybe_spill(self, value: Value) -> Value | AssignmentTarget:
10731077

10741078
def maybe_spill_assignable(self, value: Value) -> Register | AssignmentTarget:
10751079
"""
1076-
Moves a given Value instance into the environment class for generator functions. For
1080+
Moves a given Value instance into the private frame for generator functions. For
10771081
non-generator functions, allocate a temporary Register.
10781082
10791083
Returns an AssignmentTarget associated with the Value for generator functions and an
@@ -1633,24 +1637,45 @@ def add_var_to_env_class(
16331637
keep_alive_on_completion: bool = False,
16341638
prefix: str = "",
16351639
) -> AssignmentTarget:
1636-
# First, define the variable name as an attribute of the environment class, and then
1637-
# construct a target for that attribute.
1640+
return self.add_var_to_class(
1641+
var,
1642+
rtype,
1643+
self.fn_info.env_class,
1644+
base.curr_env_reg,
1645+
reassign=reassign,
1646+
always_defined=always_defined,
1647+
keep_alive_on_completion=keep_alive_on_completion,
1648+
prefix=prefix,
1649+
)
1650+
1651+
def add_var_to_class(
1652+
self,
1653+
var: SymbolNode,
1654+
rtype: RType,
1655+
cls: ClassIR,
1656+
base: Value,
1657+
reassign: bool = False,
1658+
always_defined: bool = False,
1659+
keep_alive_on_completion: bool = False,
1660+
prefix: str = "",
1661+
) -> AssignmentTarget:
1662+
"""Declare an attribute on a class and construct a target using an explicit base."""
16381663
name = prefix + remangle_redefinition_name(var.name)
1639-
self.fn_info.env_class.attributes[name] = rtype
1664+
cls.attributes[name] = rtype
16401665
if keep_alive_on_completion:
1641-
self.fn_info.env_class.attrs_to_keep_alive_on_completion.add(name)
1666+
cls.attrs_to_keep_alive_on_completion.add(name)
16421667
if always_defined:
1643-
self.fn_info.env_class.attrs_with_defaults.add(name)
1644-
attr_target = AssignmentTargetAttr(base.curr_env_reg, name)
1668+
cls.attrs_with_defaults.add(name)
1669+
attr_target = AssignmentTargetAttr(base, name)
16451670

16461671
if reassign:
16471672
# Read the local definition of the variable, and set the corresponding attribute of
1648-
# the environment class' variable to be that value.
1673+
# the class' variable to be that value.
16491674
reg = self.read(self.lookup(var), self.fn_info.fitem.line)
1650-
self.add(SetAttr(base.curr_env_reg, name, reg, self.fn_info.fitem.line))
1675+
self.add(SetAttr(base, name, reg, self.fn_info.fitem.line))
16511676

16521677
# Override the local definition of the variable to instead point at the variable in
1653-
# the environment class.
1678+
# the class.
16541679
return self.add_target(var, attr_target)
16551680

16561681
def is_builtin_ref_expr(self, expr: RefExpr) -> bool:

mypyc/irbuild/for_helpers.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@ def need_cleanup(self) -> bool:
705705
def init(self, expr_reg: Value, target_type: RType) -> None:
706706
# Define targets to contain the expression, along with the iterator that will be used
707707
# for the for-loop. If we are inside of a generator function, spill these into the
708-
# environment class.
708+
# private generator frame.
709709
builder = self.builder
710710
iter_reg = builder.primitive_op(iter_op, [expr_reg], self.line)
711711
builder.maybe_spill(expr_reg)
@@ -753,7 +753,7 @@ def need_cleanup(self) -> bool:
753753

754754
def init(self, expr_reg: Value, target_type: RType) -> None:
755755
# Define target to contains the generator expression. It's also the iterator.
756-
# If we are inside a generator function, spill these into the environment class.
756+
# If we are inside a generator function, spill these into the private generator frame.
757757
builder = self.builder
758758
self.iter_target = builder.maybe_spill(expr_reg)
759759
self.target_type = target_type
@@ -811,7 +811,7 @@ def init(self, expr_reg: Value, target_type: RType) -> None:
811811
# Define targets to contain the expression, along with the
812812
# iterator that will be used for the for-loop. We are inside
813813
# of a generator function, so we will spill these into
814-
# environment class.
814+
# the private generator frame.
815815
builder = self.builder
816816
iter_reg = builder.call_c(aiter_op, [expr_reg], self.line)
817817
builder.maybe_spill(expr_reg)
@@ -910,7 +910,7 @@ def init(
910910
self.reverse = reverse
911911
# Define target to contain the expression, along with the index that will be used
912912
# for the for-loop. If we are inside of a generator function, spill these into the
913-
# environment class.
913+
# private generator frame.
914914
self.expr_target = builder.maybe_spill(expr_reg)
915915
if is_immutable_rprimitive(expr_reg.type):
916916
# If the expression is an immutable type, we can load the length just once.
@@ -1011,7 +1011,7 @@ def init(self, expr_reg: Value, target_type: RType) -> None:
10111011
builder = self.builder
10121012
self.target_type = target_type
10131013

1014-
# We add some variables to environment class, so they can be read across yield.
1014+
# Spill some values so they can be read across yield.
10151015
self.expr_target = builder.maybe_spill(expr_reg)
10161016
offset = Integer(0)
10171017
self.offset_target = builder.maybe_spill_assignable(offset)

mypyc/test-data/run-generators.test

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -772,9 +772,12 @@ def make() -> str:
772772

773773
def outer() -> Generator[str, str, str]:
774774
def nested(value: str) -> Generator[str, str, str]:
775-
# The result of make() is a compiler-generated temporary live across
776-
# the yield. The source argument stays in the separate environment.
777-
return make() + (yield value)
775+
for item in [value]:
776+
# The loop iterator and the result of make() are compiler-generated
777+
# temporaries live across the yield. The source bindings stay in the
778+
# separate environment.
779+
return make() + (yield item)
780+
return "unreachable"
778781

779782
return nested("right")
780783

@@ -792,9 +795,12 @@ def make_number() -> int:
792795

793796
class Same(other_base.Same):
794797
def gen(self) -> Generator[None, None, object]:
795-
# This spill has a different native representation from the base class's spill.
798+
# The builder and transform spills have different native representations from the
799+
# corresponding base class spills.
796800
# Both generator frames have the same short name, but different full names.
797-
return (make_number(), (yield None))[0]
801+
for _ in range(1):
802+
return (make_number(), (yield None))[0]
803+
return None
798804

799805
def run(g: Generator[None, None, object]) -> object:
800806
assert next(g) is None
@@ -816,8 +822,10 @@ def make_text() -> str:
816822

817823
class Same:
818824
def gen(self) -> Generator[None, None, object]:
819-
# The call result is live across the yield.
820-
return (make_text(), (yield None))[0]
825+
# The loop state and call result are live across the yield.
826+
for _ in [None]:
827+
return (make_text(), (yield None))[0]
828+
return None
821829

822830
[case testGeneratorReuse]
823831
from typing import Iterator, Any

mypyc/test/test_spill.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ def make() -> str:
2424
2525
def outer():
2626
def nested(value: str):
27-
return make() + (yield value)
27+
for item in [value]:
28+
# The loop iterator uses IR-builder-managed spill slots, while the
29+
# result of make() is spilled later by the spill transform.
30+
return make() + (yield item)
31+
return "unreachable"
2832
return nested("right")
2933
"""
3034
module, _, _, _ = build_ir_for_single_file2(source.splitlines())
@@ -41,6 +45,7 @@ def nested(value: str):
4145
# is protected by the running flag and can use plain attribute access.
4246
assert frame.attrs_are_thread_confined()
4347
assert NEXT_LABEL_ATTR_NAME in frame.attributes
48+
assert any(name.startswith(TEMP_ATTR_NAME + "1_") for name in frame.attributes)
4449
assert any(name.startswith(TEMP_ATTR_NAME + "2_") for name in frame.attributes)
4550

4651
# Source-level variables stay in the shared environment.

0 commit comments

Comments
 (0)