Skip to content

Own the encoder and the digest, and cut what a caller links by two thirds - #13

Merged
timzifer merged 4 commits into
mainfrom
smaller-binary
Sep 10, 2026
Merged

timzifer merged 4 commits into
mainfrom
smaller-binary

Conversation

@timzifer

Copy link
Copy Markdown
Owner

blazon told callers it had no dependencies, which was true of go.mod and not
of what it put in their binaries. A program whose whole job is to turn a
version string into one small picture was linking a general-purpose
compressor, a general-purpose compositor, the FIPS-140 module and the
reflection runtime to do it.

All four are gone. Nothing a version renders to has changed — the committed
gallery is byte-identical, and the marks now come out the same under gc and
under LLVM.

What replaced them

Was Is Why it was never a real dependency
image/png, compress/flate, hash/crc32 internal/pngenc A fixed-Huffman deflate stream with LZ77 and per-row filters. The committed gallery grows 0.4%.
crypto/sha256 internal/sha256 Same digests, so every mark is unchanged. The standard library's version now arrives behind the FIPS-140 module's self-tests and entropy sources.
image/draw canvas.fillRect, blazontest.blit Two uniform fills and one blit were the entire use of a general-purpose compositor.
fmt, and with it reflect string assembly Every value the SVG back end formatted was already a string. The format verbs were decorative; the reflection behind them was not.
sort, encoding/binary slices.Sort, byte shifts Both import reflect, which is what actually cost the kilobytes.

version.go keeps errors.Is(err, ErrSyntax) working through a syntaxError
that wraps it, and its messages are byte-identical to what fmt.Errorf
produced.

What it costs a caller

Measured on internal/sizeprobe, the smallest program that renders a mark:

before after
gc, -s -w 2,444,288 1,783,808
TinyGo, wasip1 1,011,974 785,684
above an empty Go binary 947 KB 287 KB

Keeping it that way

Two new rules, because these imports come back by accident and nothing else
would notice — one fmt.Errorf on an image type is enough.

  • TestNoHeavyStandardLibraryImports fails the build if any of them reappears
    in what a caller links. blazontest and cmd/blazon are exempt: their
    callers already link testing or flag.
  • A new tinygo CI leg weighs internal/sizeprobe against a byte budget, and
    renders 1.4.2 under both compilers to compare the files byte for byte. gc
    and LLVM are not obliged to agree about floats and the renderers are full of
    them; today they agree exactly, including for the float-heavy default
    renderer.

The root package's suite is left out of the TinyGo run — it renders whole
corpora and exhausts the wasm heap. TestRegisterRejectsDuplicates moves
behind a !tinygo tag: TinyGo's recover does not catch a panic from another
function and its t.Skip does not unwind, so the test would take the process
down instead of reporting.

Released as 0.2.0, with the logo regenerated for that version.

🤖 Generated with Claude Code

https://claude.ai/code/session_019RJ8FUiT1YvbBz3D2PriM9

timzifer and others added 4 commits September 10, 2026 10:31
A program whose whole job is to turn a version string into one small picture
was linking a general-purpose compressor, a general-purpose compositor and the
FIPS-140 module to do it. None of the three earns its place:

  - internal/pngenc replaces image/png with a fixed-Huffman deflate stream and
    the four chunks PNG actually needs. The committed gallery grows by 0.4%.
  - internal/sha256 replaces crypto/sha256, which now arrives behind the
    FIPS-140 module's self-tests and entropy sources. The digests are the same
    digests, so every mark is unchanged, and a test checks that against the
    standard library.
  - canvas.fillRect and blazontest.blit replace the two image/draw calls that
    were the package's only use of it.

Measured on a program that renders one 256px PNG, built with -s -w:

  before  2,444,288
  after   1,951,232   (-493,056, -20%; an empty Go binary is 1,497,088)

The gallery is regenerated and is byte-identical to what the code renders.
TestNoHeavyStandardLibraryImports keeps the imports from drifting back, since
a single fmt.Errorf on an image type would bring one of them with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RJ8FUiT1YvbBz3D2PriM9
The size argument for owning the encoder and the digest was made with numbers
from one laptop, which is where such numbers usually stay. This adds the leg
that keeps them true.

TinyGo is the scale, because it has no reflection-heavy runtime to hide behind:
one fmt.Errorf on an image type is hundreds of kilobytes there rather than
noise. internal/sizeprobe is the thing being weighed — the smallest program
that renders a mark, so that what the scale reads is this library and not a
CLI's flag parsing.

The leg also renders 1.4.2 under both compilers and compares the files byte for
byte. gc and LLVM are not obliged to agree about floats and the renderers are
full of them, so this is a real check rather than a formality; today they
agree exactly, including for the float-heavy default renderer.

The root package's suite is left out of the TinyGo run: it renders whole
corpora and exhausts the wasm heap. TestRegisterRejectsDuplicates moves behind
a !tinygo tag for a related reason — TinyGo's recover does not catch a panic
from another function, and its t.Skip does not unwind, so the test would take
the process down instead of reporting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RJ8FUiT1YvbBz3D2PriM9
fmt was the last heavy import, and by some distance the worst value for money:
it exists here to join strings that are already strings. Every coordinate in
the SVG back end arrives from num() pre-formatted, every colour from hex(),
every escape from three integers. The format strings were decorative, and the
reflection behind them was not.

What changed, in order of how much it saved:

  - canvas/svg.go and canvas/ansi.go assemble their markup and their escapes
    directly. The SVG and ANSI bytes are unchanged, which the golden files and
    the untouched gallery both say.
  - version.go grows a syntaxError that wraps ErrSyntax, so errors.Is keeps
    working; the messages are byte-identical to what fmt.Errorf produced.
  - blazon.go spells out the unknown-renderer error, and takes slices.Sort
    over sort.Strings, since package sort imports reflect.
  - internal/sha256 and the entropy streams shift their own big-endian
    counters rather than importing encoding/binary, which also imports
    reflect.

fmt, reflect, sort and encoding/binary are now absent from what a caller
links, and TestNoHeavyStandardLibraryImports says so. blazontest and
cmd/blazon keep fmt: their callers already link testing or flag, which bring
reflection regardless.

Measured on internal/sizeprobe:

  gc, -s -w   1,951,232 -> 1,783,808
  tinygo/wasi 1,011,974 ->   785,684  (-22%)

Cumulatively, across this and the two commits before it, the library's cost
above an empty Go binary falls from 947 KB to 287 KB. The CI budget drops to
950,000 bytes to match, tight enough that fmt cannot come back unnoticed.

Both compilers still render 1.4.2 to the same bytes, and those bytes are the
same ones as before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RJ8FUiT1YvbBz3D2PriM9
The library's cost to a caller has changed enough to be worth a version: the
PNG encoder, the digest, the rasteriser's fills and the string formatting are
all this repository's own now, and what blazon adds to a binary above an empty
Go program fell from 947 KB to 287 KB. No rendered mark moved.

The logo is regenerated because it is the flowfield mark for this exact
version, which is the whole point of signing releases with our own output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RJ8FUiT1YvbBz3D2PriM9
@timzifer
timzifer merged commit 7d45ac4 into main Sep 10, 2026
10 checks passed
@timzifer
timzifer deleted the smaller-binary branch September 10, 2026 11:50
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