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 ツールでは、起動して引数を読み、少しファイルを触って終わる処理がよくある。便利だからといって外部パッケージを入口で全部読ませると、実際の処理へ入る前に待ち時間だけが積み上がる。グラフを描かない経路や表を開かない経路まで同じ重さを背負わせると、毎回少し待たされる感触だけが先に来てしまう。そういう入口に 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 を押してからプロンプトが戻るまでだからだ。次はパーサを直して -X importtime の結果を取り直してみるつもり。
標準ライブラリも同じ条件で測っている。なぜ外部パッケージだけを比べなかったのかというと、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ほど使ってしまう。ネットワーク待ちが長い処理なら隠れるが、キャッシュ済みデータを確認してすぐ終わるコマンドでは見える差になる。
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 文の重さは十分に見えてしまった、というのが今回の収穫。