Architecture: Performance¶
The optimization strategies behind disarm's speed against pure-Python alternatives: 24–116× Unidecode on Latin-script text, ~13× on Cyrillic, Greek, Arabic, Persian and Hebrew, and ~2.4–4.8× on the Indic and Southeast Asian scripts, whose romanization does work Unidecode does not (Performance has the figures and the machine they were recorded on).
The PyO3 boundary problem¶
Every call from Python into Rust crosses the PyO3 boundary: argument conversion, the call itself, and result conversion back to a Python object. A native call on a short string costs a few tens of nanoseconds (transliterate("hello") is ~41 ns in total), so on short input the boundary, not the transliteration, is most of the cost, and anything layered on top of it shows. A Python wrapper function around the native call costs more than the call: the #469 surrogate guard, while it was one, added 70–84 ns to every call, and it is now native (#1067). Every optimization strategy below either reduces the time spent in the boundary or reduces the number of crossings.
Optimization 1: One crossing, and the original object back¶
transliterate() is bound directly to its native entry point (#277): a call on a str
crosses into Rust once, with no Python function in between, and keyword defaults cost
nothing. When the engine leaves the text unchanged (pure ASCII, the common case in
English workloads) it returns the caller's own str object rather than a copy, so a
no-op allocates nothing. The check lives in the core, not in Python, so lang is still
validated on ASCII input (#197).
Call (fresh str each time) |
Cost |
|---|---|
transliterate("hello") |
~41 ns |
strip_accents("hello") |
~54 ns |
Optimization 2: Flat BMP array¶
The default transliteration table covers U+0080–U+FFFF as a flat [Option<&'static str>; 65408] array indexed by (codepoint - 0x80). Lookup is a bounds check and an array dereference — no hashing, no collision chain, no cache-unfriendly pointer chasing.
The array occupies ~512 KB in the .rodata section. The OS pages it in on demand; only the pages containing accessed codepoint ranges are resident in memory.
This replaced a PHF map for BMP lookups and delivered the single largest speedup: Latin transliteration went from 34× faster than Unidecode to 38× faster.
Optimization 3: Range-based dispatch¶
Before consulting the flat BMP array, lookup_default() dispatches by codepoint range to dedicated handlers:
- CJK Unified Ideographs → Hanzi pinyin PHF table
- Hangul syllables and compatibility jamo → algorithmic romanization
This avoids probing the 65K-entry array for scripts that have their own higher-quality tables. It also means the flat array doesn't need entries for CJK/Hangul ranges, keeping it focused on Latin/Cyrillic/Arabic/Greek and other BMP scripts.
Optimization 4: Cow<'a, str> return¶
transliterate_impl() returns Cow<'a, str>. When the input is pure ASCII (detected via str::is_ascii()), it returns Borrowed — a pointer to the input with no allocation. Non-ASCII input returns Owned with a pre-sized buffer. The Cow type is transparent to callers and avoids the cost of cloning strings that don't need modification.
Optimization 5: Capacity pre-sizing¶
The output buffer is pre-sized based on the first non-ASCII character's script:
- CJK/Hangul/kana: 4× input byte length (each character expands to a multi-letter syllable plus space)
- Everything else: 1× input byte length (most characters map 1:1)
This heuristic eliminates reallocations for the two most common workload shapes. The cost of over-sizing (a few KB of unused capacity) is negligible compared to the cost of repeated reallocations that memcpy the entire buffer.
Optimization 6: List input (batch processing)¶
transliterate(), slugify(), normalize(), and strip_accents() accept a list[str] and process all strings in a single PyO3 boundary crossing, with the GIL released for the compute loop. transliterate borrows each item's UTF-8 and hands an unchanged item back as the original object (#1069). Because a single call is already one cheap crossing, the list form saves the per-call overhead, not the work:
transliterate, 100 fresh strings |
List | Loop | Speedup |
|---|---|---|---|
| ASCII | 9.1 µs | 12.5 µs | 1.4× |
| Mixed scripts | 43.4 µs | 50.0 µs | 1.15× |
Optimization 7: Consistent Rust-native normalization¶
normalize() uses the Rust unicode-normalization crate (Unicode 16.0) for
every input — single strings, list inputs and pipelines. The core returns ASCII
unchanged without running the normalizer (ASCII is invariant under all four forms),
and since #1064 it runs the normalizer only over the segments that can change. This
ensures consistent results across code paths and eliminates Unicode version
mismatches between CPython's unicodedata and the Rust crate's tables.
While CPython's unicodedata.normalize() is faster for single-string calls
(it operates directly on PEP 393 compact strings with zero-copy semantics),
the correctness tradeoff is worth the performance cost: using a single
Unicode version prevents subtle bugs where different code paths produce
different results for codepoints assigned between Unicode versions.
Optimization 8: PHF for specialized data¶
All secondary lookup tables (Hanzi pinyin, confusables, case folding, emoji) use compile-time perfect hash functions via phf_codegen. PHF provides O(1) lookup with no runtime allocation, no collision handling, and deterministic performance. The tables are generated at build time by build.rs and embedded as static data.
What disarm does NOT optimize¶
Two operations are inherently slower than their CPython C-builtin counterparts:
- Normalization:
unicodedata.normalize()operates on CPython's internal string buffer without copying. disarm uses Rust for all normalization (consistency over speed — see Optimization 7). - Case folding:
str.casefold()is a CPython C builtin with zero allocation overhead. On a short stringfold_case()is ~1.7× slower, the gap being the boundary crossing; on a paragraph it is ~1.9× faster, since #1066 answers an already-folded character from a bitmap instead of a table probe.
These gaps are acceptable because normalization and case folding are rarely the bottleneck in real workloads — transliteration and slugification dominate processing time, and disarm is ~6–11× faster than python-slugify and 2.4–116× faster than Unidecode, by script, for those.