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
// 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().
// Include/cpython/object.h
Py_ssize_t tp_vectorcall_offset;
...
vectorcallfunc tp_vectorcall;// Include/object.h
#define Py_TPFLAGS_HAVE_VECTORCALL (1UL << 11)Argument encoding
// Include/abstract.h
#define PY_VECTORCALL_ARGUMENTS_OFFSET \
(_Py_STATIC_CAST(size_t, 1) << (8 * sizeof(size_t) - 1))// 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
// 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
// Include/cpython/funcobject.h
typedef struct {
PyObject_HEAD
...
vectorcallfunc vectorcall;
uint32_t func_version;
} PyFunctionObject;// 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,
};// 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:
// 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
// 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:
// 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
// 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
// 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.