Skip to content

Commit 4fa6652

Browse files
authored
[mypyc] Keep generator local state in C locals when possible (#21982)
Initially generate IR for generators and coroutines where locals and temporaries are stored in registers. Only spill registers that are live across a yield/await to the generator object. Previously most registers were stored in the generator object as attributes. The new spill is implemented as a new transform. We still use the old spill transform for op results that are not stored in registers. This makes microbenchmarks where most work happens between awaits significantly faster, as C locals can be used for many operations instead of attributes. This also reduces the size of generator objects, as fewer struct fields are needed. Remove old spill-related helpers from the IR builder as unnecessary. I used coding agent assist.
1 parent 2563dfc commit 4fa6652

17 files changed

Lines changed: 729 additions & 149 deletions

mypyc/analysis/dataflow.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Generic, TypeVar
88

99
from mypyc.ir.ops import (
10+
ERR_NEVER,
1011
Assign,
1112
AssignMulti,
1213
BasicBlock,
@@ -518,6 +519,55 @@ def analyze_live_regs(blocks: list[BasicBlock], cfg: CFG) -> AnalysisResult[Valu
518519
)
519520

520521

522+
def analyze_live_regs_with_exception_edges(
523+
blocks: list[BasicBlock],
524+
) -> dict[BasicBlock, set[Value]]:
525+
"""Calculate block-entry liveness before exception handling is inserted.
526+
527+
Unlike get_cfg(), this models an error edge at each operation that can
528+
raise. This matters when an assignment later in the same block overwrites a
529+
value that the error handler can still read. Returns inserted for a yield
530+
are treated as edges to their continuation blocks.
531+
532+
TODO: Unlike run_analysis(), this supports edges from the middle of basic
533+
blocks, so we reimplement data flow analysis here. Figure out a way
534+
to unify this with run_analysis().
535+
"""
536+
visitor = LivenessVisitor()
537+
live_in: dict[BasicBlock, set[Value]] = {block: set() for block in blocks}
538+
539+
while True:
540+
changed = False
541+
for block in reversed(blocks):
542+
terminator = block.terminator
543+
if isinstance(terminator, Return) and terminator.yield_target is not None:
544+
successors = (terminator.yield_target,)
545+
else:
546+
successors = terminator.targets()
547+
548+
live: set[Value] = set()
549+
for successor in successors:
550+
live.update(live_in[successor])
551+
552+
for op in reversed(block.ops):
553+
if (
554+
block.error_handler is not None
555+
and isinstance(op, RegisterOp)
556+
and op.error_kind != ERR_NEVER
557+
):
558+
live.update(live_in[block.error_handler])
559+
gen, kill = op.accept(visitor)
560+
live.difference_update(kill)
561+
live.update(gen)
562+
563+
if live != live_in[block]:
564+
live_in[block] = live
565+
changed = True
566+
567+
if not changed:
568+
return live_in
569+
570+
521571
# Analysis kinds
522572
MUST_ANALYSIS = 0
523573
MAYBE_ANALYSIS = 1

mypyc/irbuild/builder.py

Lines changed: 17 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@
7575
KEEP_ALIVE_WHOLE_EXPRESSION,
7676
MODULE_PREFIX,
7777
SELF_NAME,
78-
TEMP_ATTR_NAME,
7978
shared_lib_name,
8079
)
8180
from mypyc.crash import catch_errors
@@ -152,7 +151,7 @@
152151
AssignmentTargetRegister,
153152
AssignmentTargetTuple,
154153
)
155-
from mypyc.irbuild.util import bytes_from_str, is_constant
154+
from mypyc.irbuild.util import bytes_from_str, get_func_def, is_constant
156155
from mypyc.irbuild.vec import vec_set_item
157156
from mypyc.namegen import exported_name
158157
from mypyc.options import CompilerOptions
@@ -249,11 +248,8 @@ def __init__(
249248
self.callable_class_names: set[str] = set()
250249
self.options = options
251250

252-
# These variables keep track of the number of lambdas, implicit indices, and implicit
253-
# iterators instantiated so we avoid name conflicts. The indices and iterators are
254-
# instantiated from for-loops.
251+
# Keep track of the number of lambdas instantiated so we avoid name conflicts.
255252
self.lambda_counter = 0
256-
self.temp_counter = 0
257253

258254
# These variables are populated from the first-pass PreBuildVisitor.
259255
self.free_variables = pbv.free_variables
@@ -263,6 +259,7 @@ def __init__(
263259
self.fdefs_to_decorators = pbv.funcs_to_decorators
264260
self.module_import_groups = pbv.module_import_groups
265261
self.comprehension_to_fitem = pbv.comprehension_to_fitem
262+
self.deleted_vars = pbv.deleted_vars
266263

267264
self.singledispatch_impls = singledispatch_impls
268265

@@ -749,11 +746,11 @@ def get_assignment_target(
749746
if line == -1:
750747
line = lvalue.line
751748
if isinstance(lvalue, NameExpr):
752-
# If we are visiting a decorator, then the SymbolNode we really want to be looking at
753-
# is the function that is decorated, not the entire Decorator node itself.
749+
# Use the concrete implementation as the symbol-table key for
750+
# decorated and overloaded functions.
754751
symbol = lvalue.node
755-
if isinstance(symbol, Decorator):
756-
symbol = symbol.func
752+
if isinstance(symbol, Decorator | OverloadedFuncDef):
753+
symbol = get_func_def(symbol)
757754
if symbol is None:
758755
# Semantic analyzer doesn't create ad-hoc Vars for special forms.
759756
assert lvalue.is_special_form
@@ -766,12 +763,14 @@ def get_assignment_target(
766763
reg_type = self.type_to_rtype(symbol.type)
767764
else:
768765
reg_type = self.node_type(lvalue)
769-
# If the function is a generator function, then first define a new variable
770-
# in the current function's environment class. Next, define a target that
771-
# refers to the newly defined variable in that environment class. Add the
772-
# target to the table containing class environment variables, as well as the
773-
# current environment.
774-
if self.fn_info.is_generator or self.fn_info.is_coroutine:
766+
# A deleted error-overlap value needs the environment's
767+
# definedness bitmap. Other generator locals start in
768+
# registers and are promoted later if they cross a yield.
769+
if (
770+
self.fn_info.is_generator
771+
and reg_type.error_overlap
772+
and symbol in self.deleted_vars
773+
):
775774
return self.add_var_to_env_class(
776775
symbol,
777776
reg_type,
@@ -780,7 +779,6 @@ def get_assignment_target(
780779
prefix=GENERATOR_ATTRIBUTE_PREFIX,
781780
)
782781

783-
# Otherwise define a new local variable.
784782
return self.add_local_reg(symbol, reg_type)
785783
else:
786784
# Assign to a previously defined variable.
@@ -856,11 +854,6 @@ def read(
856854

857855
assert False, "Unsupported lvalue: %r" % target
858856

859-
def read_nullable_attr(self, obj: Value, attr: str, line: int = -1) -> Value:
860-
"""Read an attribute that might have an error value without raising AttributeError."""
861-
assert isinstance(obj.type, RInstance) and obj.type.class_ir.is_ext_class
862-
return self.add(GetAttr(obj, attr, line, allow_error_value=True))
863-
864857
def assign(self, target: Register | AssignmentTarget, rvalue_reg: Value, line: int) -> None:
865858
if isinstance(target, Register):
866859
self.add(Assign(target, self.coerce_rvalue(rvalue_reg, target.type, line), line))
@@ -1045,47 +1038,8 @@ def push_loop_stack(self, continue_block: BasicBlock, break_block: BasicBlock) -
10451038
def pop_loop_stack(self) -> None:
10461039
self.nonlocal_control.pop()
10471040

1048-
def make_spill_target(self, type: RType) -> AssignmentTarget:
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}"
1055-
self.temp_counter += 1
1056-
target = self.add_var_to_class(Var(name), type, frame.ir, frame.self_reg)
1057-
return target
1058-
1059-
def spill(self, value: Value) -> AssignmentTarget:
1060-
"""Moves a given Value instance into the private generator frame."""
1061-
target = self.make_spill_target(value.type)
1062-
# Shouldn't be able to fail
1063-
self.assign(target, value, NO_TRACEBACK_LINE_NO)
1064-
return target
1065-
1066-
def maybe_spill(self, value: Value) -> Value | AssignmentTarget:
1067-
"""
1068-
Moves a given Value instance into the private frame for generator functions. For
1069-
non-generator functions, leaves the Value instance as it is.
1070-
1071-
Returns an AssignmentTarget associated with the Value for generator functions and the
1072-
original Value itself for non-generator functions.
1073-
"""
1074-
if self.fn_info.is_generator:
1075-
return self.spill(value)
1076-
return value
1077-
1078-
def maybe_spill_assignable(self, value: Value) -> Register | AssignmentTarget:
1079-
"""
1080-
Moves a given Value instance into the private frame for generator functions. For
1081-
non-generator functions, allocate a temporary Register.
1082-
1083-
Returns an AssignmentTarget associated with the Value for generator functions and an
1084-
assignable Register for non-generator functions.
1085-
"""
1086-
if self.fn_info.is_generator:
1087-
return self.spill(value)
1088-
1041+
def ensure_register(self, value: Value) -> Register:
1042+
"""Return an assignable register containing a value."""
10891043
if isinstance(value, Register):
10901044
return value
10911045

mypyc/irbuild/expression.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1415,7 +1415,7 @@ def transform_dictionary_comprehension(builder: IRBuilder, o: DictionaryComprehe
14151415

14161416

14171417
def _dict_comp_body(builder: IRBuilder, o: DictionaryComprehension) -> Value:
1418-
d = builder.maybe_spill(builder.call_c(dict_new_op, [], o.line))
1418+
d = builder.call_c(dict_new_op, [], o.line)
14191419
loop_params = list(zip(o.indices, o.sequences, o.condlists, o.is_async))
14201420

14211421
def gen_inner_stmts() -> None:

mypyc/irbuild/for_helpers.py

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ def set_item(x: Value, y: Value, z: Value, line: int) -> None:
321321
if val is not None:
322322
return val
323323

324-
list_ops = builder.maybe_spill(builder.new_list_op([], gen.line))
324+
list_ops = builder.new_list_op([], gen.line)
325325

326326
loop_params = list(zip(gen.indices, gen.sequences, gen.condlists, gen.is_async))
327327

@@ -357,7 +357,7 @@ def translate_set_comprehension(builder: IRBuilder, gen: GeneratorExpr) -> Value
357357
if raise_error_if_contains_unreachable_names(builder, gen):
358358
return builder.none()
359359

360-
set_ops = builder.maybe_spill(builder.new_set_op([], gen.line))
360+
set_ops = builder.new_set_op([], gen.line)
361361
loop_params = list(zip(gen.indices, gen.sequences, gen.condlists, gen.is_async))
362362

363363
def gen_inner_stmts() -> None:
@@ -687,7 +687,7 @@ def gen_step(self) -> None:
687687
def gen_cleanup(self) -> None:
688688
"""Generate post-loop cleanup (if needed)."""
689689

690-
def load_len(self, expr: Value | AssignmentTarget) -> Value:
690+
def load_len(self, expr: Value) -> Value:
691691
"""A helper to get collection length, used by several subclasses."""
692692
return self.builder.builder.builtin_len(
693693
self.builder.read(expr, self.line), self.line, use_pyssize_t=True
@@ -702,13 +702,11 @@ def need_cleanup(self) -> bool:
702702
return True
703703

704704
def init(self, expr_reg: Value, target_type: RType) -> None:
705-
# Define targets to contain the expression, along with the iterator that will be used
706-
# for the for-loop. If we are inside of a generator function, spill these into the
707-
# private generator frame.
705+
# Define a target containing the iterator used by the for-loop. The generator spill
706+
# transform will promote it to the private generator frame if needed.
708707
builder = self.builder
709708
iter_reg = builder.primitive_op(iter_op, [expr_reg], self.line)
710-
builder.maybe_spill(expr_reg)
711-
self.iter_target = builder.maybe_spill(iter_reg)
709+
self.iter_target = iter_reg
712710
self.target_type = target_type
713711

714712
def gen_condition(self) -> None:
@@ -751,10 +749,9 @@ def need_cleanup(self) -> bool:
751749
return True
752750

753751
def init(self, expr_reg: Value, target_type: RType) -> None:
754-
# Define target to contains the generator expression. It's also the iterator.
755-
# If we are inside a generator function, spill these into the private generator frame.
756-
builder = self.builder
757-
self.iter_target = builder.maybe_spill(expr_reg)
752+
# The generator expression is also the iterator. The generator spill transform will
753+
# promote it to the private generator frame if needed.
754+
self.iter_target = expr_reg
758755
self.target_type = target_type
759756

760757
def gen_condition(self) -> None:
@@ -807,14 +804,11 @@ class ForAsyncIterable(ForGenerator):
807804
"""Generate IR for an async for loop."""
808805

809806
def init(self, expr_reg: Value, target_type: RType) -> None:
810-
# Define targets to contain the expression, along with the
811-
# iterator that will be used for the for-loop. We are inside
812-
# of a generator function, so we will spill these into
813-
# the private generator frame.
807+
# Define a target containing the iterator used by the for-loop. The generator spill
808+
# transform will promote it to the private generator frame if needed.
814809
builder = self.builder
815810
iter_reg = builder.call_c(aiter_op, [expr_reg], self.line)
816-
builder.maybe_spill(expr_reg)
817-
self.iter_target = builder.maybe_spill(iter_reg)
811+
self.iter_target = iter_reg
818812
self.target_type = target_type
819813
self.stop_reg = Register(bool_rprimitive)
820814

@@ -894,7 +888,7 @@ class ForSequence(ForGenerator):
894888
Supports iterating in both forward and reverse.
895889
"""
896890

897-
length_reg: Value | AssignmentTarget | None
891+
length_reg: Value | None
898892

899893
def init(
900894
self, expr_reg: Value, target_type: RType, reverse: bool, length: Value | None = None
@@ -907,13 +901,12 @@ def init(
907901
# Record a Value indicating the length of the sequence, if known at compile time.
908902
self.length = length
909903
self.reverse = reverse
910-
# Define target to contain the expression, along with the index that will be used
911-
# for the for-loop. If we are inside of a generator function, spill these into the
912-
# private generator frame.
913-
self.expr_target = builder.maybe_spill(expr_reg)
904+
# The generator spill transform will promote loop state to the private generator frame
905+
# if needed.
906+
self.expr_target = expr_reg
914907
if is_immutable_rprimitive(expr_reg.type):
915908
# If the expression is an immutable type, we can load the length just once.
916-
self.length_reg = builder.maybe_spill(self.length or self.load_len(self.expr_target))
909+
self.length_reg = self.length or self.load_len(self.expr_target)
917910
else:
918911
# Otherwise, even if the length is known, we must recalculate the length
919912
# at every iteration for compatibility with python semantics.
@@ -926,7 +919,7 @@ def init(
926919
else:
927920
len_val = self.load_len(self.expr_target)
928921
index_reg = builder.builder.int_sub(len_val, 1)
929-
self.index_target = builder.maybe_spill_assignable(index_reg)
922+
self.index_target = builder.ensure_register(index_reg)
930923
self.target_type = target_type
931924

932925
def gen_condition(self) -> None:
@@ -1010,15 +1003,15 @@ def init(self, expr_reg: Value, target_type: RType) -> None:
10101003
builder = self.builder
10111004
self.target_type = target_type
10121005

1013-
# Spill some values so they can be read across yield.
1014-
self.expr_target = builder.maybe_spill(expr_reg)
1006+
# The generator spill transform will promote loop state that crosses a yield.
1007+
self.expr_target = expr_reg
10151008
offset = Integer(0)
1016-
self.offset_target = builder.maybe_spill_assignable(offset)
1017-
self.size = builder.maybe_spill(self.load_len(self.expr_target))
1009+
self.offset_target = builder.ensure_register(offset)
1010+
self.size = self.load_len(self.expr_target)
10181011

10191012
# For dict class (not a subclass) this is the dictionary itself.
10201013
iter_reg = builder.call_c(self.dict_iter_op, [expr_reg], self.line)
1021-
self.iter_target = builder.maybe_spill(iter_reg)
1014+
self.iter_target = iter_reg
10221015

10231016
def gen_condition(self) -> None:
10241017
"""Get next key/value pair, set new offset, and check if we should continue."""
@@ -1132,7 +1125,7 @@ def init(self, start_reg: Value, end_reg: Value, step: int) -> None:
11321125
self.start_reg = start_reg
11331126
self.end_reg = end_reg
11341127
self.step = step
1135-
self.end_target = builder.maybe_spill(end_reg)
1128+
self.end_target = end_reg
11361129
if is_short_int_rprimitive(start_reg.type) and is_short_int_rprimitive(end_reg.type):
11371130
index_type: RType = short_int_rprimitive
11381131
elif is_fixed_width_rtype(end_reg.type):
@@ -1141,7 +1134,7 @@ def init(self, start_reg: Value, end_reg: Value, step: int) -> None:
11411134
index_type = int_rprimitive
11421135
index_reg = Register(index_type, line=self.line)
11431136
builder.assign(index_reg, start_reg, self.line)
1144-
self.index_reg = builder.maybe_spill_assignable(index_reg)
1137+
self.index_reg = index_reg
11451138
# Initialize loop index to 0. Assert that the index target is assignable.
11461139
self.index_target: Register | AssignmentTarget = builder.get_assignment_target(self.index)
11471140
builder.assign(self.index_target, builder.read(self.index_reg, self.line), self.line)
@@ -1195,7 +1188,7 @@ def init(self) -> None:
11951188
# Create a register to store the state of the loop index and
11961189
# initialize this register along with the loop index to 0.
11971190
zero = Integer(0)
1198-
self.index_reg = builder.maybe_spill_assignable(zero)
1191+
self.index_reg = builder.ensure_register(zero)
11991192
self.index_target: Register | AssignmentTarget = builder.get_assignment_target(self.index)
12001193

12011194
def gen_step(self) -> None:

mypyc/irbuild/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def f(x: int) -> int:
4747
from mypyc.irbuild.visitor import IRBuilderVisitor
4848
from mypyc.irbuild.vtable import compute_vtable
4949
from mypyc.options import CompilerOptions
50+
from mypyc.transform.generator_spills import promote_generator_registers
5051

5152
# The stubs for callable contextmanagers are busted so cast it to the
5253
# right type...
@@ -123,6 +124,13 @@ def build_ir(
123124
result[module.fullname] = module_ir
124125
class_irs.extend(builder.classes)
125126

127+
# Generator helper calls are fresh C activations after every suspension.
128+
# Move values that must outlive one activation to the private frame before
129+
# attribute-definedness and the remaining IR transforms run.
130+
for class_ir in class_irs:
131+
if class_ir.env_user_function is not None:
132+
promote_generator_registers(class_ir.env_user_function, class_ir)
133+
126134
analyze_always_defined_attrs(class_irs)
127135

128136
# Compute vtables.

0 commit comments

Comments
 (0)