¿Merece la pena parallel?

Un trabajo, cinco maneras de ejecutarlo. La respuesta depende de un único número, y no es el número al que todo el mundo recurre.

Todos los artículos que dicen «usa isolates para el trabajo de CPU» se detienen justo antes de la parte que lo decide. Entregar un elemento a otro isolate y recuperar el resultado cuesta unos 5µs. Si el trabajo sobre ese elemento cuesta menos que eso, no hay número de núcleos que te salve: has contratado un mensajero para llevar una carta al otro lado de la habitación.

Por eso estos tres casos varían una sola cosa: el coste de un único elemento. Todo lo demás — el conjunto de datos, la suma de verificación, la función de trabajo — se mantiene fijo, y todos los programas llaman a la misma función de nivel superior, así que lo único que los diferencia es dónde se ejecuta esa función.

Cuándo pasar chunk y cuándo no

Imagina diez habitaciones al fondo del pasillo, una persona en cada una. Para dar un trabajo caminas, entregas una hoja y vuelves con la respuesta. Ese camino son unos 5µs — un instante — cada vez, aunque la hoja esté casi en blanco. El número 10 es cuántas habitaciones contrataste. En esta página es 10 porque la máquina que midió los gráficos tiene 10 núcleos.

Deja chunk fuera cuando el trabajo de una hoja es pesado. Abajo hay 20.000 contraseñas y 10 trabajadores. Rehashear una contraseña tarda unos 250µs — cincuenta caminos de trabajo. Entonces el camino es ruido. Envía una contraseña por viaje. Son 20.000 caminos, y está bien:

// 20,000 passwords, 10 workers. No chunk.
await fx(creds).parallel(10, rehash).toList();
// 20,000 trips. Each trip ~5µs, each job ~250µs.

Pasa chunk cuando el trabajo de una hoja es más ligero que el camino. Abajo hay 1.500.000 líneas de log y 10 trabajadores. La huella de una línea tarda unos 3,5µs — menos que un camino. Una línea por viaje son 1.500.000 caminos, más lento que hacerlo en tu propio escritorio. En su lugar mete 37.500 líneas en cada sobre (1,500,000 ~/ (10 * 4) = 37,500). ¿Por qué 10 * 4? Diez habitaciones, cuatro sobres cada una, así que 40 viajes en lugar de 1.500.000 — y si un sobre va más lento, las otras habitaciones aún tienen tres para repartir:

// 1,500,000 log lines, 10 workers, 37,500 lines per envelope.
await fx(lines).parallel(10, fingerprint, chunk: 37500).toList();
// 40 trips. 1,500,000 / (10 * 4) = 37,500.

Las cinco maneras

  1. Nativo, un isolate — un bucle for simple. La referencia contra la que se mide todo lo demás, porque es el aspecto que tenía el código antes de que nadie buscara una biblioteca.
  2. Nativo + dart:isolate — trocea la lista, un Isolate.run por trozo, Future.wait y concatena. Esto es lo que escribes a mano, y es el listón que parallel tiene que superar: no basta con ganarle al bucle.
  3. Cadena de fxdart, un isolatefx(xs).map(work).toList(). No comparte nada con los isolates; está aquí para ponerle precio a la cadena en sí, de modo que a la fila de abajo no se le regale ni se le cargue ese coste.
  4. fxdart .parallel() — la misma cadena con un operador cambiado, en su forma por defecto: cada elemento cruza solo hasta un trabajador. Esto es lo que escribes primero.
  5. fxdart .parallel(chunk:) — el mismo operador con k elementos en cada mensaje, de modo que el viaje se paga una vez por lote en lugar de una vez por elemento. Las dos últimas filas están juntas a propósito: el hueco entre ellas es el viaje de ida y vuelta, dibujado a escala.

Cómo leer los números

Cada caso está dimensionado para que el bucle simple tarde unos cinco segundos. Es deliberado: por debajo de un segundo, arrancar los isolates (~1ms cada uno) y copiar los datos pesan tanto sobre el total que la medición habla sobre todo del instrumento. Un trabajo que merece paralelizarse es un trabajo que tarda un rato.

Los dos bloques pequeños ejecutan el mismo programa con N = 10.000 y N = 100. No son relleno: son la otra mitad de la respuesta. Los isolates tienen un precio fijo — cerca de un milisegundo por arrancar cada uno, más copiar los datos de ida y los resultados de vuelta. Cuanto más pequeño es el trabajo, menos queda por ganar, y dónde está ese cruce no se adivina solo con el número de elementos. Fíjate en que password-rehash sigue ganando con N = 100 mientras que log-fingerprint ya ha perdido con N = 10.000: lo que decide es el trabajo total, no cuántas cosas hay.

Qué mirar. En password-rehash cada elemento cuesta ~250µs — cincuenta veces el viaje — y parallel gana sin ajustar nada. En log-fingerprint cada elemento cuesta ~3,5µs, menos que el viaje, y parallel por defecto es más lento que el bucle simple. No es un defecto: es pedirle al operador que pague un precio por elemento por un trabajo que se mide por elemento. chunk: es la solución, y las dos últimas filas son cuánto vale.

Por qué más trabajadores no arreglan la fila lenta

Si a log-fingerprint simplemente le faltara paralelismo, un grupo más grande ayudaría. No lo hace. El mismo programa con N = 100.000, variando solo el número de trabajadores:

workers   .parallel()        .parallel(chunk:)
      1     768.8 ms             381.0 ms
      2     831.9 ms             191.1 ms
      5     899.1 ms              86.5 ms
     10     873.5 ms              71.0 ms

Esta tabla es una medición aparte (BENCH_N=100000, BENCH_WORKERS 1–10). No está en results-parallel.json, así que regenerar los gráficos de la página no refresca estas cuatro filas.

La forma por defecto no mejora en absoluto — se va poniendo algo peor, y su coste se queda en torno a 8µs por elemento sea cual sea el tamaño del grupo. La forma con lotes escala 5,4× en ese mismo rango.

Ese es el diagnóstico. Con chunk: 1, cada elemento cuesta dos copias de mensaje, un evento de puerto y un completer en el isolate principal, que es un único hilo y lo único del sistema que no se puede paralelizar. Unos 8µs de coordinación (el viaje más ese completer y ese evento) para repartir 3,5µs de trabajo. El cuello de botella no son los trabajadores: están parados, esperando a que les dé trabajo un isolate principal que se pasa el tiempo echando cartas al buzón. Añadir trabajadores solo añade contención por él.

Un lote no abarata la coordinación: hace que haya menos. La fila con lote usa n ~/ (workers * 4), así que diez trabajadores siempre envían 40 mensajes. En este barrido eso es chunk: 2500 en lugar de 100.000 viajes; en el titular (N = 1.500.000) es chunk: 37500 en lugar de 1,5 millones de viajes. El isolate principal deja de ser el cuello de botella y el trabajo por fin llega a donde tenía que ir.

Fuentes: benchmark/cases-parallel/. Se regenera con dart run benchmark/run_parallel_benchmarks.dart. El ejecutor rechaza un caso cuyas variantes no produzcan todas una suma de verificación idéntica, así que las filas son siempre maneras distintas de calcular una misma respuesta.

Medido con 10 trabajadores, AOT (dart compile exe), mediana de 3 iteraciones (1 ronda(s) × 3). Las 5 variantes calculan la misma suma de verificación — el ejecutor rechaza el caso si no.

password-rehash

N = 20,000

Nativo, un isolate 4.98 s referencia
Nativo + dart:isolate 748.4 ms 6.65x más rápido
cadena fxdart, un isolate 4.95 s 1.01x más rápido
fxdart .parallel() 794.2 ms 6.27x más rápido
fxdart .parallel(chunk:) 758.1 ms 6.57x más rápido

N = 10,000

Nativo, un isolate 2.48 s referencia
Nativo + dart:isolate 373.7 ms 6.64x más rápido
cadena fxdart, un isolate 2.48 s igual
fxdart .parallel() 417.3 ms 5.94x más rápido
fxdart .parallel(chunk:) 370.4 ms 6.70x más rápido

N = 100

Nativo, un isolate 25.2 ms referencia
Nativo + dart:isolate 5.0 ms 5.07x más rápido
cadena fxdart, un isolate 25.2 ms igual
fxdart .parallel() 4.7 ms 5.40x más rápido
fxdart .parallel(chunk:) 4.9 ms 5.19x más rápido
Los cinco programas, y el trabajo que comparten
El trabajo en sí — los cinco llaman a esta misma función
// The per-element job, shared verbatim by all five variants.
//
// Top-level and sendable, which is what `parallel` asks of a worker and
// what `Isolate.run` needs anyway — so the four files differ only in where
// this runs, never in what it computes.

/// One credential to re-hash.
class Credential {
  const Credential(this.user, this.salt, this.secret);
  final int user;
  final int salt;
  final int secret;
}

/// The result: the derived key, and the user it belongs to.
class Derived {
  const Derived(this.user, this.key);
  final int user;
  final int key;
}

/// Iterated key derivation, PBKDF2's shape: mix the secret with the salt
/// over and over so that verifying a password is deliberately expensive.
///
/// The round count is what a KDF is *tuned* by, and it is set here so one
/// credential costs ~250 µs — the range a real deployment picks, and fifty
/// times the ~5 µs it costs to hand the credential to another isolate. That
/// ratio is the case: work this heavy does not need the batching the cheap
/// cases do.
///
/// Why not heavier still: the headline N has to stay above the 10,000 the
/// runner also measures at, or the "full" block would be *smaller* than the
/// block above it and the page would read backwards. Cost per credential and
/// the headline size trade off against each other at a fixed ~5 s baseline.
const kdfRounds = 55000;

Derived rehash(Credential c) {
  var h = c.secret ^ (c.salt * 0x9E3779B1);
  for (var i = 0; i < kdfRounds; i++) {
    h = (h * 31 + c.salt + i) & 0x1FFFFFFFFFFFFF;
    h ^= (h >> 13);
    h = (h * 0x27D4EB2D) & 0x1FFFFFFFFFFFFF;
    h ^= (h >> 7);
  }
  return Derived(c.user, h);
}

/// The same job over a slice, for the hand-rolled isolate variant.
List<Derived> rehashAll(List<Credential> batch) => [
  for (final c in batch) rehash(c),
];
Nativo, un isolate
// 1 of 5 — a plain loop. One isolate, no chain. The baseline.
import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final creds = makeCredentials();
  await bench(
    slug: 'password-rehash',
    impl: 'native',
    n: n,
    run: () {
      final out = <Derived>[];
      for (final c in creds) {
        out.add(rehash(c));
      }
      return checksum(out);
    },
  );
}
Nativo + dart:isolate
// 2 of 5 — hand-rolled isolates: slice the list, one Isolate.run per slice,
// wait for all, concatenate. This is what you write when you reach for
// dart:isolate directly, and it is what `parallel` has to beat.
import 'dart:isolate';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final creds = makeCredentials();
  await bench(
    slug: 'password-rehash',
    impl: 'native-isolate',
    n: n,
    run: () async {
      final slices = sliceEvenly(creds, benchWorkers);
      final parts = await Future.wait([
        for (final s in slices) Isolate.run(() => rehashAll(s)),
      ]);
      return checksum([for (final p in parts) ...p]);
    },
  );
}
cadena fxdart, un isolate
// 3 of 5 — the fxdart chain, still on one isolate. Isolates the cost of the
// chain itself, so the parallel row below is not credited with it.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final creds = makeCredentials();
  await bench(
    slug: 'password-rehash',
    impl: 'fxdart',
    n: n,
    run: () => checksum(fx(creds).map(rehash).toList()),
  );
}
fxdart .parallel()
// 4 of 5 — the same chain, one operator changed. The default form: every
// element crosses to a worker on its own.
//
// ~250 µs per credential dwarfs the ~5 µs round trip, so streaming is
// already the right call here — the trip is noise against the work, and
// every worker stays busy without waiting for a batch to fill.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final creds = makeCredentials();
  await bench(
    slug: 'password-rehash',
    impl: 'fxdart-parallel',
    n: n,
    run: () async =>
        checksum(await fx(creds).parallel(benchWorkers, rehash).toList()),
  );
}
fxdart .parallel(chunk:)
// 5 of 5 — the same operator, with `chunk` set.
//
// `chunk: k` puts k elements on one message instead of one each, so the
// round trip is paid once per batch. `length ~/ (workers * 16)` leaves every
// worker 16 turns, which is enough to balance uneven elements without
// paying per element.
//
// ~250 us per credential against a ~5 us round trip: the trip is already
// noise, so the default streaming form is the right one and a batch has
// almost nothing left to save. This row is here to show that.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final creds = makeCredentials();
  final chunk = (n ~/ (benchWorkers * 16)).clamp(1, 1 << 30);
  await bench(
    slug: 'password-rehash',
    impl: 'fxdart-parallel-chunk',
    n: n,
    run: () async => checksum(
      await fx(creds).parallel(benchWorkers, rehash, chunk: chunk).toList(),
    ),
  );
}

image-tiles

N = 147,000

Nativo, un isolate 5.56 s referencia
Nativo + dart:isolate 1.68 s 3.32x más rápido
cadena fxdart, un isolate 5.50 s 1.01x más rápido
fxdart .parallel() 1.75 s 3.17x más rápido
fxdart .parallel(chunk:) 781.9 ms 7.11x más rápido

N = 10,000

Nativo, un isolate 326.3 ms referencia
Nativo + dart:isolate 87.6 ms 3.73x más rápido
cadena fxdart, un isolate 317.7 ms 1.03x más rápido
fxdart .parallel() 117.9 ms 2.77x más rápido
fxdart .parallel(chunk:) 56.0 ms 5.82x más rápido

N = 100

Nativo, un isolate 3.3 ms referencia
Nativo + dart:isolate 0.9 ms 3.82x más rápido
cadena fxdart, un isolate 3.2 ms 1.05x más rápido
fxdart .parallel() 1.4 ms 2.46x más rápido
fxdart .parallel(chunk:) 1.4 ms 2.38x más rápido
Los cinco programas, y el trabajo que comparten
El trabajo en sí — los cinco llaman a esta misma función
// The per-element job, shared verbatim by all five variants.
//
// The middle of the three: ~40 µs per tile, so the round trip is a tenth of
// the work rather than all of it or none of it.

import 'dart:typed_data';

/// One 32×32 greyscale tile of a larger image.
class Tile {
  const Tile(this.index, this.pixels);
  final int index;
  final Uint8List pixels;
}

/// What the filter produced: the tile's index and its summary statistics.
class TileStats {
  const TileStats(this.index, this.edgeEnergy, this.mean);
  final int index;
  final int edgeEnergy;
  final int mean;
}

const tileSide = 32;

/// How many filter passes each tile gets. A sharpen stage runs a stack of
/// them in any real pipeline; eight puts one tile at ~42 µs, which is the
/// middle of the three cases — several times the ~5 µs isolate round trip,
/// but not the 100x that [password-rehash] has.
const passes = 8;

/// A 3×3 Sobel pass over the tile, reduced to two numbers. Real image work:
/// every output pixel reads nine inputs, so it is memory-bound in a way a
/// synthetic spin loop is not.
TileStats sharpen(Tile tile) {
  final p = tile.pixels;
  var energy = 0;
  var sum = 0;
  for (var pass = 0; pass < passes; pass++) {
    (energy, sum) = _pass(p, energy, sum);
  }
  final inner = (tileSide - 2) * (tileSide - 2);
  return TileStats(tile.index, energy, sum ~/ (inner * passes));
}

(int, int) _pass(Uint8List p, int energy, int sum) {
  for (var y = 1; y < tileSide - 1; y++) {
    final row = y * tileSide;
    for (var x = 1; x < tileSide - 1; x++) {
      final i = row + x;
      final gx =
          -p[i - tileSide - 1] +
          p[i - tileSide + 1] -
          2 * p[i - 1] +
          2 * p[i + 1] -
          p[i + tileSide - 1] +
          p[i + tileSide + 1];
      final gy =
          -p[i - tileSide - 1] -
          2 * p[i - tileSide] -
          p[i - tileSide + 1] +
          p[i + tileSide - 1] +
          2 * p[i + tileSide] +
          p[i + tileSide + 1];
      energy += (gx < 0 ? -gx : gx) + (gy < 0 ? -gy : gy);
      sum += p[i];
    }
  }
  return (energy, sum);
}

/// The same job over a slice, for the hand-rolled isolate variant.
List<TileStats> sharpenAll(List<Tile> batch) => [
  for (final t in batch) sharpen(t),
];
Nativo, un isolate
// 1 of 5 — a plain loop. One isolate, no chain. The baseline.
import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final tiles = makeTiles();
  await bench(
    slug: 'image-tiles',
    impl: 'native',
    n: n,
    run: () {
      final out = <TileStats>[];
      for (final t in tiles) {
        out.add(sharpen(t));
      }
      return checksum(out);
    },
  );
}
Nativo + dart:isolate
// 2 of 5 — hand-rolled isolates: one Isolate.run per slice.
import 'dart:isolate';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final tiles = makeTiles();
  await bench(
    slug: 'image-tiles',
    impl: 'native-isolate',
    n: n,
    run: () async {
      final slices = sliceEvenly(tiles, benchWorkers);
      final parts = await Future.wait([
        for (final s in slices) Isolate.run(() => sharpenAll(s)),
      ]);
      return checksum([for (final p in parts) ...p]);
    },
  );
}
cadena fxdart, un isolate
// 3 of 5 — the fxdart chain, still on one isolate.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final tiles = makeTiles();
  await bench(
    slug: 'image-tiles',
    impl: 'fxdart',
    n: n,
    run: () => checksum(fx(tiles).map(sharpen).toList()),
  );
}
fxdart .parallel()
// 4 of 5 — the same chain, one operator changed. The default form: every
// element crosses to a worker on its own.
//
// ~37 µs per tile against a ~5 µs round trip. The trip is about a tenth of
// the work: enough to notice, not enough to lose to.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final tiles = makeTiles();
  await bench(
    slug: 'image-tiles',
    impl: 'fxdart-parallel',
    n: n,
    run: () async => checksum(
      await fx(tiles).parallel(benchWorkers, sharpen).toList(),
    ),
  );
}
fxdart .parallel(chunk:)
// 5 of 5 — the same operator, with `chunk` set.
//
// `chunk: k` puts k elements on one message instead of one each, so the
// round trip is paid once per batch. `length ~/ (workers * 16)` leaves every
// worker 16 turns, which is enough to balance uneven elements without
// paying per element.
//
// ~37 us per tile against a ~5 us round trip: the trip is about a tenth of
// the work, so batching has something to take but the default already wins.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final tiles = makeTiles();
  final chunk = (n ~/ (benchWorkers * 16)).clamp(1, 1 << 30);
  await bench(
    slug: 'image-tiles',
    impl: 'fxdart-parallel-chunk',
    n: n,
    run: () async => checksum(
      await fx(tiles).parallel(benchWorkers, sharpen, chunk: chunk).toList(),
    ),
  );
}

log-fingerprint

N = 1,500,000

Nativo, un isolate 5.44 s referencia
Nativo + dart:isolate 3.81 s 1.43x más rápido
cadena fxdart, un isolate 5.43 s igual
fxdart .parallel() 13.68 s 2.52x más lento
fxdart .parallel(chunk:) 1.16 s 4.69x más rápido

N = 10,000

Nativo, un isolate 34.8 ms referencia
Nativo + dart:isolate 9.0 ms 3.87x más rápido
cadena fxdart, un isolate 34.7 ms igual
fxdart .parallel() 87.4 ms 2.51x más lento
fxdart .parallel(chunk:) 6.7 ms 5.17x más rápido

N = 100

Nativo, un isolate 0.4 ms referencia
Nativo + dart:isolate 0.3 ms 1.17x más rápido
cadena fxdart, un isolate 0.3 ms 1.03x más rápido
fxdart .parallel() 1.2 ms 3.53x más lento
fxdart .parallel(chunk:) 0.9 ms 2.55x más lento
Los cinco programas, y el trabajo que comparten
El trabajo en sí — los cinco llaman a esta misma función
// The per-element job, shared verbatim by all five variants.
//
// Deliberately cheap — a few microseconds — because that is the case where
// the isolate round trip costs more than the work, and where `chunk` stops
// being a tuning knob and becomes the whole difference.

/// One raw log line.
class LogLine {
  const LogLine(this.id, this.text);
  final int id;
  final String text;
}

/// A line reduced to the shape it shares with every other line like it.
class Fingerprint {
  const Fingerprint(this.id, this.hash, this.digits);
  final int id;
  final int hash;
  final int digits;
}

/// How many hash permutations the sketch keeps. This is the knob the case
/// is calibrated on: it sets the per-line cost, and the point of this case
/// is that the cost lands *below* the ~5 µs it takes to hand one line to
/// another isolate. Real MinHash sketches run 16-128 permutations.
const sketchSize = 96;

/// Shingle width — overlapping character n-grams, so a line that differs by
/// one token still shares most of its shingles with the lines like it.
const shingle = 5;

/// Normalise a log line and reduce it to a MinHash sketch.
///
/// Digit runs collapse to `#` first, so ids and durations do not make every
/// line unique; then the normalised text is shingled and each shingle is fed
/// through [sketchSize] cheap permutations, keeping the minimum of each. Two
/// lines of the same shape land on the same sketch, which is how a log
/// pipeline groups a million lines into a handful of templates.
///
/// ~3 µs per line — *less* than the ~5 µs round trip to an isolate. That is
/// this case: at `chunk: 1`, `parallel` loses to the plain loop no matter
/// how many workers it is given, because the trip costs more than the trip
/// is for. It is the one case where `chunk` is not a tuning knob but the
/// difference between winning and losing.
Fingerprint fingerprint(LogLine line) {
  final text = line.text;
  var digits = 0;

  // Normalise in place into a small code-unit buffer: digit runs to one `#`.
  final norm = List<int>.filled(text.length, 0);
  var len = 0;
  var lastWasDigit = false;
  for (var i = 0; i < text.length; i++) {
    final c = text.codeUnitAt(i);
    final isDigit = c >= 0x30 && c <= 0x39;
    if (isDigit) {
      digits++;
      if (lastWasDigit) continue;
      norm[len++] = 0x23;
    } else {
      norm[len++] = c;
    }
    lastWasDigit = isDigit;
  }

  var sketch = 0x7FFFFFFF;
  var mixed = 0;
  for (var start = 0; start + shingle <= len; start++) {
    // One rolling hash per shingle...
    var h = 0x811C9DC5;
    for (var k = 0; k < shingle; k++) {
      h = ((h ^ norm[start + k]) * 0x01000193) & 0x3FFFFFFF;
    }
    // ...then the permutations, keeping each one's running minimum. The
    // minima are folded together rather than kept as a vector: the case
    // needs the *cost* of a sketch, not the sketch itself.
    for (var p = 0; p < sketchSize; p++) {
      final v = ((h + p * 0x9E3779B1) * 0x85EBCA6B) & 0x3FFFFFFF;
      if (v < sketch) sketch = v;
      mixed = (mixed + (v & 0x3F)) & 0x3FFFFFFF;
    }
  }
  return Fingerprint(line.id, (sketch * 31 + mixed) & 0x3FFFFFFF, digits);
}

/// The same job over a slice, for the hand-rolled isolate variant.
List<Fingerprint> fingerprintAll(List<LogLine> batch) => [
  for (final l in batch) fingerprint(l),
];
Nativo, un isolate
// 1 of 5 — a plain loop. One isolate, no chain. The baseline.
import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final lines = makeLines();
  await bench(
    slug: 'log-fingerprint',
    impl: 'native',
    n: n,
    run: () {
      final out = <Fingerprint>[];
      for (final l in lines) {
        out.add(fingerprint(l));
      }
      return checksum(out);
    },
  );
}
Nativo + dart:isolate
// 2 of 5 — hand-rolled isolates: one Isolate.run per slice.
//
// Note what this variant does *not* pay: the slices are sent once, so it is
// already the batched shape. That is exactly why plain `parallel` loses here
// and `parallel(chunk:)` does not — the comparison is only fair once both
// sides send the same number of messages.
import 'dart:isolate';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final lines = makeLines();
  await bench(
    slug: 'log-fingerprint',
    impl: 'native-isolate',
    n: n,
    run: () async {
      final slices = sliceEvenly(lines, benchWorkers);
      final parts = await Future.wait([
        for (final s in slices) Isolate.run(() => fingerprintAll(s)),
      ]);
      return checksum([for (final p in parts) ...p]);
    },
  );
}
cadena fxdart, un isolate
// 3 of 5 — the fxdart chain, still on one isolate.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final lines = makeLines();
  await bench(
    slug: 'log-fingerprint',
    impl: 'fxdart',
    n: n,
    run: () => checksum(fx(lines).map(fingerprint).toList()),
  );
}
fxdart .parallel()
// 4 of 5 — the same chain, one operator changed. The default form: every
// element crosses to a worker on its own.
//
// ~3.5 µs of work against a ~5 µs round trip — the trip costs more than the
// trip is for, and no number of workers fixes that. This row is expected to
// lose to the plain loop, and the next one is why that is a tuning problem
// rather than a verdict on the operator.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final lines = makeLines();
  await bench(
    slug: 'log-fingerprint',
    impl: 'fxdart-parallel',
    n: n,
    run: () async => checksum(
      await fx(lines).parallel(benchWorkers, fingerprint).toList(),
    ),
  );
}
fxdart .parallel(chunk:)
// 5 of 5 — the same operator, with `chunk` set.
//
// `chunk: k` puts k elements on one message instead of one each, so the
// round trip is paid once per batch. `length ~/ (workers * 4)` leaves every
// worker 4 turns, which is enough to balance uneven elements without
// paying per element.
//
// ~3.5 us per line against a ~5 us round trip. This is the row the page is
// about: the batch is the difference between losing to a plain loop and
// beating it several times over.
import 'package:fxdart/fxdart.dart';

import '../../harness.dart';
import 'data.dart';
import 'work.dart';

Future<void> main() async {
  final lines = makeLines();
  final chunk = (n ~/ (benchWorkers * 4)).clamp(1, 1 << 30);
  await bench(
    slug: 'log-fingerprint',
    impl: 'fxdart-parallel-chunk',
    n: n,
    run: () async => checksum(
      await fx(lines).parallel(benchWorkers, fingerprint, chunk: chunk).toList(),
    ),
  );
}