Почему F # накладывает нижний предел на размер стека?

#recursion #f# #tail-recursion

#рекурсия #f# #хвостовая рекурсия

Вопрос:

Я хотел бы знать, есть ли фундаментальная причина для ограничения глубины рекурсии в F # до 10000 или около того, и в идеале, как избежать этого ограничения. Я думаю, что вполне разумно писать код, который использует пространство стека O (n), и был бы признателен, если бы кто-то, кто не согласен, мог объяснить, почему они это делают. Большое спасибо. Я объясняю свое мышление ниже.

Я не вижу никаких причин для того, чтобы не позволять стеку расти до тех пор, пока не будет исчерпана вся доступная память. Это означало бы, что бесконечная рекурсия займет больше времени, чтобы заметить, но это не значит, что мы уже не можем писать программы, которые потребляют бесконечный объем памяти. Я знаю, что можно уменьшить использование стека до O (1), используя продолжения и хвостовую рекурсию, но я не особенно понимаю, как мне полезно делать это все время. Я также не понимаю, как это помогает знать, когда функции, вероятно, потребуется обработать «большой» ввод (ну, по стандартам 8-разрядного микроконтроллера).

Я думаю, что это принципиально отличается от необходимости, например, использовать накапливающиеся параметры, чтобы избежать квадратичного поведения во времени. Хотя это тоже связано с беспокойством о деталях реализации и не обязательно должно выполняться для «небольших» входных данных, оно также сильно отличается тем, что компилятор не может тривиально устранить проблему самостоятельно. Кроме того, он отличается тем, что слегка сложный O (n) код, который был бы O (n ^ 2), если бы был написан наивно, значительно более полезен, чем простая, медленная, удобная для чтения версия. Напротив, код в стиле продолжения имеет точно такую же сложность памяти, что и соответствующая наивная версия, но просто использует другой тип памяти. Это та вещь, о которой компилятор не должен заставлять меня беспокоиться в наши дни?

Хотя я бы «предпочел» теоретическую причину, по которой невозможно иметь глубокий стек, мы могли бы также обсудить практические аспекты. Мне кажется, что стек — это несколько более эффективный способ управления памятью, чем куча, поскольку он не требует сборки мусора и легко освобождается? Я не уверен, что даже вижу, что есть затраты на разрешение глубоких стеков. По общему признанию, ОС должна выделить достаточно виртуального пространства, чтобы вместить всю память, которую вы, возможно, захотите использовать сразу во всей программе для стека каждого потока. Ну и что. Это не значит, что мы, вероятно, исчерпаем распространенный в настоящее время 48-разрядный лимит, сделав это, или что производители оборудования не могут тривиально увеличить этот лимит до 64 бит?

Здесь не так много специфичного для F #. Я ожидаю, что такое же ограничение применяется в C #, и не вижу, что там это больше необходимо, хотя на практике это, очевидно, намного меньше проблем при программировании в императивном стиле.

Большое спасибо за любые ответы / комментарии.

РЕДАКТИРОВАТЬ: я написал краткое изложение ответов ниже.

Комментарии:

1. Возможно, стоит упомянуть, что это не уникально для F #; Python также имеет предел рекурсии (я думаю, предел глубины 1000 или 10000).

2. В Python ограничение составляет около 1000 (не 10000). Тем не менее, мы прекрасно ладим. Зачем требовать O (n) места в стеке, когда вы можете потреблять O (1) памяти? Не могли бы вы показать пример, который требует сотен рекурсивных вызовов и не может быть легко переписан в цикл?

3. Не могли бы вы предоставить ссылку на F #, ограничивающую глубину рекурсии до 10000? Насколько я понимаю, среда CLR не имела фиксированного максимального размера стека, и вместо этого максимальный размер стека зависел от объема доступной памяти. Компилятор F # также переписывает многие формы рекурсии, чтобы не использовать пространство стека.

4. @WesleyWiser, при нормальных обстоятельствах стек каждого . Чистый поток ограничен 1 МБ.

5. Делнан: Я не могу. Весь код можно переписать, чтобы использовать O (1) пространство стека. Я хочу сказать, что i) тогда становится сложнее читать и поддерживать, и ii) преобразование (или должно быть) — это работа компилятора.

Ответ №1:

Безусловно, наиболее веской причиной для F # наследовать ограничения .NET в этом контексте является совместимость. Компиляторы могут и полностью устраняют стек, например, компилятор SML / NJ для стандартного ML автоматически преобразует программы в стиль передачи продолжения. Два основных недостатка заключаются в том, что это требует глобального изменения соглашения о вызовах, которое нарушает совместимость, и что оно существенно менее эффективно. Если бы F # сделал это, чтобы избежать переполнения стека, тогда взаимодействие с C # было бы намного сложнее, а F # было бы намного медленнее.

Еще одна причина, по которой глубокие стеки — плохая идея, — это сборщик мусора. Стеки обрабатываются GCs специально, потому что они гарантированно являются локальными для потока и могут сжиматься без необходимости сбора. .NET GC обходит все стеки потоков всякий раз, когда какой-либо поток использует коллекцию gen0. Следовательно, наличие всего двух спящих потоков с глубокими стеками может привести к тому, что другой поток будет работать в 10 раз медленнее. Представьте, насколько хуже это было бы с гораздо более глубокими стеками. Это можно решить, изменив способ обработки стеков в GC, по сути, превратив их в кучи, но это значительно замедляет работу со стеком.

Комментарии:

1. Это очень хороший ответ, о котором я не думал. Большое спасибо. Однако я не понимаю, почему каждый стек не может иметь маркер с надписью «отсюда все ссылки относятся к объектам gen 1 / gen 2». Это, по-видимому, сохранит постоянную амортизированную стоимость обработки элементов стека даже при неограниченном стеке?

2. Вы можете передавать изменяемые значения в стек по ссылке, чтобы вы могли изменять указатели дальше по стеку, чтобы указывать на недавно выделенные объекты в gen0. Следовательно, необходимо выполнить немало дополнительной работы, чтобы поддерживать ваши маркеры в актуальном состоянии, и, следовательно, техника немного медленнее. Другие GC делают это, например, некоторые JVM. Однако у .NET GCS есть другие недостатки, которые я бы предпочел рассмотреть в первую очередь.

Ответ №2:

Теоретически возможно все. Вы могли бы написать компилятор, который использует кучу для управления тем, что традиционно называется «стеком».

На практике производительность (особенно для фундаментальных задач, таких как «вызовы функций») имеет значение. У нас есть полувековой опыт работы с оборудованием и операционной системой, адаптированной / оптимизированной для модели памяти с конечным стеком.

Это та вещь, о которой компилятор не должен заставлять меня беспокоиться в наши дни?

Meh. Сборка мусора — большая победа; управление всей вашей памятью — в основном ненужная рутина, и многие приложения могут пожертвовать некоторой производительностью для производительности программиста. Но я думаю, мало кто считает, что управление стеком / рекурсией человеком имеет огромное значение, даже на функциональном языке, поэтому ценность того, чтобы позволить программисту сорваться с крючка, здесь, ИМО, незначительна.

Обратите внимание, что в частности, в F # вы можете использовать рабочий процесс CPS, который преобразует значительную часть стека в кучу и / или хвостовые вызовы с относительно небольшим изменением стиля программирования / синтаксиса, если вы хотите пойти туда (см., Например, Здесь ).

Комментарии:

1. Верно, но i) неясно, есть ли какие-либо затраты на разрешение больших стеков на x86-64, а стоимость на других архитектурах кажется тривиальной (т. Е. Небольшой постоянный коэффициент, и то только в том случае, если вы используете большие стеки), и ii) Я почти уверен, что использование CPS вездеэто было бы очень важно с точки зрения ясности и удобства обслуживания, хотя бы потому, что это излишне налагает определенный порядок вычисления.

2. Большие стеки действительно имеют огромное значение, по крайней мере, для крупномасштабных параллельных серверных приложений. Вся «асинхронная» шумиха, которую вы слышите, в значительной степени связана с этим; основная причина дороговизны потоков заключается в том, что они занимают так много памяти, а причина, по которой потоки занимают так много памяти, заключается в том, что для них зарезервирован мегабайт стека (или чего-то еще). Чем больше памяти вы оставляете для стека, тем меньше потоков вы можете иметь перед запуском процесса / системы из памяти. (Тем не менее, для немасштабируемых или клиентских приложений, которым не требуется много потоков, конечно, просто сделайте огромный размер стека, и все в порядке.)

3. Интересно, правильно ли сказано о потоках, требующих 1 м стека. Существует большая разница между выделением виртуального пространства и физической памяти. Я думаю, что самое большее, что вам нужно потратить на X86-64, — это одна физическая страница 4k на стек?

4. @user1002059: «неясно, есть ли какие-либо затраты на разрешение больших стеков на x86-64, а затраты на других архитектурах кажутся тривиальными (т. Е. Небольшой постоянный коэффициент, и то только в том случае, если вы используете большие стеки)». Нет, большие стеки асимптотически снижают пропускную способность и задержку GC, если не будут приняты специальные меры, меры, которые снизили бы производительность на существенный постоянный коэффициент везде.

5. В 32-разрядном адресном пространстве есть конечный объем. ЧИСТЫЙ процесс. Каждый поток поглощает кусок этого адресного пространства немалого размера для своего стекового резерва. В результате со стеками обычного размера у вас может быть только около тысячи потоков в .NET, прежде чем вам не хватит места для обращения к стекам потоков. Это было основным узким местом для создания простых крупномасштабных серверных приложений. Чем больше размер вашего стека, тем меньше потоков вы можете использовать в процессе. (Именно поэтому неблокирующая асинхронность так важна для серверных приложений: больше работы процессора с меньшим количеством потоков.)

Ответ №3:

Вы можете легко избежать этого ограничения: просто запустите новый поток со стеком такого размера, какой вы хотите, используя перегрузку конструктора for Thread .

Комментарии:

1. Большое спасибо. Единственная ложка дегтя в бочке меда заключается в том, что конструктор принимает значение int , поэтому существует ограничение 2G / 4G. Код будет выполняться на сервере 96G, поэтому мне бы очень хотелось ограничить этот размер 🙂

2. @user1002059 : Это фундаментально . СЕТЕВОЕ ограничение; его невозможно обойти с помощью управляемого кода, F # или другого.

3. @user1002059 Если вы когда-либо пробовали работать с большими стеками, вы заметите, что весь ваш процесс становится медленнее. В ответах не упоминалось, что стеки не должны превышать аппаратные ограничения для кэшей процессора. это устраняет предсказуемость. Это легко показать, запустив тесты производительности с резко увеличенными размерами стека (попробуйте, например, 100 мб). Это не только ограничение на поведение .net, но и налагается аппаратным обеспечением и ограничениями на алгоритмы при проектировании кэша процессора.

Ответ №4:

Я могу придумать как минимум две возможные причины:

(1) На многих компьютерных архитектурах трудно увеличить размер, доступный для стека во время выполнения, не перемещая его куда-нибудь еще в адресном пространстве. Как указал @svick в комментариях, .NET в 32-разрядной Windows ограничивает размер стека основного потока до 1 МБ. Изменение размера стека основного потока в Windows требует изменения .EXE-файл.

(2) Гораздо чаще переполнение стека вызвано ошибкой программирования, чем фактическим наличием кода, который действительно должен превышать доступный размер стека. Ограниченный размер стека — очень полезный индикатор для обнаружения ошибок программирования. В 98% случаев, если бы стеку было разрешено расти до доступной памяти, вы бы впервые узнали о своих ошибках программирования, когда исчерпали доступную память.

Комментарии:

1. (1) Я не уверен, что это актуально. Перемещение стека в другое место необходимо только в том случае, если вы не выделили достаточно виртуального пространства для начала, и в нем нет недостатка на 64-разрядных архитектурах. Даже тогда для его перемещения не потребуется физическая копия, достаточно просто переназначить виртуальную память в другое место. И даже при O (n) копировании операции стека все равно будут занимать O (1) амортизированного времени. В любом случае, все это относится к стеку процессора. DotNET использует IL, который может легко хранить свой стек в структуре данных, которая легко растет (возможно, что-то вроде C deque).

2. (2) Это не мой опыт. В любом случае, нижний предел является источником сбоев во время выполнения, которые трудно предсказать и которые запускаются просто пользователем, вводящим немного больший ввод, чем был протестирован код. Кроме того, я подозреваю, что он улавливает только ошибки, которые были бы обнаружены в любом случае, за исключением использования немного большего объема памяти.

3. @user1002059: «DotNET использует IL, который может легко хранить свой стек в структуре данных, которая легко растет (возможно, что-то вроде C deque)». Эти структуры данных существенно (например, на 50%) медленнее, чем «сырой» стек.

4. Джон Харроп: Да, но это имело бы значение только для программ, которые в противном случае зависли бы? Я действительно не мог заботиться о потере 50% производительности во время выполнения, если это означало, что i) не нужно везде вставлять продолжения и ii) не нужно беспокоиться, что я где-то пропустил (что удивительно легко сделать).

Ответ №5:

Я думаю, что теперь на этот вопрос было столько ответов, сколько он получит. Вот краткое изложение:

i) никто не предложил какой-либо фундаментальной причины для ограничения стека ниже общего объема доступной памяти

ii) ответ, который я узнал больше всего, был ответом Брайана (большое спасибо). Я бы настоятельно рекомендовал сообщение в блоге, на которое он ссылался, и остальную часть его блога тоже. Я нашел это более информативным, чем любая из двух хороших книг по F #, которые у меня есть. (Сказав это, вам, вероятно, следует взглянуть на то, что он говорит о том, насколько просто добиться хвостовой рекурсии в части 6 сообщений в блоге о катаморфизмах https://lorgonblog.wordpress.com/2008/06/02/catamorphisms-part-six / прежде чем принять слово «маргинальный», он употребил его за чистую монету 🙂 ).

РЕДАКТИРОВАТЬ: Джон Харроп был очень близким вторым. Большое спасибо.

iii) Свик предложил простой способ увеличения предела на три порядка. Большое спасибо.

iv) Делнан предположил, что наилучшей практикой является просто использовать сгибы / карты везде и определять их хвостовым рекурсивным способом. Это, безусловно, разумный совет для списков, но, возможно, менее применим при обходе графиков. В любом случае, большое спасибо за предложение.

v) Джоэл и Брайан предложили несколько практических причин, почему ограничение является хорошей идеей. Все это были низкоуровневые детали, которые, по моему мнению, должны быть хорошо скрыты языком высокого уровня. Большое спасибо.

Ответ №6:

В большинстве случаев стек не будет проблемой, если вы напишете свои функции как хвостовые рекурсивные