How CPython Makes Python Calls Fast with Vectorcall

PEP 590 vectorcall: type slots, argument arrays, kwnames, the PY_VECTORCALL_ARGUMENTS_OFFSET trick, FASTCALL methods, bound methods, and how call specialization uses it.

Every Python call used to build a tuple for positional arguments and a dict for keyword arguments, even when the callee only wanted to read them. PEP 590 introduced vectorcall, which passes arguments as a C array plus a kwnames tuple, skipping the temporary containers entirely. It is now the fast path for Python functions, C functions, method descriptors, bound methods, and built-in types. This article traces how it works.

The core type

c
// Include/object.h
typedef PyObject *(*vectorcallfunc)(PyObject *callable, PyObject *const *args,
                                    size_t nargsf, PyObject *kwnames);

A type opts in by setting tp_vectorcall_offset (the byte offset inside an instance where its vectorcallfunc * lives) and Py_TPFLAGS_HAVE_VECTORCALL. The type-level tp_vectorcall is used for classes/types themselves, e.g. list(), bool(), range().

c
// Include/cpython/object.h
Py_ssize_t tp_vectorcall_offset;
...
vectorcallfunc tp_vectorcall;
c
// Include/object.h
#define Py_TPFLAGS_HAVE_VECTORCALL (1UL << 11)

Argument encoding

c
// Include/abstract.h
#define PY_VECTORCALL_ARGUMENTS_OFFSET \
    (_Py_STATIC_CAST(size_t, 1) << (8 * sizeof(size_t) - 1))
c
// Include/cpython/abstract.h
static inline Py_ssize_t
_PyVectorcall_NARGS(size_t n)
{
    return n & ~PY_VECTORCALL_ARGUMENTS_OFFSET;
}

args points to positional args followed by keyword values. nargsf is the positional count, optionally ORed with the offset flag. If the flag is set, the callee may temporarily mutate args[-1] but must restore it. kwnames is a tuple of keyword names, or NULL.

The offset flag is the trick that makes bound methods cheap: a bound method needs to prepend self, and if there is spare slot before args it can write self there instead of allocating a new array.

The inline dispatch

c
// Include/internal/pycore_call.h
static inline vectorcallfunc
_PyVectorcall_FunctionInline(PyObject *callable)
{
    PyTypeObject *tp = Py_TYPE(callable);
    if (!PyType_HasFeature(tp, Py_TPFLAGS_HAVE_VECTORCALL)) {
        return NULL;
    }
    Py_ssize_t offset = tp->tp_vectorcall_offset;
    vectorcallfunc ptr;
    memcpy(&ptr, (char *) callable + offset, sizeof(ptr));
    return ptr;
}

static inline PyObject *
_PyObject_VectorcallTstate(PyThreadState *tstate, PyObject *callable,
                           PyObject *const *args, size_t nargsf,
                           PyObject *kwnames)
{
    vectorcallfunc func;
    func = _PyVectorcall_FunctionInline(callable);
    if (func == NULL) {
        Py_ssize_t nargs = PyVectorcall_NARGS(nargsf);
        return _PyObject_MakeTpCall(tstate, callable, args, nargs, kwnames);
    }
    PyObject *res = func(callable, args, nargsf, kwnames);
    return _Py_CheckFunctionResult(tstate, callable, res, NULL);
}

Both helpers are static inline, so the compiler can fold the type-feature check and the offset lookup into the call site. PyObject_Vectorcall is just an alias for _PyObject_VectorcallTstate.

If there is no vectorcall, _PyObject_MakeTpCall falls back to tp_call, building a tuple and dict the old way.

Python functions

c
// Include/cpython/funcobject.h
typedef struct {
    PyObject_HEAD
    ...
    vectorcallfunc vectorcall;
    uint32_t func_version;
} PyFunctionObject;
c
// Objects/funcobject.c
op->vectorcall = _PyFunction_Vectorcall;
...
PyTypeObject PyFunction_Type = {
    ...
    offsetof(PyFunctionObject, vectorcall),     /* tp_vectorcall_offset */
    ...
    PyVectorcall_Call,                          /* tp_call */
    ...
    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC |
    Py_TPFLAGS_HAVE_VECTORCALL |
    Py_TPFLAGS_METHOD_DESCRIPTOR,
};
c
// Objects/call.c
PyObject *
_PyFunction_Vectorcall(PyObject *func, PyObject* const* stack,
                       size_t nargsf, PyObject *kwnames)
{
    PyFunctionObject *f = (PyFunctionObject *)func;
    Py_ssize_t nargs = PyVectorcall_NARGS(nargsf);
    PyThreadState *tstate = _PyThreadState_GET();
    if (((PyCodeObject *)f->func_code)->co_flags & CO_OPTIMIZED) {
        return _PyEval_Vector(tstate, f, NULL, stack, nargs, kwnames);
    }
    else {
        return _PyEval_Vector(tstate, f, f->func_globals, stack, nargs, kwnames);
    }
}

So a Python function call goes straight into the evaluation loop with the argument array, no tuple. PyFunction_SetVectorcall allows profilers to replace the slot; it also clears func_version.

C functions and method descriptors

The vectorcall implementation is chosen from the METH_* flags at creation:

c
// Objects/methodobject.c
switch (ml->ml_flags & (METH_VARARGS | METH_FASTCALL | METH_NOARGS |
                        METH_O | METH_KEYWORDS | METH_METHOD)) {
    case METH_VARARGS:
    case METH_VARARGS | METH_KEYWORDS:
        vectorcall = NULL;   // tuple building unavoidable anyway
        break;
    case METH_FASTCALL:
        vectorcall = cfunction_vectorcall_FASTCALL;
        break;
    case METH_FASTCALL | METH_KEYWORDS:
        vectorcall = cfunction_vectorcall_FASTCALL_KEYWORDS;
        break;
    case METH_NOARGS:
        vectorcall = cfunction_vectorcall_NOARGS;
        break;
    case METH_O:
        vectorcall = cfunction_vectorcall_O;
        break;
    ...
}

METH_VARARGS deliberately has no vectorcall, because those functions want a tuple regardless. METH_FASTCALL functions receive args and nargs directly. Method descriptors in Objects/descrobject.c follow the same pattern.

Bound methods

c
// Objects/classobject.c
static PyObject *
method_vectorcall(PyObject *method, PyObject *const *args,
                  size_t nargsf, PyObject *kwnames)
{
    PyObject *self = PyMethod_GET_SELF(method);
    PyObject *func = PyMethod_GET_FUNCTION(method);
    return _PyObject_VectorcallPrepend(tstate, func, self, args, nargsf, kwnames);
}

_PyObject_VectorcallPrepend uses the offset flag to write self into args[-1] when possible, avoiding allocation.

Built-in types

Builtin classes use tp_vectorcall at the type level:

c
// Python/ceval.c
res = tp->tp_vectorcall((PyObject *)tp, args_o,
                        total_args | PY_VECTORCALL_ARGUMENTS_OFFSET, NULL);

list, dict, bool, range, tuple, etc. implement these directly, so list(iterable) or bool(x) never builds an argument tuple.

Type creation and inheritance

c
// Objects/typeobject.c
/* Always inherit tp_vectorcall_offset to support PyVectorcall_Call(). */
COPYSLOT(tp_vectorcall_offset);

if (!type->tp_call &&
    _PyType_HasFeature(base, Py_TPFLAGS_HAVE_VECTORCALL))
{
    type_add_flags(type, Py_TPFLAGS_HAVE_VECTORCALL);
}
COPYSLOT(tp_call);

The offset is always inherited; the flag is inherited only if tp_call is not overridden, so a subclass that replaces __call__ falls back to the normal path.

Recursion caveat

Unlike tp_call, vectorcall does not manage recursion automatically. Callees that can recurse must call Py_EnterRecursiveCall / Py_LeaveRecursiveCall themselves. This is the one cost of the faster path.

How specialization uses it

c
// Python/specialize.c
if (func->vectorcall != _PyFunction_Vectorcall) {
    SPECIALIZATION_FAIL(CALL, SPEC_FAIL_CALL_VECTORCALL);
    return -1;
}

Vectorcall is not just the normal fast path; it is also a guard for Tier 1 call specialization. A function whose vectorcall slot has been replaced (by a profiler or wrapper) is ineligible for specialized calls.

Why this is fast

The win is the absence of work: no tuple allocation, no dict allocation, no argument copying beyond the array the caller already had. For a Python function the arguments land directly on the evaluation stack’s argument array; for a C function they land in the C parameter list. Bound methods and built-in types get the same treatment, and the offset flag makes prepending self free. In a language where calls are frequent, removing two allocations per call is a large, compounding speedup.

PythonCpythonInternalsPerformanceCalls