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.
다섯 가지 방법
- 네이티브, isolate 하나 — 평범한
for반복문. 나머지 모든 행이 이것을 기준으로 측정됩니다. 누군가 라이브러리를 찾아 나서기 전의 코드가 바로 이 모습이기 때문입니다. - 네이티브 +
dart:isolate— 리스트를 쪼개고 조각마다Isolate.run을 하나씩,Future.wait으로 모아 이어 붙입니다. 손으로 짜면 이렇게 되고,parallel이 넘어야 할 기준선이 바로 이것입니다. 반복문만 이겨서는 부족합니다. - fxdart 체인, isolate 하나 —
fx(xs).map(work).toList(). isolate와는 아무 관련이 없습니다. 체인 자체의 값을 매기려고 있는 행입니다. 그래야 아래 행이 체인의 오버헤드를 슬쩍 공짜로 얻지도, 대신 뒤집어쓰지도 않습니다. - fxdart
.parallel()— 같은 체인에서 연산자 하나만 바꿉니다. 기본값 그대로라 원소 하나하나가 따로 워커로 건너갑니다. 처음 쓸 때 쓰게 되는 형태입니다. - 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로 다시
생성합니다. 변형들이 모두 같은 체크섬을 내지 않으면 러너가 그 사례를
거부하므로, 각 행은 언제나 같은 답을 구하는 서로 다른 방법입니다.
password-rehash
N = 20,000
N = 10,000
N = 100
다섯 프로그램, 그리고 이들이 공유하는 작업
// 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),
];
// 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);
},
);
}
// 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]);
},
);
}
// 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()),
);
}
// 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()),
);
}
// 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
N = 10,000
N = 100
다섯 프로그램, 그리고 이들이 공유하는 작업
// 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),
];
// 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);
},
);
}
// 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]);
},
);
}
// 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()),
);
}
// 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(),
),
);
}
// 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
N = 10,000
N = 100
다섯 프로그램, 그리고 이들이 공유하는 작업
// 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),
];
// 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);
},
);
}
// 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]);
},
);
}
// 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()),
);
}
// 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(),
),
);
}
// 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(),
),
);
}