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>
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. | ||
| 37 | 37 | Reach for V here because you want to write the logic in V, or already have it |
| 38 | 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 | 64 | ## Run it |
| 41 | 65 | |
| 42 | 66 | ```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 it | 37 | 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 it | 64 | ## Run it |
| 41 | 65 | ||
| 42 | ```bash | 66 | ```bash |
modified
example/lib/main.dart +9 -3 | @@ -55,7 +55,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 55 | 55 | bool _rendering = false; |
| 56 | 56 | bool _renderQueued = false; |
| 57 | 57 | int _lastMs = 0; |
| 58 | - int _tiles = 4; | |
| 58 | + int _tiles = 16; | |
| 59 | 59 | _Benchmark? _benchmark; |
| 60 | 60 | |
| 61 | 61 | @override |
| @@ -321,8 +321,14 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 321 | 321 | child: Slider( |
| 322 | 322 | value: _tiles.toDouble(), |
| 323 | 323 | 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, | |
| 326 | 332 | onChanged: (n) => setState(() => _tiles = n.round()), |
| 327 | 333 | onChangeEnd: (_) => _render(), |
| 328 | 334 | ), |
| @@ -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 | @override | 61 | @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() { | ||
| 32 | 32 | final sliders = tester.widgetList<Slider>(find.byType(Slider)).toList(); |
| 33 | 33 | expect(sliders, hasLength(2)); |
| 34 | 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 | 38 | 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 | ||
| 35 | 35 | dart compile exe example/lib/fractal_bench.dart -o /tmp/vflutter_bench |
| 36 | 36 | 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 | 43 | # String round-trip and isolate dispatch. No Flutter SDK required. |
| 39 | 44 | smoke: build |
| 40 | 45 | 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_bench | 35 | dart compile exe example/lib/fractal_bench.dart -o /tmp/vflutter_bench |
| 36 | LD_LIBRARY_PATH="{{lib_path}}" /tmp/vflutter_bench | 36 | 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: build | 44 | smoke: build |
| 40 | LD_LIBRARY_PATH="{{lib_path}}" dart example/lib/main_test.dart | 45 | LD_LIBRARY_PATH="{{lib_path}}" dart example/lib/main_test.dart |