From b1571dd4556b85d14d21d6875a0cc9d5aeb98a0b Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 11 Aug 2026 11:50:46 +0200 Subject: [PATCH] DrawImage: stop re-proving what a repeat already proved, and keep rowOpaque out of line Two defects, both found by measuring the widgets rather than the primitive, and both invisible from reading the code. v0.7.0 regressed the unclipped cases it was not meant to touch: a full 1000x700 blit went from 265,039 ns/op at v0.5.0 to 1,152,535. The clipped rewrite made the loop bigger, and rowOpaque -- a tight strided scan whose cost lives entirely in register allocation -- was inlined into it. Kept out of line the SAME code measures 238,252. The directive carries that pair of numbers, because a reader deleting it deserves to know what it costs. Profiling pointed at rowOpaque while attributing memmove's work to it; only crossing the two implementations against each other, then the two inlining choices, settled which of the two was to blame. Separately, an enlarged blit scanned every source row TWICE: once when the row was built, once for each destination row repeating it -- 700 scans of 1.4 MB per blit to prove 350 rows opaque. Settling the repeat before touching the source removes that half outright. prevSY only ever holds a row that was written whole, so a match already proves opacity. 1:1 279726 ns/op (per-pixel 1867584) 6.7x enlarged 351219 ns/op (per-pixel 2066103) 5.9x clipped 314708 ns/op (per-pixel 2644680) 8.4x Every shape is now faster than it was at any earlier version: v0.6.0 blit scaled in 434,038 and v0.7.0 blit clipped in 911,278. 100% statement coverage; the 16-case comparison against the loop DrawImage replaced is unchanged and still passes. Co-Authored-By: Claude Opus 4.8 --- image.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/image.go b/image.go index 2af1daa..b7c9989 100644 --- a/image.go +++ b/image.go @@ -78,16 +78,23 @@ func (p *PixelPainter) DrawImage(dst Rect, src []byte, srcW, srcH int) { continue } + // A repeat is settled before anything is read from the source. prevSY + // only holds a source row that was written whole, so matching it proves + // the row is opaque as well as already built -- re-scanning its alphas + // would be half the bytes of the blit spent proving what is known. + if sy == prevSY && prevRow >= 0 { + copy(p.Buf[lo:hi], p.Buf[prevRow+eff.X*4:prevRow+(eff.X+eff.W)*4]) + prevRow = dstRow + continue + } + // A fully opaque row replaces what was underneath, so it can be written // without consulting it. A row with any translucency cannot: the result // depends on the ground, which differs from row to row. if rowOpaque(src[srcRow : srcRow+srcW*4]) { - switch { - case sy == prevSY && prevRow >= 0: - copy(p.Buf[lo:hi], p.Buf[prevRow+eff.X*4:prevRow+(eff.X+eff.W)*4]) - case dst.W == srcW && eff.X == dst.X && eff.W == dst.W: + if dst.W == srcW && eff.X == dst.X && eff.W == dst.W { copy(p.Buf[lo:hi], src[srcRow:srcRow+srcW*4]) - default: + } else { scaleRow(p.Buf[lo:hi], src[srcRow:srcRow+srcW*4], srcW, dst.W, eff.X-dst.X) } prevRow, prevSY = dstRow, sy @@ -151,6 +158,15 @@ func scaleRow(dst, src []byte, srcW, dstW, x0 int) { // rowOpaque reports whether every pixel of an RGBA row is fully opaque, which // is what makes a wholesale copy equivalent to compositing it. +// +// The directive is not decoration. Inlined into the loop above -- which grew +// when the clip stopped being tested per pixel -- this scan measured 1,152,535 +// ns/op on a full 1000x700 blit; kept out of line, the SAME code measures +// 238,252. The body is a tight strided scan whose cost lives entirely in +// register allocation, and folding it into an already register-hungry loop is +// what wrecks it. Remove the directive and the benchmark says so immediately. +// +//go:noinline func rowOpaque(row []byte) bool { for i := 3; i < len(row); i += 4 { if row[i] != 0xFF {