HN 日本語サマリー

← 一覧へ戻る
プログラミング

Pythonを遅延ロード可能にするための取り組み

Working to Make Python Lazy (iscinumpy.dev)

31 pointsby js25 コメント

要約

Python 3.15で導入される「遅延インポート」機能により、CLIアプリケーションや多数のインポートを持つ大規模コードベースのパフォーマンスが向上します。この機能は、モジュールが実際に使用されるまでインポート処理を遅延させることで、不要なインポートによるオーバーヘッドを削減します。記事では、この機能の仕組みと、それを容易にするためのツール「flake8-lazy」の紹介、そして開発におけるAIの活用経験について解説しています。

全文翻訳

Pythonの遅延インポートを可能にするための取り組み

Python 3.15a7(主要プラットフォームでは uv python install 3.15 で入手可能)には、遅延インポートが搭載されました!PEP 810 で提案されたこのエキサイティングな機能は、CLIアプリケーション(特に --help のようなフラグを使用する場合)を高速化し、常に使用されない多くのインポートを持つ大規模なコードを高速化する可能性があります。以前の失敗した試みとは異なり、これにはライブラリ側での対応が必要です。私はそれを容易にするためのヘルパーツールを開発しました。この記事では、遅延インポートとは何か、そして私のツールをどのように使用するかを説明します。これは私がAIを多用して開発した最初のライブラリなので、記事の後半では、このようなタスクでAIを使用した私の経験について説明します。

TL;DR: uvx flake8-lazy --apply=list を実行して、Python 3.15でコードを魔法のように高速化しましょう!

遅延インポートとは?

標準的なPythonのargparse CLIを持つ、以下のようなファイルがあると想像してください。

import argparse
import numpy


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo", action="store_true")
    args = parser.parse_args()
    if args.foo:
        print(numpy.array([1, 2, 3]))

このコードを --help で実行するとどうなるでしょうか? numpy ライブラリは、実際には使用されていないにもかかわらずインポートされます。最新の uv ツールを使用している場合、これはさらに悪化する可能性があります。なぜなら、uv は明示的に要求しない限りバイトコードを事前コンパイルしないため、インストールは速くなりますが、初回インポートは遅くなります。

上記の例はほんの一例です。以下のような一般的なパターンでも発生する可能性があります。

# __init__.py
from . import a
from . import b

__all__ = ["a", "b"]

このアイデアは、ユーザーが import lib.a ではなく import lib だけで lib.a.stuff を使用できるようにすることですが、たとえすべてのインポートが使用されなくても、インポートのコストを支払うことになります。rich のような一部のライブラリはこれを回避することに注意しており、ユーザーに明示的なインポートを求めていますが、多くの古いライブラリはこれを行っていました。

また、複数の機能(サブコマンドを持つCLIライブラリなど)を持つライブラリもありますが、すべてのサブコマンドで依存関係が必要なわけではありません。

Python 3.15の遅延インポートの使用方法

最初の例を取り上げましょう。Python 3.15では、次のように記述できるようになります。

lazy import argparse
lazy import numpy

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--foo", action="store_true")
    args = parser.parse_args()
    if args.foo:
        print(numpy.array([1, 2, 3]))

これで、両方のインポートが「遅延」になります。つまり、インポート時には何も起こりません。それらはインストールされていない可能性すらあります。しかし、オブジェクトを初めて使用しようとすると、実際のインポート済みオブジェクトになります。したがって、--help を実行しても、numpy は決してアクセスされず、インポートもされません。

後方互換性のある構文もあります。

__lazy_modules__ = ["argparse", "numpy"]

import argparse
import numpy

これは古いPythonでも機能します(単に遅延ではないだけです)。また、必要に応じてこのリストを動的に生成または操作することもできます。Ruff のようなリンターは、これをインポートの上に配置してもリンティング違反をトリガーしないように更新されています。

Python がすべてのインポートを遅延として扱うためのフラグと変数があることにも言及しておくべきです。-X lazy_imports=all および PYTHON_LAZY_IMPORTS=all(normal および none もあります)。これは主にテスト用です。

なぜ遅延しないのか?

すべてのものを遅延としてマークすべきではないでしょうか?そうする必要はありません。インポート時に副作用があるモジュールがいくつかあります。それらの副作用がインポート時に発生する必要がある場合、それらは遅延にできません。例えば、このパターンは遅延にできません。

try:
    import numpy
except ModuleNotFoundError:
    ...

ここでのエラーは、numpy から何かを最初に使用したときに移動します。半遅延の代替策があります。

import importlib.util

if importlib.util.find_spec("numpy") is None:
  ... # numpy が欠落している場合にやりたかったこと

lazy import numpy

これは、何もしないよりもわずかにコストがかかります(だからこそ遅延インポートはこれを行いません)。パッケージをインポートしてサブパッケージ(a.b は a をインポートする)に到達させます。また、一部の種類のインポートエラーは、仕様を見つけるだけではトリガーされません(上記の例では、numpy が正しくコンパイルされていない場合、numpy._core が欠落または破損している可能性がありますが、これはまれです)。それでも、パッケージがインストールされているかどうかを確認するのに非常に良い方法です。

遅延を必要としないもう一つのケースは、トップレベルで何かを使用する場合です。例えば:

lazy import re

REGEX = re.compile(...) # ここでは遅延ではない

ここでは、このファイルをインポートせずに処理できないため、遅延インポートは必要ありません。これをキャッシュで回避できます。

import functools
lazy import re

@functools.cache
def regex() -> re.Pattern:
  return re.compile(...)
from __future__ import annotations

__lazy_modules__ = ["re"]

import functools
import re


@functools.cache
def regex() -> re.Pattern:
    return re.compile(...)

このコードは、注釈が re モジュールをロードしないため、これが機能するために from __future__ import annotations を必要としないことに注意してください。Python 3.14では、注釈はこのバージョンでデフォルトで遅延になりました。

これらの種類のインポートを遅延にすることはできますが、理由もなくインポートエラーを移動しているだけなので、そうしない方が少し良いです。

ファイル内のすべてを遅延にしたい場合は、次のように行うことができます。

class AllLazy:
    @staticmethod
    def __contains__(_: str) -> bool:
        return True


__lazy_modules__ = AllLazy()

これは、完全なモジュール名に対する in を使用したテスト専用ですが、独自のオブジェクトをここに入れることができます。(以下の静的ツールはまだこれを検索していません)。

支援ツール

したがって、ライブラリは理想的にはこれらの __lazy_modules__ を追加し始めるべきですが、すべてのモジュールをそれに追加するよりも少し複雑です。そこで、私はツール flake8-lazy を作成しました。これは、正確に何を追加すべきかを把握し、それを整理するのに役立ちます。これは私がAIツールを多用して開発した最初のライブラリです(plumbum の保守に AI を使い始めましたが、ゼロからではありません)。そのため、AI を使用した経験について最後にセクションを設けます(非常にうまくいきました)。私は過去に flake8-errmsg を開発したことがあるので、これは私の最初の flake8 プラグインではありません。そのプロジェクトと同様に、組み込みのスタンドアロンランナーもあります。3.15 のライフサイクルの初期段階では、それが主な使用方法になると予想しています。

使用するには:

# flake8 スタイルのエラーを表示
uvx flake8-lazy <filenames>
# 追加する必要がある行を表示
uvx flake8-lazy --format=lazy-modules
# 追加するだけ!
uvx flake8-lazy --apply=list <filenames>
# flake8 スタイルのエラーを表示
pipx run flake8-lazy <filenames>
# 追加する必要がある行を表示
pipx run flake8-lazy --format=lazy-modules
# 追加するだけ!
pipx run flake8-lazy --apply=list <filenames>

これにより、エラー(noqa はシンプルなランナーでは機能しません)が報告されます。

現在実装されているエラー(0.6.0)は以下のとおりです。

コード1xx: 遅延宣言の欠落
LZY101標準ライブラリのモジュールは __lazy_modules__ にリストされるべき
LZY102サードパーティまたはローカルモジュールは __lazy_modules__ にリストされるべき

これらは、トップレベルで使用されていないものを検索し、__lazy_modules__ に追加することを提案します(lazy 構文も機能します)。現在、注釈がインポートをトリガーしないと仮定しています(flake8 は、Ruff とは異なり、ターゲットとする最小 Python バージョンを知らないため、3.14 以降かそうでないかを判断できません)。

コード2xx: __lazy_modules__ の検証
LZY201__lazy_modules__ がソートされていない
LZY202__lazy_modules__ にリストされたモジュールがインポートされていない
LZY203__lazy_modules__ にリストされたモジュールが重複している
LZY204__lazy_modules__ が、それが名前を付けるモジュールをインポートした後に代入されている
LZY205__lazy_modules__ にリストされたモジュールは絶対名でなければならない

これらは、特に __lazy_modules__ に関する一般的な問題を検索します。

コード3xx: ネイティブ lazy キーワード(Python 3.15+)
LZY301suppress(ImportError) 内の遅延インポートは誤解を招く
LZY302lazy キーワードと __lazy_modules__ の両方で宣言されたモジュール
LZY303モジュールが意図的に(eagerly)かつ遅延的に(lazily)インポートされている

これらは Python 3.15 以降の新しい構文に固有の問題を検索します。これらはホスト Python が 3.15 以降の場合にのみ機能します。uv には --python=3.15 で既にこれを使用するように指示できます。

コード4xx: 遅延インポートの安全性とセマンティクス
LZY401モジュールは遅延として宣言されているが、トップレベルでアクセスされている
LZY402モジュールはファイルのエンクロージングパッケージであり、遅延にすべきではない

LZY401 は、基本的に LZY101/LZY102 チェックの反対です。トップレベルで何かをアクセスする場合、遅延にしない方が良いかもしれません。これは 9xx チェックに移動される可能性があります。なぜなら、それを行うことは問題ではなく、チェックシステムが間違っている可能性があるからです。

ヒント

テストスイートにはこれを適用しないでください。

ここにリストされていないものに遅延を適用する機会を探してください。上記の re の例はそのような例です。しかし、実際にインポートされたライブラリもチェックしてください。あるライブラリが別のライブラリをインポートすることがあります(多くのライブラリが re をインポートしており、typing も含まれています。これは避けるのが非常に難しいです!re は残念ながら非常に遅いです)。これは -X importtime で行うことができます。遅延されていて決してインポートされないものは、ここにはもう表示されません。遅延インポートをオフにして違いを確認できます。遅延インポートをオンにして、開始する前にどれだけの時間を節約できるかを確認することもできます。

型チェッカーは常に TYPE_CHECKING を True として扱います。そのため、このトリックで typing のインポートを回避できます。

TYPE_CHECKING = False
if TYPE_CHECKING:
    ...

Ruff を使用すると、TID251 チェックでこれを強制することさえできます。

[tool.ruff.lint.flake8-tidy-imports.banned-api]
"typing.TYPE_CHECKING".msg = "Use TYPE_CHECKING=False instead"

__lazy_modules__ システムは完全に動的です(絶対モジュール名には __contains__ メソッドが必要です)。チェックはここでは動的なものを処理しません。最も一般的なユースケースである相対インポートは、静的に残すことができます。

__lazy_modules__ = [f"{__spec__.parent}.thing"]
from . import thing

__package__ は __spec__.parent の古い形式であることに注意してください。__main__.py ではこれを使用しないでください。それには絶対インポートを使用してください(mypy はこのファイルでは __spec__ が None になる可能性があることに気づきます)。

結果

このツールを自身のソースコードで実行したところ、Python 3.15 で --help フラグが 2 倍速くなりました。cibuildwheel では、これは --help や --print-build-identifiers のようなものの 3-4 倍の高速化を達成しました。Python 3.14 のリリースマネージャーである Hugo は、私の PR への PR でさらに速度を向上させることができました(https://github.com/henryiii/cibuildwheel/pull/17)。以下は、これまでに得られた結果の一部です。各ケースで --help をテストしていますが、他のものも高速化される可能性があります。3.15a7 で言及されたバグのため、ビフォーのタイミングでは 3.14 を使用することがあります。

パッケージビフォーアフタースピードアップメモ
flake8-lazy100+ ms50 ms2x元の高速化(現在のバージョンは元のバージョンより少し速い)
repo-review113 ms35 ms3xPR
cibuildwheel179 ms61 ms3xPR
build50 ms45 ms10%まだ公開されておらず、あまり速くない(既に高速)
packagingN/AN/AN/APR、テストする --help がない
check-sdist45.3 ms34.3 ms30%PR

100 ms 超は顕著であり、それを下回るとアプリがより機敏に感じられます。Python 自体は約 15 ms(私の M1 で)かかるため、それより速くすることはできません(そして、argparse のようなものが少なくともいくつか必要でしょう)。

-X importtime を使用すると、各ライブラリの影響と、インポートを削減した成功/失敗を確認できます。

自分でタイミングを測定したい場合は、hyperfine を使用します。

hyperfine --warmup 10 \
     -n "main" --prepare "git checkout main"        "python3.15 -m <pkg> --help" \
     -n "PR"   --prepare "git checkout some-branch" "python3.15 -m <pkg> --help"

単一の実行を行い、-X lazy_modules=none または all を渡すこともできます。

uv やその他のツールのいくつかはデフォルトでバイトコードをコンパイルしないため、上記の測定値よりも初回実行コストで多くの時間を節約できる可能性があることに注意してください。サードパーティライブラリや標準ライブラリが遅延インポートを追加した場合、上記のいくつかの結果はさらに改善される可能性があります。

まだ道のりは長いです。何かが解決されているかどうかを検出する際に、多くのエッジケースがあります。例えば、dataclasses は typing.ClassVar が使用されているかどうかを確認するために型ヒントを解決しますが、これは遅延を壊します。遅延に多くのものを入れるよりも、少ない方が良いです。

また、この構文には大きな問題があります。

from a import b

a.b はモジュールでしょうか、それともそうではないでしょうか?型チェッカーだけが知っています(型付けされている場合)。これは再び同じことです。

from . import b

私は右辺がモジュールではないと仮定しなければなりませんでしたが、もしそうであれば、それは見落とされます。この曖昧な構文を避けるために as を使用できます。

また、おそらく明白ですが、numpy のような多くの大きくて遅いインポートは、まだ CPython 3.15 用にビルドされていません(コンパイル済み wheel が公開されるのは最初の RC の頃です)。そのため、時間に関する最もエキサイティングな改善のいくつかはまだテストできません。


AI を用いたツールの開発

これは、AI を試すのに非常に興味深いプロジェクトでした。部分的には、これがこれまで行われたことがなかったためです。遅延インポートはかなり最近追加され、CPython のアルファビルドで初めてリリースされたのはわずか1週間前で、uv で容易に入手可能になったのはわずか3日ほどです。AI は既存のコードを単に取得するだけではできません。それは私の入力を受け取り、テストを実行し、PEP を読み、「推論」する必要があります。そして、それはそれを実行します。私は 40 以上のタスクでそれを使用しましたが、私が依頼したことを理解できなかったことは一度もありませんでした。それは私を「出し抜いて」私がしたよりも賢いことをしませんでしたが、指示には完璧に従いました。私が手書きしたコードは 5% 程度(主に調整と設定)で、実装の詳細をすべてフォローしたわけではありません。最初のバージョンは 1 日もかかりませんでした(AI が作業している間、私は他のこともしていました)。それを(ライブラリで使用することで)使用可能な形式にするのは、次の数日間(これも断続的に)かかりました。手作業でやった場合よりも 5-7 倍速いでしょう。コードは手書きのソリューションよりも少し長いです。主に重複のためです(クリーンで読みやすいように反復処理できましたが、それでも私のコードのように見えます)。私の手で設計されたコードがどのように見えるかを確認するには、repo-review を参照してください。

私は Scientific Python Development Guide のテンプレートから始めました。このテンプレートには、強力なリンティング、フォーマット、テスト設定が既に組み込まれており、AI の使用に最適です。uv_build をバックエンドとして使用したり、新しい Zensical ドキュメントエンジンを使用したりするなど、以前は使用したことのないオプションをいくつか試しました。また、改善できる点がいくつか見つかったので、それらをテンプレートに反映させました。さらに、リンティングチェックを ALL に増やし、uvx --from sp-repo-review[cli] sp-ruff-checks . を使用して、常に無視すべきチェックのリストを取得しました。AI の成功の多くは、このセットアップの良さによるものだと思います。

AI には、GitHub Copilot を使用しています。VSCode で主に(後で GitHub エージェント機能も並行して機能開発に使用しましたが)自動モデル選択を使用しています。モデルは主に GPT-5.3-codex のようでしたが、Claude Sonnet 4.6 が時々自動選択されることもありました。

最初は設定を追加しませんでしたが、最初のコンテキストウィンドウがいっぱいになり、新しいチャットを開始したいと思ったときに、手書きの AGENTS.md と Copilot CI 設定(.github/workflows/copilot-setup-steps.yml)を追加しました。これらは、uv、prek、nox を利用可能にし、ツールに使用するように指示することに重点を置いていました。これにより、これらのツールを手動で実行したり、エージェントにそれらを伝えたりする必要がなくなりました。

手動でコーディングしたのはごくわずかで、ほとんどの手動編集はセットアップや設定でした。モデルの出力が気に入らない場合は、変更を依頼するだけでした。ただし、指示は非常に明確でした。以前に flake8 用のプラグインを手動ランナーで作成したことがあるので、何が欲しいかはわかっていました。そして、何度も反復しました。例えば、壊れたファイルに対してより良いエラーメッセージを追加する際、モデルは Python 3.11 の例外ノートを追加することを考えましたが、Python 3.10 が最小であるため、別の方法で実行しました。代わりにノートを追加するように依頼しましたが、3.11 用にゲートしました。

ドキュメントも当初はモデルによって書かれましたが、そこでもかなりの編集を行いました。ドキュメントと README の間の繰り返しは好きではありませんが、エージェントはそれらをチェックインするのにかなり優れています。たとえ私が一方を編集しても、もう一方を一致するように修正できます。現時点では Zensical で一方を他方に含める方法はありません。

マージコンフリクトを解決してリベースするように依頼することさえしました。実際、何も失敗しませんでした。最悪だったのは、スタイルチェックを常に実行しなかったことで、CI が失敗したチェックを検出した後に、もう一つ作業を行う必要がありました。しかし、それさえもかなりまれでした。最終的に非常に長い __init__.py ファイルをリファクタリングするように依頼したところ、それは完璧に実行されました。唯一しなかったのは、__lazy_modules__ を再適用することでした。

結果については、これ以上ないほど満足しています。エージェントは、プロンプトなしでも、追加したすべてのコードに対してテストを作成するのが得意でした。エラーや警告を処理しました。私はそれを行う必要がなく、それは素晴らしかったです。CPython のソースコードで実行したところ、問題(エンコーディングが処理されていなかった)が見つかりました。エージェントに実行方法を伝えたところ、問題を見つけて正しい修正(ファイルを直接開くのではなく、トークナイザーを使用する。これは見つけるのにずっと時間がかかっただろう)を適用しました。相対インポート(.)を正しく処理するように依頼したところ、.. などにも一般化しました。

PEP 810 を読み、テキストに基づいて可能なチェックを探すように依頼したところ、それは素晴らしい仕事をしました。いくつかのチェックは実際にそれらの提案から来ています。また、より良い番号付けスキームを考案するように依頼したところ、それも実行しました。

リファクタリングは驚異的でした。番号付けスキームの再編成のような大きな変更を行うことができましたが、エージェントはしばらくの間それを調整し、完了しました。フリー・スレッド・Python で(CLI ランナーのことですが)マルチスレッド化しようとしましたが、結果はかなり悪かったです。何かがシングルスレッドのボトルネックを作成しており、それを素早く見つけることができませんでした。しかし、そのような大きなことを非常に簡単に試すことができたのは良かったです。そして、これは依然として「間違い」ではなく、私が望んだことを正確に実行しました。時間の掛かる作業の反復は得意な点でした。ツールが出力するものを修正するのではなく、エージェントは修正(タイピングのような複雑なものを含む)を適用して再実行するのが非常に得意です。これにより、リンティングツールの価値がさらに高まります。

コードの品質はひどいものではありませんが、手書きの方が優れていると思います。一般的に、過去には AI を使用してパフォーマンスなどを確認するために最初のドラフトを素早く作成していましたが、実際の PR には手書きの実装を行うでしょう。AI は、依頼すればクリーンアップもできます。最小 Python バージョンや、モダンで読みやすいコードを重視していることなどを伝えることで、すべてが役立ちます。しかし、それは疑問を投げかけます。テストとリンティングが強力で、将来 AI を使用して編集する場合、人間の可読性はそれほど重要ではなくなったのでしょうか?また、それは AI ツールを使用することに限定されるのでしょうか?(品質がそれほど悪くないようにしましたが、興味深い哲学的問題です)。

作業がどのようなものだったかを見たい場合は、コミット履歴と GitHub Agent の PR を見ることができます。全体として、昨年初めの AI に対する私の試み(単なるゴミ)と比較して、昨年の TypeScript でのコード(言語学習には良い、そして実際リファクタリングやヘルプにはかなり良い)と比較して、そして今からわずか 3 か月後には、それは本当に、本当に良くなっています。以前にパターンマッチングを試みましたが、ひどいものでした。そして今、それは実際にそれを正しく行います(ただし、まだ依頼する必要があります)。常に理想的なパターンを書くわけではありませんが、正しければ簡単にクリーンアップできます。それはまだ指示されたことを行うツールですが、指示されたことを行うのが上手になりました。適切なリンティングとテスト設定(重要!)と組み合わせると、非常に良いヘルパーになります。

それと連携するために必要なスキルセットは同じだと思います。私は依然として、ライブラリを設計する際に高レベルで行うようなことを行っています。boost-histogram にジェネリック型を追加したとき、私は手動で 1 つ作成し、その後 AI に私の例に従って残りを実行するように指示しました。私が意思決定をしており、それらの意思決定の結果を見るのが(つまり、AI が作業している間に他のことをしているので、私の時間が少なくて済む)はるかに速くなりました。

ちなみに、0x トークンモデル(GPT 5 mini を試しました)は、flake8-lazy --format=lazy-modules の出力を受け取り、それを非自明なコードベースに自動的に適用するのに問題なく機能します。少し遅いですが、機能します。最初に cibuildwheel でそれを使用しました。そのため、0.4.0 で CLI に --apply 機能を加えて行を挿入できるようにしました。現在(0.6.0)では、list、set、native、dynamic のいくつかの形式をサポートしています。