第三篇写完以后,我原本准备继续拆 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 开始,而不是先决定一定要上缓冲池或者合并循环。
项目与版本:
- GitHub:hourinX/gdenticon
- Gitee:hourinX/gdenticon
- Release:gdenticon v2.2.0