python / cpython

The Python programming language
https://www.python.org
Other
62.33k stars 29.94k forks source link

Reduce asyncio heapq overhead #122881

Open bdraco opened 1 month ago

bdraco commented 1 month ago

Feature or enhancement

Proposal:

The heapq calls in base_events.py represents quite a bit of asyncio scheduling overhead because they have to run __lt__ quite often in TimerHandle https://github.com/python/cpython/blob/0fd97e46c75bb3060485b796ca597b13af7e6bec/Lib/asyncio/events.py#L128

https://github.com/python/cpython/blob/0fd97e46c75bb3060485b796ca597b13af7e6bec/Lib/asyncio/base_events.py#L1968 heapify

https://github.com/python/cpython/blob/0fd97e46c75bb3060485b796ca597b13af7e6bec/Lib/asyncio/base_events.py#L815 heappush

https://github.com/python/cpython/blob/0fd97e46c75bb3060485b796ca597b13af7e6bec/Lib/asyncio/base_events.py#L1975 heappop

Avoiding running __lt__ can speed up processing call_ats by ~10%

Has this already been discussed elsewhere?

This is a minor feature, which does not need previous discussion elsewhere

Links to previous discussion of this feature:

Wrapping TimerHandle in a tuple starting with when avoids the __lt__ call. Thank you to whoever wrote the heapq docs for help getting there https://docs.python.org/3/library/heapq.html#basic-examples 6f80b4c8b7be

Example benchmark

from asyncio import TimerHandle
import heapq
import timeit

def callback():
    """This is the callback function that will be called when the timer expires."""

class MockLoop:
    def get_debug(self):
        return False

loop = MockLoop()

def heap_tuple():
    scheduled = []
    when = 1

    for _ in range(100):
        when += 1
        handle = TimerHandle(when, callback, (), loop)
        heapq.heappush(scheduled, (when, handle))

    while scheduled:
        when, handle = heapq.heappop(scheduled)

def heap_handle():
    scheduled = []
    when = 1

    for _ in range(100):
        when += 1
        handle = TimerHandle(when, callback, (), loop)
        heapq.heappush(scheduled, handle)

    while scheduled:
        handle = heapq.heappop(scheduled)

print("wrap when, TimerHandle in tuple", timeit.timeit(heap_tuple))
print("bare TimerHandle", timeit.timeit(heap_handle))
% python3 bench/timer_handle_heap.py
wrap when, TimerHandle in tuple 34.082984749999014
bare TimerHandle 49.678519583001616

Linked PRs

bdraco commented 1 month ago

call_at benchmark with the change in #122882

import asyncio
import timeit

def run():
    asyncio.run(call_at())

async def call_at():
    loop = asyncio.get_running_loop()
    when = loop.time()
    future = loop.create_future()

    def callback():
        """Callback function."""

    def done():
        """Done function."""
        future.set_result(None)

    for _ in range(100):
        when += 0.00000001
        loop.call_at(when, callback)

    loop.call_at(when, done)
    await future

print("call_at_benchmark", timeit.timeit(run))
before: call_at_benchmark 252.9052056250075
after: call_at_benchmark 228.09943808300886
rhettinger commented 1 month ago

Wow, this was an excellent problem analysis.

Your recommended solution seems reasonable to me.