nandi/vflutter_ffipublic Fork 0
59a731a
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.

Raise the isolate ceiling to 32, and add the sweep that justifies it

The slider stopped at 8 on the assumption that one band per core is the
ceiling. Measuring says otherwise: on an 8-thread machine 8 tiles renders in
43 ms and 32 tiles in 29 ms, still improving slightly to 64.

The gain is not parallelism, it is load balance. Bands cost very different
amounts — rows through the set's interior run the full iteration cap, rows in
open space escape immediately — and the frame waits on its slowest band. At 8
bands the slowest is 2.2x the mean, so cores finish early and idle. Cutting
finer lets them pick up the next small band.

Isolate overhead is not what is left to chase: 24 spawns cost ~0.8 ms and
returning the whole 1.83 MB frame ~1.4 ms, against a ~29 ms frame.
TransferableTypedData measured slower than the plain return.

Default is now 16 rather than 4. tile_sweep.dart reproduces all of this via
`just sweep`, since the right number is machine-dependent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-18T16:25:02-07:00 Browse files
59a731a parent: f7c3825
modified example/README.md +24 -0
@@ -37,6 +37,30 @@ caller-owned memory, safe multi-isolate calls — not as a speed claim for V.
3737 Reach for V here because you want to write the logic in V, or already have it
3838 in V, not because C-via-V is expected to outrun Dart AOT on arithmetic.
3939
40+## How many isolates?
41+
42+The slider goes to 32, and past the core count is the right place to be. Bands
43+cost very different amounts — rows crossing the set's interior run the full
44+iteration cap, rows in open space escape almost immediately — and a frame is
45+not done until its slowest band is. Over-decomposing lets a free core start the
46+next small band instead of idling.
47+
48+Measured here (800x600 / 500 iterations, 8 logical cores):
49+
50+| tiles | best ms | speedup |
51+|---|---|---|
52+| serial | 153 | 1.00x |
53+| 4 | 64 | 2.39x |
54+| 8 | 43 | 3.56x |
55+| 16 | 34 | 4.50x |
56+| 32 | 29 | 5.28x |
57+| 64 | 27 | 5.67x |
58+
59+Even at 32 bands the slowest is still 2.7x the mean, which is the remaining
60+inefficiency — not isolate overhead. Spawning 24 isolates costs ~0.8 ms and
61+moving the whole 1.83 MB frame back across them ~1.4 ms, against a ~29 ms
62+frame. Run `just sweep` to get these numbers for your own machine.
63+
4064 ## Run it
4165
4266 ```bash
@@ -37,6 +37,30 @@ caller-owned memory, safe multi-isolate calls — not as a speed claim for V.
37 Reach for V here because you want to write the logic in V, or already have it37 Reach for V here because you want to write the logic in V, or already have it
38 in V, not because C-via-V is expected to outrun Dart AOT on arithmetic.38 in V, not because C-via-V is expected to outrun Dart AOT on arithmetic.
39 39
40+## How many isolates?
41+
42+The slider goes to 32, and past the core count is the right place to be. Bands
43+cost very different amounts — rows crossing the set's interior run the full
44+iteration cap, rows in open space escape almost immediately — and a frame is
45+not done until its slowest band is. Over-decomposing lets a free core start the
46+next small band instead of idling.
47+
48+Measured here (800x600 / 500 iterations, 8 logical cores):
49+
50+| tiles | best ms | speedup |
51+|---|---|---|
52+| serial | 153 | 1.00x |
53+| 4 | 64 | 2.39x |
54+| 8 | 43 | 3.56x |
55+| 16 | 34 | 4.50x |
56+| 32 | 29 | 5.28x |
57+| 64 | 27 | 5.67x |
58+
59+Even at 32 bands the slowest is still 2.7x the mean, which is the remaining
60+inefficiency — not isolate overhead. Spawning 24 isolates costs ~0.8 ms and
61+moving the whole 1.83 MB frame back across them ~1.4 ms, against a ~29 ms
62+frame. Run `just sweep` to get these numbers for your own machine.
63+
40 ## Run it64 ## Run it
41 65
42 ```bash66 ```bash
modified example/lib/main.dart +9 -3
@@ -55,7 +55,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
5555 bool _rendering = false;
5656 bool _renderQueued = false;
5757 int _lastMs = 0;
58- int _tiles = 4;
58+ int _tiles = 16;
5959 _Benchmark? _benchmark;
6060
6161 @override
@@ -321,8 +321,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
321321 child: Slider(
322322 value: _tiles.toDouble(),
323323 min: 1,
324- max: 8,
325- divisions: 7,
324+ // Deliberately well past the core count. Bands cost wildly
325+ // different amounts — rows through the set's interior run the full
326+ // iteration cap, rows in open space escape immediately — so the
327+ // frame waits on its slowest band. Over-decomposing lets a free
328+ // core pick up the next small band instead of idling. Measured on
329+ // an 8-thread machine: 8 tiles 44 ms, 24 tiles 29 ms.
330+ max: 32,
331+ divisions: 31,
326332 onChanged: (n) => setState(() => _tiles = n.round()),
327333 onChangeEnd: (_) => _render(),
328334 ),
@@ -55,7 +55,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
55 bool _rendering = false;55 bool _rendering = false;
56 bool _renderQueued = false;56 bool _renderQueued = false;
57 int _lastMs = 0;57 int _lastMs = 0;
58- int _tiles = 4;58+ int _tiles = 16;
59 _Benchmark? _benchmark;59 _Benchmark? _benchmark;
60 60
61 @override61 @override
@@ -321,8 +321,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
321 child: Slider(321 child: Slider(
322 value: _tiles.toDouble(),322 value: _tiles.toDouble(),
323 min: 1,323 min: 1,
324- max: 8,324+ // Deliberately well past the core count. Bands cost wildly
325- divisions: 7,325+ // different amounts — rows through the set's interior run the full
326+ // iteration cap, rows in open space escape immediately — so the
327+ // frame waits on its slowest band. Over-decomposing lets a free
328+ // core pick up the next small band instead of idling. Measured on
329+ // an 8-thread machine: 8 tiles 44 ms, 24 tiles 29 ms.
330+ max: 32,
331+ divisions: 31,
326 onChanged: (n) => setState(() => _tiles = n.round()),332 onChanged: (n) => setState(() => _tiles = n.round()),
327 onChangeEnd: (_) => _render(),333 onChangeEnd: (_) => _render(),
328 ),334 ),
added example/lib/tile_sweep.dart +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+// Finds where band parallelism stops paying on this machine.
2+//
3+// just sweep
4+//
5+// The interesting result is that the best tile count is well above the core
6+// count. Bands cost very different amounts — rows crossing the set's interior
7+// run the full iteration cap, rows in open space escape almost immediately —
8+// and a frame is not finished until its slowest band is. Cutting finer lets a
9+// free core start the next small band instead of idling, so over-decomposing
10+// wins even though it cannot add parallelism.
11+import 'dart:io';
12+
13+import 'package:vflutter_ffi/vflutter_ffi.dart' as v;
14+
15+const _view = v.FractalView(width: 800, height: 600, maxIter: 500);
16+
17+Future<void> main() async {
18+ // Warm the library and the code paths before timing anything.
19+ v.render(const v.FractalView(width: 64, height: 64, maxIter: 50));
20+
21+ print('${_view.width}x${_view.height}, maxIter ${_view.maxIter}, '
22+ '${Platform.numberOfProcessors} logical cores\n');
23+
24+ final serial = _best(() => v.render(_view));
25+ print('tiles best ms speedup');
26+ print('${"serial".padRight(8)} ${serial.toString().padRight(9)} 1.00x');
27+
28+ for (final tiles in [1, 2, 4, 8, 12, 16, 24, 32, 48, 64]) {
29+ final ms = await _bestAsync(() => v.renderParallel(_view, tiles: tiles));
30+ print('${tiles.toString().padRight(8)} ${ms.toString().padRight(9)} '
31+ '${(serial / ms).toStringAsFixed(2)}x');
32+ }
33+
34+ print('\nwhy more tiles than cores helps — per-band cost spread:');
35+ for (final tiles in [8, 32]) {
36+ _imbalance(tiles);
37+ }
38+}
39+
40+/// Times each band of a [tiles]-way split serially, so the numbers describe
41+/// the work itself rather than how it happened to be scheduled.
42+void _imbalance(int tiles) {
43+ final rows = (_view.height / tiles).ceil();
44+ final times = <int>[];
45+ for (var t = 0; t < tiles; t++) {
46+ final y0 = t * rows;
47+ final y1 = ((t + 1) * rows).clamp(0, _view.height);
48+ if (y0 >= y1) continue;
49+ final sw = Stopwatch()..start();
50+ v.renderBand(_view, y0, y1);
51+ sw.stop();
52+ times.add(sw.elapsedMicroseconds);
53+ }
54+ final total = times.reduce((a, b) => a + b);
55+ final slowest = times.reduce((a, b) => a > b ? a : b);
56+ final mean = total / times.length;
57+ print(' $tiles bands: slowest ${(slowest / 1000).toStringAsFixed(1)} ms '
58+ 'vs mean ${(mean / 1000).toStringAsFixed(1)} ms '
59+ '(${(slowest / mean).toStringAsFixed(1)}x — the frame waits on this)');
60+}
61+
62+int _best(void Function() f) {
63+ var best = 1 << 30;
64+ for (var i = 0; i < 3; i++) {
65+ final sw = Stopwatch()..start();
66+ f();
67+ sw.stop();
68+ best = sw.elapsedMilliseconds < best ? sw.elapsedMilliseconds : best;
69+ }
70+ return best;
71+}
72+
73+Future<int> _bestAsync(Future<void> Function() f) async {
74+ var best = 1 << 30;
75+ for (var i = 0; i < 3; i++) {
76+ final sw = Stopwatch()..start();
77+ await f();
78+ sw.stop();
79+ best = sw.elapsedMilliseconds < best ? sw.elapsedMilliseconds : best;
80+ }
81+ return best;
82+}
new file mode 100644
@@ -0,0 +1,82 @@
1+// Finds where band parallelism stops paying on this machine.
2+//
3+// just sweep
4+//
5+// The interesting result is that the best tile count is well above the core
6+// count. Bands cost very different amounts — rows crossing the set's interior
7+// run the full iteration cap, rows in open space escape almost immediately —
8+// and a frame is not finished until its slowest band is. Cutting finer lets a
9+// free core start the next small band instead of idling, so over-decomposing
10+// wins even though it cannot add parallelism.
11+import 'dart:io';
12+
13+import 'package:vflutter_ffi/vflutter_ffi.dart' as v;
14+
15+const _view = v.FractalView(width: 800, height: 600, maxIter: 500);
16+
17+Future<void> main() async {
18+ // Warm the library and the code paths before timing anything.
19+ v.render(const v.FractalView(width: 64, height: 64, maxIter: 50));
20+
21+ print('${_view.width}x${_view.height}, maxIter ${_view.maxIter}, '
22+ '${Platform.numberOfProcessors} logical cores\n');
23+
24+ final serial = _best(() => v.render(_view));
25+ print('tiles best ms speedup');
26+ print('${"serial".padRight(8)} ${serial.toString().padRight(9)} 1.00x');
27+
28+ for (final tiles in [1, 2, 4, 8, 12, 16, 24, 32, 48, 64]) {
29+ final ms = await _bestAsync(() => v.renderParallel(_view, tiles: tiles));
30+ print('${tiles.toString().padRight(8)} ${ms.toString().padRight(9)} '
31+ '${(serial / ms).toStringAsFixed(2)}x');
32+ }
33+
34+ print('\nwhy more tiles than cores helps — per-band cost spread:');
35+ for (final tiles in [8, 32]) {
36+ _imbalance(tiles);
37+ }
38+}
39+
40+/// Times each band of a [tiles]-way split serially, so the numbers describe
41+/// the work itself rather than how it happened to be scheduled.
42+void _imbalance(int tiles) {
43+ final rows = (_view.height / tiles).ceil();
44+ final times = <int>[];
45+ for (var t = 0; t < tiles; t++) {
46+ final y0 = t * rows;
47+ final y1 = ((t + 1) * rows).clamp(0, _view.height);
48+ if (y0 >= y1) continue;
49+ final sw = Stopwatch()..start();
50+ v.renderBand(_view, y0, y1);
51+ sw.stop();
52+ times.add(sw.elapsedMicroseconds);
53+ }
54+ final total = times.reduce((a, b) => a + b);
55+ final slowest = times.reduce((a, b) => a > b ? a : b);
56+ final mean = total / times.length;
57+ print(' $tiles bands: slowest ${(slowest / 1000).toStringAsFixed(1)} ms '
58+ 'vs mean ${(mean / 1000).toStringAsFixed(1)} ms '
59+ '(${(slowest / mean).toStringAsFixed(1)}x — the frame waits on this)');
60+}
61+
62+int _best(void Function() f) {
63+ var best = 1 << 30;
64+ for (var i = 0; i < 3; i++) {
65+ final sw = Stopwatch()..start();
66+ f();
67+ sw.stop();
68+ best = sw.elapsedMilliseconds < best ? sw.elapsedMilliseconds : best;
69+ }
70+ return best;
71+}
72+
73+Future<int> _bestAsync(Future<void> Function() f) async {
74+ var best = 1 << 30;
75+ for (var i = 0; i < 3; i++) {
76+ final sw = Stopwatch()..start();
77+ await f();
78+ sw.stop();
79+ best = sw.elapsedMilliseconds < best ? sw.elapsedMilliseconds : best;
80+ }
81+ return best;
82+}
modified example/test/explorer_test.dart +1 -1
@@ -32,7 +32,7 @@ void main() {
3232 final sliders = tester.widgetList<Slider>(find.byType(Slider)).toList();
3333 expect(sliders, hasLength(2));
3434 expect(sliders[0].value, 400, reason: 'iterations should default to 400');
35- expect(sliders[1].value, 4, reason: 'isolates should default to 4');
35+ expect(sliders[1].value, 16, reason: 'isolates should default to 16');
3636 });
3737
3838 testWidgets('reset restores the framing without touching iterations',
@@ -32,7 +32,7 @@ void main() {
32 final sliders = tester.widgetList<Slider>(find.byType(Slider)).toList();32 final sliders = tester.widgetList<Slider>(find.byType(Slider)).toList();
33 expect(sliders, hasLength(2));33 expect(sliders, hasLength(2));
34 expect(sliders[0].value, 400, reason: 'iterations should default to 400');34 expect(sliders[0].value, 400, reason: 'iterations should default to 400');
35- expect(sliders[1].value, 4, reason: 'isolates should default to 4');35+ expect(sliders[1].value, 16, reason: 'isolates should default to 16');
36 });36 });
37 37
38 testWidgets('reset restores the framing without touching iterations',38 testWidgets('reset restores the framing without touching iterations',
modified justfile +5 -0
@@ -35,6 +35,11 @@ bench: build
3535 dart compile exe example/lib/fractal_bench.dart -o /tmp/vflutter_bench
3636 LD_LIBRARY_PATH="{{lib_path}}" /tmp/vflutter_bench
3737
38+# Sweep the isolate count to find the best band split for this machine.
39+sweep: build
40+ dart compile exe example/lib/tile_sweep.dart -o /tmp/vflutter_sweep
41+ LD_LIBRARY_PATH="{{lib_path}}" /tmp/vflutter_sweep
42+
3843 # String round-trip and isolate dispatch. No Flutter SDK required.
3944 smoke: build
4045 LD_LIBRARY_PATH="{{lib_path}}" dart example/lib/main_test.dart
@@ -35,6 +35,11 @@ bench: build
35 dart compile exe example/lib/fractal_bench.dart -o /tmp/vflutter_bench35 dart compile exe example/lib/fractal_bench.dart -o /tmp/vflutter_bench
36 LD_LIBRARY_PATH="{{lib_path}}" /tmp/vflutter_bench36 LD_LIBRARY_PATH="{{lib_path}}" /tmp/vflutter_bench
37 37
38+# Sweep the isolate count to find the best band split for this machine.
39+sweep: build
40+ dart compile exe example/lib/tile_sweep.dart -o /tmp/vflutter_sweep
41+ LD_LIBRARY_PATH="{{lib_path}}" /tmp/vflutter_sweep
42+
38 # String round-trip and isolate dispatch. No Flutter SDK required.43 # String round-trip and isolate dispatch. No Flutter SDK required.
39 smoke: build44 smoke: build
40 LD_LIBRARY_PATH="{{lib_path}}" dart example/lib/main_test.dart45 LD_LIBRARY_PATH="{{lib_path}}" dart example/lib/main_test.dart