93 votes

Programmation fonctionnelle - l'immuabilité est-elle coûteuse ?

Demande :

  1. La question est en deux parties. La première est conceptuelle, elle compare la programmation fonctionnelle et impérative du point de vue du cosy. impérative du point de vue du coût de l'immuabilité. La seconde, sur les spécificités de Java/scala.
  2. Quicksort est enseigné et généralement mis en œuvre comme un tri en mémoire. Il n'y a aucun doute là-dessus. Mais comment implémenter une telle chose d'une manière PUREment fonctionnelle ? Plus précisément en Scala ?

Question :

Je viens d'un milieu fortement impératif (C++, Java). J'ai exploré la programmation fonctionnelle, plus particulièrement Scala. Comme je suis novice en programmation fonctionnelle, j'ai pensé que ce serait une bonne idée de poser cette question ici maintenant.

Certains des concepts de la programmation fonctionnelle (je suis sûr que j'en oublie beaucoup) :

  1. Fonctionne comme des citoyens de première classe.
  2. Les fonctions n'ont pas d'effets secondaires et donc objets immuables .
  3. La concussion devient facile grâce à 2)

Même si la modernité JVMs sont extrêmement efficaces pour la création d'objets et collecte des déchets est très peu coûteux pour les objets de courte durée, il est probablement encore mieux de minimiser la création d'objets, non ? Au moins dans une application monofilaire où la concurrence et le verrouillage ne sont pas un problème. Comme Scala est un hybride, je sais que je peux écrire du code impératif avec des objets mutables si nécessaire. Mais, en tant que personne qui a passé beaucoup d'années à essayer de réutiliser des objets et de minimiser l'allocation. J'aimerais avoir une bonne compréhension de la contre école de pensée.

Dans un cas précis, j'ai été un peu surpris par ce bout de code dans le document ce tutoriel par Ted Neward (semble être un nom éminent dans la communauté Scala). Il propose une version Java de quicksort et donne ensuite une implémentation Scala soignée de la même chose.

Voici ma tentative d'évaluation comparative des implémentations. Je n'ai pas fait de profilage détaillé. Mais je pense que la version Scala est plus lente car le nombre d'objets alloués est linéaire (un par appel de récursion). Y a-t-il une chance que les optimisations des appels de queue puissent entrer en jeu ? Si j'ai raison, Scala supporte les optimisations de tail call pour les appels auto-récursifs. Donc, cela ne devrait que l'aider. J'utilise Scala 2.8.

Version 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) {
      int pivot = xs[(l+r)/2];
      int a = l; int b = r;
      while (a <= b){
        while (xs[a] < pivot) { a = a + 1; }
        while (xs[b] > pivot) { b = b - 1; }
        if (a <= b) {
          swap(xs, a, b);
          a = a + 1;
          b = b - 1;
        }
      }
      if (l < b) sort(xs, l, b);
      if (b < r) sort(xs, a, r);
    }

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

Version du scanner

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 <)))
    }
}

Code Scala pour comparer les implémentations

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"))

Résultats

Temps en millisecondes pour cinq passages consécutifs

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

103voto

Konrad Rudolph Points 231505

Comme il y a quelques idées fausses qui volent par ici, j'aimerais clarifier certains points.

  • Le tri rapide "en place" n'est pas vraiment en place (et le tri rapide est no par définition en place). Elle nécessite un stockage supplémentaire sous forme d'espace de pile pour l'étape récursive, qui est de l'ordre de O (log n ) dans le meilleur des cas, mais O ( n ) dans le pire des cas.

  • L'implémentation d'une variante fonctionnelle de quicksort qui opère sur des tableaux va à l'encontre du but recherché. Les tableaux ne sont jamais immuables.

  • L'implémentation fonctionnelle "correcte" de quicksort utilise des listes immuables. Bien sûr, elle n'est pas in-place, mais elle a le même temps d'exécution asymptotique dans le pire des cas ( O ( n ^2)) et la complexité spatiale ( O ( n )) comme la version procédurale in-place.

    En moyenne, son temps de fonctionnement est de todavía à égalité avec celle de la variante in-place ( O ( n journal n )). Sa complexité spatiale, cependant, est toujours O ( n ).


Il y a deux inconvénients évidents d'une implémentation fonctionnelle de quicksort. Dans ce qui suit, nous allons considérer cette implémentation de référence en Haskell (je ne connais pas Scala ) à partir de l'application Introduction à Haskell :

qsort []     = []
qsort (x:xs) = qsort lesser ++ [x] ++ qsort greater
    where lesser  = (filter (< x) xs)
          greater = (filter (>= x) xs)
  1. Le premier inconvénient est le choix de l'élément pivot qui est très peu flexible. La force des implémentations modernes de quicksort repose en grande partie sur un choix judicieux du pivot (voir "Ingénierie d'une fonction de tri" par Bentley et al. ). L'algorithme ci-dessus est médiocre à cet égard, ce qui dégrade considérablement les performances moyennes.

  2. Deuxièmement, cet algorithme utilise concaténation de listes (au lieu de la construction de listes) qui est un O ( n ). Cela n'a pas d'impact sur la complexité asymptotique mais c'est un facteur mesurable.

Un troisième inconvénient est quelque peu caché : contrairement à la variante "in-place", cette implémentation demande continuellement de la mémoire du tas pour les contre cellules de la liste et éparpille potentiellement la mémoire partout. En conséquence, cet algorithme a un très mauvaise localisation du cache . Je ne sais pas si les allocateurs intelligents des langages de programmation fonctionnels modernes peuvent atténuer ce problème, mais sur les machines modernes, les ratés du cache sont devenus un problème majeur de performance.


Quelle est la conclusion ? Contrairement à d'autres, je ne dirais pas que quicksort est intrinsèquement impératif et que c'est la raison pour laquelle il fonctionne mal dans un environnement FP. Au contraire, je dirais que quicksort est un exemple parfait d'algorithme fonctionnel : il se traduit sans problème dans un environnement immuable, son temps d'exécution asymptotique et sa complexité spatiale sont équivalents à ceux de l'implémentation procédurale, et même son implémentation procédurale utilise la récursion.

Mais cet algorithme todavía est moins performant lorsqu'il est limité à un domaine immuable. La raison en est que l'algorithme a la propriété particulière de bénéficier d'un grand nombre de réglages fins (parfois de bas niveau) qui ne peuvent être effectués efficacement que sur des tableaux. Une description naïve du quicksort passe à côté de toutes ces subtilités (tant dans la variante fonctionnelle que dans la variante procédurale).

Après avoir lu "Engineering a sort function", je ne peux plus considérer le tri rapide comme un algorithme élégant. Mis en œuvre de manière efficace, c'est un désordre maladroit, un travail d'ingénieur, pas d'artiste (sans vouloir dévaloriser l'ingénierie, elle a sa propre esthétique).


Mais je tiens également à préciser que ce point est particulier à quicksort. Tous les algorithmes ne se prêtent pas au même type de modification de bas niveau. Beaucoup d'algorithmes et de structures de données puede être exprimée sans perte de performance dans un environnement immuable.

Et l'immuabilité peut même diminuer les coûts de performance en éliminant le besoin de copies coûteuses ou de synchronisations inter-filières.

Donc, pour répondre à la question initiale, " L'immuabilité est-elle coûteuse ? "Dans le cas particulier de quicksort, il y a un coût qui est effectivement le résultat de l'immuabilité. Mais en général, pas de .

41voto

Rex Kerr Points 94401

Il y a un tas de choses qui ne vont pas avec cette référence de la programmation fonctionnelle. Les points forts incluent :

  • Vous utilisez des primitives, qui peuvent avoir besoin d'être boxées/déballées. Vous n'essayez pas de tester l'overhead de l'encapsulation des objets primitifs, vous essayez de tester l'immuabilité.
  • Vous avez choisi un algorithme où l'opération in-place est exceptionnellement efficace (et c'est prouvé). Si vous voulez montrer qu'il existe des algorithmes qui sont plus rapides lorsqu'ils sont implémentés de manière mutable, alors c'est un bon choix ; sinon, cela risque d'être trompeur.
  • Vous utilisez la mauvaise fonction de chronométrage. Utilisez System.nanoTime .
  • Le benchmark est trop court pour que vous puissiez être sûr que la compilation JIT ne représentera pas une part importante du temps mesuré.
  • Le tableau n'est pas divisé de manière efficace.
  • Les tableaux sont mutables, donc les utiliser avec FP est de toute façon une étrange comparaison.

Cette comparaison illustre donc parfaitement le fait que vous devez comprendre votre langage (et votre algorithme) en détail afin d'écrire un code performant. Mais ce n'est pas une très bonne comparaison entre FP et non-FP. Si vous voulez en savoir plus, consultez Haskell contre C++ au jeu de benchmark des langages informatiques . Le message à retenir est que la pénalité n'est généralement pas supérieure à un facteur de 2 ou 3, mais cela dépend vraiment. (Il n'est pas certain que les gens de Haskell aient écrit les algorithmes les plus rapides possibles, mais au moins certains d'entre eux ont probablement essayé ! Mais là encore, certaines bibliothèques Haskell font appel à des bibliothèques C....)

Maintenant, supposons que vous souhaitiez une évaluation plus raisonnable de Quicksort, en reconnaissant qu'il s'agit probablement de l'un des pires cas pour les algorithmes FP vs. mutables, et en ignorant la question de la structure des données (c'est-à-dire en prétendant que nous pouvons avoir un tableau immuable) :

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
  }
}

Notez la modification du Quicksort fonctionnel pour qu'il ne parcoure les données qu'une seule fois si possible, et la comparaison avec le tri intégré. Lorsque nous l'exécutons, nous obtenons quelque chose comme :

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

Ainsi, en plus d'apprendre qu'essayer d'écrire son propre tri est une mauvaise idée, nous découvrons qu'il y a une pénalité de ~3x pour un quicksort immuable si ce dernier est implémenté avec un certain soin. (Vous pourriez également écrire une méthode trisect qui renvoie trois tableaux : ceux qui sont inférieurs à, ceux qui sont égaux à, et ceux qui sont supérieurs au pivot. Cela pourrait accélérer un peu plus les choses).

10voto

TreyE Points 1066

Je ne pense pas que la version Scala soit réellement récursive à la queue, puisque vous utilisez Array.concat .

En outre, ce n'est pas parce que ce code est idiomatique en Scala que c'est la meilleure façon de procéder.

La meilleure façon de procéder est d'utiliser l'une des fonctions de tri intégrées de Scala. Ainsi, vous bénéficiez de la garantie d'immuabilité et vous savez que vous disposez d'un algorithme rapide.

Voir la question de Stack Overflow Comment trier un tableau en Scala ? pour un exemple.

7voto

Brian Points 82719

Trier un tableau est, comme, la tâche la plus impérative de l'univers. Il n'est pas surprenant que de nombreuses stratégies/implémentations "immuables" élégantes échouent mal sur un microbenchmark "trier un tableau". Cela ne signifie pas pour autant que l'immuabilité est coûteuse "en général". Il existe de nombreuses tâches pour lesquelles les implémentations immuables auront des performances comparables à celles des implémentations mutables, mais le tri de tableaux n'en fait souvent pas partie.

7voto

huynhjl Points 26045

Le coût de l'immuabilité dans Scala

Voici une version qui est presque aussi rapide que celle de Java ;)

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

Cette version fait une copie du tableau, le trie en place en utilisant la version Java et retourne la copie. Scala ne vous oblige pas à utiliser une structure immuable en interne.

Ainsi, l'avantage de Scala est que vous pouvez exploiter la mutabilité et l'immuabilité comme bon vous semble. L'inconvénient est que si vous vous y prenez mal, vous ne bénéficiez pas vraiment des avantages de l'immuabilité.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X