Skip to content

Commit 3d75cdb

Browse files
[mypyc] Borrow final attributes more aggressively (#21702)
Final instance attributes of native classes can't be rebound after initialization, so we can borrow them more aggressively than regular attributes, as long as the base object is kept alive for the duration of the borrowing. This allows skipping some incref/decref operations. Many workloads spend a significant fraction of CPU on incref/decref, and these operations are quite a bit more expensive on free-threaded builds, so the potential performance impact is significant especially on free-threaded builds. For example, the attribute `x` can only be safely borrowed if it's Final, since otherwise `foo` could assign to the attribute and free the old value: ```py def func(o: C) -> None: foo(o.x) ``` There are some subtleties in the implementation. Here are the main things that required extra care: * We don't allow borrowed values to escape from conditionally executed code paths (e.g. conditional expressions, comprehensions). * If a local variable can be modified with an assignment expression, we restrict borrowing based on that variable. * We can only borrow for a longer duration if the attribute value doesn't depend on a subexpression with a smaller borrow scope. * Lambda expressions generate a complete separate expression and borrowing scope. I used coding agent assist, especially for tests, but created the implementation is short, individually reviewed increments. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 24c237d commit 3d75cdb

15 files changed

Lines changed: 1536 additions & 47 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ repos:
2626
hooks:
2727
- id: codespell
2828
args:
29-
- --ignore-words-list=HAX,Nam,ccompiler,ot,statics,whet,zar
29+
- --ignore-words-list=HAX,Nam,ccompiler,keep-alives,ot,statics,whet,zar
3030
exclude: ^(mypy/test/|mypy/typeshed/|mypyc/test-data/|test-data/).+$
3131
- repo: https://github.com/rhysd/actionlint
3232
rev: v1.7.7

mypyc/common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@
6868
BITMAP_TYPE: Final = "uint32_t"
6969
BITMAP_BITS: Final = 32
7070

71+
# Constant for keeping a (often borrowed) op alive for a short time (a few subexpressions,
72+
# no arbitrary computation or memory allocations), until flush_keep_alives() is called.
73+
KEEP_ALIVE_SHORT_LIVED: Final = 0
74+
# Keep value alive longer, up to until the current top-level expression has been fully
75+
# evaluated. This allows keeping alive values across function calls and arbitrary
76+
# computation. Note that some expressions (e.g. lambdas), restrict the scope of borrowing.
77+
KEEP_ALIVE_WHOLE_EXPRESSION: Final = 1
78+
7179
# Runtime C library files that are always included (some ops may bring
7280
# extra dependencies via mypyc.ir.deps.SourceDep or mypyc.ir.deps.HeaderDep)
7381
RUNTIME_C_FILES: Final = [

mypyc/ir/ops.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class to enable the new behavior. Sometimes adding a new abstract
3131

3232
from mypy_extensions import trait
3333

34-
from mypyc.common import PROPSET_PREFIX
34+
from mypyc.common import KEEP_ALIVE_SHORT_LIVED, PROPSET_PREFIX
3535
from mypyc.ir.deps import Dependency
3636
from mypyc.ir.rtypes import (
3737
RArray,
@@ -898,6 +898,7 @@ def __init__(
898898
*,
899899
borrow: bool = False,
900900
allow_error_value: bool = False,
901+
borrow_scope: int = KEEP_ALIVE_SHORT_LIVED,
901902
) -> None:
902903
super().__init__(line)
903904
self.obj = obj
@@ -912,6 +913,8 @@ def __init__(
912913
elif attr_type.error_overlap:
913914
self.error_kind = ERR_MAGIC_OVERLAPPING
914915
self.is_borrowed = borrow and attr_type.is_refcounted
916+
# How long a borrowed result of this op stays valid (a KEEP_ALIVE_* constant).
917+
self.borrow_scope = borrow_scope
915918

916919
def sources(self) -> list[Value]:
917920
return [self.obj]

mypyc/irbuild/builder.py

Lines changed: 170 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,17 @@
2020
TYPE_VAR_KIND,
2121
TYPE_VAR_TUPLE_KIND,
2222
ArgKind,
23+
AssignmentExpr,
24+
AwaitExpr,
2325
CallExpr,
2426
Decorator,
27+
DictionaryComprehension,
2528
Expression,
2629
FuncDef,
30+
GeneratorExpr,
2731
IndexExpr,
2832
IntExpr,
33+
LambdaExpr,
2934
Lvalue,
3035
MemberExpr,
3136
MypyFile,
@@ -41,7 +46,10 @@
4146
TypeInfo,
4247
TypeParam,
4348
Var,
49+
YieldExpr,
50+
YieldFromExpr,
4451
)
52+
from mypy.traverser import TraverserVisitor
4553
from mypy.types import (
4654
AnyType,
4755
DeletedType,
@@ -63,6 +71,8 @@
6371
EXT_SUFFIX,
6472
GENERATOR_ATTRIBUTE_PREFIX,
6573
IS_FREE_THREADED,
74+
KEEP_ALIVE_SHORT_LIVED,
75+
KEEP_ALIVE_WHOLE_EXPRESSION,
6676
MODULE_PREFIX,
6777
SELF_NAME,
6878
TEMP_ATTR_NAME,
@@ -80,6 +90,7 @@
8090
BasicBlock,
8191
Branch,
8292
Call,
93+
Cast,
8394
ComparisonOp,
8495
GetAttr,
8596
InitStatic,
@@ -284,6 +295,20 @@ def __init__(
284295
self.imports: dict[str, None] = {}
285296

286297
self.can_borrow = False
298+
self.expression_depth = 0
299+
# Symbols (local vars) reassigned via a walrus expression within the current
300+
# top-level expression. Used to avoid borrowing an attribute over the whole
301+
# expression when the borrow root could be rebound (and thus freed) partway.
302+
self.reassigned_in_expr: set[SymbolNode] = set()
303+
# Whether the current top-level expression contains a suspension point
304+
# (await, yield or yield from). A whole-expression borrow can't span such a
305+
# point, since the borrowed value (and its root) live in registers that are
306+
# not spilled into the generator environment across the suspend.
307+
self.expr_has_suspend = False
308+
# Saved expression state for enclosing functions (see enter()/leave()).
309+
self.expression_depth_stack: list[int] = []
310+
self.reassigned_in_expr_stack: list[set[SymbolNode]] = []
311+
self.expr_has_suspend_stack: list[bool] = []
287312

288313
# When set, load_globals_dict uses this module instead of self.module_name.
289314
# Used by generate_attr_defaults_init for cross-module inherited defaults.
@@ -315,6 +340,10 @@ def accept(self, node: Statement | Expression, *, can_borrow: bool = False) -> V
315340
"""
316341
with self.catch_errors(node.line):
317342
if isinstance(node, Expression):
343+
self.expression_depth += 1
344+
if self.expression_depth == 1:
345+
self.reassigned_in_expr = find_walrus_targets(node)
346+
self.expr_has_suspend = expr_has_suspend(node)
318347
old_can_borrow = self.can_borrow
319348
self.can_borrow = can_borrow
320349
try:
@@ -329,6 +358,11 @@ def accept(self, node: Statement | Expression, *, can_borrow: bool = False) -> V
329358
self.can_borrow = old_can_borrow
330359
if not can_borrow:
331360
self.flush_keep_alives(node.line)
361+
self.expression_depth -= 1
362+
if self.expression_depth == 0:
363+
self.flush_keep_alives(node.line, scope=KEEP_ALIVE_WHOLE_EXPRESSION)
364+
self.reassigned_in_expr = set()
365+
self.expr_has_suspend = False
332366
return res
333367
else:
334368
try:
@@ -337,8 +371,8 @@ def accept(self, node: Statement | Expression, *, can_borrow: bool = False) -> V
337371
pass
338372
return None
339373

340-
def flush_keep_alives(self, line: int) -> None:
341-
self.builder.flush_keep_alives(line)
374+
def flush_keep_alives(self, line: int, *, scope: int = KEEP_ALIVE_SHORT_LIVED) -> None:
375+
self.builder.flush_keep_alives(line, scope=scope)
342376

343377
# Pass through methods for the most common low-level builder ops, for convenience.
344378

@@ -1192,7 +1226,9 @@ def is_synthetic_type(self, typ: TypeInfo) -> bool:
11921226
return typ.is_named_tuple or typ.is_newtype or typ.typeddict_type is not None
11931227

11941228
def get_final_ref(self, expr: MemberExpr) -> tuple[str, Var, bool] | None:
1195-
"""Check if `expr` is a final attribute.
1229+
"""Check if `expr` is a final class or module attribute.
1230+
1231+
Return False for instance attributes.
11961232
11971233
This needs to be done differently for class and module attributes to
11981234
correctly determine fully qualified name. Return a tuple that consists of
@@ -1341,6 +1377,15 @@ def enter(self, fn_info: FuncInfo | str = "", *, ret_type: RType = none_rprimiti
13411377
self.fn_info = fn_info
13421378
self.fn_infos.append(self.fn_info)
13431379
self.ret_types.append(ret_type)
1380+
# A function body is its own top-level expression context, even when the
1381+
# function (e.g. a lambda) is being generated in the middle of an outer
1382+
# expression. Save the outer expression state and start fresh.
1383+
self.expression_depth_stack.append(self.expression_depth)
1384+
self.reassigned_in_expr_stack.append(self.reassigned_in_expr)
1385+
self.expr_has_suspend_stack.append(self.expr_has_suspend)
1386+
self.expression_depth = 0
1387+
self.reassigned_in_expr = set()
1388+
self.expr_has_suspend = False
13441389
if fn_info.is_generator:
13451390
self.nonlocal_control.append(GeneratorNonlocalControl())
13461391
else:
@@ -1354,6 +1399,9 @@ def leave(self) -> tuple[list[Register], list[RuntimeArg], list[BasicBlock], RTy
13541399
ret_type = self.ret_types.pop()
13551400
fn_info = self.fn_infos.pop()
13561401
self.nonlocal_control.pop()
1402+
self.expression_depth = self.expression_depth_stack.pop()
1403+
self.reassigned_in_expr = self.reassigned_in_expr_stack.pop()
1404+
self.expr_has_suspend = self.expr_has_suspend_stack.pop()
13571405
self.builder = self.builders[-1]
13581406
self.fn_info = self.fn_infos[-1]
13591407
return builder.args, runtime_args, builder.blocks, ret_type, fn_info
@@ -1389,6 +1437,27 @@ def enter_scope(self, fn_info: FuncInfo) -> Iterator[None]:
13891437
self.builder = self.builders[-1]
13901438
self.fn_info = self.fn_infos[-1]
13911439

1440+
@contextmanager
1441+
def enter_borrow_scope(self, line: int) -> Iterator[None]:
1442+
"""Enter new borrow scope from which borrows can't leak to outer expressions.
1443+
1444+
This is a borrow region (see LowLevelIRBuilder.borrow_region) that also
1445+
resets the per-expression borrowing heuristic state, since the body forms
1446+
its own top-level expression context (e.g. a comprehension iteration or a
1447+
lambda body).
1448+
"""
1449+
old_expression_depth = self.expression_depth
1450+
old_reassigned_in_expr = self.reassigned_in_expr
1451+
old_expr_has_suspend = self.expr_has_suspend
1452+
self.expression_depth = 0
1453+
try:
1454+
with self.builder.borrow_region(line):
1455+
yield
1456+
finally:
1457+
self.expression_depth = old_expression_depth
1458+
self.reassigned_in_expr = old_reassigned_in_expr
1459+
self.expr_has_suspend = old_expr_has_suspend
1460+
13921461
@contextmanager
13931462
def enter_method(
13941463
self,
@@ -1581,6 +1650,32 @@ def is_final_native_attr_ref(self, expr: MemberExpr) -> bool:
15811650
return expr.name in ir.final_attributes
15821651
return False
15831652

1653+
def root_is_reassigned(self, v: Value) -> bool:
1654+
"""Is the root local variable a borrow chain 'v' reads from reassigned this expression?
1655+
1656+
A whole-expression borrow of an attribute keeps the borrow root alive only
1657+
via the register holding it. If that register belongs to a local variable
1658+
that is rebound (via a walrus assignment) during the same top-level
1659+
expression, the old value may be freed while the borrow is still live.
1660+
"""
1661+
if not self.reassigned_in_expr:
1662+
return False
1663+
# Peel borrowed links back to the root value the chain reads from.
1664+
while True:
1665+
if isinstance(v, GetAttr) and v.is_borrowed:
1666+
v = v.obj
1667+
elif isinstance(v, Cast) and v.is_borrowed:
1668+
v = v.src
1669+
else:
1670+
break
1671+
if not isinstance(v, Register):
1672+
return False
1673+
for symbol in self.reassigned_in_expr:
1674+
target = self.symtables[-1].get(symbol)
1675+
if isinstance(target, AssignmentTargetRegister) and target.register is v:
1676+
return True
1677+
return False
1678+
15841679
def mark_block_unreachable(self) -> None:
15851680
"""Mark statements in the innermost block being processed as unreachable.
15861681
@@ -1695,6 +1790,78 @@ def get_call_target_fullname(ref: RefExpr) -> str:
16951790
return ref.fullname
16961791

16971792

1793+
class WalrusTargetCollector(TraverserVisitor):
1794+
"""Collect the symbols assigned to by walrus expressions in a subtree."""
1795+
1796+
def __init__(self) -> None:
1797+
self.targets: set[SymbolNode] = set()
1798+
1799+
def visit_assignment_expr(self, o: AssignmentExpr) -> None:
1800+
if o.target.node is not None:
1801+
self.targets.add(o.target.node)
1802+
super().visit_assignment_expr(o)
1803+
1804+
def visit_lambda_expr(self, o: LambdaExpr) -> None:
1805+
# A lambda body forms its own expression context, so don't descend into it.
1806+
pass
1807+
1808+
1809+
def find_walrus_targets(expr: Expression) -> set[SymbolNode]:
1810+
"""Return the symbols reassigned via a walrus expression within 'expr'.
1811+
1812+
Walrus (':=') is the only way to rebind a variable in the middle of evaluating
1813+
an expression, so this is the complete set of in-expression reassignments.
1814+
"""
1815+
collector = WalrusTargetCollector()
1816+
expr.accept(collector)
1817+
return collector.targets
1818+
1819+
1820+
class SuspendDetector(TraverserVisitor):
1821+
"""Detect await/yield/yield from expressions in a subtree."""
1822+
1823+
def __init__(self) -> None:
1824+
self.found = False
1825+
1826+
def visit_await_expr(self, o: AwaitExpr) -> None:
1827+
self.found = True
1828+
1829+
def visit_yield_expr(self, o: YieldExpr) -> None:
1830+
self.found = True
1831+
1832+
def visit_yield_from_expr(self, o: YieldFromExpr) -> None:
1833+
self.found = True
1834+
1835+
def visit_generator_expr(self, o: GeneratorExpr) -> None:
1836+
# An 'async for' clause suspends via an implicit await on __anext__ that
1837+
# isn't represented as an AwaitExpr node in the AST (list/set comprehensions
1838+
# delegate to a GeneratorExpr, so they are covered here too).
1839+
if any(o.is_async):
1840+
self.found = True
1841+
super().visit_generator_expr(o)
1842+
1843+
def visit_dictionary_comprehension(self, o: DictionaryComprehension) -> None:
1844+
if any(o.is_async):
1845+
self.found = True
1846+
super().visit_dictionary_comprehension(o)
1847+
1848+
def visit_lambda_expr(self, o: LambdaExpr) -> None:
1849+
# A lambda body forms its own function (and suspension) context.
1850+
pass
1851+
1852+
1853+
def expr_has_suspend(expr: Expression) -> bool:
1854+
"""Does evaluating 'expr' involve a suspension point (await/yield/yield from)?
1855+
1856+
A whole-expression borrow can't safely span a suspension point, since the
1857+
borrowed value and its borrow root are held in registers that aren't spilled
1858+
into the generator environment across the suspend.
1859+
"""
1860+
detector = SuspendDetector()
1861+
expr.accept(detector)
1862+
return detector.found
1863+
1864+
16981865
def create_type_params(
16991866
builder: IRBuilder, typing_mod: Value, type_args: list[TypeParam], line: int
17001867
) -> list[Value]:

0 commit comments

Comments
 (0)