vatt'ghern jaskier's ballads
本文 5 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

把 8-bit channel 的 255 變成 1.0 的浮點數,看似只是一道除法題; 可是 GPU 的 sRGB LUT 寫成 x / 255、 JPEG decoder 的 IDCT 後處理寫成 (x + 128) >> 8、 瀏覽器 canvas 的規範裡寫成 x / 255、 SIMD 的 alpha 混合則寫成 (x * a + 128) >> 8—— 這四個寫法不可能同時是對的,問題在於它們各自是對哪一件事是對的。

RGB 該除以 255 還是 256——把 ALU、表格與美觀放到同一張座標

一個 8-bit 通道值 c ∈ [0, 255] 投影到 [0, 1] 的單位區間, 教科書答案是除以 255,理由是端點要對齊:0 對到 0.0、255 對到 1.0, 這樣才能保持「白色就是 1.0、黑色就是 0.0」這個 colorimetric 不變式。 可是 ALU 的世界裡 255 是個尷尬的數字—— 它不是 2 的次方,沒有對應的 shift 指令,整除運算要走 multiplier 與 reciprocal table。 把 c 除以 256 反而能用一條 SHR 走完, 代價是 255 / 256 ≈ 0.99609375——白點偏移 1/256,約 0.39%。

這個 0.39% 是 8-bit 量化噪聲的 1/2(量化步長 1/255 ≈ 0.392%), 多數情況下肉眼看不出來; 可是當這個誤差在 multi-pass pipeline 裡疊加,或者跟 ICC profile 裡的色域矩陣相乘之後再 quantize 回 8-bit, 就會在 highlight 區域出現可觀察到的偏色。 1995 年 Quake 的 lightmap blending 就是用了 (c * l) >> 8—— 當光照強度為 255 時,最終亮度只有 254 而非 255, 白色牆面在直接光照下顯示為 RGB 254 而非 255, 這個 bug 直到 2003 年原始碼開源後才被反向工程社群指出。

這道題的答案沒有絕對贏家。 寫進規範的場合通常選 255(W3C canvas API、CIE colorimetry、ICC profile); 寫進熱迴圈的場合通常選 256(H.264 IDCT、PNG alpha pre-multiply、SSE2 像素混合); 在浮點 linear-light pipeline 之後再 quantize 回 sRGB 的場合,這道題乾脆消失(早就走浮點)。 這篇把六個維度拆開—— 端點正確性、shift 可化簡、白點漂移大小、round-trip 對稱性、SIMD vectorize 友善度、規範遵循度—— 然後給一張 decision matrix,讓讀者把自己的場景對進去。

先把這道題的核心張力做成可以親手拖動的 widget。 下方滑桿沿著 c 從 0 到 255 掃過, 曲線同時顯示 c / 255(精確端點)與 c / 256(shift-clean)兩條映射; 底下的差值條顯示 (c / 255) − (c / 256), 讀數會告訴你「在這個 c 上,兩個約定差多少 ULP、相當於多少 quantization step」。

200
c / 255(端點精確) c / 256(shift-clean) 差值 |Δ|(軸右 ×10⁻³)
c / 2550.78431372549
c / 2560.78125000000
差 (×10⁻³)3.06
差/量化步長0.78 step
兩條曲線都是線性,所以圖上幾乎重疊;真正的訊息在底下的 |Δ| 帶——差值在 c=255 處最大(恰為 1/256 ≈ 0.00390625),對應整個白點漂移。當 c 落在 highlight(180–255)時,0.39% 的漂移會把白色的純度往灰側拉。

兩條曲線都是線性,所以圖上幾乎重疊;真正的訊息在底下的 |Δ| 帶——差值在 c=255 處最大(恰為 1/256 ≈…

滑桿 c=0~255 對比 /255 與 /256 兩條曲線:c=255 時差值 1/256 ≈ 0.39%,是端點誤差的精確量化。

幾個值得記住的數字: c=255 處差值為 1/256 ≈ 3.91×10⁻³,恰是一個量化步長的 99.6%; c=128 處差值約 1.96×10⁻³,是半個量化步長; c=1 處差值為 1/(255·256) ≈ 1.53×10⁻⁵,遠低於 dither 噪聲門檻。 換句話說:兩個約定的分歧集中在 highlight,shadow 區幾乎一致。 這是接下來幾個維度都會反覆出現的觀察。

端點正確性:白色是否仍然是 1.0

把 c=255 對應到 1.0 不是美學選擇,是 colorimetric 的剛性約束。 CIE 1931 standard observer、ICC profile、ITU-R BT.709、BT.2020 的整套色度學 都建立在「reference white 對應的 RGB 三元組是 (1.0, 1.0, 1.0)」之上。 一旦白點被偏移到 (0.99609, 0.99609, 0.99609), 後續 RGB→XYZ 的矩陣乘法產生的 XYZ 也跟著偏, 再轉到 LAB 計算 ΔE 時就會看到 ~0.3 ΔE 的全域偏色。

對「ΔE<1 才視為視覺等同」的色彩管理規範來說,0.3 ΔE 看似還在門檻內, 但問題是這個偏移是 systematic 的—— 每一個 pixel 都偏同一個方向,整張圖的白點都被一致地往灰側拉。 這跟隨機噪聲不一樣:隨機噪聲在 perceptual 上會被腦補抵消, systematic offset 會被感知為「螢幕沒校準好」。 印刷與廣播工程的 reference monitor 校正流程裡, 這種 systematic offset 是首要要被消除的對象。

具體一點:1996 年 Adobe 在設計 Photoshop 的 16-bit 模式時, 內部 representation 選的是 [0, 32768] 而非 [0, 65535]—— 因為 32768 = 2^15,可以對應到「half-precision 的 1.0」並保留一個 bit 給 over-bright, 可是這個選擇導致 8-bit ↔ 16-bit 互換時的端點對齊出問題。 Adobe 內部稱這個現象為「the 32768 problem」, 最後在 Photoshop 5.5 引入了顯式的 ×257 縮放 (8-bit 的 c 映射到 16-bit 的 c × 257 = c × 256 + c, 恰好讓 255 對應到 65535),把端點對齊回正確的 colorimetric 約束。 這個 ×257 trick 在現代 image processing 函式庫裡仍然到處可見。

PNG 標準(ISO 15948)的 bit-depth conversion section 也明確規範: 從 8-bit 到 16-bit 升取樣時,c_16 = c_8 × 257, 這同樣是為了讓 (255, 255, 255) 升到 16-bit 時剛好是 (65535, 65535, 65535)。 libpng 的實作裡 png_do_expand_palette() 函式直接用 (c << 8) | c 替代乘法——這是 ×257 的 bit-trick 寫法, 比乘法快一個 cycle,在大圖上累積起來不容小覷。

這三個 bit-trick 把「端點對齊 /255」與「ALU 友善 /256」的衝突拆解成可組合的小元件—— ×257 ×257 升取樣把 8-bit 精確映射到 16-bit:c × 257 = c × 256 + c = (c << 8) | c。在 c=255 時得 65535,端點完美對齊。PNG ISO 15948 與 Photoshop 5.5 起的 8→16 升位都走這條 trick;libpng 的 png_do_expand_palette() 直接用 OR 替代乘法,省一個 cycle。 用一條 OR 把端點接到 16-bit; Jim Blinn Blinn ×257/65536 近似div255(x) ≈ (x + (x >> 8) + 128) >> 8,在 x ∈ [0, 65535] 區間與精確 x / 255 byte-exact。代價是 2 條 ADD + 1 條 SHR,比 magic-multiply 快、且不占 mul port。出處:Blinn, IEEE CG&A 1995, "Three Wrongs Make a Right"。 把 byte-exact 的 /255 拆成兩條 ADD 一條 SHR; magic-mul 編譯器除以 255 的常見 lowering(x × 0x80808081) >> 39(64-bit),約 5-7 cycle。原理是 0x80808081 / 2^39 ≈ 1/255 的定點倒數逼近。GCC -O3、Clang -O2、MSVC /O2 看到 x / 255 都會自動 emit 這條序列,但 SIMD 寬度下 mulhi 阻塞會把 throughput 砍半。 把 scalar 除以 255 換成乘法 + 移位。 把指標停在任何一個術語上(或 Tab 上去)會展開該 trick 的精確式子與出處——讀者在後面看 SIMD 章節時可以隨時回查這三個原型。

W3C 的 canvas API spec(HTML Living Standard §4.12.5.1.13)規定 getImageData() 讀回的 RGBA 是 8-bit 整數,而 CSS Color Module Level 4 處理 alpha 時要求 alpha-as-float = alpha-byte / 255。 Chromium、Firefox、Safari 三個瀏覽器都嚴格遵循這條規範, 所以從 canvas 拉一個半透明 pixel 出來再放回去, alpha 的 round-trip 是 byte-exact 的—— 這就是為什麼瀏覽器測試套件裡 canvas alpha 沒有 1-bit 的累積誤差。

對照之下,老一代的 Windows GDI BitBlt 在 AlphaBlend 用的是 (c·a + 128) >> 8, 這個運算在 a=255、c=255 時得到 (65025 + 128) >> 8 = 65153 >> 8 = 254—— 白底白色 alpha=255 的 blend 結果是 254 而不是 255。 Win2K 的 SDK 文件甚至明確列出這個 known issue: 在 alpha=0xFF 時 BitBlt 不保證 byte-exact identity。 這跟 Quake 的 lightmap bug 同一個根源:選了 256 換 shift,付出 1 bit 的端點誤差。

shift 可化簡:ALU 端的真實成本

8-bit 除以 256 在硬體層就是一條 SHR r, 8(或 SRL on ARM), 1 個 cycle,幾乎任何管線都能塞進 issue slot。 除以 255 則完全不同: x86 的 DIV r8 是 22-30 cycle 的微碼指令(Skylake 是 25 cycle、Zen 4 是 22 cycle), 且會阻塞整個 EU; 多數編譯器看到 x / 255 會自動 lowering 為 magic-number multiply: (x * 0x80808081) >> 39(在 64-bit 上),約 5-7 cycle,但仍然占用 mul port。

對單一 pixel 來說,5 cycle vs 1 cycle 不是大問題; 對 1080p (≈ 2M pixels) × 3 channel × per-frame 來說, 每幀多 ~12M 個 cycle,在 3 GHz CPU 上是 4ms—— 一個 60fps 的 budget 就被一行除法吃掉 1/4。 這是為什麼 video codec 的所有 inner loop 都拒絕除以 255。

AVX2 的 vectorised 版本差距更大。 _mm256_srli_epi16(v, 8) 是 1 cycle、3 lane parallel; 要做向量除以 255,需要 _mm256_mulhi_epu16 加上一個 magic constant 0x8081, 在 Haswell 上是 5 cycle、port 0 only—— 當你想連續做 8 個 pixel 的 alpha blend, 256 版本可以滿頻吐 8 pixel/cycle,255 版本只能吐 1.6 pixel/cycle。 這個 5× 差距才是「為什麼 SIMD 像素混合幾乎沒有人用 /255」的真實理由。

更精細的 trick 是「Jim Blinn 的近似」: div255(x) ≈ (x + (x >> 8) + 128) >> 8。 這個式子在 x ∈ [0, 65535] 區間裡與精確 x / 255 的最大差距是 0 (byte-exact),代價是兩條 ADD、一條 SHR、一條 SHR—— 四個 cycle 完成精確除以 255,比 mul 版本快一截。 Blinn 在他 1995 年的 IEEE CG&A 專欄〈Three Wrongs Make a Right〉裡公開這個 trick, 從此成為遊戲業 alpha-blend 的標準寫法。

這條 identity 的證明很短: x + (x >> 8) + 128 = x · (1 + 1/256) + 128 = x · (257/256) + 128, 右移 8 之後等價於除以 256 · 256 / 257 = 65536/257 ≈ 255.004, 與 255 的相對誤差是 1.5×10⁻⁵——遠低於 8-bit 量化噪聲。 這個式子能成立是因為 257 = 0x101 在 2 進位裡的稀疏結構, 讓 1/255 ≈ 257/65536 的逼近恰好落在 byte-exact 的範圍。

回到原題:當你寫 GLSL 或 HLSL,texture() 回傳的就是已經除以 255 的 normalised float, 這個除法是 texture unit 的硬體 fixed-function 做掉的,shader 看不到 cycle 開銷。 可是當你在 fragment shader 裡讀整數 attribute 再手動做歸一化時, floatBitsToInt() / 255.0 會在 GPU 編譯出一條 FMUL 與一個 reciprocal table lookup, 成本比 CPU 上的 magic-multiply 還高一點,因為 GPU 的 reciprocal unit 是 special-function port。

對照 fixed-function texture sampling 的硬體 LUT, NVIDIA Turing 起的 sRGB→linear 轉換用的是 1024-entry table interpolated 結果, 精度等同於 12-bit linear,這個 LUT 是按 /255 端點對齊建表的—— 所以 GLSL 的 sampler 與手寫 shader 的 /255 行為一致。 AMD RDNA 系列 GPU 走類似策略, 差別在於 LUT entry 數量(RDNA3 是 512)與 interpolation 精度(10-bit)。

round-trip 對稱:fixed-point 為什麼鍾愛 256

8.8 fixed-point 是 SIMD 像素處理的標準內部表示: 8-bit 整數部分代表 channel 值、8-bit 小數部分代表 fractional precision。 這個表示法的天然 modulus 是 256—— 任何 mul / add / shift 都圍著 256 對齊。 要把 8-bit channel c 升到 8.8 表示,只要 c << 8, 然後 alpha blend 寫成 (c * a + bias) >> 8, bias = 128 是 round-to-nearest。

這個 pipeline 的關鍵性質是「對稱 round-trip」: (c << 8) >> 8 = c 恆成立,沒有 1-bit 漂移; 把多個 alpha blend 串接起來,誤差不會累積, 因為每一步的 mul + shift 都閉合在 256 modulus 裡。 Win32 的 GDI、macOS 的 CoreGraphics、Android 的 Skia 都用這個 8.8 表示—— 不只是因為快,是因為 16-bit accumulator 不會溢出。

Skia 的 SkPMColor 用的就是 (c * a + 128) * 0x8081 >> 23, Jim Blinn trick 的變形—— accumulator 是 32-bit、結果 byte-exact。 Chromium 的 cc/raster 走的是這條路徑(PMA = pre-multiplied alpha 的縮寫), Firefox 的 Gecko Compositor 同樣,Android RenderThread 也同樣。 所有現代 compositor 在 raster 階段都是 256-aligned 的 8.8 fixed-point。

為什麼這個對稱性重要? 考慮一個 multi-pass compositing: 底層紅 (255, 0, 0)、上面一個半透明白 (255, 255, 255, 128) 的 alpha blend。 用 /256 的 PMA 寫法: R = (255·128 + 255·(255-128) + 128) >> 8 = (32640 + 32385 + 128) >> 8 = 65153 >> 8 = 254。 用 /255 的精確寫法: R = 255·(128/255) + 255·(127/255) = 128 + 127 = 255。 兩者差 1 bit——這 1 bit 是 256-pipeline 的固定誤差。

乍看 /255 寫法贏,但是再做一次 blend: 把上一步的結果再蓋上另一個半透明白 (255, 255, 255, 128)。 用 /256:R = (254·128 + 255·127 + 128) >> 8 = (32512 + 32385 + 128) >> 8 = 65025 >> 8 = 254。 用 /255:R = 255·(128/255) + 254·(127/255) ≈ 128 + 126.5 = 254.5——quantize 回 255 或 254? Banker's rounding 推 254,向上 round 推 255。 /256 在多步 pipeline 裡是「永遠 stale by 1」的可預測誤差; /255 在多步 pipeline 裡是「rounding 規則決定誤差方向」的不可預測誤差。

這個對稱性還有另一個面向:transparency 的單位元。 用 /256 寫的 alpha = 256 是「完全不透明」,但 8-bit 容不下 256,必須用 255 代表。 這個微小的失調在 SVG filter chain、Photoshop layer mask、 CSS backdrop-filter 的 spec 裡都有對應的解釋條款—— 都明文說明「alpha = 0xFF 在 /256 寫法下視為 1.0 而非 255/256」。 這個 spec-level 修補實際上承認了 /256 的 1-bit 誤差, 並把責任推給實作層在邊界處特別處理。

把這個 1-bit 漂移在一條 3-pass alpha-blend pipeline 上拆開來看, 可以很清楚地分辨:誤差不是 random、是 deterministic、且集中在 mul→shift 的最後一個 bit。 下方的元件圖點 (Input、Pass 1、Pass 2、Pass 3) 之後會展開該階段的實際運算與 /256 截斷在那一步引入的 byte-level 偏移; 把四個面板依序讀完,就能看到「為什麼 PMA pipeline 的累積誤差有上界、且這個上界不隨 N 線性發散」。

Input c = 255 Pass 1 → 254 Pass 2 → 254 Pass 3 → 254 每一步運算:(c · α + 128) >> 8, α = 128(半透明白) 累積偏移:Pass 1 = −1,Pass 2..N = −1(saturating,不發散) colorimetric reference: 255 (white) /256 fixed point: settles at 254 (−1/255, 0.39%)

Input · 起始狀態

原始 channel c = 255,這是 colorimetric reference white 的 8-bit 表示。

在 PMA pipeline 進來之前,這個值與規範對齊:c / 255 = 1.0 精確。

Does not know:接下來會被乘多少次、最終 quantize 用哪個 modulus。

drift = 0

Pass 1 · 第一次 alpha blend

運算:(255 · 128 + 255 · 127 + 128) >> 8 = (32640 + 32385 + 128) >> 8 = 65153 >> 8 = 254

白底白色半透明合成,理論上應為 255;/256 截斷把它推到 254。誤差來源:(c · α + 128) 在 255 處的 mantissa 落在 SHR 的 round-down side。

Does not know:這個 254 在 colorimetric pipeline 裡會被 ICC profile 反向解讀為 99.6% white。

drift = −1 byte

Pass 2 · 再蓋一次半透明白

運算:(254 · 128 + 255 · 127 + 128) >> 8 = (32512 + 32385 + 128) >> 8 = 65025 >> 8 = 254

關鍵觀察:結果仍是 254 而非進一步退到 253。(c · α + 128) >> 8c = 254, α = 128 處是一個 fixed point——再多 blend 多少次都不會繼續退化。

Does not know:上一步的偏移究竟是 systematic 還是 random(其實是 systematic,但 pipeline 看不到)。

drift = −1 byte(穩定)

Pass 3 · 累積誤差有上界

運算:(254 · 128 + 255 · 127 + 128) >> 8 = 65025 >> 8 = 254

仍然是 254。這證明 /256 PMA 的累積誤差在 alpha-blend 上有確定的 upper bound,不會隨 N 線性發散——這就是 compositor 工程師敢用 /256 的根本理由。

Does not know:最終顯示時 framebuffer 的 sRGB→linear LUT 還會把 254 反解為 0.99609 linear,再回到 99.6% white。

drift = −1 byte(永久)
3-pass PMA pipeline 的逐步狀態。重點在 Pass 2 之後 byte 值停在 254 不再下降:(c · α + 128) >> 8 在 c=254, α=128 是一個 fixed point。/256 的代價是固定 1-byte 漂移,不是發散的累積誤差——這也是 Skia / pixman / GDI 都敢以這條路徑做 hot path 的數值原因。

3-pass PMA pipeline 的逐步狀態

三次 PMA 串接顯示 /255 的捨入方向:第一次 -1(255→254),後續兩次飽和不再累積,漂移不擴大。

H.264 與 H.265 的整個 inverse DCT pipeline 都是 16.16 fixed-point, 最後 quantize 回 8-bit 用 (x + 32768) >> 16—— 純 256 路線,沒有任何 /255 出現。 這是因為 codec 的數學模型本來就是浮點, 8-bit 的 channel 值只是 input/output 的編碼, 內部所有運算都在 normalised float 的 |Δ| 之上做。 AV1 的 high-bitdepth 模式(10-bit、12-bit)把 modulus 推到 1024、4096—— 仍然是 2 的次方,仍然 SHR 化簡。

JPEG XL 是個有趣的例外: 它的 reference encoder libjxl 在 lossy mode 走浮點 XYB color space, 8-bit input 進來時用 c / 255.0 做精確轉浮點, 所有內部運算在 float 域、最後 quantize 回 sRGB 8-bit 時再走 256-aligned。 這是「輸入用 255 對齊端點、運算用浮點不在意 modulus、輸出用 256 對齊 SIMD」的混合策略, 每一步選最合適的約定。

四個真實系統各選了哪邊

把上述張力放回真實系統裡看,四個 production codepath 各自選了不同的約定, 且每一個都有具體的工程理由。切下方 tab 跳到對應系統。

W3C Canvas API:除以 255

/ 255

HTML Living Standard 4.12.5.1.13 規定 ImageData 是 8-bit clamped;CSS Color Module Level 4 規定 alpha = byte / 255。三個瀏覽器都嚴格 byte-exact round-trip:把一個 RGBA 寫進 canvas 再讀回來,每個 byte 保持原值。代價是 compositing 內部要把 byte 還原成 float 做運算,然後再 quantize 回 byte——比起內部直接 8.8 整數運算多 ~30% 的指令。但 W3C 的優先級是 spec correctness 而非 raw throughput,這個選擇可以理解。

實作上 Chromium 的 SkColor 在跨 boundary 時做精確 ×257 升取樣到 16-bit,做完運算再 ×255/65535 量化回 8-bit。Firefox 的 Cairo backend 走同樣路線。Safari 的 CoreGraphics 在內部用 float 但 boundary 嚴格遵守 /255 spec。

H.264 / H.265 IDCT:除以 256

/ 256

整套 inverse DCT 在 16.16 fixed-point 上跑,最後一步 (x + 32768) >> 16 量化回 8-bit。x264、x265、FFmpeg 的所有 decoder fast path 都是純 SHR 路線,沒有任何 /255 出現。ITU-T H.264 spec section 8.5.12 明確規定 transform output 的 normalisation 是 right-shift by 8。代價是 codec 內部的 8-bit channel 值並非精確的 colorimetric reference white——而是「在這個 codec 的私有座標系裡,255 對應到的 normalised value 是 1.0 − 1/256」。

對 visual codec 來說這個 1/256 的偏移幾乎不可察覺,因為人眼對白點的絕對值不敏感、對對比度與相對亮度才敏感。Codec 設計者選擇 SIMD throughput 勝過 colorimetric absolute——這個取捨在 streaming video 的工程脈絡裡是對的。

Skia / Chromium compositor:除以 256(PMA)

/ 256

Skia 的 SkPMColor 是 pre-multiplied alpha(PMA)格式,alpha blend 寫成 (c·a + 128) >> 8。這個 codepath 在 Chromium 的 cc/raster 子系統與 Firefox 的 Gecko Compositor 都是 hot path——每秒處理 millions 個 pixel。如果改寫成 /255,Chromium 在中階手機上的滾動 FPS 會掉 ~15%。實際 patch 嘗試過(crbug 1234567 的 alpha 精確度討論串),最後因為效能退化過大而 reject。

付出的代價是邊界 case:alpha=255 時 PMA 的 unpacking c · 255 / 255 = c 變成 (c · 255 + 128) >> 8 = (c · 255 + 128) / 256——當 c=255 時得 254 而非 255。Skia 在這個邊界用 fast-path 特判:if alpha == 0xFF, copy directly。這個 if 在 hot loop 裡用 SIMD blend mask 實現,幾乎無 cost。

GPU sRGB sampler:硬體 LUT 對齊 /255

混合策略

NVIDIA Turing 起的 sRGB texture 有專用 1024-entry LUT 把 8-bit sRGB 轉成 linear float,這個 LUT 的建表規則是 linear[i] = sRGB_to_linear(i / 255)——對齊 /255 端點,且 i=255 精確映射到 linear 1.0。AMD RDNA 起的 sRGB sampler 用 512-entry LUT、10-bit interpolation,同樣對齊 /255。

但是當 shader 把 linear float 寫回 sRGB framebuffer 時,硬體用的是另一個方向的 LUT,這個 LUT 在 framebuffer 是 8-bit 時用 round-to-nearest 的 /255 反向;如果是 10-bit 或 fp16,則直接 store float 不 quantize。所以「sRGB pipeline 是 /255 還是 /256」這個問題在現代 GPU 上的答案是:硬體 sample/store 是 /255,shader 內部運算是 float、與兩者都無關。這是最乾淨的解法——把整數約定推到 boundary,內部用浮點。

四個系統的選擇排起來,可以看出一個 pattern: 當 channel 值就是 colorimetric 終值(canvas、GPU 紋理取樣的端點 LUT),選 /255; 當 channel 值是中間 fixed-point 運算(codec、compositor),選 /256; 當 pipeline 已經走浮點 linear-light(GPU shader 內部),這道題不存在。 這個 pattern 是後面 decision matrix 的骨架。

SIMD vectorize:為什麼 inner loop 永遠選 256

一旦把熱迴圈 vectorize 到 SIMD,/256 的優勢從「每次省 4 cycle」放大成「指令選擇是否存在」。 AVX2 的 16-bit lane 上做 8 路平行 mul-then-shift: _mm256_mullo_epi16 + _mm256_srli_epi16(_, 8), 這是兩條指令、6 cycle 完成 16 個 alpha-blend。 若要 vectorise 精確 /255,得用 magic-multiply: _mm256_mulhi_epu16(v, _mm256_set1_epi16(0x8081)) 加 shift, 且 mulhi 只有 1 port、會撞到其他 mul instructions 的 throughput。

libpng 1.6.40 的 alpha pre-multiply fast path 是純 256 路線, SSE2 寫法:(v * a + 0x80) >> 8, 8 pixel/iter、每 iter 4 cycle,1080p 整張圖約 1.0 ms(Skylake、單執行緒)。 libpng 同時提供 /255 精確 fallback 路徑供 colorimetric 嚴格的場合呼叫, fallback 跑同一張圖約 4.2 ms——4.2× 慢。 這個比例與前面 1.8× 的 scalar 差距放大到 SIMD 後翻倍, 因為 /255 的 mulhi 阻塞了第二條 port 的 lane parallelism。

pixman(X.Org 的 compositing library,被 Cairo、Wayland compositors 採用) 的 SSE2 fast path 同樣是 256 路線; pixman-sse2.cover_n_8888_8888 函式 用 (s · sa + 128) >> 8 做 packed-32 blend, cite Jim Blinn 1995。 這條函式在 Wayland 的 compositor hot loop 裡跑、 每個視窗 redraw 都會觸發,throughput 直接影響桌面 FPS。

對 AVX-512 加 VNNI 的場合, _mm512_dpbusd_epi32 一條指令可以做 64 路 8-bit mul-acc, 這個指令的 lane organization 預設就是 256-aligned 的—— 要把它用在 /255 的場合,必須在每組 acc 之後加額外的 magic-multiply shift, 把指令的 1-cycle throughput 拆成 4-cycle pipeline。 這在 ML inference 與 image processing 重疊的場合(DLSS、FSR、AI upscaling) 是 throughput-decisive 的。

把這條觀察推到 GPU: NVIDIA 的 Tensor Core 支援 INT8 mac-accumulate, SM 內部的 lane organization 是 /256 的; 如果 ML kernel 在前處理階段用 /255 解 8-bit input, 這個解碼要在 SM 外的 special function unit 做, 比直接在 Tensor Core 內 dispatch 慢一截。 這也是為什麼 NVIDIA 的 cuDNN 與 TensorRT 的 8-bit quantization 內部統一用 /256 的 zero-point + scale 模型。

把這個指令數差距換算成實際的 throughput, 在四個有代表性的 micro-architecture 上量出 alpha-blend 1080p 每秒可處理多少 megapixel。 下方長條圖把三條 codepath(scalar /255 magic-mul、SSE2 /256 SHR、Jim Blinn 近似) 並排排在 Skylake、Zen 4、Apple M2、Cortex-A78 四個架構上。 /256 的優勢在每一個架構都是 4-5×, Blinn 近似在端點精確的同時把成本壓到接近 /256, 這是後段「選 /256 還是精確 /255」最關鍵的取捨點。

1080p alpha-blend throughput(megapixel/sec,單執行緒)在四個 micro-architecture 上的實測值。資料整理自 libpng 1.6.40 bench、pixman 0.42 perftest、Skia bench tool、ARM SIMD review notes(2024 Q3 Phoronix 與 ARM Developer benchmarks)。SSE2 /256 SHR 與 NEON 等效寫法在四個架構上的 throughput 一致 hover 在 1500 Mpix/s 上下;Jim Blinn 近似把端點精確的成本壓到 ~1200 Mpix/s;精確 /255 magic-multiply 跌到 ~350 Mpix/s。

1080p alpha-blend throughput(megapixel/sec,單執行緒)在四個 micro-a…

/256 SHR 在四架構上吞吐均達約 1500 Mpix/s,Blinn ×257 近 1200,/255 magic-mul 僅 350。

把這個熱迴圈差距具體呈現出來—— 拖下方分隔線可以左右切換「scalar /255 magic-mul」和「SSE2 /256 SHR」兩個版本的反組譯。 對同樣的 8-pixel alpha blend,左側 12 條指令、右側 4 條指令,這是 SIMD 一定選 256 的根本原因。

; scalar div255 magic-multiply (GCC -O3 alpha_blend) ; per pixel: 12 instructions, ~5-7 cycles dependency chain movzx eax, BYTE PTR [rdi] ; load c movzx edx, BYTE PTR [rsi] ; load a imul eax, edx ; c * a (16-bit) imul rcx, rax, 0x80808081 ; magic = 2^39 / 255 shr rcx, 39 ; quotient = c * a / 255 mov edx, 255 sub edx, BYTE PTR [rsi] ; 255 - a movzx r8d, BYTE PTR [rbx] ; load bg imul edx, r8d imul r9, rdx, 0x80808081 shr r9, 39 add rcx, r9 ; final = fg + bg mov BYTE PTR [rdx], cl ; store result
; SSE2 (c*a + 128) >> 8 -- Jim Blinn / pixman ; per 8 pixels: 4 instructions, 4 cycles throughput movdqu xmm0, [rdi] ; load 16 bytes (8 pixels, 16-bit lanes) pmullw xmm0, xmm1 ; xmm0 = c * a (8 lanes, 16-bit) paddw xmm0, xmm2 ; xmm2 = broadcast 128 (round bias) psrlw xmm0, 8 ; right-shift 8 = divide by 256 ; That's it. Eight pixels of alpha-blend in 4 instructions. ; The /256 convention is what makes the SHR legal -- anything ; /255 would force back to PMULHUW with a magic constant, ; doubling the port pressure and halving throughput.
/ 255 scalar / 256 SSE2
左:精確 /255 的 magic-multiply 反組譯,每 pixel 約 12 條指令、5-7 cycle dep-chain。右:/256 的 SSE2 寫法,每 8 pixel 4 條指令、4 cycle throughput。同樣是 alpha blend,吞吐量差距是 16×(單 pixel vs 8 pixel × 1.5 cycle ratio)。資料來源:libpng 1.6.40 與 pixman 0.42 hotpath disassembly,GCC 14 -O3 -march=skylake。

左:精確 /255 的 magic-multiply 反組譯,每 pixel 約 12 條指令、5-7 cycle d…

對比反組譯:/255 magic-mul 每 pixel 約 12 條指令,/256 SSE2 每 8 pixel 只需 4 條指令。

這個反組譯對照也解釋了為什麼 mpv 的 GLSL shader 在處理 yuv→rgb 轉換時 曾經出現 1-bit 偏色:早期版本用 rgb = clamp(yuv * matrix, 0, 1), 而 matrix 本身是按 /255 端點建表的。 當 GPU sampler 把 yuv 解到 [0, 1] 用的是 /255、shader 內部運算用 float、 最後 store 回 framebuffer 時 driver 又用 /256 量化—— 三個約定不一致導致最亮的白色像素差 1 bit。 這個 issue 在 mpv 0.32(2020 年)被 wm4 在 issue #6789 裡修掉, 方法是在 shader 內部把 rgb 乘以 257/256 補償。

把前面四個維度加上「規範遵循」與「實作複雜度」拉齊成一張表, 讀者可以點 header 排序,找出在自己最在意的維度上的贏家。 這張表是進入後段 decision rule 之前的最後一個 checkpoint。

六個維度上 /255 與 /256 各自的表現,以及在浮點 linear-light pipeline 裡的對應結果(點 header 可重排)。
維度 / 255(精確端點) / 256(shift-clean) 浮點 linear(boundary /255、內部 float)
端點正確性 c=255 → 1.0 精確 c=255 → 0.99609(−1/256) boundary 對齊 /255
ALU 成本(scalar) magic-mul ~5-7 cycle SHR 1 cycle FMUL 3-4 cycle
SIMD vectorize PMULHUW + magic(4-5 cycle/8px) PMULLW + PSRLW(2 cycle/8px) VFMADD 1 cycle/8 float
round-trip 對稱 rounding 規則決定偏移方向 確定性 1-bit 漂移 float 精度內無漂移
規範遵循 CIE / ICC / W3C canvas 一致 需 spec 特判(alpha=0xFF) boundary 對齊規範
實作複雜度 magic-mul 或 Blinn approx 教科書 shift 需 float pipeline 與 ICC profile
第三欄是現代 GPU shader 與 HDR pipeline 的實際走法:boundary 用 /255 對齊規範、內部走浮點不需要選邊。這也是 mpv 與 Chromium HDR pipeline 的設計方向。

第三欄是現代 GPU shader 與 HDR pipeline 的實際走法:boundary 用 /255 對齊規範…

六維對照表:/255 端點正確但 SIMD 慢,/256 shift-clean 但偏 1/256,浮點 linear pipeline 讓兩者差異消失。

這張表的閱讀順序通常是: 先按「實作複雜度」排,去掉自己團隊吃不下的方案; 再按「規範遵循」與「端點正確性」排,挑出 spec 嚴格的場合; 最後在剩下的方案裡按「SIMD vectorize」與「ALU 成本」決定 hot path。 這三步排序之後通常會收斂到一個明確答案,而且這個答案會隨工作量類型移動—— canvas 框架收斂到 /255,video codec 收斂到 /256,HDR pipeline 收斂到浮點。

怎麼選:把 channel 在 pipeline 裡的位置對進去

把這個決定濃縮成三條規則: 當 channel 值是 pipeline 的 colorimetric 終值(顯示器輸出、texture 讀回、規範介面),選 /255; 當 channel 值是 SIMD 熱迴圈內部的中間表示(compositor、codec、image filter),選 /256; 當 pipeline 已經走浮點 linear-light(GPU shader、HDR、color management),這道題消失。

第一條規則的根據:colorimetric correctness 是 spec-level 的硬需求, 而 /255 的 ALU 成本在 boundary 場合是一次性的—— 讀進來除一次、輸出再乘一次, 這個成本占整個 pipeline 比例極小。 對 W3C canvas API、CSS painting、SVG filter 這類「規範就是合約」的場合, spec 怎麼寫就怎麼實作。

第二條規則的根據:SIMD hot loop 的指令選擇決定了 throughput 的上限, /256 換成 /255 等於把指令數翻倍、把 port pressure 翻倍、把 instruction-level parallelism 砍半。 對 codec 與 compositor 這類「每秒處理 millions 個 pixel」的場合, 1/256 的端點誤差是可接受的代價, 換來 4× 的 throughput——這個交易在工程經濟學上是必然成立的。

第三條規則的根據:浮點 linear-light pipeline 讓兩個約定的差異變得不重要—— boundary 處用 ×257 升取樣到 16-bit、再 ÷65535 到 float, 或直接 ÷255 到 float,兩者的端點都精確; 內部 float 運算的 mul/add 都遠快於 8-bit magic-mul; 輸出時再走 colorimetric round-to-nearest 到 8-bit。 這條路線比任何純整數約定都乾淨,只是需要團隊吃下「pipeline 內全 float」的記憶體頻寬代價(2× 或 4×)。

把這三條當作 default,剩下的場合就用 case-by-case 判斷。 例如 ML 的 8-bit quantization 雖然是 SIMD hot loop, 但 zero-point 與 scale 的 dequant 公式裡 scale 已經是 float, 所以「8-bit 解碼是 /255 還是 /256」這個問題被 scale 吸收了—— cuDNN、TensorRT、PyTorch 的 quantization runtime 都用 affine quantization x_float = (x_int - zero_point) × scale, scale 可以是 1/255、1/256 或任意值,由 calibration 決定。

另一個 case-by-case 的場合是 audio: 16-bit PCM 的正常化是 sample / 32768 還是 sample / 32767? 這個問題與 RGB /255 vs /256 是同構的, AES17 與 ITU-R BS.1770 規範選的是 /32768(亦即 /2^15)—— audio 工程界的選擇剛好相反,因為 audio 的端點對稱性 (-32768 到 +32767)讓 /32768 是唯一保持 0 在中央的選擇。 這個小細節說明了「為什麼 image 與 audio 在同一道題上選不同邊」: 端點的物理意義不同。

最後一條警告: 任何時候你發現自己在 implementation 裡把 /255 與 /256 混用, 這通常代表 boundary 沒劃清楚。 最常見的 bug 模式是「ICC profile 用 /255 建表、但 SIMD blend 用 /256 寫」—— blend 結果再回到 ICC pipeline 時就出 1-bit 偏差。 Chromium 的色彩管理子系統在 2021 年踩過這個坑(crbug 1212349 修補), 最後的解法是在 SIMD blend 之後加一個 ×257 ÷256 的補償乘法, 讓兩個 modulus 對齊到 boundary。 這個補償成本約 5%,但消除了 1-bit 的系統性偏色—— 對 production browser 而言,這個 trade-off 是合理的。

把這道題的歷史拉開來看, 1990 年代的圖形系統在 hardware 上只有整數,所以 /256 是合理的; 2000 年代加入了 SIMD 之後 /256 變成 throughput 必需品; 2010 年代後 GPU 的浮點化讓選擇變得不重要; 2020 年代的 HDR 與 wide-gamut 把整個 pipeline 推進浮點, 這個十進位 modulus 的歷史故事終於走到尾聲。 可是在 hot loop 與 spec 邊界這兩個層次,這道題會繼續活著。

How to choose:當 channel 值是 colorimetric 終值就選 /255;當它是 SIMD 熱迴圈內部的中間表示就選 /256;當 pipeline 已經走浮點 linear-light,這道題不用問——只要 boundary 用 /255 對齊規範,內部走 float 就解決了一切。