nandi/vflutter_ffipublic Fork 0
4506a586d7400b00d732dcb97167be75ab41cab1
Commits
Clone
git clone https://git.rickub.com/nandi/vflutter_ffi.git
git clone ssh://git@rickub.com/nandi/vflutter_ffi.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Render in parallel on web, via a pool of Web Workers 4506a58 · on 4506a586d7400b00d732dcb97167be75ab41cab1 · nandithebull · 17h ago
README.md · 237 lines · 10.3 KBmarkdown
Blame HistoryOpen raw

nimflutter_ffi

Write application logic in Nim, call it from Flutter over dart:ffi.

Built on Flutter's plugin_ffi template. No platform channels, no method-channel
serialisation — Dart calls Nim through the C ABI directly.

How it works

Nim compiles to C. That is the whole trick:

src/nimflutter.nim  --[ nim c --compileOnly ]-->  C  --[ NDK / clang / MSVC ]-->  libnimflutter.so

Nim never cross-compiles for the target itself. It only emits C, and the
platform's own toolchain owns the ABI, sysroot and flags. That is why Android
arm64, iOS arm64, Linux, macOS, Windows and wasm32 all work from one source
file.

One wrinkle versus a single-file emitter: Nim writes one .c per module plus
nimbase.h, and the set is only known after it runs. src/CMakeLists.txt
therefore runs Nim at configure time and globs the result, rather than
wiring an add_custom_command.

Platform Built by Artifact
Android NDK via android/build.gradlesrc/CMakeLists.txt libnimflutter.so per ABI
Linux / Windows Flutter's CMake → src/CMakeLists.txt shared library, auto-bundled
iOS / macOS CocoaPods compiles pre-generated C static archive in the app binary

iOS is the odd one out: Xcode's build sandbox can't run nim, and App Store
builds want a static archive. So tool/gen_ios_sources.sh generates the C ahead
of time into ios/Classes/, and that is checked in. Re-run it whenever
src/nimflutter.nim changes.

The two rules

Everything that goes wrong with this bridge goes wrong in one of two ways.

1. Only C types cross the boundary.
cint, cdouble, cstring, pointer. Never a Nim string, seq, ref or
object — those have Nim-specific layouts and lifetimes Dart cannot read.
Convert at the edge.

2. Memory that crosses the boundary is manually owned, and comes from the
shared heap.

The library is built --mm:arc. ARC frees Nim's own temporaries
deterministically at scope exit, so the leak-every-temporary problem does not
arise — but anything handed out to Dart has deliberately escaped that, and
the caller must return it:

proc nf_greet(name: cstring): cstring {.exportc: "nf_greet", dynlib, cdecl.} =
  let greeting = "Hello, " & $name & ", from Nim!"   # ARC frees this at exit
  # allocShared0, not alloc0: Dart may free this from a different isolate than
  # the one that allocated it, and Nim's default heap is thread-local.
  let buf = cast[cstring](allocShared0(greeting.len + 1))
  copyMem(buf, greeting.cstring, greeting.len)
  buf                                                # caller owns this

--mm:arc rather than the default ORC because a C-ABI surface has no cycles
to collect, and no reason to pay for a cycle detector.

Ownership across the boundary: Nim allocates, Dart copies, Nim frees. The
Dart wrapper in lib/nimflutter_ffi.dart does the nf_free in a finally, so
callers never hold a pointer and never leak. tool/../example/lib/memory_check.dart
holds RSS flat across 900k round trips.

Better still, for bulk data, don't allocate at all: let Dart own the buffer and
have Nim fill it in place. nf_mandelbrot works that way, which is why it
needs no nf_free — and why it takes a buf_len it validates in 64-bit before
writing a single byte.

Quick start

With just:

just mandelbrot   # build the Nim library and run the fractal explorer
just web          # serve the same app with Nim as WebAssembly (no browser opened)
just test         # analyze + the example's test suite
just bench        # Nim vs Dart timings for the same frame
just --list       # everything else

Flutter is expected at ~/flutter/bin and Nim at ~/nim/bin; set
FLUTTER_BIN or NIM_BIN if yours live elsewhere.

Usage

import 'package:nimflutter_ffi/nimflutter_ffi.dart' as v;

await v.initialize();           // required on web, a formality on native
v.add(20, 22);                  // 42
v.greet('Flutter');             // "Hello, Flutter, from Nim!"
await v.greetAsync('isolate');  // same, off the UI isolate

ARC has no stop-the-world phase, so calls are safe from any isolate — provided
anything handed across the boundary comes from the shared heap, as above. Use
Isolate.run for anything long enough to jank a frame.

For bulk data, let Dart own the buffer and have Nim fill it in place — then
nothing crosses the boundary that needs freeing, and independent slices can be
computed concurrently:

final view = v.FractalView(width: 800, height: 600, maxIter: 500);
final pixels = await v.renderParallel(view, tiles: 4);   // RGBA8888

Web

The same Nim source also runs in the browser. just web compiles it to
WebAssembly with emscripten and serves the example app at
http://localhost:8080 — it prints the URL and waits rather than launching a
browser, so open it yourself (WEB_PORT=9000 just web to move it). Hot
restart still works. dart:ffi does not exist on web, so a second backend
drives the wasm module over dart:js_interop instead:

lib/src/backend_native.dart   dart:ffi
lib/src/backend_web.dart      dart:js_interop + emscripten
lib/nimflutter_ffi.dart         picks one with a conditional import

Nothing has to be installed for this. tool/bin/emcc is a
DotSlash file that pins emscripten by content
hash; the toolchain is fetched on first use and cached in ~/.cache/dotslash.
Only dotslash itself needs to be on PATH.

The two backends are held to being the same, not merely similar: just parity
renders a frame with each kernel and compares them byte for byte, and they
agree exactly — across different compilers, different libm implementations
(glibc vs musl) and different word sizes (64-bit vs wasm32). Speed is close
too, ~2-5% off native for the same frame:

800x600 maxIter 100:  68.3 ms native / 70.2 ms wasm
800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm

Web renders in parallel too, without SharedArrayBuffer or COOP/COEP
headers: instead of threading one module, the pool runs several instances of
it, one per Web Worker, each with its own linear memory. Nothing is shared, so
there is nothing to synchronise, and a module is only ~34 KB. Measured in
headless Chrome on 8 cores, 800x600 at 500 iterations:

main thread, one band   345 ms
1 worker                164 ms
32 bands over 8 workers  37 ms   (4.5x)

More bands than workers, for the same reason as native: band costs are wildly
uneven, so over-decomposing keeps a free worker busy rather than waiting on
the slowest one. just check-pool re-measures this and asserts the pooled
frame is byte-identical to a single-instance render — a pool that quietly
falls back to one thread, or assembles bands out of order, otherwise looks
just like a working one.

One difference remains, and it is surfaced in the API:

  • await v.initialize() before anything else. A wasm module cannot be
    instantiated synchronously. On native it resolves immediately.

One trap worth knowing about

Nim's vmemcpy silently skips any copy whose source or destination is
<= 0xFFFF — a null-pointer heuristic that is sound on 64-bit desktop, where
the low 64 KB is never mapped. On wasm32 emscripten packs static data down at
address ~1300, so every copy out of a string literal does nothing and Nim strings
come back as runs of zero bytes, with no crash and no diagnostic. It only
appears at -O1 and above, which makes it look like an optimiser bug.
tool/build_wasm.sh passes -sGLOBAL_BASE=1048576 to move static data, the
stack and the heap clear of that check.

Is Nim faster than Dart?

For the numeric kernel in the example: no, not meaningfully. Measured at
800x600 / 500 iterations on an 8-core Linux box, Nim through C takes ~152 ms
against Dart AOT's ~169 ms — about 1.1x, which is inside the range where the
answer depends on the loop rather than the language. Dart's AOT compiler is
good at scalar floating-point loops. (V, which this plugin used previously,
measured the same ~1.1x.)

What the bridge does buy you is the ability to write the logic in Nim — or
reuse Nim you already have — with a C ABI that is cheap to call, safe to call
concurrently, and that also compiles to wasm for the web build. Pick it for the
language, not for an expected speedup. And build with optimisation on: at
-O0, or without -d:danger, the same kernel is several times slower, which
is what tool/build.sh and src/CMakeLists.txt guard against.

The parallel figures in the example come from splitting the frame across
isolates, not from the language — see just sweep, which shows the best band
count is well above the core count because the bands cost wildly different
amounts.

Status

Verified on Linux with Nim 2.2.0, Dart 3 and Flutter 3.47: host build, string
round-trip, isolate dispatch, 900k-call memory stability, and the example app
built and run as a release Linux binary (CMake -> v -> NDK/clang path
included).

The web path is verified end to end as well — wasm built with emscripten 6.0.9,
the module exercised headlessly under node, the kernel compared byte for byte
against the native build, and the release web app loaded in headless Chrome
with its rendered canvas checked for an actual fractal. just ci runs all of
it.

The Android/iOS/Windows glue is written to the standard plugin_ffi contract
but is not exercised here — it needs the respective toolchains.

Layout

src/nimflutter.v          the Nim source — the only file you normally edit
src/nimflutter.h          C declarations (ffigen input, Xcode input)
src/CMakeLists.txt      Nim -> C -> shared lib; shared by Android/Linux/Windows
lib/nimflutter_ffi.dart   the Dart API callers use
lib/src/backend_*.dart  the two backends: dart:ffi and wasm/js_interop
ios/, macos/            pre-generated C + podspec (static archive)
android/build.gradle    NDK build via externalNativeBuild
tool/build.sh           host build + export smoke test
tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the Nim source
tool/bin/emcc           DotSlash file pinning emscripten — nothing to install
tool/build_wasm.sh      Nim -> C -> wasm for the web build
tool/test_wasm.mjs      headless check of the wasm module (no browser needed)
tool/check_parity.sh    native vs wasm kernel, byte for byte
tool/check_web.sh       loads the built web app in headless Chrome
example/                Flutter app exercising the bridge (see example/README.md)
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# nimflutter_ffi

Write application logic in **Nim**, call it from **Flutter** over `dart:ffi`.

Built on Flutter's `plugin_ffi` template. No platform channels, no method-channel
serialisation — Dart calls Nim through the C ABI directly.

## How it works

Nim compiles to C. That is the whole trick:

```
src/nimflutter.nim  --[ nim c --compileOnly ]-->  C  --[ NDK / clang / MSVC ]-->  libnimflutter.so
```

Nim never cross-compiles for the target itself. It only emits C, and the
platform's own toolchain owns the ABI, sysroot and flags. That is why Android
arm64, iOS arm64, Linux, macOS, Windows and wasm32 all work from one source
file.

One wrinkle versus a single-file emitter: Nim writes one `.c` per module plus
`nimbase.h`, and the set is only known after it runs. `src/CMakeLists.txt`
therefore runs Nim at *configure* time and globs the result, rather than
wiring an `add_custom_command`.

| Platform | Built by | Artifact |
|---|---|---|
| Android | NDK via `android/build.gradle``src/CMakeLists.txt` | `libnimflutter.so` per ABI |
| Linux / Windows | Flutter's CMake → `src/CMakeLists.txt` | shared library, auto-bundled |
| iOS / macOS | CocoaPods compiles pre-generated C | static archive in the app binary |

iOS is the odd one out: Xcode's build sandbox can't run `nim`, and App Store
builds want a static archive. So `tool/gen_ios_sources.sh` generates the C ahead
of time into `ios/Classes/`, and that is checked in. **Re-run it whenever
`src/nimflutter.nim` changes.**

## The two rules

Everything that goes wrong with this bridge goes wrong in one of two ways.

**1. Only C types cross the boundary.**
`cint`, `cdouble`, `cstring`, `pointer`. Never a Nim `string`, `seq`, `ref` or
object — those have Nim-specific layouts and lifetimes Dart cannot read.
Convert at the edge.

**2. Memory that crosses the boundary is manually owned, and comes from the
shared heap.**

The library is built `--mm:arc`. ARC frees Nim's own temporaries
deterministically at scope exit, so the leak-every-temporary problem does not
arise — but anything handed *out* to Dart has deliberately escaped that, and
the caller must return it:

```nim
proc nf_greet(name: cstring): cstring {.exportc: "nf_greet", dynlib, cdecl.} =
  let greeting = "Hello, " & $name & ", from Nim!"   # ARC frees this at exit
  # allocShared0, not alloc0: Dart may free this from a different isolate than
  # the one that allocated it, and Nim's default heap is thread-local.
  let buf = cast[cstring](allocShared0(greeting.len + 1))
  copyMem(buf, greeting.cstring, greeting.len)
  buf                                                # caller owns this
```

`--mm:arc` rather than the default ORC because a C-ABI surface has no cycles
to collect, and no reason to pay for a cycle detector.

Ownership across the boundary: **Nim allocates, Dart copies, Nim frees.** The
Dart wrapper in `lib/nimflutter_ffi.dart` does the `nf_free` in a `finally`, so
callers never hold a pointer and never leak. `tool/../example/lib/memory_check.dart`
holds RSS flat across 900k round trips.

Better still, for bulk data, don't allocate at all: let Dart own the buffer and
have Nim fill it in place. `nf_mandelbrot` works that way, which is why it
needs no `nf_free` — and why it takes a `buf_len` it validates in 64-bit before
writing a single byte.

## Quick start

With [`just`](https://github.com/casey/just):

```
just mandelbrot   # build the Nim library and run the fractal explorer
just web          # serve the same app with Nim as WebAssembly (no browser opened)
just test         # analyze + the example's test suite
just bench        # Nim vs Dart timings for the same frame
just --list       # everything else
```

Flutter is expected at `~/flutter/bin` and Nim at `~/nim/bin`; set
`FLUTTER_BIN` or `NIM_BIN` if yours live elsewhere.

## Usage

```dart
import 'package:nimflutter_ffi/nimflutter_ffi.dart' as v;

await v.initialize();           // required on web, a formality on native
v.add(20, 22);                  // 42
v.greet('Flutter');             // "Hello, Flutter, from Nim!"
await v.greetAsync('isolate');  // same, off the UI isolate
```

ARC has no stop-the-world phase, so calls are safe from any isolate — provided
anything handed across the boundary comes from the shared heap, as above. Use
`Isolate.run` for anything long enough to jank a frame.

For bulk data, let Dart own the buffer and have Nim fill it in place — then
nothing crosses the boundary that needs freeing, and independent slices can be
computed concurrently:

```dart
final view = v.FractalView(width: 800, height: 600, maxIter: 500);
final pixels = await v.renderParallel(view, tiles: 4);   // RGBA8888
```

## Web

The same Nim source also runs in the browser. `just web` compiles it to
WebAssembly with emscripten and serves the example app at
`http://localhost:8080` — it prints the URL and waits rather than launching a
browser, so open it yourself (`WEB_PORT=9000 just web` to move it). Hot
restart still works. `dart:ffi` does not exist on web, so a second backend
drives the wasm module over `dart:js_interop` instead:

```
lib/src/backend_native.dart   dart:ffi
lib/src/backend_web.dart      dart:js_interop + emscripten
lib/nimflutter_ffi.dart         picks one with a conditional import
```

Nothing has to be installed for this. `tool/bin/emcc` is a
[DotSlash](https://dotslash-cli.com) file that pins emscripten by content
hash; the toolchain is fetched on first use and cached in `~/.cache/dotslash`.
Only `dotslash` itself needs to be on `PATH`.

The two backends are held to being the same, not merely similar: `just parity`
renders a frame with each kernel and compares them byte for byte, and they
agree exactly — across different compilers, different libm implementations
(glibc vs musl) and different word sizes (64-bit vs wasm32). Speed is close
too, ~2-5% off native for the same frame:

```
800x600 maxIter 100:  68.3 ms native / 70.2 ms wasm
800x600 maxIter 500: 148.8 ms native / 157.2 ms wasm
```

Web renders in parallel too, without `SharedArrayBuffer` or COOP/COEP
headers: instead of threading one module, the pool runs *several instances* of
it, one per Web Worker, each with its own linear memory. Nothing is shared, so
there is nothing to synchronise, and a module is only ~34 KB. Measured in
headless Chrome on 8 cores, 800x600 at 500 iterations:

```
main thread, one band   345 ms
1 worker                164 ms
32 bands over 8 workers  37 ms   (4.5x)
```

More bands than workers, for the same reason as native: band costs are wildly
uneven, so over-decomposing keeps a free worker busy rather than waiting on
the slowest one. `just check-pool` re-measures this and asserts the pooled
frame is byte-identical to a single-instance render — a pool that quietly
falls back to one thread, or assembles bands out of order, otherwise looks
just like a working one.

One difference remains, and it is surfaced in the API:

* `await v.initialize()` before anything else. A wasm module cannot be
  instantiated synchronously. On native it resolves immediately.

### One trap worth knowing about

Nim's `vmemcpy` silently skips any copy whose source or destination is
`<= 0xFFFF` — a null-pointer heuristic that is sound on 64-bit desktop, where
the low 64 KB is never mapped. On wasm32 emscripten packs static data down at
address ~1300, so every copy out of a string literal does nothing and Nim strings
come back as runs of zero bytes, with no crash and no diagnostic. It only
appears at `-O1` and above, which makes it look like an optimiser bug.
`tool/build_wasm.sh` passes `-sGLOBAL_BASE=1048576` to move static data, the
stack and the heap clear of that check.

## Is Nim faster than Dart?

For the numeric kernel in the example: **no, not meaningfully.** Measured at
800x600 / 500 iterations on an 8-core Linux box, Nim through C takes ~152 ms
against Dart AOT's ~169 ms — about 1.1x, which is inside the range where the
answer depends on the loop rather than the language. Dart's AOT compiler is
good at scalar floating-point loops. (V, which this plugin used previously,
measured the same ~1.1x.)

What the bridge does buy you is the ability to *write the logic in Nim* — or
reuse Nim you already have — with a C ABI that is cheap to call, safe to call
concurrently, and that also compiles to wasm for the web build. Pick it for the
language, not for an expected speedup. And build with optimisation on: at
`-O0`, or without `-d:danger`, the same kernel is several times slower, which
is what `tool/build.sh` and `src/CMakeLists.txt` guard against.

The parallel figures in the example come from splitting the frame across
isolates, not from the language — see `just sweep`, which shows the best band
count is well above the core count because the bands cost wildly different
amounts.

## Status

Verified on Linux with Nim 2.2.0, Dart 3 and Flutter 3.47: host build, string
round-trip, isolate dispatch, 900k-call memory stability, and the example app
built and run as a release Linux binary (CMake -> `v` -> NDK/clang path
included).

The web path is verified end to end as well — wasm built with emscripten 6.0.9,
the module exercised headlessly under node, the kernel compared byte for byte
against the native build, and the release web app loaded in headless Chrome
with its rendered canvas checked for an actual fractal. `just ci` runs all of
it.

The Android/iOS/Windows glue is written to the standard `plugin_ffi` contract
but is not exercised here — it needs the respective toolchains.

## Layout

```
src/nimflutter.v          the Nim source — the only file you normally edit
src/nimflutter.h          C declarations (ffigen input, Xcode input)
src/CMakeLists.txt      Nim -> C -> shared lib; shared by Android/Linux/Windows
lib/nimflutter_ffi.dart   the Dart API callers use
lib/src/backend_*.dart  the two backends: dart:ffi and wasm/js_interop
ios/, macos/            pre-generated C + podspec (static archive)
android/build.gradle    NDK build via externalNativeBuild
tool/build.sh           host build + export smoke test
tool/gen_ios_sources.sh regenerate ios/ and macos/ C after editing the Nim source
tool/bin/emcc           DotSlash file pinning emscripten — nothing to install
tool/build_wasm.sh      Nim -> C -> wasm for the web build
tool/test_wasm.mjs      headless check of the wasm module (no browser needed)
tool/check_parity.sh    native vs wasm kernel, byte for byte
tool/check_web.sh       loads the built web app in headless Chrome
example/                Flutter app exercising the bridge (see example/README.md)
```