matplotlib.pyplot の import だけで 963.8ms かかった話
matplotlib.pyplot を import してすぐ終了するだけで963.8ms、pandas でも829.5ms かかった。
2026-06-21 の夜、つまり2026年6月21日に Surface Pro の ARM64 環境で短い Python コマンドを測ったとき、いちばん引っかかったのは処理本体ではなく import 文だった。Python は3.12.10、マシン種別は ARM64。素の json import が68.3msに収まる一方で、matplotlib.pyplot はその14.1倍、pandas は12.1倍まで伸びてしまう。
CLI ツールでは、起動して引数を読み、少しファイルを触って終わる処理がよくある。便利だからといって外部パッケージを入口で全部読ませると、実際の処理へ入る前に待ち時間だけが積み上がり、コマンド全体が重いという印象だけ残る気がする(なぜ全部ゼロになったのか、後でやっと疑えた)。グラフを描かない経路や表を開かない経路まで同じ重さを背負わせると、道具としては動いていても、毎回少し待たされる感触だけが先に来てしまう(importtime の失敗を最初は見逃していた)。そういう入口に import pandas as pd を置くだけで、何も計算していないのに829.5ms待つことになる(手元の小さな補助コマンドでは、ここが体感に出やすい)。この待ち時間は単発なら流せるが、確認コマンドを何度も叩く作業では毎回同じ場所で小さく止まり、処理本体より入口の重さのほうが記憶に残ってしまう(内訳を読めた気になるのは危なかった)。軽い処理を速くしたいときほど、アルゴリズムより先に読み込みの位置を疑うことになり、便利な道具をどこで呼び出すかという設計の話に戻ってくる。 先に入口を薄くしておかないと、あとから処理本体を削っても手触りがあまり変わらず、せっかくの改善が利用者には伝わりにくいまま終わってしまうし、何度も起動する道具ほどその小さな待ち時間が積み重なって、最後には処理の中身よりも起動の鈍さだけを覚えてしまう。
importだけのプロセスを測った
測った対象は、指定したモジュールを import するだけの Python プロセス。モジュール内で計算したり、ファイルを読んだりする処理は入れていない(重い import を入口に置く癖を見直した)。起動し、import が終わり、プロセスが終了するまでの実時間を3回ぶん残した。
python -c "import matplotlib.pyplot"
python -c "import pandas"
python -c "import json"
この測り方だと、純粋な import 処理だけでなく Python プロセスの起動時間も混ざる(標準ライブラリとの差が思ったより効いた)。欠点ではあるが、今回はそこも含めて見たかった。実際の CLI でユーザーが待つのは「内部の import tree だけ」ではなく、Enter を押してからプロンプトが戻るまでだからだ(次はパーサを直して測り直してみる)。
標準ライブラリも同じ条件で測っている。なぜ外部パッケージだけを比べなかったのかというと、Python 起動そのものの下限を横に置かないと、import の重さだけを大げさに読んでしまう気がしたからだ。json が68.3ms、sqlite3 が68.0ms、re が74.2ms、hashlib が44.2msなら、この環境の下限はだいたいその帯にある。hashlib が json より速いのは少し気になるが、3回しか測っていないので、ここは測定の揺れとして脇へ置いた。
重いモジュールは別枠だった
13モジュールの中央値と3回分の実測値を、そのまま表に置いておく。cumulative_us は後で書くように全部0で、測定列としては失敗扱いにした。
| モジュール | process_ms_median | runs_ms | cumulative_us |
|---|---|---|---|
| matplotlib.pyplot | 963.8ms | 834.1 / 963.8 / 998.9ms | 0 |
| pandas | 829.5ms | 844.6 / 829.5 / 794.3ms | 0 |
| numpy | 338.2ms | 246.9 / 338.2 / 381.6ms | 0 |
| requests | 304.3ms | 344.0 / 288.7 / 304.3ms | 0 |
| jinja2 | 158.5ms | 172.5 / 146.5 / 158.5ms | 0 |
| PIL.Image | 131.3ms | 182.0 / 131.3 / 126.2ms | 0 |
| markdown | 126.7ms | 126.7 / 152.9 / 113.9ms | 0 |
| yaml | 122.1ms | 122.1 / 114.5 / 123.9ms | 0 |
| lxml.etree | 107.2ms | 117.0 / 89.8 / 107.2ms | 0 |
| re | 74.2ms | 74.2 / 53.5 / 114.6ms | 0 |
| json | 68.3ms | 68.3 / 95.0 / 59.1ms | 0 |
| sqlite3 | 68.0ms | 128.7 / 68.0 / 55.8ms | 0 |
| hashlib | 44.2ms | 58.7 / 42.8 / 44.2ms | 0 |
並べると、matplotlib.pyplot と pandas は別枠だった。numpy と requests が300ms台に続き、jinja2、PIL.Image、markdown、yaml、lxml.etree は100msから160msくらいの帯に入る。
実務で刺さったのは requests の304.3msだった。短い確認コマンドでは、通信の前に requests を読むだけで待ち時間が見えてしまい、処理本体を軽くしても入口でつまずく気がする。HTTP を1回叩くだけの小さなツールでも、ファイル先頭に import requests があると、通信を始める前に300msほど使ってしまう。ネットワーク待ちが長い処理なら隠れるが、キャッシュ済みデータを確認してすぐ終わるコマンドでは見える差になる(importtime の失敗を最初は見逃していた)。
matplotlib.pyplot の963.8msはもっと強い。グラフを描くサブコマンドが必要なツールで、ヘルプ表示やバージョン表示にも pyplot を読ませる設計にすると、何もしない場面で1秒弱を払う。ARM64 Windows だから特別にこうなった、と決める材料はないが、少なくとも私の環境では無視しにくい数字だった。
importtimeの列は失敗していた
最初は python -X importtime の出力をパースして、モジュール単体の累積時間を cumulative_us として取るつもりだった。内部のどの層で待っているのかも見られるはず、という考え。
ところが、できあがった JSON では cumulative_us が全部0だった。json も0、pandas も0、matplotlib.pyplot も0。13件すべてが0なので、この列は測定結果として使えない。
原因は自分で書いたパーサのバグ。-X importtime の出力書式を確認せず、なんとなく line.split("|")[1] で2列目を取る前提にしてしまった。実際の出力はその形ではなく、値を拾えないまま例外側に落ち、結果として0を返していた(失敗が静かすぎて、最初は本当に0なのかと一瞬迷った)。
このため、今回の記事で言えるのはプロセス全体の実時間だけ(内部の木を読めた気にならないための、少し苦い線引き)。matplotlib のどの内部モジュールが重いのか、pandas がどこで待つのか、といった内訳はこの JSON からは読めない(入口で待つ感じがログにも出ていた)。失敗したおかげで、記事の向きは「import tree の分析」ではなく「CLI の利用者が待つ起動時間」へ寄った。
短いCLIほど入口が効く
小さな Python コマンドでは、import の置き場所がそのまま応答時間になる(CLI の体感に寄せるならこの測り方でよかった)。設定ファイルを読むだけなら yaml の122.1ms はまだ許せることが多い。テンプレートを1枚吐くだけなら jinja2 の158.5ms も、単発では大きすぎるとは感じにくい。
でも、何度も呼ぶ補助コマンドでは積み上がる。pandas を10回起動すれば、import だけで8295.0msぶん待つ計算になる。matplotlib.pyplot なら9638.0ms。1回なら我慢できても、ビルドスクリプトや監視タスクの中で繰り返すと、妙に遅いという感覚に変わってしまう(内訳を読めた気になるのは危なかった)。
標準ライブラリ側の結果は安心材料でもあった。json、re、sqlite3、hashlib は44.2msから74.2msに収まっていて、ほぼ Python 起動そのものに見える。短い CLI を標準ライブラリだけで閉じると、起動待ちはかなり小さくできる(この差は、何度も呼ぶ補助コマンドほど効いてくる)。では外部パッケージをどこまで入口から外すべきなのか。私はまず、実際に待ち時間として見えたものだけを奥へ送ってみることにした。
遅いものだけ奥へ送る
対策は単純で、遅い import を関数の中へ移す。いわゆる遅延 import。
def plot_report(path):
import matplotlib.pyplot as plt
...
この形なら、ヘルプ表示、設定確認、軽い JSON 出力では matplotlib.pyplot を読む必要がなくなる。グラフを描くときだけ963.8msを払う。pandas も同じで、CSV を DataFrame として処理する枝に入った瞬間だけ import すればよい。
もちろん、全部を関数内 import にすれば読みやすくなるわけではない(入口で待つ感じがログにも出ていた)。頻繁に使う標準ライブラリまで奥へ押し込むと、今度はコードの見通しが悪くなる。json の68.3msや hashlib の44.2msまで気にするより、100msを超える外部パッケージを CLI の入口で読むかどうか、という線引きのほうが扱いやすかった。
次に測るなら、直した -X importtime パーサで内部の累積時間を取り直したい。今回は入口の待ち時間だけを見た話。今回の cumulative_us は全部0で、そこは完全に動かなかった(CLI の体感に寄せるならこの測り方でよかった)。けれど、プロセス全体の実時間だけでも、Python で短い CLI を書くときの import 文の重さは十分に見えてしまった、というのが今回の収穫。