web-dev-qa-db-ja.com

関数型プログラミング-不変性は高価ですか?

問題は2つの部分にあります。最初は概念的なものです。次に、同じ質問をScalaでより具体的に見ていきます。

  1. プログラミング言語で不変のデータ構造のみを使用すると、実際には特定のアルゴリズム/ロジックの実装が本質的に計算コストが高くなりますか?これは、不変性が純粋に関数型言語の中心的な信条であることを示しています。これに影響を与える他の要因はありますか?
  2. より具体的な例を見てみましょう。 Quicksort は、通常、メモリ内データ構造に対する可変操作を使用して教え、実装します。可変バージョンに匹敵する計算およびストレージオーバーヘッドを備えたPURE機能的な方法で、そのようなことをどのように実装するのでしょうか。特にScalaで。以下にいくつかの大まかなベンチマークを含めました。

詳細:

私は命令型プログラミングのバックグラウンド(C++、Java)から来ています。私は関数型プログラミング、特にScalaを調査しています。

純粋な関数型プログラミングの主要な原則のいくつか:

  1. 機能は一流の市民です。
  2. 関数には副作用がないため、オブジェクト/データ構造は immutable です。

現代の JVMs はオブジェクトの作成に関して非常に効率的であり、 garbage collection は存続期間の短いオブジェクトに対しては非常に安価ですが、オブジェクトの作成を最小限に抑える方が適切でしょうか?少なくとも、同時実行性とロックが問題にならないシングルスレッドアプリケーションでは。 Scalaはハイブリッドパラダイムであるため、必要に応じて、可変オブジェクトを使用して命令型コードを記述することを選択できます。しかし、オブジェクトを再利用し、割り当てを最小限に抑えるために何年も費やしてきた人として、それさえ許さない思考の学校をよく理解するように。

特定のケースとして、 this tutorial6 のこのコードスニペットに少し驚いた。 Java Quicksortのバージョンに続いて、見栄えのよいScalaの同じ実装です。

ここに、実装のベンチマークを行う私の試みがあります。詳細なプロファイリングは行っていません。しかし、私の推測では、Scalaバージョンは、割り当てられるオブジェクトの数が線形(再帰呼び出しごとに1つ)であるため、遅いです)。末尾呼び出しの最適化が機能する可能性はありますか?私は正しい、Scalaは自己再帰呼び出しの末尾呼び出しの最適化をサポートしています。そのため、それだけで役立つはずです。私はScala 2.8。

Javaバージョン

public class QuickSortJ {

    public static void sort(int[] xs) {
      sort(xs, 0, xs.length -1 );
    }

    static void sort(int[] xs, int l, int r) {
      if (r >= l) return;
      int pivot = xs[l];
      int a = l; int b = r;
      while (a <= b){
        while (xs[a] <= pivot) a++;
        while (xs[b] > pivot) b--;
        if (a < b) swap(xs, a, b);
      }
      sort(xs, l, b);
      sort(xs, a, r);
    }

    static void swap(int[] arr, int i, int j) {
      int t = arr[i]; arr[i] = arr[j]; arr[j] = t;
    }
}

Scalaバージョン

object QuickSortS {

  def sort(xs: Array[Int]): Array[Int] =
    if (xs.length <= 1) xs
    else {
      val pivot = xs(xs.length / 2)
      Array.concat(
        sort(xs filter (pivot >)),
        xs filter (pivot ==),
        sort(xs filter (pivot <)))
    }
}

実装を比較するScalaコード

import Java.util.Date
import scala.testing.Benchmark

class BenchSort(sortfn: (Array[Int]) => Unit, name:String) extends Benchmark {

  val ints = new Array[Int](100000);

  override def prefix = name
  override def setUp = {
    val ran = new Java.util.Random(5);
    for (i <- 0 to ints.length - 1)
      ints(i) = ran.nextInt();
  }
  override def run = sortfn(ints)
}

val benchImmut = new BenchSort( QuickSortS.sort , "Immutable/Functional/Scala" )
val benchMut   = new BenchSort( QuickSortJ.sort , "Mutable/Imperative/Java   " )

benchImmut.main( Array("5"))
benchMut.main( Array("5"))

結果

5回の連続実行の時間(ミリ秒)

Immutable/Functional/Scala    467    178    184    187    183
Mutable/Imperative/Java        51     14     12     12     12
95
smartnut007

このあたりにはいくつかの誤解が飛び交っているので、いくつかの点を明確にしたいと思います。

  • 「インプレース」クイックソートは実際にはインプレースではありません(クイックソートはnot定義によりインプレースです)。 [~~~~ o [〜#〜](log n)の順序で、再帰的なステップのためのスタックスペースの形式で追加のストレージが必要です。最良の場合ですが、[〜#〜] o [〜#〜])最悪の場合。

  • 配列を操作するクイックソートの機能バリアントを実装すると、目的が達成されません。配列は不変ではありません。

  • クイックソートの「適切な」機能実装は、不変のリストを使用します。もちろんインプレースではありませんが、最悪の場合の漸近ランタイムと同じです([〜#〜] o [〜#〜]^ 2))とスペースの複雑さ([〜#〜] o [〜#〜]))手続き型インプレースバージョンとして。

    平均して、その実行時間はstillインプレースバリアントと同等です([〜#〜] o [〜#〜] ログn))。ただし、そのスペースの複雑さは[〜#〜] o [〜#〜])。


関数型クイックソート実装には2つの明らかな欠点があります。以下では、Haskellでのこのリファレンス実装について考えてみましょう( Haskellの紹介 からScala…)がわかりません)。

qsort []     = []
qsort (x:xs) = qsort lesser ++ [x] ++ qsort greater
    where lesser  = (filter (< x) xs)
          greater = (filter (>= x) xs)
  1. 最初の欠点はピボット要素の選択であり、非常に柔軟性がありません。最新のクイックソート実装の長所は、ピボットの賢い選択に大きく依存しています(比較 「エンジニアリングのソート関数」by Bentley et al。 と比較してください)。上記のアルゴリズムはその点で貧弱であり、平均パフォーマンスを大幅に低下させます。

  2. 第二に、このアルゴリズムは[〜#〜] o [〜#〜]((リスト構築の代わりに)リスト連結を使用します)操作。これは漸近的な複雑さには影響しませんが、測定可能な要因です。

3番目の欠点はやや隠されています。「インプレース」バリアントとは異なり、この実装ヒープからメモリを継続的に要求するリストのコンスセルに対してメモリを分散し、場所全体にメモリを分散させる可能性があります。その結果、このアルゴリズムには非常にキャッシュの局所性が悪いがあります。最近の関数型プログラミング言語のスマートアロケーターがこれを軽減できるかどうかはわかりませんが、最近のマシンでは、キャッシュミスがパフォーマンスを大幅に低下させています。


結論は何ですか?他とは異なり、クイックソートが本質的に不可欠であるとは言えません。そのため、FP環境。まったく逆に、クイックソートは関数型アルゴリズムの完璧な例であると主張します:シームレスに不変の環境に変換され、その漸近的な実行時間と空間の複雑さは手続き型実装と同等であり、さらには手続き型の実装では再帰を使用します。

しかし、このアルゴリズムstillは、不変のドメインに制限されている場合、パフォーマンスが低下します。この理由は、アルゴリズムには、配列でのみ効率的に実行できる多くの(場合によっては低レベルの)微調整の恩恵を受けるという独特の特性があるためです。クイックソートの素朴な説明では、これらすべての複雑な機能(機能と手順の両方のバリエーション)がありません。

「ソート関数のエンジニアリング」を読んだ後は、クイックソートをエレガントなアルゴリズムと見なすことはできません。効率的に実装された、それは不格好な混乱であり、エンジニアの作品ではなく、アーティストの作品ではありません(エンジニアリングの価値を下げることではありません。これには独自の美学があります)。


ただし、この点はクイックソートに特有のものであることも指摘しておきます。すべてのアルゴリズムが同じ種類の低レベルの調整に適しているわけではありません。実際、多くのアルゴリズムとデータ構造canは、不変の環境でパフォーマンスを低下させることなく表現できます。

そして、不変性は減少コストのかかるコピーやスレッド間同期の必要性を排除することにより、パフォーマンスコストさえも下げる可能性があります。

したがって、元の質問に答えるために、「不変性は高いですか?」–クイックソートの特定のケースでは、実際に次の結果のコストがあります。不変性。しかし、一般的にはnoです。

104
Konrad Rudolph

関数型プログラミングのベンチマークとして、これには多くの問題点があります。ハイライトは次のとおりです。

  • ボックス化/ボックス化解除が必要な場合があるプリミティブを使用しています。プリミティブオブジェクトをラップするオーバーヘッドをテストするのではなく、不変性をテストしようとしています。
  • インプレース操作が非常に効果的である(そしておそらくそうである)アルゴリズムを選択しました。可変的に実装した方が高速なアルゴリズムが存在することを示したい場合、これは良い選択です。そうでなければ、これは誤解を招く可能性があります。
  • 間違ったタイミング機能を使用しています。使用する System.nanoTime
  • ベンチマークが短すぎるため、JITコンパイルが測定時間の重要な部分ではないことを確信できません。
  • 配列は効率的な方法で分割されていません。
  • 配列は可変であるため、FPでそれらを使用することはとにかく奇妙な比較です。

したがって、この比較は、高性能のコードを作成するために言語(およびアルゴリズム)を詳細に理解する必要があることを示す優れた例です。しかし、FP vs.非FPの比較としてはあまり良いとは言えません。それが必要な場合は、チェックしてください コンピュータ言語ベンチマークゲームのHaskellとC++ 。持ち帰りのメッセージでは、ペナルティは通常2倍または3倍程度であるということですが、それは実際に依存します(Haskellの人々が可能な限り最速のアルゴリズムを書いたという約束はありませんが、少なくともそれらのいくつかはおそらく次に、Haskellの一部がCライブラリを呼び出します...)

ここで、Quicksortのより妥当なベンチマークが必要で、これがFP vs.可変アルゴリズムの最悪のケースの1つであると認識し、データ構造の問題(つまり、不変の配列を持つことができます):

object QSortExample {
  // Imperative mutable quicksort
  def swap(xs: Array[String])(a: Int, b: Int) {
    val t = xs(a); xs(a) = xs(b); xs(b) = t
  }
  def muQSort(xs: Array[String])(l: Int = 0, r: Int = xs.length-1) {
    val pivot = xs((l+r)/2)
    var a = l
    var b = r
    while (a <= b) {
      while (xs(a) < pivot) a += 1
      while (xs(b) > pivot) b -= 1
      if (a <= b) {
        swap(xs)(a,b)
        a += 1
        b -= 1
      }
    }
    if (l<b) muQSort(xs)(l, b)
    if (b<r) muQSort(xs)(a, r)
  }

  // Functional quicksort
  def fpSort(xs: Array[String]): Array[String] = {
    if (xs.length <= 1) xs
    else {
      val pivot = xs(xs.length/2)
      val (small,big) = xs.partition(_ < pivot)
      if (small.length == 0) {
        val (bigger,same) = big.partition(_ > pivot)
        same ++ fpSort(bigger)
      }
      else fpSort(small) ++ fpSort(big)
    }
  }

  // Utility function to repeat something n times
  def repeat[A](n: Int, f: => A): A = {
    if (n <= 1) f else { f; repeat(n-1,f) }
  }

  // This runs the benchmark
  def bench(n: Int, xs: Array[String], silent: Boolean = false) {
    // Utility to report how long something took
    def ptime[A](f: => A) = {
      val t0 = System.nanoTime
      val ans = f
      if (!silent) printf("elapsed: %.3f sec\n",(System.nanoTime-t0)*1e-9)
      ans
    }

    if (!silent) print("Scala builtin ")
    ptime { repeat(n, {
      val ys = xs.clone
      ys.sorted
    }) }
    if (!silent) print("Mutable ")
    ptime { repeat(n, {
      val ys = xs.clone
      muQSort(ys)()
      ys
    }) }
    if (!silent) print("Immutable ")
    ptime { repeat(n, {
      fpSort(xs)
    }) }
  }

  def main(args: Array[String]) {
    val letters = (1 to 500000).map(_ => scala.util.Random.nextPrintableChar)
    val unsorted = letters.grouped(5).map(_.mkString).toList.toArray

    repeat(3,bench(1,unsorted,silent=true))  // Warmup
    repeat(3,bench(10,unsorted))     // Actual benchmark
  }
}

機能的なクイックソートの変更に注意してください。可能な限りデータを1回だけ処理し、組み込みのソートと比較します。実行すると、次のようになります。

Scala builtin elapsed: 0.349 sec
Mutable elapsed: 0.445 sec
Immutable elapsed: 1.373 sec
Scala builtin elapsed: 0.343 sec
Mutable elapsed: 0.441 sec
Immutable elapsed: 1.374 sec
Scala builtin elapsed: 0.343 sec
Mutable elapsed: 0.442 sec
Immutable elapsed: 1.383 sec

したがって、独自のソートを書こうとすることは悪い考えであることを知るほかに、不変のクイックソートがいくらか注意深く実装されている場合、不変のクイックソートには3倍のペナルティがあることがわかります。 (3つの配列を返すtrisectメソッドを作成することもできます。これらは、ピボットより小さい、等しい、ピボットより大きいものです。これにより、処理速度が少し速くなる場合があります。)

42
Rex Kerr

Scalaバージョンは実際には末尾再帰的ではないと思います。Array.concatを使用しているためです。

また、これが慣用的なScalaコードであるからといって、これが最良の方法であるとは限りません。

これを行う最良の方法は、Scalaの組み込みソート関数の1つを使用することです。これにより、不変性が保証され、高速なアルゴリズムを使用していることがわかります。

例については、スタックオーバーフローの質問Scalaで配列をソートするにはどうすればよいですか?を参照してください。

10
TreyE

不変性は高価ではありません。プログラムが実行する必要があるタスクの小さなサブセットを測定し、クイックソートの測定など、起動する可変性に基づいてソリューションを選択する場合、コストが高くなる可能性があります。

簡単に言えば、純粋に関数型の言語を使用している場合は、クイックソートは行いません。

これを別の角度から考えてみましょう。次の2つの関数について考えてみましょう。

// Version using mutable data structures
def tailFrom[T : ClassManifest](arr: Array[T], p: T => Boolean): Array[T] = {
  def posIndex(i: Int): Int = {
    if (i < arr.length) {
      if (p(arr(i)))
        i
      else
        posIndex(i + 1)
    } else {
      -1
    }
  }

  var index = posIndex(0)

  if (index < 0) Array.empty
  else {
    var result = new Array[T](arr.length - index)
    Array.copy(arr, index, result, 0, arr.length - index)
    result
  }
}

// Immutable data structure:

def tailFrom[T](list: List[T], p: T => Boolean): List[T] = {
  def recurse(sublist: List[T]): List[T] = {
    if (sublist.isEmpty) sublist
    else if (p(sublist.head)) sublist
    else recurse(sublist.tail)
  }
  recurse(list)
}

それをベンチマークすると、変更可能なデータ構造を使用するコードは配列をコピーする必要があるため、パフォーマンスが大幅に低下することがわかります。一方、不変のコードはそれ自体に関与する必要はありません。

不変のデータ構造を使用してプログラミングする場合は、その長所を活用するようにコードを構造化します。これは、単にデータタイプや個々のアルゴリズムではありません。プログラムは、異なる方法で設計済みになります。

これが、ベンチマークが通常意味をなさない理由です。いずれかのスタイルに自然なアルゴリズムを選択し、そのスタイルが優先されるか、またはアプリケーション全体のベンチマークを行うかのどちらかであり、これは実際的でないことがよくあります。

8

配列の並べ替えは、宇宙で最も重要なタスクのようです。多くのエレガントな「不変の」戦略/実装が「配列の並べ替え」マイクロベンチマークでうまく機能しないことは当然のことです。ただし、これは不変性が「一般的に」高価であることを意味するものではありません。不変の実装が変更可能なものと同等に実行される多くのタスクがありますが、配列のソートはそれらの1つではないことがよくあります。

7
Brian

Scalaの不変性のコスト

Java one。;)とほぼ同じ速度のバージョンです。

object QuickSortS {
  def sort(xs: Array[Int]): Array[Int] = {
    val res = new Array[Int](xs.size)
    xs.copyToArray(res)
    (new QuickSortJ).sort(res)
    res
  }
}

このバージョンは、配列のコピーを作成し、Javaバージョンを使用して所定の位置にソートし、コピーを返します。Scalaは、内部で不変構造を使用することを強制しません。

したがって、Scalaの利点は、必要に応じて可変性と不変性を活用できることです。欠点は、間違った場合に実際に不変性の利点を得られないことです。

7
huynhjl

命令型アルゴリズムとデータ構造を関数型言語に単純に書き換えるだけの場合、それは確かに高価で役に立たないでしょう。物事を輝かせるには、関数型プログラミングでのみ利用可能な機能を使用する必要があります:データ構造の永続性、遅延評価など。

7
Vasil Remeniuk

QuickSortは、インプレースで実行すると高速になることがわかっているため、公平な比較とは言えません。

それを言った... Array.concat?他に何もない場合は、命令型プログラミング用に最適化されたコレクション型が、関数型アルゴリズムで使用しようとすると、特に遅いことを示しています。他のほとんどの選択はより高速になります!


2つのアプローチを比較する際に考慮すべきもう1つのvery重要なポイント、おそらくthe最も重要な問題は次のとおりです: "これは、複数のノード/コアにどれだけ拡張できますか?」

おそらく、不変のクイックソートを探しているのであれば、実際には並列クイックソートが必要なため、そうしています。ウィキペディアにはこの主題に関するいくつかの引用があります: http://en.wikipedia.org/wiki/Quicksort#Parallelizations

scalaバージョンは、関数が再帰する前に単純にforkできるため、十分なコアがある場合、数十億のエントリを含むリストを非常に迅速にソートできます。

現在、私のシステムのGPUは、Scalaコードを実行するだけで128コアを利用できます。これは、現在の世代から2年遅れたシンプルなデスクトップシステムです。

シングルスレッドの命令型アプローチに対してどのようにそれを積み重ねますか?.

したがって、おそらくより重要な質問は次のとおりです。

「個々のコアが速くなることはなく、同期/ロックは並列化にとって実際の課題であり、可変性は高価ですか?」

7
Kevin Wright

OOプログラミングは抽象化を使用して複雑さを隠し、関数型プログラミングは不変性を使用して複雑さを取り除くと言われています。 Scalaのハイブリッドの世界では、OOを使用して命令コードを非表示にし、アプリケーションコードを賢くすることができます。実際、コレクションライブラリは多くの命令コードを使用しますが、それを使用すべきではないという意味ではありません。他の人が言ったように、慎重に使用すると、あなたは本当にここで両方の世界の最高のものを手に入れます。

2
Ben Hardy