把 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」。
兩條曲線都是線性,所以圖上幾乎重疊;真正的訊息在底下的 |Δ| 帶——差值在 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 · 起始狀態
原始 channel c = 255,這是 colorimetric reference white 的 8-bit 表示。
在 PMA pipeline 進來之前,這個值與規範對齊:c / 255 = 1.0 精確。
Does not know:接下來會被乘多少次、最終 quantize 用哪個 modulus。
drift = 0Pass 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 bytePass 2 · 再蓋一次半透明白
運算:(254 · 128 + 255 · 127 + 128) >> 8 = (32512 + 32385 + 128) >> 8 = 65025 >> 8 = 254。
關鍵觀察:結果仍是 254 而非進一步退到 253。(c · α + 128) >> 8 在 c = 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(永久)(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
/ 255HTML 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)
/ 256Skia 的 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.c 的 over_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-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 的根本原因。
左:精確 /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(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 對齊規範…
六維對照表:/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 就解決了一切。