parallel은 값어치를 하는가?

작업 하나, 실행 방법 다섯 가지. 답을 가르는 숫자는 딱 하나인데, 사람들이 흔히 떠올리는 그 숫자가 아닙니다.

"CPU 작업에는 isolate를 쓰라"는 글은 하나같이 정작 답을 가르는 지점 앞에서 멈춥니다. 원소 하나를 다른 isolate에 넘기고 결과를 받아 오는 데는 약 5µs가 듭니다. 그 원소에 드는 일이 그보다 싸다면 코어를 아무리 늘려도 소용없습니다. 방 건너편에 편지 한 장 전하려고 택배를 부른 셈이니까요.

그래서 아래 세 가지 사례는 딱 하나, 원소 하나에 드는 비용만 바꿉니다. 데이터셋도 체크섬도 워커 함수도 모두 고정이고, 모든 프로그램이 같은 최상위 함수를 호출합니다. 이들 사이의 유일한 차이는 그 함수가 어디에서 실행되느냐입니다.

chunk를 넣을 때, 빼 둘 때

복도 끝에 방 열 개가 있고, 방마다 사람이 한 명씩 일을 한다고 생각해 보세요. 일을 시키려면 걸어가서 종이 한 장을 건네고, 답을 들고 돌아와야 합니다. 그 왕복이 매번 약 5µs — 아주 짧은 시간 — 입니다. 종이가 거의 비어 있어도 걸음값은 같습니다. 숫자 10이 워커 수, 즉 고용한 방의 개수입니다. 이 페이지의 차트는 코어가 10개인 컴퓨터에서 돌렸기 때문에 10입니다.

chunk를 빼 두는 경우는 종이 한 장에 적힌 일이 무거울 때입니다. 아래는 비밀번호 2만 개, 사람 10명입니다. 비밀번호 하나를 다시 해시하는 데 약 250µs — 걸어가는 시간의 50배. 걸음값은 오차처럼 작아집니다. 한 번에 비밀번호 하나만 보내세요. 왕복 2만 번이고, 그래도 됩니다:

// 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.

chunk를 넣는 경우는 종이 한 장의 일이 걸음보다 가벼울 때입니다. 아래는 로그 150만 줄, 사람 10명입니다. 한 줄의 지문을 내는 데 약 3.5µs — 걸어가는 시간보다 짧습니다. 한 줄마다 걸어가면 왕복 150만 번이고, 자기 책상에서 하는 것보다 느려집니다. 대신 봉투 하나에 37,500줄을 넣으세요 (1,500,000 ~/ (10 * 4) = 37,500). 10 * 4인 이유요? 방 열 개, 방마다 봉투 네 장, 그래서 왕복이 150만이 아니라 40번입니다. 한 봉투가 늦어도 다른 방에 남은 세 장이 있어 일을 나눠 가질 수 있습니다:

// 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.

다섯 가지 방법

  1. 네이티브, isolate 하나 — 평범한 for 반복문. 나머지 모든 행이 이것을 기준으로 측정됩니다. 누군가 라이브러리를 찾아 나서기 전의 코드가 바로 이 모습이기 때문입니다.
  2. 네이티브 + dart:isolate — 리스트를 쪼개고 조각마다 Isolate.run을 하나씩, Future.wait으로 모아 이어 붙입니다. 손으로 짜면 이렇게 되고, parallel이 넘어야 할 기준선이 바로 이것입니다. 반복문만 이겨서는 부족합니다.
  3. fxdart 체인, isolate 하나fx(xs).map(work).toList(). isolate와는 아무 관련이 없습니다. 체인 자체의 값을 매기려고 있는 행입니다. 그래야 아래 행이 체인의 오버헤드를 슬쩍 공짜로 얻지도, 대신 뒤집어쓰지도 않습니다.
  4. fxdart .parallel() — 같은 체인에서 연산자 하나만 바꿉니다. 기본값 그대로라 원소 하나하나가 따로 워커로 건너갑니다. 처음 쓸 때 쓰게 되는 형태입니다.
  5. fxdart .parallel(chunk:) — 같은 연산자인데 메시지 하나에 원소 k개를 실어, 왕복 비용을 원소마다가 아니라 배치마다 한 번만 냅니다. 마지막 두 행을 나란히 둔 것은 의도적입니다. 둘 사이의 간격이 바로 왕복 비용을 실제 비율로 그린 것입니다.

숫자 읽는 법

각 사례는 평범한 반복문이 약 5초 걸리도록 크기를 맞췄습니다. 의도적입니다. 1초 아래에서는 isolate를 띄우는 비용(개당 약 1ms)과 데이터 복사가 전체에서 차지하는 몫이 커서, 측정 결과가 작업이 아니라 측정 장치를 말하게 됩니다. 병렬화할 가치가 있는 작업이란 곧 시간이 좀 걸리는 작업입니다.

작은 블록 두 개는 똑같은 프로그램을 N = 10,000과 N = 100에서 돌린 것입니다. 채우려고 넣은 것이 아니라, 이것이 답의 나머지 절반입니다. isolate에는 고정 비용이 있습니다. 하나 띄우는 데 약 1ms, 거기에 데이터를 넣고 결과를 꺼내 오는 복사 비용까지. 작업이 작을수록 남는 이득이 줄어드는데, 그 경계가 어디인지는 원소 개수만으로는 짐작할 수 없습니다. password-rehash는 N = 100에서도 여전히 이기는데 log-fingerprint는 N = 10,000에서 이미 진다는 점을 보세요. 개수가 아니라 전체 작업량이 결정합니다.

무엇을 볼 것인가. password-rehash는 원소 하나에 약 250µs — 왕복 비용의 쉰 배 — 가 들고, parallel은 아무 것도 조율하지 않아도 이깁니다. log-fingerprint는 원소 하나에 약 3.5µs로 왕복보다 싸고, 기본값 parallel평범한 반복문보다 느립니다. 결함이 아닙니다. 원소 단위 작업에 원소 단위 값을 치르라고 시킨 것뿐입니다. chunk:가 해답이고, 마지막 두 행이 그 값어치입니다.

워커를 늘려도 느린 행이 나아지지 않는 이유

log-fingerprint가 단지 병렬성이 모자란 것이라면 풀을 키우면 나아져야 합니다. 그렇지 않습니다. 같은 프로그램을 N = 100,000에서 워커 수만 바꿔가며 돌린 결과입니다.

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

이 표는 따로 잰 값입니다 (BENCH_N=100000, BENCH_WORKERS 1–10). results-parallel.json에 들어 있지 않아서, 페이지 차트를 다시 그려도 이 네 줄은 갱신되지 않습니다.

기본 형태는 전혀 나아지지 않습니다. 오히려 조금씩 나빠지고, 풀 크기와 무관하게 원소당 8µs 근처에 머뭅니다. chunk를 준 형태는 같은 구간에서 5.4배로 확장됩니다.

진단은 이것입니다. chunk: 1에서는 원소마다 메시지 복사 두 번, 포트 이벤트 하나, 컴플리터 하나가 메인 isolate에서 발생합니다. 메인 isolate는 스레드 하나이고, 이 시스템에서 유일하게 병렬화할 수 없는 지점입니다. 3.5µs짜리 일을 넘기려고 8µs쯤을 조율에 (hop에 컴플리터와 이벤트까지) 쓰는 셈입니다. 병목은 워커가 아닙니다. 워커들은 편지 부치느라 바쁜 메인 isolate가 일을 던져주기를 기다리며 놀고 있습니다. 워커를 더 붙이면 그 메인 isolate를 두고 경합만 늘어납니다.

배치는 조율을 싸게 만드는 것이 아니라, 조율할 일 자체를 줄입니다. chunk 행은 n ~/ (workers * 4)이라 워커 열 개면 언제나 메시지 40개입니다. 이 스윕에서는 chunk: 2500으로 왕복 10만 번 대신이고, 헤드라인 (N = 1,500,000)에서는 chunk: 37500으로 왕복 150만 번 대신입니다. 메인 isolate가 병목에서 벗어나면서 일이 비로소 원래 가야 할 곳으로 갑니다.

소스는 benchmark/cases-parallel/에 있습니다. dart run benchmark/run_parallel_benchmarks.dart로 다시 생성합니다. 변형들이 모두 같은 체크섬을 내지 않으면 러너가 그 사례를 거부하므로, 각 행은 언제나 같은 답을 구하는 서로 다른 방법입니다.

워커 10개, AOT (dart compile exe), 3회 반복의 중앙값 (1 라운드 × 3). 5개 프로그램이 같은 체크섬을 냅니다 — 그렇지 않으면 러너가 거부합니다.

password-rehash

N = 20,000

네이티브, isolate 하나 4.98 s 기준
네이티브 + dart:isolate 748.4 ms 6.65배 더 빠름
fxdart 체인, isolate 하나 4.95 s 1.01배 더 빠름
fxdart .parallel() 794.2 ms 6.27배 더 빠름
fxdart .parallel(chunk:) 758.1 ms 6.57배 더 빠름

N = 10,000

네이티브, isolate 하나 2.48 s 기준
네이티브 + dart:isolate 373.7 ms 6.64배 더 빠름
fxdart 체인, isolate 하나 2.48 s 동일
fxdart .parallel() 417.3 ms 5.94배 더 빠름
fxdart .parallel(chunk:) 370.4 ms 6.70배 더 빠름

N = 100

네이티브, isolate 하나 25.2 ms 기준
네이티브 + dart:isolate 5.0 ms 5.07배 더 빠름
fxdart 체인, isolate 하나 25.2 ms 동일
fxdart .parallel() 4.7 ms 5.40배 더 빠름
fxdart .parallel(chunk:) 4.9 ms 5.19배 더 빠름
다섯 프로그램, 그리고 이들이 공유하는 작업
작업 자체 — 다섯 프로그램 모두 이 함수 하나를 호출합니다
// 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),
];
네이티브, 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);
    },
  );
}
네이티브 + 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]);
    },
  );
}
fxdart 체인, 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

네이티브, isolate 하나 5.56 s 기준
네이티브 + dart:isolate 1.68 s 3.32배 더 빠름
fxdart 체인, isolate 하나 5.50 s 1.01배 더 빠름
fxdart .parallel() 1.75 s 3.17배 더 빠름
fxdart .parallel(chunk:) 781.9 ms 7.11배 더 빠름

N = 10,000

네이티브, isolate 하나 326.3 ms 기준
네이티브 + dart:isolate 87.6 ms 3.73배 더 빠름
fxdart 체인, isolate 하나 317.7 ms 1.03배 더 빠름
fxdart .parallel() 117.9 ms 2.77배 더 빠름
fxdart .parallel(chunk:) 56.0 ms 5.82배 더 빠름

N = 100

네이티브, isolate 하나 3.3 ms 기준
네이티브 + dart:isolate 0.9 ms 3.82배 더 빠름
fxdart 체인, isolate 하나 3.2 ms 1.05배 더 빠름
fxdart .parallel() 1.4 ms 2.46배 더 빠름
fxdart .parallel(chunk:) 1.4 ms 2.38배 더 빠름
다섯 프로그램, 그리고 이들이 공유하는 작업
작업 자체 — 다섯 프로그램 모두 이 함수 하나를 호출합니다
// 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),
];
네이티브, 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);
    },
  );
}
네이티브 + 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]);
    },
  );
}
fxdart 체인, 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

네이티브, isolate 하나 5.44 s 기준
네이티브 + dart:isolate 3.81 s 1.43배 더 빠름
fxdart 체인, isolate 하나 5.43 s 동일
fxdart .parallel() 13.68 s 2.52배 더 느림
fxdart .parallel(chunk:) 1.16 s 4.69배 더 빠름

N = 10,000

네이티브, isolate 하나 34.8 ms 기준
네이티브 + dart:isolate 9.0 ms 3.87배 더 빠름
fxdart 체인, isolate 하나 34.7 ms 동일
fxdart .parallel() 87.4 ms 2.51배 더 느림
fxdart .parallel(chunk:) 6.7 ms 5.17배 더 빠름

N = 100

네이티브, isolate 하나 0.4 ms 기준
네이티브 + dart:isolate 0.3 ms 1.17배 더 빠름
fxdart 체인, isolate 하나 0.3 ms 1.03배 더 빠름
fxdart .parallel() 1.2 ms 3.53배 더 느림
fxdart .parallel(chunk:) 0.9 ms 2.55배 더 느림
다섯 프로그램, 그리고 이들이 공유하는 작업
작업 자체 — 다섯 프로그램 모두 이 함수 하나를 호출합니다
// 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),
];
네이티브, 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);
    },
  );
}
네이티브 + 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]);
    },
  );
}
fxdart 체인, 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(),
    ),
  );
}