Skip to content

Speeding up the asyncio.Future creation #153813

Description

@deadlovelll

Bug report

Bug description:

future_init() in Modules/_asynciomodule.c reads the loop's debug flag by calling get_debug() on every Future creation:

res = PyObject_CallMethodNoArgs(fut->fut_loop, &_Py_ID(get_debug));

For the standard loop, BaseEventLoop.get_debug() is just return self._debug, but it pays for a full method call.

My proposal is to read _debug directly, with fallback to the method-call implementation (in case custom event loops dont have)

    if (PyObject_GetOptionalAttr(fut->fut_loop, &_Py_ID(_debug), &res) < 0) {
        return -1;
    }
    if (res == NULL) {
        res = PyObject_CallMethodNoArgs(fut->fut_loop, &_Py_ID(get_debug));
        if (res == NULL) {
            return -1;
        }
    }

I already benchmarked it and got a solid speedup on the future creation, but for e2e it's about 1-2%:

+----------------+---------+-----------------------+
| Benchmark      | before  | after                 |
+================+=========+=======================+
| create_future  | 166 ns  | 133 ns: 1.24x faster  |
+----------------+---------+-----------------------+
| create_task    | 26.4 us | 25.9 us: 1.02x faster |
+----------------+---------+-----------------------+
| Geometric mean | (ref)   | 1.08x faster          |
+----------------+---------+-----------------------+

Benchmark:

import asyncio
import pyperf


def bench_create_future(loops):
    loop = asyncio.new_event_loop()
    try:
        cf = loop.create_future
        t0 = pyperf.perf_counter()
        for _ in range(loops):
            fut = cf()
            fut.cancel()
        return pyperf.perf_counter() - t0
    finally:
        loop.close()


def bench_create_task(loops):
    async def noop():
        return None

    async def run():
        t0 = pyperf.perf_counter()
        for _ in range(loops):
            t = asyncio.ensure_future(noop())
            await t
        return pyperf.perf_counter() - t0

    loop = asyncio.new_event_loop()
    try:
        return loop.run_until_complete(run())
    finally:
        loop.close()


def bench_gather(loops):
    N = 100

    async def child():
        return 1

    async def one_round():
        await asyncio.gather(*[child() for _ in range(N)])

    loop = asyncio.new_event_loop()
    try:
        t0 = pyperf.perf_counter()
        for _ in range(loops):
            loop.run_until_complete(one_round())
        return pyperf.perf_counter() - t0
    finally:
        loop.close()


if __name__ == "__main__":
    runner = pyperf.Runner()
    runner.metadata["description"] = "asyncio Future creation hot paths"
    runner.bench_time_func("create_future", bench_create_future)
    runner.bench_time_func("create_task", bench_create_task)
    runner.bench_time_func("gather_100", bench_gather)

About the reading private attributes from the interp side: this pattern is already used across the stdlib including the asyncio itself e.g get_future_loop :

return PyObject_GetAttr(fut, &_Py_ID(_loop));

I already have pr, but want to know: Is this worth it, or is reading the loop's private _debug (bypassing the get_debug() API) undesirable here?

CPython versions tested on:

CPython main branch

Operating systems tested on:

macOS

Metadata

Metadata

Assignees

No one assigned

    Labels

    type-bugAn unexpected behavior, bug, or error

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions