web-dev-qa-db-fra.com

F # vs OCaml: débordement de pile

J'ai récemment trouvé une présentation sur F # pour Python , et après l'avoir regardé, j'ai décidé d'implémenter une solution au "puzzle ant") par moi-même.

Il y a une fourmi qui peut se promener sur une grille plane. La fourmi peut se déplacer d'un espace à la fois vers la gauche, la droite, le haut ou le bas. Autrement dit, à partir de la cellule (x, y), la fourmi peut aller aux cellules (x + 1, y), (x-1, y), (x, y + 1) et (x, y-1). Les points où la somme des chiffres des coordonnées x et y est supérieure à 25 sont inaccessibles à la fourmi. Par exemple, le point (59,79) est inaccessible car 5 + 9 + 7 + 9 = 30, ce qui est supérieur à 25. La question est: à combien de points la fourmi peut-elle accéder si elle commence à (1000, 1000), y compris (1000, 1000) lui-même?

J'ai implémenté ma solution dans 30 lignes de OCaml en premier , et l'ai essayée:

$ ocamlopt -unsafe -rectypes -inline 1000 -o puzzle ant.ml
$ time ./puzzle
Points: 148848

real    0m0.143s
user    0m0.127s
sys     0m0.013s

Neat, mon résultat est le même que celui de implémentation de leonardo, en D et C++ . Par rapport à l'implémentation C++ de leonardo, la version OCaml s'exécute environ 2 fois plus lentement que C++. Ce qui est bien, étant donné que leonardo a utilisé une file d'attente pour supprimer la récursivité.

J'ai ensuite traduit le code en F # ... et voici ce que j'ai obtenu:

Thanassis@HOME /g/Tmp/ant.fsharp
$ /g/Program\ Files/FSharp-2.0.0.0/bin/fsc.exe ant.fs
Microsoft (R) F# 2.0 Compiler build 2.0.0.0
Copyright (c) Microsoft Corporation. All Rights Reserved.

Thanassis@HOME /g/Tmp/ant.fsharp
$ ./ant.exe

Process is terminated due to StackOverflowException.
Quit

Thanassis@HOME /g/Tmp/ant.fsharp
$ /g/Program\ Files/Microsoft\ F#/v4.0/Fsc.exe ant.fs
Microsoft (R) F# 2.0 Compiler build 4.0.30319.1
Copyright (c) Microsoft Corporation. All Rights Reserved.

Thanassis@HOME /g/Tmp/ant.fsharp
$ ./ant.exe

Process is terminated due to StackOverflowException

Débordement de pile ... avec les deux versions de F # que j'ai dans ma machine ... Par curiosité, j'ai ensuite pris le binaire généré (ant.exe) et je l'ai exécuté sous Arch Linux/Mono:

$ mono -V | head -1
Mono JIT compiler version 2.10.5 (tarball Fri Sep  9 06:34:36 UTC 2011)

$ time mono ./ant.exe
Points: 148848

real    1m24.298s
user    0m0.567s
sys     0m0.027s

Étonnamment, il fonctionne sous Mono 2.10.5 (c'est-à-dire sans débordement de pile) - mais cela prend 84 secondes, soit 587 fois plus lentement que OCaml - oups.

Donc ce programme ...

  • fonctionne bien sous OCaml
  • ne fonctionne pas du tout sous .NET/F #
  • fonctionne, mais est très lent, sous Mono/F #.

Pourquoi?

EDIT: Bizarrerie continue - L'utilisation de "--optimize + --checked-" fait disparaître le problème, mais uniquement sous ArchLinux/Mono ; sous Windows XP et Windows 7/64bit, même la version optimisée de la pile binaire déborde.

EDIT final : J'ai trouvé la réponse moi-même - voir ci-dessous.

63
ttsiodras

Résumé:

  • J'ai écrit une implémentation simple d'un algorithme ... qui n'était pas récursif.
  • Je l'ai compilé avec OCaml sous Linux.
  • Cela a bien fonctionné et a terminé en 0,14 seconde.

Il était alors temps de porter sur F #.

  • J'ai traduit le code (traduction directe) en F #.
  • J'ai compilé sous Windows et je l'ai exécuté - j'ai eu un débordement de pile.
  • J'ai pris le binaire sous Linux et je l'ai exécuté sous Mono.
  • Cela a fonctionné, mais a fonctionné très lentement (84 secondes).

J'ai ensuite posté sur Stack Overflow - mais certaines personnes ont décidé de fermer la question (soupir).

  • J'ai essayé de compiler avec --optimize + --checked-
  • La pile binaire débordait toujours sous Windows ...
  • ... mais fonctionne bien (et a terminé en 0,5 seconde) sous Linux/Mono.

Il était temps de vérifier la taille de la pile: Sous Windows, n autre SO post a souligné qu'il est défini par défaut à 1 Mo . Sous Linux, "uname -s" et ne compilation d'un programme de test a clairement montré qu'il est de 8 Mo.

Cela expliquait pourquoi le programme fonctionnait sous Linux et non sous Windows (le programme utilisait plus de 1 Mo de pile). Cela n'expliquait pas pourquoi la version optimisée fonctionnait tellement mieux sous Mono que la version non optimisée: 0,5 seconde contre 84 secondes (même si le --optimize + semble être défini par défaut, voir le commentaire de Keith avec "Expert F #" extrait). Cela a probablement à voir avec le garbage collector de Mono, qui a été en quelque sorte poussé à l'extrême par la 1ère version.

La différence entre les temps d'exécution Linux/OCaml et Linux/Mono/F # (0,14 vs 0,5) est due à la manière simple dont je l'ai mesuré: "time ./binary ..." mesure également le temps de démarrage, ce qui est significatif pour Mono /.NET (enfin, significatif pour ce petit problème simple).

Quoi qu'il en soit, pour résoudre cela une fois pour toutes, je a écrit une version récursive - où l'appel récursif à la fin de la fonction est transformé en boucle (et donc, aucune utilisation de la pile n'est nécessaire - au moins en théorie).

La nouvelle version fonctionne également bien sous Windows et a terminé en 0,5 seconde.

Donc, morale de l'histoire:

  • Méfiez-vous de votre utilisation de la pile, surtout si vous en utilisez beaucoup et que vous exécutez sous Windows. Utilisez EDITBIN avec l'option/STACK pour définir vos binaires sur des tailles de pile plus grandes, ou mieux encore, écrivez votre code d'une manière qui ne dépend pas de l'utilisation de trop de pile.
  • OCaml peut être meilleur pour éliminer la récursivité de queue que F # - ou son garbage collector fait un meilleur travail pour ce problème particulier.
  • Ne désespérez pas ... des gens impolis qui ferment vos questions sur le débordement de pile, les bonnes personnes les contrecarreront finalement - si les questions sont vraiment bonnes :-)

P.S. Quelques commentaires supplémentaires du Dr Jon Harrop:

... vous avez juste eu de la chance que OCaml ne déborde pas aussi. Vous avez déjà identifié que les tailles de pile réelles varient selon les plates-formes. Une autre facette du même problème est que différentes implémentations de langage consomment de l'espace de pile à des taux différents et ont des caractéristiques de performances différentes en présence de piles profondes. OCaml, Mono et .NET utilisent tous des représentations de données et des algorithmes GC différents qui ont un impact sur ces résultats ... Le balisage transmet essentiellement juste assez d'informations pour que le temps d'exécution OCaml puisse traverser le tas (b) Mono traite les mots de la pile de manière conservatrice comme des pointeurs: si, en tant que pointeur, un mot pointerait vers un bloc alloué par tas, alors que le bloc est considéré comme accessible. (c) Je ne connais pas l'algorithme de .NET mais je ne serais pas surpris s'il mangeait de l'espace de pile plus rapidement et traversait toujours chaque mot de la pile (il souffre certainement des performances pathologiques du GC si un thread non lié a une pile profonde!) ... De plus, votre utilisation de tuples alloués en tas signifie que vous remplissez rapidement la génération de la pépinière (par exemple gen0) et, par conséquent, que le GC traverse souvent ces piles profondes ...

72
ttsiodras

Permettez-moi de résumer la réponse.

Il y a 3 points à souligner:

  • problème: un débordement de pile se produit sur une fonction récursive
  • ça ne se passe que sous windows: sur linux, pour la proble taille examinée, ça marche
  • même code (ou similaire) dans les travaux OCaml
  • optimiser + drapeau du compilateur, pour la taille de problème examinée, fonctionne

Il est très courant qu'une exception Stack Overflow soit le résultat d'une vall récursive. Si l'appel est en position de queue, le compilateur peut le reconnaître et appliquer l'optimisation de l'appel de queue, par conséquent le ou les appels récursifs ne prendront pas d'espace de pile. L'optimisation de l'appel peut se produire en F #, dans la CRL ou dans les deux:

Optimisation de queue CLR 1

R # récursion (plus générale) 2

F # appels de queue

L'explication correcte pour "échoue sous Windows, pas sous Linux" est, comme d'autres l'ont dit, l'espace de pile réservé par défaut sur les deux systèmes d'exploitation. Ou mieux, l'espace de pile réservé utilisé par les compilateurs sous les deux systèmes d'exploitation. Par défaut, VC++ ne réserve que 1 Mo d'espace de pile. Le CLR est (probablement) compilé avec VC++, il a donc cette limitation. L'espace de pile réservé peut être augmenté au moment de la compilation, mais je ne sais pas s'il peut être modifié sur les exécutables compilés.

EDIT: il s'avère que cela peut être fait (voir cet article de blog http://www.bluebytesoftware.com/blog/2006/07/04/ModifyingStackReserveAndCommitSizesOnExistingBinaries.aspx ) Je ne le recommanderais pas, mais dans des situations extrêmes au moins c'est possible.

La version OCaml peut fonctionner car elle a été exécutée sous Linux. Cependant, il serait intéressant de tester également la version OCaml sous Windows. Je sais que le compilateur OCaml est plus agressif lors de l'optimisation des appels de queue que F # .. pourrait-il même extraire une fonction de queue-recusable de votre code d'origine?

Ma supposition sur "--optimize +" est qu'il provoquera toujours la récurrence du code, donc il échouera toujours sous Windows, mais atténuera le problème en accélérant l'exécution de l'exécutable.

Enfin, la solution définitive consiste à utiliser la récursivité de queue (en réécrivant le code ou en réagissant sur une optimisation agressive du compilateur); c'est un bon moyen d'éviter les problèmes de débordement de pile avec les fonctions récursives.

8
Lorenzo Dematté