Skip to content

Commit 3320dbb

Browse files
DinoVmeta-codesync[bot]
authored andcommitted
Add caching for class vars
Summary: Let's leverage the slower fallback method for class vars too! Reviewed By: alexmalyshev Differential Revision: D109748680 fbshipit-source-id: 3ba2cb4f03652ad8055c517db750d968016a5b99
1 parent 183cbf7 commit 3320dbb

3 files changed

Lines changed: 231 additions & 3 deletions

File tree

cinderx/Jit/inline_cache.cpp

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,25 @@ bool loadMethodValueIsUnbound(uintptr_t bits) {
110110
return (bits & kLoadMethodUnboundTag) != 0;
111111
}
112112

113+
// For a class-method descriptor `descr` found on a type, returns the callable
114+
// to cache (which lookup() binds to the receiver's type), or nullptr if it
115+
// isn't safe to cache.
116+
BorrowedRef<> classMethodCacheableCallable(BorrowedRef<> descr) {
117+
if (Py_TYPE(descr) == &PyClassMethodDescr_Type) {
118+
// A C-level classmethod descriptor (e.g. dict.fromkeys). It is immutable
119+
// and directly callable with the bound type as its first argument, so cache
120+
// the descriptor itself.
121+
return descr;
122+
}
123+
// A Python-level classmethod. Only cache when it wraps a plain function;
124+
// other callables may run arbitrary user code when bound.
125+
BorrowedRef<> callable = Ci_PyClassMethod_GetFunc(descr);
126+
if (Py_TYPE(callable) == &PyFunction_Type) {
127+
return callable;
128+
}
129+
return nullptr;
130+
}
131+
113132
// Sentinel PyTypeObject that must never escape into user code.
114133
#pragma clang diagnostic push
115134
#pragma clang diagnostic ignored "-Wmissing-field-initializers"
@@ -1445,6 +1464,12 @@ LoadMethodResult LoadMethodCache::lookup(
14451464
// Bound method (common case): the low bit is clear, so the value is an
14461465
// untagged PyObject* which is a method-like object.
14471466
return {Py_NewRef(reinterpret_cast<PyObject*>(value)), Py_NewRef(obj)};
1467+
} else if (entry.is_class_method) {
1468+
// Class method: the (tagged) value is the underlying callable. Bind it
1469+
// to the receiver's type rather than the receiver itself.
1470+
return {
1471+
Py_NewRef(loadMethodValuePtr(value)),
1472+
Py_NewRef(reinterpret_cast<PyObject*>(tp.get()))};
14481473
} else if (value != kLoadMethodGetAttrSentinel) {
14491474
// A tagged pointer (value > the tag bit): a staticmethod or class
14501475
// variable. Untag it and return it as a plain attribute without binding
@@ -1510,6 +1535,7 @@ LoadMethodResult __attribute__((noinline)) LoadMethodCache::lookupSlowPath(
15101535
PyObject* attr;
15111536
bool is_method = false;
15121537
bool is_static_method = false;
1538+
bool is_class_method = false;
15131539

15141540
// A type with a __getattr__ hook is cacheable as long as the hook wraps the
15151541
// generic getattr we replicate below: the type-dict lookup is authoritative,
@@ -1547,6 +1573,16 @@ LoadMethodResult __attribute__((noinline)) LoadMethodCache::lookupSlowPath(
15471573
// instance attribute, so defer caching until after the instance dict
15481574
// check below.
15491575
is_static_method = true;
1576+
} else if (
1577+
Py_TYPE(descr) == &PyClassMethod_Type ||
1578+
Py_TYPE(descr) == &PyClassMethodDescr_Type) {
1579+
// A class method (Python-level classmethod or C-level
1580+
// classmethod_descriptor). Both are non-data descriptors that can be
1581+
// shadowed by an instance attribute, so defer caching until after the
1582+
// instance dict check below. `f` is set so we can still dispatch through
1583+
// the descriptor if it turns out not to be cacheable.
1584+
is_class_method = true;
1585+
f = descr->ob_type->tp_descr_get;
15501586
} else {
15511587
f = descr->ob_type->tp_descr_get;
15521588
if (f != nullptr && PyDescr_IsData(descr)) {
@@ -1596,6 +1632,27 @@ LoadMethodResult __attribute__((noinline)) LoadMethodCache::lookupSlowPath(
15961632
return {Py_None, Py_NewRef(callable)};
15971633
}
15981634

1635+
if (is_class_method) {
1636+
// The class method was found in the type dict and is not shadowed by an
1637+
// instance attribute. If it is safe to cache, cache the underlying callable
1638+
// and return it bound to the type (class methods bind to the type, not the
1639+
// instance). Otherwise fall through to the generic descriptor dispatch.
1640+
BorrowedRef<> callable = classMethodCacheableCallable(descr);
1641+
if (callable != nullptr) {
1642+
fill(
1643+
tp,
1644+
callable,
1645+
name,
1646+
/*has_getattr_hook=*/false,
1647+
/*is_bound_method=*/false,
1648+
/*is_class_method=*/true);
1649+
LoadMethodResult result = {
1650+
Py_NewRef(callable), Py_NewRef(reinterpret_cast<PyObject*>(tp))};
1651+
Py_DECREF(descr);
1652+
return result;
1653+
}
1654+
}
1655+
15991656
if (f != nullptr) {
16001657
maybeCollectCacheStats(
16011658
cache_stats_, tp, name, CacheMissReason::kUncategorized);
@@ -1635,7 +1692,8 @@ void LoadMethodCache::fill(
16351692
BorrowedRef<> value,
16361693
BorrowedRef<> name,
16371694
bool has_getattr_hook,
1638-
bool is_bound_method) {
1695+
bool is_bound_method,
1696+
bool is_class_method) {
16391697
if (!Ci_Type_HasValidVersionTag(type)) {
16401698
// The type must have a valid version tag in order for us to be able to
16411699
// invalidate the cache when the type is modified. See the comment at
@@ -1660,6 +1718,7 @@ void LoadMethodCache::fill(
16601718
entry.value = tagLoadMethodValue(value, is_bound_method);
16611719
entry.keys_version = keys_version;
16621720
entry.has_getattr_hook = has_getattr_hook;
1721+
entry.is_class_method = is_class_method;
16631722
return;
16641723
}
16651724
}

cinderx/Jit/inline_cache.h

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ class LoadMethodCache {
308308
// and must be resolved via __getattr__ / __getattribute__ dispatch.
309309
// * value > 1: a tagged PyObject* for a staticmethod descriptor or class
310310
// variable; untag it and return it as a plain attribute (no self
311-
// binding).
311+
// binding) -- unless is_class_method is set (see below).
312312
// Use the tag helpers in inline_cache.cpp to read it.
313313
uintptr_t value{0};
314314
uint32_t keys_version;
@@ -319,6 +319,12 @@ class LoadMethodCache {
319319
// value != nullptr.
320320
bool has_getattr_hook{false};
321321

322+
// Set when the entry caches a class method (a Python-level classmethod or a
323+
// C-level classmethod_descriptor). The (tagged, unbound) value is the
324+
// underlying callable, which lookup() binds to the receiver's type rather
325+
// than to the receiver itself. Only meaningful for unbound entries.
326+
bool is_class_method{false};
327+
322328
bool isValidKeysVersion(BorrowedRef<> obj);
323329
};
324330
static_assert(sizeof(Entry) == 24, "Entry must be small");
@@ -341,7 +347,8 @@ class LoadMethodCache {
341347
BorrowedRef<> value,
342348
BorrowedRef<> name,
343349
bool has_getattr_hook,
344-
bool is_bound_method = true);
350+
bool is_bound_method = true,
351+
bool is_class_method = false);
345352

346353
std::array<Entry, 4> entries_;
347354
std::unique_ptr<CacheStats> cache_stats_;

cinderx/PythonLib/test_cinderx/test_jit_attr_cache.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ def call_static_with_arg(obj):
4343
return obj.compute(10)
4444

4545

46+
@cinder_support.failUnlessJITCompiled
47+
def call_fromkeys(obj):
48+
# `fromkeys` resolves to a classmethod_descriptor (a C-level classmethod)
49+
# when accessed via an instance.
50+
return obj.fromkeys([1, 2])
51+
52+
4653
class LoadMethodCacheTests(unittest.TestCase):
4754
def test_type_modified(self):
4855
class Oracle:
@@ -404,6 +411,161 @@ class Oracle:
404411
self.assertEqual(get_meaning_of_life(obj), 0)
405412

406413

414+
class LoadMethodClassMethodTests(unittest.TestCase):
415+
"""LoadMethodCache should cache class methods found on the type. Both a
416+
Python-level classmethod and a C-level classmethod_descriptor are unwrapped
417+
to their underlying callable, cached, and bound to the receiver's type (not
418+
the receiver itself) when called.
419+
"""
420+
421+
def test_class_method_on_type(self):
422+
class Oracle:
423+
@classmethod
424+
def meaning_of_life(cls):
425+
return 42
426+
427+
obj = Oracle()
428+
# Uncached, then cached.
429+
self.assertEqual(get_meaning_of_life(obj), 42)
430+
self.assertEqual(get_meaning_of_life(obj), 42)
431+
for _ in range(100):
432+
self.assertEqual(get_meaning_of_life(obj), 42)
433+
434+
def test_class_method_binds_to_type(self):
435+
class Oracle:
436+
@classmethod
437+
def meaning_of_life(cls):
438+
return cls.__name__
439+
440+
obj = Oracle()
441+
# cls is bound to the receiver's type, not the receiver.
442+
self.assertEqual(get_meaning_of_life(obj), "Oracle")
443+
self.assertEqual(get_meaning_of_life(obj), "Oracle")
444+
445+
def test_class_method_binds_to_most_derived_type(self):
446+
class Base:
447+
@classmethod
448+
def meaning_of_life(cls):
449+
return cls.__name__
450+
451+
class Derived(Base):
452+
pass
453+
454+
# An inherited classmethod binds to the most-derived type of the
455+
# receiver. The two receiver types get independent cache entries.
456+
self.assertEqual(get_meaning_of_life(Base()), "Base")
457+
self.assertEqual(get_meaning_of_life(Derived()), "Derived")
458+
self.assertEqual(get_meaning_of_life(Base()), "Base")
459+
self.assertEqual(get_meaning_of_life(Derived()), "Derived")
460+
461+
def test_class_method_passes_args_after_cls(self):
462+
class Oracle:
463+
@classmethod
464+
def compute(cls, x):
465+
return x * 2
466+
467+
obj = Oracle()
468+
# The explicit argument follows the implicit cls.
469+
self.assertEqual(call_static_with_arg(obj), 20)
470+
self.assertEqual(call_static_with_arg(obj), 20)
471+
472+
def test_class_method_type_modified(self):
473+
class Oracle:
474+
@classmethod
475+
def meaning_of_life(cls):
476+
return 42
477+
478+
obj = Oracle()
479+
self.assertEqual(get_meaning_of_life(obj), 42)
480+
self.assertEqual(get_meaning_of_life(obj), 42)
481+
482+
# Replace with a different classmethod; the cache must be invalidated.
483+
# pyrefly: ignore [bad-assignment]
484+
Oracle.meaning_of_life = classmethod(lambda cls: 0)
485+
self.assertEqual(get_meaning_of_life(obj), 0)
486+
487+
def test_class_method_base_modified(self):
488+
class Base:
489+
@classmethod
490+
def meaning_of_life(cls):
491+
return 42
492+
493+
class Derived(Base):
494+
pass
495+
496+
obj = Derived()
497+
self.assertEqual(get_meaning_of_life(obj), 42)
498+
self.assertEqual(get_meaning_of_life(obj), 42)
499+
500+
# Mutating the base should propagate to Derived and invalidate the cache.
501+
# pyrefly: ignore [bad-assignment]
502+
Base.meaning_of_life = classmethod(lambda cls: 0)
503+
self.assertEqual(get_meaning_of_life(obj), 0)
504+
505+
def test_class_method_shadowed_by_instance(self):
506+
class Oracle:
507+
@classmethod
508+
def meaning_of_life(cls):
509+
return 42
510+
511+
obj = Oracle()
512+
# Cache the classmethod first.
513+
self.assertEqual(get_meaning_of_life(obj), 42)
514+
self.assertEqual(get_meaning_of_life(obj), 42)
515+
516+
# A classmethod is a non-data descriptor, so an instance attribute
517+
# shadows it.
518+
# pyrefly: ignore [missing-attribute]
519+
obj.meaning_of_life = nothing
520+
self.assertEqual(get_meaning_of_life(obj), 0)
521+
522+
def test_class_method_with_getattr_defined(self):
523+
"""A classmethod present on the type is returned directly, not routed
524+
through __getattr__, even when the type defines __getattr__."""
525+
526+
class Oracle:
527+
@classmethod
528+
def meaning_of_life(cls):
529+
return 42
530+
531+
def __getattr__(self, name):
532+
raise AttributeError(name)
533+
534+
obj = Oracle()
535+
self.assertEqual(get_meaning_of_life(obj), 42)
536+
self.assertEqual(get_meaning_of_life(obj), 42)
537+
for _ in range(100):
538+
self.assertEqual(get_meaning_of_life(obj), 42)
539+
540+
def test_class_method_wrapping_non_function(self):
541+
"""A classmethod wrapping a non-function callable is not cached, but is
542+
still dispatched correctly through the descriptor."""
543+
544+
class Callable:
545+
def __call__(self, cls):
546+
return cls.__name__
547+
548+
class Oracle:
549+
meaning_of_life = classmethod(Callable())
550+
551+
obj = Oracle()
552+
self.assertEqual(get_meaning_of_life(obj), "Oracle")
553+
self.assertEqual(get_meaning_of_life(obj), "Oracle")
554+
for _ in range(100):
555+
self.assertEqual(get_meaning_of_life(obj), "Oracle")
556+
557+
def test_classmethod_descriptor_on_builtin(self):
558+
"""A C-level classmethod_descriptor (e.g. dict.fromkeys) accessed via an
559+
instance is cached and bound to the type."""
560+
561+
obj = {}
562+
expected = {1: None, 2: None}
563+
self.assertEqual(call_fromkeys(obj), expected)
564+
self.assertEqual(call_fromkeys(obj), expected)
565+
for _ in range(100):
566+
self.assertEqual(call_fromkeys(obj), expected)
567+
568+
407569
class LoadMethodGetAttrTests(unittest.TestCase):
408570
"""LoadMethodCache should support types that define __getattr__.
409571

0 commit comments

Comments
 (0)