GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Slow functions calls for C-coded functions, accepting positional-or-kwarg argument vs positional-only · Issue #136681 · python/cpython

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

New issue
Copy link
New issue
Copy link
Open
Open
Slow functions calls for C-coded functions, accepting positional-or-kwarg argument vs positional-only#136681
Copy link
Labels
performancePerformance or resource usagePerformance or resource usagetopic-argument-clinictype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

Description

@skirpichev
skirpichev
opened on Jul 15, 2025
Issue body actions

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

  • gh-136681: make argsbuf static to speedup processing positional arguments with AC #136732
  • gh-136681: optimize builtin calls if kwargs allowed #137202
Reacted by Pieter Eendebak

Activity

  1. skirpichev
    self-assigned this
    on Jul 15, 2025
  2. skirpichev
    added
    type-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
    performancePerformance or resource usagePerformance or resource usage
    topic-argument-clinic
    on Jul 15, 2025
  3. serhiy-storchaka commented on Jul 16, 2025

    @serhiy-storchaka
    serhiy-storchaka
    on Jul 16, 2025
    Member
    More actions

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

    Reacted by Sergey B Kirpichev
  4. skirpichev commented on Jul 16, 2025

    @skirpichev
    skirpichev
    on Jul 16, 2025
    MemberAuthor
    More actions

    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.

  5. serhiy-storchaka commented on Jul 16, 2025

    @serhiy-storchaka
    serhiy-storchaka
    on Jul 16, 2025
    Member
    More actions

    Static initializer should only be executed once.

  6. skirpichev commented on Jul 16, 2025

    @skirpichev
    skirpichev
    on Jul 16, 2025
    MemberAuthor
    More actions

    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?

  7. serhiy-storchaka commented on Jul 16, 2025

    @serhiy-storchaka
    serhiy-storchaka
    on Jul 16, 2025
    Member
    More actions

    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.

  8. skirpichev commented on Jul 16, 2025

    @skirpichev
    skirpichev
    on Jul 16, 2025
    MemberAuthor
    More actions

    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.

  9. skirpichev
    added 4 commits that reference this issue on Jul 17, 2025

    pythongh-136681: make argsbuf static to speedup processing positional

    545e04e

    pythongh-136681: make argsbuf static to speedup processing positional

    e6eb219

    pythongh-136681: make argsbuf static to speedup processing positional

    c88f2bf

    pythongh-136681: make argsbuf static to speedup processing positional

    5463883
  10. kumaraditya303 commented on Jul 17, 2025

    @kumaraditya303
    kumaraditya303
    on Jul 17, 2025
    Contributor
    More actions

    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.

  11. skirpichev commented on Jul 17, 2025

    @skirpichev
    skirpichev
    on Jul 17, 2025
    MemberAuthor
    More actions

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

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

    pythongh-136681: optimize builtin calls if kwargs allowed

    44f8087

    pythongh-136681: optimize builtin calls if kwargs allowed

    51d4d9d

    pythongh-136681: optimize builtin calls if kwargs allowed

    18632fc
  13. skirpichev commented on Jul 30, 2025

    @skirpichev
    skirpichev
    on Jul 30, 2025
    MemberAuthor
    More actions

    @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)
  14. skirpichev commented on Sep 10, 2025

    @skirpichev
    skirpichev
    on Sep 10, 2025
    MemberAuthor
    More actions

    PR #136732 is ready for review.

  15. skirpichev
    removed their assignment
    on Sep 10, 2025
  16. eendebakpt
    added 5 commits that reference this issue on May 21, 2026

    pythongh-136681: Improve _PyArg_UnpackKeywords fast-path macro

    6eb99cb

    pythongh-136681: Pre-compute parser.pos at AC time; bltinmodule fully…

    7178bbc

    pythongh-136681: Pre-compute parser.pos at AC time; bltinmodule fully…

    6848533

    pythongh-136681: Delete dead parse_format/scan_keywords; skip parser_…

    5fddb71

    pythongh-136681: Split _PyArg_UnpackKeywords; pre-compute parser.pos …

    7ac1692
  17. eendebakpt
    added a commit that references this issue on Sep 13, 2026

    pythongh-136681: Shrink _PyArg_Parser and skip its init for static kw…

    6f9a09d
  18. eendebakpt
    added a commit that references this issue on Sep 27, 2026

    gh-157407: Argument Clinic: parse positional-only `defining_class` me…

    1c4fa4f
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

    performancePerformance or resource usagePerformance or resource usagetopic-argument-clinictype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      You are visitor 01402503 — GeezerVison does not store the pages you look up.
      Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.