Skip to content

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

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

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