Skip to content

PERF, BUG: use faster calling conventions (vectorcall) in several hot paths#31988

Open
eendebakpt wants to merge 2 commits into
numpy:mainfrom
eendebakpt:perf/simpler-call-conventions
Open

PERF, BUG: use faster calling conventions (vectorcall) in several hot paths#31988
eendebakpt wants to merge 2 commits into
numpy:mainfrom
eendebakpt:perf/simpler-call-conventions

Conversation

@eendebakpt

Copy link
Copy Markdown
Contributor

PR summary

Replace PyObject_CallMethod/PyObject_CallFunction by the faster vectorcall based calling conventions (PyObject_Vectorcall, PyObject_CallOneArg, PyObject_CallMethodNoArgs/OneArg, PyObject_CallNoArgs) and interned strings. This saves temporary tuple creation, variadic argument parsing and repeated creation of temporary strings.

Benchmark results for a few selected cases (pyperf, Windows, MSVC):

Benchmark main PR
frompyfunc_1in_1000 48.2 us 37.7 us: 1.28x faster
frompyfunc_2in_1000 53.0 us 40.7 us: 1.30x faster
conjugate_object_1000 119 us 54.5 us: 2.18x faster
floor_object_1000 126 us 95.2 us: 1.33x faster
gcd_object_1000 155 us 132 us: 1.18x faster
datetime_as_string_tz_1000 1.60 ms 1.47 ms: 1.09x faster
matrix_add_2x2 1.22 us 1.16 us: 1.05x faster
Geometric mean (ref) 1.31x faster
Benchmark script
"""Benchmarks for the simpler-calling-convention changes.

Run with:
    python bench_call_conventions.py -o result.json
    python -m pyperf compare_to base.json new.json --table
"""
import datetime
import fractions

import pyperf
import numpy as np

N = 1000

obj_int = np.array([i + 10**30 for i in range(N)], dtype=object)
obj_int2 = np.array([(i + 7) * 3 + 10**30 for i in range(N)], dtype=object)
obj_complex = np.array([complex(i, -i) for i in range(N)], dtype=object)
obj_frac = np.array(
    [fractions.Fraction(3 * i + 1, 2) for i in range(N)], dtype=object
)

identity = np.frompyfunc(lambda x: x, 1, 1)
add2 = np.frompyfunc(lambda x, y: x, 2, 1)

dt_arr = np.array(["2020-05-17T12:00"] * N, dtype="M8[m]")
tzinfo = datetime.timezone(datetime.timedelta(hours=5, minutes=30))

mat = np.matrix([[1.0, 2.0], [3.0, 4.0]])


def bench_frompyfunc_1in(loops):
    t0 = pyperf.perf_counter()
    for _ in range(loops):
        identity(obj_int)
    return pyperf.perf_counter() - t0


def bench_frompyfunc_2in(loops):
    t0 = pyperf.perf_counter()
    for _ in range(loops):
        add2(obj_int, obj_int2)
    return pyperf.perf_counter() - t0


def bench_conjugate_object(loops):
    t0 = pyperf.perf_counter()
    for _ in range(loops):
        np.conjugate(obj_complex)
    return pyperf.perf_counter() - t0


def bench_floor_object(loops):
    t0 = pyperf.perf_counter()
    for _ in range(loops):
        np.floor(obj_frac)
    return pyperf.perf_counter() - t0


def bench_gcd_object(loops):
    t0 = pyperf.perf_counter()
    for _ in range(loops):
        np.gcd(obj_int, obj_int2)
    return pyperf.perf_counter() - t0


def bench_datetime_as_string_tz(loops):
    t0 = pyperf.perf_counter()
    for _ in range(loops):
        np.datetime_as_string(dt_arr, timezone=tzinfo)
    return pyperf.perf_counter() - t0


def bench_matrix_add(loops):
    m = mat
    t0 = pyperf.perf_counter()
    for _ in range(loops):
        m + m
    return pyperf.perf_counter() - t0


runner = pyperf.Runner()
runner.bench_time_func("frompyfunc_1in_1000", bench_frompyfunc_1in)
runner.bench_time_func("frompyfunc_2in_1000", bench_frompyfunc_2in)
runner.bench_time_func("conjugate_object_1000", bench_conjugate_object)
runner.bench_time_func("floor_object_1000", bench_floor_object)
runner.bench_time_func("gcd_object_1000", bench_gcd_object)
runner.bench_time_func("datetime_as_string_tz_1000", bench_datetime_as_string_tz)
runner.bench_time_func("matrix_add_2x2", bench_matrix_add)

AI Disclosure

Claude was prompted to find all inefficient python calls in a performance critical path (this means there are some cases remaining that can be converted if needed). The benchmark script was written by Claude as well.

Replace PyObject_CallMethod/PyObject_CallFunction with format strings and
per-call attribute-name creation by the faster vectorcall based calling
conventions (PyObject_Vectorcall, PyObject_CallOneArg,
PyObject_CallMethodNoArgs/OneArg, PyObject_CallNoArgs) and interned strings:

- PyUFunc_On_Om (np.frompyfunc / np.vectorize inner loop): vectorcall with
  a stack array instead of allocating an argument tuple per element
- PyUFunc_O_O_method / PyUFunc_OO_O_method: create the method name once
  per loop instead of once per element
- object loops of floor/ceil/trunc/gcd: PyObject_CallOneArg / vectorcall
- object matmul and vdot: interned 'conjugate' + PyObject_CallMethodNoArgs
- get_tzoffset_from_pytzinfo: interned 'astimezone' + CallMethodOneArg
- PyArray_CopyConverter: interned 'value' attribute lookup
- number.c generic (inplace) binary/unary forwarding: vectorcall
- PyArray_Ptp/Round/Std and _void_compare: vectorcall instead of
  format-string PyObject_CallFunction

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@KRRT7

KRRT7 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

I think this PR introduces a borrowed-reference lifetime bug in the object gcd loop.

npy_ObjectGCD changed from PyObject_CallFunction(..., "OO", i1, i2) to PyObject_Vectorcall(...). The old call built a temporary tuple and therefore kept strong references to both object-array operands while math.gcd ran. With vectorcall, math.gcd receives borrowed pointers. If the first operand's __index__ re-entrantly clears the array slot holding the second operand, math.gcd can later dereference the freed second operand.

Reproducer on this branch:

import faulthandler
import numpy as np

faulthandler.enable()
victim_array = None

class Killer:
    def __index__(self):
        global victim_array
        print("Killer.__index__: clearing victim array slot", flush=True)
        victim_array[0] = None
        print("Killer.__index__: cleared", flush=True)
        return 12

class Victim:
    def __index__(self):
        print("Victim.__index__", flush=True)
        return 8

    def __del__(self):
        print("Victim.__del__", flush=True)

left = np.empty(1, dtype=object)
left[0] = Killer()
victim_array = np.empty(1, dtype=object)
victim_array[0] = Victim()

print("about to call np.gcd", flush=True)
np.gcd(left, victim_array)

I ran this in a subprocess locally and got returncode=-11 with a segfault. The output shows Victim.__del__ before Victim.__index__, and the C stack includes:

math_gcd
PyObject_Vectorcall
npy_ObjectGCD
PyUFunc_OO_O

As a control, keeping an extra Python reference to victim_array[0] before calling np.gcd makes the same script complete and return [4], which matches the hypothesis that the old temporary tuple was providing the missing strong reference.

Suggested fix: keep strong refs around the vectorcall, at least for npy_ObjectGCD, e.g. Py_INCREF(i1); Py_INCREF(i2); ... Py_DECREF(i2); Py_DECREF(i1);, similar to the explicit argument protection already added in PyUFunc_On_Om.

The vectorcall-based calls receive borrowed references from the object
array slots; re-entrant code run during the call (e.g. __index__) can
clear those slots and free an operand mid-call. Hold strong references
in npy_ObjectGCD, npy_ObjectLCM and PyUFunc_OO_O_method.

Also fix a pre-existing use-after-free in the PyUFunc_O_O_method error
path, which read Py_TYPE(in1) after the attribute lookup may have freed
in1 (reproducible on main and 2.5.1 with PYTHONMALLOC=debug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eendebakpt eendebakpt changed the title PERF: use faster calling conventions (vectorcall) in several hot paths PERF, BUG: use faster calling conventions (vectorcall) in several hot paths Jul 14, 2026
@eendebakpt

Copy link
Copy Markdown
Contributor Author

I think this PR introduces a borrowed-reference lifetime bug in the object gcd loop.

I agree. Similar bugs are already present on main, I am addressing those as well.

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.

2 participants