F # vs OCaml: переполнение стека

Недавно я нашел презентацию о F # для программистов на Python, и после просмотра ее решил реализовать решение головоломки ant "самостоятельно.

Существует ant, который может перемещаться по плоской сетке. ant может перемещать одно пространство за раз влево, вправо, вверх или вниз. То есть из ячейки (x, y) ant может перейти в ячейки (x + 1, y), (x-1, y), (x, y + 1) и (x, y-1), Точки, где сумма цифр координат x и y больше 25, недоступны для ant. Например, точка (59,79) недоступна, потому что 5 + 9 + 7 + 9 = 30, что больше 25. Вопрос: сколько точек может иметь доступ ant, если он начинается с (1000, 1000), включая (1000, 1000) себя?

Я выполнил свое решение в 30 строках OCaml в начале и попробовал:

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

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

Приятный, мой результат такой же, как у leonardo implementation, в D и С++. Сравнивая с реализацией leonardo С++, версия OCaml работает примерно в 2 раза медленнее, чем С++. Это нормально, учитывая, что leonardo использовал очередь для удаления рекурсии.

Я тогда перевел код на F #... и вот что я получил:

[email protected] /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.

[email protected] /g/Tmp/ant.fsharp
$ ./ant.exe

Process is terminated due to StackOverflowException.
Quit

[email protected] /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.

[email protected] /g/Tmp/ant.fsharp
$ ./ant.exe

Process is terminated due to StackOverflowException

Переполнение стека... с обеими версиями F #, которые у меня есть на моей машине... Из любопытства я тогда взял сгенерированный двоичный файл (ant.exe) и запустил его в 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

Удивительно, но он работает под Mono 2.10.5 (т.е.) - но это занимает 84 секунды, т.е. в 587 раз медленнее, чем OCaml - oops.

Итак, эта программа...

  • работает под OCaml
  • вообще не работает в .NET/F #
  • работает, но очень медленно, в режиме Mono/F #.

Почему?

РЕДАКТИРОВАТЬ: Странность продолжается - с помощью "--optimize + --checked-" проблема исчезает, , но только в ArchLinux/Mono; под Windows XP и Windows 7/64bit, даже оптимизированная версия переполнения двоичного стека.

Final EDIT: я сам выяснил ответ - см. ниже.

Ответ 1

Резюме:

  • Я написал простую реализацию алгоритма... который не был хвостовым рекурсивным.
  • Я скомпилировал его с OCaml под Linux.
  • Он работал нормально и закончил через 0,14 секунды.

Пришло время для перехода на F #.

  • Я перевел код (прямой перевод) в F #.
  • Я скомпилировал под Windows и запустил его - у меня переполнение стека.
  • Я взял двоичный файл под Linux и запустил его под Mono.
  • Он работал, но работал очень медленно (84 секунды).

Затем я отправил в Qaru - но некоторые люди решили закрыть вопрос (вздох).

  • Я попытался скомпилировать с помощью --optimize + --checked -
  • Двоичный все еще стек переполняется под Windows...
  • ... но отлично работает (и заканчивается через 0,5 секунды) под Linux/Mono.

Пришло время проверить размер стека: в Windows, еще одна публикация SO сообщила, что по умолчанию она установлена ​​в 1 МБ. В Linux "uname -s" и компиляция тестовой программы ясно показали, что она составляет 8 МБ.

Это объясняет, почему программа работала под Linux, а не под Windows (в программе использовалось более 1 МБ стека). Он не объяснил, почему оптимизированная версия работает намного лучше в Mono, чем не оптимизированная: 0,5 секунды против 84 секунд (хотя параметр -optimize + по умолчанию установлен, см. Комментарий Keith с "Expert F #", экстракт). Вероятно, это связано с сборщиком мусора Mono, который был как-то доведен до крайности 1-й версией.

Разница между временем выполнения Linux/OCaml и Linux/Mono/F # (0,14 против 0,5) объясняется простым способом, который я измерил: "время./binary..." также измеряет время запуска, что для Mono/.NET(что немаловажно для этой простой небольшой проблемы).

В любом случае, чтобы решить это раз и навсегда, я написал хвостовую рекурсивную версию - где рекурсивный вызов в конце функция преобразуется в цикл (и, следовательно, не требуется использование стека - по крайней мере теоретически).

Новая версия также отлично работает под Windows, и заканчивается через 0,5 секунды.

Итак, мораль истории:

  • Остерегайтесь использования вашего стека, особенно если вы используете его много и запускаете под Windows. Используйте EDITBIN с параметром /STACK, чтобы установить бинарные файлы в более крупные размеры стека или, еще лучше, напишите свой код таким образом, чтобы он не зависят от использования слишком большого количества стека.
  • OCaml может быть лучше в устранении хвостовой рекурсии, чем F # - или сборщик мусора делает лучшую работу по этой конкретной проблеме.
  • Не отчаивайтесь... грубые люди, закрывающие ваши вопросы о переполнении стека, хорошие люди будут противодействовать им в конце - если вопросы действительно хорошие: -)

P.S. Некоторый дополнительный вклад от доктора Джона Харропа:

... вам просто повезло, что OCaml тоже не переполнился. Вы уже определили, что фактические размеры стека варьируются между платформами. Другим аспектом этой же проблемы является то, что различные языковые реализации есть пространство стека с разной скоростью и имеют разную производительность характеристик при наличии глубоких стеков. OCaml, Mono и .NET все используют разные представления данных и алгоритмы GC, которые влияют эти результаты... (a) OCaml использует помеченные целые числа для различения указателей, давая компактные стоп-фреймы, и будет перемещать все в стеке поиск указателей. Пометка по существу передает достаточно информации для времени выполнения OCaml, чтобы иметь возможность пересекать кучу (б) Моно обрабатывает слова в стеке консервативно в качестве указателей: если в качестве указателя слово будет указывать в блок, выделенный из кучи, тогда этот блок считается доступным. (c) Я не знаю .NET-алгоритма, но я не удивлюсь, если он съел стек пространство быстрее и по-прежнему пересекало каждое слово в стеке (это, безусловно, страдает патологической характеристикой от GC, если несвязанный поток имеет глубокий стек!)... Кроме того, ваше использование выделенных кучей кортежей означает, что вы быстро заполнять генерацию детского сада (например, gen0) и, следовательно, заставляя GC часто проходить эти глубокие стеки...

Ответ 2

Позвольте мне попытаться обобщить ответ.

Есть 3 пункта:

  • проблема: переполнение стека происходит при рекурсивной функции
  • Это происходит только под окнами: на linux, для проверки размера проспекта, он работает
  • тот же (или аналогичный) код в OCaml работает
  • optimize + флаг компилятора, для проверяемого размера проспекта работает

Очень часто исключение является результатом рекурсивного vall. Если вызов находится в хвостовом положении, компилятор может распознать его и применить оптимизацию хвостового потока, поэтому рекурсивный вызов не займет место стека. Оптимизация выходов может произойти в F #, в CRL или в обоих:

Оптимизация хвоста CLR 1

Рекурсия F # (более общая) 2

F # tail вызывает 3

Правильное объяснение "сбоев при работе с окнами, а не в linux", как было сказано, является зарезервированным пространством стека по умолчанию для обеих ОС. Или лучше, зарезервированное пространство стека, используемое компиляторами под двумя ОС. По умолчанию VС++ резервирует только 1 МБ пространства стека. CLR (вероятно) скомпилирован с VС++, поэтому он имеет это ограничение. Зарезервированное пространство стека можно увеличить во время компиляции, но я не уверен, можно ли его изменить на скомпилированные исполняемые файлы.

EDIT: выясняется, что это можно сделать (см. этот пост в блоге http://www.bluebytesoftware.com/blog/2006/07/04/ModifyingStackReserveAndCommitSizesOnExistingBinaries.aspx) Я бы не рекомендовал этого, но в экстремальных ситуациях, по крайней мере, это возможно.

Версия OCaml может работать, потому что она запускалась под Linux. Однако было бы интересно проверить версию OCaml под Windows. Я знаю, что компилятор OCaml более агрессивен в оптимизации хвостового вызова, чем F #.. может ли он даже извлечь функцию возвращаемого хвостом из исходного кода?

Мое предположение о "-optimize +" заключается в том, что он все равно вызовет повторение кода, поэтому он все равно будет работать под Windows, но смягчит проблему, ускоряя выполнение исполняемого файла.

Наконец, окончательное решение - использовать хвостовую рекурсию (путем перезаписи кода или путем реализации агрессивной оптимизации компилятора); это хороший способ избежать проблемы с помощью рекурсивных функций.