PEP 810 – 明示的遅延インポート
(pep-previews--4622.org.readthedocs.build)- Pythonでは、モジュールレベルですべてのインポートを宣言するのが一般的な慣習
- しかしプログラム実行時、不要な依存関係のモジュールまで即座にロードされ、起動速度やメモリ使用量の問題が発生する
- 従来は関数内インポートなどで手動の遅延インポートが多く使われてきたが、保守や依存関係管理が難しいという欠点があった
- 今回の PEP 810 は、local, explicit, controlled, granular な新しい
lazyキーワードにより、明示的遅延インポート構文を導入する - この機能により、実際に必要な時点でのみモジュールをロードし、起動遅延・メモリ浪費の改善とコード構造の透明性を同時に実現する
Python インポートの現状と問題点
- Python では一般に、モジュールの先頭に import 文を書く慣習が広く使われている
- この方法は重複を減らし、インポート依存関係の構造をひと目で把握でき、一度だけインポートしてランタイムオーバーヘッドを最小化できる
- しかし、プログラム実行時に最初のモジュール(main)がロードされると、実際には使わない多数の依存モジュールまで即座に読み込まれる連鎖インポートが起こりやすい
- 特に CLI ツールでは、ヘルプ全体を呼び出すだけでも数十個のモジュールが事前ロードされるなど、すべてのサブコマンドで不要なオーバーヘッドが発生する
既存の代替策と問題点
- インポートを関数内部へ移すなど、手動でインポート時点を遅らせる方法がよく使われる
- しかしこの方法には、一貫性や保守性の低下、依存関係全体の把握が難しくなるなどの欠点が大きい
- 標準ライブラリの分析結果では、性能に敏感なコードで、すでに全インポートの約 17% が関数またはメソッド内部でインポート遅延目的に使われている
- インポート遅延の関連ツールとして
importlib.util.LazyLoader、サードパーティのlazy_loaderパッケージなどがあるが、すべてのケースを満たせなかったり、単一の標準が存在しなかったりする
PEP 810: 明示的遅延インポートの導入
-
新しい
lazyソフトキーワードを導入(特定の文脈でのみ意味を持ち、変数名などとしても使える) -
lazyは import 文の前でのみ使え、関数/クラス/with/try などのスコープや star import では使えない -
各インポート文単位で明確に区別し、使用時点までモジュールのロードを遅延させる
lazy import モジュール名 lazy from モジュール名 import 名前
明示的遅延インポートの実装方式と syntactic rule
-
構文エラーになるケース:
- 関数内部、クラス内部、try/with、star import (
*) はすべて不可
- 関数内部、クラス内部、try/with、star import (
-
使用例:
import sys lazy import json print('json' in sys.modules) # False (まだロード前) result = json.dumps({"hello": "world"}) # 初回使用時にロード print('json' in sys.modules) # True (遅延モジュールのロード完了) -
モジュール単位で
__lazy_modules__属性に文字列リストとして lazy 対象を指定可能__lazy_modules__ = ["json"] import json # lazy として処理される
グローバルフラグとフィルタによる動作制御
-
グローバルフラグまたはフィルタ関数を使って、モジュール単位または全体への lazy 適用可否を制御できる
-
フィルタ関数を使えば、特定モジュールだけ eager import の例外を適用できる
def my_filter(importer, name, fromlist): if name in {'problematic_module'}: return False # eager import return True # lazy import sys.set_lazy_imports_filter(my_filter)
ランタイム動作とエラー処理
-
lazy import を使うと、インポート文の時点ではなく名前への最初のアクセス時点で実際のインポートが発生する
-
インポートに失敗した場合、**例外チェーン(traceback chaining)**により定義位置と発生位置の両方が明確に示される
lazy from json import dumsp # タイポ result = dumsp({"key": "value"}) # 実際のアクセス時点で ImportError 発生
メモリと性能上の利点
- 遅延されたモジュールは sys.lazy_modules 集合にのみ表示され、実際に使われる前は sys.modules に登録されない
- 使用後は通常のモジュールオブジェクトに置き換えられ、追加の性能ペナルティなく利用可能
- 実際のワークロード環境では、起動遅延 50〜70% 削減、メモリ 30〜40% 節約の効果が現れる
動作方式の要約
- lazy object への最初のアクセス時に reification(実際のインポートと置き換え)が発生する
- 外部コードからモジュールの
__dict__にアクセスすると、すべての lazy object が強制ロードされる(reification) globals()で辞書を取り出した場合は lazy proxy が維持されるため、直接アクセスが必要
型アノテーションと TYPE_CHECKING 最適化
lazy from モジュール import 名前により、型だけに使うインポートでランタイムコスト ZEROを保証- 既存の
from typing import TYPE_CHECKING条件文を置き換え、コードがより簡潔かつ明確になる
既存の PEP 690 との違いと実装上の特徴
- PEP 810 は明示的・個別インポート単位・シンプルなプロキシオブジェクトベースの opt-in 構造
- 一方で PEP 690 は global で暗黙的な lazy import 構造だった
注意事項とモジュール間相互作用
- star import (
*) は lazy ではサポートされない(常に eager) - カスタム import hook や loader は reification のタイミングでそのまま動作する
- マルチスレッド環境でも thread-safe に一度だけインポートし、安全なバインディングを保証
- 同一モジュールで lazy と eager を同時使用した場合、eager 側が常に優先される
コード適用と移行ガイド
- 既存コードへ適用する際は、プロファイリングで必要なインポートだけを lazy に変換し、段階的な適用を推奨
__lazy_modules__を活用すれば Python 3.15 未満のバージョンでも互換性がある
その他の主要な質問と回答ポイント
- インポート時の副作用(例: 登録パターンなど)は最初のアクセスまで遅延される。side effect が必須なら明示的な初期化関数パターンを推奨
- **circular import(循環インポート)**の問題は lazy import だけでは完全に解決できない(アクセスが遅らされる場合にのみ緩和可能)
- ホットパス性能は first use 後、lazy チェックが完全に消え、自動最適化される(バイトコード adaptive specialization)
sys.modulesには reification(初回使用)後にのみ実際のモジュールが登録されるimportlib.util.LazyLoaderと違い、追加設定が不要で、性能を維持しつつ standard syntax として明確
結論
- PEP 810 は Python の import 文に
lazyキーワードを追加し、サブコマンド CLI、大規模アプリケーション、型アノテーションなどさまざまな領域で、不要なモジュール読み込みによる性能問題を簡潔かつ予測可能に最適化できるようにする - 新しいキーワードは導入時点と対象を細かく指定できるため、実サービスでの段階的導入と性能チューニングに適している
- Python の import 体系の実質的な進化として、可視性・保守性・性能の3つの要求を同時に満たす
1件のコメント
Hacker Newsのコメント
私の llm.datasette.io CLI ツールはプラグインをサポートしていますが、
llm --helpのようなコマンドでさえ起動時間が遅すぎるという不満が多くありました。調べてみると、人気プラグインがデフォルトで pytorch のような重いパッケージを import していて、起動全体を止めてしまっていたのが原因でした。そこで、プラグイン作者向けドキュメントでは、必要な場合にだけ関数内部で依存関係を import するよう案内しています(関連ドキュメントへのリンク)。ただ、こういう問題を Python 言語レベルでサポートしてくれたらずっと良いと思いますこの機能は今日すぐにでもツール側で実装できます(説明リンク)。ただしこの方式はプロセス全体にグローバルに適用されるので、numpy を遅延 import するとそのサブモジュール import もすべて遅延されます。結局、numpy 全体が不要ならまったく import されない可能性はありますが、必要になった時点で部分的にモジュールを import する遅延がランタイム中に予測不能に分散することにもなります。追加の実験では、
import foo.bar.bazのように import した場合、foo と foo.bar は引き続き即時にロードされ、foo.bar.baz だけが遅延されました。PEP が "mostly" と表現している理由の一部はたぶんこれでしょう。私の実装をさらに改善すれば、これも解決できるかもしれませんコマンドラインを先にパースして、
--helpのようなオプションは import なしで処理する方法を勧めます。本当に必要なときだけ import を実行する、あるいは簡単に言えば、簡単なコマンドオプションが処理されて、まだやることが残っているときだけ import するよう設計するのもよいですLazy import の提案は過去にもあり、直近では 2022 年に却下されました(関連議論へのリンク)。私の記憶では、lazy import は Meta の CPython 派生である Cinder にはすでに入っていて、今回の PEP も Cinder に取り組んでいた人たちが主導しています。議論の焦点は「opt-in か opt-out か?」「適用範囲はどこまでか?」「CPython のビルドフラグとして入れるべきか?」といった点でした。最終的には、Steering Council が import の挙動が二分される複雑さを理由に却下したそうです。今回はぜひ通ってほしいです。この機能を本当に使いたいです
特に opt-in 方式で、細かなレベルごとの適用とグローバルな終了スイッチまで含まれている点が気に入っています。多くの制約の中で非常によく構成された仕様です
私もこの提案が通ってほしいですが、楽観はしていません。これは大量のコードを壊し、予想外の問題も山ほど生むはずです。import 文には本質的に副作用があり、その適用タイミングが変われば、原因不明のバグに長いあいだ悩まされることになります。これは不安を煽っているのではなく、実際に理由のある懸念です。lazy import が Meta にしか入っていなかったのにも理由があります。Meta ほどリソースが豊富でないと扱いきれない種類のものだからです。多くの人は「pandas や numpy、あるいは自分のねじれた weird module が遅いから速くなってほしい」しか見ていないようですが、Python の import システムが実際にどう動いているかを理解している人は少ないと思います。lazy import の実装方法すら知らないのに賛成している意見も多いです。PEP 690 を見れば欠点はいくつもあります。たとえば、デコレータを使って関数を中央の registry に追加するコードは壊れます。代表例として Dash ライブラリは、JavaScript ベースのインターフェースと Python のコールバックを import 時点のデコレータで結びつけているので、import が lazy になるとこうしたフロントエンドは完全に死ぬでしょう。大量のユーザーを抱えるサービスですぐ壊れる可能性もあります。「opt-in なのだから合わなければ lazy import を切ればいい」と言いますが、import が推移的だったらどうでしょうか。フロントエンドが完全に初期化されたあとで重要なプロセスを始めなければならない場合はどうでしょうか。複数人のコードやライブラリが絡み合うエコシステムで、どんな影響が出るのか誰に分かるでしょう。型ヒントと違って、これはランタイム挙動に実質的な影響を与える変更です。import 文は実質的な Python コードのほぼすべてに含まれるので、lazy が導入されれば実行方式そのものが根本から変わります。このほかにも PEP が触れている妙なケースがいくつもあります。思っているよりずっと難しい問題です
import torch==2.6.0+cu124やimport numpy>=1.2.6のようなバージョン指定 import と、同じ Python 環境に複数バージョンのパッケージを同時にインストール・import できる仕組みが本当にほしいです。そろそろ conda/virtualenv/docker/bazel 地獄を終わらせてほしいですそこまで嫌いではありませんが、手放しで歓迎するほどでもありません。このままだと、実際に eager に import しなければならない少数のケースを除いて、ほぼすべての import の前に
lazyを付けることになりそうで、コードが散らかります。そしてこれがデフォルト動作に変わる予定もないので、この煩雑さはずっと残るでしょう。私はむしろ、モジュール側が lazy loading を opt-in で宣言できて、import 構文自体には変更がない仕組みのほうがよかったと思います。そうすれば大きなライブラリだけが laziness を気にすれば済みます。もちろん、その場合はインタプリタが import 時にファイルシステムを探りにいく必要があるなど別の欠点もありますがみんなが特に問題なく lazy import を多用するのなら、lazy がデフォルトであるべきで、むしろ <i>eager</i> がオプションのキーワードであるべきだったということです。こういうパラダイム変更は Python で初めてではありません。v2 で eagerly list を作っていたいろいろな構文が v3 では generator に変わりましたが、大きな問題は起きませんでした
コマンドラインフラグで Python 全体のモジュール import を lazy にするオプションがあるなら、間違いなく使うつもりです。実際、スクリプトや本当に単純なコード以外では、module load 時に side effect が起きるのは避けるべきパターンです
モジュール側で lazy loading の可否を決めるのは違うと思います。lazy load が必要かどうかを知っているのは呼び出し側だけなので、import するコード側でオプションを指定するのが妥当です。どんなモジュールでも lazy load は可能ですし、side effect があっても呼び出し側はそれごと遅延させたいかもしれません
pyproject.toml に regex で lazy loading オプションを書けたらいいのにと思います
昔、type hint、walrus、asyncio、dataclasses などの新機能が出るたびに、人々は似たような懸念を口にしていましたが、実際にはそこまで多くの人が一斉に使ったり、既存パターンを全面的に書き換えたりはしませんでした。多くのユーザーはいまだに modernized された Python 2.4 レベルの機能しか使っておらず、それでも十分に生産的です。20 年間うまく回ってきたのだから、大きな問題にはならない気がします
興味があるなら、context manager 形式で非常に便利に lazy import を実装した lazyimp を紹介します。たいていは import 文を with ブロックで囲むだけでよく、既存ツールとも相性がよく、デバッグが必要なら簡単に eager import に切り替えられます。cext で frame の f_builtins を差し替えることで importlib hook より強力にしています。完璧ではありませんが、スレッドセーフ版とグローバルハンドラ版もあります。最初は慎重でしたが、今ではコードベースのほぼ全体をこれに移しました。実際の問題は(モジュールごとの登録処理を忘れたことを除けば)まったくなく、速度の体感差が非常に大きくて満足しています
Python のリンターが import をファイル先頭に置くよう強制するのは本当に不便です。分かりやすい lazy import の実装方法を使うたびに lint エラーになります。この問題は単なる性能の話以上です。たとえばプラットフォーム固有のライブラリが必要なとき、そのプラットフォームでだけ import したくても、先頭 import を強制されると import 自体が失敗することがあります
そういうときは、リンターのほうを直すしかないと思います
ほとんどのリンターは
#noqa E402のようなコメントで無視できますこのようにすると、meta path finder を包むラッパーに置き換えて、loader を LazyLoader に差し替えます。import が実行されると、実際にはモジュール名が
<class 'importlib.util._LazyModule'>にバインドされ、属性にアクセスしたときに本物のモジュールがロードされます。実験コード:ただし、PEP の "mostly" という表現が正確に何を意味しているのかは分かりません
lazy import ではスレッド安全性のリスクが過小評価されている気がします。import がいつ、どのスレッドで、どのロックを握って実行されるのかまったく予測できず、importer ロック以外は何も保証できません。以前は、モジュール import 時に危険なコードが実行されるとしても、その大半は単一スレッドの初期化処理中に限られていたので大きな問題にはなりませんでした。lazy になると、本当に予測不能な形でエラーが飛び出すようになります(Heisenbug)。関数レベル import にも同じ問題の可能性はありますが、少なくとも明示的なコードの先頭で実行されるという予測可能性はあります
良い機能に感じます。説明も分かりやすく、実際のユースケースもあり、スコープも(グローバル用と簡単なキーワード方式で)適切です。気に入りました
最近の PEP の中では、ユーザー視点では最もすっきりしていると感じます。この伝統的な syntax bikeshedding(文法論争)の過程を経て、最終的にどうなるかが楽しみです
実務や edge case の検証、適切な妥協、やりすぎない方式、何度も磨き上げられている点など、よく準備された PEP だと思います。特に、世界中の多様なコミュニティを持つ大きな言語の中核システムに手を入れる話なので非常に危険になり得ますが、その難しさを踏まえると特に印象的です
PEP-690 がなぜ却下されたのかを十分に学んでいてほしいです。私たちのコードベースでもこういう機能を自前で実装しようとしましたが、実用的なレベルでうまく動いたことはありませんでした
lazy import は長時間動くサービスで予期しないランタイムエラーを生みやすい点が危険です。高速な起動という利点に見えますが、コード実行の途中で import 失敗により止まる可能性を抱え込むというトレードオフです。加えて、プログラム開始時点では何が import されるのか保証できなくなる edge case も起き得ます
それでも、これは必ず解決すべき本物の問題です。単に起動速度だけの話ではなく、Python の起動は大きな依存関係が入るとばかげたほど遅くなります。大規模プロジェクトでは、すべてのユーザーが使うわけでもない重いライブラリを全部バンドルすることもできないので、開発者たちはすでにもっと奇妙な回避策を使っており、それもまた別のひどい問題を増やしています。関数レベル import を重複して埋め込んだり隠したりしなければならない不便さが解消されるだけでも大きな前進です。そして、これはあくまで optional な language feature として提案されています
自動化テストで十分にリスクを軽減できるはずで、起動高速化と引き換えにする価値はあります。起動時間は決して「見た目だけ」の問題ではありません。私は Django のモノリスで、ほんの数個の重いライブラリのせいで、すべての management command、test、コンテナの reload ごとに 10〜15 秒待たされる状況を経験しました。lazy import で defer したら、とてつもない違いが出ました
私たちは明示的な先頭 import を好みます。その理由は、プログラム開始時点ですぐ依存関係の問題を表面化させたいからです。lazy import を使うと、特定のコードパスが実行されたときに(数時間後、あるいは数日後かもしれません)問題に気づくという不便さが生まれます
時間の大半は、実際には使われもしない vendor モジュール(たとえば Requests 関連だけでもほぼ 100 個)を import して unload するのに費やされています。調べてみると、合計 500 個以上のモジュールが不要に import されていました
コードジェネレータが、先頭 import ではなく関数内の local import を入れるコードを増やしている理由が分かりません。私はそのパターンを推奨したくありません。モジュールの依存関係が把握しにくくなり、あとで循環依存が生まれる危険が高まるからです
まだ PEP を全部読んでいませんが、コマンドラインフラグや外部ツールで dependency validation ができるとよいのではと思います。型ヒントに対応するツールのようにです
「私たちは」とは、正確には誰を指しているのでしょうか
これはテストでカバーすべき問題ではないでしょうか