Skip to content

perf(release): faster linux-x64 tsc (memfast, BOLT) - #106

Open
t3dotgg wants to merge 9 commits into
mainfrom
goport-relpr1
Open

t3dotgg wants to merge 9 commits into
mainfrom
goport-relpr1

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Problem

The linux-x64 tsc that release.yml publishes is static musl with no BOLT. On the same source it needs about 7.5% more cycles than our full release recipe (glibc, BOLT; relmusl1 study). Two causes:

  • musl's memcmp and bcmp are byte loops, and its memcpy uses rep movs. They took 4.4% of the cycles of a VS Code check and 9.2% on TypeORM.
  • No BOLT. build-bolt.sh refuses static bins, but that rule is about static glibc. Static musl works.

Change

  1. memfast (first commit). lust's memfast.c replaces musl's memcpy, memmove, memcmp, bcmp and memchr through -Wl,--wrap. memfast.sh compiles it with musl-gcc and checks the object (no undefined symbol, no AVX or BMI). Only the PGO build gets it. The plain tsc keeps musl's functions, so the existing PGO check also checks memfast.

  2. BOLT (second commit). bolt-musl.sh makes a counting copy (llvm-bolt -instrument), runs it on pgo-train.sh's set, and rewrites the PGO tsc. A static bin needs -instrumentation-sleep-time=1 -instrumentation-no-counters-clear, and the script kills watchers that keep spinning. No -split-eh (see the unwind check). LLVM 23 BOLT comes from apt.llvm.org. build-pgo.sh keeps --emit-relocs for this build (PGO_EMIT_RELOCS=1).

  3. A fix in "Check the tsc". tsc --listFilesOnly | grep -qx failed darwin-x64 on this PR's first run with exit 141: grep -q ended at the first match, and the tsc died of SIGPIPE under pipefail. grep without -q reads all of the output.

  4. A stronger unwind check (last commit). See below.

Other targets do not change. linux-arm64 keeps musl's functions and has no BOLT (not tried).

Numbers

cycles:u with --noEmit, median of 5 runs, as a ratio to the tsc that main's release.yml builds. 3 passes on one Blacksmith testbox, all bins in each pass. Each cell gives the range over the 3 passes. Query is not in the table: it is too short (0.24 G cycles, 5 to 13% spread).

project st, memfast st, memfast + BOLT 4 checkers, memfast + BOLT
VS Code 0.954 to 0.970 0.943 0.929 to 0.950
TypeORM 0.926 to 0.935 0.899 to 0.912 0.921 to 0.935
effect 0.972 to 0.980 0.940 to 0.949 0.952 to 0.978
hono 0.961 to 0.967 0.923 to 0.936 0.926 to 0.961
zod 0.984 to 0.992 0.955 to 0.963 0.948 to 0.958
geomean 0.962 to 0.966 0.936 to 0.938 0.941 to 0.952
  • Output is the same in all 450 timed runs. release.yml's PGO check passes for each version (1,993 files of pgo-train.sh's set: stdout, stderr, exit code, emit, build info).
  • Peak RSS is the same on VS Code and TypeORM. hono is 2 to 4% lower with BOLT.
  • The file grows from 48.1 MB to 72.2 MB (gzip 18.7 MB to 28.3 MB). BOLT runs in lite mode: it moves only the functions with a profile (6,651 of 32,011, 20.8%) to a new .text. The other 79% stay in the old text (.bolt.org.text) and run from there. Only the old copies of the moved functions (about 15.6 MB) are dead.
  • CI cost: about 1 minute for BOLT, plus the LLVM 23 install.

Unwind check

The port catches panics where Go recovers them, so the BOLT tsc must still unwind. With -split-eh, BOLT gives moved landing pads an LPStart in their LSDA. LLVM libunwind (the musl target's unwinder) then logs "lsda at ... does not start with 0xFF". That line is only a log. In some cases the unwind then fails ("failed to initiate panic", abort). In other cases it works. So the BOLT step runs check-unwind.py on the BOLT tsc:

  1. Two real caught panics through tsc --api. After each one, the tsc must send the error response, answer the next request, exit 0, and write no libunwind: line to stderr.
    • transpileDeclaration of a .d.ts file. Go asserts there ("Output generation failed").
    • With --callbacks readFile:error, createSnapshot of a project. The read of its tsconfig panics inside the config load. In the CI BOLT tsc, this unwind crosses 3 frames that BOLT moved, each with a landing pad.
  2. Every LSDA in the bin starts with 0xFF (no LPStart). This covers every function, not only the paths of check 1. It is a cautious guard: it can reject a tsc that would work, but it does not pass a tsc with an LPStart LSDA.

The CI BOLT tsc (Release run 39967282137) and a non-BOLT tsc pass both (15,411 and 12,177 LSDAs). The test BOLT tscs made with -split-eh fail both: the second panic is still caught, but stderr has 6 libunwind: lines, and 2,180 LSDAs have an LPStart. In a small test program, the BOLT copies with LPStart LSDAs aborted on a caught panic (exit 134), and the copies without them passed.

License

crates/ts_goport/scripts/memfast.c is src/runtime/memfast.c of t3dotgg/lust-lang at 6ced2b86fd (MIT, Copyright (c) 2026 Theo Browne). It is byte-equal below a new header that names the source and the license. NOTICE.md has a new "memfast" section, and npm/pack.mjs adds licenses/memfast-LICENSE.txt to each package's NOTICE.txt. BOLT is a build tool: the shipped bin has no BOLT code (no __bolt symbols).

How it was tested

A testbox ran the linux-x64 steps of release.yml from the yml text itself, for main, the first commit and the second commit: Build environment, Build, PGO build, BOLT, the PGO check and "Check the tsc" (with the Alpine run). All passed.

This PR's Release runs: all six build jobs and package pass. build (linux-x64) takes 15 to 17 minutes (BOLT tools 5 s, BOLT 42 s, the PGO check "same output: 1993 files", the five __wrap_ functions, the Alpine run). In verify (linux-x64), the tsc-rs install and check of the BOLT tsc passes.

All six verify jobs fail in "npm install and check (@tsc-rs/typescript as typescript)". This failure is not from this PR. That step imports typescript/unstable/sync, but at main's Go pin 3e9236361fa9 (typescript@7.1.0-beta, since 2026-10-22) the package exports it as typescript/sync. The last green Release run was before the pin moved. A release from main fails the same way. The fix is #108, not merged yet.

Not in this PR

  • build-release.sh's musl path (the by-hand route in npm/README.md) still uses -split-eh.
  • NOTICE.md has no musl or jemalloc notice. This gap is older than this PR.

Changes by Claude Opus 5.5 in Claude Code.

🤖 Generated with Claude Code

t3dotgg and others added 4 commits October 23, 2026 06:04
musl's memcmp and bcmp are byte loops, and its memcpy and memmove use
rep movs. They took 4.4% of the cycles of a VS Code check and 9.2% on
TypeORM (relmusl1). The linux-x64 PGO tsc now links lust's memfast.c
(MIT, Theo Browne; byte-equal below its new header) through
-Wl,--wrap for memcpy, memmove, memcmp, bcmp and memchr.

- memfast.sh compiles it with musl-gcc, checks the object (no
  undefined symbol, no AVX or BMI) and prints the link flags.
- build-pgo.sh: PGO_RUSTFLAGS adds rustc flags to both builds.
- release.yml: the flags go to the PGO build only, so the plain tsc
  (musl's functions) stays the reference of the PGO check. The tsc
  check reads the five __wrap_ symbols.
- NOTICE.md, licenses/memfast-LICENSE.txt and pack.mjs carry the
  notice into NOTICE.txt.

Other targets do not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The linux-x64 tsc had no BOLT: build-bolt.sh refuses static bins,
because BOLT breaks static glibc. Static musl works (relmusl1) with
two changes against build-release.sh's recipe:

- A static bin runs no BOLT code at exit, so the counting copy writes
  its counts from a watcher every second
  (-instrumentation-sleep-time=1 -instrumentation-no-counters-clear).
  Some watchers spin after their last write; the script kills them.
- No -split-eh. It gives moved landing pads an LPStart, and LLVM
  libunwind (the musl target's unwinder) aborts on such an LSDA, so a
  caught panic would end the tsc.

bolt-musl.sh trains a counting copy on pgo-train.sh's set and rewrites
the PGO tsc. check-unwind.py then checks the BOLT tsc: a real caught
panic (API transpileDeclaration of a .d.ts, which Go answers with a
recovered assert) must answer and the process must go on, and every
LSDA must start with 0xFF. build-pgo.sh keeps --emit-relocs for the
static link with PGO_EMIT_RELOCS=1. release.yml installs LLVM 23 BOLT
on linux-x64 and runs the step before the PGO check, which then
compares the BOLT tsc with the plain tsc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With pipefail, `cmd | grep -q` can fail when grep stops early and cmd
gets SIGPIPE, so a check could miss AVX code or BOLT runtime symbols.
Count the matches instead (grep -c reads all input). The built bins do
not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Team
  • Run ID: 6f2cf37e-7658-4386-9f7a-d6e9474b04b7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

`tsc --listFilesOnly | grep -qx` failed on darwin-x64 with exit 141:
grep -q ended at the first match, and the tsc died of SIGPIPE under
pipefail. grep without -q reads all of the output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
t3dotgg and others added 4 commits October 23, 2026 08:19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check-unwind.py check 1 now runs a second caught panic: --callbacks
readFile:error, then createSnapshot. The tsconfig read panics inside the
config load, and in the CI BOLT tsc this unwind crosses 3 frames that BOLT
moved, each with a landing pad. Check 1 also fails when stderr has a
"libunwind:" line.

LLVM libunwind only logs "lsda at ... does not start with 0xFF". It does
not always abort. The texts of check-unwind.py, bolt-musl.sh, release.yml
and npm/README.md now say that -split-eh can break the unwind, and that
check 2 is a cautious guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
t3dotgg added a commit that referenced this pull request Oct 25, 2026
… tsc 50.3 MB to 49.3 MB

llvm-bolt writes its new .gcc_except_table in place of the old one only
when it fits. Block reordering splits call-site ranges, so the new table
is about 2.6% bigger (R236: 25.8 KB over 1.0 MB). It went to the new
segment, 1.0 MB more, and the old table stayed in the file with no FDE
that points to it.

Now `bolt-musl.sh pad` writes a 64 KiB object of zeros in a
.gcc_except_table section, and release.yml links it into the linux-x64
PGO tsc (PGO_RUSTFLAGS; -u keeps it from --gc-sections). llvm-bolt then
writes the new table in place (25,974 bytes of the pad used). The script
prints the pad left, and warns when the table does not fit.

R236 rebuild of this recipe on a testbox: BOLT tsc 49,334,752 bytes, its
PGO tsc 48,290,992 (48,225,456 without the pad); without the pad the BOLT
tsc was 50,299,600. gzip -9: 18.80 MB, PGO tsc 18.76 MB. The 1.04 MB
left: the moved jump tables (0.73 MB; the PIC jump tables of musl,
jemalloc and libunwind force -jump-tables=move, and with -use-old-text
-skip-funcs does not stop that) and the file gap before the new segment
(0.32 MB).

Testbox st, query, hono, zod, VS Code and Sentry: instructions:u -0.21%
against its PGO tsc, +0.05% against the PR #106 release tsc. cycles:u on
VS Code and Sentry -2.5% and -2.5% against its PGO tsc (without the pad
-2.4% and -2.5% in the same run); the 5-cell geomean -2.1% (-2.9%), the
difference is query, the smallest cell. Output byte-equal to the PR #106
release tsc and main's R236 tsc on the out-check cells; check-unwind.py,
pgo-train.sh compare and the release.yml tsc check pass.

The header also says not to strip the BOLT tsc: llvm-strip moves the new
segment back over the gap, and Linux before 5.18 then passes a wrong
AT_PHDR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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