Skip to content

Commit 66fbf5b

Browse files
JukkaLAlexWaygood
andauthored
[mypyc] Make tuple packing and unpacking more efficient (#16022)
Previously returning a tuple from a function resulted in redundant increfs and decrefs for each item, and similarly unpacking the returned tuple in an assignment had extra incref/decref pair per item. This PR introduces these changes to make this better: * Creating a tuple steals the items always. * Accessing a tuple item optionally borrows the item. * A borrowed reference can be turned into a regular one using the new `Unborrow` op. * The no-op `KeepAlive` op can steal the operands to avoid decrefing the operands. Assignment from tuple now uses the three final features to avoid increfs and decrefs when unpacking a tuple in assignment. The docstrings in this PR contain additional explanation of how this works. In a micro-benchmark this improved performance by about 2-5%. In realistic examples the impact is likely small, but every little helps. Here is an example where this helps: ``` def f() -> tuple[C, C]: return C(), C() # Avoid 2 increfs and 2 decrefs def g() -> None: x, y = f() # Avoid 2 increfs and 2 decrefs ... ``` --------- Co-authored-by: Alex Waygood <[email protected]>
1 parent 9e520c3 commit 66fbf5b

File tree

10 files changed

+200
-19
lines changed

10 files changed

+200
-19
lines changed

mypyc/analysis/dataflow.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
Truncate,
4747
TupleGet,
4848
TupleSet,
49+
Unborrow,
4950
Unbox,
5051
Unreachable,
5152
Value,
@@ -272,6 +273,9 @@ def visit_load_address(self, op: LoadAddress) -> GenAndKill[T]:
272273
def visit_keep_alive(self, op: KeepAlive) -> GenAndKill[T]:
273274
return self.visit_register_op(op)
274275

276+
def visit_unborrow(self, op: Unborrow) -> GenAndKill[T]:
277+
return self.visit_register_op(op)
278+
275279

276280
class DefinedVisitor(BaseAnalysisVisitor[Value]):
277281
"""Visitor for finding defined registers.

mypyc/analysis/ircheck.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
Truncate,
4545
TupleGet,
4646
TupleSet,
47+
Unborrow,
4748
Unbox,
4849
Unreachable,
4950
Value,
@@ -422,3 +423,6 @@ def visit_load_address(self, op: LoadAddress) -> None:
422423

423424
def visit_keep_alive(self, op: KeepAlive) -> None:
424425
pass
426+
427+
def visit_unborrow(self, op: Unborrow) -> None:
428+
pass

mypyc/analysis/selfleaks.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
Truncate,
4141
TupleGet,
4242
TupleSet,
43+
Unborrow,
4344
Unbox,
4445
Unreachable,
4546
)
@@ -184,6 +185,9 @@ def visit_load_address(self, op: LoadAddress) -> GenAndKill:
184185
def visit_keep_alive(self, op: KeepAlive) -> GenAndKill:
185186
return CLEAN
186187

188+
def visit_unborrow(self, op: Unborrow) -> GenAndKill:
189+
return CLEAN
190+
187191
def check_register_op(self, op: RegisterOp) -> GenAndKill:
188192
if any(src is self.self_reg for src in op.sources()):
189193
return DIRTY

mypyc/codegen/emitfunc.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
Truncate,
5656
TupleGet,
5757
TupleSet,
58+
Unborrow,
5859
Unbox,
5960
Unreachable,
6061
Value,
@@ -260,7 +261,6 @@ def visit_tuple_set(self, op: TupleSet) -> None:
260261
else:
261262
for i, item in enumerate(op.items):
262263
self.emit_line(f"{dest}.f{i} = {self.reg(item)};")
263-
self.emit_inc_ref(dest, tuple_type)
264264

265265
def visit_assign(self, op: Assign) -> None:
266266
dest = self.reg(op.dest)
@@ -499,7 +499,8 @@ def visit_tuple_get(self, op: TupleGet) -> None:
499499
dest = self.reg(op)
500500
src = self.reg(op.src)
501501
self.emit_line(f"{dest} = {src}.f{op.index};")
502-
self.emit_inc_ref(dest, op.type)
502+
if not op.is_borrowed:
503+
self.emit_inc_ref(dest, op.type)
503504

504505
def get_dest_assign(self, dest: Value) -> str:
505506
if not dest.is_void:
@@ -746,6 +747,12 @@ def visit_keep_alive(self, op: KeepAlive) -> None:
746747
# This is a no-op.
747748
pass
748749

750+
def visit_unborrow(self, op: Unborrow) -> None:
751+
# This is a no-op that propagates the source value.
752+
dest = self.reg(op)
753+
src = self.reg(op.src)
754+
self.emit_line(f"{dest} = {src};")
755+
749756
# Helpers
750757

751758
def label(self, label: BasicBlock) -> str:

mypyc/ir/ops.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,9 @@ def __init__(self, items: list[Value], line: int) -> None:
792792
def sources(self) -> list[Value]:
793793
return self.items.copy()
794794

795+
def stolen(self) -> list[Value]:
796+
return self.items.copy()
797+
795798
def accept(self, visitor: OpVisitor[T]) -> T:
796799
return visitor.visit_tuple_set(self)
797800

@@ -801,13 +804,14 @@ class TupleGet(RegisterOp):
801804

802805
error_kind = ERR_NEVER
803806

804-
def __init__(self, src: Value, index: int, line: int = -1) -> None:
807+
def __init__(self, src: Value, index: int, line: int = -1, *, borrow: bool = False) -> None:
805808
super().__init__(line)
806809
self.src = src
807810
self.index = index
808811
assert isinstance(src.type, RTuple), "TupleGet only operates on tuples"
809812
assert index >= 0
810813
self.type = src.type.types[index]
814+
self.is_borrowed = borrow
811815

812816
def sources(self) -> list[Value]:
813817
return [self.src]
@@ -1387,21 +1391,76 @@ class KeepAlive(RegisterOp):
13871391
If we didn't have "keep_alive x", x could be freed immediately
13881392
after taking the address of 'item', resulting in a read after free
13891393
on the second line.
1394+
1395+
If 'steal' is true, the value is considered to be stolen at
1396+
this op, i.e. it won't be decref'd. You need to ensure that
1397+
the value is freed otherwise, perhaps by using borrowing
1398+
followed by Unborrow.
1399+
1400+
Be careful with steal=True -- this can cause memory leaks.
13901401
"""
13911402

13921403
error_kind = ERR_NEVER
13931404

1394-
def __init__(self, src: list[Value]) -> None:
1405+
def __init__(self, src: list[Value], *, steal: bool = False) -> None:
13951406
assert src
13961407
self.src = src
1408+
self.steal = steal
13971409

13981410
def sources(self) -> list[Value]:
13991411
return self.src.copy()
14001412

1413+
def stolen(self) -> list[Value]:
1414+
if self.steal:
1415+
return self.src.copy()
1416+
return []
1417+
14011418
def accept(self, visitor: OpVisitor[T]) -> T:
14021419
return visitor.visit_keep_alive(self)
14031420

14041421

1422+
class Unborrow(RegisterOp):
1423+
"""A no-op op to create a regular reference from a borrowed one.
1424+
1425+
Borrowed references can only be used temporarily and the reference
1426+
counts won't be managed. This value will be refcounted normally.
1427+
1428+
This is mainly useful if you split an aggregate value, such as
1429+
a tuple, into components using borrowed values (to avoid increfs),
1430+
and want to treat the components as sharing the original managed
1431+
reference. You'll also need to use KeepAlive with steal=True to
1432+
"consume" the original tuple reference:
1433+
1434+
# t is a 2-tuple
1435+
r0 = borrow t[0]
1436+
r1 = borrow t[1]
1437+
r2 = unborrow r0
1438+
r3 = unborrow r1
1439+
# now (r2, r3) represent the tuple as separate items, and the
1440+
# original tuple can be considered dead and available to be
1441+
# stolen
1442+
keep_alive steal t
1443+
1444+
Be careful with this -- this can easily cause double freeing.
1445+
"""
1446+
1447+
error_kind = ERR_NEVER
1448+
1449+
def __init__(self, src: Value) -> None:
1450+
assert src.is_borrowed
1451+
self.src = src
1452+
self.type = src.type
1453+
1454+
def sources(self) -> list[Value]:
1455+
return [self.src]
1456+
1457+
def stolen(self) -> list[Value]:
1458+
return []
1459+
1460+
def accept(self, visitor: OpVisitor[T]) -> T:
1461+
return visitor.visit_unborrow(self)
1462+
1463+
14051464
@trait
14061465
class OpVisitor(Generic[T]):
14071466
"""Generic visitor over ops (uses the visitor design pattern)."""
@@ -1548,6 +1607,10 @@ def visit_load_address(self, op: LoadAddress) -> T:
15481607
def visit_keep_alive(self, op: KeepAlive) -> T:
15491608
raise NotImplementedError
15501609

1610+
@abstractmethod
1611+
def visit_unborrow(self, op: Unborrow) -> T:
1612+
raise NotImplementedError
1613+
15511614

15521615
# TODO: Should the following definition live somewhere else?
15531616

mypyc/ir/pprint.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
Truncate,
5252
TupleGet,
5353
TupleSet,
54+
Unborrow,
5455
Unbox,
5556
Unreachable,
5657
Value,
@@ -153,7 +154,7 @@ def visit_init_static(self, op: InitStatic) -> str:
153154
return self.format("%s = %r :: %s", name, op.value, op.namespace)
154155

155156
def visit_tuple_get(self, op: TupleGet) -> str:
156-
return self.format("%r = %r[%d]", op, op.src, op.index)
157+
return self.format("%r = %s%r[%d]", op, self.borrow_prefix(op), op.src, op.index)
157158

158159
def visit_tuple_set(self, op: TupleSet) -> str:
159160
item_str = ", ".join(self.format("%r", item) for item in op.items)
@@ -274,7 +275,16 @@ def visit_load_address(self, op: LoadAddress) -> str:
274275
return self.format("%r = load_address %s", op, op.src)
275276

276277
def visit_keep_alive(self, op: KeepAlive) -> str:
277-
return self.format("keep_alive %s" % ", ".join(self.format("%r", v) for v in op.src))
278+
if op.steal:
279+
steal = "steal "
280+
else:
281+
steal = ""
282+
return self.format(
283+
"keep_alive {}{}".format(steal, ", ".join(self.format("%r", v) for v in op.src))
284+
)
285+
286+
def visit_unborrow(self, op: Unborrow) -> str:
287+
return self.format("%r = unborrow %r", op, op.src)
278288

279289
# Helpers
280290

mypyc/irbuild/ll_builder.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ def goto_and_activate(self, block: BasicBlock) -> None:
266266
self.goto(block)
267267
self.activate_block(block)
268268

269+
def keep_alive(self, values: list[Value], *, steal: bool = False) -> None:
270+
self.add(KeepAlive(values, steal=steal))
271+
269272
def push_error_handler(self, handler: BasicBlock | None) -> None:
270273
self.error_handlers.append(handler)
271274

mypyc/irbuild/statement.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@
5959
Register,
6060
Return,
6161
TupleGet,
62+
Unborrow,
6263
Unreachable,
6364
Value,
6465
)
6566
from mypyc.ir.rtypes import (
6667
RInstance,
68+
RTuple,
6769
c_pyssize_t_rprimitive,
6870
exc_rtuple,
6971
is_tagged,
@@ -183,8 +185,29 @@ def transform_assignment_stmt(builder: IRBuilder, stmt: AssignmentStmt) -> None:
183185

184186
line = stmt.rvalue.line
185187
rvalue_reg = builder.accept(stmt.rvalue)
188+
186189
if builder.non_function_scope() and stmt.is_final_def:
187190
builder.init_final_static(first_lvalue, rvalue_reg)
191+
192+
# Special-case multiple assignments like 'x, y = expr' to reduce refcount ops.
193+
if (
194+
isinstance(first_lvalue, (TupleExpr, ListExpr))
195+
and isinstance(rvalue_reg.type, RTuple)
196+
and len(rvalue_reg.type.types) == len(first_lvalue.items)
197+
and len(lvalues) == 1
198+
and all(is_simple_lvalue(item) for item in first_lvalue.items)
199+
and any(t.is_refcounted for t in rvalue_reg.type.types)
200+
):
201+
n = len(first_lvalue.items)
202+
for i in range(n):
203+
target = builder.get_assignment_target(first_lvalue.items[i])
204+
rvalue_item = builder.add(TupleGet(rvalue_reg, i, borrow=True))
205+
rvalue_item = builder.add(Unborrow(rvalue_item))
206+
builder.assign(target, rvalue_item, line)
207+
builder.builder.keep_alive([rvalue_reg], steal=True)
208+
builder.flush_keep_alives()
209+
return
210+
188211
for lvalue in lvalues:
189212
target = builder.get_assignment_target(lvalue)
190213
builder.assign(target, rvalue_reg, line)

mypyc/test-data/irbuild-statements.test

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -502,16 +502,16 @@ L0:
502502
[case testMultipleAssignmentBasicUnpacking]
503503
from typing import Tuple, Any
504504

505-
def from_tuple(t: Tuple[int, str]) -> None:
505+
def from_tuple(t: Tuple[bool, None]) -> None:
506506
x, y = t
507507

508508
def from_any(a: Any) -> None:
509509
x, y = a
510510
[out]
511511
def from_tuple(t):
512-
t :: tuple[int, str]
513-
r0, x :: int
514-
r1, y :: str
512+
t :: tuple[bool, None]
513+
r0, x :: bool
514+
r1, y :: None
515515
L0:
516516
r0 = t[0]
517517
x = r0
@@ -563,16 +563,19 @@ def from_any(a: Any) -> None:
563563
[out]
564564
def from_tuple(t):
565565
t :: tuple[int, object]
566-
r0 :: int
567-
r1, x, r2 :: object
568-
r3, y :: int
566+
r0, r1 :: int
567+
r2, x, r3, r4 :: object
568+
r5, y :: int
569569
L0:
570-
r0 = t[0]
571-
r1 = box(int, r0)
572-
x = r1
573-
r2 = t[1]
574-
r3 = unbox(int, r2)
575-
y = r3
570+
r0 = borrow t[0]
571+
r1 = unborrow r0
572+
r2 = box(int, r1)
573+
x = r2
574+
r3 = borrow t[1]
575+
r4 = unborrow r3
576+
r5 = unbox(int, r4)
577+
y = r5
578+
keep_alive steal t
576579
return 1
577580
def from_any(a):
578581
a, r0, r1 :: object

0 commit comments

Comments
 (0)