Benchmarks

July 20, 2026 · View on GitHub

BenchmarkDotNet-based benchmarks of FastBertTokenizer. Two suites:

  • TokenizeSpeed measures the different usage patterns of FastBertTokenizer itself (single threaded, memory-reusing, batched/multi threaded, batch enumerators). It runs every benchmark for the local build as well as for the released NuGet baseline version, each on all supported (non-EOL) runtimes. This answers "did my change make it faster?" and "is the next release faster than the current one?".
  • OtherLibs compares FastBertTokenizer against other tokenizer libraries usable from .NET: Microsoft.ML.Tokenizers, Tokenizers.DotNet (bindings for Hugging Face's Rust tokenizers) and BlingFire (Microsoft's C++ tokenizer, unmaintained since ~2021 but long the go-to fast BERT tokenizer for .NET; it doesn't read vocab.txt — its precompiled bert-base-uncased model ships in data/blingfire/).

Tokenizers that are not natively usable from .NET are benchmarked from their own ecosystems instead, so interop overhead doesn't skew their numbers:

All benchmarks tokenize the same corpus - 15,000 articles from simple english wikipedia - with the same vocabulary (baai-bge-small-en, which uses bert-base-uncased's vocab) and truncate to 512 tokens per input.

Running

Prerequisites: the .NET SDK version pinned in global.json plus the .NET 8 runtime. The benchmark corpus (data/wiki-simple.json.br) ships in the repo and is available after a normal clone.

cd src/Benchmarks

# FastBertTokenizer usage patterns (local build + NuGet baseline, net8.0 + net10.0):
dotnet run -c Release -f net10.0 -- --filter '*TokenizeSpeed*'

# comparison against other libraries:
dotnet run -c Release -f net10.0 -- --filter '*OtherLibs*'

# everything, but with quick short-running jobs (what CI does on PRs):
dotnet run -c Release -f net10.0 -- --smoke --filter '*'

# list all benchmarks:
dotnet run -c Release -f net10.0 -- --list flat

--smoke is this project's own flag and selects BenchmarkDotNet's short-run jobs (and skips the NuGet baseline); everything else is passed through to BenchmarkDotNet, see its docs for all arguments. Results are written to BenchmarkDotNet.Artifacts/.

The released baseline version measured by TokenizeSpeed is defined in BenchmarkConfigs.cs (BenchmarkDefaults.BaselineNuGetVersion) and must be kept in sync with the FastBertTokenizer PackageReference in Benchmarks.csproj and the centrally pinned version in Directory.Packages.props.

For the Python harness see ../HuggingfaceTokenizer/BenchPython: pip install -r requirements.txt && python verify.py && python bench.py.

CI

benchmark.yml runs a quick smoke pass on every push/PR (except markdown-only changes; it verifies the benchmarks and the measured API surface still work) and the full suite on demand (workflow_dispatch) and monthly. Every run uploads the complete BenchmarkDotNet.Artifacts results as workflow artifacts.

Results

Numbers below are from this full CI run on a shared GitHub Actions runner. They are reproducible by anyone, but noisier than numbers from dedicated hardware — treat small differences as noise, and note that multi-threaded results depend on the runner's (few) cores.

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores (GitHub Actions shared runner)
.NET SDK 10.0.302

FastBertTokenizer usage patterns: .NET 8 vs. .NET 10

  • Workload: Encode up to 512 tokens from each of the 15,000 articles (3,657,145 tokens produced).
  • ~12.5m tokens/s single threaded, ~31.6m tokens/s multi threaded on the runner's 4 vCPUs.
  • local jobs measure the working tree built from source, nuget jobs the released baseline package.
MethodJobRuntimeMeanErrorStdDevRatioAllocatedAlloc Ratio
SinglethreadedAllocatinglocal-net10.0.NET 10.0293.4 ms3.31 ms3.09 ms1.002039.61 KB1.00
SingleThreadedMemReuselocal-net10.0.NET 10.0289.0 ms2.98 ms2.64 ms0.99996.98 KB0.49
MultithreadedMemReuseBatchedlocal-net10.0.NET 10.0115.6 ms2.31 ms4.27 ms0.3913006.03 KB6.38
MultithreadedMemReuseAtOncelocal-net10.0.NET 10.0116.8 ms1.74 ms1.54 ms0.40180987.01 KB88.74
ParallelBatchEnumeratorlocal-net10.0.NET 10.0234.4 ms2.64 ms2.47 ms0.804795.53 KB2.35
BatchEnumeratorlocal-net10.0.NET 10.0458.1 ms6.92 ms6.13 ms1.562308.48 KB1.13
SinglethreadedAllocatinglocal-net8.0.NET 8.0296.6 ms2.84 ms2.66 ms1.002039.61 KB1.00
SingleThreadedMemReuselocal-net8.0.NET 8.0302.0 ms3.97 ms3.71 ms1.02996.98 KB0.49
MultithreadedMemReuseBatchedlocal-net8.0.NET 8.0130.7 ms2.34 ms2.88 ms0.4413006.34 KB6.38
MultithreadedMemReuseAtOncelocal-net8.0.NET 8.0130.0 ms1.43 ms1.34 ms0.44180986.88 KB88.74
ParallelBatchEnumeratorlocal-net8.0.NET 8.0252.7 ms4.03 ms3.77 ms0.854798.02 KB2.35
BatchEnumeratorlocal-net8.0.NET 8.0479.5 ms4.21 ms3.94 ms1.622308.48 KB1.13
SinglethreadedAllocatingnuget-1.0.28-net10.0.NET 10.0282.0 ms1.22 ms1.08 ms1.002039.61 KB1.00
SingleThreadedMemReusenuget-1.0.28-net10.0.NET 10.0287.7 ms2.55 ms2.39 ms1.02996.98 KB0.49
MultithreadedMemReuseBatchednuget-1.0.28-net10.0.NET 10.0112.1 ms2.20 ms2.06 ms0.4013009.12 KB6.38
MultithreadedMemReuseAtOncenuget-1.0.28-net10.0.NET 10.0114.7 ms1.92 ms1.80 ms0.41180987.01 KB88.74
ParallelBatchEnumeratornuget-1.0.28-net10.0.NET 10.0222.3 ms2.96 ms2.77 ms0.794796.37 KB2.35
BatchEnumeratornuget-1.0.28-net10.0.NET 10.0452.6 ms7.66 ms6.79 ms1.612308.48 KB1.13
SinglethreadedAllocatingnuget-1.0.28-net8.0.NET 8.0307.4 ms2.49 ms2.33 ms1.002039.61 KB1.00
SingleThreadedMemReusenuget-1.0.28-net8.0.NET 8.0304.1 ms2.08 ms1.84 ms0.99996.98 KB0.49
MultithreadedMemReuseBatchednuget-1.0.28-net8.0.NET 8.0152.5 ms3.04 ms3.62 ms0.5013006.34 KB6.38
MultithreadedMemReuseAtOncenuget-1.0.28-net8.0.NET 8.0161.2 ms2.30 ms2.15 ms0.52180986.88 KB88.74
ParallelBatchEnumeratornuget-1.0.28-net8.0.NET 8.0274.2 ms3.46 ms3.23 ms0.894808.74 KB2.36
BatchEnumeratornuget-1.0.28-net8.0.NET 8.0496.6 ms4.43 ms4.15 ms1.622308.48 KB1.13

vs. other tokenizer libraries for .NET

All single threaded on .NET 10, same environment and run as above:

MethodMeanErrorStdDevRatioAllocatedAlloc Ratio
FastBertTokenizer291.9 ms3.40 ms3.02 ms1.002039.61 KB1.000
MicrosoftMLTokenizers813.5 ms6.50 ms6.08 ms2.79106349.56 KB52.142
BlingFire1,260.7 ms2.42 ms2.14 ms4.320.02 KB0.000
TokenizersDotNet5,311.9 ms45.17 ms42.25 ms18.2014778.24 KB7.246

Fairness notes: the libraries don't do exactly the same work — FastBertTokenizer emits input_ids and attention_mask, Microsoft.ML.Tokenizers and Tokenizers.DotNet emit just input_ids, and Hugging Face tokenizers (behind Tokenizers.DotNet) computes offsets and more. Tokenizers.DotNet's number includes its per-call .NET↔Rust interop cost, which is inherent to using it from .NET; for Hugging Face tokenizers numbers without .NET interop see the cross-language results below (Python-driven — a fully native measurement is possible via BenchRust, whose results are not included in these tables). BlingFire does the least work of all: ids only, without [CLS]/[SEP], and its precompiled model agrees with Hugging Face on ~99.9% of tokens rather than exactly. Also note that the Allocated column tracks managed GC allocations only: whatever BlingFire (C++) and Tokenizers.DotNet's Rust side allocate natively is invisible to BenchmarkDotNet's MemoryDiagnoser, so the column is only meaningful for the pure-managed libraries. Correctness also differs: FastBertTokenizer's output is continuously tested to match Hugging Face transformers' AutoTokenizer.

Cross-language: Hugging Face tokenizers (Rust), flash-tokenizer (C++) and tokie (Rust)

From the same CI run (Python 3.12, tokenizers 0.23.1, flash-tokenizer 1.2.0, tokie 0.0.10), measured from Python — the way virtually all users of these libraries consume them — tokenizing the full corpus once:

BenchmarkMean
hf_tokenizers_singlethreaded9.13 s ± 0.05
flash_tokenizer_singlethreaded1.02 s ± 0.00
tokie_sequential_calls817 ms ± 11
hf_tokenizers_batch (parallel)4.10 s ± 0.03
flash_tokenizer_batch (parallel)731 ms ± 20
flash_tokenizer_batch_ids_only489 ms ± 2
tokie_batch (parallel)634 ms ± 5

The single-threaded Hugging Face number includes per-call Python overhead; the batch mode amortizes that and additionally parallelizes across documents, so these numbers don't isolate the Rust core's raw speed (BenchRust can measure that natively, but its results are not included in these tables). flash-tokenizer's batch number includes its pure-Python attention_mask/token_type_ids construction; flash_tokenizer_batch_ids_only measures it without that. tokie may parallelize internally even for single calls, so read its tokie_sequential_calls number as "sequential API calls", not necessarily "one core". An id-level parity check (verify.py) shows flash-tokenizer produces ids identical to Hugging Face tokenizers for 99.6% of the corpus documents, while tokie matches exactly. For scale: FastBertTokenizer tokenizes the same corpus single threaded in ~0.3 s in the same CI run (tables above).