Skip to content

gh-155385: Speed up range.__contains__ for compact ints - #155386

Draft
jeffchen006 wants to merge 1 commit into
python:mainfrom
jeffchen006:speedup-range-contains-compact
Draft

gh-155385: Speed up range.__contains__ for compact ints#155386
jeffchen006 wants to merge 1 commit into
python:mainfrom
jeffchen006:speedup-range-contains-compact

Conversation

@jeffchen006

@jeffchen006 jeffchen006 commented Aug 8, 2026

Copy link
Copy Markdown

range.__contains__ and range.count() route every exact-int or bool
needle through range_contains_long(), which performs the whole membership
test through the abstract number API: up to three PyObject_RichCompareBool()
calls, a PyNumber_Subtract(), a PyNumber_Remainder(), and a final
PyObject_RichCompareBool(), allocating two intermediate PyLongObjects along
the way.

That generality is only needed when an operand does not fit in a machine word.
This PR adds a fast path for the case where the needle and r->start /
r->stop / r->step are all compact ints, doing the bounds check and the
divisibility check in Py_ssize_t. Anything outside that domain falls through
to the previous implementation, renamed range_contains_long_slow() and
otherwise untouched.

Why it is safe

  • No overflow. Compact ints hold a single digit, so |v| <= PyLong_MASK
    (2**30 - 1 on 64-bit builds). Hence |value - start| <= 2**31 - 2, which
    fits Py_ssize_t with room to spare — on 15-bit-digit and 32-bit builds too.
  • Same divisibility test. C's % and Python's % differ in the sign of
    a non-zero result but never in whether the result is zero, so == 0 is
    equivalent to the PyNumber_Remainder(...) == 0 it replaces.
  • step is never 0, rejected by validate_step() at construction, so the
    remainder is always defined.
  • The fields are always exact ints. make_range_object() is the only place
    start/stop/step are assigned, and every caller supplies either a
    PyNumber_Index() result or PyNumber_* arithmetic over ints.
  • Non-int needles are unaffectedrange_contains() still sends them to
    _PySequence_IterSearch() before this code is reached.

Testing

test_range, test_list, test_tuple, test_bytes, test_str, test_dict,
test_index, test_int and test_long all pass (run=902), as does
-R 3:3 test_range on a --with-pydebug build.

Beyond the suite, I diffed patched against unpatched output over ~125k
membership evaluations, with zero divergences:

  • an exhaustive sweep of start, stop ∈ [-8, 8], step ∈ {±1, ±2, ±3, ±4},
    needle ∈ [-12, 12], cross-checked against materialised list(r) for in,
    .index() and .count();
  • a boundary sweep with start/stop drawn from
    {0, ±1, ±2, ±3, ±7, ±(2**30 - 2), ±(2**30 - 1), ±2**30, ±(2**30 + 1), ±2**31, ±10**30, ±10**100}
    and step ∈ {±1, ±3, ±(2**30 - 1), ±2**30, 10**40}, which straddles the
    compact/non-compact boundary in each field independently;
  • bools as needles, sliced ranges, empty ranges, and non-int needles
    (float, int subclass, complex, custom __eq__).

Benchmarks

--enable-optimizations=no --with-pydebug=no CFLAGS="-O3 -g0", gcc 13.3,
x86-64 Linux, otherwise-idle machine. Baseline and patched interpreters were
interleaved round by round, 120 samples per arm; figures are min per-loop ns.
The last row is a no-op control (a bare name load) included to show the harness
is unbiased.

microbenchmark before after
999 in range(0, 1000, 3) 59.5 ns 14.8 ns 4.02x
998 in range(0, 1000, 3) (miss) 63.1 ns 14.0 ns 4.49x
997 in range(1000, 0, -3) 56.4 ns 14.0 ns 4.03x
True in range(0, 1000, 3) 68.3 ns 14.8 ns 4.61x
range(0, 1000, 3).count(999) 67.9 ns 20.6 ns 3.29x
10**100 in range(0, 1000, 3) (fallback) 36.2 ns 35.3 ns 1.03x
999 in range(0, 10**100, 3) (fallback) 59.5 ns 57.6 ns 1.03x
6.0 in range(0, 8, 3) (non-int) 116.2 ns 114.7 ns 1.01x
control: needle 7.46 ns 7.50 ns 0.99x

Every fallback path is neutral within noise. objdump confirms the helper is
fully inlined into range_contains_long() — four tag tests and integer
arithmetic, no calls at all — while the fallback costs one load, one compare
and one branch before reaching the original code.

Add a fast path to range_contains_long() for the common case where the
needle and r->start / r->stop / r->step are all compact ints, doing the
bounds and divisibility checks in Py_ssize_t instead of going through
PyObject_RichCompareBool(), PyNumber_Subtract() and PyNumber_Remainder().

Compact ints hold a single digit, so their values are bounded by
PyLong_MASK and "value - start" cannot overflow Py_ssize_t.  C and Python
remainders differ in sign but never in whether they are zero, so the
divisibility test is unchanged.  Anything outside the compact domain falls
through to the previous implementation, now range_contains_long_slow().

This also speeds up range.count(), which shares the same helper.
@python-cla-bot

python-cla-bot Bot commented Aug 8, 2026

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant