Skip to content

perf: hidden shader costs — ETC2 −8%, BC1 −9%, BC5 −5%, ASTC f32 −10% - #17

Merged
verekia merged 2 commits into
mainfrom
perf/shader-hidden-costs
Sep 25, 2026
Merged

verekia merged 2 commits into
mainfrom
perf/shader-hidden-costs

Conversation

@verekia

@verekia verekia commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Every encoder except BC7 f16 is faster on the GPU. ETC2, BC5, BC7 and ASTC output is byte-identical to main. BC1 moves 0.1–0.7% of blocks with no measurable quality change.

Why

Replacing the ETC2 texture reads with synthetic data took the same time as the real kernel. The encoders are limited by arithmetic, not memory reads, so every instruction counts. Most of the gain came from costs that WGSL-to-Metal compilation adds without showing them in the source:

  • Float-to-integer conversions: u32(f32) and i32(f32) compile to a clamped conversion with extra compares and selects. Where values are exact whole numbers in [0, 2²³), bitcast(x + 2²³) reads them out in one or two ops. The worst cases were ASTC f32's to8(), with four conversions per texel in the load loop, and one per texel in the BC1, ASTC and BC7-gray index loops.
  • Runtime-indexed arrays: ETC2's edge-block loop wrote col[x][y] and qsum[q] with runtime indices. That cost about 2% on every block, even on 1K images where it never runs.
  • Constant-array lookups: each lookup with a runtime index pays a bounds check. One 4-wide table replaces four separate arrays.

Results

Measured on /eval (M3, hardware-decoded quality, interleaved timing, default corpus per format):

format f16 module f32 module output vs main
ETC2 ×0.918 ×0.917 byte-identical
BC1 ×0.907 ×0.923 0.1–0.7% of blocks move, ΔPSNR +0.0003 dB mean / −0.0005 dB worst
BC5 ×0.951 ×0.982 byte-identical
ASTC ×0.978 ×0.905 (gray −15%) byte-identical
BC7 unchanged ×0.985 (gray −5%) byte-identical
  • /test: 114 checks, 0 failures. Every quality row is above its threshold, with no easy-block regressions.
  • bun test: 225 pass.
  • WebGL: matches main on ETC2, BC5, BC7 and ASTC. BC1's GLSL got the same loop unroll, so WebGL still matches the f32 WebGPU module exactly (0 differing blocks). WebGL BC1 is 4–6% faster at 4K.
  • f16 vs f32: still byte-identical for ETC2.

Per format

  • ETC2: conversions replaced with the bitcast trick. Edge blocks go through the normal 2×2 path with clamped loads. One 4-wide score table. The table score uses min(0,x) = (x − |x|)/2, which is exact with a free |·| modifier (−2%, gray −3%). Smaller cuts: codes expanded once, branch-free ETC1 header, simpler planar fix bits, 8-select flip arrangement, constant gather offsets, index loop written out by hand, cheaper byte swap and a vec2 store.
  • BC1: index and to565 via the bitcast trick, min instead of clamp in the load, and the projection loop unrolled through a project() helper (−3.5%). The unroll lets fast-math reorder the sums, which is where the moved blocks come from.
  • BC5: endpoint conversions via the bitcast trick, unrolled quad loads.
  • ASTC: weight and endpoint conversions via the bitcast trick, including to8() in the f32 module.
  • BC7 f32: gray-path conversions only. Doing the same on the colour path made it 1–2% slower.

Edge blocks

In ETC2, blocks on the edge of images that aren't a multiple of 4 now round their sums the same way interior blocks do. In a 250×250 colour-card crop, 6 blocks moved and total error rose by 0.02%. The /test odd-size check still matches the CPU reference (Δ 0.00 dB).

Tried and rejected

  • ETC2, codes recomputed with the selected scale: can round differently from the code the differential-mode test saw, which would corrupt the block.
  • ETC2, sums computed after the branch merge: moved bytes and lost the gain.
  • BC1, texture gathers: 1% slower, and changed bytes.
  • BC5, unrolled first pass: 1.2% slower.
  • BC7 f16: the covariance unroll cost −0.008 dB, and the gray-loop unroll caused an easy-block regression.
  • Unrolled loops that change bytes: the ASTC and BC7 gray loops.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CvMZvPkPRPM8oZMVs44qB7

verekia and others added 2 commits September 25, 2026 18:09
…te-identical

The kernel is ALU-bound at 1K: replacing the gathers with synthetic data
takes the same time as the real kernel, so every op counts. The biggest
wins were costs WGSL→Metal adds that the source doesn't show:
- u32(f32) compiles to a saturating conversion (compares + selects).
  Packing's 15 now use bitcast(x + 2^23) on exact integers (~−2.5%).
- The edge-block loop wrote col[x][y]/qsum[q] with runtime indices,
  costing ~2% on every block even where it never ran. Edge blocks now
  build the same quads from clamped loads.
- Runtime-indexed const-array lookups each pay a bounds clamp: one vec4
  table per score instead of four scalar arrays (~−1%).
- Table score: min(0,x) = (x − |x|)/2 with |·| as a free source
  modifier; exact integers, identical results (−2%, gray −3%).
- Smaller: expand codes once, branch-free ETC1 header, streamlined
  planar fix bits, 8-select flip arrangement, gather constant offsets,
  hand-unrolled index loop, cheaper bswap + vec2<u32> store.

/eval (M3, default corpus): f16 ×0.918, f32 ×0.917; 100% byte-identical
to main on all 14 images; f16 ≡ f32; WebGL ≡ main. Only edge blocks of
non-multiple-of-4 images may move (they now round like interior
blocks): 6 blocks of a 250² colour-card crop, +0.02% SSE; /test
odd-size check Δ 0.00 dB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvMZvPkPRPM8oZMVs44qB7
Applies the ETC2 findings (saturating u32(f32) conversions, runtime-
indexed loops) to the other encoders:
- BC1: projection index and to565 via the 2^23 trick, min not clamp in
  the load, projection loop unrolled via a project() helper. f16 ×0.907,
  f32 ×0.923. The unroll lets fast-math reorder the sums: 0.1–0.7% of
  blocks move, ΔPSNR +0.0003 dB mean / −0.0005 worst, no easy-block
  regressions. The GLSL port got the same unroll: WebGL still matches
  the f32 WGSL module byte for byte.
- BC5: endpoint conversions via the trick, unrolled quad loads.
  f16 ×0.951, f32 ×0.982, byte-identical.
- ASTC: per-texel weight and endpoint conversions via the trick; the
  f32 module's to8() did four per texel in the load. f16 ×0.978,
  f32 ×0.905 (gray maps −15%), byte-identical.
- BC7 f32: gray-path per-texel conversions only (gray maps −5%, colour
  neutral), byte-identical. BC7 f16 unchanged: nothing measured > 0.6%
  without a quality cost.

/test 0 failures, bun test 225 pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvMZvPkPRPM8oZMVs44qB7
@verekia
verekia merged commit c0a5397 into main Sep 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant