Skip to content

Calls with 16+ keyword arguments are ~4.5x slower than calls with 15 #155141

Closed

Description

@G000D1ESS

Bug report

Bug description:

A function call with 16 or more keyword arguments compiles to a
different, slower call opcode than the same call with 15. The switch
happens at an exact, fixed cutoff, not a gradual slowdown: adding one
more keyword argument (15 -> 16) makes the call ~4.5x slower.

codegen_call_helper_impl() in Python/codegen.c picks the call opcode
based on this check:

https://github.com/python/cpython/blob/main/Python/codegen.c#L4430

if (nelts + nkwelts*2 > _PY_STACK_USE_GUIDELINE) {
     goto ex_call;
}

nelts is the number of positional args, nkwelts the number of
keyword args, _PY_STACK_USE_GUIDELINE is 30. A call with only keyword
arguments trips this at 16. Above the threshold the compiler builds a
tuple+dict and calls CALL_FUNCTION_EX; below it, it just uses
CALL_KW.

Reproducer

import dis
import timeit

N_CALLS = 200_000
N_REPEATS = 21

for n in range(12, 21):
    params = ", ".join(f"k{i}" for i in range(n))
    kwargs = ", ".join(f"k{i}=1" for i in range(n))

    namespace = {}
    exec(f"def callee({params}): pass", namespace)
    exec(f"def caller(): return callee({kwargs})", namespace)
    caller = namespace["caller"]

    call_instr = next(
        instr for instr in dis.get_instructions(caller)
        if instr.opname.startswith("CALL")
    )
    best = min(timeit.repeat(caller, number=N_CALLS, repeat=N_REPEATS))
    time_ns = best / N_CALLS * 1e9

    print(f"n ={n:>3}  {call_instr.opname:<18}  {time_ns:7.1f} ns  "
          f"stacksize={caller.__code__.co_stacksize}")

Calling an empty function with n named params, all passed as keywords:

| n  | opcode           | time     |
|----|------------------|----------|
| 12 | CALL_KW          | 80.2 ns  |
| 13 | CALL_KW          | 85.5 ns  |
| 14 | CALL_KW          | 90.7 ns  |
| 15 | CALL_KW          | 98.3 ns  |
| 16 | CALL_FUNCTION_EX | 440.1 ns | <- one more keyword, 4.5x slower
| 17 | CALL_FUNCTION_EX | 458.3 ns |
| 18 | CALL_FUNCTION_EX | 509.7 ns |
| 19 | CALL_FUNCTION_EX | 530.8 ns |
| 20 | CALL_FUNCTION_EX | 549.9 ns |

Possible directions

  1. Raise the threshold for this check. sets with >30 elements not constant-folded in in/not in and for loops #148817 has a similar discussion
    for a different code path, with 64 proposed as a round number

  2. Drop the threshold entirely for the call case. In the same thread,
    one maintainer argued cutoffs aren't worth it here, runtime
    performance matters more than bytecode size

  3. Improve the accuracy of the formula, so it tracks the CALL_KW
    path's real stack use - close to nelts + nkwelts + (nkwelts != 0),
    not nelts + nkwelts*2.

    Benchmarked this version, _PY_STACK_USE_GUIDELINE left at 30:

    // Python/codegen.c:4430, before:
    if (nelts + nkwelts*2 > _PY_STACK_USE_GUIDELINE) {
    // after:
    if (nelts + nkwelts + (nkwelts != 0) > _PY_STACK_USE_GUIDELINE) {
    n=16   CALL_KW            101.6 ns   (was 440.1 ns)
    ...
    n=29   CALL_KW            219.2 ns
    n=30   CALL_FUNCTION_EX   895.6 ns   (cliff still there, but moved to 30)
    

Related

#148817 (open) - a different change (gh-126835) stopped constant-folding
set/list literals over 30 items in in/not in/for, ~20x slower
3.13 -> 3.14. Different code path, same constant

Versions affected

Present in every release from 3.10.0 through 3.14.0, and current main

CPython versions tested on:

3.10, 3.12, CPython main branch

Operating systems tested on:

macOS

Linked PRs

Activity

  1. added
    type-featureA feature request or enhancement
    performancePerformance or resource usage
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    and removed
    type-bugAn unexpected behavior, bug, or error
    on Aug 3, 2026
  2. picnixz commented on Aug 3, 2026

    @picnixz
    Member

    I would say: how likely do you have more than 15 keyword arguments? we could consider removing the threshold but we could also increase it to support more common cases.

    cc @serhiy-storchaka @markshannon @Fidget-Spinner

  3. G000D1ESS commented on Aug 3, 2026

    @G000D1ESS
    Author

    I guess in the world of dataclass/attrs/pydantic/ORM, 15 keywords might not be enough - models mapping to API payload or db row commonly have that many fields

    That's actually how we ran into this in the first place - the workaround ended up being dropping keyword arguments in favor of positional ones, but once you're past 15 keys that doesn't really feel like a good solution either 😅

  4. picnixz commented on Aug 4, 2026

    @picnixz
    Member

    The dataclass argument is reasonable. Let's wait for more core devs opinion here.

  5. ericvsmith commented on Aug 5, 2026

    @ericvsmith
    Member

    I agree a higher limit is warranted. I routinely have 20 or so keyword args for dataclasses. I’m not sure what a good limit would be, but maybe 15 => 31, for starters?

  6. hugovk commented on Aug 5, 2026

    @hugovk
    Member

    cc @markshannon

    Are two slots no longer needed for kwargs? Perhaps switch to a calculation like in here?

    if (argsl + kwdsl + (kwdsl != 0) >= _PY_STACK_USE_GUIDELINE) {

  7. cuishuang commented on Aug 14, 2026

    @cuishuang
    Contributor

    I tested the calculation @hugovk suggested:

    nelts + nkwelts + (nkwelts != 0)

    It makes keyword-only calls with 16 through 29 arguments use CALL_KW, while larger calls still fall back to CALL_FUNCTION_EX.

    I have a small patch ready with regression tests and a NEWS entry. I don't see an assignee or linked PR, so I plan to open it as a draft
    to make this option concrete. If someone is already working on this, please let me know and I'm happy to coordinate or close.

  8. added a commit that references this issue on Aug 22, 2026
  9. added a commit that references this issue on Aug 24, 2026
  10. added 2 commits that reference this issue on Sep 3, 2026
  11. added a commit that references this issue on Sep 12, 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

    interpreter-core(Objects, Python, Grammar, and Parser dirs)performancePerformance or resource usagetype-featureA feature request or enhancement

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions