Skip to content

gh-161170: Lazily materialize coroutine objects for immediate awaits - #161171

Open
asvetlov wants to merge 1 commit into
python:mainfrom
asvetlov:claude/python-coroutine-lazy-materialization-f37780
Open

asvetlov wants to merge 1 commit into
python:mainfrom
asvetlov:claude/python-coroutine-lazy-materialization-f37780

Conversation

@asvetlov

@asvetlov asvetlov commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What

await f() on an async def no longer allocates a coroutine object unless the body actually suspends.

  • GET_AWAITABLE now also pushes the NULL and None that start the SEND loop, so the compiler emits one instruction between an awaited expression and SEND. This is the static marker RETURN_GENERATOR keys on, in the spirit of the super-instruction idea from Enable informing callee it's awaited via vector call flag #91121.
  • RETURN_GENERATOR (tier 1 only): if the caller's next instruction is GET_AWAITABLE , the body keeps running in the already-pushed thread frame, marked lazy_coro. The caller's frame is set up exactly as SEND_GEN would leave it, so RETURN_VALUE or an exception lands in the right place with no object created.
  • _YIELD_VALUE: a thread-owned frame reaching a yield is a lazy coroutine, the coroutine is materialized. Nested await chains materialize innermost-first, once each.
  • Eager path kept for: callers from C (asyncio task steps, coro.send, functools.partial, ...), an instrumented SEND or END_SEND, coroutine origin tracking, while the JIT is recording a trace, and in tier 2 (_RETURN_GENERATOR stays a frame pop).

Why

A coroutine body that returns before its first await (fast paths, cache hits, bookkeeping-only __aenter__/__aexit__) pays for allocation, _PyFrame_Copy, GC tracking and deallocation for nothing.

Release build (no PGO/LTO), x86-64, per await:

main PR callgrind Ir main → PR
await noop() (raw send) 70 ns 44 ns 1326 → 769
await noop() (asyncio.run) 71 ns 43 ns
async with no-op CM 187 ns 124 ns
await that suspends once (raw send) 228 ns 227 ns 4172 → 4166

Reviewer notes

  • Observable difference: frame.f_generator / PyFrame_GetGenerator() return None/NULL for a lazy coroutine until it first suspends. asyncio.graph now classifies frames by co_flags; documented in datamodel.rst, inspect.rst, frame.rst and the whatsnew porting section.
  • sys.exc_info() across a suspension inside a handler, tracebacks, __import__ locals, sys.settrace and sys.monitoring event sequences (including STOP_ITERATION) are unchanged versus eager; covered by LazyCoroutineTest in test_coroutines.py.
  • Under LINE instrumentation SEND is rewritten (backward-jump target), so the optimization is off everywhere there; events match eager.
  • test_opcache.test_send_with: its __aenter__ now suspends once, since the SEND it checks is otherwise never executed.
  • JIT-compiled loops do not benefit yet (tier 2 stays eager); a follow-up.

🤖 Generated with Claude Code

@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Nov 2, 2026
…waits

`await f()` on an `async def` no longer allocates a coroutine object
unless the body actually suspends.

GET_AWAITABLE now also pushes the NULL and None that start the SEND
loop, so the compiler emits a single instruction between an awaited
expression and SEND. RETURN_GENERATOR uses that opcode in the caller
to recognize an immediately awaited call: the body then keeps running
in the already-pushed thread frame (marked lazy_coro, with its own
exception-state item on the data stack) and the caller's frame is set
up as SEND_GEN would leave it. A body that returns or raises without
suspending never creates a coroutine object; YIELD_VALUE materializes
one when the body first suspends, sized from the frame's code object.

The eager path is kept for callers from C, instrumented SEND/END_SEND,
coroutine origin tracking, JIT trace recording and tier 2.

frame.f_generator and PyFrame_GetGenerator() return None/NULL for such
a coroutine until it first suspends; asyncio.graph classifies frames
by co_flags instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@asvetlov
asvetlov force-pushed the claude/python-coroutine-lazy-materialization-f37780 branch from e1ad90f to e3e43cb Compare November 2, 2026 10:49
@read-the-docs-community

Copy link
Copy Markdown

Documentation build overview

📚 cpython-previews | 🛠️ Build #35578073 | 📁 Comparing e3e43cb against main (86be79a)

  🔍 Preview build  

6 files changed · ± 6 modified

± Modified

@asvetlov

asvetlov commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

I considered converting CALL+GET_AWAITABLE into a single CALL_AWAITABLE superinstruction, but it requires the duplication of all 25 CALL specializations, which could be a huge maintenance burden.
If we decide to go this way, CALL_AWAITABLE could be accompanied by a new C API for efficiently calling/awaiting async functions from C Extensions.
I could see benefits from this approach, but I prefer to make this step in a separate PR(s) if we decide.

Also, the initial source kept the byte code unchanged (CALL; GET_AWAITABLE; PUSH_NULL; LOAD_COMMON_CONSTANT None; SEND), the checker picked ahead 4 opcodes to analyze if the optimization is applicable.

GET_AWAITABLE on the 'main' is always followed by PUSH_NULL; LOAD_COMMON_CONSTANT None; SEND, the code generation change merges these into the single GET_AWAITABLE opcode and simplifies the look ahead. Also, it makes the code more robust; otherwise subtle changes in generated code could silently disable the optimization without notifying a user.

@asvetlov

asvetlov commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

If we support the idea, I could prepare a PR for GET_AWAITABLE change first, and the follow-up PR for lazy coroutine initialization as the second part.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting core review type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant