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.stdinsplitlines() する感じで良い気がします。 (省略)

この先はservice化

そして最終的に行き着く先はservice化です。例えばなぜLSP (Language Service Protocol)が生まれたかを考えてみましょう。もちろんエディタ/IDE間での再実装を避けるためという意味もあると思いますが、serviceとして立ち上げてRPC(Remote Procedure Call)的に呼び出すことができるという点が重要だと思っています。

例えば、ASTを読み込むための便利モジュールなどのロードに時間がかかっては嫌ですし。補完のためのモジュールを毎回々読み込むのではいかにファイルにキャッシュなどしていようが辛くなっていきます。そんなわけでservice化に向けた機能も本当はほしいところだったりします。

たまに見かける古のエディタの便利機能などでも、必要になるたびにprocessを立ち上げるような物があったりしますが、そのような機能はこれまでの話と同じ轍を踏んでいることになります。重たくて使い物にならなかったりします。

jupyter notebook

ちなみに別の見方をすればjupyter notebookは、このimportを一度きりにするということに成功していたりしていますね。あるいはそもそも数秒では終わらないような長い処理時間のコードに関してはこのような起動のフットプリントは誤差のようなものかもしれません。

gist