Spec-Zone.ru › Haskell 9

15. Использование бэкенда GHC WebAssembly

15.1. Что означает WebAssembly «бэкенд»

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

Таким образом, термин GHC wasm бэкенд не имеет того же смысла, что и бэкенды unregisterised/LLVM/NCG. Он просто описывает поддержку GHC как кросс-компилятора, нацеленного на wasm, и более конкретно, на wasm32-wasi.

Сгенерированный wasm модуль использует несколько расширений, которые поддерживаются по умолчанию в последних версиях Chrome/Firefox/Safari/wasmtime. Wasm модуль использует WASI в качестве слоя вызовов системных функций, поэтому он поддерживается любым wasm движком, реализующим WASI (включая браузеры, которые могут предоставлять слой WASI через JavaScript).

15.2. Настройка бэкенда GHC wasm

Wasm бэкенд все еще находится на стадии технического превью и пока не включен в официальные bindists. Если вы используете x86_64-linux, вы можете следовать разделам «getting started» в ghc-wasm-meta, чтобы быстро настроить GHC wasm бэкенд с помощью ночных сборок.

Также возможно собрать GHC wasm бэкенд вручную, если ваша система — одна из {x86_64,aarch64}-{linux,darwin}. Обратитесь к файлу ghc-wasm-meta readme для подробных инструкций.

15.3. Использование GHC wasm бэкенда для компиляции и линковки кода

После настройки GHC wasm бэкенда вы можете использовать его для компиляции и линковки кода. Компиляторы следуют соглашению о кросс-компиляции, поэтому вам нужно вызвать wasm32-wasi-ghc, wasm32-wasi-ghc-pkg и wasm32-wasi-hsc2hs вместо ghc, ghc-pkg и hsc2hs.

Вы также можете использовать флаги --with-compiler=, --with-hc-pkg= и --with-hsc2hs для компиляции проектов cabal. Скрипт-обёртка wasm32-wasi-cabal, настроенный установщиком ghc-wasm-meta, делает это автоматически за вас, но использование флагов вручную также работает со стандартными установками cabal. Когда cabal собирает исполняемый компонент, этот компонент будет собран как wasm модуль, и вы можете использовать cabal list-bin exe:foo для поиска расположения wasm модуля в каталоге сборки.

15.4. Запуск результата GHC wasm бэкенда

После получения wasm модуля, вы можете запустить его с помощью специализированного wasm движка, такого как wasmtime, или внутри браузеров.

Для запуска с wasmtime, вы можете просто сделать следующее:

$ wasmtime run foo.wasm

Как и для обычных исполняемых файлов, вы можете передавать аргументы командной строки, а также опции RTS, при условии, что они построены с -rtsopts.

$ wasmtime run foo.wasm --bar +RTS --nonmoving-gc -RTS

Вы также можете подключить некоторые каталоги хост-системы:

$ wasmtime run --mapdir /::$PWD foo.wasm

При условии, что обеспечены возможности файловой системы, помимо операций ввода-вывода с файловой системой в коде Haskell, вы можете использовать функции RTS eventlog и профилирования, а затем просмотреть отчётные файлы:

$ wasmtime run --mapdir /::$PWD foo.wasm +RTS -hc -l -RTS

15.5. JavaScript FFI в бэкенде wasm

Бэкенд GHC wasm поддерживает функцию JavaScript FFI. Для проектов Haskell, предназначенных для выполнения в средах JavaScript, таких как браузеры или nodejs, JavaScript FFI позволяет:

  • Вызывать JavaScript из Haskell с помощью внешних импортов и наоборот — вызывать Haskell из JavaScript с помощью внешних экспортов.
  • Представлять значения JavaScript в качестве полноценных, управляемых сборкой мусора, значений Haskell в куче Haskell.
  • Ожидать асинхронных вычислений JavaScript в одном потоке Haskell без блокировки всей среды выполнения.
  • Не платить за JavaScript, если он не используется; по умолчанию инструменты по-прежнему генерируют автономные wasm32-wasi модули.

JavaScript FFI, как реализован в бэкенде GHC wasm, разработан проектом asterius и существенно вдохновлен GHCJS, предшественником бэкенда GHC для JavaScript. Несмотря на некоторые сходства, он всё же существенно отличается от реализации бэкенда GHC JavaScript. Остальная часть данного руководства является канонической ссылкой на JavaScript FFI бэкенда GHC wasm, который мы будем сокращать до JSFFI.

15.5.1. Маршализуемые типы и JSVal

JSFFI поддерживает все маршализуемые внешние типы, которые поддерживает C FFI:

  • Bool
  • Char
  • Int / Word
  • Int8 / Int16 / Int32 / Int64
  • Word8 / Word16 / Word32 / Word64
  • Ptr / FunPtr / StablePtr
  • Float / Double

Указанные выше типы и их newtype могут использоваться в качестве типов аргументов/результатов в JSFFI. Необходимо учитывать некоторые моменты:

  • Bool маршализуется в 0 / 1 вместо false / true в JavaScript. Это связано с особенностями реализации JSFFI, которая основана на C FFI и наследует некоторые его характеристики. В большинстве случаев это не должно вызвать проблем, так как неявное преобразование в boolean происходит при использовании в качестве булева значения. Также допустимо передавать JavaScript boolean в Haskell, поскольку он будет неявно преобразован в число.
  • Аналогично, Char маршализуется в 32-битное целое число, которое представляет его код Юникода. Не передавайте одиночный символ JavaScript string в качестве Char, так как неявное преобразование в число приведет к NaN! Если вам абсолютно необходимо использовать Char в качестве типа аргумента/результата JSFFI, вы должны сами обрабатывать Char как коды символов. Вероятно, вам нужно только маршализовать между Haskell String или Text и JavaScript string, для чего уже существуют функции преобразования.
  • 64-битные целочисленные типы маршализуются в JavaScript bigint. В JavaScript смешивание bigint и обычных чисел в арифметических операциях приводит к ошибкам типа, поэтому учитывайте это. Что касается Int / Word, они являются 32-битными, так как бэкенд GHC wasm основан на wasm32.
  • JSFFI не поддерживает немаршализуемые внешние типы, такие как Int#, ByteArray#, и т. д., даже когда UnliftedFFITypes включен.

В дополнение к указанным типам JSFFI поддерживает тип JSVal и его newtype в качестве типов аргументов/результатов. JSVal определен в GHC.Wasm.Prim в ghc-experimental, что представляет собой неявную ссылку на значение JavaScript.

JSVal — это полноценные значения Haskell в куче Haskell. Их можно получить через внешние импорты или внешние экспорты, сохранять в структурах данных Haskell и передавать между Haskell/JavaScript. Они управляются сборкой мусора GHC RTS:

  • Может существовать несколько JSVal , указывающих на одно и то же значение JavaScript. Пока хотя бы один JSVal активен в куче Haskell, соответствующее значение JavaScript будет активным в куче JavaScript.
  • Если JSVal , указывающие на значение JavaScript, больше не активны в куче Haskell, после сбора мусора в Haskell среда выполнения не хранит больше никаких ссылок на него, и среда выполнения JavaScript в конечном итоге соберет его мусор.

Помимо сбора мусора, GHC.Wasm.Prim также экспортирует freeJSVal :: JSVal -> IO (), что позволяет пользователю явно удалить ссылку на JavaScript из среды выполнения. Рекомендуется использовать freeJSVal , когда вы уверены в сроке жизни JSVal , особенно для временных JSVal. Это поможет уменьшить потребление памяти во время выполнения.

Обратите внимание, что freeJSVal не идемпотентен, и его безопасно вызывать только один раз или вообще не вызывать. После вызова любое последующее использование данного JSVal приводит к сбою среды выполнения.

15.5.2. Внешние импорты

Можно встроить фрагмент кода JavaScript в объявление внешнего импорта и вызвать этот фрагмент кода JavaScript, вызвав функцию внешнего импорта:

import GHC.Wasm.Prim

foreign import javascript unsafe "console.log($1)"
  js_print :: JSString -> IO ()

foreign import javascript unsafe "typeof $1 === 'object'"
  js_is_obj :: JSVal -> Bool

foreign import javascript unsafe "let acc = 1; for (let i = 1; i <= $1; ++i) acc *= i; return acc;"
  js_fac :: Word -> Word

Фрагмент кода импорта JSFFI может быть либо единственным JavaScript-выражением, либо набором JavaScript-операторов в качестве тела функции, в этом случае вы можете использовать return для возврата значения результата импорта. Фрагмент кода импорта имеет доступ к:

  • Значениям аргументов импорта, привязанным к аргументам $1, $2, и т. д.
  • Связыванию __export, содержащему все экспорты модуля wasm. Например, вы можете использовать __exports.memory для доступа к объекту WebAssembly.Memory и использовать его для копирования блоков между сторонами Haskell/JavaScript. Экспорт memory существует по умолчанию.
  • Полному API веб-технологий, существующему в глобальной области видимости JavaScript.

Существует два типа импортов JSFFI: синхронные/асинхронные импорты. unsafe обозначает синхронные импорты, которые имеют следующие особенности:

  • Вызывающий поток, а также вся среда выполнения блокируются при ожидании результата импорта.
  • Если код JavaScript вызывает исключение, среда выполнения терпит крах с той же ошибкой. Исключение JavaScript нельзя обработать как исключение Haskell, поэтому вам нужно явно использовать JavaScript catch при необходимости.
  • Как и при импорте C unsafe, повторное вхождение не поддерживается; импортированный внешний код не должен вызывать Haskell повторно. В противном случае среда выполнения прервётся.

Когда импорт JSFFI помечен как safe / interruptible или не имеет аннотации безопасности, он обрабатывается как асинхронный импорт. Асинхронные импорты JSFFI комбинируют модель конкурентности Haskell и цикл событий JavaScript, позволяя коду Haskell работать с асинхронными вычислениями JavaScript без блокировки всей среды выполнения.

import Control.Exception

foreign import javascript safe "new Promise(res => setTimeout(res, $1))"
  js_sleep :: Int -> IO ()

sleep :: Int -> IO ()
sleep t = evaluate =<< js_sleep t

foreign import javascript safe "const r = await fetch($1); return r.text();"
  js_fetch :: JSString -> IO JSString

Асинхронный код импорта заключен в асинхронные функции JavaScript, поэтому await также поддерживается. Асинхронные JavaScript-функции всегда возвращают Promise , и вы также можете явно создать и вернуть Promise , который решает конечный результат асинхронных вычислений.

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

По сравнению с синхронными импортами JSFFI, асинхронные импорты JSFFI имеют следующие преимущества и недостатки:

  • Ожидание результата блокирует только один поток Haskell; другие потоки могут продолжать работу, и сборка мусора может выполняться.
  • Если Promise отклоняется, код Haskell может перехватывать JavaScript-ошибки в качестве JSException.
  • Поддерживается повторное вхождение. Код JavaScript может повторно вызывать Haskell и наоборот.
  • Конечно, он имеет более высокую стоимость, чем синхронные импорты JSFFI.

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

Также существует особый тип импорта JSFFI, который позволяет преобразовывать вызываемую JSVal в функцию Haskell:

type Logger = JSString -> IO ()

type JSFunction = JSVal

foreign import javascript unsafe "s => console.log(s)"
  js_logger :: JSFunction

foreign import javascript unsafe "dynamic"
  js_logger_to_hs :: JSFunction -> Logger

Подобно foreign import ccall "dynamic", которая оборачивает указатель на C-функцию в функцию Haskell, foreign import javascript "dynamic" оборачивает JSVal, представляющую JavaScript-функцию, в функцию Haskell. Возвращаемая Haskell-функция сохраняет ссылку на эту JSVal, а аннотация unsafe / safe указывает, является ли эта JavaScript-функция синхронной или асинхронной.

Конечно, без foreign import javascript "dynamic", можно было бы легко реализовать аналогичную функциональность:

foreign import javascript unsafe "$1($2)"
  js_logger_to_hs :: JSFunction -> JSString -> IO ()

И именно так это реализовано внутри. Это обрабатывается как импорт JSFFI с автоматически сгенерированным фрагментом кода, который вызывает первый аргумент, передавая остальные аргументы.

15.5.3. Внешние экспорт

Можно использовать foreign export javascript для экспорта связывания Haskell верхнего уровня в качестве экспорта модуля wasm, который можно вызвать в JavaScript:

foreign export javascript "my_fib"
  fib :: Word -> Word

При fib :: Word -> Word, вышеуказанное объявление экспортирует fib в качестве my_fib. Это функция экспорта модуля wasm без какого-либо обертки JavaScript, и, пока экземпляр wasm правильно инициализирован, можно вызвать await instance.exports.my_fib(10) для вызова экспортированной функции Haskell и получения результата.

В отличие от импортов JSFFI, которые имеют синхронные/асинхронные варианты, экспорты JSFFI всегда асинхронны. Вызов их всегда возвращает Promise в JavaScript, который необходимо await для получения реального результата. Если функция Haskell вызывает исключение, Promise отклоняется с WebAssembly.RuntimeError, и поле message содержит строку JavaScript исключения Haskell.

Выше представлен статический вариант экспортов JSFFI. Также можно экспортировать динамически созданную замыкание функции Haskell в качестве функции JavaScript и получить ее JSVal:

type BinOp a = a -> a -> a

foreign import javascript "wrapper"
  js_func_from_hs :: BinOp Int -> IO JSVal

Это также очень похоже на foreign import ccall "wrapper", которая оборачивает замыкание функции Haskell в указатель функции C. Обратите внимание, что аннотация unsafe / safe здесь игнорируется, так как JSVal, представляющие экспортированную функцию, всегда возвращаются синхронно, но это всегда асинхронная функция JavaScript, как и статические экспорты JSFFI.

Обратные вызовы JSVal созданные динамическими экспортами JSFFI, могут быть переданы в остальной мир JavaScript для последующего вызова. Но подождите, разве мы не говорили ранее, что JSVal собираются сборщиком мусора? Не подстерегает ли ловушка использования после освобождения, когда JSVal собирается в Haskell, но обратный вызов JavaScript вызывается позже?

Итак, обычные JSVal созданные результатами импорта JSFFI или аргументами экспорта JSFFI, управляют только одним типом ресурса: значением JavaScript, на которое они ссылаются. Но JSVal созданные динамическими экспортами JSFFI, управляют двумя типами ресурсов: обратным вызовом JavaScript, а также стабильным указателем, который сохраняет замыкание функции Haskell. Если этот JSVal собран сборщиком мусора, среда выполнения Haskell больше не сохраняет обратный вызов JavaScript, но сторона JavaScript все еще может удерживать этот обратный вызов и планирует вызвать его позже, поэтому замыкание функции Haskell по умолчанию все еще сохраняется.

Тем не менее, среда выполнения может постепенно отменять эти удержания, используя FinalizerRegistry для вызова финализаторов, чтобы освободить основанные указатели, как только обратные вызовы JavaScript будут переработаны.

Еще один крайний случай - циклическая ссылка между двумя кучами: если обратный вызов JavaScript удерживается только JSVal и этот JSVal удерживается только замыканием функции Haskell, которое экспортируется, это создает циклическую ссылку, которую нельзя автоматически переработать. Это фундаментальное ограничение бекенда GHC wasm на сегодня, так как куча Haskell существует в линейной памяти, отличной от кучи JavaScript хоста, и координация между двумя кучами всегда представляет собой непростую задачу. Однако, можно использовать freeJSVal для разрыва цикла. Когда freeJSVal применяется к JSVal, представляющему обратный вызов JavaScript, созданный динамическим экспортом JSFFI, оба типа ресурсов освобождаются одновременно: обратный вызов JavaScript и замыкание функции Haskell.

15.5.4. Определение, используется ли JSFFI

Если вы пишете библиотеку Haskell, вам может потребоваться определить, включает ли окончательный связанный модуль логику JSFFI или это по-прежнему самодостаточный wasm32-wasi модуль. Очевидная причина в том, что wasi реализации в средах JavaScript часто неполные и лишены определенных функций (например, poll_oneoff вызов), поэтому имеет смысл распределять код.

GHC.Wasm.Prim экспортирует isJSFFIUsed :: Bool, которые можно использовать для этой цели. Пока в окончательный модуль wasm связан хотя бы один импорт/экспорт JSFFI или что-то, что включает JSVal, он будет True. Это False только в том случае, если пользовательский код совершенно не имеет транзитивных зависимостей от чего-либо, связанного с JSFFI, в этом случае связанный модуль wasm будет самодостаточным wasm32-wasi модулем. Если что-то компилируется нормально с помощью бекенда GHC wasm до слияния функции JSFFI, но isJSFFIUsed все еще True, это определенно ошибка.

15.5.5. Взаимодействие с асинхронными исключениями

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

  • Асинхронное исключение не отменит Promise. В целом, асинхронные вызовы JavaScript не отменяемы. Существуют сторонние библиотеки Promise, которые обеспечивают интерфейс «отмена», который гарантирует, что все .then() продолжения, зарегистрированные в этом Promise, больше не будут вызваны. Для простоты реализации мы их пока не используем.
  • Обычно throwTo будет заблокирован до момента доставки асинхронного исключения. В случае JSFFI, throwTo всегда возвращается успешно сразу, в то время как целевой поток по-прежнему остается в приостановленном состоянии. Целевой поток будет разбужен только тогда, когда Promise фактически разрешится или отклонится, хотя результат Promise будет отброшен в этот момент.

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

15.5.6. Взаимодействие с C FFI

Пользовательский код может использовать JSFFI и C FFI вместе и использовать сторонний код C/C++, пока они работают с wasm32-wasi. Однако есть важное ограничение, которое следует учитывать при взаимодействии между JSFFI и C FFI:

Поток Haskell не может принудительно вызвать асинхронную функцию JSFFI импорта, когда она представляет функцию Haskell, экспортированную через C FFI. Это приведет к WouldBlockException.

Например, предположим, что мы используем связывание Haskell определенной библиотеки C, и некоторые функции C ожидают, что вызывающие стороны передадут указатель функции C в качестве аргумента обратного вызова. Да, мы можем использовать foreign import ccall "wrapper" для обертки замыкания функции Haskell и передачи его в эту функцию C. Обёрнутая функция Haskell может даже вызывать синхронные импорты JSFFI, но не может вызывать асинхронный импорт JSFFI и блокироваться на результате.

Другое направление работает нормально. Независимо от того, является ли импорт C unsafe или safe, его можно вызывать в потоке Haskell, представляющем функцию Haskell, экспортированную в JavaScript. Помните, что в данный момент используется однопоточная среда выполнения, поэтому помимо поддержки повторного входа, safe вызовы C не предлагают преимуществ по сравнению с unsafe.

Как упоминалось ранее, JSFFI накладывается поверх C FFI в подкапотной реализации, и они используют одно и то же пространство имен C. В большинстве случаев JSFFI связанные символы генерируются автоматически, поэтому конфликтов можно избежать, но для каждого foreign export javascript "my_func", будет внешний видимый символ C, поэтому вам нужно быть осторожным, чтобы не дублировать символы с C-стороны.

15.5.7. Интерфейс JavaScript

При подключении модуля wasm, использующего JSFFI, необходимо передать корректные аргументы времени компоновки в GHC, и это требует настройки на уровне каждого проекта:

ghc -no-hs-main -optl-mexec-model=reactor -optl-Wl,--export=my_func

Почему?

Рассмотрим случай ghc hello.hs , где hello.hs — обычная main = putStrLn "hello world". По умолчанию ghc экспортирует main как функцию C, компилирует и подключает небольшой файл-заглушку C, содержащий фактическую main для инициализации среды выполнения и вызова Haskell main, и clang бы подключал всё как модуль команды wasm32-wasi.

По концепции, модуль команды wasm32-wasi подобен исполняемому файлу с единственной точкой входа, предназначенному для вызова только один раз и затем завершения. Но определённые аргументы времени компоновки могут настроить clang на целевое использование модуля-реактора wasm32-wasi вместо этого. Модуль-реактор похож на общую библиотеку: он содержит внутренние функции и пользовательские точки входа, и после инициализации точки входа можно вызывать любое количество раз.

Учитывая природу JSFFI, если проект использует JSFFI, то он, безусловно, предназначен для целевого использования модуля-реактора wasm32-wasi. И нет разумной точки входа по умолчанию, даже main, вам необходимо явно передать необходимые имена экспорта через аргументы времени компоновки, иначе эти экспорты будут отсутствовать в результирующем модуле wasm из-за удаления неиспользуемого кода линковщиком.

Теперь предположим, что модуль wasm уже подключен и использует JSFFI. Этот модуль wasm будет содержать ghc_wasm_jsffi пользовательских секций, а данные секций содержат информацию, такую как фрагменты кода импорта JSFFI и арности функций. Следующим шагом является вызов небольшой скрипта после компоновки, который проанализирует модуль wasm и сгенерирует модуль JavaScript. Скрипт после компоновки post-linker написан на nodejs, хотя результирующий модуль JavaScript не имеет ничего специфичного для nodejs и работает и в браузерах.

$ $(wasm32-wasi-ghc --print-libdir)/post-link.mjs -i hello.wasm -o hello.js

Сгенерированный модуль JavaScript содержит экспорт по умолчанию, который является функцией. Функция принимает аргумент __exports и генерирует импорты wasm ghc_wasm_jsffi. Здесь происходит связывание: только после того, как модуль wasm инициализирован, вы можете получить доступ к его экспорту, но импортам ghc_wasm_jsffi для инициализации требуется доступ к экспорту! JavaScript не является ленивым языком, но мы можем достичь связывания менее изящно, используя мутацию:

let __exports = {};

const { instance } = await WebAssembly.instantiateStreaming(
  fetch(wasm_url),
  {
    ghc_wasm_jsffi: (await import(js_url)).default(__exports),
    wasi_snapshot_preview1: ...
  }
);

Object.assign(__exports, wasm_instance.exports);

Таким образом, импортам ghc_wasm_jsffi будет доступен весь экспорт экземпляра wasm.

После создания экземпляра wasm необходимо выполнить инициализацию:

wasi.initialize(wasm_instance);

Интерфейс ABI модуля-реактора wasm32-wasi определяет функцию экспорта _initialize, которая автоматически генерируется во время компоновки и должна быть вызвана ровно один раз перед вызовом любых других экспортов wasm. Правильный способ её вызова зависит от поставщика реализации wasi в JavaScript.

Наконец, в JavaScript вы можете использовать await __exports.my_func() для вызова экспортированной my_func функции, получения результата, передачи аргументов, обработки ошибок и т.д.

© 2002–2007 The University Court of the University of Glasgow. All rights reserved.
Licensed under the Glasgow Haskell Compiler License.
https://downloads.haskell.org/~ghc/9.12.1/docs/users_guide/wasm.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API