Skip to content

Benchmarks

Most numbers on this page come from two AWS EC2 instances, measured on 2026-11-06 at commit f35bd9e4: a c7i.4xlarge (Intel Xeon Platinum 8488C, Sapphire Rapids) and a c8g.4xlarge (AWS Graviton4). Both have 16 vCPUs and run Amazon Linux 2023. They are VMs on real hardware, not bare metal. Every timed run was pinned to 8 CPUs; on the c7i that is one thread per core. The runs from WARC files are from two other c7i.4xlarge instances, on 2026-11-09 and 2026-11-10.

datatrove 0.10.1 and the other incumbents ran on CPython 3.13.18, and pyroid on free-threaded CPython 3.15.2t, from release wheels built on each machine (the WARC run used CPython 3.13.18 for both). The section on other modules and the earlier runs are from a Docker VM on a Mac and are labelled as such.

Summary

From raw Common Crawl WARC files to deduplicated documents, datatrove's FineWeb pipeline ran 9.5× faster with pyroid's steps in datatrove's own executor, pyroid's WarcReader included: 89.4 s against 848.3 s for stock datatrove with 8 workers. As one command, pyroid clean --recipe fineweb-warc took 79.6 s (10.7×). That is 8 WARC files of CC-MAIN-2026-39 (171,595 HTML records) on the c7i.4xlarge. With datatrove's reader, the first run had given 8.4× at 8 workers and 9.0× at one. Text extraction is most of the difference. Both kept the same documents, once the pages stock datatrove drops at its 1 s extraction timeout are left out of pyroid's run too.

On 100,000 FineWeb documents on the c7i.4xlarge, pyroid's FineWeb chain took 10.2 s with 8 threads, against 272.1 s for datatrove with 8 processes: about 27× faster. On one thread it took 43.4 s against 2,444.4 s, about 56× faster. The output is the same document for document. On the c8g.4xlarge the same comparison gives 29× and 50×.

On the 10,000-document dev slice the ratios are 18× (8 threads against 8 processes) and 38× (one thread against one process) on the c7i, and 20× and 45× on the c8g.

Stage by stage, on one thread against one process on the c7i, pyroid is 1.6× (fastText with lid.176.bin) to 108× (ftfy) faster than the library it replaces. The quality filters are 35–69× faster and MinHash signatures 34×.

The 100k datatrove runs and the runs of pyroid's stages in datatrove's executor were timed once each, without a warm-up, because a single-process datatrove run takes about 40 minutes. The other rows are medians of several runs.

From WARC files: datatrove's FineWeb pipeline

bench/fineweb_warc.py runs datatrove 0.10.1's examples/fineweb.py from WARC files to the final documents: WarcReader, URLFilter, Trafilatura(favour_precision=True), LanguageFilter (English), the Gopher, C4 and FineWeb quality filters with their exclusion writers, MinHash dedup in its four stages, and PIIFormatter. It ran three ways, each with 8 workers and with 1:

  • stock datatrove, as a fresh pip install 'datatrove[io,processing]==0.10.1' resolves it (trafilatura 1.11.0), in datatrove's LocalPipelineExecutor;
  • the same pipeline and executor with pyroid's drop-in steps for Trafilatura (which follows the installed trafilatura 1.11.0), URLFilter, LanguageFilter, the four quality filters, the MinHash signatures and PIIFormatter; the reader, the writers and the other three MinHash stages stay datatrove's;
  • a native pyroid chain without the executor: per WARC file, in a pool of processes, pyroid.trafilatura.extract_batch(compat="1.11.0"), language ID and the filters; then MinHash dedup and PII over all files in one process.

Every mode runs one task per WARC file, so its output does not depend on the number of workers.

AWS EC2 c7i.4xlarge (Intel Xeon Platinum 8488C, 16 vCPU), Amazon Linux 2023, every run on CPUs 0 to 7; CPython 3.13.18, datatrove 0.10.1 with trafilatura 1.11.0, and a pyroid release wheel built on the instance. 8 WARC files of CC-MAIN-2026-39 (7.5 GB, 171,595 HTML records), one task per file. Each run was timed once; fastest first. CPU s is user+sys including the worker processes. The Trafilatura column is the time datatrove's statistics give that step, summed over tasks.

Run Wall s vs stock datatrove, same workers CPU s Trafilatura step, s Pages dropped at the 1 s timeout Documents kept Peak RSS of the largest process, MiB
datatrove with pyroid's steps, 8 workers 104.0 8.4× 792 514 0 23,505 1,206
native pyroid chain, 8 workers 110.3 7.9× 808 526 0 23,505 874
native pyroid chain, 1 worker 738.7 9.4× 740 487 0 23,505 885
datatrove with pyroid's steps, 1 worker 775.9 9.0× 777 506 0 23,505 1,229
stock datatrove, 8 workers 870.8 1.0× 6,759 5,543 69 23,503 965
stock datatrove, 1 worker 6,975.8 1.0× 6,928 5,720 80 23,501 982

In every run, 5 more pages are lost because trafilatura 1.11.0 itself raises on them: datatrove's child process dies, and pyroid's step counts them as broken_process and drops them the same way.

Source: bench/fineweb_warc.py and bench/ec2/run_warc.sh, run on 2026-11-09 (bench/results/aws-c7i.4xlarge/2026-11-09-warc: runs//summary.json and compare.txt).

Stock datatrove spends most of its time in Trafilatura: 5,543 of its 6,759 CPU seconds at 8 workers. With pyroid's steps that step takes 514 s.

Stock datatrove's results vary from run to run, and pyroid's do not. datatrove extracts each page in a child process and gives up on it after 1 s (timeout=1), dropping the page and starting a new child with an empty dedup cache. Which pages hit that limit depends on the load on the machine, so the two stock runs dropped different pages (69 and 80) and kept different documents (23,503 and 23,501). pyroid's step extracts in the task's process and has no timeout, so it kept the same 23,505 documents in all four runs, with the same digest after MinHash and after PII.

To compare the documents exactly, the native chain was re-run with the pages each stock run dropped left out the same way (native --timeouts-of). Its final documents then equal stock datatrove's byte for byte, after MinHash and after PII, for both stock runs. Without that step, pyroid's steps at 8 workers keep 2 documents more than stock datatrove at 8 workers, both from pages stock datatrove timed out on, and no document's text differs.

Trafilatura is still the largest step with pyroid (514 of 792 CPU seconds at 8 workers). The next was datatrove's WarcReader, which parses the WARC files with warcio in Python: 154 s.

With pyroid's WARC reader

On 2026-11-10 the pipeline ran again over the same 8 files on another c7i.4xlarge, with pyroid's WarcReader drop-in (pyroid.warc) among pyroid's steps, and as one command, pyroid clean --recipe fineweb-warc --workers 8 (command line). Stock datatrove ran from the first run's exact package versions.

AWS EC2 c7i.4xlarge (Intel Xeon Platinum 8488C, 16 vCPU), Amazon Linux 2023, every run on CPUs 0 to 7; CPython 3.13.18, datatrove 0.10.1 with trafilatura 1.11.0 (the package versions of the 2026-11-09 run, installed from its freeze), and a pyroid release wheel built on the instance. The same 8 WARC files of CC-MAIN-2026-39 (7.5 GB, 171,595 HTML records), one task per file. Each run was timed once; fastest first. CPU s is user+sys including the worker processes. The step columns are the time datatrove's statistics, or the native chain's own timers, give each step, summed over tasks; the command line reports no per-step times.

Run Wall s vs stock datatrove CPU s WARC reader step, s Trafilatura step, s Pages dropped at the 1 s timeout Documents kept Peak RSS of the largest process, MiB
pyroid clean --recipe fineweb-warc, 8 workers 79.6 10.7× 600 – – 0 23,505 1,192
datatrove with pyroid's steps, WarcReader included, 8 workers 89.4 9.5× 678 71 487 0 23,505 1,224
native pyroid chain, 8 workers 90.4 9.4× 650 69 465 0 23,505 1,078
stock datatrove, 8 workers 848.3 1.0× 6,593 170 5,403 68 23,503 966

In every run, 5 more pages are lost because trafilatura 1.11.0 itself raises on them, as in the 2026-11-09 run. At 1 worker, datatrove with pyroid's steps took 640.5 s and the native chain 607.8 s, with the same 23,505 documents; stock datatrove was not re-run at 1 worker.

Source: bench/fineweb_warc.py and bench/ec2/run_warc.sh, run on 2026-11-10 (bench/results/aws-c7i.4xlarge/2026-11-10-warc: runs//summary.json and compare.txt).

The reader step went from 170 CPU seconds in stock datatrove (154 s in the first run with pyroid's other steps) to 71 s. Stock datatrove kept the same 23,503 documents as its first run at 8 workers, with the same digests; every pyroid run kept the same 23,505, and with the 73 pages stock datatrove lost left out the same way, the native chain's documents equal stock datatrove's byte for byte.

The reader alone, on one core, from bench/fineweb_warc.py reader:

The same machine and files, one process on CPU 0: each reader over each file, timed on a pass that only counts the documents, then checked on a second pass that digests them (id, url, date, text). Every reader gave the same 172,376 documents: the pages process_record returns, before datatrove skips the 781 with no text.

Reader CPU s vs warcio Per file, s
pyroid.warc.iter_documents 64.5 2.0× 8.1
pyroid.warc.iter_batches (Arrow) 85.1 1.6× 10.6
warcio 1.8.2 over gzip.open, datatrove's process_record 132.1 1.0× 16.5

Part of the time is the same for every reader: faust-cchardet's guess for the pages that are not valid UTF-8, and making the Python strings.

Source: bench/fineweb_warc.py reader, run on 2026-11-10 (bench/results/aws-c7i.4xlarge/2026-11-10-warc/runs/reader/summary.json).

The results, per-step times and digests are in bench/results/aws-c7i.4xlarge/2026-11-09-warc/ and 2026-11-10-warc/; bench/README.md has the commands.

End to end: the FineWeb chain

datatrove 0.10.1's FineWeb-style chain: fix_text → LanguageFilter (English, lid.176.bin) → Gopher repetition → Gopher quality → C4 quality (FineWeb settings) → FineWeb quality → MinHash dedup. It ran three ways, each with 1 and 8 workers:

  • datatrove in its own LocalPipelineExecutor, on CPython 3.13;
  • the same pipeline in datatrove's executor with every stage swapped for pyroid's, on CPython 3.13;
  • pyroid's native chain: the same steps as pyroid calls in one process, on Arrow input, with threads=1 and threads=8, on free-threaded CPython 3.15t (bench/chain.py).

The output is checked with a per-document digest of each document's text, language, score, keep/drop decision, reason and duplicate flag. On the dev slice, datatrove and pyroid's stages in its executor give the same digest (ef314b8d…) with 1 and 8 processes, and so does the native chain with 8 threads. On the 100k corpus, the 8-process runs and the native chain with 8 threads give da897999…. Both machines give the same digests, and pyroid's output never depends on threads.

Each run is a whole process under hyperfine, so interpreter start-up, imports, model loading and reading the input are included. The 8-process rows give each process its own copy of the fastText model, and the peak RSS column is for one process, not the total.

100,000 documents

The pinned 100,000-document FineWeb slice.

Wall time of the FineWeb chain on 100,000 documents on AWS EC2 c7i.4xlarge: pyroid's native chain 10.2 s with 8 threads and 43.4 s with 1; pyroid's stages in datatrove's executor 20.0 s and 64.4 s; datatrove 272.1 s with 8 processes and 2,444.4 s with 1

c7i.4xlarge, 100,000 documents. Wall seconds for the whole chain, fastest first.

Run Wall s, median [IQR] CPU s Peak RSS of one process, MiB vs datatrove, 1 process vs datatrove, 8 processes
pyroid native chain, 8 threads (CPython 3.15t) 10.17 [10.15–10.23] 45.3 2234 240.3× 26.7×
pyroid stages in datatrove's executor, 8 processes (3.13) 19.96 (1 run) 69.7 1515 122.5× 13.6×
pyroid native chain, 1 thread (CPython 3.15t) 43.37 [43.36–43.70] 44.6 2123 56.4× 6.3×
pyroid stages in datatrove's executor, 1 process (3.13) 64.42 (1 run) 65.1 1621 37.9× 4.2×
datatrove 0.10.1, 8 processes (CPython 3.13) 272.09 (1 run) 2122.2 449 9.0× 1.0×
datatrove 0.10.1, 1 process (CPython 3.13) 2444.38 (1 run) 2445.1 725 1.0× 0.1×

Source: bench/results/aws-c7i.4xlarge/2026-11-06/headline.

c8g.4xlarge, 100,000 documents. Wall seconds for the whole chain, fastest first.

Run Wall s, median [IQR] CPU s Peak RSS of one process, MiB vs datatrove, 1 process vs datatrove, 8 processes
pyroid native chain, 8 threads (CPython 3.15t) 10.16 [10.10–10.17] 45.7 2222 229.8× 29.4×
pyroid stages in datatrove's executor, 8 processes (3.13) 20.26 (1 run) 75.5 1511 115.3× 14.7×
pyroid native chain, 1 thread (CPython 3.15t) 46.83 [46.32–47.13] 47.6 2086 49.9× 6.4×
pyroid stages in datatrove's executor, 1 process (3.13) 69.64 (1 run) 70.3 1608 33.5× 4.3×
datatrove 0.10.1, 8 processes (CPython 3.13) 298.46 (1 run) 2335.1 447 7.8× 1.0×
datatrove 0.10.1, 1 process (CPython 3.13) 2334.87 (1 run) 2335.4 700 1.0× 0.1×

Source: bench/results/aws-c8g.4xlarge/2026-11-06/headline.

10,000 documents

The dev slice: the first 10,000 documents of the 100k slice. datatrove and pyroid's stages in its executor are the median of 3 runs, the native chain the median of 5, each after a warm-up.

Wall time of the FineWeb chain on 10,000 documents on AWS EC2 c7i.4xlarge: pyroid's native chain 1.9 s with 8 threads and 5.6 s with 1; pyroid's stages in datatrove's executor 3.2 s and 8.5 s; datatrove 35.4 s with 8 processes and 212.8 s with 1

c7i.4xlarge, 10,000 documents (dev slice). Wall seconds for the whole chain, fastest first.

Run Wall s, median [IQR] CPU s Peak RSS of one process, MiB vs datatrove, 1 process vs datatrove, 8 processes
pyroid native chain, 8 threads (CPython 3.15t) 1.95 [1.90–2.01] 6.6 593 109.4× 18.2×
pyroid stages in datatrove's executor, 8 processes (3.13) 3.15 [3.14–3.18] 11.3 377 67.5× 11.2×
pyroid native chain, 1 thread (CPython 3.15t) 5.61 [5.59–5.62] 6.4 509 37.9× 6.3×
pyroid stages in datatrove's executor, 1 process (3.13) 8.52 [8.48–8.54] 9.3 422 25.0× 4.2×
datatrove 0.10.1, 8 processes (CPython 3.13) 35.37 [35.24–35.52] 263.8 378 6.0× 1.0×
datatrove 0.10.1, 1 process (CPython 3.13) 212.76 [210.27–239.77] 213.5 443 1.0× 0.2×

Source: bench/results/aws-c7i.4xlarge/2026-11-06/dev.

c8g.4xlarge, 10,000 documents (dev slice). Wall seconds for the whole chain, fastest first.

Run Wall s, median [IQR] CPU s Peak RSS of one process, MiB vs datatrove, 1 process vs datatrove, 8 processes
pyroid native chain, 8 threads (CPython 3.15t) 1.64 [1.64–1.65] 5.4 604 141.5× 19.6×
pyroid stages in datatrove's executor, 8 processes (3.13) 2.68 [2.68–2.71] 9.6 351 86.8× 12.0×
pyroid native chain, 1 thread (CPython 3.15t) 5.15 [5.10–5.16] 5.6 506 45.2× 6.2×
pyroid stages in datatrove's executor, 1 process (3.13) 7.52 [7.49–7.57] 8.0 414 30.9× 4.3×
datatrove 0.10.1, 8 processes (CPython 3.13) 32.15 [32.14–32.18] 239.8 366 7.2× 1.0×
datatrove 0.10.1, 1 process (CPython 3.13) 232.51 [231.77–232.99] 233.0 446 1.0× 0.1×

Source: bench/results/aws-c8g.4xlarge/2026-11-06/dev.

Per stage

Each stage alone, on the dev slice. The incumbents ran in one process on CPython 3.13, as a per-document loop over their largest measured batch: 10,000 documents for fix_text, language ID and translit, and 1,000 for the filters and MinHash. pyroid made one call over the same documents as a list[str], with a pool of 1 or 8 threads. Every row first passed its output gate: identical ftfy and anyascii output, the same fastText labels with |Δp| ≤ 1e-6, datatrove's filter decisions and text, and datatrove's MinHash signatures.

The same list is reused between timed calls, so CPython's cached UTF-8 form of each str is warm. The earlier translit run rebuilt the strings every time.

Time per document for each stage on AWS EC2 c7i.4xlarge at the incumbents' largest batch: ftfy, fastText, datatrove's filters and MinHash, anyascii, and pyroid on CPython 3.13 and 3.15t with 1 and 8 threads

c7i.4xlarge, 10,000 documents (dev slice). µs per document; the speedup is pyroid on one thread against the incumbent in one process.

Module Call Docs per call Incumbent Incumbent, 1 process pyroid, 1 thread pyroid, 8 threads Speedup
pyroid.ftfy fix_text_batch 10,000 ftfy 6.3.1 724 6.70 2.06 108×
pyroid.fasttext predict, lid.176.bin, k=1 10,000 fasttext-predict 0.9.2.4 185 114 16.8 1.6×
pyroid.fasttext predict, lid.176.ftz, k=1 10,000 fasttext-predict 0.9.2.4 373 103 16.3 3.6×
pyroid.filters GopherRepetition 1,000 datatrove 0.10.1 6,105 174 26.4 35×
pyroid.filters GopherQuality 1,000 datatrove 0.10.1 4,615 99.9 16.3 46×
pyroid.filters C4Quality (default) 1,000 datatrove 0.10.1 4,508 65.7 14.5 69×
pyroid.filters C4Quality (FineWeb config) 1,000 datatrove 0.10.1 5,967 114 19.5 52×
pyroid.filters FineWebQuality 1,000 datatrove 0.10.1 4,287 90.8 13.2 47×
pyroid.dedup MinHash signatures, sha1/64 1,000 datatrove 0.10.1 4,940 146 28.0 34×
pyroid.translit anyascii 10,000 anyascii 0.3.3 92.2 1.74 1.01 53×

Source: bench/results/aws-c7i.4xlarge/2026-11-06/dev.

c8g.4xlarge, 10,000 documents (dev slice). µs per document; the speedup is pyroid on one thread against the incumbent in one process.

Module Call Docs per call Incumbent Incumbent, 1 process pyroid, 1 thread pyroid, 8 threads Speedup
pyroid.ftfy fix_text_batch 10,000 ftfy 6.3.1 818 5.54 1.64 148×
pyroid.fasttext predict, lid.176.bin, k=1 10,000 fasttext-predict 0.9.2.4 284 116 14.4 2.4×
pyroid.fasttext predict, lid.176.ftz, k=1 10,000 fasttext-predict 0.9.2.4 500 89.2 13.1 5.6×
pyroid.filters GopherRepetition 1,000 datatrove 0.10.1 6,494 158 19.2 41×
pyroid.filters GopherQuality 1,000 datatrove 0.10.1 5,125 92.1 9.34 56×
pyroid.filters C4Quality (default) 1,000 datatrove 0.10.1 4,618 55.8 9.34 83×
pyroid.filters C4Quality (FineWeb config) 1,000 datatrove 0.10.1 5,969 101 11.0 59×
pyroid.filters FineWebQuality 1,000 datatrove 0.10.1 4,748 78.5 7.85 60×
pyroid.dedup MinHash signatures, sha1/64 1,000 datatrove 0.10.1 5,541 117 20.7 47×
pyroid.translit anyascii 10,000 anyascii 0.3.3 109 1.39 0.87 79×

Source: bench/results/aws-c8g.4xlarge/2026-11-06/dev.

On the c7i, with 10,000 documents per call, the filters and MinHash took 92.2–173 µs per document on one thread and 12.7–23.9 on 8: larger batches use the pool better. With Arrow input, fix_text_batch took 6.63 µs per document on one thread and 1.31 on 8, and translit.anyascii 1.79 and 0.60.

Other MinHash libraries use other hash families, so they don't reproduce datatrove's signatures or decisions; they were gated on near-duplicate precision and recall ≥ 0.90 instead. On the c7i, rensa 0.5.0 took 144 µs per document (RMinHash.update, 112 permutations) and 161 (RMinHash.from_token_sets), and datasketch 2.0.0 took 436 (MinHash.bulk, num_perm=112).

Each run's CONSOLIDATED.md has every row with its interquartile range, pyroid on CPython 3.13, and each incumbent stage alone in datatrove's executor at 8 processes: c7i, c8g.

Other modules, languages and integrations

These runs are not in the EC2 benchmark yet. They come from each module's own benchmark in a Docker VM (OrbStack, Linux aarch64, 12 vCPUs on an Apple Silicon Mac) that other work was also using, so treat them as indicative. Each was gated on identical output before timing. Their conditions differ from table to table (interpreters, batch sizes, how loaded the VM was), so compare rows within a table, not across tables.

pyroid.metrics: ROUGE, BLEU and chrF

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Milliseconds for the whole workload, median of the runs (a single run for the reference libraries on 100k pairs). Every row was first checked identical to the 1-process reference. The reference libraries ran in 1 process and in 8 forked processes, pyroid in one process with threads=1 and threads=8. CPython 3.13, release wheel; other work kept the load average between 4 and 19. ROUGE scores CNN/DailyMail lead-3 against the highlights with rouge1/2/L/Lsum (the 100k pairs are the 11,490 pairs repeated). BLEU and chrF use WMT21 segments, the first reference and default settings. Guide: metrics.

Workload Pairs rouge-score / sacrebleu, 1 process 8 processes pyroid, threads=1 threads=8 Speedup, 1 / 8
ROUGE (score_batch) 1,000 852 238 22.4 18.3 38× / 13×
ROUGE (score_batch) 100,000 178,492 34,741 3,039 1,179 59× / 29×
ROUGE with the stemmer 100,000 306,034 47,777 4,715 1,359 65× / 35×
sentence_bleu (sentence_bleu_batch) 100,000 9,741 2,434 1,862 1,327 5× / 2×
corpus_bleu 10,000 898 310 94.2 41.3 10× / 8×
corpus_bleu 100,000 7,827 1,716 681 276 11× / 6×
sentence_chrf (sentence_chrf_batch) 100,000 22,661 4,361 1,987 915 11× / 5×
corpus_chrf 10,000 3,023 646 186 34.8 16× / 19×
corpus_chrf 100,000 27,476 5,213 1,421 288 19× / 18×

Source: bench/results/orbstack-aarch64-12cpu/2026-11-06-metrics (bench/metrics.py).

pyroid.json_repair

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Single documents, best time per call, json_repair 0.64.5 on CPython 3.12 (3.15t was within ±10%). Release wheel; other work kept the load average between 16 and 24. Guide: json_repair.

Document and call json_repair pyroid Speedup
valid JSON, ~1 KB, repair_json 11.3 µs 6.6 µs 1.7×
valid JSON, ~1 KB, loads 6.2 µs 6.4 µs 1.0×
broken JSON, ~1 KB, repair_json 600 µs 20.1 µs 30×
broken JSON, ~1 KB, loads 589 µs 22.2 µs 27×
valid JSON, 1.2 MB, repair_json 9.9 ms 8.1 ms 1.2×
broken JSON, 1 MB, repair_json 544 ms 17.1 ms 32×
broken JSON, 1 MB, loads 548 ms 18.6 ms 30×

Source: bench/json_repair_bench.py, run on 2026-11-06.

pyroid.json_repair: batches

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Documents per second over 10,000 broken documents of about 1.2 KB, on free-threaded CPython 3.15t with the GIL off. The shared, loaded VM limits the 8-thread rows. Guide: json_repair.

Run Documents/s vs json_repair in a loop
json_repair, loop 1,204 1×
json_repair, 8-thread pool 4,681 3.9×
pyroid repair_json, loop 41,414 34×
repair_json_batch, threads=1 46,221 38×
repair_json_batch, threads=8 156,134 130×

Source: bench/json_repair_bench.py, run on 2026-11-06.

pyroid.datasketch: MinHash signatures

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Seconds for the first 10,000 FineWeb dev documents (5.1 million word 5-gram shingles), 128 permutations, best of 3, on free-threaded CPython 3.15t with the GIL off. datasketch 2.0.0 with numpy 2.5.4; its 8-thread rows split the documents over a thread pool. Every pyroid result was first checked for identical signatures. Load average 7 to 10. Guide: datasketch.

Run datasketch, 1 thread datasketch, 8 threads pyroid, 1 thread pyroid, 8 threads
MinHash.bulk, affine32 3.13 s 2.31 s 0.39 s (8.0×) 0.15 s (21×)
MinHash.bulk, legacy 4.51 s 2.22 s 0.78 s (5.8×) 0.26 s (17.5×)
MinHash.bulk, affine64 3.66 s 2.27 s 0.68 s (5.4×) 0.23 s (16.2×)
from text: shingling + bulk vs minhash_batch, affine32 4.43 s 2.43 s 0.35 s (12.5×) 0.052 s (85×)

Source: bench/results/orbstack-aarch64-12cpu/2026-11-07-datasketch (bench/datasketch_bench.py).

pyroid.datasketch: MinHashLSH

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Milliseconds for 10,000 affine32 signatures, 128 permutations, same run. The loop rows call insert or query per document with LeanMinHash objects; the batch rows pass the signature matrix. Every query result set was first checked against datasketch's. Guide: datasketch.

Run datasketch, loop pyroid, loop pyroid batch, 1 thread pyroid batch, 8 threads
MinHashLSH(threshold=0.9), the (b, r) search 13.0 1.5 (8.9×)
insert, threshold 0.9 119.3 15.5 (7.7×) 11.3 (10.6×) 11.2 (10.7×)
query, threshold 0.9 58.5 10.5 (5.6×) 4.4 (13.2×) 5.0 (11.8×)
insert, threshold 0.5 524.7 42.9 (12.2×) 40.7 (12.9×) 19.2 (27.3×)
query, threshold 0.5 235.2 26.7 (8.8×) 17.9 (13.1×) 12.3 (19.1×)

Source: bench/results/orbstack-aarch64-12cpu/2026-11-07-datasketch (bench/datasketch_bench.py).

pyroid.jieba

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. µs per document over 1,000 FineWeb-2 cmn_Hani documents (the slice four times, about 1,450 characters each), on free-threaded CPython 3.15t with the GIL off, release wheel, median of 5 runs (2 for jieba), load average 1.5 to 3. Every document's words were first checked against jieba 0.42.1's. jieba's 8 processes are a warmed fork Pool(8); its 8 threads run in parallel on 3.15t. Guide: jieba.

Run lcut Speedup lcut_for_search Speedup
jieba, one thread 3,072 1× 2,998 1×
jieba, 8 threads 2,245 1.4× 2,499 1.2×
jieba, 8 processes 584 5.3× 895 3.3×
pyroid lcut(doc) per document 162 19× 187 16×
pyroid lcut(docs, threads=1) 127 24× 160 19×
pyroid lcut(docs, threads=8) 53 58× 67 45×

pyroid builds the Python lists of words on the calling thread (about 35 µs per document), which limits the 8-thread rows. A fresh interpreter reaches its first segmented sentence in 27 ms with pyroid (import included) and about 410 ms with jieba.

Source: bench/results/orbstack-aarch64-12cpu/2026-11-07-jieba (bench/jieba_bench.py).

pyroid.nltk

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. µs per document over the first 1,000 documents of the FineWeb dev slice (3,018 characters each on average), English model, release wheels, median of 5 runs (2 for NLTK), load average 3 to 5. Every document's output was first checked against NLTK 3.10.4's. NLTK's 8 processes are a warmed fork Pool(8); its 8 threads run in parallel only on 3.15t. Guide: nltk.

Run sent_tokenize, 3.13 word_tokenize, 3.13 sent_tokenize, 3.15t word_tokenize, 3.15t
NLTK, one thread 651 3,576 592 3,513
NLTK, 8 threads 4,102 25,633 259 1,244
NLTK, 8 processes 197 593 117 724
pyroid, one call per document 10.9 (60×) 117 (31×) 11.2 (53×) 105 (33×)
pyroid, batch, threads=1 10.4 (63×) 92 (39×) 10.5 (56×) 96 (37×)
pyroid, batch, threads=8 5.6 (116×) 44 (82×) 5.0 (118×) 43 (81×)

Speedups are against NLTK on one thread. pyroid builds the Python lists of sentences and tokens on the calling thread, which limits the 8-thread rows; against NLTK on 8 processes they are 35× (sent_tokenize) and 14× (word_tokenize) on 3.13. The first sentence in a fresh interpreter takes 177 ms with pyroid and 203 ms with NLTK on 3.13: pyroid imports NLTK to find the model when NLTK is installed.

Source: bench/results/orbstack-aarch64-12cpu/2026-11-09-nltk (bench/nltk_bench.py).

pyroid.trafilatura

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Milliseconds per page over 1,000 pages of the CC-MAIN-2026-39 development sample (157,000 characters each on average), CPython 3.13, release wheel, load average 2 to 4. Each mode against its own trafilatura release, with lxml 6.1.3 and jusText 3.0.2, and every page's text first checked identical. datatrove's call is favor_precision=True, include_comments=False, deduplicate=True; with dedup, pages are extracted in page order, so threads only parse ahead. Guide: trafilatura.

Call compat="2.3.1" trafilatura 2.3.1 Speedup compat="1.11.0" trafilatura 1.11.0 Speedup
extract(page), one page at a time 4.4 31.6 7.2× 2.7 23.4 8.6×
extract_batch, 1 / 4 / 8 threads 4.1 / 0.99 / 0.50 2.7 / 0.68 / 0.33
datatrove's call, one page at a time 3.5 27.3 7.8× 3.0 32.4 10.8×
datatrove's call through extract_batch, 1 / 4 / 8 threads 3.4 / 2.0 / 2.0 2.9 / 2.1 / 2.1
datatrove's Trafilatura step, one task, threads=1 / threads=8, against datatrove's own step 3.0 / 2.1 26.3 8.7× 2.9 / 2.2 32.9 11×

Source: the trafilatura port's timing runs on 2026-11-09, whose scripts are not in the repository.

Languages: FineWeb-2 filters and MinHash

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. µs per document over 1,000 documents per language (each FineWeb-2 slice four times), median of 5 runs (2 for datatrove). Every timed document was first checked for the same keep/drop, reason and signatures. The filters are FineWeb-2's chain (Gopher repetition, FineWeb quality, Gopher quality) with each language's settings; MinHash computes FineWeb-2's signatures. datatrove's 8 processes are a warmed fork Pool(8). CPython 3.13, release wheel, load average 6 to 16. Guide: languages.

Language Tokenizer datatrove, 1 process: filters / MinHash pyroid, 1 thread Speedup datatrove, 8 processes pyroid, 8 threads Speedup
German (deu_Latn) native 8,342 / 6,489 122 / 123 68× / 53× 2,022 / 1,545 37 / 28 54× / 55×
French (fra_Latn) native 16,853 / 12,342 195 / 194 86× / 64× 3,668 / 2,948 246* / 71 15×* / 42×
Spanish (spa_Latn) native 14,553 / 10,154 218 / 199 67× / 51× 2,872 / 1,752 104 / 56 28× / 31×
Russian (rus_Cyrl) native 16,478 / 14,154 457 / 397 36× / 36× 4,619 / 3,162 83 / 76 55× / 42×
Arabic (arb_Arab) native 8,225 / 6,424 145 / 132 57× / 49× 1,549 / 1,498 37 / 39 42× / 38×
Hindi (hin_Deva) native (IndicNLP) 2,514 / 1,237 126 / 127 20× / 10× 474 / 273 32 / 31 15× / 9×
Chinese (cmn_Hani) native (jieba)† 10,628 / 7,857 242 / 178 44× / 44× 1,556 / 1,172 36 / 28 43× / 42×
Japanese (jpn_Jpan) native (SudachiPy port)‡ 11,210 / 8,546 1,444 / 1,331 7.8× / 6.4× 1,768 / 1,384 193 / 202 9.2× / 6.9×
Vietnamese (vie_Latn) native (pyvi port)§ 18,203 / 17,075 839 / 676 22× / 25× 5,362 / 3,697 201 / 110 27× / 34×
Thai (tha_Thai) native (PyThaiNLP port)§ 9,045 / 7,628 704 / 505 13× / 15× 1,596 / 1,259 112 / 122 14× / 10×

* An outlier: in this run pyroid was slower with 8 threads than with 1 while the load rose. It needs a rerun before it can be quoted. † Rerun on 2026-11-07 with the native Chinese tokenizer (pyroid.jieba), load average 1 to 2; through python_tokenizer=True it had run at 1.2× / 0.9× on one thread and 0.4× with 8. ‡ Rerun on 2026-11-09 with the native Japanese tokenizer (a port of SudachiPy 0.7.0, sudachidict-core 20261015), load average 0.6 to 1.9; through python_tokenizer=True it had run at 1.2× / 0.9× on one thread and 0.3× / 0.2× with 8. SudachiPy's lattice dominates both sides: pyroid's whole MinHash (1,151 µs per document, one thread) takes less than SudachiPy's own tokenize() on the same chunks (1,583 µs). § Measured on 2026-11-09 with the native Vietnamese tokenizer (a port of pyvi 0.1.1 through spaCy) and the native Thai one (a port of PyThaiNLP 5.4.0, reading the installed pythainlp's word list and model), load average 2.1 at the start and 7.4 after the 8-worker runs. Through python_tokenizer=True, before the ports, they ran at 1.5× / 1.5× (Vietnamese) and 1.5× / 1.6× (Thai) on one thread, and 0.4× / 0.2× and 0.6× / 0.7× with 8; that run overlapped other builds on the VM (load average 3.5 to 23), so only its ratios are given.

Source: bench/results/orbstack-aarch64-12cpu/2026-11-06-multilingual, 2026-11-07-jieba, 2026-11-09-japanese and 2026-11-09-vithai (bench/multilingual.py).

More datatrove steps

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Each datatrove step alone on the FineWeb dev slice, in one process and in a warmed multiprocessing.Pool(8), against pyroid's native call at 1 and 8 threads. Medians of 5 runs for pyroid and 2 for datatrove; release wheel, CPython 3.13, taskset -c 0-7, load average about 8 (about 22 for the URL filter). Where datatrove wasn't measured at 8 processes, the 8-thread speedup is against one process. Guide: datatrove.

Step Workload datatrove, 1 process 8 processes pyroid, 1 thread 8 threads Speedup, 1 thread / 8 threads
PIIFormatter 10,000 documents 2.885 s 0.595 s 0.032 s 0.019 s 89× / 31× (vs 8 processes)
C4BadWordsFilter 10,000 documents 6.968 s 1.312 s 0.305 s 0.053 s 23× / 25× (vs 8 processes)
SymbolLinesFormatter (FineWeb-2 settings) 10,000 documents 0.145 s 0.198 s 0.028 s 0.008 s 5.2× / 25× (vs 8 processes)
URLFilter (datatrove's filter() loop; pyroid.filters.URLFilter) 10,000 URLs 29.4k URLs/s — 912k URLs/s 1.72M URLs/s 31× / 59× (vs 1 process)
Sentence dedup, stages 1 and 3 (drop-in steps) 13,232 documents 98 docs/s — 5,664 docs/s 10,457 docs/s 58× / 107× (vs 1 process)

Source: a one-off run on 2026-11-06, whose scripts are not in the repository.

datatrove's FineWeb pipeline with pyroid's steps swapped in

Docker/OrbStack VM on Apple Silicon (12 vCPU), indicative. Whole runs on the FineWeb dev slice (10,000 documents unless noted): wall seconds, and user+sys CPU seconds including children, from bash's time. CPython 3.13, release wheel, taskset -c 0-7. The VM was heavily shared (load average 18 to 30), which slows stock datatrove the most, so each ratio comes from a pair of runs in the same minute (stock, then pyroid). pyroid's runs include datatrove's executor phases, worker start-up, Parquet IO and loading the 125 MiB model; pyroid clean includes interpreter start-up, IO and writing both outputs. Every output has datatrove's per-document digest. Guide: datatrove.

Run Stock datatrove 0.10.1 With pyroid Speedup, wall (CPU)
Executor, 8 processes, pair 1 498.8 s (691 CPU s) steps swapped in: 21.1 s (26.9) 23.6× (25.7×)
Executor, 8 processes, pair 2 213.4 s (582) 10.2 s (25.4) 20.9× (22.9×)
Executor, 1 process, first 2,000 documents, 2 pairs 122.3 / 131.2 s (116 / 115) 6.9 / 7.8 s (5.9 / 6.8) 17.6× / 16.7× (19.7× / 17.1×)
pyroid clean --recipe fineweb, 8 threads, vs stock at 8 processes (pairs 1, 2) 498.8 / 213.4 s 8.7 / 2.2 s (12.7 / 7.7) 58× / 99× (54× / 75×)
pyroid clean, 1 thread, vs stock at 8 processes 498.8 / 213.4 s 11.4 / 7.5 s (11.1 / 7.8) 44× / 29×

Source: examples/datatrove/verify.py and the pyroid CLI, timed with bash's time on 2026-11-06.

Method

The benchmark harness is in bench/. It enforces these rules in code:

  1. Same output before any timing. Every timed row first passes an equivalence gate against pinned reference outputs: ftfy byte-identical; language ID with the same top-k labels and |Δp| ≤ 1e-6; filters with the same keep/drop, reason and rewritten text; MinHash with identical signatures. A mismatch aborts the run. The end-to-end runs must also give datatrove's per-document digest.
  2. Pinned data: FineWeb slices of 10k and 100k documents, 8 FineWeb-2 languages, lid.176.bin and lid.176.ftz, and a seeded adversarial set of 3,232 documents, each pinned by sha256 in bench/manifest.json.
  3. Stage rows use pyperf. One value is one call over the batch, reported as the median [interquartile range] per document over all of a row's values. Rows with more than one thread are pinned to 8 CPUs.
  4. End-to-end rows use hyperfine 2.0 over whole processes pinned to 8 CPUs: datatrove's own LocalPipelineExecutor(tasks=N, workers=N) with N = 1 and 8, and bench/chain.py for pyroid's native chain.
  5. CPU seconds and peak memory are recorded for every row.
  6. Charts are horizontal bars on a linear axis, fastest on top, with the incumbents and close rivals named.

To reproduce on Linux (or in a Linux container), install the pyroid wheel into a CPython 3.13 environment that has the bench dependencies and into a free-threaded CPython 3.15t one (BENCH_PY313, BENCH_PY315T), then:

$ cd bench
$ uv run --frozen python fetch.py                     # the pinned inputs
$ BENCH_GIT_SHA=$(git rev-parse --short HEAD) bash run_consolidated.sh

This writes CONSOLIDATED.md, consolidated.json (the source of the tables above) and the charts into bench/results/<machine>/<date>-consolidated/. bench/README.md documents every option, including BENCH_RIGOROUS=1 for a quiet machine, and bench/ec2/ has the scripts for the EC2 runs.

Where the CPU goes

How datatrove's time splits between the stages, from the harness's first baseline run in the Docker VM (CPython 3.13, one process, the first 10k FineWeb documents, 30.5 M characters). The share of the chain is the stage's single-process time over the sum of all stages.

Stage Incumbent ms/doc, 1 process Share of chain
fix_text ftfy 6.3.1 0.88 [0.84–0.94] 2.3%
language ID datatrove LanguageFilter (fastText lid.176.bin) 0.39 [0.36–0.44] 1.0%
filter GopherRepetitionFilter 8.65 [8.47–9.20] 22.2%
filter GopherQualityFilter 7.21 [6.72–7.68] 18.5%
filter C4QualityFilter (FineWeb settings) 8.42 [7.81–8.87] 21.6%
filter FineWebQualityFilter 6.54 [6.23–6.99] 16.8%
MinHash datatrove MinhashDedupSignature 6.88 [6.72–7.07] 17.7%
whole chain datatrove, 1 process 39.9 100%

The four filters take about 79% of the CPU and MinHash about 18%; ftfy and language ID together take about 3%. Making fix_text 10× faster on its own would make this pipeline only about 1.02× faster. datatrove tokenizes English with spaCy's blank("en") tokenizer for both the filters and MinHash, and in MinHash spaCy is about 70% of the cost. The same baseline run measured a peak of 2.36 GiB of anonymous memory at 8 processes.

On the c7i.4xlarge, datatrove ran the whole chain on the dev slice in 212.8 s in one process and 35.4 s with 8 (End to end).

Thread scaling

One process on free-threaded CPython 3.15t (GIL off), one call over 10,000 FineWeb documents, on the c7i.4xlarge (µs per document):

Module and call 1 thread 8 threads
ftfy.fix_text_batch, list / Arrow 6.70 / 6.63 2.06 / 1.31
fasttext predict, .bin / .ftz 114 / 103 16.8 / 16.3
filters, one filter per call 92.2–173 12.7–23.8
dedup.minhash_signatures 157 23.9
translit.anyascii, list / Arrow 1.74 / 1.79 1.01 / 0.60

The earlier module runs in the Docker VM measured these speedups at 8 threads over 1:

Module and call 8 threads vs 1
ftfy.fix_text from 8 Python threads 3.2–5.8×
filters.apply, FineWeb's 4 filters, list / Arrow 4.3× / 5.0×
dedup.MinHashDedup.fit, 10k / 100k docs 4.2× / 5.4×
translit.anyascii, FineWeb-2 mix (40k docs), Arrow 6.1×

Arrow input scales best. With a list on a free-threaded build, pyroid builds each string's UTF-8 form on the pool (for batches of at least 128 documents and 1 Mi code points), but it still builds the output strings one after another on the calling thread. That limits transforms that change most documents. A different global allocator (mimalloc) was measured and not adopted.

Earlier runs

These ran in the Docker VM described above, before the EC2 runs, and cover what the EC2 run doesn't: other languages, other calls and options, whole pipelines and memory. They are indicative.

The consolidated run of 2026-11-05 in that VM gave 26× (8 threads against 8 processes) and 49× (one thread against one process) on the dev slice; its CONSOLIDATED.md has the details. The EC2 numbers above replace it.

pyroid.ftfy

fix_text called once per document (the consolidated runs time fix_text_batch), on one thread, best of 3, the same documents for both:

Corpus ftfy 6.3.1 (µs/doc) pyroid.ftfy (µs/doc) Speedup
FineWeb, first 10k docs, CPython 3.15 693.0 7.3 95×
FineWeb, first 10k docs, CPython 3.12 969.3 5.8 167×
FineWeb-2 Spanish / German / French (250 docs each), 3.15 1,213 / 810 / 1,988 12.7 / 9.3 / 28.9 95× / 87× / 69×
FineWeb-2 Chinese / Russian / Japanese / Arabic, 3.15 960 / 1,199 / 701 / 900 33.5 / 40.7 / 38.0 / 39.2 29× / 29× / 18× / 23×
FineWeb-2 Hindi, 3.15 (slowest) 799 56.1 14×
Adversarial set (3,232 mojibake-heavy docs) 970 30.3 32×

An identical earlier run of the FineWeb rows gave 71× (3.12) and 93× (3.15).

pyroid.fasttext

The harness's stages.py language-ID rows at k=5, against fasttext-predict 0.9.2.4: median ms per document, 1,000 FineWeb documents with newlines replaced by spaces, CPython 3.13, gated on identical output.

Model fasttext-predict pyroid Speedup
lid.176.bin, k=5 0.377 0.248 1.5×
lid.176.ftz, k=5 0.636 0.139 4.6×

load_model in a fresh process took 50–60 ms (.bin) and 4–7 ms (.ftz) for pyroid, against 115–205 ms and 38–68 ms.

Threads in one process (free-threaded CPython 3.15t, 10k documents, best of 5, ms):

Model 1 2 4 8 8 threads vs single-threaded fastText
.bin, predict(batch, threads=n) 2,061 839 384 247 13.9×
.bin, n Python threads × predict(str) 2,436 1,143 636 401 8.5×
.ftz, predict(batch, threads=n) 1,517 859 632 316 20×

Single-threaded fastText (fasttext-predict, CPython 3.13) took 3,426 ms (.bin) and 6,354 ms (.ftz) for the same 10k documents.

With eight threads in one process, the model and predictions added 132 MiB (.bin) or 1 MiB (.ftz), against about 1.04 GiB for eight per-process copies of the model. The whole process peaked at 473 MiB, 320 MiB of which was the corpus itself: about 25% of the 1.87 GiB that datatrove's 8-process language-ID stage holds.

pyroid.filters

FineWeb's four filters chained through apply, on one core, CPython 3.13, the first 2,000 FineWeb documents, median of 5 (datatrove: median of 2), every result checked equal to datatrove's before timing: 23,875 µs per document for datatrove against 277 µs for pyroid on a list (363 on Arrow), about 86×.

datatrove's chain costs less than the sum of its filters because it caches tokenization between them, and apply shares it too. A separate check of three separate filter calls measured 0.43 ms per document on one thread, about 52× datatrove, and 0.097 ms per document with 8 threads.

pyroid.dedup

The whole pipeline, MinHashDedup.fit (sha1/64, free-threaded CPython 3.15t, median of 3 fresh processes, wall seconds):

Documents 1 thread 8 threads datatrove's 4 stages
10k 1.21 0.29 99.5 (1 process); 13.78 (8 processes)
100k 12.86 2.36 —

The 1-process datatrove figure was measured earlier, under heavier load. With xxhash/64 instead of sha1/64, signatures took 0.085–0.091 ms per document (1,000 FineWeb documents, CPython 3.13, one thread).

For exact duplicates (100k documents, CPython 3.15t), Python's own set over a list[str] beat pyroid.dedup.exact_dedup: 0.020 s against 0.071 s on one thread. exact_dedup is for Arrow input, normalized comparison and streaming state.

pyroid.translit

One thread, CPython 3.12, best of at least 5 runs. The str objects were rebuilt before every call, because CPython caches a string's UTF-8 form and that makes repeated runs 1.1–2.9× faster. pyroid's output was first checked equal to the anyascii package's on every document. µs per document:

Corpus anyascii (Python) Unidecode 1.4.0 pyroid anyascii (str) pyroid unidecode (list) Speedup vs anyascii / Unidecode
FineWeb, first 10k docs 80.8 192.0 2.19 2.54 36.9× / 75.7×
FineWeb-2 German / French / Spanish (250 docs each) 126 / 162 / 120 307 / 484 / 350 3.5 / 7.6 / 4.5 3.7 / 8.3 / 4.7 21–37× / 58–83×
FineWeb-2 Russian / Arabic / Hindi 333 / 284 / 347 562 / 487 / 788 24.3 / 23.2 / 20.8 26.8 / 23.9 / 20.7 12–17× / 20–38×
FineWeb-2 Japanese / Chinese 217 / 149 341 / 253 9.8 / 9.8 10.1 / 9.7 15–22× / 26–34×

On CPython 3.15 (GIL build) the speedups were 11–34× against anyascii and 19–68× against Unidecode. pyroid.translit.unidecode's output is not Unidecode's; see how different it is.

What's pending

  • The metrics, json_repair, datasketch, jieba, nltk and trafilatura modules, the FineWeb-2 languages and the newer datatrove steps in the gated harness on EC2. Their numbers above come from each module's own run in the Docker VM; only trafilatura has also run on EC2, inside the WARC pipeline.
  • Runs with more than 8 threads. Every EC2 run used 8 of the 16 vCPUs.