yakov.codes

Calling Haskell from Flutter for fun (and some profit)

A pilot micro-framework for calling Haskell from Dart over FFI, where the same C ABI is written exactly once via a tiny Template Haskell splice and a Dart codegen step that reads an embedded manifest. With a worked Flutter example and benchmarks.

Contents


Open source TL;DR #

Why? #

There's a Dart package on pub.dev called pixer. It's a Flutter image manipulation library, and the interesting part isn't the API. It's that the implementation is Rust, called from Dart through FFI, with a build hook that stages prebuilt binaries during the Flutter build. The Dart developer writes nothing about Rust. They just import and call.

A nice pattern. The question that has been bugging me is whether we can do the same thing with Haskell. Not "is it possible to call Haskell from Dart", which is trivially yes (foreign export ccall on one side, @Native on the other, you're done), but "can we make it pleasant enough that the user just writes Haskell functions and they show up in Dart, without spelling out the same C ABI three times by hand".

That's what hsdart is. A pilot micro-framework, a worked Flutter example, and a benchmark that says the answer is "kinda yes".

hsdart demo

What you write #

The whole Haskell side of the example is this:

{-# LANGUAGE TemplateHaskell #-}
module Core where

import HsDart
import Data.Text (Text)
import qualified Data.Text as T

square :: Int -> Int
square x = x * x

greet :: Text -> Text
greet name = "Hello, " <> name <> "!"

shout :: Text -> Text
shout = T.toUpper

sumPrimesBelow :: Int -> Int
sumPrimesBelow n = … -- sieve via STUArray Word8 + unsafeRead/Write

exportAll ['square, 'greet, 'shout, 'sumPrimesBelow]

That is it. One splice. No foreign export ccall, no CInt, no manual C wrapper. The TH macro reifies each name, picks a marshaling recipe based on the type, and emits the matching wrapper plus the foreign export. It also embeds a tiny JSON manifest of the API into the dylib as another foreign-exported function (hsdart_manifest :: IO CString), and that manifest is what the Dart side reads to generate its bindings.

The Dart side is just as boring:

import 'haskell_example.g.dart';

void main() {
  print(square(12));                  // 144
  print(greet('world'));              // Hello, world!
  print(shout('μετά'));               // ΜΕΤΆ
  print(sumPrimesBelow(100000));      // 454396537
}

haskell_example.g.dart is generated by dart run hsdart_dart:gen before Flutter compiles. Bindings are statically typed, the marshaling wrappers (UTF-8 in/out for strings, int↔CInt for ints) are written once in the codegen, and hs_init is hidden inside a lazy _ensureRts() helper that fires on the first call.

What actually happens #

Four moving parts:

Haskell source  →  GHC + cabal  →  libhaskell-example.dylib
                                   (with embedded hsdart_manifest)
                                          │
                                          ↓
                              dart run hsdart_dart:gen
                              (dlopen, read manifest, emit Dart)
                                          │
                                          ↓
                              lib/haskell_example.g.dart
                                          │
                                          ↓
                                  flutter run / build
                                          │
                                          ↓
                            build hook stages dylib as a CodeAsset
                              → Flutter bundles it in the .app
  1. exportAll does the heavy lifting on the Haskell side. For each name it reifys the type, dispatches on (from, to) (currently Int and Text), and produces a wrapper hsdartFFI_<name>, a foreign export ccall <name> ..., and contributes the function's signature to a JSON blob that's bundled into the dylib as a foreign-exported function returning a freshly-malloc'd C string.
  2. Building is a core-flake-based Nix flake. The Haskell side is two cabal packages, hsdart-haskell (the TH framework, publishable to Hackage) and haskell-example (the user code, depending on it).
  3. dart run hsdart_dart:gen nix builds the foreign library, dlopens it from a regular Dart CLI, calls hsdart_manifest, parses the JSON, and writes lib/haskell_example.g.dart with one @Native declaration plus a marshaling wrapper per function. Plus the _ensureRts() helper around hs_init.
  4. The Flutter build hook is now two lines: hsdartBuild(args, flakeAttr: 'haskell-example', flakeDir: '../..'). The helper lives in the hsdart_dart package, nix builds the foreign library, stages it under the right SONAME and registers it as a CodeAsset so Flutter bundles it into the .app.

Codegen and the build hook are deliberately split. Flutter's hook protocol runs hooks after the Dart kernel snapshot is compiled — codegen from inside the hook would write the bindings, but the snapshot was already built against the previous version (or the stub on first run), so the regenerated bindings don't reach the running app. Codegen has to be a pre-build step.

How fast is it, actually #

The repo ships a benchmark package that compares Haskell-via-FFI to pure-Dart equivalents of the same four functions. It uses package:benchmark_harness with the standard 100 ms warmup and 2 s measurement window. The task runs the same logic twice, once with dart run (JIT) and once after dart compile exe (AOT), so we get four numbers per function. The foreign library is built with -O2 -funbox-strict-fields and the GHC runtime statically linked in (options: standalone).

Function iters Dart (JIT) Dart (AOT) Haskell+FFI (JIT) Haskell+FFI (AOT)
square(i) 100k 5.1 ns 4.8 ns 122.9 ns 117.9 ns
greet(name) 10k 36.5 ns 72.1 ns 889.0 ns 860.8 ns
shout(name) 10k 27.0 ns 24.5 ns 783.2 ns 731.9 ns
sumPrimesBelow(500k) 5 3.16 ms 2.69 ms 1.40 ms 1.40 ms

Three things jump out.

First, the two Haskell+FFI columns are essentially identical. The Dart-side call site is compiled by either the JIT or the AOT compiler, but in both cases it boils down to "marshal arg, do an indirect call into the native lib, marshal return". That cost is structural and doesn't care which mode you compiled the rest of your Dart in. Roughly ~120 ns for a numeric round trip and ~800 ns for a string round trip (which includes the four-step String → malloc'd UTF-8 → Haskell Text → malloc'd C string → Dart String → free dance).

Second, Dart AOT is not consistently faster than Dart JIT on hot microbenchmarks. For square and shout AOT shaves off a fraction, for greet JIT is twice as fast because it specialises the string-interpolation path harder, for the sieve they are within noise. The general rule "JIT wins steady-state, AOT wins startup and consistency" holds.

Third, and this is the actual lesson, the FFI overhead is fixed per call. ~120 ns of overhead is 24× the cost of a 5 ns multiplication, but only 0.009% of a 1.4 ms sieve. Spend that fee on a millisecond of real CPU work, like a Sieve of Eratosthenes (sumPrimesBelow does ~520k cache-friendly stores in the marking pass plus ~500k reads and adds in the summing pass), and Haskell beats Dart by ~2× in either compilation mode. Spend it on a multiply and you have paid 20× the cost of the work itself.

One catch on that last row: the sieve had to use STUArray Int Word8 with unsafeRead/unsafeWrite to beat Dart. With a Bool payload and bounds-checked reads, the Haskell version was 3.3 ms, barely tied. The Word8 array packs the sieve into a byte per entry, the unsafe variants skip a per-access conditional, and together they cut the time by roughly 60%. The full discussion is in BENCHMARKS.md.

The mental model is just that. Chunky work amortizes the FFI tax, trivial work cannot. Reach for Haskell-over-FFI when your function is measured in microseconds or milliseconds, not nanoseconds, and the choice of JIT vs AOT on the Dart side doesn't change the conclusion.

Credits #