Skip to content

Commit 7696234

Browse files
add tests
1 parent 16ab4f9 commit 7696234

9 files changed

Lines changed: 307 additions & 4 deletions

File tree

Include/internal/pycore_typecache.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ struct _PyTypeCacheLookupResult {
3535

3636
extern void _PyTypeCache_InitType(PyTypeObject *type);
3737
extern void _PyTypeCache_Insert(PyTypeObject *type, PyObject *name, PyObject *value);
38-
extern struct _PyTypeCacheLookupResult _PyTypeCache_Lookup(PyTypeObject *type, PyObject *name);
39-
extern void _PyTypeCache_Invalidate(PyTypeObject *type);
38+
PyAPI_FUNC(struct _PyTypeCacheLookupResult) _PyTypeCache_Lookup(PyTypeObject *type, PyObject *name);
39+
PyAPI_FUNC(void) _PyTypeCache_Invalidate(PyTypeObject *type);
4040

4141
#ifdef __cplusplus
4242
}

Lib/test/test_free_threading/test_type.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
from threading import Thread
66
from unittest import TestCase
77

8-
from test.support import threading_helper
8+
from test.support import import_helper, threading_helper
9+
10+
_testinternalcapi = import_helper.import_module("_testinternalcapi")
911

1012

1113

@@ -178,6 +180,51 @@ def reader():
178180

179181
self.run_one(writer, reader)
180182

183+
def test_per_type_cache_concurrent_reads(self):
184+
class C:
185+
pass
186+
187+
names = [f"attr_{i}" for i in range(
188+
_testinternalcapi._Py_TYPECACHE_MINSIZE * 4)]
189+
for name in names:
190+
setattr(C, name, name)
191+
# Prime the cache.
192+
for name in names:
193+
getattr(C, name)
194+
195+
lookup = _testinternalcapi.type_cache_lookup
196+
197+
def reader():
198+
for _ in range(500):
199+
for name in names:
200+
hit, value, _ = lookup(C, name)
201+
self.assertEqual(hit, 1, name)
202+
self.assertEqual(value, name)
203+
204+
threading_helper.run_concurrently(reader, nthreads=NTHREADS)
205+
206+
def test_per_type_cache_concurrent_invalidate(self):
207+
class C:
208+
x = "value"
209+
210+
# Prime the cache.
211+
C.x
212+
hit, value, version = _testinternalcapi.type_cache_lookup(C, "x")
213+
self.assertEqual(hit, 1)
214+
self.assertIs(value, "value")
215+
self.assertGreater(version, 0)
216+
217+
def reader():
218+
for _ in range(10_000):
219+
self.assertIs(C.x, "value")
220+
221+
def invalidator():
222+
for _ in range(10_000):
223+
_testinternalcapi.type_cache_invalidate(C)
224+
225+
workers = [invalidator] + [reader] * (NTHREADS - 1)
226+
threading_helper.run_concurrently(workers)
227+
181228
def run_one(self, writer_func, reader_func):
182229
barrier = threading.Barrier(NTHREADS)
183230

Lib/test/test_type_cache.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,5 +261,162 @@ def to_bool_2(instance):
261261
self._check_specialization(to_bool_2, H(), "TO_BOOL", should_specialize=False)
262262

263263

264+
@support.cpython_only
265+
class PerTypeLookupCacheTests(unittest.TestCase):
266+
"""Tests for the per-type lookup cache."""
267+
268+
type_cache_lookup = staticmethod(_testinternalcapi.type_cache_lookup)
269+
type_cache_invalidate = staticmethod(_testinternalcapi.type_cache_invalidate)
270+
271+
def _make_type(self):
272+
class C:
273+
x = "x-value"
274+
return C
275+
276+
def test_lookup_miss_on_empty_cache(self):
277+
# A freshly-created type has not cached any names yet; the cache
278+
# should report a miss for an arbitrary name.
279+
C = self._make_type()
280+
hit, value, version = self.type_cache_lookup(C, "x")
281+
self.assertEqual(hit, 0)
282+
self.assertIsNone(value)
283+
self.assertEqual(version, 0)
284+
285+
def test_lookup_hit_after_access(self):
286+
# Reading an attribute goes through _PyType_Lookup which
287+
# caches the result. Subsequent lookups for the same name
288+
# should hit the cache.
289+
C = self._make_type()
290+
hit, value, version = self.type_cache_lookup(C, "x")
291+
self.assertEqual(hit, 0)
292+
attr = C.x
293+
hit, value, version = self.type_cache_lookup(C, "x")
294+
self.assertEqual(hit, 1)
295+
self.assertIs(value, attr)
296+
self.assertNotEqual(version, 0)
297+
self.assertEqual(version, type_get_version(C))
298+
299+
def test_lookup_caches_missing_name(self):
300+
# _PyType_Lookup caches negative results too: a name that is not in
301+
# the MRO should still produce a cache hit with a None value.
302+
C = self._make_type()
303+
with self.assertRaises(AttributeError):
304+
C.does_not_exist
305+
hit, value, _ = self.type_cache_lookup(C, "does_not_exist")
306+
self.assertEqual(hit, 1)
307+
self.assertIsNone(value)
308+
309+
def test_lookup_on_static_type(self):
310+
# The cache for static types is stored on interpreter for isolation
311+
# between subinterpreters, test that cache works for them as well.
312+
self.type_cache_invalidate(int)
313+
self.assertEqual(self.type_cache_lookup(int, "bit_length")[0], 0)
314+
attr = int.bit_length
315+
hit, value, _ = self.type_cache_lookup(int, "bit_length")
316+
self.assertEqual(hit, 1)
317+
self.assertIs(value, attr)
318+
319+
def test_invalidate_clears_cache(self):
320+
C = self._make_type()
321+
C.x # populate cache
322+
self.assertEqual(self.type_cache_lookup(C, "x")[0], 1)
323+
324+
self.type_cache_invalidate(C)
325+
hit, value, _ = self.type_cache_lookup(C, "x")
326+
self.assertEqual(hit, 0)
327+
self.assertIsNone(value)
328+
329+
def test_setattr_invalidates_cache(self):
330+
# Mutating a type's attributes must invalidate any cached entries
331+
# for that type.
332+
C = self._make_type()
333+
C.x
334+
self.assertEqual(self.type_cache_lookup(C, "x")[0], 1)
335+
336+
C.x = "new-value"
337+
hit, _, _ = self.type_cache_lookup(C, "x")
338+
self.assertEqual(hit, 0)
339+
340+
# The next access should re-populate the cache with the new value.
341+
self.assertEqual(C.x, "new-value")
342+
hit, value, _ = self.type_cache_lookup(C, "x")
343+
self.assertEqual(hit, 1)
344+
self.assertEqual(value, "new-value")
345+
346+
def test_setattr_on_subclass_preserves_base(self):
347+
# Adding an attribute to a subclass changes the lookup result for
348+
# the subclass, so its cache must be invalidated, but the base's
349+
# cache for the same name stays valid.
350+
class Base:
351+
x = "base"
352+
class Sub(Base):
353+
pass
354+
355+
self.assertEqual(Sub.x, "base")
356+
self.assertEqual(Base.x, "base")
357+
self.assertEqual(self.type_cache_lookup(Sub, "x")[0], 1)
358+
self.assertEqual(self.type_cache_lookup(Base, "x")[0], 1)
359+
360+
Sub.x = "sub"
361+
# Sub's cache should be invalidated.
362+
self.assertEqual(self.type_cache_lookup(Sub, "x")[0], 0)
363+
# Base is untouched.
364+
hit, value, _ = self.type_cache_lookup(Base, "x")
365+
self.assertEqual(hit, 1)
366+
self.assertEqual(value, "base")
367+
368+
def test_setattr_on_base_invalidates_subclass(self):
369+
class Base:
370+
x = "base"
371+
class Sub(Base):
372+
pass
373+
374+
Sub.x
375+
self.assertEqual(self.type_cache_lookup(Sub, "x")[0], 1)
376+
377+
Base.x = "new-base"
378+
# Modifying the base must invalidate the subclass cache too.
379+
self.assertEqual(self.type_cache_lookup(Sub, "x")[0], 0)
380+
381+
def test_lookup_detects_stale_cache_version(self):
382+
# The cache stores the type's tp_version_tag alongside its entries
383+
# and re-checks it after locating a hit. If the type version moves
384+
# forward without the cache being invalidated (the race window in
385+
# lock-free invalidation), the consistency check must downgrade
386+
# the hit to a miss.
387+
C = self._make_type()
388+
C.x # populate cache
389+
orig_version = type_get_version(C)
390+
self.assertNotEqual(orig_version, 0)
391+
self.assertEqual(self.type_cache_lookup(C, "x")[0], 1)
392+
393+
# Bump the type version directly without touching the cache slot
394+
# (PyType_Modified would also invalidate, defeating the test).
395+
type_assign_specific_version_unsafe(C, orig_version + 1)
396+
self.assertEqual(type_get_version(C), orig_version + 1)
397+
398+
hit, value, _ = self.type_cache_lookup(C, "x")
399+
self.assertEqual(hit, 0)
400+
self.assertIsNone(value)
401+
402+
def test_setattr_on_unrelated_type_preserves_cache(self):
403+
# Modifying one type must not invalidate a sibling's cache.
404+
class A:
405+
x = "a"
406+
class B:
407+
x = "b"
408+
409+
A.x
410+
B.x
411+
self.assertEqual(self.type_cache_lookup(A, "x")[0], 1)
412+
self.assertEqual(self.type_cache_lookup(B, "x")[0], 1)
413+
414+
B.x = "b2"
415+
# A's cache is unaffected.
416+
hit, value, _ = self.type_cache_lookup(A, "x")
417+
self.assertEqual(hit, 1)
418+
self.assertEqual(value, "a")
419+
420+
264421
if __name__ == "__main__":
265422
unittest.main()

Modules/Setup.stdlib.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@
172172
@MODULE_XXSUBTYPE_TRUE@xxsubtype xxsubtype.c
173173
@MODULE__XXTESTFUZZ_TRUE@_xxtestfuzz _xxtestfuzz/_xxtestfuzz.c _xxtestfuzz/fuzzer.c
174174
@MODULE__TESTBUFFER_TRUE@_testbuffer _testbuffer.c
175-
@MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c _testinternalcapi/test_lock.c _testinternalcapi/pytime.c _testinternalcapi/set.c _testinternalcapi/test_critical_sections.c _testinternalcapi/complex.c _testinternalcapi/interpreter.c _testinternalcapi/tuple.c
175+
@MODULE__TESTINTERNALCAPI_TRUE@_testinternalcapi _testinternalcapi.c _testinternalcapi/test_lock.c _testinternalcapi/pytime.c _testinternalcapi/set.c _testinternalcapi/test_critical_sections.c _testinternalcapi/complex.c _testinternalcapi/interpreter.c _testinternalcapi/tuple.c _testinternalcapi/typecache.c
176176
@MODULE__TESTCAPI_TRUE@_testcapi _testcapimodule.c _testcapi/vectorcall.c _testcapi/heaptype.c _testcapi/abstract.c _testcapi/unicode.c _testcapi/dict.c _testcapi/set.c _testcapi/list.c _testcapi/tuple.c _testcapi/getargs.c _testcapi/datetime.c _testcapi/docstring.c _testcapi/mem.c _testcapi/watchers.c _testcapi/long.c _testcapi/float.c _testcapi/complex.c _testcapi/numbers.c _testcapi/structmember.c _testcapi/exceptions.c _testcapi/code.c _testcapi/buffer.c _testcapi/pyatomic.c _testcapi/run.c _testcapi/file.c _testcapi/codec.c _testcapi/immortal.c _testcapi/gc.c _testcapi/hash.c _testcapi/time.c _testcapi/bytes.c _testcapi/object.c _testcapi/modsupport.c _testcapi/monitoring.c _testcapi/config.c _testcapi/import.c _testcapi/frame.c _testcapi/type.c _testcapi/function.c _testcapi/module.c
177177
@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/slots.c _testlimitedcapi/sys.c _testlimitedcapi/threadstate.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c
178178
@MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c

Modules/_testinternalcapi.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3345,6 +3345,9 @@ module_exec(PyObject *module)
33453345
if (_PyTestInternalCapi_Init_Tuple(module) < 0) {
33463346
return 1;
33473347
}
3348+
if (_PyTestInternalCapi_Init_TypeCache(module) < 0) {
3349+
return 1;
3350+
}
33483351

33493352
Py_ssize_t sizeof_gc_head = 0;
33503353
#ifndef Py_GIL_DISABLED

Modules/_testinternalcapi/parts.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ int _PyTestInternalCapi_Init_Set(PyObject *module);
1616
int _PyTestInternalCapi_Init_Complex(PyObject *module);
1717
int _PyTestInternalCapi_Init_CriticalSection(PyObject *module);
1818
int _PyTestInternalCapi_Init_Tuple(PyObject *module);
19+
int _PyTestInternalCapi_Init_TypeCache(PyObject *module);
1920

2021
#endif // Py_TESTINTERNALCAPI_PARTS_H
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Test wrappers for the per-type lookup cache (pycore_typecache.h).
2+
//
3+
// Insertion is exercised indirectly through normal attribute access (which
4+
// calls _PyType_Lookup); only Lookup and Invalidate need direct wrappers.
5+
6+
#include "parts.h"
7+
8+
#include "pycore_stackref.h" // PyStackRef_AsPyObjectSteal()
9+
#include "pycore_typecache.h" // _PyTypeCache_Lookup()
10+
11+
12+
static int
13+
require_type(PyObject *obj)
14+
{
15+
if (!PyType_Check(obj)) {
16+
PyErr_SetString(PyExc_TypeError, "expected a type");
17+
return -1;
18+
}
19+
return 0;
20+
}
21+
22+
static PyObject *
23+
intern_name(PyObject *name)
24+
{
25+
if (!PyUnicode_CheckExact(name)) {
26+
PyErr_SetString(PyExc_TypeError, "name must be a str");
27+
return NULL;
28+
}
29+
Py_INCREF(name);
30+
PyUnicode_InternInPlace(&name);
31+
return name;
32+
}
33+
34+
// type_cache_lookup(type, name) -> (cache_hit, value_or_None, version_tag)
35+
static PyObject *
36+
type_cache_lookup(PyObject *Py_UNUSED(self), PyObject *args)
37+
{
38+
PyObject *type_obj, *name;
39+
if (!PyArg_ParseTuple(args, "OU", &type_obj, &name)) {
40+
return NULL;
41+
}
42+
if (require_type(type_obj) < 0) {
43+
return NULL;
44+
}
45+
name = intern_name(name);
46+
if (name == NULL) {
47+
return NULL;
48+
}
49+
struct _PyTypeCacheLookupResult r =
50+
_PyTypeCache_Lookup((PyTypeObject *)type_obj, name);
51+
Py_DECREF(name);
52+
PyObject *value;
53+
if (PyStackRef_IsNull(r.value)) {
54+
value = Py_NewRef(Py_None);
55+
}
56+
else {
57+
value = PyStackRef_AsPyObjectSteal(r.value);
58+
}
59+
return Py_BuildValue("(iNk)",
60+
r.cache_hit, value,
61+
(unsigned long)r.version_tag);
62+
}
63+
64+
static PyObject *
65+
type_cache_invalidate(PyObject *Py_UNUSED(self), PyObject *type_obj)
66+
{
67+
if (require_type(type_obj) < 0) {
68+
return NULL;
69+
}
70+
_PyTypeCache_Invalidate((PyTypeObject *)type_obj);
71+
Py_RETURN_NONE;
72+
}
73+
74+
75+
static PyMethodDef test_methods[] = {
76+
{"type_cache_lookup", type_cache_lookup, METH_VARARGS},
77+
{"type_cache_invalidate", type_cache_invalidate, METH_O},
78+
{NULL},
79+
};
80+
81+
int
82+
_PyTestInternalCapi_Init_TypeCache(PyObject *m)
83+
{
84+
if (PyModule_AddFunctions(m, test_methods) < 0) {
85+
return -1;
86+
}
87+
if (PyModule_AddIntMacro(m, _Py_TYPECACHE_MINSIZE) < 0) {
88+
return -1;
89+
}
90+
return 0;
91+
}

PCbuild/_testinternalcapi.vcxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
<ClCompile Include="..\Modules\_testinternalcapi\complex.c" />
102102
<ClCompile Include="..\Modules\_testinternalcapi\interpreter.c" />
103103
<ClCompile Include="..\Modules\_testinternalcapi\tuple.c" />
104+
<ClCompile Include="..\Modules\_testinternalcapi\typecache.c" />
104105
</ItemGroup>
105106
<ItemGroup>
106107
<ResourceCompile Include="..\PC\python_nt.rc" />

PCbuild/_testinternalcapi.vcxproj.filters

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
<ClCompile Include="..\Modules\_testinternalcapi\tuple.c">
3131
<Filter>Source Files</Filter>
3232
</ClCompile>
33+
<ClCompile Include="..\Modules\_testinternalcapi\typecache.c">
34+
<Filter>Source Files</Filter>
35+
</ClCompile>
3336
</ItemGroup>
3437
<ItemGroup>
3538
<ResourceCompile Include="..\PC\python_nt.rc">

0 commit comments

Comments
 (0)