この記事は Calendar for UdonTech Advent Calendar 2021 | Advent Calendar 2021 - Qiita の7日目の記事です。
日付変わってますが、まだ寝てないから7日目ということで :p
この前はベルターンスガチャの話を書きましたが、今日のWAS-INDの試合中にベルターンス選手ポンポントラブルで退場してっちゃったのでしばらくガチャが回せなさそうです。
というわけで仕方がないので、仕事の話でもちょっと書きます。
やりたいこと
さて、数値計算屋だったりHPC屋の仕事では、結構な頻度で数値実験をします。 この時、「同じプログラムを、ちょっとづつパラメータを変えながら何ケースも測定して、結果の出力から一部を抜きだしてきてまとめる」という作業がたいてい発生します。*1
私の場合、測定する時に
という手順でやるので一通りの測定が終わると、「微妙に名前の違う大量のディレクトリに、同じプログラムの出力ファイルがずらーっと入っている」という状態になります。
今日の本題はこの後の処理です。
今までは、こういう実験結果ファイルから grep, sed, tr, awkあたりを駆使したシェルスクリプトでcsvファイルを作成 -> エクセルでグラフ作成 -> パワポに貼り付け という流れでまとめていました。
ここでシェルスクリプトを使っているのは
「 1つづつコマンドを実行して結果を見ながらパイプで継ぎ足していく」
というインクリメンタル泥縄式開発を採用しているという点が大きいです。
時々、「シェルスクリプトを捨てよう○○言語スクリプトで全部やろう」みたいな論をみかけますが、 こういう開発手法だとどうしてもシェルスクリプトに軍配が上がります。
しかし、最近はその後の「エクセルでグラフ作成」の部分が、pandas+matplotlibで表の整理&グラフ作成にとって代わられてるのと、jupyter notebookのおかげで、pythonでも泥縄式開発が容易になってきました。
そうすると、今までシェルスクリプトでやっていた部分も、pythonで書いてjupyter notebookにまとめてしまえば 中間ファイルも作らなくても済む(かもしれん)し、なにより後から流用する時に便利そうです*2
実際のスクリプトを移植する前段階として、とりあえずgrep, sed(tr), awkで普段やっている処理をpure pythonで書くにはどうすれば良いかというのをちょっと見ていこうと思います。
grepの代替
grepといっても、たいていの場合は複数のディレクトリに散らばったファイル相手に実行するので grep elapse **/*.txt
みたいな形で実行しています。
これをpythonで代替しようとすると次のような形になりそうです。
import glob def multigrep(keyword, filePatter): for filename in glob.glob(filePattern): with open(filename) as f: for line in f: if line.find(keyword)>=0: print(filename,":",line)
手元の環境でざっくりと時間をはかってみたところ
となりました。
なお、測定環境は以下のとおりです。
- M1 mac book air (メモリ16GB)
- jupyter lab desktop 3.2.4-3
- python 3.8.12 | packaged by conda-forge | (default, Oct 12 2021, 21:50:38)
実験したディレクトリ下には613のディレクトリ、30400のファイルが存在する状況で測定しています。 *3
grepの方はコマンドラインで実行して/usr/bin/time
で測定した時のreal time、python版の方はjupyterlabから %time
で測定した時のWall timeなので、そのまま比較できるような数字ではないですがこの程度の時間差なら私にとっては十分実用の範囲内といえそうです。*4
sed, tr
続いて、sedとtrです。 最終的にはawkを使って、前のgrepの出力に含まれるディレクトリ名からパラメータや、ファイルに書かれていた値を取り出すんですが、その前に名前を正規化したり、不要な文字列を削ってawkの処理を*5軽くするために使います。
pythonだと単純に文字列メソッドのreplace
を使えば良いのですが、用途によっては{r,l,}strip
なんかも使えそうです。
状況によって変わるので具体的な例で考えてみましょう。
たとえば、intel CPUのマシンでMPI16プロセス、OpenMP4スレッドで測定した時の実行時間が"output.txt"というファイルの"elapsed time"という行に書かれているとすると、前段のgrepを実行した後の出力には次のような行が含まれているはずです。
yyyymmdd_MPI16_OMP4_intel/output.txt: elapsed time = 1.04e-5
ここからMPI、OMPの後の数字とintel, 最後の1.04e-5を取り出すという処理を考えます。
この場合、まずsedかtrで"_"区切りの文字列に変換します。
sedの場合
echo yyyymmdd_MPI16_OMP4_intel/output.txt: elapsed time = 1.04e-5 |sed -e 's!/!_!' -e 's/:/_/' -e 's/=/_/'
trの場合
echo yyyymmdd_MPI16_OMP4_intel/output.txt: elapsed time = 1.04e-5 |tr '/' '_'|tr ':' '_'|tr '=' '_'
可能な場合はtrでやることが多いですが、諸事情により全ての文字を変換しちゃうとマズイとか、区切りに使う文字が本来区切りとして使わない場所にあったりするとsedで別の文字に変換して保護するような使い方が多いです。
pythonの場合は単純に文字列メソッドのreplaceをつなげていけば良いので
"yyyymmdd_MPI16_OMP4_intel/output.txt: elapsed time = 1.04e-5".replace("/","_").replace(":","_").replace("=","_")
でできますね。
awk
こちらも具体例ということで、sed,trで処理した結果の文字列から、MPIプロセス数、OpenMPスレッド数、実行マシン、実行時間 を取り出す処理を考えます。
awkの場合だとこんな感じですね。
echo yyyymmdd_MPI16_OMP4_intel_output.txt_ elapsed time _ 1.04e-5 |awk -F'_' 'BEGIN{OFS=","}{print $2,$3,$4,$7}'
pythonだと、splitして必要な要素だけをjoinすれば良いので
",".join([ v for i,v in enumerate("yyyymmdd_MPI16_OMP4_intel_output.txt_ elapsed time _ 1.04e-5 ".split('_')) if i in [1,2,3,6]])
実際には、ここでカンマ区切りの文字列にする必要は無くて、リストのままpandasのdataFrameに突っ込んでさらに後続の処理へと続くことになると思います。
まとめ
今まで、シェルスクリプトでやってたテキスト処理をpure pythonにする方法についてちょっと検討してみました。
実行時間とかメモリ使用量なんかは、実際に使うデータによって異なるので、pythonで全部処理するのが必ずしも良いとは言いませんが、私の場合は全部pythonでやってもなんとかなりそうな感触です。*6
*1:チューニングやデバッグ中なら同じプログラムというのが、同じコードから生成されたものではなくて、微妙に違うバージョンのプログラムになったりします。あと測定環境を変えて実行して比較するようなケースもあるので、同じプログラムというのは厳密に同じ実行ファイルを指すとは限りません
*2:ちょくちょく、後処理のスクリプトはあるんだけど、csvファイルが残ってなくて、どういうデータに整形すれば使えるのか分からないという悲しい事件が起きるのです・・・orz
*3:なお、この実行時間はマシンの状態やファイルのサイズによって大幅に異なると思われるので必要であればご自身が使う予定の環境で測定してください
*4:そもそも、今回はコンソールに出力してるけど、実際はこの出力内容をパイプで渡すなり、文字列として保持して次の処理へ渡すので、ここで測った時間よりはさらに早くなることが期待できます
*6:ま、だめならsubprocessで・・・