Repository navigation
Speed regression in the decimal module due to using heap types #144650
Description
This is essentially a rebirth of #114682, which was closed due to @skrah ban (unfortunately, such things usually don't fix issues).
The problem seems to be valid and I open a new issue per kindly @gpshead permission. I labeled issue as "type-feature", but IMO it's looks rather as a regression, i.e. "type-bug".
Here benchmarks results (with PGO) for 3.9-3.15:
| Benchmark | 39 | 310 | 311 | 312 | 313 | 314 | 315 |
|---|---|---|---|---|---|---|---|
| decimal_factorial | 804 ms | 843 ms: 1.05x slower | 763 ms: 1.05x faster | 794 ms: 1.01x faster | 1.02 sec: 1.27x slower | 950 ms: 1.18x slower | 951 ms: 1.18x slower |
| decimal_pi | 1.12 sec | 1.26 sec: 1.13x slower | 1.29 sec: 1.15x slower | 1.31 sec: 1.17x slower | 1.86 sec: 1.67x slower | 1.66 sec: 1.48x slower | 1.66 sec: 1.49x slower |
| Geometric mean | (ref) | 1.09x slower | 1.05x slower | 1.07x slower | 1.45x slower | 1.32x slower | 1.33x slower |
Benchmarks were available in the pyperformance package long time ago but are disabled, see python/pyperformance#453 (I use first commit from above PR to enable both benchmarks. Though, the "decimal_factorial" benchmark seems to be less affected, I guess because it has more large numbers).
N.B.: the "decimal_pi" benchmark do computation with low precision (9 and 19). Here is what happens with the default precision (28):
| Benchmark | 39 | 313 |
|---|---|---|
| decimal_pi | 1.20 sec | 1.99 sec: 1.65x slower |
Not much better.
It seems that major slowdown come in the 3.13 (around 1.67x slower for "decimal_pi" on my system) with #106079. Unfortunately, nobody asked to do performance measurements during PR review :(
Something like this happens already on toy extension types:
| Benchmark | ref | heap |
|---|---|---|
| xyz(123) + xyz(321) | 204 ns | 357 ns: 1.75x slower |
code and benchmark
# bench.py
import pyperf
from operator import add
from example import xyz
x, y = map(xyz, [123, 321])
runner = pyperf.Runner()
s = repr(x) + " + " + repr(y)
runner.bench_func(s, add, x, y)/* static type */
#define PY_SSIZE_T_CLEAN
#include <Python.h>
typedef struct {
PyObject_HEAD
long value;
} XYZ_Object;
PyTypeObject XYZ_Type;
#define XYZ_CheckExact(u) Py_IS_TYPE((u), &XYZ_Type)
static XYZ_Object *
XYZ_new(long value)
{
XYZ_Object *res = PyObject_New(XYZ_Object, &XYZ_Type);
if (res) {
res->value = value;
}
return res;
}
static PyObject *
new(PyTypeObject *type, PyObject *args, PyObject *keywds)
{
Py_ssize_t argc = PyTuple_GET_SIZE(args);
if (argc == 1) {
PyObject *arg = PyTuple_GET_ITEM(args, 0);
long value = PyLong_AsLong(arg);
if (value == -1 && PyErr_Occurred()) {
return NULL;
}
return (PyObject *)XYZ_new(value);
}
PyErr_SetString(PyExc_TypeError, "value required");
return NULL;
}
static PyObject *
add(PyObject *self, PyObject *other)
{
XYZ_Object *x = (XYZ_Object *)self;
XYZ_Object *y = (XYZ_Object *)other;
if (XYZ_CheckExact(x) && XYZ_CheckExact(y)) {
return (PyObject *)XYZ_new(x->value + y->value);
}
Py_RETURN_NOTIMPLEMENTED;
}
static PyObject *
repr(PyObject *self)
{
return PyUnicode_FromFormat("xyz(%ld)", ((XYZ_Object *)self)->value);
}
static PyNumberMethods xyz_as_number = {
.nb_add = add,
};
PyTypeObject XYZ_Type = {
PyVarObject_HEAD_INIT(NULL, 0)
.tp_name = "xyz",
.tp_basicsize = sizeof(XYZ_Object),
.tp_new = new,
.tp_repr = repr,
.tp_as_number = &xyz_as_number,
.tp_flags = Py_TPFLAGS_DEFAULT,
};
static int
example_exec(PyObject *module)
{
if (PyModule_AddType(module, &XYZ_Type) < 0) {
return -1;
}
return 0;
}
#ifdef __GNUC__
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wpedantic"
#endif
static PyModuleDef_Slot example_slots[] = {
{Py_mod_exec, example_exec},
{0, NULL}};
#ifdef __GNUC__
# pragma GCC diagnostic pop
#endif
static struct PyModuleDef example_module = {
PyModuleDef_HEAD_INIT,
.m_name = "example",
.m_doc = "Test module.",
.m_size = 0,
.m_slots = example_slots,
};
PyMODINIT_FUNC
PyInit_example(void)
{
return PyModuleDef_Init(&example_module);
}/* heap type */
#define PY_SSIZE_T_CLEAN
#include <Python.h>
typedef struct {
PyObject_HEAD
long value;
} XYZ_Object;
typedef struct {
PyTypeObject *XYZ_Type;
} example_state;
#define XYZ_CheckExact(st, u) Py_IS_TYPE((u), (st)->XYZ_Type)
static XYZ_Object *
XYZ_new(example_state *Py_UNUSED(state), PyTypeObject *type, long value)
{
XYZ_Object *res = PyObject_GC_New(XYZ_Object, type);
if (res) {
PyObject_GC_Track((PyObject *)res);
res->value = value;
}
return res;
}
static struct PyModuleDef example_module;
static example_state *
get_state(PyTypeObject *type)
{
PyObject *module = PyType_GetModuleByDef(type, &example_module);
return PyModule_GetState(module);
}
static PyObject *
new(PyTypeObject *type, PyObject *args, PyObject *keywds)
{
Py_ssize_t argc = PyTuple_GET_SIZE(args);
example_state *state = get_state(type);
if (argc == 1) {
PyObject *arg = PyTuple_GET_ITEM(args, 0);
long value = PyLong_AsLong(arg);
if (value == -1 && PyErr_Occurred()) {
return NULL;
}
return (PyObject *)XYZ_new(state, type, value);
}
PyErr_SetString(PyExc_TypeError, "value required");
return NULL;
}
static int
traverse(PyObject *self, visitproc visit, void *arg)
{
Py_VISIT(Py_TYPE(self));
return 0;
}
static void
dealloc(PyObject *self)
{
PyTypeObject *type = Py_TYPE(self);
PyObject_GC_UnTrack(self);
type->tp_free(self);
Py_DECREF(type);
}
static inline example_state *
find_state_left_or_right(PyObject *left, PyObject *right)
{
PyObject *module = PyType_GetModuleByDef(Py_TYPE(left), &example_module);
if (module) {
return PyModule_GetState(module);
}
PyErr_Clear();
return PyType_GetModuleState(Py_TYPE(right));
}
static PyObject *
add(PyObject *self, PyObject *other)
{
example_state *state = find_state_left_or_right(self, other);
XYZ_Object *x = (XYZ_Object *)self;
XYZ_Object *y = (XYZ_Object *)other;
if (XYZ_CheckExact(state, x) && XYZ_CheckExact(state, y)) {
return (PyObject *)XYZ_new(state, state->XYZ_Type, x->value + y->value);
}
Py_RETURN_NOTIMPLEMENTED;
}
static PyObject *
repr(PyObject *self)
{
return PyUnicode_FromFormat("xyz(%ld)", ((XYZ_Object *)self)->value);
}
#ifdef __GNUC__
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wpedantic"
#endif
static PyType_Slot xyz_slots[] = {
{Py_tp_repr, repr},
{Py_tp_new, new},
{Py_nb_add, add},
{Py_tp_traverse, traverse},
{Py_tp_dealloc, dealloc},
};
#ifdef __GNUC__
# pragma GCC diagnostic pop
#endif
static PyType_Spec xyz_spec = {
.name = "xyz",
.basicsize = sizeof(XYZ_Object),
.flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC | Py_TPFLAGS_IMMUTABLETYPE,
.slots = xyz_slots,
};
static int
example_exec(PyObject *module)
{
example_state *state = PyModule_GetState(module);
state->XYZ_Type = (PyTypeObject *)PyType_FromModuleAndSpec(module,
&xyz_spec,
NULL);
if (!state->XYZ_Type || PyModule_AddType(module, state->XYZ_Type) < 0) {
return -1;
}
return 0;
}
static int
example_clear(PyObject *module)
{
example_state *state = PyModule_GetState(module);
Py_CLEAR(state->XYZ_Type);
return 0;
}
static int
example_traverse(PyObject *module, visitproc visit, void *arg)
{
example_state *state = PyModule_GetState(module);
Py_VISIT(state->XYZ_Type);
return 0;
}
#ifdef __GNUC__
# pragma GCC diagnostic push
# pragma GCC diagnostic ignored "-Wpedantic"
#endif
static PyModuleDef_Slot example_slots[] = {
{Py_mod_exec, example_exec},
{0, NULL}};
#ifdef __GNUC__
# pragma GCC diagnostic pop
#endif
static struct PyModuleDef example_module = {
PyModuleDef_HEAD_INIT,
.m_name = "example",
.m_doc = "Test module.",
.m_size = 0,
.m_slots = example_slots,
.m_clear = example_clear,
.m_traverse = example_traverse,
};
PyMODINIT_FUNC
PyInit_example(void)
{
return PyModuleDef_Init(&example_module);
}So, what we could do? Can heap types (at least immutable) be less costly c.f. static types? Can we convert the decimal module back to static types?
I tried to add a freelist for Decimal's (quick patch is there: skirpichev#16). It looks this will mitigate the problem, but not entirely fix regression.
Linked PRs
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
serhiy-storchaka commentedon Feb 11, 2026
What is the cause? I do not think that heap types are slower than static types per se, but looking at the diff between 3.12 and 3.13,
get_module_state_by_def(Py_TYPE(dec))was added in every function, and that might have large impact.skirpichev commentedon Feb 11, 2026
See example with extension type. Here my tests before and after fb0d9b9 (no PGO):
Details
Edit: You are right in sense, that mentioned diff is not the only reason to speed regression. Following PRs, that isolate the decimal module (e.g. a43cc3f) - make things much worse.
vstinner commentedon Feb 18, 2026
To check if the overhead comes from this additional function call, one can try to store the module state directly in
PyDecObject. This optimization is used byitertools.tee()for example:skirpichev commentedon Feb 18, 2026
There is some overhead from
get_module_state_by_def(), but I don't think it's the only reason.I don't know how caching state in the object helps to avoid get_module_state_by_def calls, but here is what happens if I just drop such calls in my toy module:
Details
Patch for heap2 version wrt code in the issue description:
skirpichev commentedon Mar 2, 2026
As for the Decimal, we could also add Vectorcall protocol support. Probably, this will mitigate speed degradation even more than my freelist proposal above.
But I think we should wait #145381 here.
Edit:
no, this doesn't help too much
(with
./configure -qand pyperformance from python/pyperformance#453)AC patch: skirpichev#18
FL patch: skirpichev#16
Siyet commentedon Apr 12, 2026
Looking at the current code on
main, the dominant overhead source seems to be GC infrastructure rather than module state lookups.PyDecObjectholds no references to other Python objects - onlyhash,dec(mpd_t), and inlinedata[]. GC tracking is required solely because heap types needtp_traverseto visit the type.Every arithmetic result pays
PyObject_GC_New+PyObject_GC_Trackon allocation andPyObject_GC_UnTrack+Py_DECREF(tp)on dealloc. This is consisetnt with the toy example above where removing all state lookups still left a 1.61x slowdown.A minor point:
find_state_left_or_right()uses the publicPyType_GetBaseByToken()which doesPy_INCREF(*result), then immediatelyPy_DECREF(base)after extarcting the state - a wasted refcount pair on every binary op.Switching to
_PyType_GetBaseByToken_Borrow(internal API, already available) would eliminate this, though the impact is probably small.Has there been any discussion about a lighter GC mode for heap type instacnes that hold no Python object references? This seems like it would benefit any C extension with high-throughput small objects migrated to heap types.
PRs and issues reviewed
_decimalextension module #106078, gh-106078: Convert_decimaltypes to heap types #106079 -_decimalisolation and heap types conversionMETH_METHODcalling convention in_decimal#115196, gh-114682: Improve performance of module state access in_decimal#115401 - METH_METHOD convention and module state access optimizations (closed with _decimal: pi benchmark 31-45% slower compared to 3.9 #114682)_decimalArgument Clinic conversion (7 PRs)skirpichev commentedon Apr 13, 2026
@Siyet, thanks. Very helpful analysis.
I think that we need a flag (for tp_flags) to denote such a special heap type.
Yes, I think it's something occurring, when you wrap C libs. We already have many performance-related flags, this case seems to be a good candidate.
I doubt this can recover all speed loss for Decimal's, but it's something that should be tested first. Are you interested in implementing this?
As for some not-so-toy example, I have this patch: diofant/python-gmp#195
skirpichev commentedon Jul 18, 2026
Note that if we avoid PyObject_GC_UnTrack/PyObject_GC_Track on freelist path (I doubt it possible to do correctly: current patch fails in debug builds) in the skirpichev#16 (updated pr) - decimal_pi benchmark speedup ~1.17x vs ~1.10x.
vstinner commentedon Aug 10, 2026
@skirpichev:
That sounds like an uncontroversial change. I agree that it would be better to wait for PR gh-145381.
skirpichev commentedon Aug 10, 2026
@vstinner, according to benchmarks above - effect from a freelist is bigger. Do you think we should add this?
Edit: unfortunately, all this only mitigates problems with heap types.
vstinner commentedon Aug 11, 2026
This issue is not easy to follow since you discuss multiple changes in parallel. If you consider that adding a free list is a good idea (is more efficient), open a PR with a benchmark only on this PR.
skirpichev commentedon Aug 11, 2026
The main issue is noticeable speed regression for heap types (wrt static), shown on very primitive example. This is something we should deal with. (I think the major issue is GC tracking, see this.) The decimal module - is an example of that within the stdlib.
Using freelists and/or vectorcall - are optimization techniques, that works both for static and heap types.
skirpichev commentedon Aug 16, 2026
Hmm, I think that a big speed loss coming from GC. But not major. I tried to get rid of GC support in skirpichev#24. That makes standard benchmarks 1.12-1.20x faster.
Same was implemented for mpz type (diofant/python-gmp#195). That makes patch 1.45x slower (vs 1.62x). The rest, probably, coming from state lookups in slot methods, as correctly pointed out by Serhiy. I've no idea how to speedup this. Itertools optimization will not work here.
@sergey-miryanov, you authored 1588413. What do you think on removal GC support for
_decimaltypes? Some own references to other objects (i.e. Context), but not arbitrary. I don't see how this can create reference cycles.pythongh-144650: support Vectorcall for _decimal.Decimal's