Skip to content

perf: defer heavy imports on Python 3.15 with __lazy_modules__ - #931

Merged
Abhijeet Prasad (AbhiPrasad) merged 3 commits into
mainfrom
perf/py315-lazy-modules
Oct 14, 2026
Merged

Abhijeet Prasad (AbhiPrasad) merged 3 commits into
mainfrom
perf/py315-lazy-modules

Conversation

@AbhiPrasad

@AbhiPrasad Abhijeet Prasad (AbhiPrasad) commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

import braintrust now loads about 24% faster on Python 3.15. It uses PEP 810 __lazy_modules__ to defer jsonschema, tqdm, asyncio, slugify, and gzip until first use. On 3.10–3.14 __lazy_modules__ is an unused variable, so behavior there is unchanged apart from the two notes below.

Follow-up to #924.

Changes

  • __lazy_modules__ declarations in parameters, framework, framework2, logger, trace, workflow_eval, sandbox, audit, functions/stream, functions/invoke, and prompt_cache/disk_cache.
  • __init__.py star-imports five of those modules, and they have no __all__: logger, functions/stream, functions/invoke, framework2, and audit. from m import * would reify their lazy names, so they bind the deferred module under a private alias (import asyncio as _asyncio).
  • framework and logger call os.cpu_count() instead of multiprocessing.cpu_count(). Both call it at import time, and the switch drops the multiprocessing import on every version. The only difference is when the CPU count can't be determined: the import used to raise, and now the thread pools fall back to their default size.

Visible on all versions

The accidental stdlib re-exports braintrust.asyncio, braintrust.gzip, braintrust.slugify, and braintrust.cpu_count no longer exist. Nothing in this repo uses them. Keeping them would mean adding __all__ to logger and the other star-exported modules, which changes the public API far more.

Measurements

Median of 60 interleaved runs of python -c "import braintrust" on a Linux devbox:

Python Before After Modules
3.15.0 472 ms 357 ms (−24%) 511 → 403
3.14.6 472 ms 469 ms (noise) 502 → 493

Per step on 3.15:

  • jsonschema + tqdm: −15%
  • adding asyncio: −21%
  • adding slugify + gzip: −22% to −23%
  • adding os.cpu_count(): −24%

The last two steps saved about 5 ms each, close to the noise floor.

This box drifted ±10% between sequential pyperf runs, so the interleaved numbers are the primary measurement. make bench-compare on adjacent runs agrees: 3.15 is 1.23x faster and 3.14 is 1.02x faster. Over the same runs, the unchanged python -c pass control read 1.21x slower, which is pure drift.

Not attempted:

  • requests (~100 ms): api/_transport.py subclasses HTTPAdapter at class-definition time.
  • _generated_types: it is star-exported, so lazy imports can't help it.

Tests and benchmark

  • New pyperf benchmark benchmarks/benches/bench_import_time.py. It measures python -c pass and import braintrust separately, so interpreter startup can be subtracted.
  • Two new 3.15-only tests in test_lazy_integration_imports.py:
    • One collects every loaded braintrust.* module's __lazy_modules__ and asserts none of those modules (nor multiprocessing) is in sys.modules after import. It fails on main and passes on this branch.
    • A smoke test runs a local Eval and checks jsonschema parameter validation through the deferred imports.

OpenRouter test fix

This PR made test_openrouter_integration_setup_is_idempotent fail on Windows with Python 3.10 only. The root cause is old: before 3.12, a nested import on one thread can make importlib raise a spurious KeyError. OpenRouter's SDK handles that by deleting and re-importing the sub-module, which creates a new Embeddings class. setup() then patches the new class while the test still holds the old one. Dropping multiprocessing from import braintrust likely changes what OpenRouter's GC finalizer imports for the first time, which surfaced the problem on Windows. The test now resolves the classes when it runs, the same way setup() does. Forcing that re-import on Linux 3.10 reproduces the exact CI failure, and the fix passes it.

Validation (local, Linux)

  • nox -s test_core: 3.10.22 ✅ (1012 passed), 3.14.6 ✅ (1017 passed), 3.15.0 ✅ (1024 passed).
  • pylint and test_types on 3.15.0 ✅. pre-commit ✅.
  • Windows was not tested locally.

🤖 Generated with Claude Code

Co-authored by StarfolkAI (@starfolkai)[bot]

Use PEP 810 `__lazy_modules__` so `import braintrust` on Python 3.15
no longer eagerly loads jsonschema, tqdm, asyncio, slugify, or gzip.
On 3.10-3.14 `__lazy_modules__` is an unused variable.

Modules star-exported from `braintrust/__init__.py` without an
`__all__` (logger, functions/stream, functions/invoke, framework2,
audit) bind the deferred module under a private alias, since
`from m import *` would otherwise reify it.

framework and logger now call `os.cpu_count()` instead of
`multiprocessing.cpu_count()`, which drops the multiprocessing import
on all versions.

As a side effect, the accidental stdlib re-exports `braintrust.asyncio`,
`braintrust.gzip`, `braintrust.slugify`, and `braintrust.cpu_count` are
gone on every version.

Interleaved medians, 60 rounds, `python -c "import braintrust"`:
- 3.15.0: 472 ms -> 357 ms (-24%), 511 -> 403 modules
- 3.14.6: 472 ms -> 469 ms (noise), 502 -> 493 modules

Adds a pyperf import-time benchmark and a 3.15-only test that checks
sys.modules after import, plus an Eval/parameters smoke test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reuse the existing subprocess helper instead of a copy. Both tests now
skip below 3.15, where they only duplicated existing coverage. The
deferral test derives its module list from the `__lazy_modules__`
declarations, so new entries are covered automatically. The import-time
benchmark now runs a plain `import braintrust` in the child process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T18:35:18.990983Z bb3b63e New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

… test

On CPython < 3.12 a nested import on one thread makes importlib raise a
spurious `KeyError: <thread id>`. openrouter's lazy sub-SDK loader
handles that by popping the module from sys.modules and re-importing it.
That creates a new `Embeddings` class, and setup() patches the new one,
while the test still held the class it imported at module load. The
test then saw an unwrapped `Embeddings.generate`.

Windows/3.10 CI started hitting this once `import braintrust` stopped
pulling in `multiprocessing`, which shifts what openrouter's GC
finalizer (`asyncio.run(aclose())`) imports for the first time.

Look the classes up when the test runs, as setup() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) merged commit 9809e89 into main Oct 14, 2026
98 checks passed
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) deleted the perf/py315-lazy-modules branch October 14, 2026 13:46
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