**有一阵子没更新了,好久不见。这段时间文章虽然停了一下,gdenticon 的重构倒是没停。本周我又顺着图片生成这条链路做了一轮优化,也把版本推进到了 v2.3.0。
上一篇停在 v2.2.0:我把 PNG、GIF、JPEG、BMP 和 TIFF 从同一块 image.NRGBA 画布里拆了出来。PNG 和 GIF 使用 image.Paletted,JPEG 使用 image.RGBA,BMP、TIFF 再根据透明度决定走调色板还是 image.NRGBA。
这一步解决的是数据表示不合适的问题,但我继续测下去以后,又发现了一个不太相称的数字:生成并编码一张 64×64 的 PNG,v2.2.0 每次操作会分配大约 847 KiB。
图片本身只有 4096 个像素,而且通常只有前景、背景两种颜色。就算把生成、栅格化和编码全算进去,八百多 KiB 仍然有点扎眼。这次的目标也就比较明确了:先确认内存花在什么地方,再沿着已经拆开的栅格和编码链路继续往下改。
相关改动已经发布在 gdenticon v2.3.0。公开 API 没变,头像图案和颜色也没变,主要变化发生在 PNG 编码器的临时工作区,以及几种栅格画布填充矩形的方式上。
这 847 KiB 是怎么测出来的
这一轮新增了比较完整的 benchmark_test.go,不再只测某一个最终格式。现在可以分别观察:
Generate本身花了多少时间;Paletted、RGBA、NRGBA三种栅格化路径的成本;- 六种输出格式在不同尺寸下的编码表现;
- 生成加编码的完整链路;
- 并发情况下的 PNG 吞吐;
- 64×64 网格写入 64×64 图片时,每个格子只有一个像素的极端情况。
Release 里的对比使用 64×64 PNG,生成参数固定为 7×7 网格、镜像、balanced、SHA-256。测试机器是 Intel Core i5-12400F。核心 benchmark 没有写入磁盘,输出直接交给 io.Discard:
go
func BenchmarkPNG64Single(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
icon, err := Generate("first@example.com", benchmarkPNG64Options)
if err != nil {
b.Fatal(err)
}
if err := Write(io.Discard, icon, WriteOptions{Size: 64}); err != nil {
b.Fatal(err)
}
}
b.ReportMetric(float64(b.N)/b.Elapsed().Seconds(), "icons/s")
}
这样测到的是生成和编码,不包含文件系统、终端输出或者网络传输。最终数据如下:
| 指标 | v2.2.0 | v2.3.0 |
|---|---|---|
| 单线程吞吐 | 约 6,300 icons/s | 约 30,000 icons/s |
| 8 worker 吞吐 | 约 13,000 icons/s | 约 155,000 icons/s |
| 每次操作内存 | 约 847 KiB | 约 4.8 KiB |
| 每次操作分配 | 71 | 20 |
单线程大约提高到原来的 4.8 倍,8 worker 场景接近 12 倍,每次操作的内存减少约 99.4%。这些数字只对应这台机器上的 64×64 PNG 测试,不代表其他尺寸、格式和硬件也会得到相同倍数。
真正占内存的,是 PNG 编码工作区
v2.2.0 的 PNG 已经使用 image.Paletted。64×64 的索引画布并不大,填充的格子数量也有限,但后面调用的仍然是标准库提供的快捷函数:
go
return png.Encode(w, rasterPaletted(icon, opts.Size))
png.Encode 每次调用都要准备编码过程中使用的临时缓冲区。对于大图,这部分成本会被大量像素处理摊薄;对于 64×64 的小头像,画布很快就画完了,编码器工作区反而显得很重。
标准库的 png.Encoder 提供了 BufferPool,可以让调用方复用编码工作区。v2.3.0 增加了一层很薄的适配,把 sync.Pool 接到这个接口上:
go
type pngBufferPool struct {
pool sync.Pool
}
func (p *pngBufferPool) Get() *png.EncoderBuffer {
buffer, _ := p.pool.Get().(*png.EncoderBuffer)
return buffer
}
func (p *pngBufferPool) Put(buffer *png.EncoderBuffer) {
p.pool.Put(buffer)
}
var pngBuffers pngBufferPool
var pngEncoder = png.Encoder{BufferPool: &pngBuffers}
PNG 的写入随后改成:
go
return pngEncoder.Encode(w, rasterPaletted(icon, opts.Size))
这里复用的是编码器的工作区,不是最终生成的 PNG 内容,也不是 Icon、栅格画布或调用方传入的 io.Writer。每次调用仍然生成自己的图片,并写入自己的目标;池里保存的对象只在编码器内部作为临时空间使用。
这个范围是我有意控制的。把最终字节或者可变画布也放进全局池,确实还能继续减少部分分配,但归还时就要处理残留数据、错误路径和对象生命周期。现在这层复用由 PNG 编码器自己的接口约束,改动更小,也不需要为整个项目增加任务队列或全局锁。
sync.Pool 也不是永久缓存。GC 可以清掉池中的对象,所以程序的正确性不能依赖下一次一定能取到旧工作区。池命中时少一次申请,取不到时照常创建,比较适合这里的临时对象。
一个纯色方块,没必要逐像素填写
PNG 工作区解释了大部分内存差距,但栅格化本身也有重复工作。
v2.2.0 填充 RGBA 和 NRGBA 矩形时,会遍历矩形里的每一个像素,每次重新计算偏移,再写入四个颜色通道。逻辑很直白:
go
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
offset := m.PixOffset(x, y)
m.Pix[offset+0] = c.R
m.Pix[offset+1] = c.G
m.Pix[offset+2] = c.B
m.Pix[offset+3] = c.A
}
}
identicon 的一个格子实际上是同一种颜色组成的矩形。第一行填好以后,后面的行并不需要重新拆一次 RGBA。v2.3.0 把 RGBA 和 NRGBA 的公共部分收进 fillFourChannel:
go
func fillFourChannel(
pix []byte,
offset, stride, width, height int,
pixel [4]byte,
) {
rowBytes := width * 4
first := pix[offset : offset+rowBytes]
copy(first, pixel[:])
for filled := len(pixel); filled < len(first); {
filled += copy(first[filled:], first[:filled])
}
for row := 1; row < height; row++ {
start := offset + row*stride
copy(pix[start:start+rowBytes], first)
}
}
它先写入一个像素,再通过倍增复制铺满第一行,最后把完整的一行复制到剩余各行。原来每个像素都要做的通道赋值和偏移计算,变成了几次连续内存复制。
这里不能默认 stride == width * 4。从完整图片上截出来的子图,行与行之间可能仍然保留父图的跨度,所以后续每一行都用 row*stride 找起点。对应测试特意在一张较大的父图上建立子图,再检查目标矩形之外的像素有没有被改掉。
image.Paletted 也采用了类似思路:先填满第一行,再复制到后面的行。不过索引图的一个像素只有一个字节,如果矩形本来就很窄,准备复制路径的开销可能比直接循环还大。
所以代码保留了一个小格子的直接路径:
go
if rect.Dx() < minPalettedCopyWidth || rect.Dy() < 2 {
for y := rect.Min.Y; y < rect.Max.Y; y++ {
row := m.Pix[m.PixOffset(rect.Min.X, y):m.PixOffset(rect.Max.X, y)]
for x := range row {
row[x] = 1
}
}
return
}
fillPaletted(m, rect, 1)
这也是为什么 benchmark 里单独放了一个 64×64 网格写入 64×64 图片的用例。常见的 5×5、7×7 网格会产生比较宽的色块,而 64×64 网格的单元格只有一个像素。只测前一种情况,很容易把大块复制的优势误写成所有场景都更快。
并发吞吐为什么涨得更多
8 worker 的吞吐从约 13,000 icons/s 提高到约 155,000 icons/s,看起来像是增加了某种并行框架,实际并没有。
gdenticon 仍然没有全局 worker pool,也没有共享的可变画布。并行 benchmark 只是让 Go 同时执行多次正常的 Generate 和 Write。每个 goroutine 使用独立的 Icon、栅格图片和 Writer,共享的只有支持并发访问的 PNG 工作区池。
提升更明显,是因为旧路径在多个 goroutine 中会同时反复申请较大的 PNG 临时空间,内存分配和回收压力一起被放大。工作区可以复用以后,并发请求不再把同一类短命对象不断推给 GC。
为了确认池没有把不同请求的状态混在一起,测试会先生成一份标准输出,再启动 32 个 goroutine 并发编码同一个 Icon,逐字节比较所有结果。测试还保留了标准 png.Encode 与池化编码器的输出对照。
这部分验证的是当前实现没有共享错误的数据,不代表所有调用方都可以复用同一个 bytes.Buffer 或文件句柄。Write 支持并发调用的前提仍然是每次调用使用独立、可安全写入的 Writer。
跑得快了,先确认图片没有画坏
这次从 v2.2.0 升到 v2.3.0,一共改了 13 个文件,增加 632 行、删除 26 行。单看数字像是改了很多生产代码,实际上新加的 benchmark_test.go 就占了 331 行,再算上几个测试文件,新增内容里有五百多行都在 benchmark 和回归测试上。
主要代码并没有膨胀多少,真正花时间的是确认这些快路径没有把原来的行为改坏。
PNG 工作区加进池以后,我先保留了一份标准库 png.Encode 的结果,再用新的 pngEncoder 编码同一个 Icon,最后直接比较两边的字节:
go
func TestWritePNGPoolMatchesStandardEncoder(t *testing.T) {
icon := testIcon(FormatPNG)
const size = 64
var want bytes.Buffer
if err := png.Encode(&want, rasterPaletted(icon, size)); err != nil {
t.Fatal(err)
}
var got bytes.Buffer
if err := Write(&got, icon, WriteOptions{Size: size}); err != nil {
t.Fatal(err)
}
if !bytes.Equal(got.Bytes(), want.Bytes()) {
t.Fatal("pooled PNG output differs from the standard encoder")
}
}
这里只验证一次还不够。工作区真正容易出问题的地方是在并发下,所以测试又启动了 32 个 goroutine,同时编码同一个只读 Icon。每个 goroutine 使用自己的 bytes.Buffer,结果必须和基准输出逐字节一致。这样至少能确认池里复用的是临时空间,不会把上一次或者另一个请求的内容带进来。
矩形填充的测试更麻烦一点。完整画布的 Stride 通常正好等于一行像素所占的字节数,就算代码误把两者当成一回事,也不容易暴露。所以测试先创建一张大图,再从中截出一块子图,最后只填子图里的某个矩形。填完以后不只检查目标区域,还会把父图上其他像素全部扫一遍,确认复制第一行时没有跨到相邻区域。
image.Paletted 的两条路径也分别留了用例:窄色块继续逐字节写,较宽的色块走整行复制。除此之外,空矩形也会直接交给三个填充函数,确认它们能够什么都不做,而不是在取第一行时越界。
benchmark 这边也不只保留那张 64×64 PNG 的汇总表。现在可以分别测:
classic和balanced的生成;- 5×5、7×7、11×11 等不同网格;
- Paletted、RGBA、NRGBA 三种栅格画布;
- PNG、SVG、JPEG、GIF、BMP、TIFF 在 64 到 1024 像素下的编码;
- 单线程生成加编码,以及并行完整链路;
- 64×64 网格写进 64×64 图片的单像素格子场景。
这些测试不会让头像本身变得更好看,但后面再改某一段时,至少能知道到底是生成慢了、画布慢了,还是编码慢了。性能优化最怕数字涨了,却不知道是哪条输入被改坏了,这次算是把这部分欠账一起补上了。
性能之外,这次还补了几个边界
性能是 v2.3.0 的主线,但这次还处理了几个库行为上的问题。
传错 PatternStyle 现在会直接报错
PatternDefault 会先解析为默认样式,随后只接受 PatternClassic 和 PatternBalanced。其他数字现在直接返回错误:
go
style := opts.Style
if style == PatternDefault {
style = DefaultStyle
}
if style != PatternClassic && style != PatternBalanced {
return Icon{}, fmt.Errorf("unsupported pattern style %d", style)
}
公开类型和函数签名没有变化,但错误值不会再悄悄落到某一条可运行的分支。对正常调用没有影响,依赖非法枚举值的代码会在升级后更早暴露问题。
缓存文件名从 16 个字符增加到 32 个
缓存仍然根据最终编码内容的 SHA-256 摘要命名,只是截取长度从 16 个十六进制字符增加到了 32 个,对应 128 位摘要前缀。
旧缓存文件不会损坏,也不会被删除,但新版本不会再命中原来的 16 字符文件名。第一次运行会重新生成一份新名称的缓存,这是这项修改带来的实际迁移成本。
缓存扩展名不能再夹带路径
缓存扩展名现在只能是空字符串,或者 .png、.tar.gz 这种普通后缀。路径分隔符、冒号和异常点号会被拒绝。测试覆盖了 ../evil、.png/../../evil 和 Windows 路径分隔符等输入,确认返回的缓存路径始终留在指定目录内。
这一段不影响正常的 CLI 输出,却把 PutCacheEntry 作为库函数使用时的边界说得更清楚了。
这一轮重构先写到这里
这次重构差不多就先到这里了,目前想到的、也有数据能确认的改动,都已经放进 v2.3.0。
至于下一篇什么时候更新,说实话我也不知道。平时还要上班,只能下班或者周末抽点时间写代码。有空的时候可能连续改好几天,忙起来就只能先放着,所以后面也不打算硬给自己定时间了,有新进展再写。
项目基本还是我一个人在折腾,难免有些地方自己已经看习惯了,反而发现不了问题。如果大家看代码的时候觉得哪里不太对,或者实际用起来有点绕,都可以来提 Issue 跟我说。也不用是什么很完整的方案,哪怕只是觉得这个接口用着别扭,也可能是我之前没注意到。
我后面还有个比较大的想法,想在某个大版本里试试二次元漫画头像。它不是拿现成的五官、发型和配件素材来拼,而是和现在一样保持纯确定性:输入同一个 ID,就得到同一张头像。不同的是,后面的脸型、头发、五官这些元素都会由程序通过贝塞尔曲线直接画出来,不再把表现形式限制在像素风。光影部分我可能会尝试赛璐璐风格,不过这个还没完全想定。
这个方向我自己挺想做的,但工作量肯定不小。从输入里怎样稳定地产生绘制参数、不同曲线之间怎样组合,又怎么保证生成结果不会奇怪,都得慢慢试。
所以这件事暂时先放在愿望单里,什么时候能动手还真不好说。现阶段还是希望大家多帮我看看现在这个版本,有什么遗漏或者还能继续改的地方,欢迎直接来聊。
项目与版本:
- GitHub:hourinX/gdenticon
- Gitee:hourinX/gdenticon
- Release:
gdenticonv2.3.0
**