perf(thumbhash): cache cosine tables and flatten hot loop in decoder

The reference port recomputed the inverse DCT cosine tables once per output
pixel — for a 32x24 decode that's ~43k redundant cos() calls. This change
lifts the tables out of the inner loop and caches them across decodes
keyed by (size, componentCount) so subsequent placeholders at the same
target size skip cosine evaluation entirely.

Additional wins:
- AC coefficients unpack into pre-sized DoubleArrays (no ArrayList<Double>
  boxing, no post-hoc copy)
- truncated hashes are rejected up-front via a single length check instead
  of mid-stream
- LPQA -> sRGB uses an inline branch clamp instead of a
  min/max/round/coerceIn chain

Tests: 12 unit tests cover determinism across repeated decodes, cache
clearing, alpha preservation, aspect-ratio preservation both directions,
average-color drift bounds, truncated input rejection, and round-tripping
base64 vs. raw bytes. All green.

Bench: new ThumbHashBenchmark exercises opaque decode, alpha decode,
warm- and cold-cache decode, aspect-ratio probe, and the full
decodeKeepAspectRatio pipeline so the perf delta is visible on device.
This commit is contained in:
Claude
2026-04-20 00:33:26 +00:00
parent b2f297b3bb
commit 63da850e3b
3 changed files with 557 additions and 92 deletions
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalEncodingApi::class)
@RunWith(AndroidJUnit4::class)
class ThumbHashBenchmark {
@get:Rule
val benchmarkRule = BenchmarkRule()
// Representative opaque landscape hash. Produced by encoding a smooth
// 32x24 warm gradient so the AC coefficients exercise the full L block.
private val warmLandscape =
run {
val w = 32
val h = 24
val pixels =
IntArray(w * h) { i ->
val x = i % w
val y = i / w
val r = (160 + (x * 3)) and 0xff
val g = (90 + (y * 4)) and 0xff
val b = (40 + ((x + y) * 2)) and 0xff
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
}
ThumbHashEncoder.encodeToBase64(pixels, w, h)
}
// Representative alpha hash. Radial alpha vignette forces the alpha DCT
// block to carry real energy so decode cost matches production inputs.
private val alphaPortrait =
run {
val w = 24
val h = 32
val pixels =
IntArray(w * h) { i ->
val x = i % w
val y = i / w
val dx = x - w / 2
val dy = y - h / 2
val dist = kotlin.math.sqrt((dx * dx + dy * dy).toDouble())
val maxDist = kotlin.math.sqrt((w * w / 4 + h * h / 4).toDouble())
val alpha = (255 * (1.0 - (dist / maxDist).coerceIn(0.0, 1.0))).toInt()
(alpha shl 24) or (0x40 shl 16) or (0x80 shl 8) or 0xC0
}
ThumbHashEncoder.encodeToBase64(pixels, w, h)
}
private val warmBytes = Base64.decode(padded(warmLandscape))
private val alphaBytes = Base64.decode(padded(alphaPortrait))
private fun padded(s: String): String {
val r = s.length % 4
return if (r == 0) s else s + "=".repeat(4 - r)
}
@Test
fun testAspectRatioFromBase64() {
// Warm up
ThumbHashDecoder.aspectRatio(warmLandscape)
benchmarkRule.measureRepeated {
ThumbHashDecoder.aspectRatio(warmLandscape)
}
}
@Test
fun testAspectRatioFromBytes() {
ThumbHashDecoder.aspectRatio(warmBytes)
benchmarkRule.measureRepeated {
ThumbHashDecoder.aspectRatio(warmBytes)
}
}
@Test
fun testDecodeOpaqueBytes() {
// Warm up the cosine cache for this size.
ThumbHashDecoder.decode(warmBytes)
benchmarkRule.measureRepeated {
ThumbHashDecoder.decode(warmBytes)
}
}
@Test
fun testDecodeWithAlphaBytes() {
ThumbHashDecoder.decode(alphaBytes)
benchmarkRule.measureRepeated {
ThumbHashDecoder.decode(alphaBytes)
}
}
@Test
fun testDecodeOpaqueBase64() {
ThumbHashDecoder.decode(warmLandscape)
benchmarkRule.measureRepeated {
ThumbHashDecoder.decode(warmLandscape)
}
}
/**
* Measures decode cost when the cosine cache is cold on every call.
* This is the realistic "first time we see a new output size" cost; the
* cached case above represents steady-state feed scrolling.
*/
@Test
fun testDecodeOpaqueColdCache() {
ThumbHashDecoder.decode(warmBytes)
benchmarkRule.measureRepeated {
ThumbHashDecoder.clearCache()
ThumbHashDecoder.decode(warmBytes)
}
}
@Test
fun testDecodeKeepAspectRatio() {
ThumbHashDecoder.decodeKeepAspectRatio(warmLandscape, 32)
benchmarkRule.measureRepeated {
ThumbHashDecoder.decodeKeepAspectRatio(warmLandscape, 32)
}
}
}