Skip to content

Speed up tuple comparisons for exact compact integers #156712

Description

@lesshaste

Feature or enhancement

Proposal

tuple_richcompare() currently calls PyObject_RichCompareBool(..., Py_EQ) for each pair of corresponding tuple elements until it finds a mismatch.

When both elements are exact compact int objects, equality can be determined directly from _PyLong_CompactValue(), avoiding generic rich-comparison dispatch for every element in a long common prefix.

This could speed up comparisons such as:

a == b
a != b
a < b
a <= b
a > b
a >= b

when the tuples contain ordinary integers.

Current implementation

On current main, tuple_richcompare() searches for the first unequal pair with:

for (i = 0; i < vlen && i < wlen; i++) {
    int k = PyObject_RichCompareBool(vt->ob_item[i],
                                     wt->ob_item[i], Py_EQ);
    if (k < 0)
        return NULL;
    if (!k)
        break;
}

If a differing pair is found, == and != are resolved immediately. For the ordering operators, the differing elements are then compared again using:

return PyObject_RichCompare(vt->ob_item[i], wt->ob_item[i], op);

Consequently, comparing two equal integer tuples of length 10,000 can perform 10,000 generic rich-comparison operations even though each comparison is between ordinary integers.

Similarly, tuples with a long common integer prefix perform a generic comparison for every element in that prefix.

Possible fast path

pycore_long.h already provides _PyLong_CheckExactAndCompact() and _PyLong_CompactValue().

tuple_richcompare() could therefore special-case a pair when both objects are exact compact integers:

PyObject *vitem = vt->ob_item[i];
PyObject *witem = wt->ob_item[i];

if (vitem == witem) {
    continue;
}

if (_PyLong_CheckExactAndCompact(vitem) &&
    _PyLong_CheckExactAndCompact(witem))
{
    Py_ssize_t a =
        _PyLong_CompactValue((PyLongObject *)vitem);
    Py_ssize_t b =
        _PyLong_CompactValue((PyLongObject *)witem);

    if (a == b) {
        continue;
    }

    break;
}

int k = PyObject_RichCompareBool(vitem, witem, Py_EQ);
if (k < 0) {
    return NULL;
}
if (!k) {
    break;
}

Objects/tupleobject.c would need to include pycore_long.h.

The existing generic path would remain unchanged for every other type.

A further small optimization is possible once the first differing elements are known. If they are also exact compact integers, the ordering operation could be evaluated directly:

Py_RETURN_RICHCOMPARE(a, b, op);

instead of calling PyObject_RichCompare().

That second optimization is optional; avoiding generic equality comparisons throughout a long integer prefix is the main opportunity.

Correctness

The shortcut is restricted to exact int objects.

This is important because integer subclasses may override comparison methods:

class MyInt(int):
    def __eq__(self, other):
        ...

Such objects would continue through PyObject_RichCompareBool() exactly as they do now.

Other numeric types including bool, float, Decimal, NumPy scalar types, etc. would also remain on the generic path.

For two exact compact integers, CPython's existing integer comparison already compares their compact values directly. long_compare() contains:

if (_PyLong_BothAreCompact(a, b)) {
    return _PyLong_CompactValue(a) - _PyLong_CompactValue(b);
}

so comparing _PyLong_CompactValue() values directly gives the same equality and ordering result.

The current PyObject_RichCompareBool() also treats identical objects as equal before generic dispatch. An explicit:

if (vitem == witem)
    continue;

preserves that behavior while avoiding even the compact-int tests in that case.

Tuple immutability means that the tuple contents and lengths cannot change as a side effect of comparing two exact integer elements. The existing implementation already explicitly relies on tuple immutability to reuse the cached tuple lengths across comparison calls.

Why this may be worthwhile

The avoided overhead occurs once per corresponding element, rather than once per Python operation.

For:

a = tuple(...)
b = tuple(...)

a == b

with a long common integer prefix, the current cost includes potentially thousands of calls to PyObject_RichCompareBool(...).

The exact compact-int path would replace each of those with a small number of inline checks, two compact-value loads, and a machine integer comparison.

This is particularly relevant for:

  • equality of long integer tuples;
  • inequality where the first mismatch occurs late;
  • lexicographical ordering of tuples with long common prefixes;
  • tuples used as structured numeric records or keys.

Benchmark

A patch should be benchmarked with pyperf on current main.

It is important that the benchmark include equal integer objects that are not merely identical references, since PyObject_RichCompareBool() already has a fast identity check.

For example:

import pyperf

runner = pyperf.Runner()

for n in (10, 100, 1_000, 10_000):
    base = 100_000

    # Construct separately so corresponding ints are equal
    # but generally distinct Python objects.
    a = tuple(range(base, base + n))
    b = tuple(range(base, base + n))

    c = tuple(range(base, base + n))
    c = c[:-1] + (base + n + 1,)

    runner.timeit(
        f"equal integer tuples, n={n}",
        stmt="a == b",
        globals={"a": a, "b": b},
    )

    runner.timeit(
        f"late unequal integer tuples, n={n}",
        stmt="a == c",
        globals={"a": a, "c": c},
    )

    runner.timeit(
        f"ordered integer tuples, n={n}",
        stmt="a < c",
        globals={"a": a, "c": c},
    )

Benchmarks should also include:

  • tuples whose corresponding elements are identical objects, to make sure that case does not regress;
  • tuples of strings or arbitrary objects, to check the generic path;
  • mixed-type tuples;
  • short tuples, where additional fast-path tests must not outweigh the saving.

Scope

I suggest starting with tuples because the implementation is small and tuple immutability makes the fast path particularly simple.

If the change proves worthwhile, a similar idea might later be investigated for list comparisons, where mutation and free-threading require more care.

Related issues

Has this already been discussed elsewhere?

This is a minor performance enhancement which does not need previous discussion elsewhere.

Links to previous discussion of this feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions