Skip to content

Commit b2eca73

Browse files
donislawdevclaude
andauthored
perf: planning a png or a gif stops coding the picture twice (#58)
Planning drew the whole picture and compressed it only to learn its length, threw that away, and let the writer do it again. That was 38 to 53% of a PNG run. The ladder is walked largest rung first, so for any request comfortably above what the top rung encodes to, that rung is already the answer and the encode only confirms it. ladderCeiling is what "comfortably above" means, and planning takes the top rung without encoding when the request clears it. Everything near a rung boundary still encodes and still gets the exact number, so the answer never changes. The bytes are identical, and that was checked rather than reasoned: 147 combinations of size, seed, label and frame count - including sizes either side of the fast path threshold and either side of every rung - came out identical, refusals included. Measured with tools/probes/abcpu, order reversed, ranges disjoint: png, 300 files of 200 kB cpu 3797 -> 1859 ms wall 4734 -> 2580 ms gif, 200 files of 200 kB cpu 797 -> 453 ms wall 1232 -> 789 ms jpg is deliberately left alone. It writes its padding in FRONT of the picture, so the comment length has to be known before anything is encoded - which is exactly the number the fast path does not have. The only way round is moving the padding behind the picture, and that is a documented padding channel and a breaking change. Two guards, four mutations, all caught. One says the ceiling is safe - too low is the dangerous direction, because planning would then accept a size the writer has to refuse - and it reads the constant out of the source rather than copying it. The other says the fast path is actually taken: 50 plans allocate 29 kB against 2.4 MB for one write. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 9b087d5 commit b2eca73

4 files changed

Lines changed: 370 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,25 @@ because it turns other people's test suites red.
5858

5959
### Changed
6060

61+
- **Producing `.png` and `.gif` files is about twice as cheap.** Working out
62+
what a file will contain used to draw the whole picture and compress it, only
63+
to throw the result away and do it again when the file was actually written.
64+
It now does that once.
65+
66+
Measured on 300 files of 200 kB: `.png` takes **2.0 times less processor time
67+
and 1.8 times less wall clock**. For `.gif`, 1.8 and 1.6.
68+
69+
**The files are byte for byte identical.** This changes only how the work is
70+
ordered, and it was checked that way - across sizes either side of every step
71+
in the picture ladder, for several seeds, with the label on and off.
72+
73+
A preview (`--dry-run`) of a large run gets the bigger share of this, since
74+
previewing was almost entirely the work now removed.
75+
76+
`.jpg` is unchanged and cannot get the same treatment: it writes its padding
77+
in front of the picture, so it has to know how large the picture is before it
78+
starts.
79+
6180
- **`verify` and `cleanup` read the files over several threads, so checking a
6281
large run is several times faster.** Nothing about what they report changes -
6382
the same differences, in the same order, with the same exit codes.

‎internal/format/gif/gif.go‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ type memo struct {
159159
label string
160160
// body is the encoded picture up to but not including the trailer.
161161
body int64
162+
// bodyKnown says whether planning worked that out. For a request far
163+
// above what the largest rung encodes to, the answer cannot change which
164+
// picture is chosen, so planning skips the encoding and the writer fills
165+
// this in. See ladderCeiling.
166+
bodyKnown bool
162167
// payload is how many bytes of filler the comment carries, and blocks how
163168
// many sub blocks carry them. Both zero means no comment at all.
164169
payload int64
@@ -209,8 +214,14 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
209214
},
210215
}
211216

212-
if err := settlePadding(&m, r.Bytes, bare); err != nil {
213-
return format.Plan{}, err
217+
// With the body unknown the padding cannot be settled yet, and it does not
218+
// need to be: the fast path in chooseSize already established there is room
219+
// for a comment carrying whatever is left. The writer settles it once it
220+
// has encoded, which it has to do anyway.
221+
if m.bodyKnown {
222+
if err := settlePadding(&m, r.Bytes, bare); err != nil {
223+
return format.Plan{}, err
224+
}
214225
}
215226

216227
labelled := r.Label && imagelabel.Fits(w, len(label))
@@ -312,7 +323,17 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
312323
if err := encode(holder, m); err != nil {
313324
return err
314325
}
315-
if holder.written != m.body {
326+
if !m.bodyKnown {
327+
// Planning skipped the encoding, so this is where the exact size
328+
// arrives and the padding gets settled - the same arithmetic planning
329+
// would have done with the same number.
330+
m.body = holder.written
331+
if err := settlePadding(&m, p.Bytes, m.body+trailerSize); err != nil {
332+
// Unreachable unless ladderCeiling is wrong, and then saying so
333+
// beats writing a file of the wrong length.
334+
return fmt.Errorf("gif: %w - ladderCeiling is wrong", err)
335+
}
336+
} else if holder.written != m.body {
316337
return fmt.Errorf("gif: the picture encoded to %d B where planning said %d B", holder.written, m.body)
317338
}
318339
if holder.tail[0] != 0x3B {
@@ -368,6 +389,21 @@ func writeComment(ctx context.Context, w io.Writer, seed uint64, blocks, payload
368389
return err
369390
}
370391

392+
// ladderCeiling is the most the largest rung has ever been seen to encode to,
393+
// with room to spare. It only ever decides that a request is far enough above
394+
// the ladder that no search is needed, so being generous costs a few sizes
395+
// their fast path and being wrong costs nothing silently - the writer refuses
396+
// rather than producing a file of the wrong length.
397+
//
398+
// Measured 2026-09-06 at 640x480 over three seeds, the label both on and off,
399+
// and one, three, ten and sixty frames: 54518 B at the smallest and 64020 B at
400+
// the largest. Frames barely move it, about 150 B each, which is why this is
401+
// one number rather than a function of the frame count.
402+
//
403+
// TestTheLadderCeilingIsAboveEveryPictureTheTopRungMakes sweeps it rather than
404+
// trusting this comment.
405+
const ladderCeiling = 98304
406+
371407
// sizeLadder is tried from the largest down when the recipe names no picture
372408
// size, exactly as PNG does. The first rung that leaves a reachable remainder
373409
// wins, so a small file gets a small picture instead of being refused.
@@ -402,18 +438,33 @@ func chooseSize(r format.Request, label string) (memo, error) {
402438
if err != nil {
403439
return memo{}, err
404440
}
405-
m.body = body
441+
m.body, m.bodyKnown = body, true
406442
return m, nil
407443
}
408444

445+
// Planning does not have to encode the picture to know which rung wins.
446+
// The ladder is walked largest first, so for a request comfortably above
447+
// what the largest rung encodes to, that rung is the answer and encoding
448+
// only confirms it - at the cost of a whole encode thrown away so the
449+
// writer can do it again (P7 in the 2026-09-05 performance review).
450+
//
451+
// A fast path, not a change of answer: it fires only where the rung is
452+
// already settled, and the margin also guarantees the comment can carry
453+
// whatever is left, so none of the refusals in settlePadding are reachable
454+
// from here.
455+
if r.Bytes >= ladderCeiling+trailerSize+smallestCarryingComment {
456+
rung := sizeLadder[0]
457+
return memo{width: rung[0], height: rung[1], frames: frames, seed: r.Seed, label: label}, nil
458+
}
459+
409460
var smallest memo
410461
for _, rung := range sizeLadder {
411462
m := memo{width: rung[0], height: rung[1], frames: frames, seed: r.Seed, label: label}
412463
body, err := encodedBodySize(m)
413464
if err != nil {
414465
return memo{}, err
415466
}
416-
m.body = body
467+
m.body, m.bodyKnown = body, true
417468
smallest = m
418469

419470
bare := body + trailerSize

‎internal/format/png/png.go‎

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,16 @@ type memo struct {
118118
width, height int
119119
seed uint64
120120
label string
121-
// body is the exact number of bytes the encoded picture takes before the
122-
// closing chunk. Worked out during planning so that a size this format
123-
// cannot reach is refused before any file exists.
124-
body int64
121+
// body is the number of bytes the encoded picture takes before the closing
122+
// chunk. Worked out during planning so that a size this format cannot
123+
// reach is refused before any file exists.
124+
//
125+
// bodyKnown says whether it was worked out at all. For a request far above
126+
// what the largest rung can encode to, the answer cannot change which
127+
// picture is chosen, so planning skips the encoding and the writer - which
128+
// has to encode anyway - fills both fields in. See ladderCeiling.
129+
body int64
130+
bodyKnown bool
125131
// padData is how many bytes of padding the chunk carries. A negative
126132
// value means no chunk at all, which happens when the picture lands
127133
// exactly on the requested size.
@@ -184,6 +190,14 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
184190
bare := body + iendSize
185191

186192
switch {
193+
case !m.bodyKnown:
194+
// The fast path in chooseSize already established that this request is
195+
// far above the largest rung and that one chunk can carry the padding,
196+
// so all three refusals below are unreachable and the only number still
197+
// missing is how much padding there is. The writer settles that once it
198+
// has encoded, which it has to do anyway.
199+
m.withPad = true
200+
187201
case r.Bytes == bare:
188202
// The picture lands exactly on the requested size. No padding chunk.
189203
m.withPad = false
@@ -263,7 +277,21 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
263277
return err
264278
}
265279

266-
if holder.written != m.body {
280+
if !m.bodyKnown {
281+
// Planning skipped the encoding because the request was far above the
282+
// largest rung, so this is where the exact size arrives. The padding is
283+
// whatever is left, which is the same arithmetic planning would have
284+
// done with the same number.
285+
m.body = holder.written
286+
m.padData = p.Bytes - m.body - iendSize - chunkOverhead
287+
if m.padData < 0 || m.padData > maxChunkData {
288+
// Unreachable unless ladderCeiling is wrong, and then it is better
289+
// to say so than to write a file of the wrong length.
290+
return fmt.Errorf(
291+
"png: the picture encoded to %d B, which leaves %d B of padding for a %d B file - ladderCeiling is wrong",
292+
m.body, m.padData, p.Bytes)
293+
}
294+
} else if holder.written != m.body {
267295
return fmt.Errorf("png: the picture encoded to %d B where planning said %d B", holder.written, m.body)
268296
}
269297
if string(holder.tail[4:8]) != "IEND" {
@@ -280,6 +308,22 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
280308
return err
281309
}
282310

311+
// ladderCeiling is the most the largest rung has ever been seen to encode to,
312+
// with room to spare. It is only ever used to decide that a request is far
313+
// enough above the ladder that no search is needed, so being generous costs a
314+
// few sizes their fast path and being wrong costs nothing silently - a picture
315+
// larger than this simply leaves less padding, and the writer would refuse
316+
// rather than produce a wrong file.
317+
//
318+
// Measured 2026-09-06 over ten seeds with the label both on and off: 5456 B at
319+
// the smallest and 5808 B at the largest, a spread of 352 B. The gradient
320+
// compresses about 210 to 1, so the number is nowhere near the 1229280 B that
321+
// an incompressible 640x480 picture would take.
322+
//
323+
// TestTheLadderCeilingIsAboveEveryPictureTheTopRungMakes sweeps it rather than
324+
// trusting this comment.
325+
const ladderCeiling = 16384
326+
283327
// sizeLadder is tried from the largest down when the recipe names no picture
284328
// size. The first rung that leaves room for the padding chunk wins, so a
285329
// small file gets a small picture instead of being refused.
@@ -321,18 +365,43 @@ func chooseSize(r format.Request, label string) (memo, error) {
321365
if err != nil {
322366
return memo{}, err
323367
}
324-
m.body = body
368+
m.body, m.bodyKnown = body, true
325369
return m, nil
326370
}
327371

372+
// Planning does not have to encode the picture to know which rung wins.
373+
//
374+
// The ladder is walked from the largest rung down and the first one that
375+
// fits is taken, so for any request comfortably above what the largest rung
376+
// encodes to, the answer is the largest rung and encoding only confirms it.
377+
// That confirmation was 38 to 53% of a PNG run - a whole encode, thrown
378+
// away, so that the writer could do it again (P7 in the 2026-09-05
379+
// performance review).
380+
//
381+
// This is a fast path and NOT a change of answer. It fires only where the
382+
// rung is already settled, so the bytes are the ones the slow path below
383+
// produces. Everything near a rung boundary still encodes and still gets
384+
// the exact number.
385+
//
386+
// The second condition keeps the refusal above the chunk limit exact.
387+
// Padding is r.Bytes minus the picture and the overheads, so it is largest
388+
// when the picture is smallest, and a picture is never smaller than
389+
// nothing. Bounding it that way costs a fallback to the slow path for a
390+
// sliver of sizes just under two gigabytes and keeps the refusal honest.
391+
if r.Bytes >= ladderCeiling+iendSize+chunkOverhead &&
392+
r.Bytes-iendSize-chunkOverhead <= maxChunkData {
393+
rung := sizeLadder[0]
394+
return memo{width: rung[0], height: rung[1], seed: r.Seed, label: label}, nil
395+
}
396+
328397
var smallest memo
329398
for _, rung := range sizeLadder {
330399
m := memo{width: rung[0], height: rung[1], seed: r.Seed, label: label}
331400
body, err := encodedBodySize(m)
332401
if err != nil {
333402
return memo{}, err
334403
}
335-
m.body = body
404+
m.body, m.bodyKnown = body, true
336405
smallest = m
337406

338407
bare := body + iendSize

0 commit comments

Comments
 (0)