test(artwork): benchmark both hash encoders from one file

benchImage and BenchmarkEncodeAtInputSize existed in both encoder packages, and
blurhash's copy had drifted into a third inline duplicate once both benches
moved to NRGBA input.

The head-to-head now lives in core/artwork, which already imports both encoders
and can pin the input to the real thumbnailSize constant. The gradient builder
moves to tests, which both packages already import via their suite bootstrap.

thumbhash keeps a shipped-vs-reference benchmark, since referenceEncode is
test-only and cannot be reached from core/artwork.
This commit is contained in:
Deluan 2026-07-31 17:58:55 -04:00
parent ecf12b269f
commit 9084685fbc
4 changed files with 97 additions and 115 deletions

View File

@ -1,63 +0,0 @@
package blurhash_test
import (
"fmt"
"image"
"image/color"
"testing"
"github.com/navidrome/navidrome/core/artwork/blurhash"
)
// benchImage builds a deterministic gradient so runs are comparable across revisions.
func benchImage(size int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
img.SetNRGBA(x, y, color.NRGBA{
R: uint8(255 * x / size),
G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)),
A: 255,
})
}
}
return img
}
// BenchmarkEncodeAtInputSize mirrors the thumbhash bench of the same name. *image.NRGBA, matching
// makeThumbnail's output, so neither package is measured with a conversion the other avoids.
func BenchmarkEncodeAtInputSize(b *testing.B) {
const size = 100
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := range size {
for x := range size {
img.SetNRGBA(x, y, color.NRGBA{
R: uint8(255 * x / size),
G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)),
A: 255,
})
}
}
b.ReportAllocs()
for range b.N {
if _, err := blurhash.Encode(img); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkEncode(b *testing.B) {
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
img := benchImage(size)
b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) {
b.ReportAllocs()
for range b.N {
if _, err := blurhash.Encode(img); err != nil {
b.Fatal(err)
}
}
})
}
}

View File

@ -0,0 +1,52 @@
package artwork
import (
"fmt"
"image"
"testing"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/core/artwork/thumbhash"
"github.com/navidrome/navidrome/tests"
)
// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail.
var hashEncoders = []struct {
name string
encode func(image.Image) error
}{
{"blurhash", func(img image.Image) error { _, err := blurhash.Encode(img); return err }},
{"thumbhash", func(img image.Image) error { _, err := thumbhash.Encode(img); return err }},
}
func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image) {
b.Helper()
b.ReportAllocs()
for b.Loop() {
if err := encode(img); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image
// makeThumbnail produces, so neither is measured with a conversion the other avoids.
func BenchmarkHashEncodersAtInputSize(b *testing.B) {
img := tests.GradientImage(thumbnailSize)
for _, e := range hashEncoders {
b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) })
}
}
// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive
// downscale starts to dominate.
func BenchmarkHashEncoders(b *testing.B) {
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
img := tests.GradientImage(size)
for _, e := range hashEncoders {
b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) {
benchEncoder(b, e.encode, img)
})
}
}
}

View File

@ -1,66 +1,36 @@
package thumbhash_test
import (
"fmt"
"image"
"image/color"
"testing"
"github.com/navidrome/navidrome/core/artwork/thumbhash"
"github.com/navidrome/navidrome/tests"
)
// benchImage builds the same deterministic gradient the blurhash bench uses, as *image.NRGBA —
// the concrete type makeThumbnail hands both encoders in production.
func benchImage(size int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
// BenchmarkEncodeVsReference pins the two-pass separable encoder against the naive reference port
// it replaced. referenceEncode is test-only, so this cannot live beside the cross-encoder
// benchmark in core/artwork.
func BenchmarkEncodeVsReference(b *testing.B) {
const size = 100
img := tests.GradientImage(size)
pix := make([]byte, 0, size*size*4)
for y := range size {
for x := range size {
img.SetNRGBA(x, y, color.NRGBA{
R: uint8(255 * x / size),
G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)),
A: 255,
})
}
pix = append(pix, img.Pix[y*img.Stride:y*img.Stride+size*4]...)
}
return img
}
// BenchmarkEncodeAtInputSize is the bar: 100x100 is exactly what the artwork pipeline feeds.
func BenchmarkEncodeAtInputSize(b *testing.B) {
img := benchImage(100)
b.ReportAllocs()
for range b.N {
if _, err := thumbhash.Encode(img); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkEncode(b *testing.B) {
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
img := benchImage(size)
b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) {
b.ReportAllocs()
for range b.N {
if _, err := thumbhash.Encode(img); err != nil {
b.Fatal(err)
}
b.Run("shipped", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := thumbhash.Encode(img); err != nil {
b.Fatal(err)
}
})
}
}
}
})
// BenchmarkReferenceEncode is the naive baseline: four w*h scratch arrays and 40 pixel re-reads.
func BenchmarkReferenceEncode(b *testing.B) {
img := benchImage(100)
w, h := 100, 100
pix := make([]byte, 0, w*h*4)
for y := range h {
pix = append(pix, img.Pix[y*img.Stride:y*img.Stride+w*4]...)
}
b.ReportAllocs()
for range b.N {
_ = referenceEncode(w, h, pix)
}
b.Run("reference", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
_ = referenceEncode(size, size, pix)
}
})
}

23
tests/images.go Normal file
View File

@ -0,0 +1,23 @@
package tests
import (
"image"
"image/color"
)
// GradientImage builds a deterministic square gradient, so benchmark runs are comparable across
// revisions. NRGBA is the type the artwork pipeline's makeThumbnail hands its hash encoders.
func GradientImage(size int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := range size {
for x := range size {
img.SetNRGBA(x, y, color.NRGBA{
R: uint8(255 * x / size),
G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)),
A: 255,
})
}
}
return img
}