Quantcast
Channel: ループタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 92

関数型プログラミング言語で文字列を再帰的に生成するときの高速化

$
0
0

何をしようとしているか

関数型プログラミング言語にて、データ列を持つモジュールを作り、そこにtoString関数を作ろうとしています。
例えば、以下のようなシグネチャーを持つモジュールになります。

自作モジュールの例
signatureMY_MODULE=sigtypetvaltoString:t->stringend

よくやる畳み込みを使った解法とその問題点

このとき、自作モジュールのデータ列は、リストを使って実装するとします。
すると、以下のようにリストの畳み込み関数foldrと、文字列結合関数^を使って、toString書くと見通しが良さそうです。
リストの他にも、arrayvectorなどでも同様です。

toString関数
typeelem=...(*何らかの要素 *)typet=elemlistfuntoStringdatas=foldr(fn(e,acc)=>(elemToStringe)^acc)""datas

例えば、[A, B, C, D]のようなデータがあれば、ざっくり言うと、以下のように展開されながら動きます。
foldrを使って実装したので、右から処理されていきます。

toString関数の展開例
   toString [A, B, C, D]
=> "A" ^ ("B" ^ ("C" ^ ("D" ^ "")))
=> "ABCD"

しかし、このコードはとても遅いです。
具体的には、入力のデータの長さnに対してO(n^2)時間かかります。

なぜ畳み込みと文字列結合を組み合わせると遅いのか

SMLの文字列はイミュータブルであり、文字列の実体はchar vectorです。
そのため、文字列結合関数^は、新たな文字列を生成して返します。

このような前提の上で、"X" ^ "Y"のアルゴリズムは、例えば以下のようになります。1

  1. "X"の長さと"Y"の長さの合計の幅のバッファを確保する
  2. バッファの前半に"X"をコピーする
  3. バッファの後半に"Y"をコピーする
  4. バッファを結合された文字列として返す

よって、toString関数の実行中に、途中生成される文字列は以下になります。

toString[A,B,C,D]が生成する文字列
""
"D"
"D"
"C"
"CD"
"B"
"BCD"
"A"
"ABCD"

はい。
明らかに無駄な文字列が大量に生成されています。
この関数は、入力のデータの長さnに対して、文字のコピー回数がO(n^2)起こるため、遅いです。

解法

stringの実体はchar vectorのため、
徐々にデータが大きくなるようなループに使うと、
一時変数を作るコストがとても高いです。
よって、同じような場合でも、一時変数を作るコストが低いデータ構造を考えます。
これには、例えばリストがあげられます。

ここではリストを一時変数に使うことのコストの低さの説明のために、
1 :: [2, 3]のアルゴリズムを考えてみましょう。
ここで、[2, 3]はSMLでのリストを表します。

  1. 1を値に持つコンスセルを作る
  2. 1のコンスセルのポインタを、リスト[2, 3]の参照にする
  3. 1のコンスセルの参照を、新たなリストとして返す

このように、リストのcons(::)は、
新たなリストの生成時に元のリストを使いまわします。

それでは、一時変数にリストを使って先のtoString関数を書き直してみます。
幸いにも、Standard ML Basis Libraryには、
CharVector.fromList : char list -> stringという関数があります。
これを使い、char list型で構築して、最後に文字列に変換すれば良いです。

新たなtoString関数
funtoStringdatas=letvalcharList=foldr(fn(e,acc)=>(elemToChare)::acc)nildatasinCharVector.fromListcharListend

このtoString関数は、ざっくり言うと、以下のように展開されながら動きます。
なお、#"A"はSMLでのchar型のAを表します。

新たなtoString関数の展開例
   toString [A, B, C, D]
=> CharVector.fromList (#"A" :: #"B" :: #"C" :: "D" :: nil)
=> "ABCD"

このとき、生成されるデータは以下です。

新たなtoString[A,B,C,D]が生成するデータ
#"D"
#"C"
#"B"
#"A"
[#"A", #"B", #"C", #"D"]

今度は無駄なデータの生成がなくなりました。2

実行時間の差の計測

実際のコンパイラで差が出るのかを確認します。
今回は、AtCoderなどでも利用されているMLtonコンパイラと、自分が好きなSML#コンパイラを使って、速度差が出るかを検証しました。
使ったテストコードを以下に示します。
このテストコードでは、長さ10,000のテストデータを作り、文字列へ変換してプリントします。
時間の計測にはtimeコマンドを使います。

問題のあるテストコード

test1.sml
structureS:sigdatatypeelem=A|BtypetvaltoString:t->stringend=structdatatypeelem=A|Btypet=elemlistfunelemToStringA="A"|elemToStringB="B"funtoStringdatas=foldl(fn(e,acc)=>(elemToStringe)^acc)""datasendfunmain()=let(* n個のAを持つデータをプリントする *)valn=10000valinput:S.t=List.tabulate(n,fn_=>S.A)valformat=S.toStringinputinprintformatendval()=main()

改善したテストコード

test2.sml
structureS:sigdatatypeelem=A|BtypetvaltoString:t->stringend=structdatatypeelem=A|Btypet=elemlist(* char listにしてからCharVector.fromListでstringにする *)funelemToCharA=#"A"|elemToCharB=#"B"funtoStringdatas=letvalcharList=foldr(fn(e,acc)=>elemToChare::acc)nildatasinCharVector.fromListcharListendendfunmain()=let(* n個のAを持つデータをプリントする *)valn=10000valinput:S.t=List.tabulate(n,fn_=>S.A)valformat=S.toStringinputinprintformatendval_=main()

計測結果

MLtonコンパイラでもSML#コンパイラでも如実に時間差が見られます。
特にMLtonコンパイラでは時間差が顕著となりました。

MLtonコンパイラによる実行時間の差
$mlton
MLton 20130715 (built Fri Apr 28 06:06:34 UTC 2017 on lcy01-11)
$make &&time ./test1 > /dev/null &&time ./test2 > /dev/null
mlton test1.sml
mlton test2.sml

real    0m0.123s
user    0m0.118s
sys     0m0.000s

real    0m0.002s
user    0m0.000s
sys     0m0.000s
SML#コンパイラによる実行時間の差
$smlsharp
SML#3.5.0 (2019-12-24 17:29:31 JST)for x86_64-pc-linux-gnu with LLVM 7.0.0
$make &&time ./test1 > /dev/null &&time ./test2 > /dev/null
smlsharp -c -O2 test1.sml
smlsharp test1.smi -o test1
smlsharp -c -O2 test2.sml
smlsharp test2.smi -o test2

real    0m0.030s
user    0m0.013s
sys     0m0.026s

real    0m0.011s
user    0m0.014s
sys     0m0.000s

今回しなかった話

今回は例が簡単なデータ構造のため、一方向からの挿入さえ早ければよく、一時変数はリストで十分でした。
より複雑なデータ構造になり、複数の結合や削除を行う場合には、ツリー構造など適したものを選定する必要があります。

使う言語がBufferやSeqやArrayのようなものを持っており、そこから文字列を構築する方法があれば、より簡単に

まとめ

  • 文字列は素朴なデータではないので、再帰などのループで扱うときには注意が必要
    • ^foldなどのループ内で使ってはいけない
    • コストの低い一時変数を選び、最後に結合する方法を使う

追記

CharVector.fromListや同じ動作をするString.implode以外にも便利な関数が。
SML#の実装も眺めて見ましたが、やはりMLtonと同じく先にバッファを確保するため効率がよいです。
String.concat : string list -> stringも活用していきましょう。


  1. SML#コンパイラでの実装方法 

  2. 正確には、リスト[#"A", ... , #"D"]の各要素も共有されており、新たな#"A"生成はされません。新たに生成されるデータは、このリストを表現するためのコンスセルになります。 


Viewing all articles
Browse latest Browse all 92

Trending Articles