Skip to content

Commit ee953f5

Browse files
donislawdevclaude
andauthored
perf: one filler instead of thirteen, and eight bytes per draw instead of one (#56)
* perf: one listing instead of two questions a file, and one collection instead of two Three findings from the performance report, and two of them came back with the report's own suggestion measured and refused. preflight asked the filesystem twice for every planned file. It reads the output directory once. A run of 100 000 files with --dry-run goes from 15.7-19.7 s to 0.49-0.56 s, order alternated - the check was 97% of it. Ten thousand names measured on their own: 1.937 s of stat calls against 8.9 ms for one listing. That is also a fix rather than only a speedup, and the guard for it says so. os.Stat follows a link, so a link pointing at nothing answered "no name here" and the run replaced it without a word. A directory ENTRY is what a taken name is, whatever it points at. With the old question put back, the new guard reports that the run went ahead over a name somebody else's link was holding. A directory that cannot be LISTED is still asked about file by file. Both systems allow write permission without read, a run into such a directory has always worked, and reading nothing there and calling it empty would let the run write over what is inside. That fallback has its own guard, which skips on Windows because denying a listing there needs an ACL. The plan ceiling forced a collection to take every reading, so a run of one kilobyte paid for two of them - measured with GODEBUG=gctrace=1, exactly two on every run however small. It asks /gc/heap/allocs:bytes first, at 251 ns against 519 us, and only collects when that says it might be over. The shortcut is sound by an inequality rather than by an estimate: the live heap cannot have grown by more than has been allocated. The report asked for /gc/heap/live:bytes and that metric is WRONG here. It reports the heap as of the last collection, and measured on 2026-09-05 all four existing ceiling guards stay green with it, because 25 MB of allocation makes the collector run on its own and the lagging reading catches up by luck. With the collector switched off the luck goes: a plan six times the ceiling is accepted. The new guard turns the collector off for exactly that reason. hashFile reads in 256 KiB pieces. The report asked for a 1 MB buffer through io.CopyBuffer and that does nothing at all: os.File implements io.WriterTo, so CopyBuffer hands it the whole job and throws the buffer away - 128 KiB, 256 KiB and 1 MiB with a plain file all take the same 167-172 ms that io.Copy takes. Hidden behind a reader that offers only Read, 256 KiB takes 161 ms against 199. The size is measured too: 64 KiB is 182 ms and nothing above a quarter of a megabyte can be told apart, so sixteen workers cost four megabytes. planChildren is sized up front, since the total is known from the groups. TotalBytes being walked twice is NOT done, and that is a measurement rather than an oversight: one walk over 100 000 planned files has a median of 0 s and a maximum of 541 us, so two of them cost half a millisecond of a nineteen second run. Widening a signature for that would be a change nothing can see. engine.go went past the length ceiling, and the guard asks for a split by what the parts do rather than for a bigger number - so preflight and the questions it asks about names are their own file now. Two ceilings came down with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf: one filler instead of thirteen, and eight bytes per draw instead of one Thirteen packages each carried their own copy of the bulk random filler loop, in four different shapes. Measured over 64 MiB through a 32 KiB buffer, interleaved with the order reversed between repetitions: one draw per byte 182 MB/s zip, targz, wav eight via a temporary array 1468 MB/s bmp, gif, ico, opc, png, tiff eight via a shift loop 845 MB/s avif, jpg, jxl, webp one store into the buffer 2499 MB/s the shape they all use now All thirteen now call core.FillRandomBE or core.FillRandomLE. Two functions rather than one with a flag, because choosing the wrong byte order is not a style mistake - it silently rewrites every file a format has ever produced, and an argument would put that one typo away. BREAKING: zip, targz and wav files have different bytes. Their padding is where those formats spend almost the whole file, so almost every byte changes. Size, structure and readability are untouched. End to end on 64 MB, ranges disjoint: zip 2.81x, targz 2.74-3.32x. wav is in that list for uniformity and not for speed, by the owner's decision after the measurement: its padding is audio modulo the frame size, so exactly two bytes of a WAV differ and the time is unchanged. The other ten packages moved with no byte change at all - the shift loop IS little endian, which the performance report did not notice, so those four collapse to one store for free. Checked across 24 formats at five sizes and two seeds: three moved, twenty identical. Also closes a blind spot the golden set had. Forcing the filler to emit a constant moved 33 of the 54 pinned cases and not one WAV: wav_32kib lands on a size the audio fills exactly and never reaches the filler, so that path had no pinned witness. wav_with_the_padding_chunk is that witness. Guard: TestBulkRandomBytesComeFromOnePlace, two mutations, both caught. It names the one honest UintN caller as an exception and fails if that exception outlives its code. A third mutation proves the golden set covers the shared filler's short tail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 479c494 commit ee953f5

18 files changed

Lines changed: 244 additions & 96 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,29 @@ because it turns other people's test suites red.
1414

1515
## [Unreleased]
1616

17+
### Breaking
18+
19+
- **ZIP, TAR.GZ and WAV files have different bytes.** The padding these three
20+
formats write is now drawn from the random generator eight bytes at a time
21+
instead of one byte at a time. For a ZIP or a TAR.GZ that padding is almost
22+
the whole file, so almost every byte changes.
23+
24+
Nothing a reader can see is different. The size is the same to the byte, the
25+
structure is the same, the same tools open the same files. What changes is the
26+
content of the padding, so a hash you recorded from an earlier version will
27+
not match one you generate now.
28+
29+
This is what makes large archives faster to produce. Measured on 64 MB files,
30+
runs interleaved: ZIP 2.8 times faster, TAR.GZ 2.7 to 3.3 times faster.
31+
32+
WAV is on this list for consistency rather than for speed. Its padding is at
33+
most a few bytes per file, so only those bytes differ and the time to produce
34+
one is unchanged.
35+
36+
Every other format keeps exactly the bytes it had. Twelve of them share the
37+
same new code and were checked against their recorded hashes and across every
38+
format at five sizes and two seeds.
39+
1740
### Changed
1841

1942
- **`verify` and `cleanup` read the files over several threads, so checking a

‎internal/core/fill.go‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package core
2+
3+
import (
4+
"encoding/binary"
5+
// D11 promises the same bytes from the same seed, so a deliberate,
6+
// reproducible generator is the product rather than a weakness. Nothing
7+
// here ever makes a secret.
8+
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
9+
"math/rand/v2"
10+
)
11+
12+
// Bulk random bytes are drawn eight at a time, and the two byte orders are two
13+
// functions rather than one function with a flag.
14+
//
15+
// Eight at a time rather than one is the whole point. Measured 2026-09-06 over
16+
// 64 MiB through a 32 KiB buffer, repetitions interleaved with the order
17+
// reversed: one draw per byte runs at 182 MB/s, eight bytes per draw at
18+
// 2499 MB/s. The write path of an archive is slower than the disk underneath it
19+
// when it draws a byte at a time (P2 in PERFORMANCE-REVIEW-2026-09-05.md).
20+
//
21+
// The orders stay separate because picking the wrong one is not a style
22+
// mistake - it silently rewrites every file a format has ever produced, which
23+
// is exactly what D11 forbids. A boolean argument would put that mistake one
24+
// typo away and would read the same in review either way. Two names cannot be
25+
// confused by accident, and the compiler cannot help with a flag.
26+
//
27+
// Neither function draws anything for an empty slice, and both spend exactly
28+
// one draw on a trailing group shorter than eight bytes. That is what the ten
29+
// call sites did before they were folded into here, so folding them in moved no
30+
// bytes except where the owner asked for them to move.
31+
32+
// FillRandomBE fills b with random bytes, most significant byte of each draw
33+
// first. This is the order bmp, gif, ico, opc, png, tiff and - since 0.3.0 -
34+
// targz, wav and zip write.
35+
func FillRandomBE(b []byte, rng *rand.Rand) {
36+
i := 0
37+
for ; i+8 <= len(b); i += 8 {
38+
binary.BigEndian.PutUint64(b[i:], rng.Uint64())
39+
}
40+
if i < len(b) {
41+
var eight [8]byte
42+
binary.BigEndian.PutUint64(eight[:], rng.Uint64())
43+
copy(b[i:], eight[:])
44+
}
45+
}
46+
47+
// FillRandomLE fills b with random bytes, least significant byte of each draw
48+
// first. This is the order avif, jpg, jxl and webp write.
49+
func FillRandomLE(b []byte, rng *rand.Rand) {
50+
i := 0
51+
for ; i+8 <= len(b); i += 8 {
52+
binary.LittleEndian.PutUint64(b[i:], rng.Uint64())
53+
}
54+
if i < len(b) {
55+
var eight [8]byte
56+
binary.LittleEndian.PutUint64(eight[:], rng.Uint64())
57+
copy(b[i:], eight[:])
58+
}
59+
}

‎internal/format/avif/avif.go‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -515,20 +515,11 @@ func writeFiller(ctx context.Context, w io.Writer, buf []byte, rng *rand.Rand, s
515515
return ctx.Err()
516516
default:
517517
}
518-
fillBytes(buf[:n], rng)
518+
core.FillRandomLE(buf[:n], rng)
519519
if _, err := w.Write(buf[:n]); err != nil {
520520
return err
521521
}
522522
left -= n
523523
}
524524
return nil
525525
}
526-
527-
func fillBytes(b []byte, rng *rand.Rand) {
528-
for i := 0; i < len(b); i += 8 {
529-
v := rng.Uint64()
530-
for j := 0; j < 8 && i+j < len(b); j++ {
531-
b[i+j] = byte(v >> (8 * uint(j)))
532-
}
533-
}
534-
}

‎internal/format/bmp/bmp.go‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -372,11 +372,7 @@ func writeGap(ctx context.Context, w io.Writer, seed uint64, n int64) error {
372372
take = remaining
373373
}
374374
chunk := buf[:take]
375-
for i := 0; i < len(chunk); i += 8 {
376-
var eight [8]byte
377-
binary.BigEndian.PutUint64(eight[:], rng.Uint64())
378-
copy(chunk[i:], eight[:])
379-
}
375+
core.FillRandomBE(chunk, rng)
380376
if _, err := w.Write(chunk); err != nil {
381377
return err
382378
}

‎internal/format/gif/gif.go‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,7 @@ func writeComment(ctx context.Context, w io.Writer, seed uint64, blocks, payload
358358
}
359359
buf[0] = byte(size)
360360
chunk := buf[1 : 1+size]
361-
for j := 0; j < len(chunk); j += 8 {
362-
var eight [8]byte
363-
putUint64(eight[:], rng.Uint64())
364-
copy(chunk[j:], eight[:])
365-
}
361+
core.FillRandomBE(chunk, rng)
366362
if _, err := w.Write(buf[:1+size]); err != nil {
367363
return err
368364
}
@@ -372,17 +368,6 @@ func writeComment(ctx context.Context, w io.Writer, seed uint64, blocks, payload
372368
return err
373369
}
374370

375-
func putUint64(b []byte, v uint64) {
376-
b[0] = byte(v >> 56)
377-
b[1] = byte(v >> 48)
378-
b[2] = byte(v >> 40)
379-
b[3] = byte(v >> 32)
380-
b[4] = byte(v >> 24)
381-
b[5] = byte(v >> 16)
382-
b[6] = byte(v >> 8)
383-
b[7] = byte(v)
384-
}
385-
386371
// sizeLadder is tried from the largest down when the recipe names no picture
387372
// size, exactly as PNG does. The first rung that leaves a reachable remainder
388373
// wins, so a small file gets a small picture instead of being refused.

‎internal/format/ico/ico.go‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -333,11 +333,7 @@ func writeGap(ctx context.Context, w io.Writer, seed uint64, n int64) error {
333333
take = remaining
334334
}
335335
chunk := buf[:take]
336-
for i := 0; i < len(chunk); i += 8 {
337-
var eight [8]byte
338-
binary.BigEndian.PutUint64(eight[:], rng.Uint64())
339-
copy(chunk[i:], eight[:])
340-
}
336+
core.FillRandomBE(chunk, rng)
341337
if _, err := w.Write(chunk); err != nil {
342338
return err
343339
}

‎internal/format/jpg/jpg.go‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -543,14 +543,7 @@ func writeComments(ctx context.Context, w io.Writer, seed uint64, segments int,
543543
size = remaining
544544
}
545545
chunk := buf[:size]
546-
for j := 0; j < len(chunk); j += 8 {
547-
var eight [8]byte
548-
v := rng.Uint64()
549-
for k := 0; k < 8; k++ {
550-
eight[k] = byte(v >> (8 * k))
551-
}
552-
copy(chunk[j:], eight[:])
553-
}
546+
core.FillRandomLE(chunk, rng)
554547
if _, err := w.Write(chunk); err != nil {
555548
return err
556549
}

‎internal/format/jxl/jxl.go‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -566,20 +566,11 @@ func writeFiller(ctx context.Context, w io.Writer, buf []byte, rng *rand.Rand, s
566566
return ctx.Err()
567567
default:
568568
}
569-
fillBytes(buf[:n], rng)
569+
core.FillRandomLE(buf[:n], rng)
570570
if _, err := w.Write(buf[:n]); err != nil {
571571
return err
572572
}
573573
left -= n
574574
}
575575
return nil
576576
}
577-
578-
func fillBytes(b []byte, rng *rand.Rand) {
579-
for i := 0; i < len(b); i += 8 {
580-
v := rng.Uint64()
581-
for j := 0; j < 8 && i+j < len(b); j++ {
582-
b[i+j] = byte(v >> (8 * uint(j)))
583-
}
584-
}
585-
}

‎internal/format/opc/opc.go‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import (
2424
"bytes"
2525
"compress/flate"
2626
"context"
27-
"encoding/binary"
2827
"fmt"
2928
"hash/crc32"
3029
"io"
@@ -412,11 +411,7 @@ func writeFiller(ctx context.Context, w io.Writer, seed uint64, n int64) error {
412411
take = remaining
413412
}
414413
chunk := buf[:take]
415-
for i := 0; i < len(chunk); i += 8 {
416-
var eight [8]byte
417-
binary.BigEndian.PutUint64(eight[:], rng.Uint64())
418-
copy(chunk[i:], eight[:])
419-
}
414+
core.FillRandomBE(chunk, rng)
420415
if _, err := w.Write(chunk); err != nil {
421416
return err
422417
}

‎internal/format/png/png.go‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -470,11 +470,7 @@ func writePaddingChunk(ctx context.Context, w io.Writer, kind string, seed uint6
470470
size = remaining
471471
}
472472
chunk := buf[:size]
473-
for i := 0; i < len(chunk); i += 8 {
474-
var eight [8]byte
475-
binary.BigEndian.PutUint64(eight[:], rng.Uint64())
476-
copy(chunk[i:], eight[:])
477-
}
473+
core.FillRandomBE(chunk, rng)
478474

479475
crc.Write(chunk)
480476
if _, err := w.Write(chunk); err != nil {

0 commit comments

Comments
 (0)