Skip to content

Commit 2f6e48d

Browse files
donislawdevclaude
andcommitted
perf: the files of a run are written over several threads
Writing the files is what a run is. After planning stopped encoding the picture twice, planning a run of 300 PNGs is 51 ms and writing it is 2741 ms, so the write loop was all that was left to parallelise. Measured end to end on built binaries, variants interleaved and their order reversed between repetitions. 240 png files of 200 kB go from 2.03 s to 0.44 s, which is 4.62x. zip 2.88x, docx 2.03x, and two thousand txt files of 4 kB 1.39x - files that small spend their time in what a run does once, and that stays serial. One file of any size is unchanged, and the measurement says so rather than the reasoning: the two ranges overlap there. The bytes do not move, and that was checked rather than argued. All 23 formats compared between the two binaries at ten combinations of size and seed each, 48 malformed recipes through two commands character for character with their exit codes, and the manifest of the same run identical block for block including the order. Everything that runs beside anything else is in one new file, listed in the concurrency gate with its reason, and the race detector is run for it. The whole suite under -race finds zero races. Two things a person can observe. OnProgress no longer promises which goroutine it is called from. It promises never two at once, which the engine gives with a lock. Both callers move unguarded state on the strength of that contract and neither needed a line of change. The lock was priced on the most talkative generator in the tree rather than assumed to be free: 319 840 callbacks in one run through one mutex, ranges overlap, no cost claimed. A run stopped part way now names every file that finished, which can leave a gap where a writer was cut off. Recording only the leading run of them would leave a finished file with no manifest entry, and untouchable rule 7 makes that a file nothing here can remove. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3759e5e commit 2f6e48d

9 files changed

Lines changed: 770 additions & 163 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ jobs:
600600
# in somebody else's file.
601601
run: |
602602
set -euo pipefail
603-
watched='internal/format/registry.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go go.mod'
603+
watched='internal/format/registry.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod'
604604
# On a pull request there is no "before" - the field belongs to a push
605605
# - so this asked for something empty and every pull request answered
606606
# "touched". That quietly undid the decision of 2026-08-20, because

‎CHANGELOG.md‎

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

5959
### Changed
6060

61+
- **Files are written over several threads, so a run of many files is several
62+
times faster.** They used to be written one after another.
63+
64+
Nothing about what you get changes. The files are byte for byte identical,
65+
the manifest lists them in the same order, `verify` and `cleanup` behave
66+
exactly as before, and every refusal says what it said.
67+
68+
Measured on an eight core machine, variants interleaved and their order
69+
reversed between repetitions. 240 `.png` files of 200 kB went from 2.03 to
70+
0.44 seconds, which is **4.6 times faster**. 80 `.zip` files of 2 MB, 2.9
71+
times. 240 `.docx` files of 200 kB, 2.0 times. Two thousand `.txt` files of
72+
4 kB, 1.4 times - with files that small the time goes into what a run does
73+
once rather than into writing them.
74+
75+
A single file is unchanged whatever its size, and the measurement says so
76+
rather than the reasoning: at one 20 MB `.png` the two ranges overlap, so no
77+
difference is claimed. There is nothing to write beside a single file.
78+
79+
The gain follows the number of cores you have and the kind of file. Work the
80+
processor does - drawing a picture, compressing an archive - scales best. A
81+
run held up by the disk gains less. A handful of files was already quick and
82+
is unaffected.
83+
84+
**One thing changes if you stop a run part way.** Ctrl+C used to leave behind
85+
the files finished so far, which were always a consecutive run of them.
86+
Several threads means one file can be cut off while a later one is already
87+
finished, so what survives can have a gap in it. The manifest names exactly
88+
what is on the disk either way, which is what `verify` and `cleanup` work
89+
from, so neither is affected.
90+
91+
The progress bar counts the whole run rather than one file at a time, so its
92+
file counter can move by more than one between redraws.
93+
6194
- **Producing `.png` and `.gif` files is about twice as cheap.** Working out
6295
what a file will contain used to draw the whole picture and compress it, only
6396
to throw the result away and do it again when the file was actually written.

‎internal/engine/engine.go‎

Lines changed: 66 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
package engine
22

33
import (
4-
"bufio"
54
"context"
65
"crypto/sha256"
76
"encoding/hex"
87
"errors"
98
"fmt"
10-
"io"
119
"io/fs"
1210
"os"
1311
"path/filepath"
@@ -162,11 +160,25 @@ type Options struct {
162160
// OnProgress is called as the run advances. Nil means silence, which is
163161
// what every caller that has nobody to show it to should pass.
164162
//
165-
// Called from the same goroutine doing the work, so there is no
166-
// concurrency here to get wrong. Called often - once per write inside a
167-
// file, not only once per finished file - so rate limiting what actually
168-
// reaches a screen belongs to the caller. Without the writes inside a
169-
// file, one 5 GB file would report once, at the end.
163+
// NEVER TWO AT ONCE, though not always from the same goroutine. Until
164+
// 2026-09-06 this promised the stronger thing - "from the same goroutine
165+
// doing the work" - and both callers were built on it: the command line bar
166+
// moves last and printed without a lock, and the window's throttle reads
167+
// and writes a timestamp without one. The files are written over several
168+
// goroutines now, so the engine serialises these calls instead. The lock
169+
// gives happens-before, so both callers stay correct unchanged. What a
170+
// caller may NOT do is assume the goroutine, which is why the sentence is
171+
// here rather than only in the commit that changed it.
172+
//
173+
// Called often - once per write inside a file, not only once per finished
174+
// file - so rate limiting what actually reaches a screen belongs to the
175+
// caller. Without the writes inside a file, one 5 GB file would report
176+
// once, at the end.
177+
//
178+
// With several files in flight the byte count is the whole run's, so it
179+
// moves while any writer moves rather than tracking one file. It still
180+
// falls back when a file fails, exactly as it did before, because a file
181+
// that failed counts for nothing.
170182
OnProgress func(Progress)
171183
}
172184

@@ -443,6 +455,12 @@ type Progress struct {
443455
//
444456
// A manifest is returned even when the run is cut short, otherwise cleanup
445457
// has nothing to work with.
458+
//
459+
// The writing itself happens over several goroutines, and everything about
460+
// that lives in parallel.go - including why, and what it measured. What stays
461+
// here is everything a run does exactly once: the checks that decide whether
462+
// it may start at all, and the reading back of the answers in the order the
463+
// plan lists them.
446464
func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error) {
447465
m := manifest.New(
448466
"testing-files-generator", version.Version,
@@ -516,133 +534,58 @@ func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error)
516534
}
517535
}()
518536

519-
totalBytes := TotalBytes(files)
520-
var bytesDone int64
521-
522-
for i, f := range files {
523-
select {
524-
case <-ctx.Done():
525-
// Stop starting new files. What is already finished stays, and
526-
// the manifest describes exactly that.
527-
m.Run.Complete = false
528-
return res, ctx.Err()
529-
default:
530-
}
537+
// The files are written over several goroutines. Everything that runs
538+
// beside anything else lives in parallel.go, including the measurements
539+
// that put it there.
540+
written := writeAll(ctx, files, opt.OutDir, newProgressGate(files, opt.OnProgress))
531541

532-
// Built per file rather than once, because it closes over how far the
533-
// run had got before this file started. Left nil when nobody is
534-
// listening, so a run without progress allocates nothing for it.
535-
var report func(int64)
536-
if opt.OnProgress != nil {
537-
report = func(inFile int64) {
538-
opt.OnProgress(Progress{
539-
FilesDone: i, FilesTotal: len(files),
540-
BytesDone: bytesDone + inFile, BytesTotal: totalBytes,
541-
})
542-
}
543-
}
544-
545-
sum, err := writeOne(ctx, f, opt.OutDir, report)
546-
if err == nil {
547-
// Only what reached the disk. Counting a file that failed would
548-
// have the bar claim bytes nobody can find, and on a run where
549-
// several fail the total would arrive before the files do.
550-
bytesDone += f.Plan.Bytes
551-
}
552-
if opt.OnProgress != nil {
553-
opt.OnProgress(Progress{
554-
FilesDone: i + 1, FilesTotal: len(files),
555-
BytesDone: bytesDone, BytesTotal: totalBytes,
556-
})
557-
}
558-
if err != nil {
559-
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
560-
m.Run.Complete = false
561-
return res, err
542+
// Read back in the order the plan lists, on this goroutine alone. Two
543+
// things rest on that and neither is tidiness:
544+
//
545+
// - the manifest keeps the order it has always had, which is the order
546+
// cleanup prints to a person before deleting from it,
547+
// - a run stopped part way names the LOWEST cancelled file rather than
548+
// whichever writer happened to notice first, so the same interruption
549+
// reports the same thing on every machine.
550+
var stopped error
551+
for i, r := range written {
552+
switch {
553+
case r.ok:
554+
m.Add(entryFor(files[i], r.sha, true, nil))
555+
case r.err == nil:
556+
// Never started. A cancelled run leaves these behind and they are
557+
// neither a success nor a failure, so they get no entry - which is
558+
// what the sequential loop did by never reaching them.
559+
case errors.Is(r.err, context.Canceled) || errors.Is(r.err, context.DeadlineExceeded):
560+
// A writer stopped half way through wrote nothing that survived,
561+
// so there is nothing to record about it either.
562+
if stopped == nil {
563+
stopped = r.err
562564
}
565+
default:
563566
// One file failing does not end the run. Nine thousand good
564567
// files are worth keeping, and the entry says what went wrong.
565568
res.Failures++
566-
m.Add(entryFor(f, "", false, err))
567-
continue
569+
m.Add(entryFor(files[i], "", false, r.err))
568570
}
569-
m.Add(entryFor(f, sum, true, nil))
570571
}
571572

572-
m.Run.Complete = true
573-
return res, nil
574-
}
575-
576-
func writeOne(ctx context.Context, f PlannedFile, outDir string, report func(int64)) (string, error) {
577-
final := filepath.Join(outDir, f.Name)
578-
// The process id is in the name because two runs writing into one directory
579-
// used to meet on it. Measured on 2026-08-03: two runs of the same target
580-
// collided on the temporary file, one of them reported two files it could
581-
// not produce, and the bytes of the other had already gone through the same
582-
// handle. The name never survives the run, so nothing about it has to be
583-
// repeatable - and the file it becomes is settled by the plan, not by this.
584-
tmp := tempPathFor(outDir, f.Name)
585-
586-
// os.Create, and O_EXCL was tried here and taken back out on 2026-08-25.
587-
//
588-
// The idea was sound: the check in preflight answers "this name is free"
589-
// a few hundred lines before the write, and O_EXCL would have the
590-
// filesystem answer it at the moment of writing instead. What it costs on
591-
// Windows is not sound. Measured with a probe, a file created in a
592-
// directory reached through a symbolic link:
593-
//
594-
// os.Create works
595-
// O_CREATE|O_EXCL|O_WRONLY fails with "The file exists"
596-
//
597-
// about a file that does not exist. Go asks for the reparse point rather
598-
// than what it points at when O_EXCL is set, so every file of a run whose
599-
// output directory is a link fails - and this tool supports exactly that
600-
// on purpose, because people keep fixtures on a mounted workspace or a
601-
// scratch disk. Two guards said so within a minute of the change.
602-
//
603-
// The window O_EXCL would have closed is a real one and it is small:
604-
// preflight refuses every name that is taken before the run starts, so
605-
// what is left is somebody else creating our temporary name, with our
606-
// process id in it, during the run. Trading a supported way of pointing
607-
// the tool at a directory for that is the wrong way round.
608-
fh, err := os.Create(tmp)
609-
if err != nil {
610-
return "", err
611-
}
612-
613-
h := sha256.New()
614-
buffered := bufio.NewWriterSize(fh, 64<<10)
615-
counter := &countingWriter{w: io.MultiWriter(buffered, h), report: report}
616-
617-
writeErr := writeWithoutCrashing(ctx, f, counter)
618-
if writeErr == nil {
619-
writeErr = buffered.Flush()
620-
}
621-
closeErr := fh.Close()
622-
623-
if writeErr != nil {
624-
_ = os.Remove(tmp)
625-
return "", writeErr
626-
}
627-
if closeErr != nil {
628-
_ = os.Remove(tmp)
629-
return "", closeErr
573+
// A stopped run keeps every file that FINISHED, which may leave a hole
574+
// where a writer was cut off. The sequential loop could only ever leave a
575+
// contiguous prefix, so this is the one thing a person can observe that
576+
// changed - decided by the owner on 2026-09-06, and the alternative is
577+
// worse in a way untouchable rule 7 names: a finished file with no entry
578+
// in the manifest is a file no command of this tool can remove.
579+
if stopped == nil && ctx.Err() != nil {
580+
stopped = ctx.Err()
630581
}
631-
632-
// The size is the promise. A generator that missed it by a byte is a bug
633-
// worth catching here rather than in someone's test suite, so the file
634-
// never reaches its final name.
635-
if counter.n != f.Plan.Bytes {
636-
_ = os.Remove(tmp)
637-
return "", fmt.Errorf("generator for %s produced %d B where the plan said %d B",
638-
f.Desc.ID, counter.n, f.Plan.Bytes)
582+
if stopped != nil {
583+
m.Run.Complete = false
584+
return res, stopped
639585
}
640586

641-
if err := os.Rename(tmp, final); err != nil {
642-
_ = os.Remove(tmp)
643-
return "", err
644-
}
645-
return hex.EncodeToString(h.Sum(nil)), nil
587+
m.Run.Complete = true
588+
return res, nil
646589
}
647590

648591
func entryFor(f PlannedFile, sha string, materialized bool, failure error) manifest.File {
@@ -927,22 +870,3 @@ func runID(seed int64) string {
927870
h := sha256.Sum256([]byte(fmt.Sprintf("run:%d", seed)))
928871
return "run_" + hex.EncodeToString(h[:5])
929872
}
930-
931-
type countingWriter struct {
932-
w io.Writer
933-
n int64
934-
// report, when set, is called with the running total for this file. It is
935-
// what gives progress inside a single large file rather than only between
936-
// files - the case where silence is worst, because one 5 GB file is one
937-
// callback if you only count finished files.
938-
report func(int64)
939-
}
940-
941-
func (c *countingWriter) Write(p []byte) (int, error) {
942-
n, err := c.w.Write(p)
943-
c.n += int64(n)
944-
if c.report != nil {
945-
c.report(c.n)
946-
}
947-
return n, err
948-
}

0 commit comments

Comments
 (0)