ループ回数減らそう!

ERDOS Balint
Jul 12 · 7 min read

どんなアプリケーションでも、複数の要素を処理する必要が出てくるのは普通だと思う。複数の要素を回って処理するために言語にループの概念が存在する。関数指向だと集合体を扱う mapreduce みたいな関数があったり、命令型だと whilefor などがある。

そう。人によってショックかも知れないが、ルビーで for 使わず map 使ってもループはループ。そしてループはパフォーマンスの天敵。ループで要素毎に何らかの処理を実行するので、要素が多くなるほど時間がかかる。だからループの中で更にループするのは基本禁物だし、そもそもループ自体もできるだけしたくない。

ダミー課題

画像を扱うサービスで、ユーザーがいっきに複数の画像をアップできる機能がある。上がった画像をその後にリサイズなり、EXIFに基づいて回転させたり、美化フィルターかけたりして処理する。

流してみる

画像に指しているオブジェクトが配列で渡ってくると考えると、 map できれいに収まるじゃないかな。例えのため並行処理は考えない。

images.map(&:resize).tap { |i| log "resizing image##{i.id}" }
.map(&:rotate).tap { |i| log "rotating image##{i.id}" }
.map(&:prettify).tap { |i| log "making image##{i.id} pretty" }

ここでは中身の実装はせずただログ出力させるが、何が出てくるだろう。

INFO: resizing image#1
INFO: resizing image#2
INFO: resizing image#3
INFO: rotating image#1
INFO: rotating image#2
INFO: rotating image#3
INFO: making image#1 pretty
INFO: making image#2 pretty
INFO: making image#3 pretty

要するに画像ごと最後まで終わらせて次の画像に移るのではなく、処理ごと全画像を回っている。この構造の弱点は、どこかで途中でもしエラーが発生して処理が失敗したら、全画像が中途半端な状態になる。逆に画像ごとにやるとしたら、1個の画像が終わったらその後のが例えば失敗したとしても、以前のものには影響がない。上記のログで考えると以下のようになって欲しい。

INFO: resizing image#1
INFO: rotating image#1
INFO: making image#1 pretty
INFO: resizing image#2
INFO: rotating image#2
INFO: making image#2 pretty
INFO: resizing image#3
INFO: rotating image#3
INFO: making image#3 pretty

ルビーでできるのだろうか。

怠け者の登場

ネタバレ注意:できる。それも極めて簡単に。

images.lazy
.map(&:resize).tap { |i| log "resizing image##{i.id}" }
.map(&:rotate).tap { |i| log "rotating image##{i.id}" }
.map(&:prettify).tap { |i| log "making image##{i.id} pretty" }
.force

lazy はルビーの標準ライブラリーに入っている Enumerator::Lazy に切り替えるためのもので、 force は配列全体の処理を実行させるために入る。怠け者処理なので、使われない限りは走らない。つまり force じゃなく、例えば first でアクセスすると、1個目の画像の処理しか実行しない。ログは思う通り!

Railsを使う人は、ActiveRecordの振る舞いで似たようなことは経験しているはず。ActiveRecordもクエリを組み立てるときは、実際の中身がアクセスされるまではクエリを実行しない。ActiveRecordの場合は頑丈正じゃなく、再利用性と組み合わせやすさの目的でそういう実装になっている(結果的にクエリ数も減るし)。

処理は減らない

これで「ループする」回数は減っているが、要素に加える処理の数は変わらない。そのため単純に「早くなりました」とは言いかねる。ただ、画像の処理のような場合は実は特することが多い。なぜかというと、画像の処理は、暗黙的に「画像の一時ファイルを開きます」というのが行われる。

処理ごとに進めると、プロセスが持てる開いたファイルに上限があるので、前の画像を閉じる必要がある場合もある。そしてファイルにアクセスするとか、IOが関わる処理が遅いのはどの時代も変わらない。それがリサイズ、回転、美化の段階でも行われると損。一回ファイル開いて最後までやっちゃった方が効率的。

Enumerator::Lazy が一番役に立つのは、ファイルポインタやHTTPの接続みたいに要素ごとに状態(ステート)が発生する処理や、そもそも最後まで必要がないかも知れない処理。

ボーナス: reduce

reducemap と違ってまとめる処理。数値の配列があって、全体の合計と積、そして最小値と最大値も算出したい。今回は入力がないのでランダムな数値で例文を作る。

numbers = 1.upto(20).map { rand(100) }

この数値の配列に対してそれぞれの値を求めるとすると… 例えのため Enumerable#sum とかは使わない(やっていることほぼ同じ)。

sum = numbers.reduce(&:+)
product = numbers.reduce(&:*)
min = numbers.reduce(Float::INFINITY) do |result, number|
number < result ? number : result
end
max = numbers.reduce(-Float::INFINITY) do |result, number|
number > result ? number : result
end

上記を一回しかループしない実装に変えるのは lazy ではできない。 reduce は入力値と同じ配列を返すわけではないので。手動でできはするけど。(数値1個もないとか、複素数が入ってるとかは考慮しない)

numbers.each_with_object(
sum: 0, product: 1, min: Float::INFINITY, max: -Float::INFINITY
) do |number, aggr|
# 和
aggr[:sum] += number
# 積
aggr[:product] *= number
# 最小値
aggr[:min] = number if number < aggr[:min]
# 最大値
aggr[:max] = number if number > aggr[:max]
end

これもまた、アプリ層でやっている処理自体は減らないが、ループは一回しかしないようには抑えた。 map と似た感じで、ループすること、要素を「取りにいくこと」自体に負荷がある場合はこういう手もあると覚えといてもらうだけでいい。

一本締め

パフォーマンス考えながら日々こうしてルビーの新しい書き方とかコードの新しい構造を試してみるのは、よくプロダクトの改善できるいいアイデアがうまれる。いいもの作る楽しさを一緒に追求してくれる仲間絶賛募集中!

スタディスト開発ブログ

スタディストの開発部がお届けする発展と成長の物語

ERDOS Balint

Written by

スタディスト開発ブログ

スタディストの開発部がお届けする発展と成長の物語