Skip to content

perf: shrink the release binary and match stub kinds without string building - #1232

Draft
zzet wants to merge 7 commits into
mainfrom
perf/release-size-and-stub-kind
Draft

zzet wants to merge 7 commits into
mainfrom
perf/release-size-and-stub-kind

Conversation

@zzet

@zzet zzet commented Oct 31, 2026

Copy link
Copy Markdown
Owner

Two of the regressions since v0.64.10 tracked in #1116 have a cause in the code: release binary size and a hot spot in cold indexing. This draft fixes both and adds a CI guard so the size regression cannot come back unnoticed.

Draft status: the code and its tests are done. A fix-up round after review was interrupted before it finished. The cold-index check and the linux size confirmation are still open (see "Still open" below).

Changes

Release size

  • Service unit rendering without text/template. renderLaunchdPlist and renderSystemdUnit were the only reachable template execution in cmd/gortex.
    • Executing a template calls reflect.Value.MethodByName with a name known only at run time. Once such a call is reachable, the linker stops pruning unused methods and keeps every exported method of every reachable type.
    • Rendering with fmt over the same text drops the stripped binary from 315,899,314 to 309,820,674 bytes: -6,078,640 bytes, -1.92%.
    • That figure comes from a stripped darwin/arm64 build with the linux release tags and -trimpath. Projected onto the linux release binaries, main would be about +1.70% (amd64) and +1.39% (arm64) over v0.64.10. The linux release build still has to confirm this.
    • The output is byte-identical. New golden tests pin both unit files whole, and a one-off differential run over 20,000 generated inputs matched the old renderers exactly.
  • Smaller follow-ups. Five fragment render tests that the golden tests fully cover are removed. The renderers no longer return an error that was always nil, and the dead failure branches in installLaunchd / installSystemd are gone.
  • CI guard (scripts/ci-dumpdep). The build relinks the release binary with -dumpdep and fails when a reachable symbol carries the linker's <ReflectMethod> flag, or when the text/template executor is reachable. On failure it prints the chain of symbols from the first-party caller to that symbol.
    • It fails closed: a listing with no reachable main.main, or with no <UsedInIface> flag, is rejected, so a failed link or an unreadable listing cannot pass as clean.
    • It runs in build-linux-static, and in build-native on macOS and Windows with the flags release.yml ships. A wiring test keeps the step in both jobs.
    • On the parent of the renderer commit it reports 10 symbols, the first being text/template.(*Template).execute reached through installLaunchd -> renderLaunchdPlist. It passes at this head.

Cold index

  • StubKind without string building. A CPU profile of a large cold index put graph.StubKind at about 13% of CPU, most of it building "<kind>::" for every candidate the resolver filters. A switch on constant prefixes does the same check.
    • A new table test pins the stub-id grammar. A second test compares the new implementation with the old one on every prefix of generated ids.
    • BenchmarkStubKind (10 runs, GOMAXPROCS=2): first-party id ~72 -> ~8.1 ns/op, per-repository stub ~54 -> ~7.2 ns/op, legacy stub ~33 -> ~2.1 ns/op. Still 0 allocations.

Tested at this head (70c88e7)

  • go vet passes on the touched packages at every commit in the series.
  • At the head:
    • gofmt and go vet are clean;
    • ci-test --check-workflow and check-cgo-flags pass;
    • all 5 scripts/ci-dumpdep tests pass, including the flagless-listing rejection and the wiring test;
    • internal/graph TestStubKind* passes, and so does the cmd/gortex render, service and install subset;
    • golangci-lint 2.14.0 on the touched packages reports 0 issues.
  • The build-native step was run locally on macOS and passes. A real -dumpdep listing with its flags stripped is rejected.
  • Before the fix-up round, a full cmd/gortex run (without race) and an internal/graph race run passed. The full suites at this head run in CI.

Still open

  • Interrupted fix-up items. The fix-up round had six items. Five are done:

    • fail-closed guard;
    • guard on the darwin and windows builds;
    • removal of the duplicate tests;
    • removal of the always-nil error;
    • commit reword.

    The sixth is not finished: the cold-index check of the StubKind change.

    • One cold run per build completed: 335 s before the change and 243 s at this head, with the same node and edge counts.
    • That gap is much larger than StubKind's share of the profile can explain, so this PR claims nothing from it.
    • The second run of each build, with CPU profiles, was interrupted.
    • The independent check of the fix-up round also did not run.
  • Cold-index comparison. A paired v0.64.10 vs this-head cold-index run on the reference host, using Commit an agent-profile load client and record v0.64.10 vs main baselines with cgroup, crash and CPU profiles #996's harness, has not been done.

  • Linux size. The size saving has not been measured on the linux release build.

  • Budget. No budget decision is recorded yet. The proposal: a +2% review line that requires a same-image attribution, and a +3% hard limit per architecture, both against v0.64.10, confirmed on the release candidate's linux build.

Refs #1116

zzet added 7 commits October 31, 2026 19:03
The renderLaunchdPlist and renderSystemdUnit tests check fragments: a
key/value pair, an ExecStart= line, the absence of an Environment= line.
They would not notice a renderer that moved a newline, changed an indent
or dropped the trailing newline, which launchd and systemd would load
differently or not at all.

Pin both unit files whole, with and without captured environment
variables, including the XML escapes in the plist and the systemd
quoting and %-escaping of Environment= and log paths. The expected
output is what the current text/template rendering produces.
renderLaunchdPlist and renderSystemdUnit filled the launchd plist and
the systemd unit with text/template. Executing a template evaluates
each field reference through reflect.Value.MethodByName with a name
known only at run time. The compiler flags such a call, and once one is
reachable the linker stops pruning methods: any exported method of any
reachable type might be looked up, so it keeps all of them.

These two renderers were the only reachable template execution in
cmd/gortex. Without them the binary drops from 315,899,314 to
309,820,674 bytes (-6,078,640, -1.92%), nearly all of it methods
nothing calls and more than half of the release-size growth since
v0.64.10 tracked in #1116. That was measured on a stripped darwin/arm64
build with the linux release's build tags plus -trimpath (-trimpath
-tags netgo,osusergo,static_link -ldflags "-s -w -X ..."), not on the
linux release binaries. Projected onto those, main would be about
1.70% (linux/amd64) and 1.39% (linux/arm64) larger than v0.64.10; the
linux release build has to confirm that.

Render with fmt over the same document text instead. The output is
byte-identical: the golden tests from the previous commit and the
existing render tests pass unchanged, and a one-off differential run
against the template renderers over 20,000 generated inputs (XML and
systemd metacharacters, percent signs, quotes, backslashes, newlines,
template-like text) matched exactly. The functions keep their
(string, error) signatures in this commit so callers and tests stay as
they are; the error is now always nil.
One reachable call to reflect.Value.Method, or to MethodByName with a
non-constant name, makes the linker keep every exported method of every
reachable type. Executing a text/template or html/template makes that
call. Nothing in a diff shows the cost: the template rendering removed
in the previous commit held about 6 MB of the stripped binary.

scripts/ci-dumpdep reads the reachability listing a -dumpdep link
prints and fails when a reachable symbol carries the linker's
<ReflectMethod> flag, or when text/template's executor is reachable
(Execute is usually inlined into its caller, so the executor is what
the listing names). For each one it prints the chain of symbols that
reached it, ending at the first-party caller. It fails closed: input
without a reachable main.main is rejected, with its first lines echoed,
and so is a listing in which no symbol carries the <UsedInIface> flag
every real program has. A failed or skipped link, or a listing format
the tool cannot read, cannot pass as clean.

The build-linux-static job now relinks the release build with -dumpdep
and pipes the listing through the tool under pipefail. Compilation is
cached from the job's build step, so the step costs one link; locally a
darwin/arm64 relink with -dumpdep took 3-5 s and printed ~36 MB.
build-native does the same on macOS and Windows, linked the way
release.yml links those binaries: no build tags, and on Windows the
mingw runtime linked in statically. A wiring test keeps the step in
both jobs.

On the parent of the previous commit the check reports 10 symbols,
starting with text/template.(*Template).execute reached through
runDaemonInstallService -> installLaunchd -> renderLaunchdPlist; on
this tree it passes.
The NoXDG, PropagatesXDG and EscapesXML tests for the launchd plist and
the NoXDG and PropagatesXDG tests for the systemd unit each check a few
substrings of a rendered file. The golden tests render the same cases,
with and without captured variables, XML escapes, systemd quoting and
an empty PATH, and compare the whole file, so every fragment these
checked is pinned there too.

The PATH capture, empty PATH and daemon log override tests stay: they
exercise how the installing environment reaches the renderers, which
the golden tests hold fixed (empty PATH, variables passed in directly).
renderLaunchdPlist and renderSystemdUnit returned an error only because
executing a template can fail. Rendered with fmt they cannot, so the
error was always nil and installLaunchd and installSystemd carried a
"render plist"/"render unit" failure branch that never ran.

Return the string alone and drop those branches and the NoError checks
in the render tests.
StubKind had no direct tests: a few packages spot-check IsStub on one
id each. The resolver calls IsStub on every name-lookup candidate, and
a profile of a large cold index put StubKind at 13% of CPU, most of it
in runtime.concatstring2 building "<kind>::" for each candidate kind.

Pin the stub-id grammar in a table (kind as the first or second
segment, only when "::" follows it, empty repo prefix, near-miss kinds,
third-segment kinds), and check StubKind against a copy of the current
implementation on every prefix of generated ids, so a faster version
has to agree at each truncation point around the separators.

BenchmarkStubKind covers a first-party id (the common candidate), a
per-repo stub and a legacy stub. On the current implementation (Apple
M5 Pro, GOMAXPROCS=2) they take ~72, ~54 and ~33 ns/op with no heap
allocation: the concatenations use a stack buffer, so the cost is the
calls, not garbage.
StubKind looped over the four kinds and tested strings.HasPrefix(id,
k+"::"), twice over for prefixed ids: up to eight run-time string
concatenations for every id that is not a stub, which is most of what
the resolver's IsStub filter sees.

Match each kind in a switch on constant "<kind>::" prefixes instead.
The concatenation is folded at compile time and the comparisons are
inlined, so no call remains. The order (stdlib, external_call,
builtin, module) and the grammar are unchanged; the table and the
every-prefix comparison against the previous implementation from the
previous commit pass.

BenchmarkStubKind, 10 runs each (Apple M5 Pro, GOMAXPROCS=2):

  first_party  ~72 ns/op -> ~8.1 ns/op
  repo_stub    ~54 ns/op -> ~7.2 ns/op
  legacy_stub  ~33 ns/op -> ~2.1 ns/op

still with 0 B/op and 0 allocs/op.
@zzet zzet added this to the v0.64.12 milestone Oct 31, 2026
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