Skip to content

Commit ece6088

Browse files
committed
[mypyc] Add memoization support for functools.cached_property in native classes
Previously mypyc silently compiled functools.cached_property as a plain non-caching property: the semantic analyzer marks it is_property / is_settable_property like builtins.property, and mypyc had no handling of its own, so the decorated body re-ran on every access -- a severe silent performance regression. Where the cached value is stored depends on whether the instance has a __dict__: - A native (compiled) class has no instance __dict__. Its cached_property is backed by a hidden native slot instead, so caching works without a __dict__. - An interpreted subclass of a native class is given its own instance __dict__ by CPython (a heap subclass of an extension type that lacks one has a __dict__ added automatically). A cached_property defined on the interpreted subclass therefore memoizes into that __dict__ with no special handling, while an inherited native cached_property keeps using the native slot. No manual __dict__ setup (e.g. assigning self.__dict__ = {}) is required. - The only case that cannot cache is a class that suppresses its __dict__ with __slots__: a functools.cached_property defined on it raises at access time. This is ordinary CPython behaviour, unchanged by mypyc.
1 parent f8bf7ab commit ece6088

10 files changed

Lines changed: 637 additions & 5 deletions

File tree

mypyc/codegen/emitclass.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
MYPYC_DEFAULTS_SETUP,
3333
NATIVE_PREFIX,
3434
PREFIX,
35+
PROPCACHE_PREFIX,
3536
REG_PREFIX,
3637
short_id_from_name,
3738
)
@@ -1268,6 +1269,34 @@ def generate_property_setter(
12681269
)
12691270
)
12701271
emitter.emit_line("{")
1272+
# A NULL value means that the attribute is being deleted.
1273+
if cl.is_cached_property(attr):
1274+
# Deleting a functools.cached_property clears the cached value, so that
1275+
# it is recomputed on the next read. This matches CPython semantics,
1276+
# including raising AttributeError if there is no cached value.
1277+
slot_attr = PROPCACHE_PREFIX + attr
1278+
slot_expr = f"self->{emitter.attr(slot_attr)}"
1279+
slot_type = cl.attr_type(slot_attr)
1280+
emitter.emit_line("if (value == NULL) {")
1281+
emitter.emit_line(f"if ({slot_expr} == NULL) {{")
1282+
emitter.emit_line("PyErr_SetString(PyExc_AttributeError,")
1283+
emitter.emit_line(f' "attribute {repr(attr)} of {repr(cl.name)} undefined");')
1284+
emitter.emit_line("return -1;")
1285+
emitter.emit_line("}")
1286+
emitter.emit_dec_ref(slot_expr, slot_type)
1287+
emitter.emit_line(f"{slot_expr} = NULL;")
1288+
emitter.emit_line("return 0;")
1289+
emitter.emit_line("}")
1290+
else:
1291+
# Properties otherwise have no deleter, so raise AttributeError
1292+
# (instead of calling the setter with a NULL value).
1293+
emitter.emit_line("if (value == NULL) {")
1294+
emitter.emit_line("PyErr_SetString(PyExc_AttributeError,")
1295+
emitter.emit_line(
1296+
f' "{repr(cl.name)} object attribute {repr(attr)} cannot be deleted");'
1297+
)
1298+
emitter.emit_line("return -1;")
1299+
emitter.emit_line("}")
12711300
if arg_type.is_unboxed:
12721301
emitter.emit_unbox("value", "tmp", arg_type, error=ReturnHandler("-1"), declare_dest=True)
12731302
emitter.emit_line(

mypyc/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
TEMP_ATTR_NAME: Final = "__mypyc_temp__"
2424
LAMBDA_NAME: Final = "__mypyc_lambda__"
2525
PROPSET_PREFIX: Final = "__mypyc_setter__"
26+
# Prefix of hidden attributes that store values of functools.cached_property properties
27+
PROPCACHE_PREFIX: Final = "__mypyc_cache__"
2628
SELF_NAME: Final = "__mypyc_self__"
2729
MYPYC_DEFAULTS_SETUP: Final = "__mypyc_defaults_setup"
2830
GENERATOR_ATTRIBUTE_PREFIX: Final = "__mypyc_generator_attribute__"

mypyc/ir/class_ir.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from typing import NamedTuple
66

7-
from mypyc.common import PROPSET_PREFIX, JsonDict
7+
from mypyc.common import PROPCACHE_PREFIX, PROPSET_PREFIX, JsonDict
88
from mypyc.ir.func_ir import FuncDecl, FuncIR, FuncSignature, RuntimeArg
99
from mypyc.ir.ops import DeserMaps, Value
1010
from mypyc.ir.rtypes import RInstance, RType, deserialize_type, object_rprimitive
@@ -307,6 +307,10 @@ def has_attr(self, name: str) -> bool:
307307
def is_deletable(self, name: str) -> bool:
308308
return any(name in ir.deletable for ir in self.mro)
309309

310+
def is_cached_property(self, name: str) -> bool:
311+
"""Is the attribute a functools.cached_property backed by a hidden cache slot?"""
312+
return any(PROPCACHE_PREFIX + name in ir.attributes for ir in self.mro)
313+
310314
def is_always_defined(self, name: str) -> bool:
311315
if self.is_deletable(name):
312316
return False

mypyc/irbuild/function.py

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
Var,
3030
)
3131
from mypy.types import CallableType, Type, UnboundType, get_proper_type
32-
from mypyc.common import FAST_PREFIX, LAMBDA_NAME, PROPSET_PREFIX, SELF_NAME
32+
from mypyc.common import FAST_PREFIX, LAMBDA_NAME, PROPCACHE_PREFIX, PROPSET_PREFIX, SELF_NAME
3333
from mypyc.ir.class_ir import ClassIR, NonExtClassInfo
3434
from mypyc.ir.func_ir import (
3535
FUNC_CLASSMETHOD,
@@ -42,10 +42,14 @@
4242
)
4343
from mypyc.ir.ops import (
4444
BasicBlock,
45+
Box,
46+
Branch,
47+
Cast,
4548
ComparisonOp,
4649
GetAttr,
4750
Integer,
4851
LoadLiteral,
52+
Op,
4953
Register,
5054
Return,
5155
SetAttr,
@@ -77,6 +81,7 @@
7781
)
7882
from mypyc.irbuild.generator import gen_generator_func, gen_generator_func_body
7983
from mypyc.irbuild.targets import AssignmentTarget
84+
from mypyc.irbuild.util import is_cached_property
8085
from mypyc.primitives.dict_ops import (
8186
dict_get_method_with_none,
8287
dict_new_op,
@@ -500,7 +505,19 @@ def handle_ext_method(builder: IRBuilder, cdef: ClassDef, fdef: FuncDef) -> None
500505
py_setattr_op, [typ, builder.load_str(name), decorated_func], fdef.line
501506
)
502507

508+
cached_property = False
503509
if fdef.is_property:
510+
if is_cached_property_class_member(cdef, fdef.name):
511+
if class_ir.is_trait:
512+
builder.error(
513+
'"functools.cached_property" is unsupported in traits and protocols', fdef.line
514+
)
515+
else:
516+
# Add caching to the getter of a functools.cached_property
517+
# (the setter is synthesized below).
518+
cached_property = True
519+
insert_cached_property_ops(func_ir, PROPCACHE_PREFIX + fdef.name, fdef.line)
520+
504521
# If there is a property setter, it will be processed after the getter,
505522
# We populate the optional setter field with none for now.
506523
assert name not in class_ir.properties
@@ -514,6 +531,29 @@ def handle_ext_method(builder: IRBuilder, cdef: ClassDef, fdef: FuncDef) -> None
514531

515532
class_ir.methods[func_ir.decl.name] = func_ir
516533

534+
if cached_property:
535+
# Synthesize the setter of a functools.cached_property. Note that this
536+
# must be added to class_ir.methods immediately after the getter, since
537+
# the vtable layout assumes that a property setter directly follows the
538+
# getter.
539+
setter_name = PROPSET_PREFIX + name
540+
setter_ir = gen_cached_property_setter_ir(
541+
builder, class_ir.method_decls[setter_name], fdef.line
542+
)
543+
builder.functions.append(setter_ir)
544+
class_ir.methods[setter_name] = setter_ir
545+
class_ir.properties[name] = (func_ir, setter_ir)
546+
547+
if class_ir.allow_interpreted_subclasses:
548+
# Generate a shadow glue method for the setter so that attribute
549+
# assignment dispatches properly for instances of interpreted
550+
# subclasses.
551+
f = gen_glue_property_setter(
552+
builder, setter_ir.sig, setter_ir, class_ir, class_ir, fdef.line
553+
)
554+
class_ir.glue_methods[(class_ir, setter_name)] = f
555+
builder.functions.append(f)
556+
517557
# If this overrides a parent class method with a different type, we need
518558
# to generate a glue method to mediate between them.
519559
for base in class_ir.mro[1:]:
@@ -576,7 +616,13 @@ def handle_non_ext_method(
576616

577617
# TODO: Support property setters in non-extension classes
578618
if fdef.is_property:
579-
prop = builder.load_module_attr_by_fullname("builtins.property", fdef.line)
619+
if is_cached_property_class_member(cdef, fdef.name):
620+
# Non-extension classes have an instance __dict__, so we can use
621+
# functools.cached_property directly and get the full interpreted
622+
# semantics, including caching.
623+
prop = builder.get_module_attr("functools", "cached_property", fdef.line)
624+
else:
625+
prop = builder.load_module_attr_by_fullname("builtins.property", fdef.line)
580626
func_reg = builder.py_call(prop, [func_reg], fdef.line)
581627

582628
elif builder.mapper.func_to_decl[fdef].kind == FUNC_CLASSMETHOD:
@@ -1220,3 +1266,87 @@ def gen_property_setter_ir(
12201266
builder.add(Return(builder.none(), line))
12211267
args, _, blocks, ret_type, fn_info = builder.leave()
12221268
return FuncIR(func_decl, args, blocks, line)
1269+
1270+
1271+
def is_cached_property_class_member(cdef: ClassDef, name: str) -> bool:
1272+
"""Is the class member with the given name a functools.cached_property?"""
1273+
sym = cdef.info.names.get(name)
1274+
return sym is not None and isinstance(sym.node, Decorator) and is_cached_property(sym.node)
1275+
1276+
1277+
def insert_cached_property_ops(func_ir: FuncIR, slot: str, line: int) -> None:
1278+
"""Add caching ops to the getter of a functools.cached_property.
1279+
1280+
The cached value is stored in a hidden attribute (the cache slot named
1281+
'slot', declared in the prepare phase). The transformed getter is
1282+
equivalent to this:
1283+
1284+
if is_defined(self.<slot>):
1285+
return self.<slot>
1286+
... original body, with each "return r" replaced by ...
1287+
self.<slot> = r
1288+
return r
1289+
1290+
Boxed property types are stored in the slot as-is, with NULL marking an
1291+
undefined (not yet cached) value. Unboxed types (such as int) are stored
1292+
in a boxed form, since they may not have a dedicated error value.
1293+
"""
1294+
self_reg = func_ir.arg_regs[0]
1295+
ret_type = func_ir.decl.sig.ret_type
1296+
1297+
# Store the computed value in the cache slot before each return.
1298+
for block in func_ir.blocks:
1299+
new_ops: list[Op] = []
1300+
for op in block.ops:
1301+
if isinstance(op, Return):
1302+
value = op.value
1303+
boxed: Value = value
1304+
if value.type.is_unboxed:
1305+
box = Box(value, op.line)
1306+
new_ops.append(box)
1307+
boxed = box
1308+
new_ops.append(SetAttr(self_reg, slot, boxed, op.line))
1309+
new_ops.append(op)
1310+
block.ops = new_ops
1311+
1312+
# Create a new entry block that returns the value of the cache slot if it
1313+
# is defined, and otherwise runs the original getter body (which now also
1314+
# stores the computed value in the cache slot).
1315+
entry, cache_hit = BasicBlock(), BasicBlock()
1316+
compute = func_ir.blocks[0]
1317+
cached = GetAttr(self_reg, slot, line, allow_error_value=True)
1318+
entry.ops.append(cached)
1319+
entry.ops.append(Branch(cached, compute, cache_hit, Branch.IS_ERROR))
1320+
result: Value = cached
1321+
if ret_type.is_unboxed:
1322+
result = Unbox(cached, ret_type, line)
1323+
cache_hit.ops.append(result)
1324+
elif not is_same_type(ret_type, cached.type):
1325+
# The property in a subclass may have a narrower type than the
1326+
# cache slot defined in a base class.
1327+
result = Cast(cached, ret_type, line)
1328+
cache_hit.ops.append(result)
1329+
cache_hit.ops.append(Return(result, line))
1330+
func_ir.blocks = [entry, cache_hit, *func_ir.blocks]
1331+
1332+
1333+
def gen_cached_property_setter_ir(builder: IRBuilder, func_decl: FuncDecl, line: int) -> FuncIR:
1334+
"""Generate the setter of a functools.cached_property.
1335+
1336+
Assigning to a cached property stores the value in the cache slot,
1337+
similar to functools.cached_property in CPython (where an assignment
1338+
overrides the cached value through the instance __dict__).
1339+
"""
1340+
name = func_decl.name
1341+
builder.enter(name)
1342+
self_type = func_decl.sig.args[0].type
1343+
self_reg = builder.add_argument("self", self_type)
1344+
value_reg = builder.add_argument("value", func_decl.sig.args[1].type)
1345+
assert name.startswith(PROPSET_PREFIX)
1346+
slot = PROPCACHE_PREFIX + name[len(PROPSET_PREFIX) :]
1347+
assert isinstance(self_type, RInstance), self_type
1348+
slot_type = self_type.class_ir.attr_type(slot)
1349+
builder.add(SetAttr(self_reg, slot, builder.coerce(value_reg, slot_type, line), line))
1350+
builder.add(Return(builder.none(), line))
1351+
args, _, blocks, ret_type, fn_info = builder.leave()
1352+
return FuncIR(func_decl, args, blocks, line)

mypyc/irbuild/prepare.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from mypyc.common import (
4444
FAST_PREFIX,
4545
MYPYC_DEFAULTS_SETUP,
46+
PROPCACHE_PREFIX,
4647
PROPSET_PREFIX,
4748
SELF_NAME,
4849
get_id_from_name,
@@ -75,6 +76,7 @@
7576
default_attr_name,
7677
get_func_def,
7778
get_mypyc_attrs,
79+
is_cached_property,
7880
is_dataclass,
7981
is_extension_class,
8082
is_trait,
@@ -326,6 +328,57 @@ def prepare_method_def(
326328
assert node.func.type, f"Expected return type annotation for property '{node.name}'"
327329
decl.is_prop_getter = True
328330
ir.property_types[node.name] = decl.sig.ret_type
331+
if ir.is_ext_class and not ir.is_trait and is_cached_property(node):
332+
prepare_cached_property(ir, module_name, cdef, mapper, node, decl)
333+
334+
335+
def prepare_cached_property(
336+
ir: ClassIR,
337+
module_name: str,
338+
cdef: ClassDef,
339+
mapper: Mapper,
340+
node: Decorator,
341+
getter_decl: FuncDecl,
342+
) -> None:
343+
"""Set up declarations for a functools.cached_property in a native class.
344+
345+
The cached value is stored in a hidden attribute (the "cache slot").
346+
The property getter returns the value of the slot if it is defined, and
347+
otherwise runs the decorated function and stores the result in the slot
348+
(see handle_ext_method). Assigning to the property stores the assigned
349+
value directly in the slot via a synthesized setter (the setter IR is
350+
generated in transform_class_def).
351+
"""
352+
name = node.name
353+
ret_type = getter_decl.sig.ret_type
354+
# If a base class already defines this cached property, reuse its cache
355+
# slot. Both properties then share the cached value, similar to how
356+
# functools.cached_property instances share an instance __dict__ entry.
357+
if not is_inherited_cached_property(cdef, name, mapper):
358+
# For unboxed property types use a boxed slot, so that an undefined
359+
# cache slot is always represented by NULL.
360+
slot_type = object_rprimitive if ret_type.is_unboxed else ret_type
361+
ir.attributes[PROPCACHE_PREFIX + name] = slot_type
362+
sig = FuncSignature(
363+
(getter_decl.sig.args[0], RuntimeArg("value", ret_type, pos_only=True)), none_rprimitive
364+
)
365+
setter_decl = FuncDecl(PROPSET_PREFIX + name, cdef.name, module_name, sig, is_prop_setter=True)
366+
ir.method_decls[PROPSET_PREFIX + name] = setter_decl
367+
368+
369+
def is_inherited_cached_property(cdef: ClassDef, name: str, mapper: Mapper) -> bool:
370+
"""Does a native base class already define a cached property with this name?"""
371+
for base in cdef.info.mro[1:]:
372+
base_ir = mapper.type_to_ir.get(base)
373+
if base_ir is not None and base_ir.is_ext_class:
374+
sym = base.names.get(name)
375+
if (
376+
sym is not None
377+
and isinstance(sym.node, Decorator)
378+
and is_cached_property(sym.node)
379+
):
380+
return True
381+
return False
329382

330383

331384
def prepare_fast_path(

mypyc/irbuild/statement.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1227,7 +1227,15 @@ def transform_del_item(builder: IRBuilder, target: AssignmentTarget, line: int)
12271227
elif isinstance(target, AssignmentTargetAttr):
12281228
if isinstance(target.obj_type, RInstance):
12291229
cl = target.obj_type.class_ir
1230-
if not cl.is_deletable(target.attr):
1230+
# Deleting a functools.cached_property is supported: it clears the
1231+
# cached value (the property descriptor handles the deletion).
1232+
# Attributes of non-extension classes can always be deleted, since
1233+
# instances have an ordinary __dict__.
1234+
if (
1235+
cl.is_ext_class
1236+
and not cl.is_deletable(target.attr)
1237+
and not cl.is_cached_property(target.attr)
1238+
):
12311239
builder.error(f'"{target.attr}" cannot be deleted', line)
12321240
builder.note(
12331241
'Using "__deletable__ = '

mypyc/irbuild/targets.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ def __init__(self, obj: Value, attr: str, can_borrow: bool = False) -> None:
4545
self.obj = obj
4646
self.attr = attr
4747
self.can_borrow = can_borrow
48-
if isinstance(obj.type, RInstance) and obj.type.class_ir.has_attr(attr):
48+
if (
49+
isinstance(obj.type, RInstance)
50+
and obj.type.class_ir.is_ext_class
51+
and obj.type.class_ir.has_attr(attr)
52+
):
4953
# Native attribute reference
5054
self.obj_type: RType = obj.type
5155
self.type = obj.type.attr_type(attr)

mypyc/irbuild/util.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,23 @@ def is_trait_decorator(d: Expression) -> bool:
6565
return isinstance(d, RefExpr) and d.fullname == "mypy_extensions.trait"
6666

6767

68+
def is_cached_property(node: Decorator) -> bool:
69+
"""Is the decorated function a functools.cached_property?
70+
71+
The semantic analyzer removes the cached_property decorator from
72+
Decorator.decorators (it only marks the function as a settable
73+
property), so we need to look at the original decorators. Ignore
74+
properties that have additional decorators.
75+
"""
76+
return (
77+
node.func.is_property
78+
and not node.decorators
79+
and any(
80+
refers_to_fullname(d, "functools.cached_property") for d in node.original_decorators
81+
)
82+
)
83+
84+
6885
def is_trait(cdef: ClassDef) -> bool:
6986
return any(is_trait_decorator(d) for d in cdef.decorators) or cdef.info.is_protocol
7087

0 commit comments

Comments
 (0)