一张只有两种颜色的头像,为什么还要用 NRGBA?—— `gdenticon` 重构日志(四)

第三篇写完以后,我原本准备继续拆 balanced,看看候选筛选里有哪些重复统计和临时分配。真正把 benchmark 补起来以后,先冒出来的却不是生成算法,而是更靠后的图片编码。

gdenticon 的一张头像通常只有两种颜色:背景色和前景色。可在 v2.1.0 里,不管最后要写成 PNG、JPEG、GIF、BMP 还是 TIFF,都会先生成一张完整的 image.NRGBA。这条路实现起来很省心,却等于让每一种编码器都接收同一块通用画布,哪怕它们真正擅长处理的图片类型并不一样。

这次改动最后进入了 v2.2.0。公开 API 没变,头像图案、颜色和 SVG 输出也没变,调整的只是 Icon 落到栅格图片以后,应该以什么形式交给编码器。

v2.1.0:一块 NRGBA 画布接住所有格式

旧版的 raster 很直接:创建 image.NRGBA,先填满背景色,再把需要显示的前景格子画上去。

go 复制代码
func raster(icon Icon, size int) *image.NRGBA {
    m := image.NewNRGBA(image.Rect(0, 0, size, size))
    draw.Draw(
        m,
        m.Bounds(),
        image.NewUniform(icon.Background),
        image.Point{},
        draw.Src,
    )

    for row := range icon.GridSize {
        for col := range icon.GridSize {
            if icon.Filled[row*icon.GridSize+col] {
                draw.Draw(
                    m,
                    cell(size, icon.GridSize, col, row),
                    image.NewUniform(icon.Foreground),
                    image.Point{},
                    draw.Src,
                )
            }
        }
    }
    return m
}

后面的五个栅格编码器全部调用它:

go 复制代码
png.Encode(w, raster(icon, size))
jpeg.Encode(w, raster(icon, size), options)
gif.Encode(w, raster(icon, size), nil)
bmp.Encode(w, raster(icon, size))
tiff.Encode(w, raster(icon, size), nil)

这段设计不能说错。NRGBA 能表达每个像素独立的红、绿、蓝和 Alpha,项目只维护一套渲染逻辑,透明色也能自然保留下来。刚把多格式输出接通时,这种统一比为每个格式单独写一份代码更稳妥。

问题是,统一输入不代表每个编码器处理起来都一样省事。

先把生成和编码分开测

这轮 benchmark 没把 Generate、栅格化和编码揉成一个数字。我分别保留了 BenchmarkGenerate、BenchmarkRaster、BenchmarkWrite 和 BenchmarkGenerateAndWrite,这样结果变慢时,至少能先判断问题发生在哪一段。

编码测试使用固定输入生成一张 7×7、开启镜像和 balanced、摘要函数为 SHA-256 的 Icon,然后分别输出 128、256、512 和 1024 像素。发布说明中的前后对照取 256×256,机器是 Intel Core i5-14600KF,并同时记录:

  • 每次操作时间;
  • 每次操作分配的内存;
  • 分配次数;
  • 实际编码后的字节数。

核心 benchmark 的写法大致如下,省略了格式和尺寸的外层循环:

go 复制代码
var measurement countingWriter
if err := Write(&measurement, icon, opts); err != nil {
    b.Fatal(err)
}

b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
    if err := Write(io.Discard, icon, opts); err != nil {
        b.Fatal(err)
    }
}
b.ReportMetric(float64(measurement.bytes), "encoded-B")

io.Discard 避免把磁盘速度混进编码时间,countingWriter 则单独测量输出大小。正式对比时,Before 和 After 必须在同一台机器、相同 Go 环境和相同 benchmark 输入下运行,不能拿两次不同环境的数字直接相除。

我在发布前记录到的 256×256 结果是:

格式 Before After 变化
GIF 26.43 ms 227.5 µs 约 116 倍
BMP 482.7 µs 39.91 µs 约 12.1 倍
TIFF 426.3 µs 51.55 µs 约 8.3 倍
PNG 977.0 µs 248.7 µs 约 3.9 倍
JPEG 1.431 ms 605.0 µs 约 2.4 倍

这些数字会随平台、Go 版本、图片尺寸和输入变化。它们说明的是这组实现前后的差别,不是所有机器上都能固定复现的常数。

真正的问题:项目已经知道只有两种颜色

NRGBA 每个像素保存四个通道。256×256 的画布仅像素数据就需要 262,144 字节,哪怕整张头像实际只在背景色和前景色之间切换。

对于普通照片,这很正常;对于 identicon,就有些浪费了。项目在生成 Icon 时已经明确知道两种颜色,也知道每个逻辑格子属于背景还是前景。如果仍然把它展开成真彩色图片再交给 GIF,GIF 编码器还得重新扫描像素、整理颜色并生成调色板,相当于把上游已经掌握的信息丢掉,再让下游推导一次。

image.Paletted 更贴近这种数据。它不为每个像素保存 RGBA,而是保存调色板索引:

go 复制代码
func rasterPaletted(icon Icon, size int) *image.Paletted {
    m := image.NewPaletted(
        image.Rect(0, 0, size, size),
        color.Palette{icon.Background, icon.Foreground},
    )

    // 背景默认使用索引 0,只把前景区域写成索引 1。
    return m
}

背景索引固定为 0,前景固定为 1。即使前景色与背景色的数值刚好相同,也不交换索引,这样几何和透明度测试仍然有稳定的含义。

不是所有格式都改成 Paletted

一开始看,既然头像只有两种颜色,所有格式都用 image.Paletted 好像最省事。但编码器之间的实现和格式能力不同,强行统一仍然会回到原来的问题。

v2.2.0 最后使用了三种栅格后端:

输出格式 画布 原因
PNG image.Paletted 两色索引图足够,调色板可以保存完整 Alpha
GIF image.Paletted 直接提供两色调色板,避免重新量化颜色
JPEG image.RGBA 标准库 JPEG 编码器对这个具体类型有专用路径
BMP 不透明时 Paletted,带 Alpha 时 NRGBA 在速度和透明度之间选择
TIFF 不透明时 Paletted,带 Alpha 时 NRGBA 同上

SVG 没有经过这些画布。它仍然按照逻辑格子直接输出 <rect>,所以这轮改动没有碰 SVG 路径。

几何仍然只保留一份

拆成三种画布以后,最容易出现的新问题是每个后端各算一遍网格位置。图片尺寸不能被网格整除时,只要某一份计算稍有差异,同一张头像在 PNG 和 JPEG 里就可能差一个像素。

所以我没有复制原来的 cell 逻辑,而是先计算一份共享布局:

go 复制代码
type rasterLayout struct {
    edges []int
}

func newRasterLayout(size, gridSize int) rasterLayout {
    edges := make([]int, gridSize+1)
    for i := range edges {
        edges[i] = edge(size, gridSize, i)
    }
    return rasterLayout{edges: edges}
}

gridSize + 1 条边界只计算一次,rasterPaletted、rasterRGBA 和 rasterNRGBA 都从这份布局取矩形。后端只负责怎么填像素,不再各自决定格子落在哪里。

代码量确实比原来一份 raster 多了。这个代价我愿意接受,是因为变化被限制在像素存储方式上,边距、镜像和小尺寸取整仍然只有一套规则。

GIF 的百倍差距从哪里来

GIF 是这次变化最大的一项。

旧路径把两色头像先展开成 NRGBA,再交给 gif.Encode:

text 复制代码
两色 Icon
  → NRGBA 真彩色画布
  → GIF 扫描像素并量化颜色
  → 重新生成调色板
  → 编码

新路径直接交付已经带两个调色板项的 image.Paletted:

text 复制代码
两色 Icon
  → Paletted 索引画布
  → GIF 编码

从 26.43 ms 到 227.5 µs,并不是我改写了 GIF 压缩算法,而是不再让编码器重复猜测一份项目本来就知道的调色板。

这条经验也不能随便推广到所有图片。照片、渐变或颜色数量很多的图像仍然需要颜色分析,提前压成两色只会破坏内容。它成立的前提,是 gdenticon 的栅格结果本身就只有两种确定颜色。

JPEG 不要调色板,而是换成 RGBA

JPEG 没有透明通道,也不是索引色格式,所以没有跟 PNG 和 GIF 走同一条路。这里的变化看起来只是一处类型替换:

go 复制代码
return jpeg.Encode(
    w,
    rasterRGBA(icon, opts.Size),
    &jpeg.Options{Quality: quality},
)

但标准库编码器会根据具体图片类型选择处理路径。旧 benchmark 中,JPEG256 每次操作有 65,575 次分配;切换到 image.RGBA 后剩下 11 次。

256×256 一共有 65,536 个像素,这两个数量非常接近。结合编码器对具体图片类型的处理差异,可以判断大量分配来自通用逐像素转换路径;不过文章里的 benchmark 能证明结果,不能单独替代完整的 profile,所以我不会把这个数字对应写成唯一原因。

这次修改后,JPEG 编码结果与旧 NRGBA 路径在不透明颜色下保持字节一致。带 Alpha 的输入也增加了像素级对照测试,确认预乘 Alpha 的转换语义没有因为换画布而失控。

透明度决定 BMP 和 TIFF 是否能走快路径

PNG 的调色板项可以保留完整 Alpha,因此透明背景和部分透明前景仍然可以使用 Paletted。GIF 本身只支持二值透明,部分透明会按照 GIF 的格式能力落成透明或不透明。

BMP 和 TIFF 的处理更保守。只有前景、背景都完全不透明时才走调色板后端;只要其中一个带 Alpha,就退回 NRGBA:

go 复制代码
func iconColoursOpaque(icon Icon) bool {
    _, _, _, backgroundAlpha := icon.Background.RGBA()
    _, _, _, foregroundAlpha := icon.Foreground.RGBA()
    return backgroundAlpha == 0xffff && foregroundAlpha == 0xffff
}

func writeBMP(w io.Writer, icon Icon, opts WriteOptions) error {
    if iconColoursOpaque(icon) {
        return bmp.Encode(w, rasterPaletted(icon, opts.Size))
    }
    return bmp.Encode(w, rasterNRGBA(icon, opts.Size))
}

这意味着带透明度的 BMP、TIFF 不会拿到表格里最漂亮的性能数字,但原来的颜色语义能保住。优化不是让每条分支都强行走同一条快路,而是先确认格式和输入允许不允许。

没有加入画布池

看到内存和分配以后,一个很自然的想法是把画布塞进 sync.Pool。v2.2.0 没有这样做。

每次编码仍然创建自己的图片,Icon 在写入期间保持只读,Writer 也由调用方单独提供。代码里没有共享的可变画布,没有全局 worker pool,也没有任务队列。这样并发调用不需要围绕画布加锁,也不用处理对象归还时残留像素或错误路径忘记归还的问题。

为此新增的测试不只比较输出文件能不能解码,还会逐像素检查不透明、全透明和部分透明颜色,并对栅格与编码路径运行 race test。PNG 另外保留了并行 benchmark,用来观察独立 Writer 下的吞吐,而不是用共享状态换一个更好看的单线程数字。

性能以外,还有输出兼容性

公开 Go API 在 v2.2.0 没有变化,调用方仍然使用 Generate、Icon 和 Write。解码后的图片几何和颜色也应当在各格式能力范围内等价。

但编码后的字节不保证和旧版相同。PNG、GIF、BMP、TIFF 和 JPEG 可能因为底层图片表示不同,产生不同的文件内容。PNG benchmark 中,同一张测试头像从 874 字节变成 160 字节,就是索引色编码带来的结果,并不是头像少画了几块。

如果应用只把图片发给浏览器或解码后使用,这通常没有影响。如果外部系统把完整文件字节当作缓存键、内容哈希或快照结果,升级以后需要重新生成。性能优化没有改公开函数,也不代表编码产物可以逐字节保持不变。

这次真正改掉的是什么

回头看,这一轮并没有改头像生成算法,也没有引入复杂的并行框架。它只改了一件基础的事:不要先把所有数据塞进最通用的表示,再希望每个下游都能同样高效地处理。

现在的路径是:

text 复制代码
Icon
  → 共享 rasterLayout
  ├─ Paletted → PNG、GIF、不透明 BMP/TIFF
  ├─ RGBA     → JPEG
  └─ NRGBA    → 带 Alpha 的 BMP/TIFF

结果不只反映在耗时上。GIF256 的内存从约 422 KB 降到 146 KB,BMP256 从约 264 KB 降到 67 KB,TIFF256 从约 265 KB 降到 74 KB;JPEG256 从 65,575 次分配降到 11 次。与此同时,透明度回退、像素等价和并发安全都留了对应测试。

原来准备做的 balanced 候选分析没有取消,只是被这轮更明确的瓶颈往后排了。等下一次真的动到候选筛选,我仍然会从 benchmark 和 profile 开始,而不是先决定一定要上缓冲池或者合并循环。

项目与版本:

相关推荐
我不会起名字3221 小时前
Redis 从 0 到 1(十六):1 亿 UV 只占 12KB?计数去重三件套的 Go 实测
redis·go·uv·布隆过滤器·hyperloglog
FungLeo4 小时前
成为全栈·Go 后端篇·换后端不改前端:四个客户端如何做兼容回归
flutter·golang·go·成为全栈·兼容测试·taro 小程序
我不会起名字3224 小时前
Redis 从 0 到 1(二十一):P99 涨到 80ms,慢查询日志 5 步定位法 + Go 采集器
redis·go·慢查询·排查·slowlog
我不会起名字3225 小时前
Redis 从 0 到 1(十八):List、Pub/Sub、Stream 为什么生产只用 Stream?Go 实操
redis·消息队列·go·stream·pub/sub
孔令飞6 小时前
AI 岗位类别、要求、技能及学习路线
云原生·go·ai编程
君万1 天前
【LeetCode每日一题】回溯:46. 全排列 78. 子集 39. 组合总和
算法·leetcode·go
vipxieliang1 天前
Go 逃逸分析与内存优化:从读懂 -gcflags=“-m“ 到降低 GC 压力
go
小杨互联网2 天前
Go 现代 CLI 工具离线镜像合集
开发语言·后端·golang·开源·go·命令行
hourinX2 天前
从直接填格子到筛选图案:头像生成这部分是怎样改过来的—— gdenticon 重构日志(三)
go