pythonでimportが長めのコマンドをbulk action的に実行したい
例えば以下の様な感じで雑にシェル上でループしてコマンドを実行することがあったりすると思います。
$ for i in `seq 10`; do python xxx.py `pintf "%02dfile.txt" $i`; done
あるいは
$ ls *.txt | xargs -I{} python xxx.py {}
こういうときに例えばimportに1秒程度掛かるスクリプトなら、渡されたファイルの分秒数が増えていきます。ほとんどがimport timeなのに。。
import pandas...
(補足)ここでimport pandasは。。。
$ time python -c "import pandas" real 0m1.113s user 0m0.706s sys 0m0.269s
jinja2は
$ time python -c "import jinja2" real 0m0.269s user 0m0.158s sys 0m0.066s
scipyは
$ time python -c "import scipy" real 0m0.384s user 0m0.346s sys 0m0.120s
いずれもトップレベルのモジュールだけを読み込んだときで貧弱な環境での例ですが。。
試しにシミュレートした環境を作ってみる
試しにそのような少しimportが重ためのスクリプトを利用する状況をシミュレートした環境を作ってみようと思います。
まずはimportが長めのモジュールです。特に何をするわけではないですが、大きめのモジュールをシミュレートするために0.5秒くらいsleepを入れています。
heavy_module.py
import time print("loading") time.sleep(0.5) print("loaded") def hello(target: str): print(f"hello {target}")
次はこれを利用したスクリプトを実行しまくる対象です。
$ mkdir -p targets $ for i in `seq 10`; do echo $i > targets/`printf "%02dtarget.txt" $i`; done
テキトーにファイルを作っておきます。
ナイーブに実装してみる
ナイーブに実装してみます。サブコマンドを使うのですが手抜きのためにhandofcatsを使おうと思います。
00actions.py
from handofcats import as_subcommand @as_subcommand def foo(target: str): import heavy_module heavy_module.hello(target) @as_subcommand def bar(target: str): import heavy_module heavy_module.hello(target) as_subcommand.run()
実行対象はtargets以下の全部です。
$ tree targets targets ├── 01target.txt ├── 02target.txt ├── 03target.txt ├── 04target.txt ├── 05target.txt ├── 06target.txt ├── 07target.txt ├── 08target.txt ├── 09target.txt └── 10target.txt 0 directories, 10 files
これをすべてのファイルに実行してみます。まぁ当然ですがファイルごとにインタプリタを起動しているわけなので、概ね0.6 * ファイル数ぶん程度時間がかかってますね。辛い。だるい。
ls targets/*.txt | xargs -n 1 python 00actions.py foo loading loaded hello targets/01target.txt loading loaded hello targets/02target.txt loading loaded ... loaded hello targets/09target.txt loading loaded hello targets/10target.txt real 0m6.417s user 0m1.121s sys 0m0.216s
0.6 * 10 = 6.0
複数のファイルが取れるようにする
先程のナイーブな実装のimport timeの時間を一つにまとめたいわけです。一番安直に思いつくのは元のサブコマンドが複数のファイルを対象にするように書き換えることでしょうか。
例えば以下の様な感になると思います。
01actions.py
import typing as t from handofcats import as_subcommand @as_subcommand def foo(targets: t.List[str]): import heavy_module for target in targets: heavy_module.hello(target) @as_subcommand def bar(targets: t.List[str]): import heavy_module for target in targets: heavy_module.hello(target) as_subcommand.run()
今度は一回の読み込みで終わるので早いですね。
$ python 01actions.py foo targets/*.txt loading loaded hello targets/01target.txt hello targets/02target.txt hello targets/03target.txt hello targets/04target.txt hello targets/05target.txt hello targets/06target.txt hello targets/07target.txt hello targets/08target.txt hello targets/09target.txt hello targets/10target.txt real 0m0.655s user 0m0.118s sys 0m0.027s
はやい。
bulk actions?
でもちょっと待ってください。全部同じコマンドなのでしょうか?そして全部のコマンドをこのように書き換える必要があるのでしょうか?そして全部同じ引数で実行できるんでしょうか?
bulk actionsしたいですね。例えばこういうやつです。
var bulk = db.items.initializeUnorderedBulkOp(); bulk.insert( { item: "efg123", status: "A", defaultQty: 100, points: 0 } ); bulk.insert( { item: "xyz123", status: "A", defaultQty: 100, points: 0 } ); bulk.execute( { w: "majority", wtimeout: 5000 } );
とくにこのmongo dbの例を選んだ深い理由はないのですが、複数のコマンドを束ねた一つのコマンドを作ってそれを実行してあげるというやつです。SQLで言えばbulk insert。たまにweb APIでもbatch requestみたいな形で用意されているものがありますね。
bulk actions -- 区切り文字として扱う引数を用意する
というわけでbulk actionsを作ってみましょう。今回は区切り文字を用意して扱うことにしてみましょう。 例えば "-" を使ってみることにします。
以下の様な形で一度に呼び出せるようにします。
$ python 02actions.py foo targets/target00.txt - bar targets/target01.txt ...
02actions.py
from handofcats import as_subcommand @as_subcommand def foo(target: str): import heavy_module print("foo") heavy_module.hello(target) @as_subcommand def bar(target: str): import heavy_module print("bar") heavy_module.hello(target) if __name__ == "__main__": import sys import itertools sep = "-" itr = iter(sys.argv[1:]) while True: argv = list(itertools.takewhile(lambda x: x != sep, itr)) if len(argv) == 0: break as_subcommand.run(argv=argv)
"-"区切りでコマンドを渡してあげます。今回はfooとbarのどちらのサブコマンドを使う形でもimport timeは一回です。
$ python 02actions.py \ foo targets/target01.txt - bar targets/target02.txt - \ foo targets/target03.txt - bar targets/target04.txt - \ foo targets/target05.txt - bar targets/target06.txt - \ foo targets/target07.txt - bar targets/target08.txt - \ foo targets/target09.txt - bar targets/target10.txt loading loaded foo hello targets/target01.txt bar hello targets/target02.txt foo hello targets/target03.txt bar hello targets/target04.txt foo ... hello targets/target09.txt bar hello targets/target10.txt real 0m0.682s user 0m0.146s sys 0m0.028s
今回も読み込みは1回で済むので早いですね。これを良い感じに機能として組み込みたいところです。
bulk actions -- 標準入力としてコマンドを受け取り1行ごとに実行する
あるいは標準入力としてコマンドを受け取り1行ごとに実行するというbulk actionsも考えられます。ただしこちらはパイプとして繋げられるような機能を失うということでもあります。
実装自体は sys.stdin
を splitlines()
する感じで良い気がします。 (省略)
この先はservice化
そして最終的に行き着く先はservice化です。例えばなぜLSP (Language Service Protocol)が生まれたかを考えてみましょう。もちろんエディタ/IDE間での再実装を避けるためという意味もあると思いますが、serviceとして立ち上げてRPC(Remote Procedure Call)的に呼び出すことができるという点が重要だと思っています。
例えば、ASTを読み込むための便利モジュールなどのロードに時間がかかっては嫌ですし。補完のためのモジュールを毎回々読み込むのではいかにファイルにキャッシュなどしていようが辛くなっていきます。そんなわけでservice化に向けた機能も本当はほしいところだったりします。
たまに見かける古のエディタの便利機能などでも、必要になるたびにprocessを立ち上げるような物があったりしますが、そのような機能はこれまでの話と同じ轍を踏んでいることになります。重たくて使い物にならなかったりします。
cliのbulk actionがサービス化に繋がる感じ。
— po (@podhmo) 2020年1月18日
結局import timeがだるいという話。
jupyter notebook
ちなみに別の見方をすればjupyter notebookは、このimportを一度きりにするということに成功していたりしていますね。あるいはそもそも数秒では終わらないような長い処理時間のコードに関してはこのような起動のフットプリントは誤差のようなものかもしれません。