From a2c8af558e59e748d8ac1e8076eeb692cd812597 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 2 Jul 2026 12:17:17 -0400 Subject: [PATCH 1/9] Use `sys._getframe()` in `get_logger()` mod lookup `get_caller_mod()` (nested in `get_logger()`) walks the WHOLE call-stack via `inspect.stack()`, which also resolves src-file info for every frame and scans all of `sys.modules` per frame via `inspect.getmodule()`. During nested imports (deep importlib stacks) each module-level `get_logger()` call costs ~5-10ms, making the ~39 such calls dominate `import tractor` wall-time: ~244ms of the ~420ms total (see gh #470). Deats, - resolve the caller frame with `sys._getframe(frames_up)` and map its `f_globals['__name__']` through `sys.modules`: O(1) vs. O(stack x sys.modules). - guard `ValueError` (stack too shallow) -> `None`, matching the existing null-caller handling at all use-sites. - drop the now-unused `inspect` imports; pull `FrameType` from `types` instead. Results: `import tractor` drops 0.42s -> ~0.155s; sequential `.start_actor()` spawn latency ~0.42 -> ~0.18s/actor. Prompt-IO: ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/log.py | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/tractor/log.py b/tractor/log.py index a87dd7c1..d337e244 100644 --- a/tractor/log.py +++ b/tractor/log.py @@ -26,11 +26,6 @@ built on `tractor`. ''' from collections.abc import Mapping from functools import partial -from inspect import ( - FrameInfo, - getmodule, - stack, -) import sys import logging from logging import ( @@ -38,7 +33,10 @@ from logging import ( Logger, StreamHandler, ) -from types import ModuleType +from types import ( + FrameType, + ModuleType, +) import warnings import colorlog # type: ignore @@ -436,16 +434,33 @@ def get_logger( pkg_name: str = _root_name def get_caller_mod( - frames_up:int = 2 - ): + frames_up: int = 2, + ) -> ModuleType|None: ''' - Attempt to get the module which called `tractor.get_logger()`. + Attempt to get the module which called + `tractor.get_logger()`. + + Resolve the caller's frame with `sys._getframe()` and + map its `__name__` through `sys.modules`; `inspect.stack()` + (the previous impl) builds src-file info for EVERY frame + on the stack, scanning all of `sys.modules` per frame via + `inspect.getmodule()`, which made module-level + `get_logger()` calls dominate `import tractor` time + (see gh #470). ''' - callstack: list[FrameInfo] = stack() - caller_fi: FrameInfo = callstack[frames_up] - caller_mod: ModuleType = getmodule(caller_fi.frame) - return caller_mod + try: + caller_frame: FrameType = sys._getframe(frames_up) + except ValueError: + return None + + mod_name: str|None = caller_frame.f_globals.get( + '__name__', + ) + if mod_name is None: + return None + + return sys.modules.get(mod_name) # --- Auto--naming-CASE --- # ------------------------- From d1d3fc58bb0f59a5414bb9978b40750f17b50d5b Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 2 Jul 2026 12:19:27 -0400 Subject: [PATCH 2/9] Lazy-import optional 3rd-party deps (gh #470) Move every import-time-only-by-accident dep off the eager `import tractor` path so cold child-actor boots only pay for what they actually use: - `bidict` -> `TYPE_CHECKING` in `discovery._addr` (annotation-only; `_address_types` is a plain `dict` literal). - `multiaddr` -> `TYPE_CHECKING` + fn-local imports in `discovery._multiaddr.mk_maddr()`/`parse_maddr()`; also `TYPE_CHECKING` the `Multiaddr` annots in `ipc._tcp`/`._uds` (adds future-annots to `._multiaddr`). - `colorlog` -> fn-local in `log.get_console_log()`. - `pdbp` + `wrapt` -> fn-local in `devx._frame_stack.hide_runtime_frames()`/`api_frame()`. - `platformdirs` -> fn-local in `runtime._state.get_rt_dir()`. Still eager (documented follow-ups), - `pdbp` via `devx.debug._repl` class-bases (`PdbREPL(pdbp.Pdb)`) + the module-lvl `@pdbp.hideframe` in `._tty_lock`; needs a `._repl` restructure. - `platformdirs` via the `UDSAddress.def_bindspace: ClassVar` class-body eval of `get_rt_dir()`; needs an `Address`-proto rework. - `stackscope` is already fn-local; `setproctitle` is not imported anywhere. Prompt-IO: ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/devx/_frame_stack.py | 8 ++++++-- tractor/discovery/_addr.py | 4 +++- tractor/discovery/_multiaddr.py | 10 ++++++++-- tractor/ipc/_tcp.py | 7 ++++++- tractor/ipc/_uds.py | 4 +++- tractor/log.py | 9 ++++++++- tractor/runtime/_state.py | 5 ++++- 7 files changed, 38 insertions(+), 9 deletions(-) diff --git a/tractor/devx/_frame_stack.py b/tractor/devx/_frame_stack.py index 9c722def..cf067a5a 100644 --- a/tractor/devx/_frame_stack.py +++ b/tractor/devx/_frame_stack.py @@ -39,14 +39,15 @@ from typing import ( Type, ) -import pdbp +# NOTE, `pdbp` + `wrapt` are lazy-imported at their +# single use-sites below to keep them off the eager +# `import tractor` path (gh #470). from tractor.log import get_logger import trio from tractor.msg import ( pretty_struct, NamespacePath, ) -import wrapt log = get_logger() @@ -257,6 +258,7 @@ def api_frame( caller_frames_up: int = 1, ) -> Callable: + import wrapt # handle the decorator called WITHOUT () case, # i.e. just @api_frame, NOT @api_frame(extra=) @@ -320,6 +322,8 @@ def hide_runtime_frames() -> dict[FunctionType, CodeType]: as possible, particularly from inside a `PdbREPL`. ''' + import pdbp + # XXX HACKZONE XXX # hide exit stack frames on nurseries and cancel-scopes! # |_ so avoid seeing it when the `pdbp` REPL is first engaged from diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index 2697c5c9..ba473b69 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -22,7 +22,6 @@ from typing import ( TYPE_CHECKING, ) -from bidict import bidict from trio import ( SocketListener, ) @@ -35,6 +34,9 @@ from ..ipc._tcp import TCPAddress from ..ipc._uds import UDSAddress if TYPE_CHECKING: + # ONLY type-annots, the eager import costs ~4.5ms + # of `import tractor` wall-time (gh #470). + from bidict import bidict from ..runtime._runtime import Actor log = get_logger() diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 74076fd4..3b7b79f6 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -24,13 +24,16 @@ Multiaddress support using the upstream `py-multiaddr` lib - https://github.com/multiformats/multiaddr/blob/master/protocols/unix.md ''' +from __future__ import annotations import ipaddress from pathlib import Path from typing import TYPE_CHECKING -from multiaddr import Multiaddr - if TYPE_CHECKING: + # NOTE, `multiaddr` is lazy-imported at first use + # (in the fns below) to keep it off the eager + # `import tractor` path (gh #470). + from multiaddr import Multiaddr from tractor.discovery._addr import Address # map from tractor-internal `proto_key` identifiers @@ -56,6 +59,8 @@ def mk_maddr( multiaddr-spec-compliant protocol path. ''' + from multiaddr import Multiaddr + proto_key: str = addr.proto_key maddr_proto: str|None = _tpt_proto_to_maddr.get(proto_key) if maddr_proto is None: @@ -98,6 +103,7 @@ def parse_maddr( ''' # lazy imports to avoid circular deps + from multiaddr import Multiaddr from tractor.ipc._tcp import TCPAddress from tractor.ipc._uds import UDSAddress diff --git a/tractor/ipc/_tcp.py b/tractor/ipc/_tcp.py index 293ae4be..75519f3b 100644 --- a/tractor/ipc/_tcp.py +++ b/tractor/ipc/_tcp.py @@ -21,6 +21,7 @@ from __future__ import annotations import ipaddress from typing import ( ClassVar, + TYPE_CHECKING, ) # from contextlib import ( # asynccontextmanager as acm, @@ -33,7 +34,6 @@ from trio import ( open_tcp_listeners, ) -from multiaddr import Multiaddr from tractor.msg import MsgCodec from tractor.log import get_logger from tractor.discovery._multiaddr import mk_maddr @@ -42,6 +42,11 @@ from tractor.ipc._transport import ( MsgpackTransport, ) +if TYPE_CHECKING: + # ONLY type-annots, the eager import costs + # `import tractor` wall-time (gh #470). + from multiaddr import Multiaddr + log = get_logger() diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index d1e9d2f6..ca9eaca7 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -49,7 +49,6 @@ from trio._highlevel_open_unix_stream import ( has_unix, ) -from multiaddr import Multiaddr from tractor.msg import MsgCodec from tractor.log import get_logger from tractor.discovery._multiaddr import mk_maddr @@ -63,6 +62,9 @@ from tractor.runtime._state import ( ) if TYPE_CHECKING: + # ONLY type-annots, the eager import costs + # `import tractor` wall-time (gh #470). + from multiaddr import Multiaddr from tractor.runtime._runtime import Actor diff --git a/tractor/log.py b/tractor/log.py index d337e244..01444b38 100644 --- a/tractor/log.py +++ b/tractor/log.py @@ -39,7 +39,10 @@ from types import ( ) import warnings -import colorlog # type: ignore +# NOTE, `colorlog` is lazy-imported in +# `get_console_log()` to keep it off the eager +# `import tractor` path (gh #470). +# # ?TODO, some other (modern) alt libs? # import coloredlogs # import colored_traceback.auto # ?TODO, need better config? @@ -795,6 +798,10 @@ def get_console_log( None, ) ): + # lazy-imported to keep it off the eager + # `import tractor` path (gh #470). + import colorlog # type: ignore + fmt: str = LOG_FORMAT # always apply our format? handler = StreamHandler() formatter = colorlog.ColoredFormatter( diff --git a/tractor/runtime/_state.py b/tractor/runtime/_state.py index 0d9a4435..0020fd8d 100644 --- a/tractor/runtime/_state.py +++ b/tractor/runtime/_state.py @@ -30,7 +30,6 @@ from typing import ( TYPE_CHECKING, ) -import platformdirs from trio.lowlevel import current_task from msgspec import ( @@ -332,6 +331,10 @@ def get_rt_dir( the lovely `platformdirs` lib. ''' + # lazy-imported to keep it off the eager + # `import tractor` path (gh #470). + import platformdirs + rt_dir: Path = Path( platformdirs.user_runtime_dir( appname=appname, From 213a298aade4c77cd8b655820bc2ee9698210429 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 2 Jul 2026 12:19:43 -0400 Subject: [PATCH 3/9] Defer `asyncio` import via PEP-562 `.to_asyncio` `asyncio` (~5ms) only matters for infected-aio actors yet gets imported by every cold `import tractor` via module-lvl `.to_asyncio` imports in the debug-REPL + spawn-entry mods. Deats, - `devx.debug._trace`/`._tty_lock`: mv `import asyncio` under `TYPE_CHECKING` + fn-local it at the two `asyncio.current_task()` call-sites; fn-local the `run_trio_task_in_future` imports in the infected-aio-only branches. - `spawn._entry`: fn-local `run_as_asyncio_guest` inside the `infect_asyncio=True` branches of `_mp_main()`/ `_trio_main()`. - `tractor/__init__.py`: add a PEP-562 module `__getattr__` lazy-loading `.to_asyncio` on first attr-access so the public `tractor.to_asyncio.` API (e.g. `LinkedTaskChannel` annots in `test_child_manages_service_nursery.py` + downstream users) keeps working unchanged. Prompt-IO: ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tractor/__init__.py | 23 +++++++++++++++++++++++ tractor/devx/debug/_trace.py | 11 +++++++++-- tractor/devx/debug/_tty_lock.py | 9 +++++++-- tractor/spawn/_entry.py | 8 +++++++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/tractor/__init__.py b/tractor/__init__.py index 287ac940..42df61e8 100644 --- a/tractor/__init__.py +++ b/tractor/__init__.py @@ -74,3 +74,26 @@ from .discovery._registry import ( Arbiter as Arbiter, ) # from . import hilevel as hilevel + + +def __getattr__(name: str): + ''' + PEP 562 lazy sub-module loading, presently only for + `.to_asyncio` which (transitively) imports `asyncio` + itself: a non-trivial multi-ms chunk of the eager + `import tractor` cost (gh #470) unneeded by + `trio`-only apps. + + Any `tractor.to_asyncio.` access (or a + `from tractor import to_asyncio`) still works, the + sub-mod is simply imported on first-access instead + of at pkg-import time. + + ''' + if name == 'to_asyncio': + from importlib import import_module + return import_module('.to_asyncio', __name__) + + raise AttributeError( + f'module {__name__!r} has no attribute {name!r}' + ) diff --git a/tractor/devx/debug/_trace.py b/tractor/devx/debug/_trace.py index 30c2b3c1..5bac9d6c 100644 --- a/tractor/devx/debug/_trace.py +++ b/tractor/devx/debug/_trace.py @@ -24,7 +24,6 @@ mult-process support within a single actor tree. ''' from __future__ import annotations -import asyncio import bdb from contextlib import ( AbstractContextManager, @@ -53,7 +52,6 @@ from trio import ( ) import tractor from tractor.log import get_logger -from tractor.to_asyncio import run_trio_task_in_future from tractor._context import Context from tractor.runtime import _state from tractor._exceptions import ( @@ -85,6 +83,10 @@ from ..pformat import ( ) if TYPE_CHECKING: + # NOTE, `asyncio` (and `.to_asyncio`) are + # lazy-imported at their use-sites to keep them off + # the eager `import tractor` path (gh #470). + import asyncio from trio.lowlevel import Task from threading import Thread from tractor.runtime._runtime import ( @@ -164,6 +166,7 @@ async def _pause( 'An `asyncio` task should not be calling this!?' ) from rte else: + import asyncio task = asyncio.current_task() if debug_func is not None: @@ -946,6 +949,7 @@ def pause_from_sync( asyncio_task: asyncio.Task|None = None if is_infected_aio: + import asyncio asyncio_task = asyncio.current_task() # TODO: we could also check for a non-`.to_thread` context @@ -1059,6 +1063,9 @@ def pause_from_sync( greenback: ModuleType = maybe_import_greenback() if greenback.has_portal(): + from tractor.to_asyncio import ( + run_trio_task_in_future, + ) DebugStatus.shield_sigint() fute: asyncio.Future = run_trio_task_in_future( partial( diff --git a/tractor/devx/debug/_tty_lock.py b/tractor/devx/debug/_tty_lock.py index 0016b61e..0b63798d 100644 --- a/tractor/devx/debug/_tty_lock.py +++ b/tractor/devx/debug/_tty_lock.py @@ -20,7 +20,6 @@ Root-actor TTY mutex-locking machinery. ''' from __future__ import annotations -import asyncio from contextlib import ( AbstractContextManager, asynccontextmanager as acm, @@ -52,7 +51,6 @@ from trio import ( TaskStatus, ) import tractor -from tractor.to_asyncio import run_trio_task_in_future from tractor.log import get_logger from tractor._context import Context from tractor.runtime import _state @@ -66,6 +64,10 @@ from tractor.runtime._state import ( ) if TYPE_CHECKING: + # NOTE, `asyncio` (and `.to_asyncio`) are + # lazy-imported at their use-sites to keep them off + # the eager `import tractor` path (gh #470). + import asyncio from trio.lowlevel import Task from threading import Thread from tractor.ipc import ( @@ -910,6 +912,9 @@ class DebugStatus: async def _set_repl_release(): repl_release.set() + from tractor.to_asyncio import ( + run_trio_task_in_future, + ) fute: asyncio.Future = run_trio_task_in_future( _set_repl_release ) diff --git a/tractor/spawn/_entry.py b/tractor/spawn/_entry.py index 3143d9c4..83f1d3ab 100644 --- a/tractor/spawn/_entry.py +++ b/tractor/spawn/_entry.py @@ -38,7 +38,11 @@ from ..devx import ( pformat, ) # from ..msg import pretty_struct -from ..to_asyncio import run_as_asyncio_guest +# +# NOTE, `.to_asyncio` (and thus `asyncio` itself) is +# lazy-imported at the `infect_asyncio=True` use-sites +# below to keep it off the eager `import tractor` path +# (gh #470). from ..discovery._addr import UnwrappedAddress from ..runtime._runtime import ( async_main, @@ -96,6 +100,7 @@ def _mp_main( ) try: if infect_asyncio: + from ..to_asyncio import run_as_asyncio_guest actor._infected_aio = True run_as_asyncio_guest(trio_main) else: @@ -156,6 +161,7 @@ def _trio_main( ) try: if infect_asyncio: + from ..to_asyncio import run_as_asyncio_guest actor._infected_aio = True run_as_asyncio_guest(trio_main) else: From 62b729a1062a301a3ae5cee5919c846ab725c0a7 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 2 Jul 2026 12:19:43 -0400 Subject: [PATCH 4/9] Add prompt-io entries for gh #470 latency work Log the AI-assisted session per the NLNet generative-AI policy: prompt, profiling findings, per-file diff pointers, measured results and the unimplemented `pdbp`/`platformdirs` deferral follow-ups. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- .../20260702T155626Z_65bf9df5_prompt_io.md | 83 ++++++++++++ ...20260702T155626Z_65bf9df5_prompt_io.raw.md | 122 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md create mode 100644 ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.raw.md diff --git a/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md new file mode 100644 index 00000000..5628aa03 --- /dev/null +++ b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.md @@ -0,0 +1,83 @@ +--- +model: claude-fable-5 +service: claude +session: b6b42e23-0454-4d9b-be1c-0a89d65aed1b +timestamp: 2026-07-02T15:56:26Z +git_ref: 65bf9df5 +scope: code +substantive: true +raw_file: 20260702T155626Z_65bf9df5_prompt_io.raw.md +--- + +## Prompt + +From `ai/prompt-io/prompts/issue_470.md`: + +> attempt to resolve +> https://github.com/goodboy/tractor/issues/470 +> do it with /open-wkt, ensure you /run-tests on all +> changes + +(plus a hard pause-for-human-review deadline of +12:50PM EST.) + +Issue #470 asks to trim the ~0.42s `import tractor` +cost — which dominates per-actor spawn latency on the +`trio` backend — by lazy-importing heavy/optional deps +(`pdbp`, `stackscope`, `multiaddr`, + audit of +`colorlog`/`bidict`/`wrapt`/`setproctitle`). + +## Response summary + +Profiling showed the issue's dep-list only accounted +for ~20ms; the dominant cost (~244ms) was +`log.get_logger()`'s `get_caller_mod()` calling +`inspect.stack()` at module level in ~39 modules — +each call walks every stack frame (deep during nested +imports) and scans `sys.modules` per frame via +`inspect.getmodule()`. + +Changes, in impact order: + +1. `get_caller_mod()` -> `sys._getframe()` + + `f_globals['__name__']` `sys.modules` lookup + (~240ms saved). +2. Issue's lazy-import checklist: `bidict`, + `multiaddr`, `colorlog`, `wrapt` moved to + `TYPE_CHECKING`/function-local imports; + `platformdirs` function-local; `asyncio` + + `.to_asyncio` deferred out of the `devx.debug` + + `spawn._entry` eager paths (~15ms saved). +3. PEP 562 `__getattr__` on `tractor/__init__.py` + preserving public `tractor.to_asyncio` attr access. + +Results: `import tractor` 0.42s -> ~0.145s (~65%); +sequential `start_actor` latency 0.40-0.44s -> +~0.179s/actor. `pdbp` (needs `_repl.py` class-base +restructure) + `platformdirs` (needs +`UDSAddress.def_bindspace` protocol rework) documented +as follow-ups. + +## Files changed + +- `tractor/log.py` — `get_caller_mod()` perf fix + + lazy `colorlog` +- `tractor/__init__.py` — PEP 562 lazy `to_asyncio` +- `tractor/discovery/_addr.py` — `bidict` -> + `TYPE_CHECKING` +- `tractor/discovery/_multiaddr.py` — lazy `multiaddr` +- `tractor/ipc/_tcp.py`, `tractor/ipc/_uds.py` — + `Multiaddr` -> `TYPE_CHECKING` +- `tractor/runtime/_state.py` — lazy `platformdirs` +- `tractor/devx/_frame_stack.py` — lazy `pdbp` + + `wrapt` +- `tractor/devx/debug/_trace.py`, + `tractor/devx/debug/_tty_lock.py` — lazy `asyncio` + + `.to_asyncio` +- `tractor/spawn/_entry.py` — lazy + `run_as_asyncio_guest` + +## Human edits + +None yet — pending user review at the 12:50PM EST +pause gate (test-suite results reported in-session). diff --git a/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.raw.md b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.raw.md new file mode 100644 index 00000000..e58ee408 --- /dev/null +++ b/ai/prompt-io/claude/20260702T155626Z_65bf9df5_prompt_io.raw.md @@ -0,0 +1,122 @@ +--- +model: claude-fable-5 +service: claude +timestamp: 2026-07-02T15:56:26Z +git_ref: 65bf9df5 +diff_cmd: git diff main..wkt/boot_latency_470 +--- + +# Raw AI output — gh #470 `import tractor` latency trim + +All generated code is committed on the +`wkt/boot_latency_470` branch; per diff-ref mode each +file's content is referenced via its diff instead of +copied verbatim. + +## Profiling findings (verbatim analysis output) + +Baseline: `import tractor` ~0.39-0.42s wall. + +`python -X importtime` + `cProfile` traced the cost NOT +primarily to third-party deps (the issue's hypothesis) +but to `tractor/log.py:get_logger()` calling +`get_caller_mod()` -> `inspect.stack()` at module level +in ~39 tractor modules: + +- `inspect.stack()` builds `FrameInfo` (incl. src-file + and line-context resolution) for EVERY frame on the + stack; during nested imports the stack is dozens of + importlib frames deep. +- each `FrameInfo` resolution calls + `inspect.getmodule()` which scans all of + `sys.modules` per frame (1.4M `ismodule()` calls in + one profiled import). +- aggregate: ~244ms of tractor-own module "self" time + vs ~20ms for ALL the issue-listed third-party deps + (`pdbp` ~10ms, `bidict` ~4.5ms, `multiaddr` ~3.5ms, + `wrapt`/`colorlog` ~1ms each); `trio` itself is + ~70-100ms and unavoidable. + +## Generated changes + +> `git diff main..wkt/boot_latency_470 -- tractor/log.py` + +`get_caller_mod()` rewritten from `inspect.stack()` + +`inspect.getmodule()` to `sys._getframe(frames_up)` + +`frame.f_globals['__name__']` -> `sys.modules` lookup +(O(1) vs O(stack x sys.modules)). Unused `inspect` +imports dropped; `FrameType` imported from `types`. +Also `colorlog` lazy-imported inside +`get_console_log()`. + +> `git diff main..wkt/boot_latency_470 -- tractor/discovery/_addr.py` + +`bidict` import moved under `TYPE_CHECKING` +(annotation-only use; `_address_types` is a plain dict +literal). + +> `git diff main..wkt/boot_latency_470 -- tractor/discovery/_multiaddr.py` + +`from __future__ import annotations` added; `multiaddr` +import moved under `TYPE_CHECKING` + function-local +imports in `mk_maddr()`/`parse_maddr()`. + +> `git diff main..wkt/boot_latency_470 -- tractor/ipc/_tcp.py tractor/ipc/_uds.py` + +`Multiaddr` imports moved under `TYPE_CHECKING` +(annotation-only in both transports). + +> `git diff main..wkt/boot_latency_470 -- tractor/runtime/_state.py` + +`platformdirs` lazy-imported inside `get_rt_dir()` +(NOTE: still imported eagerly via +`UDSAddress.def_bindspace` class-var eval; see +follow-ups). + +> `git diff main..wkt/boot_latency_470 -- tractor/devx/_frame_stack.py` + +`pdbp` + `wrapt` lazy-imported inside +`hide_runtime_frames()` / `api_frame()` respectively. + +> `git diff main..wkt/boot_latency_470 -- tractor/devx/debug/_trace.py tractor/devx/debug/_tty_lock.py` + +`asyncio` moved to `TYPE_CHECKING` + call-site local +imports (`asyncio.current_task()` sites); +`tractor.to_asyncio.run_trio_task_in_future` imports +moved into the infected-aio runtime branches. + +> `git diff main..wkt/boot_latency_470 -- tractor/spawn/_entry.py` + +`run_as_asyncio_guest` import moved into the +`infect_asyncio=True` branches of `_mp_main()` / +`_trio_main()`. + +> `git diff main..wkt/boot_latency_470 -- tractor/__init__.py` + +PEP 562 module `__getattr__` added so +`tractor.to_asyncio` attr-access still works (required +by `tests/test_child_manages_service_nursery.py` and +any downstream user) while keeping `asyncio` off the +eager import path. + +## Measured results (verbatim) + +- `import tractor`: 0.39-0.42s -> ~0.145s (~65% cut) +- `start_actor` spawn+boot+reg+cancel: ~0.40-0.44s -> + ~0.179s/actor (n=5 sequential, warm parent) +- post-change eager-module check: only `pdbp` + + `platformdirs` of the issue's list remain eager. + +## Known follow-ups (not implemented, deadline-bound) + +- `pdbp` (~10ms): still eager via + `devx/debug/_repl.py` class bases + (`class PdbREPL(pdbp.Pdb)`) + `_tty_lock.py` + module-level `@pdbp.hideframe`; needs `_repl` + restructure + PEP 562 in `devx.debug.__init__`. +- `platformdirs` (~1.5ms): eager via + `UDSAddress.def_bindspace: ClassVar = get_rt_dir()` + class-body call; needs `Address`-protocol rework of + `def_bindspace` to a lazy accessor. +- `stackscope` + `setproctitle`: already lazy/absent — + no change needed. From 70c7e334a773617dbcaa69380252ab4e07412fb7 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 2 Jul 2026 22:16:08 -0400 Subject: [PATCH 5/9] Make sub-spawn strategy toggleable in `we_are_processes` The #470 boot-latency example hard-coded spawning each `worker_` subactor concurrently from a bg `trio.Task` (so each child's cold `import tractor` overlaps). Add a `main()` `spawn_subs_in_bg_tasks` flag so the serial-spawn path can be demo'd/compared too: flip it `False` to `start_actor()` each sub inline in the loop before handing the ready `Portal` to the bg task. Deats, - factor an `open_ep(ptl, i)` helper out of `spawn_and_open_ep()` - just the `Portal.open_context()` + `wait_for_result()` half, now that the spawn step is caller-optional. - `spawn_and_open_ep()` grows a `maybe_ptl: Portal|None = None` param: spawn the subactor itself when unset (bg-task path), OW reuse the pre-spawned one (serial path). - move the "overlap cold imports" rationale comment onto the new `main()` param where the toggle now lives. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- examples/parallelism/we_are_processes.py | 52 +++++++++++++++++------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/examples/parallelism/we_are_processes.py b/examples/parallelism/we_are_processes.py index cce503b6..45485512 100644 --- a/examples/parallelism/we_are_processes.py +++ b/examples/parallelism/we_are_processes.py @@ -23,18 +23,10 @@ async def endpoint( await trio.sleep_forever() -async def spawn_and_open_ep( - an: tractor.ActorNursery, +async def open_ep( + ptl: tractor.Portal, i: int, ) -> None: - ''' - Spawn a subactor, start a remote `endpoint()`-task in it. - - ''' - ptl: tractor.Portal = await an.start_actor( - name=f'worker_{i}', - enable_modules=[__name__], - ) ctx: tractor.Context async with ptl.open_context(endpoint) as ( ctx, @@ -47,7 +39,33 @@ async def spawn_and_open_ep( await ctx.wait_for_result() -async def main(): +async def spawn_and_open_ep( + an: tractor.ActorNursery, + i: int, + maybe_ptl: tractor.Portal|None = None, +) -> None: + ''' + Spawn a subactor, start a remote `endpoint()`-task in it. + + ''' + if maybe_ptl is None: + maybe_ptl: tractor.Portal = await an.start_actor( + name=f'worker_{i}', + enable_modules=[__name__], + ) + await open_ep( + ptl=maybe_ptl, + i=i, + ) + + +async def main( + # spawn subs concurrently (in bg `trio.Task`s) so each + # actor's cold `import tractor` (~0.4s, see #470) overlaps + # instead of stacking; once forkserver (#463) lands, spawn + # is cheap enough to just loop sequentially. + spawn_subs_in_bg_tasks: bool = True, +): ''' Spawn a subactor-per-CPU then self-destruct the cluster. @@ -60,17 +78,21 @@ async def main(): # https://github.com/goodboy/tractor/pull/463 # start_method='main_thread_forkserver', ) as an, - # spawn subs concurrently (in bg `trio.Task`s) so each - # actor's cold `import tractor` (~0.4s, see #470) overlaps - # instead of stacking; once forkserver (#463) lands, spawn - # is cheap enough to just loop sequentially. trio.open_nursery() as tn, ): for i in range(cpu_count()): + + maybe_ptl: tractor.Portal|None = None + if not spawn_subs_in_bg_tasks: + maybe_ptl: tractor.Portal = await an.start_actor( + name=f'worker_{i}', + enable_modules=[__name__], + ) tn.start_soon( spawn_and_open_ep, an, i, + maybe_ptl, ) destruct_in: int = 2 print( From 7987f6b8d788646496a5e9b247aa85496e1c1493 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 12 Aug 2026 20:52:15 -0400 Subject: [PATCH 6/9] Keep lazy annotations runtime-resolvable Provide import-free runtime aliases for annotation-only actor and multiaddr types so `typing.get_type_hints()` remains usable without eagerly loading optional dependencies. Correct `_address_types` to its actual `dict` shape and cover the affected discovery and transport APIs. Caught-during: review remediation Found-via: `/run-tests` test_lazy_annotation_names_resolve Review: PR #478 (goodboy) https://github.com/goodboy/tractor/pull/478#pullrequestreview-4922213201 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- tests/test_lazy_imports.py | 41 +++++++++++++++++++++++++++++++++ tractor/discovery/_addr.py | 6 +++-- tractor/discovery/_multiaddr.py | 8 ++++++- tractor/ipc/_tcp.py | 3 +++ tractor/ipc/_uds.py | 4 ++++ 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 tests/test_lazy_imports.py diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py new file mode 100644 index 00000000..cdafe6a8 --- /dev/null +++ b/tests/test_lazy_imports.py @@ -0,0 +1,41 @@ +''' +Regression tests for the cold package import surface. + +''' +from typing import ( + Any, + get_type_hints, +) + +from tractor.discovery import ( + _addr, + _multiaddr, +) +from tractor.ipc import ( + _tcp, + _uds, +) + + +def test_lazy_annotation_names_resolve(): + ''' + Resolve annotations without importing optional dependencies. + + Moving annotation-only third-party names under `TYPE_CHECKING` + left their runtime globals undefined, causing + `typing.get_type_hints()` to raise `NameError`. Resolve every + affected API and prove the lazy aliases retain import-free runtime + introspection. + + ''' + assert get_type_hints(_multiaddr.mk_maddr)['return'] is Any + assert get_type_hints(_tcp.MsgpackTCPStream.maddr.fget)[ + 'return' + ] is Any + assert get_type_hints(_uds.MsgpackUDSStream.maddr.fget)[ + 'return' + ] == Any|str + assert get_type_hints(_addr.Address.get_random)[ + 'current_actor' + ] is Any + assert _addr.__annotations__['_address_types'].startswith('dict') diff --git a/tractor/discovery/_addr.py b/tractor/discovery/_addr.py index ba473b69..8d440bb3 100644 --- a/tractor/discovery/_addr.py +++ b/tractor/discovery/_addr.py @@ -16,6 +16,7 @@ from __future__ import annotations from uuid import uuid4 from typing import ( + Any, Protocol, ClassVar, Type, @@ -36,8 +37,9 @@ from ..ipc._uds import UDSAddress if TYPE_CHECKING: # ONLY type-annots, the eager import costs ~4.5ms # of `import tractor` wall-time (gh #470). - from bidict import bidict from ..runtime._runtime import Actor +else: + Actor = Any log = get_logger() @@ -172,7 +174,7 @@ class Address(Protocol): ... -_address_types: bidict[str, Type[Address]] = { +_address_types: dict[str, Type[Address]] = { 'tcp': TCPAddress, 'uds': UDSAddress } diff --git a/tractor/discovery/_multiaddr.py b/tractor/discovery/_multiaddr.py index 3b7b79f6..d3afdb28 100644 --- a/tractor/discovery/_multiaddr.py +++ b/tractor/discovery/_multiaddr.py @@ -27,7 +27,10 @@ Multiaddress support using the upstream `py-multiaddr` lib from __future__ import annotations import ipaddress from pathlib import Path -from typing import TYPE_CHECKING +from typing import ( + Any, + TYPE_CHECKING, +) if TYPE_CHECKING: # NOTE, `multiaddr` is lazy-imported at first use @@ -35,6 +38,9 @@ if TYPE_CHECKING: # `import tractor` path (gh #470). from multiaddr import Multiaddr from tractor.discovery._addr import Address +else: + Multiaddr = Any + Address = Any # map from tractor-internal `proto_key` identifiers # to the standard multiaddr protocol name strings. diff --git a/tractor/ipc/_tcp.py b/tractor/ipc/_tcp.py index 75519f3b..421afb08 100644 --- a/tractor/ipc/_tcp.py +++ b/tractor/ipc/_tcp.py @@ -20,6 +20,7 @@ TCP implementation of tractor.ipc._transport.MsgTransport protocol from __future__ import annotations import ipaddress from typing import ( + Any, ClassVar, TYPE_CHECKING, ) @@ -46,6 +47,8 @@ if TYPE_CHECKING: # ONLY type-annots, the eager import costs # `import tractor` wall-time (gh #470). from multiaddr import Multiaddr +else: + Multiaddr = Any log = get_logger() diff --git a/tractor/ipc/_uds.py b/tractor/ipc/_uds.py index ca9eaca7..6dae10ec 100644 --- a/tractor/ipc/_uds.py +++ b/tractor/ipc/_uds.py @@ -32,6 +32,7 @@ from socket import ( ) import struct from typing import ( + Any, Type, TYPE_CHECKING, ClassVar, @@ -66,6 +67,9 @@ if TYPE_CHECKING: # `import tractor` wall-time (gh #470). from multiaddr import Multiaddr from tractor.runtime._runtime import Actor +else: + Multiaddr = Any + Actor = Any # Platform-specific credential passing constants From 089e158da94857e58e6e55c09727b6145db8f8ac Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 12 Aug 2026 20:52:47 -0400 Subject: [PATCH 7/9] Retain dynamic logger caller discovery Keep the `sys.modules` lookup as the normal fast path, then use `inspect.getmodule()` for unregistered `runpy`, plugin, and `exec()` namespaces. Cover an unregistered module name backed by a real package file and verify implicit logger naming still resolves to that package. Review: PR #478 (goodboy) https://github.com/goodboy/tractor/pull/478#pullrequestreview-4922213201 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- tests/test_log_sys.py | 49 +++++++++++++++++++++++++++++++++++++++++++ tractor/log.py | 9 +++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/test_log_sys.py b/tests/test_log_sys.py index 1894bd1f..f4fcee70 100644 --- a/tests/test_log_sys.py +++ b/tests/test_log_sys.py @@ -2,9 +2,11 @@ `tractor.log`-wrapping unit tests. ''' +import importlib import logging from pathlib import Path import shutil +import sys from types import ModuleType import pytest @@ -165,6 +167,53 @@ def test_implicit_mod_name_applied_for_child( assert submod.log.logger in sub_logs +def test_implicit_mod_name_from_unregistered_namespace( + tmp_path: Path, +): + ''' + Preserve implicit logger naming for dynamic module namespaces. + + The fast `sys.modules` caller lookup cannot resolve `runpy`, + plugin-loader, or `exec()` namespaces that are not registered. + Compile a real package file under an unregistered module name so + the rare filename fallback must recover its imported package and + retain the same package-level logger name. + + ''' + pkg_name = 'dynamic_logger_pkg' + pkg_dir = tmp_path / pkg_name + pkg_dir.mkdir() + init_path = pkg_dir / '__init__.py' + init_path.write_text('') + mod_path = pkg_dir / 'plugin.py' + mod_path.write_text('') + + sys.path.insert(0, str(tmp_path)) + try: + importlib.import_module(pkg_name) + namespace = { + '__name__': f'{pkg_name}.unregistered', + '__package__': pkg_name, + 'tractor': tractor, + } + exec( + compile( + 'log = tractor.log.get_logger(' + f'pkg_name={pkg_name!r})', + str(mod_path), + 'exec', + ), + namespace, + ) + dynamic_log = namespace.get('log') + finally: + sys.path.remove(str(tmp_path)) + sys.modules.pop(pkg_name, None) + + assert dynamic_log is not None + assert dynamic_log.name == pkg_name + + def test_io_custom_level_registered(): ''' The `IO`(21) level (registered via `add_log_level()` at diff --git a/tractor/log.py b/tractor/log.py index 01444b38..7cab2b99 100644 --- a/tractor/log.py +++ b/tractor/log.py @@ -463,7 +463,14 @@ def get_logger( if mod_name is None: return None - return sys.modules.get(mod_name) + if caller_mod := sys.modules.get(mod_name): + return caller_mod + + # Preserve caller discovery for `runpy`, plugin loaders, + # and `exec()` namespaces not registered in `sys.modules`. + # Import `inspect` only on this rare fallback path. + from inspect import getmodule + return getmodule(caller_frame) # --- Auto--naming-CASE --- # ------------------------- From 66ac7863b5b61160152b352f1cf28e303012b2de Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 12 Aug 2026 20:55:41 -0400 Subject: [PATCH 8/9] Advertise the lazy `to_asyncio` API Preserve the existing public wildcard surface while adding the lazy `to_asyncio` submodule to `__all__` and `dir(tractor)`. Exercise both APIs in cold interpreters and verify normal package import still leaves `asyncio` unloaded. Review: PR #478 (goodboy) https://github.com/goodboy/tractor/pull/478#pullrequestreview-4922213201 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- tests/test_lazy_imports.py | 51 ++++++++++++++++++++++++++++++++++++++ tractor/__init__.py | 13 ++++++++++ 2 files changed, 64 insertions(+) diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index cdafe6a8..ac4224d1 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -2,6 +2,9 @@ Regression tests for the cold package import surface. ''' +import json +import subprocess +import sys from typing import ( Any, get_type_hints, @@ -17,6 +20,54 @@ from tractor.ipc import ( ) +def run_cold_import(code: str) -> dict[str, object]: + result = subprocess.run( + [ + sys.executable, + '-c', + code, + ], + check=True, + capture_output=True, + text=True, + ) + return json.loads(result.stdout) + + +def test_lazy_to_asyncio_package_api(): + ''' + Keep the public lazy submodule discoverable without eagerly + importing it. + + Before the lazy conversion, package import side effects exposed + `to_asyncio` to `dir()` and wildcard imports. Exercise those APIs + in cold interpreters so this test proves normal `import tractor` + leaves `asyncio` unloaded, while discovery and wildcard access + still advertise and resolve the public submodule. + + ''' + cold = run_cold_import( + 'import json, sys, tractor; ' + 'print(json.dumps({' + '"advertised": "to_asyncio" in dir(tractor), ' + '"asyncio_loaded": "asyncio" in sys.modules}))' + ) + assert cold == { + 'advertised': True, + 'asyncio_loaded': False, + } + + wildcard = run_cold_import( + 'import json; ' + 'from tractor import *; ' + 'print(json.dumps({' + '"module": to_asyncio.__name__}))' + ) + assert wildcard == { + 'module': 'tractor.to_asyncio', + } + + def test_lazy_annotation_names_resolve(): ''' Resolve annotations without importing optional dependencies. diff --git a/tractor/__init__.py b/tractor/__init__.py index 42df61e8..13689c06 100644 --- a/tractor/__init__.py +++ b/tractor/__init__.py @@ -76,6 +76,19 @@ from .discovery._registry import ( # from . import hilevel as hilevel +__all__: tuple[str, ...] = tuple( + name + for name in globals() + if not name.startswith('_') +) + ( + 'to_asyncio', +) + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) + + def __getattr__(name: str): ''' PEP 562 lazy sub-module loading, presently only for From 3705bbe5947dfe8f9e99f8e8f67f4a07bd322020 Mon Sep 17 00:00:00 2001 From: goodboy Date: Thu, 13 Aug 2026 02:35:07 -0400 Subject: [PATCH 9/9] Guard the cold import latency budget Run seven fresh interpreters and gate the median `import tractor` time below a conservative 0.35s ceiling. Provide an explicit environment override for platforms with a different baseline. Also assert the optional modules deferred by this patch remain unloaded so a timing pass cannot hide an eager-import regression. Review: PR #478 (goodboy) https://github.com/goodboy/tractor/pull/478#pullrequestreview-4922213201 (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- tests/test_lazy_imports.py | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index ac4224d1..75c238bf 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -3,6 +3,8 @@ Regression tests for the cold package import surface. ''' import json +import os +from statistics import median import subprocess import sys from typing import ( @@ -68,6 +70,72 @@ def test_lazy_to_asyncio_package_api(): } +def test_cold_import_budget(): + ''' + Keep cold package import below the pre-optimization regression. + + The original `inspect.stack()` caller lookup made a fresh + `import tractor` take about 0.42s and dominate actor startup. + Run seven independent interpreters and gate their median at a + deliberately broad 0.35s: over twice the measured ~0.145s + baseline, but low enough to catch restoration of that hot path. + + Taking the median absorbs process-start and shared-runner noise. + The child measures only its import, rather than parent-side + process creation. `TRACTOR_IMPORT_BUDGET_S` provides an explicit, + reviewable override for platforms that establish a different + baseline instead of silently weakening the project default. + + Each child also reports the modules whose eager loading this PR + intentionally removes, proving a timing pass cannot hide a + dependency-import regression. + + ''' + budget_s = float( + os.environ.get( + 'TRACTOR_IMPORT_BUDGET_S', + '0.35', + ) + ) + optional_mods = ( + 'asyncio', + 'bidict', + 'colorlog', + 'multiaddr', + 'wrapt', + ) + code = ( + 'import json, sys, time; ' + 'started = time.perf_counter(); ' + 'import tractor; ' + 'elapsed = time.perf_counter() - started; ' + f'optional = {optional_mods!r}; ' + 'print(json.dumps({' + '"elapsed": elapsed, ' + '"loaded": [name for name in optional ' + 'if name in sys.modules]}))' + ) + samples = [ + run_cold_import(code) + for _ in range(7) + ] + elapsed = [ + float(sample['elapsed']) + for sample in samples + ] + loaded = { + name + for sample in samples + for name in sample['loaded'] + } + + assert not loaded + assert median(elapsed) < budget_s, ( + f'cold import median exceeded {budget_s:.3f}s budget: ' + f'{elapsed!r}' + ) + + def test_lazy_annotation_names_resolve(): ''' Resolve annotations without importing optional dependencies.