it-swarm.com.ru

Почему Haskell (GHC) так чертовски быстр?

Haskell (с компилятором GHC) - это намного быстрее, чем вы ожидаете . При правильном использовании он может приблизиться к языкам низкого уровня. (Любимая вещь для Haskellers - попытаться получить в пределах 5% от C (или даже побить его, но это означает, что вы используете неэффективную программу на C, поскольку GHC компилирует Haskell в C).) Мой вопрос: почему?

Хаскель декларативен и основан на лямбда-исчислении. Архитектура машин явно обязательна, грубо говоря, на основе машин Тьюринга. Действительно, у Haskell даже нет определенного порядка оценки. Кроме того, вместо того, чтобы иметь дело с типами машинных данных, вы постоянно создаете алгебраические типы данных.

Самое странное, что это функции высшего порядка. Вы могли бы подумать, что создание функций на лету и их использование приведет к замедлению работы программы. Но использование функций более высокого порядка на самом деле делает Haskell быстрее. Действительно, кажется, что для оптимизации кода на Haskell вам нужно сделать его более элегантным и абстрактным, а не более машиноподобным. Кажется, что ни одна из более продвинутых функций Haskell даже не влияет на его производительность, если они не улучшают ее.

Извините, если это звучит бессмысленно, но вот мой вопрос: Почему Haskell (скомпилирован с GHC) так быстр, учитывая его абстрактную природу и отличия от физических машин?

Примечание. Причина, по которой я говорю, что C и другие императивные языки чем-то похожи на машины Тьюринга (но не в той степени, в которой Haskell похож на лямбда-исчисление) состоит в том, что в императивном языке у вас есть конечное число состояний (или номер строки) вместе с магнитной лентой (тараном), так что состояние и текущая лента определяют, что делать с лентой. Смотрите статью в Википедии Эквиваленты машин Тьюринга , чтобы перейти от машин Тьюринга к компьютерам.

200
PyRulez

Я думаю, что это немного основано на мнении. Но я постараюсь ответить.

Я согласен с Дитрихом Эппом: это сочетание нескольких вещей , которые делают GHC быстрым.

В первую очередь, Haskell очень высокого уровня. Это позволяет компилятору выполнять агрессивную оптимизацию , не нарушая ваш код.

Подумайте о SQL. Теперь, когда я пишу оператор SELECT, он может выглядеть как императивный цикл, , но это не так . Может выглядеть так , что циклически перебирает все строки в этой таблице, пытаясь найти ту, которая соответствует заданным условиям, но на самом деле "компилятор" (механизм БД) может вместо этого выполнять поиск индекса - который имеет совершенно другие характеристики производительности. Но поскольку SQL является настолько высокоуровневым, "компилятор" может заменить совершенно разные алгоритмы, применить несколько процессоров или каналов ввода-вывода или целых серверов , и больше.

Я думаю о Хаскеле как о том же самом. Вы можете подумать , что вы только что попросили Haskell сопоставить входной список со вторым списком, отфильтровать второй список в третий список, а затем посчитать, сколько элементов привели. Но вы не видели, чтобы GHC применял правила перезаписи потокового слияния за кулисами, превращая все это в один жесткий цикл машинного кода, который выполняет всю работу за один проход по данным без выделения ресурсов - то, что могло бы быть утомительным, подверженным ошибкам и не подлежащим ремонту, чтобы писать от руки. Это действительно возможно только из-за отсутствия низкоуровневых деталей в коде.

Другой способ взглянуть на это может быть ... почему не должно Haskell быть быстрым? Что он делает, что должно сделать это медленно?

Это не интерпретируемый язык, как Perl или JavaScript. Это даже не система виртуальных машин, такая как Java или C #. Он компилируется вплоть до машинного кода, поэтому никаких накладных расходов нет.

В отличие от OO языков [Java, C #, JavaScript…], Haskell имеет полное стирание типа [например, C, C++, Pascal…]. Вся проверка типов происходит только во время компиляции. Так что нет никакой проверки типов во время выполнения, чтобы замедлить вас либо. (Никаких проверок нулевого указателя, в этом отношении. В Java, скажем, JVM должна проверять нулевые указатели и выдавать исключение, если вы уважаете один. Haskell не должен беспокоиться об этой проверке.)

Вы говорите, что звучит медленно "создавать функции на лету во время выполнения", но если вы посмотрите очень внимательно, вы на самом деле этого не делаете. Это может выглядеть , как вы, но вы не делаете. Если вы скажете (+5), это жестко запрограммировано в вашем исходном коде. Это не может измениться во время выполнения. Так что это не совсем динамическая функция. Даже функции с карри на самом деле просто сохраняют параметры в блок данных. Весь исполняемый код фактически существует во время компиляции; нет интерпретации во время выполнения. (В отличие от некоторых других языков, которые имеют функцию eval.)

Подумай о Паскале. Он старый, и никто его больше не использует, но никто не будет жаловаться, что Паскаль медленный . Есть много вещей, которые могут не понравиться, но медлительность на самом деле не одна из них. На самом деле, Haskell не сильно отличается от Pascal, за исключением сбора мусора, а не ручного управления памятью. А неизменяемые данные позволяют несколько оптимизаций движку GC [что ленивая оценка затем несколько усложняет].

Я думаю, дело в том, что Haskell выглядит продвинутым, сложным и высокоуровневым, и все думают: "Ого, это действительно мощно, это должно быть удивительно медленно! "Но это не так. Или, по крайней мере, это не так, как вы ожидаете. Да, у него есть удивительная система типов. Но вы знаете, что? Это все происходит во время компиляции. Во время выполнения это ушло. Да, это позволяет вам создавать сложные ADT с помощью строки кода. Но вы знаете, что? ADT - это просто обычное C union из structs. Ничего более.

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

Например, я написал небольшую простую программу, которая подсчитывает, сколько раз каждый байт появляется в файле. Для входного файла размером 25 КБ программе потребовалось 20 минут для запуска и проглотил 6 гигабайт ОЗУ! Это абсурд !! Но затем я понял, в чем проблема, добавил один паттерн взрыва, и время выполнения упало до ,02 секунды.

Это , где Haskell неожиданно медленно идет. И, конечно, нужно время, чтобы привыкнуть к этому. Но со временем становится проще писать действительно быстрый код.

Что делает Хаскелл так быстро? Чистота. Статические типы. Лень. Но прежде всего, будучи достаточно высокоуровневым, чтобы компилятор мог радикально изменить реализацию, не нарушая ожиданий вашего кода.

Но я думаю, что это только мое мнение ...

215
MathematicalOrchid

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

Вторая волна проектов появилась на основе сокращения графов и открыла возможность для гораздо более эффективной компиляции. Саймон Пейтон Джонс писал об этом исследовании в своих двух книгах Реализация языков функционального программирования и Реализация функциональных языков: учебное пособие (первый с разделами Уодлера и Хэнкока, а также последний написан с Дэвидом Лестером). (Леннарт Огюсссон также сообщил мне, что одним из ключевых мотивов для предыдущей книги было описание того, как его компилятор LML, который не был подробно прокомментирован, выполнил свою компиляцию).

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

Это описано в более поздней статье, в которой изложены основы того, как GHC все еще работает сегодня (хотя по модулю много различных настроек): "Реализация ленивых функциональных языков на стандартном оборудовании: G-машина без тегов без шпинделей". , Текущая модель выполнения для GHC более подробно описана в GHC Wiki .

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

Таким образом, получается, что, хотя архитектуры машин в определенном смысле являются императивными, языки могут отображаться на них очень удивительными способами, которые не похожи на обычное управление потоком в стиле C, и если мы думаем достаточно низкоуровнево, это также может быть эффективный.

Вдобавок к этому есть много других оптимизаций, в частности, связанных с чистотой, поскольку это позволяет расширить диапазон "безопасных" преобразований. Когда и как применять эти преобразования так, чтобы они делали вещи лучше, а не хуже, это, конечно, эмпирический вопрос, и в связи с этим и многими другими небольшими вариантами годы работы были вложены как в теоретическую работу, так и в практический сравнительный анализ. Так что это, конечно, тоже играет роль. Хорошим примером такого рода исследований является документ " Создание быстрого карри: толчок/ввод по сравнению с оценкой/применение для языков высшего порядка".

Наконец, следует отметить, что эта модель все еще вводит накладные расходы из-за косвенных указаний. Этого можно избежать в тех случаях, когда мы знаем, что "безопасно" строго выполнять действия и, таким образом, исключать косвенные зависимости графа. Механизмы, которые определяют строгость/требование, снова подробно описаны в GHC Wiki .

61
sclv

Ну, тут есть что комментировать. Я постараюсь ответить как можно больше.

При правильном использовании он может приблизиться к языкам низкого уровня.

По моему опыту, во многих случаях обычно можно в два раза увеличить производительность Rust. Но есть также некоторые (широкие) случаи использования, когда производительность низкая по сравнению с языками низкого уровня.

или даже побить его, но это означает, что вы используете неэффективную программу на C, поскольку GHC компилирует Haskell в C)

Это не совсем правильно. Haskell компилируется в C подмножество C), который затем компилируется с помощью генератора собственного кода в Assembly. Генератор собственного кода обычно генерирует более быстрый код, чем компилятор C, потому что он может применять некоторые оптимизации, которые не может сделать обычный компилятор C.

Архитектура машин явно обязательна, грубо говоря, на основе машин Тьюринга.

Это не очень хороший способ думать об этом, тем более что современные процессоры будут оценивать команды не по порядку и, возможно, одновременно.

Действительно, у Haskell даже нет определенного порядка оценки.

На самом деле, Haskell неявно определяет порядок оценки.

Кроме того, вместо того, чтобы иметь дело с типами машинных данных, вы постоянно создаете алгебраические типы данных.

Они соответствуют во многих случаях, если у вас достаточно продвинутый компилятор.

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

Haskell компилируется, и поэтому функции высшего порядка на самом деле не создаются на лету.

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

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

Например, f x = [x] и f = pure - это одно и то же в Haskell. Хороший компилятор не даст лучшей производительности в первом случае.

Почему Haskell (скомпилирован с GHC) так быстр, учитывая его абстрактную природу и отличия от физических машин?

Короткий ответ: "потому что он был разработан именно для этого". GHC использует бесхребетный G-станок без меток (STG). Вы можете прочитать статью об этом --- (здесь (это довольно сложно). GHC также делает много других вещей, таких как анализ строгости и оптимистичная оценка .

Причина, по которой я говорю, что C и другие императивные языки чем-то похожи на машины Тьюринга (но не в той степени, в которой Haskell похож на лямбда-исчисление) состоит в том, что в императивном языке у вас есть конечное число состояний (или номер строки), а также с лентой (плунжер), так что состояние и текущая лента определяют, что делать с лентой.

Является ли смысл путаницы, что изменчивость должна привести к замедлению кода? На самом деле лень Хаскелла означает, что изменчивость не так важна, как вы думаете, плюс она высокого уровня, поэтому компилятор может применить множество оптимизаций. Таким образом, изменение записи на месте редко будет медленнее, чем в языке, таком как C.

11
user8174234