GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — gh-105250: Fix NEWOBJ handling of custom metaclasses in C pickle by chaerrypick01 · Pull Request #155920 · python/cpython Conversation
|
| Benchmark | base | patched |
|---|---|---|
newobj_default (NEWOBJ, default metaclass) |
295 µs | 296 µs: not significant |
newobj_ex_default (NEWOBJ_EX, default metaclass) |
604 µs | 605 µs: not significant |
newobj_metaclass (NEWOBJ, custom metaclass) |
293 µs | 338 µs: 1.15x slower |
The fast path shows no measurable regression for either opcode — the added Py_TYPE(cls) == &PyType_Type check is lost in the noise. The 1.15x slowdown is confined to the new slow path, which is only taken for classes with a custom metaclass: the cost of the PyObject_GetAttr(cls, '__new__') call plus building the argument tuple, on the path where the previous behaviour did not match the documented semantics.
Environment: macOS 26.5.1, Apple M3 Pro, no CPU isolation (pyperf system tune is Linux-only), 20 processes per benchmark.
Given the trade-off — documented cls.__new__ semantics for custom-metaclass classes, at ~15% on that path and no change for ordinary classes — I'd like your feedback on whether this is acceptable, or whether you'd prefer to close
gh-105250 as a known limitation instead.
"""Micro-benchmark for load_newobj() in Modules/_pickle.c (gh-105250).""" import pickle import pyperf assert pickle.Unpickler.__module__ == '_pickle', 'need the C implementation' class Point: def __init__(self, x, y): self.x = x self.y = y class PointEx: def __init__(self, x, y): self.x = x self.y = y def __getnewargs_ex__(self): return (self.x,), {'y': self.y} def __new__(cls, x=0, y=0): return super().__new__(cls) class Meta(type): pass class PointMeta(metaclass=Meta): def __init__(self, x, y): self.x = x self.y = y N = 1000 DATA_DEFAULT = pickle.dumps([Point(i, i) for i in range(N)], protocol=2) DATA_EX = pickle.dumps([PointEx(i, i) for i in range(N)], protocol=4) DATA_META = pickle.dumps([PointMeta(i, i) for i in range(N)], protocol=2) runner = pyperf.Runner() runner.metadata['description'] = 'unpickle NEWOBJ/NEWOBJ_EX (gh-105250)' runner.bench_func('newobj_default', pickle.loads, DATA_DEFAULT) runner.bench_func('newobj_ex_default', pickle.loads, DATA_EX) runner.bench_func('newobj_metaclass', pickle.loads, DATA_META)
Sorry, something went wrong.
There was an error while loading. Please reload this page.
The NEWOBJ and NEWOBJ_EX opcodes are documented to call cls.__new__(cls, *args), but the C implementation called tp_new directly, so a metaclass __getattribute__ hook was skipped unless the class happened to define __new__ in Python. Perform a real attribute lookup of cls.__new__ when the class has a custom metaclass, matching the pure Python implementation. The default-metaclass case keeps calling tp_new directly, where the lookup is not observable.
There was an error while loading. Please reload this page.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
| metaclass_new_lookups = [] | ||
|
|
||
| class LookupLoggingMeta(type): | ||
| def __getattribute__(cls, name): |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
It's better to move look up count as class varaible
Something like:
class LookupLoggingMeta(type):
new_lookup_count = 0
def __getattribute__(cls, name):
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Done in f22a1a5 — moved the count to a class variable on the metaclass.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Can you also provide benchmark compare to main branch by using pyperf? |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
…NaXOE.rst Co-authored-by: Donghee Na <[email protected]>
|
|
||
| def __getattribute__(cls, name): | ||
| if name == '__new__': | ||
| LookupLoggingMeta.new_lookup_count += 1 |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
| LookupLoggingMeta.new_lookup_count += 1 | |
| cls.new_lookup_count += 1 |
?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| /* Look __new__ up on the class so that a custom metaclass | ||
| __getattribute__ observes the lookup, as in the Python | ||
| implementation. */ | ||
| PyObject *func = PyObject_GetAttr(cls, &_Py_ID(__new__)); | ||
| if (func == NULL) { | ||
| goto error; | ||
| } | ||
| Py_ssize_t nargs = PyTuple_GET_SIZE(args); | ||
| PyObject *newargs = PyTuple_New(nargs + 1); | ||
| if (newargs == NULL) { | ||
| Py_DECREF(func); | ||
| goto error; | ||
| } | ||
| PyTuple_SET_ITEM(newargs, 0, Py_NewRef(cls)); | ||
| for (Py_ssize_t i = 0; i < nargs; i++) { | ||
| PyTuple_SET_ITEM(newargs, i + 1, | ||
| Py_NewRef(PyTuple_GET_ITEM(args, i))); | ||
| } | ||
| obj = PyObject_Call(func, newargs, kwargs); | ||
| Py_DECREF(newargs); |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
| /* Look __new__ up on the class so that a custom metaclass | |
| __getattribute__ observes the lookup, as in the Python | |
| implementation. */ | |
| PyObject *func = PyObject_GetAttr(cls, &_Py_ID(__new__)); | |
| if (func == NULL) { | |
| goto error; | |
| } | |
| Py_ssize_t nargs = PyTuple_GET_SIZE(args); | |
| PyObject *newargs = PyTuple_New(nargs + 1); | |
| if (newargs == NULL) { | |
| Py_DECREF(func); | |
| goto error; | |
| } | |
| PyTuple_SET_ITEM(newargs, 0, Py_NewRef(cls)); | |
| for (Py_ssize_t i = 0; i < nargs; i++) { | |
| PyTuple_SET_ITEM(newargs, i + 1, | |
| Py_NewRef(PyTuple_GET_ITEM(args, i))); | |
| } | |
| obj = PyObject_Call(func, newargs, kwargs); | |
| Py_DECREF(newargs); | |
| PyThreadState *tstate = _PyThreadState_GET(); | |
| obj = _PyObject_Call_Prepend(tstate, func, cls, args, kwargs); |
Can you benchmark this one too?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I applied it(b4dfe45), and re-ran the benchmark to see whether the small-stack call path helps:
it doesn't, measurably. newobj_metaclass stays at ~1.15x (337 µs vs 294 µs baseline, within run-to-run noise of the tuple version's 338 µs), and the fast paths are unchanged. So this commit is a code simplification and a consistency win, not a performance change — the numbers in the benchmark comment above still stand.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Export it from pycore_call.h, since _pickle can be built as a shared extension and cannot link against a hidden internal symbol.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I think the behavior change looks correct, but this change does introduce some performance overhead.
I’m fine with approving this PR, though I’m not sure whether you’ll be satisfied with the performance impact. :)
FYI, @chaerrypick01 is a first-time contributor who participated in the PyCon KR sprint organized by @hugovk and me. :)
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.