假設使用者搜尋 café,而您的語料庫包含 CAFÉ;或者他們輸入 straße,而您儲存的是 STRASSE。為了讓這些視為相符,您需要一個能消除大小寫差異的正規形式,使僅在大小寫上不同的兩個字串能比較相等。那種形式就是大小寫折疊(case folding),它出現在任何進行文字比對而非顯示的場景:搜尋引擎、正規表示式 (?i) 旗標、大小寫不敏感的使用者名稱與主機名稱。
這是個基本操作,但在 GitHub 我們執行它的次數非常多。GitHub 的程式碼搜尋引擎 Blackbird 索引超過 1.8 億個儲存庫——超過 480TB 的原始碼。在我們提取 ngram 與建立索引前,每個位元組都會先進行大小寫折疊;而對於每個可能的查詢結果,還需要另一次(隱含或明確)大小寫折疊操作來定位符合項目。在這樣的規模下,即使是基本操作的速度也開始變得重要。
這篇文章說明我們如何讓它變快,而起點卻有點反直覺:在 ASCII 快速路徑中,最大的效益來自移除一項最佳化,而不是新增。事實證明,無分支地掃過整個緩衝區,比在第一個非 ASCII 位元組就提前停止還要快。我們已將成果開源為名為 casefold 的 Rust 套件。
折疊不是轉小寫
人們很容易想要使用 str::to_lowercase,但轉小寫與折疊是目的不同的兩種操作:
轉小寫是用於顯示,它與地區與上下文相關:希臘文詞尾 sigma 在字尾會轉成 ς,其餘地方轉成 σ;而土耳其文的 I 與英文的 I 轉小寫方式也不同。大小寫折疊是用於比對,它刻意不依賴上下文與地區。重點是要建立一個在任何地區都能保持穩定且對稱的關係,使若 A 折疊後能與 B 相符,則 B 折疊後也能與 A 相符。Unicode 字元資料庫正是為此提供明確的 CaseFolding.txt。
這兩種操作在真實字元上有所差異——ß、İ、詞尾 sigma——這就是為什麼直接以轉小寫作為替代,會在無聲無息中產生錯誤的比對結果。本套件僅實作簡單(1 對 1)的折疊——也就是 CaseFolding.txt 中的 C 與 S 狀態——而不包含多字元「完整」折疊(ß → ss)或土耳其地區折疊(帶點的 İ)。這不是少見的選擇:常見工具與正規表示式引擎如 ripgrep 也採取相同的限制,而跨工具的一致性很重要。
反直覺的核心:不要提前停止
我們處理的主要是原始碼,因此要折疊的文字絕大多數是 ASCII,而讓它以記憶體速度執行,是我們能做的最重要的事。其餘一切都只需確保罕見的非 ASCII 路徑不會破壞效能。
ASCII 字母的折疊很簡單——A..=Z 對應到 a..=z,其他位元組保持不變——因此 ASCII 階段其實就是「掃過緩衝區、原地轉小寫」。若請任何 LLM 產生程式碼,可能會得到類似這樣的結果:
let bytes = s.as_bytes_mut();
for (i, b) in bytes.iter_mut().enumerate() {
if *b >= 0x80 {
break; // non-ASCII at index i: hand the rest to the Unicode path
}
if b.is_ascii_uppercase() {
*b += 32; // 'A'..='Z' → 'a'..='z'
}
}
這看起來很理想:做便宜的位元組工作,一碰到非 ASCII 位元組就中斷,讓「真正的」Unicode 路徑接手:「只在必要時才做昂貴的工作。」在 Apple M4 上,這段程式碼約可達到 每秒 3 GiB。單獨看起來不錯,但由於 if 分支的關係,它比「最佳」慢了 15 倍以上。
讓我們逐行刪除所有分支:
if b >= 0x80 { break }→ 完全不要停止。把每個位元組OR進累加器,在迴圈結束後再檢查一次:high_bit_acc |= *b。取得相同資訊(是否有任何非 ASCII 位元組?),卻在主體中沒有分支。A..=Z範圍測試 → 改用算術運算。b.wrapping_sub(b'A') < 26恰好對A..=Z為真(其他位元組會回繞成 ≥ 26),產生 0/1 遮罩而無需分支。- 條件式寫入 → 把遮罩併入儲存。
| (is_upper << 5)會設定第 5 位元——把大寫字母轉成小寫,對其他位元組則是無操作——位元組總是被寫入,從不分支。
剩下的程式碼在主體中沒有分支,也沒有提前結束:
let mut high_bit_acc: u8 = 0;
for b in &mut bytes {
high_bit_acc |= *b; // detect any non-ASCII byte
let is_upper = b.wrapping_sub(b'A') < 26; // branchless A..=Z test
*b |= u8::from(is_upper) << 5; // set bit 5 → lowercase, else no-op
}
if high_bit_acc & 0x80 == 0 {
return bytes; // pure ASCII: already folded in place, no second buffer
}
沒有資料相依控制流的迴圈很容易向量化:LLVM 會一次發出 16 位元組的 NEON,整段程式碼可達到 超過每秒 45 GiB——基本上就是記憶體頻寬。而且我們在這個階段結束時,已經從 high_bit_acc 得知是否還有非 ASCII 工作要做。
每個步驟各貢獻多少效益?以下是在純 ASCII 文字上(Apple M4,5.7 KB 緩衝區)累積測量的結果:
| 版本 | 吞吐量 | 已向量化? |
|---|---|---|
| naive (break + branch test) | 3.1 GiB/s | no (0 vector instrs) |
| → branchless test/write, keep break | 2.6 GiB/s | no (0 vector instrs) |
| → drop the early-exit break | 7.6 GiB/s | partially (25 vector instrs) |
| → branchless test + write (the loop) | >45 GiB/s | fully (41 vector instrs) |
提前結束正是向量化的大門:保留 break 但讓主體完全無分支,依然會得到 零 個向量指令(約 2.6 GiB/s);資料相依的迴圈結束本身,就足以讓迴圈維持純量。只有在 break 消失後,編譯器才能進行向量化。最後一步——讓大寫折疊也無分支——才把「部分」向量化(條件式儲存仍會編譯成 compare-blend-masked-store,約 7.6 GiB/s)的迴圈,轉成能達到記憶體頻寬的直線算術。
注意:Branchless 在純量程式碼中其實是「負最佳化」。 再看一次表格:在保留 break 的情況下讓主體無分支(2.6 GiB/s),實際上比有分支的 naive 迴圈(3.1 GiB/s)還慢。組語說明了原因。有分支的版本只在真的改變位元組時才寫入;它的條件式 strb 會對每個小寫字母、數字與空格(真實文字的絕大部分)跳過,而保護它的分支預測幾乎是免費的。無分支版本則把這個很少執行的儲存,換成每次迭代都無條件 strb,把全部約 5,700 個位元組都寫回去,而不只是少數大寫字母。多餘的寫入流量毫無益處。Branchless-write 只有在迴圈向量化後才會贏,因為此時儲存變成單一 16 位元組的向量寫入,與內容無關,每位元組成本消失。教訓是:無分支主體只有在能啟用向量化時才有價值。單獨在純量程式碼中,它可能讓你付出代價。
還有一個中間方案,這也是標準函式庫採用的作法。[u8]::is_ascii 不是一次測試一個位元組,而是以機器字為單位掃描——在 64 位元目標上,它一次用兩個 u64 通道做 OR,再用單一 & 0x8080_8080_8080_8080 遮罩檢查所有高位元,一次測試 16 個位元組。您可以在此之上建立 ASCII 快速路徑:先以區塊掃描找出 ASCII 前綴,再對它執行無分支(可向量化的)轉換。這保留了提前結束的能力——遇到第一個非 ASCII 區塊就退出——同時讓兩半都很快。缺點是它會把資料讀兩次(一次掃描,一次轉換),速度約為 每秒 23 GiB——大約是單次無分支掃描的一半,卻是 naive break 迴圈的約 7 倍。這是個穩固的通用預設值;但當您能掌控整個迴圈,並把偵測與轉換併入單一次無分支通過時,它就不是絕對上限。
把兩次通過「融合」在一起會更快嗎? 這是顯而易見的下一個想法:保留分區塊的提前結束,但在確認某個 16 位元組區塊是 ASCII 後,立刻對它進行轉換,只讀取資料一次。實測結果卻慢了 約 2.6 倍——8.7 GiB/s 對兩次通過的 23 GiB/s。內層區塊轉換仍然會向量化成單一 16 位元組操作,但現在每 16 位元組就會有一次資料相依的提前結束分支,而這個分支會把迴圈鎖定在一次處理一個區塊:編譯器不會跨區塊進行展開或軟體管線化,每次迭代都要付出完整的 load→test→branch→convert→store 延遲,而且沒有任何東西可以隱藏它。拆成兩次通過後,每一次都很乾淨:掃描是分支少且不寫入的字元掃描,能快速穿越記憶體;而轉換則是完全向量化、無分支的掃描,速度超過每秒 45 GiB。兩個快速、無分支的通過,勝過一次有分支的融合通過——即使融合版本接觸資料的次數只有一半。同樣的教訓再次出現:在熱迴圈中,分支就是敵人。
避免堆積
每秒 45 GiB 也意味著零不必要的配置。simple_fold 以值的方式接收輸入 String,擁有可變動並回傳的堆積緩衝區。若 OR 累加器的高位元為零,表示輸入已是純 ASCII 且已在原地折疊。我們直接把同一個配置交還,不需要第二個緩衝區,也不需要複製。否則,我們用 memchr 找到第一個非 ASCII 位元組,再從那裡掃描尾端,直到遇到會折疊成不同位元組的字元前,都讓輸出緩衝區保持未配置狀態(寫入游標為 null)。那些多位元組內容從不折疊的文字——CJK、韓文假名、阿拉伯文、希伯來文、符號——也會原封不動地回傳原始配置,從不複製任何位元組。
為什麼需要第二個緩衝區,而不是像 ASCII 階段那樣原地改寫?因為折疊可能讓字串變長:幾乎所有折疊都會保持或縮短 UTF-8 長度,但有兩個例外會增長——U+023A (Ⱥ) 與 U+023E (Ɀ) 原本各 2 位元組,折疊後卻變成 3 位元組字元(ⱥ、ɀ)。一旦出現其中之一,輸出就不再能塞進輸入的位元組,我們就需要新的地方來寫入。
我們只配置一次緩衝區,並依最壞情況調整大小,而不是隨著折疊出現而逐步擴充。逐步的 reserve 呼叫意味著要不斷檢查容量、偶爾重新配置、複製目前已寫入的內容,並處理額外的長度/容量簿記;一次性的預先配置則讓原始寫入游標能直達結尾,完全不需要那些額外步驟。而且因為游標在第一次增長/改變的折疊出現前是 null,它同時也扮演「是否已配置額外緩衝區」的旗標。
決定大小需要成長上限,而那兩個例外字元提供了依據:每 2 個輸入位元組最多產生 3 個輸出位元組,因此輸出上限為輸入的 1.5 倍——這正是我們保留的容量:
out = Vec::with_capacity(bytes.len() + bytes.len() / 2 + 4);
之後迴圈會透過原始指標寫入,完全不做容量檢查,並在最後只呼叫一次 set_len。另外兩個細節能讓分支保持輕量。兩次折疊之間不變的位元組區塊,會用單一次 copy_nonoverlapping 移動,而不是逐位元組複製。而每次折疊都會無條件寫入一個小端序字的全部 4 個位元組,再把游標只前進折疊後的長度(1–4)——把輸出長度的分支從熱路徑移除,而保留區中的 + 4 則是讓最後一個字元的多餘寫入安全的餘裕。
讓 Unicode 也變便宜
當字元真的需要折疊時,我們仍然不希望效能斷崖式下滑——解碼 UTF-8、雜湊查表、再編碼。Unicode 16.0 有 1484 個簡單折疊對應,但它們是非常稀疏且結構化的關係。四個觀察讓我們把表格縮小到 1776 位元組,並讓折疊在完全不解碼完整字元的情況下執行。
即使在非 ASCII 路徑上,絕大多數字元也不會折疊。熱操作其實不是「折疊這個字元」,而是「這個字元會折疊嗎?」答案幾乎總是「不會」。表格必須讓這個否定測試盡可能便宜;真正的折疊是已經罕見的路徑中更罕見的情況。這個優先順序塑造了下方的配置——頁面點陣圖的存在,正是為了讓不折疊的字元能直接從其 UTF-8 開頭位元組,經單一位元測試就遭到拒絕,而無需解碼或掃描任何東西。
這正是為什麼 HashMap<u32, u32> 不適合這項工作,而且不只是「比較大」的問題。雜湊映射針對命中做了最佳化:它大約一次探測就能找到存在的鍵,只有在負載因子或碰撞發生時才會花額外工夫(更多探測、完整鍵比對)。但我們的工作負載以未命中為主——完全不在表格中的字元——而未命中是雜湊映射最不喜歡的查詢:它仍然必須對鍵做雜湊、跳到桶、並走完探測序列到足以證明不存在的程度。
可折疊碼位會聚集在 64 碼位的「頁面」中
可折疊碼位會聚集在一起。把碼位空間切成 64 碼位的「頁面」,約 1484 個折疊只會觸及約 1960 個可能頁面中的 59 個。每頁一位元的存在 點陣圖就能獨力回答否定測試:位元為零就是絕對「不折疊」——直接複製通過,完成——這讓不含折疊的文字變得便宜。只有在位元為 1 時,我們才會查詢第二個結構:一個累積 popcount 側邊表,它會對頁面進行排名(計算它之前有多少已填充頁面),以找到其條目切片,對約 1900 個空白頁面則不儲存任何東西。
let (word_idx, bit_idx, c_len) = if lead < 0xE0 {
(0usize, lead & 0x1F, 2usize) // 2-byte: word 0
} else if lead < 0xF0 {
((lead & 0x0F) as usize, bytes[read + 1] & 0x3F, 3) // 3-byte: word = nibble
} else {
(
(((lead & 0x07) as usize) << 6) | (bytes[read + 1] & 0x3F) as usize,
bytes[read + 2] & 0x3F,
4usize,
) // 4-byte: merge 2 bytes
};
// reject without decoding: clear bit ⇒ no fold
if word_idx >= PAGE_BITMAP.len() || (PAGE_BITMAP[word_idx] >> bit_idx) & 1 == 0 {
read += c_len;
continue;
}
因為 word_idx 只依賴開頭位元組(以及四位元組序列的第一個接續位元組),點陣圖載入可以很早發出。
在頁面內,折疊以連續區塊出現
頁面位元為 1 告訴我們這個頁面有東西會折疊,但不知道是哪些碼位、折疊成什麼。顯而易見的編碼方式是為每個可折疊碼位各存一筆——但這樣既龐大,搜尋也慢:一個頁面可能有數十個折疊,而我們必須掃描全部才能找到符合目前碼位的那一個。資料的結構再次拯救我們。相鄰碼位壓倒性地共用相同的折疊差值:A–Z 全部對應 +32,而 Latin Extended 充滿交替區塊,例如 0x0100、0x0102、0x0104…… 每隔一個碼位就會折疊。我們不是為每個碼位各存一筆,而是儲存區塊——起點、終點、步距、差值——而一個 1 位元的步距旗標就能涵蓋連續與每隔一個兩種情況。這種區間壓縮把約 1484 個個別折疊壓縮成 59 個頁面中的僅 238 個區塊(平均每頁約四個),讓頁面內搜尋只需查看少數幾筆,而非數十筆。這種帶差值的範圍編碼(包含步距技巧)借用自 Go 的 unicode 套件,其 CaseRange 記錄儲存 Lo/Hi 範圍加上各大小寫差值,並以 UpperLower 哨兵標記交替區塊。區塊會在頁面邊界切斷,因此一個區塊永遠不會跨越兩個頁面。
一個區塊記錄只有兩個乾淨位元組
因為兩個端點都在同一個頁面內,它們可以用 6 位元儲存,分成兩個陣列:RUN_END_LOW[``i``] = end & 0x3F(掃描鍵)與 RUN_START_STRIDE[``i``] = (start & 0x3F) | ((stride − 1) << 6)(僅在命中時讀取)。因為每個鍵都是一個乾淨位元組,頁面內搜尋可以做得很寬:我們不是一次比對一個 cp & 0x3F 與區塊,而是把 8 個 end_low 位元組載入單一 u64,再用一次無分支 SWAR 步驟同時測試全部——(chunk | 0x80…80) − broadcast(low) & 0x80…80 會把鍵值 ≥ cp & 0x3F 的每個通道最高位元設為 1。對該遮罩做單一次位元掃描(鍵已排序,因此第一個被設定的通道就是我們要的區塊)就能找到位置。一個頁面平均約有 4 個區塊;那個 8 寬度的比較幾乎總能一步解決整個搜尋。只有一個不幸的頁面有 30 個區塊,這會讓比較進入一個短迴圈,每次跨 8 個鍵前進——但這個迴圈在整個 Unicode 中最多只會在一個頁面上觸發幾次,而且絕對不會發生在常見頁面上。無論如何:沒有每個區塊的分支,也不需要在任何地方重建碼位。
/// Offset of the first run with `end_low >= low_v` in a page of `n` runs,
/// or `n` if none. Scans 8 `end_low` bytes at a time via SWAR.
#[inline]
fn scan_end_low(lo: usize, n: usize, low_v: u8) -> usize {
const HIGH: u64 = 0x8080_8080_8080_8080;
const ONES: u64 = 0x0101_0101_0101_0101;
let bcast = (low_v as u64).wrapping_mul(ONES);
let mut base = 0;
while base < n {
// RUN_END_LOW is padded by 8 bytes so this read is always in bounds.
let chunk = u64::from_le_bytes(
RUN_END_LOW[lo + base..lo + base + 8]
.try_into()
.expect("8-byte slice"),
);
// `(b | 0x80) - low_v` keeps its high bit iff `b >= low_v` (no
// cross-lane borrow). The first set lane is the first run `>= low_v`.
let ge = (chunk | HIGH).wrapping_sub(bcast) & HIGH;
if ge != 0 {
let j = base + (ge.trailing_zeros() / 8) as usize;
return if j < n { j } else { n };
}
base += 8;
}
n
}
折疊就是小端序位元組加法
在小端序機器上,把折疊後字元的 UTF-8 位元組視為 u32 讀取,其值等於來源位元組(同樣視為 u32)加上一個每個區塊的常數。接著一個平行的 BYTE_DELTA[i] 表格,就能把整個折疊變成一次遮罩載入、一次 wrapping_add,以及一次 4 位元組儲存:
let word = u32::from_le_bytes(next_four_bytes) & length_mask; // keep this char's bytes
let folded = word.wrapping_add(BYTE_DELTA[i]); // the fold, as one byte add
write_u32_le(dst, folded); // store all 4 bytes...
dst += utf8_len(folded); // ...advance by the folded length
片段中的兩個長度——來源字元的 length_mask 與目的地「依折疊後長度前進」——來自另一個小技巧。UTF-8 序列的長度由其開頭位元組的前四個位元決定,讓 16 種可能的長度各以一個半位元組塞進單一 64 位元常數(0x4322_1111_1111_1111);長度則是移位加遮罩:(LEN_BITS >> (4 * (lead >> 4))) & 0xF——沒有 if 鏈、沒有表格記憶體,也沒有任何東西會讓預測器出錯。(計算前導 1——(!lead).leading_zeros()——也行得通,因為一個開頭位元組會為序列中的每個位元組各帶一個前導 1 位元。)
/// Number of bytes in the UTF-8 sequence whose lead byte is `lead`.
#[inline]
pub fn utf8_len(lead: u8) -> usize {
const UTF8_LEN_BY_LEAD: u64 = 0x4322_1111_1111_1111;
((UTF8_LEN_BY_LEAD >> (4 * (lead >> 4))) & 0xF) as usize
}
因為我們依折疊後長度前進,這甚至能處理長度會改變的折疊——U+212A KELVIN SIGN(3 位元組)→ k(1 位元組),或 U+023A Ⱥ(2 位元組)→ U+2C65 ⱥ(3 位元組)——寫入的位元組數會比讀取的少或多。這部分我們認為是真正新的:我們看過的其他折疊器——ICU、Go 的 unicode、Rust 的 regex、CPython、glibc——都是先把 UTF-8 解碼成碼位、在碼位上套用折疊,再重新編碼(即使是 SIMD 折疊器也會先解碼)。在位元組空間做算術跳過了解碼與編碼,這正是為什麼這條路徑能跑贏已經把答案做成表格的雜湊映射——雜湊映射仍然必須對它的鍵解碼,並對結果編碼。位元組空間算術假設輸入是格式正確、最短形式的 UTF-8——每個碼位都用最少的位元組編碼。把來源位元組視為 u32 讀取並加上每個區塊的差值,只有在來源是正規形式時才會得到正確的折疊編碼;過長編碼(把碼位填入比必要更多位元組,例如 / 寫成 0xC0 0xAF)會有不同的位元組模式,並破壞 length_mask 與差值算術。這在 Rust 中不是真正的限制——&str/String 保證持有有效的 UTF-8,而有效 UTF-8 依定義就會拒絕過長序列——但從其他地方餵入原始位元組的呼叫者必須先驗證(或以其他方式正規化)它們。
尾端迴圈中的 ASCII 捷徑
另一個捷徑補足了尾端迴圈。請記得第一階段已經把所有 ASCII 位元組轉小寫,因此當掃描在尾端遇到 ASCII 位元組時,它會一次前進一個位元組然後繼續——完全不需要頁面探測,也不會碰到任何表格。它也不會複製那個位元組:未修改的位元組(ASCII 與不折疊的多位元組皆然)不會一個一個移動。掃描只會持續前進,直到遇到真正會折疊的字元,然後才用一次 copy_nonoverlapping 把上次折疊到這次折疊之間的整個未改變區塊寫出。因此,混合文字——帶有 ASCII 空格與標點的 CJK,或偶爾出現重音識別碼的程式碼——會快速穿越 ASCII 填充物,只對真正的多位元組字元查詢點陣圖,並以大量複製而非逐位元組複製的方式處理。
把一切組合起來:完整表格
| 元件 | 位元組 |
|---|---|
| PAGE_BITMAP(每 64-cp 頁面 1 位元) | 248 |
| POPCNT_SAMPLES(累積 popcount) | 32 |
| PAGE_OFFSET(每個已填充頁面) | 60 |
| RUN_END_LOW(掃描鍵,end & 0x3F,+8 填充) | 246 |
| RUN_START_STRIDE(start & 0x3F | stride) | 238 |
| BYTE_DELTA(每個區塊的小端序折疊差值) | 952 |
| 總計 | 1776 |
這是每個折疊條目 9.6 位元,其中一半以上是我們為了換取無解碼路徑而付出的 BYTE_DELTA 側邊表;索引加上區塊記錄本身約為 4.4 位元/條目。
與明顯的替代方案相比,這 1776 位元組小了一個數量級以上——而且與它們大多數不同,它從不解碼字元:
| 表示方式 | 大小 |
|---|---|
| naïve [(u32, u32); 1484] | 約 11.6 KB |
| regex-syntax 的 case_folding_simple 表格 | 約 70 KB |
| Go 的 unicode.SimpleFold(orbit + ASCII + ranges) | 約 7.3 KB |
| 執行期 HashMap<u32, u32> | 約 17 KB |
| 本套件(分頁點陣圖 + 壓縮區塊) | 1776 B |
與替代方案的比較結果
在常見情況(ASCII)下,折疊以記憶體頻寬(超過每秒 45 GiB)執行,比其他真實折疊器快了一個數量級以上,也比(非等價的)str::to_lowercase 函式快了 50% 以上。為了對非 ASCII 情況取得粗略的「上限」,我們使用 simdutf 套件測量了最佳化的 Utf8 解碼 + 編碼來回,而沒有執行任何實際的大小寫折疊。這個實驗穩定達到約每秒 2GB,只比我們在最壞情況(全部都要折疊)的輸入快約兩倍。naïve 雜湊映射在所有工作負載上都落後。
三個欄位是會產生相同輸出的真實大小寫折疊器:simple_fold(本套件)、simd_normalizer(simd-normalizer 套件)與 HashMap(naïve CaseFolding.txt 查詢)。工作負載列選擇了從典型到最壞情況的不同情境:
| 工作負載(輸入大小) | simple_fold | simd_normalizer | HashMap(位元組路徑) |
|---|---|---|---|
| Pure ASCII(5.7 KB) | >45 GiB/s | 1.21 GiB/s | 213 MiB/s |
| Chinese/Japanese/Korean,無折疊(8.1 KB) | 2.95 GiB/s | 1.97 GiB/s | 558 MiB/s |
| 符號 / 緬甸文,無折疊(9.0 KB) | 2.96 GiB/s | 1.56 GiB/s | 410 MiB/s |
| 最壞情況:拉丁文/希臘文/斯拉夫文(Unicode U+0000–U+FFFF),全部折疊(8.8 KB) | 869 MiB/s | 922 MiB/s | 334 MiB/s |
| 長度會改變的折疊(1.7 KB) | 1.26 GiB/s | 716 MiB/s | 233 MiB/s |
請將絕對數字視為說明性質,而非跨平台通用:整個設計依賴自動向量化、SWAR 與小端序位元組算術,因此在不同的微架構(更寬或更窄的向量單元、不同記憶體頻寬、大端序目標、x86 對 ARM)上,數字甚至各列之間的比率都可能大幅改變。
更多細節請參閱 README 的效能章節。
帶著走的重點
大小寫折疊是文字操作中最基本的一種,這正是為什麼值得花力氣:我們對索引的每個位元組都執行它。效益來自兩個與直覺相反的想法——無分支地掃過整個緩衝區,而不是提前停止;以及在位元組空間做算術,而不是解碼成碼位再折疊。兩者結合讓常見情況能以記憶體頻寬執行,而罕見折疊也能在不解碼的情況下進行,表格小到(1776 位元組)足以常駐在快取中。無解碼的位元組空間折疊是我們認為真正新的部分;這就是為什麼這條路徑能打敗已經擁有答案的雜湊映射。
這裡肯定還有更多可以發現的地方,我們很樂意看到它。套件位於 casefold;產生的表格與完整設計說明與原始碼放在一起。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.