Skip to content

Slow functions calls for C-coded functions, accepting positional-or-kwarg argument vs positional-only #136681

Open

Description

@skirpichev

Bug report

Bug description:

I don't see measurable difference on pure-Python level, e.g. for

# a.py
import pyperf

def f1(x):
    return 1

def f2(x, /):
    return 1

runner = pyperf.Runner()
runner.bench_func("f1(1)", f1, 1)
runner.bench_func("f2(1)", f2, 1)

I got on 3.13:

$ python a.py -q
f1(1): Mean +- std dev: 233 ns +- 2 ns
f2(1): Mean +- std dev: 232 ns +- 2 ns

But for functions, implemented in C, using the Argument Clinic - the difference is bigger:

$ python bench.py -q -o patch.json
isfinite(1.0): Mean +- std dev: 152 ns +- 1 ns
$ git checkout master && make -s
Checked 114 modules (35 built-in, 78 shared, 1 n/a on linux-x86_64, 0 disabled, 0 missing, 0 failed on import)
$ python bench.py -q -o ref.json
isfinite(1.0): Mean +- std dev: 136 ns +- 1 ns

Benchmark ref patch
isfinite(1.0) 136 ns 152 ns: 1.12x slower

(In the second version - the math.isfinite() accept also keyword argument x)

a patch

(run ./python Tools/clinic/clinic.py Modules/mathmodule.c !)

diff --git a/Modules/mathmodule.c b/Modules/mathmodule.c
index 7c2a421dd6..e33281ac8b 100644
--- a/Modules/mathmodule.c
+++ b/Modules/mathmodule.c
@@ -3156,14 +3156,13 @@ math_radians_impl(PyObject *module, double x)
 math.isfinite
 
     x: double
-    /
 
 Return True if x is neither an infinity nor a NaN, and False otherwise.
 [clinic start generated code]*/
 
 static PyObject *
 math_isfinite_impl(PyObject *module, double x)
-/*[clinic end generated code: output=8ba1f396440c9901 input=46967d254812e54a]*/
+/*[clinic end generated code: output=8ba1f396440c9901 input=a47ad6e72998b81d]*/
 {
     return PyBool_FromLong((long)isfinite(x));
 }

This performance penalty affects API decisions (e.g. #131886) and that looks as a bug.

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux

Linked PRs

Activity

  1. self-assigned this
    on Jul 15, 2025
  2. serhiy-storchaka commented on Jul 16, 2025

    @serhiy-storchaka
    Member

    Look at the generated code. _PyArg_UnpackKeywords has a fast path. I would check if kwnames is NULL here.

  3. skirpichev commented on Jul 16, 2025

    @skirpichev
    MemberAuthor

    Look at the generated code. _PyArg_UnpackKeywords has a fast path. I would check if kwnames is NULL here.

    No. It's already checked. Twice. Both in _PyArg_UnpackKeywords() function and in the _PyArg_UnpackKeywords() macro. And this path - hits.

    The problem is code for initialization of _kwtuple in clinic's generated output:

        #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE)
    
        #define NUM_KEYWORDS 1
        static struct {
            PyGC_Head _this_is_not_used;
            PyObject_VAR_HEAD
            Py_hash_t ob_hash;
            PyObject *ob_item[NUM_KEYWORDS];
        } _kwtuple = {
            .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS)
            .ob_hash = -1,
            .ob_item = { _Py_LATIN1_CHR('x'), },
        };
        #undef NUM_KEYWORDS
        #define KWTUPLE (&_kwtuple.ob_base.ob_base)
    
        #else  // !Py_BUILD_CORE
        #  define KWTUPLE NULL
        #endif  // !Py_BUILD_CORE
    
        static const char * const _keywords[] = {"x", NULL};
        static _PyArg_Parser _parser = {
            .keywords = _keywords,
            .fname = "isfinite",
            .kwtuple = KWTUPLE,
        };
        #undef KWTUPLE
        PyObject *argsbuf[1];
        double x;
    
        args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser,
                /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf);

    If I move that to if (kwnames) { ... } block - speed loss is lost.

  4. serhiy-storchaka commented on Jul 16, 2025

    @serhiy-storchaka
    Member

    Static initializer should only be executed once.

  5. skirpichev commented on Jul 16, 2025

    @skirpichev
    MemberAuthor

    Hmm, with following in the clinic output:

        if (kwnames) {
            PyObject *argsbuf[1];
    
            args = _PyArg_UnpackKeywords(args, nargs, NULL, kwnames, &_parser,
                       /*minpos*/ 1, /*maxpos*/ 1, /*minkw*/ 0, /*varpos*/ 0, argsbuf);
        }

    I got same numbers as in the master (i.e. with positional-only isfinite) for a simple benchmark:

    # a.py
    import pyperf
    from math import isfinite
    
    runner = pyperf.Runner()
    runner.bench_func('isfinite(1.0)', isfinite, 1.0)

    (CPython built with default configure flags.)

    patch for the AC
    diff --git a/Tools/clinic/libclinic/parse_args.py b/Tools/clinic/libclinic/parse_args.py
    index 0e15d2f163..bb25576481 100644
    --- a/Tools/clinic/libclinic/parse_args.py
    +++ b/Tools/clinic/libclinic/parse_args.py
    @@ -622,7 +622,6 @@ def parse_general(self, clang: CLanguage) -> None:
                     self.flags = "METH_FASTCALL|METH_KEYWORDS"
                     self.parser_prototype = PARSER_PROTOTYPE_FASTCALL_KEYWORDS
                     self.declarations = declare_parser(self.func, codegen=self.codegen)
    -                self.declarations += "\nPyObject *argsbuf[%s];" % (len(self.converters) or 1)
                     if self.varpos:
                         self.declarations += "\nPyObject * const *fastargs;"
                         argsname = 'fastargs'
    @@ -640,15 +639,18 @@ def parse_general(self, clang: CLanguage) -> None:
                     argsname = 'fastargs'
                     argname_fmt = 'fastargs[%d]'
                     self.declarations = declare_parser(self.func, codegen=self.codegen)
    -                self.declarations += "\nPyObject *argsbuf[%s];" % (len(self.converters) or 1)
                     self.declarations += "\nPyObject * const *fastargs;"
                     self.declarations += "\nPy_ssize_t nargs = PyTuple_GET_SIZE(args);"
                     if has_optional_kw:
                         self.declarations += "\nPy_ssize_t noptargs = %s + (kwargs ? PyDict_GET_SIZE(kwargs) : 0) - %d;" % (nargs, self.min_pos + self.min_kw_only)
                     unpack_args = '_PyTuple_CAST(args)->ob_item, nargs, kwargs, NULL'
                 parser_code = [libclinic.normalize_snippet(f"""
    -                {argsname} = _PyArg_UnpackKeywords({unpack_args}, &_parser,
    -                        /*minpos*/ {self.min_pos}, /*maxpos*/ {self.max_pos}, /*minkw*/ {self.min_kw_only}, /*varpos*/ {1 if self.varpos else 0}, argsbuf);
    +                if ({"kwnames" if self.fastcall else "kwargs"}) {{{{
    +                    PyObject *argsbuf[{len(self.converters) or 1}];
    +
    +                    {argsname} = _PyArg_UnpackKeywords({unpack_args}, &_parser,
    +                               /*minpos*/ {self.min_pos}, /*maxpos*/ {self.max_pos}, /*minkw*/ {self.min_kw_only}, /*varpos*/ {1 if self.varpos else 0}, argsbuf);
    +                }}}}
                     if (!{argsname}) {{{{
                         goto exit;
                     }}}}

    Though, if I benchmark a loop with:

    # b.py
    import pyperf
    from math import isfinite
    
    def f(n):
        for _ in range(n):
            isfinite(1.0)
    
    runner = pyperf.Runner()
    for n in [1, 2, 10, 100]:
        s = f'isfinite(1.0) x {n:3} times'
        runner.bench_func(s, f, n)

    it's still worse!. I got

    Benchmark ref patch patch0
    isfinite(1.0) x 1 times 661 ns 699 ns: 1.06x slower 717 ns: 1.08x slower
    isfinite(1.0) x 2 times 757 ns 802 ns: 1.06x slower 842 ns: 1.11x slower
    isfinite(1.0) x 10 times 1.38 us 1.56 us: 1.13x slower 1.70 us: 1.23x slower
    isfinite(1.0) x 100 times 8.40 us 10.1 us: 1.21x slower 11.3 us: 1.34x slower
    Geometric mean (ref) 1.11x slower 1.19x slower

    where patch0 - version without if (kwnames) condition.

    I suspect this might be related to specialization for c-methods calls in Python/specialize.c. (Edit: indeed, it's because specialize_c_call().)

    @serhiy-storchaka, the AC patch alone does make sense for you?

  6. serhiy-storchaka commented on Jul 16, 2025

    @serhiy-storchaka
    Member

    No, this change is not correct. _PyArg_UnpackKeywords cannot be omitted if kwnames is NULL. For example, it checks that the correct number of positional arguments was passed.

    There are two implementations of _PyArg_UnpackKeywords -- as a macro and as a C function. The macro version handles the fast path, it should add very little overhead. The function implementation is only used for non-fast path. So my suspect is that for some reasons the fast path may be not correctly detected, so the code goes the general slow path. Please check if the _PyArg_UnpackKeywords function was called in that case.

  7. skirpichev commented on Jul 16, 2025

    @skirpichev
    MemberAuthor

    No, this change is not correct. _PyArg_UnpackKeywords cannot be omitted if kwnames is NULL. For example, it checks that the correct number of positional arguments was passed.

    That was just a draft:)

    So my suspect is that for some reasons the fast path may be not correctly detected, so the code goes the general slow path. Please check if the _PyArg_UnpackKeywords function was called in that case.

    I already did this first. No - fast path was taken.

    I think I've found a simpler solution: just add a static decorator for argsbuf. Here benchmark for math.fmin (ref - current main, patch-v0 - support for kwargs, patch-v1 - make also argsbuf static). Up to a noise - the v1 patch match timings in the main branch:

    Benchmark ref patch-v0 patch-v1
    fmin(1.0, 2.0) 168 ns 185 ns: 1.10x slower not significant
    fmin(1.0, 2.0) x 2 times 908 ns 956 ns: 1.05x slower 926 ns: 1.02x slower
    fmin(1.0, 2.0) x 10 times 2.04 us 2.23 us: 1.09x slower 2.06 us: 1.01x slower
    fmin(1.0, 2.0) x 100 times 14.8 us 16.5 us: 1.11x slower not significant
    Geometric mean (ref) 1.09x slower 1.01x slower
    # bench.py
    import pyperf
    from math import fmin
    
    def f(n):
        for _ in range(n):
            fmin(1.0, 2.0)
    
    runner = pyperf.Runner()
    runner.bench_func('fmin(1.0, 2.0)', fmin, 1.0, 2.0)
    for n in [2, 10, 100]:
        s = f'fmin(1.0, 2.0) x {n:3} times'
        runner.bench_func(s, f, n)

    For one-arg functions (like isfinite()) - the difference holds also in the v1 version for loops. I hope this could be fixed too, using different specialized instructions.

  8. added 4 commits that reference this issue on Jul 17, 2025
  9. kumaraditya303 commented on Jul 17, 2025

    @kumaraditya303
    Contributor

    There are redundant checks in the implementation of these functions:

    PyObject * const *
    _PyArg_UnpackKeywords(PyObject *const *args, Py_ssize_t nargs,
                          PyObject *kwargs, PyObject *kwnames,
                          struct _PyArg_Parser *parser,
                          int minpos, int maxpos, int minkw, int varpos,
                          PyObject **buf)
    {
        PyObject *kwtuple;
        PyObject *keyword;
        int i, posonly, minposonly, maxargs;
        int reqlimit = minkw ? maxpos + minkw : minpos;
        Py_ssize_t nkwargs;
        PyObject * const *kwstack = NULL;
    
        assert(kwargs == NULL || PyDict_Check(kwargs));
        assert(kwargs == NULL || kwnames == NULL);
    
        if (parser == NULL) {
            PyErr_BadInternalCall();
            return NULL;
        }
    
        if (kwnames != NULL && !PyTuple_Check(kwnames)) {
            PyErr_BadInternalCall();
            return NULL;
        }

    Here the check for NULL check for parser and type check for kwnames can be turned into asserts, they should not be runtime checks, similarly for other functions as well.

  10. skirpichev commented on Jul 17, 2025

    @skirpichev
    MemberAuthor

    The function is not the reason for a slowdown, as the fast path is taken in the macro.

  11. added 3 commits that reference this issue on Jul 29, 2025
  12. skirpichev commented on Jul 30, 2025

    @skirpichev
    MemberAuthor

    @serhiy-storchaka, @kumaraditya303 - #136732 implements a variation of the storage class specifier. So far, I kept this PR as a draft, as it's huge. Though, actual changes in AC are small. Let me know if it worth to make this review-ready.

    This doesn't fix problem completely. Still, there are some differences, especially for 1-arg case (as noted in #136681 (comment)).

    In the main:

    Benchmark posonly-ref posorkw-ref
    fmin(1.0, 2.0) 171 ns 190 ns: 1.11x slower
    fmin(1.0, 2.0) x 2 times 967 ns 992 ns: 1.03x slower
    fmin(1.0, 2.0) x 10 times 2.10 us 2.26 us: 1.08x slower
    fmin(1.0, 2.0) x 100 times 14.8 us 16.5 us: 1.12x slower
    Geometric mean (ref) 1.08x slower
    Benchmark posonly-ref posorkw-ref
    isfinite(1.0) 136 ns 154 ns: 1.13x slower
    isfinite(1.0) x 2 times 768 ns 838 ns: 1.09x slower
    isfinite(1.0) x 10 times 1.39 us 1.69 us: 1.21x slower
    isfinite(1.0) x 100 times 8.38 us 11.3 us: 1.35x slower
    Geometric mean (ref) 1.19x slower

    With the patch:

    Benchmark posonly-patch posorkw-patch
    fmin(1.0, 2.0) 170 ns 180 ns: 1.06x slower
    fmin(1.0, 2.0) x 2 times 929 ns 946 ns: 1.02x slower
    fmin(1.0, 2.0) x 10 times 2.06 us 2.09 us: 1.01x slower
    fmin(1.0, 2.0) x 100 times 14.8 us 15.0 us: 1.02x slower
    Geometric mean (ref) 1.03x slower
    Benchmark posonly-patch posorkw-patch
    isfinite(1.0) 136 ns 141 ns: 1.04x slower
    isfinite(1.0) x 2 times 750 ns 804 ns: 1.07x slower
    isfinite(1.0) x 10 times 1.37 us 1.59 us: 1.16x slower
    isfinite(1.0) x 100 times 8.38 us 10.4 us: 1.25x slower
    Geometric mean (ref) 1.13x slower
    Details
    import pyperf
    from math import fmin
    
    def f(n):
        for _ in range(n):
            fmin(1.0, 2.0)
    
    runner = pyperf.Runner()
    runner.bench_func('fmin(1.0, 2.0)', fmin, 1.0, 2.0)
    for n in [2, 10, 100]:
        s = f'fmin(1.0, 2.0) x {n:3} times'
        runner.bench_func(s, f, n)
    # bench2.py
    import pyperf
    from math import isfinite
    
    def f(n):
        for _ in range(n):
            isfinite(1.0)
    
    runner = pyperf.Runner()
    runner.bench_func('isfinite(1.0)', isfinite, 1.0)
    for n in [2, 10, 100]:
        s = f'isfinite(1.0) x {n:3} times'
        runner.bench_func(s, f, n)
  13. skirpichev commented on Sep 10, 2025

    @skirpichev
    MemberAuthor

    PR #136732 is ready for review.

  14. removed their assignment
    on Sep 10, 2025
  15. added 5 commits that reference this issue on May 21, 2026
  16. added a commit that references this issue on Sep 13, 2026
  17. added a commit that references this issue on Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions