Skip to content

Opt-in inline async scheduler (CG_ASYNC_INLINE): skip per-node asyncio.create_task - #81

Open
erez-work wants to merge 3 commits into
masterfrom
async-inline-runner
Open

erez-work wants to merge 3 commits into
masterfrom
async-inline-runner

Conversation

@erez-work

Copy link
Copy Markdown

Summary

The async runner currently wraps every node downstream of any async node in asyncio.create_task, even when that node's inputs resolve synchronously. Measured at ~6.3µs/node of pure task machinery on trivial nodes — which matches the ~7.3µs/node "plumbing floor" we see on large voice agents (the floor is asyncio scheduling, not node bodies).

This adds an opt-in runner behind the env flag CG_ASYNC_INLINE=1 (off by default → zero behavior change for every existing caller) that:

  • runs a node inline (a plain call, no task) when its dependencies are already resolved values;
  • spends a task only on nodes that genuinely depend on a still-pending async result;
  • uses eager tasks so a short-circuiting async node completes immediately and its downstream collapses to inline execution.

Only the async path is affected; the sync path is untouched.

Semantics preserved

  • make_first/make_or priority option-fallback on handled raises (the deferred path mirrors node_to_input_async)
  • side-effect / _profile hooks, and the sync isawaitable guard
  • concurrency of independent in-flight async ops (eager tasks start them when reached, gathered at the end)
  • the unhandled-exception cyclic-ref cleanup dance

Validation

  • Full test suite: 69/69 with the flag on and off (Python 3.14).
  • Real bon_secours_voice agent (63,912 nodes), Python 3.12, baseline vs flag-on:
    • greeting: graph-compute 870ms → 610ms (−30%), byte-identical response
    • schedule (routing turn): byte-identical response + events (ASKED_DOB_QUESTION/FLOW_STARTED/SKILL_STARTED), no crash
    • graph-compute = depth-tracked outermost-reducer wall (isolates scheduler from VIC/LLM network)

Requires Python 3.12+

Eager asyncio tasks are native only on 3.12+. The runner raises a clear error if the flag is set on an older interpreter; the default (flag off) path is unaffected on any version.

🤖 Generated with Claude Code

erez-work and others added 3 commits June 25, 2026 10:45
The async runner currently wraps every node downstream of any async node in
asyncio.create_task, even when its inputs resolve synchronously. Measured at
~6.3us/node of pure task machinery on trivial nodes (matches the ~7.3us/node
voice plumbing floor).

This adds an opt-in runner (env flag CG_ASYNC_INLINE=1, off by default -> zero
behaviour change) that runs a node INLINE when its dependencies are already
concrete values, and only spends a task on nodes that truly depend on a
still-pending async result. Short-circuiting async nodes complete via eager
tasks, so their downstream collapses to inline execution.

Semantics preserved: make_first/make_or priority fallback (deferred path mirrors
node_to_input_async), side-effect/_profile hooks, the sync isawaitable guard,
concurrency of independent async ops, and the unhandled-exception cyclic-ref
cleanup. Full suite passes 69/69 with the flag on and off.

Measured 4.4x per-node on the short-circuit scenario (7724 -> 1741 ns/node),
projecting the ~440ms voice sync floor to ~99ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eager_start (asyncio.Task) is 3.12+, but nlu-runtime runs 3.11. Add a
version-portable _eager_task: native eager-start on 3.12+, and on 3.11 a
faithful copy of asyncio Task.__step (step the coroutine; on a yielded future
register add_done_callback to resume; reschedule bare None yields via
call_soon) so a short-circuiting async node still completes synchronously and
its downstream collapses to inline execution.

Full suite passes under both interpreters: 69/69 on 3.14 (native eager) and
68/68 on 3.11 (manual driver; trace test excluded — graphviz dep missing in the
3.11 env, unrelated). Concurrency (test_async_run_as_soon_as_possible) and
make_first/make_or fallback verified on both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the Python 3.11 eager-task fallback (we only support 3.12+, where eager
asyncio tasks are native): _eager_task is now a one-liner, and the inline runner
raises a clear error if selected on <3.12 instead of failing cryptically.

Un-nest _run_graph_async_inline: the five inner closures are now named
module-level helpers (_inline_present / _inline_resolve_async /
_inline_fast_resolve / _inline_compute_deferred / _inline_run_async) taking
explicit args, and dep-classification is consolidated in _inline_dep_status
(value/pending/pruned/error) instead of being duplicated. The driver loop is now
a short, readable sequence; no behaviour change.

Verified identical: 69/69 on 3.14 and 68/68 on 3.12 (trace excluded — graphviz
not in that venv), both flag on and off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant