Skip to content

compiler: skip slice bounds checks that cannot fail - #5997

Open
dgryski wants to merge 2 commits into
tinygo-org:devfrom
dgryski:bce-slice-len-cap
Open

dgryski wants to merge 2 commits into
tinygo-org:devfrom
dgryski:bce-slice-len-cap

Conversation

@dgryski

@dgryski dgryski commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

When slicing a slice without a high or max index, as in s[i:], the high bound is the length and the max bound is the capacity of the same slice. The check high > max is then always false because len <= cap, but LLVM often cannot know that. The extra comparison can stay in the IR and block loop optimizations.

This change does not emit that check. It also does not emit max > cap when max is the capacity itself, as in s[a:b]. LLVM already folded the second case, so it only trims the IR. All other checks are unchanged.

Results

Measured on darwin/arm64 and wasip1. Timings are medians over 6 code layouts, built with different amounts of unused code, to reduce alignment noise.

A loop that consumes a byte slice with s = s[4:] is 8x faster at -opt=2 and about 8% faster at -opt=z. A loop that mixes s[i:] and x[1:] is 11% faster at -opt=2 and 21% faster at -opt=z. utf8.Valid on mixed text is 7% faster natively and 22% faster on wasip1. base64 decoding is 3 to 4% faster natively.

On a Seeeduino XIAO (SAMD21, Cortex-M0+ at 48 MHz, -opt=s) I counted cycles with SysTick for loops that re-slice a 1 KB buffer. They take 11.0%, 15.6%, 2.8% and 1.4% fewer cycles in the four loops that changed. One other re-slicing loop and two control loops are unchanged. The cycle counts are exactly repeatable and do not depend on code layout.

Most other benchmarks I tried (encoding, image/draw, compression) are within a few percent either way. Some small slicing loops are 6 to 9% slower. I checked one of them and its machine code is identical, so that is code placement and not this change.

Code size on the testdata programs for wasip1 changes by less than 0.4% in either direction. The largest increase was json at -opt=2 with 992 bytes (0.1%). It also shrinks json by about 1.3 KB at -opt=s and 1.5 KB at -opt=z.

Roughly 10% of slicing sites take the new path. LLVM already removes the check at about half of those. The other half, about 5% of all slicing sites, lose a check they would otherwise keep, and about 2.5% of all sites lose one inside a loop. I measured this on wasip1 and native with a corpus of about 100 Go repos plus the standard library, by building the test binaries without running them.

Testing

  • Added compiler tests for s[i:], s[i:j] and s[i:j:k].
  • Added runtime tests in testdata/slice.go for 11 in range and out of range cases. The expected output comes from the reference Go toolchain.
  • The new runtime test passes on the host and on wasip1 at -opt=0, 2, s and z.
  • The compiler tests slice, float, zeromap and large already fail on dev with LLVM 23 and are unchanged here. I updated string.ll for renumbered values and left the other failing goldens alone.
  • I updated builder/testdata/binary-size.txt with the sizes from the CI run, because I could not build the Cortex-M targets locally. Code size changes by at most 32 bytes per row.
  • The slice.ll golden lists declarations in the order that LLVM 20 and 21 use. With LLVM 20, 21 and 23 the compiler tests fail on the same tests as dev.
  • I did not run the Cortex-M size tests locally. They run in CI.

@dgryski

dgryski commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

Marking as draft so I can figure out how many of these checks LLVM was already able to remove via it's own constant folding logic.

@dgryski
dgryski marked this pull request as draft October 22, 2026 21:48
@dgryski

dgryski commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Update with a measurement of how much of this LLVM could already do on its own.

The PR description says about 10% of slicing sites take the new path. That counted every site where the check is skipped, including ones where LLVM would have removed the check anyway. To separate the two I built a throwaway experiment, not part of this PR. It emits the high > max check of s[i:] as its own branch to a marker panic function, with nothing skipped. After optimization I count the markers that are still in the IR. Those are the checks LLVM cannot remove by itself, so those are the ones this PR actually removes.

I compiled the test binaries of about 100 Go repos plus the standard library and runtime, for native and wasip1. Nothing was run.

                              native    wasip1
sites on the new path          5571      5165
still present after LLVM       2781      2502
  of those, inside loops       1532      1280

So LLVM already removes about half of the new path checks. The PR removes the other half. On wasip1 that is 2502 checks, about 5% of the roughly 50,000 slicing sites in the same set. About 2.5% of all sites lose a check that was inside a loop.

The corpus repos and the standard library behave similarly. 46% of corpus sites and 59% of std and runtime sites on wasip1 keep the check without this change.

Caveats. Survivors are counted once per function name across binaries, and a site that is inlined into several callers can count more than once, so the survival rate is approximate and may be a little high. Loops are detected as a cycle in the function's control flow graph. Four corpus jobs do not build and are missing from the numbers. I have not measured cortex-m.

The earlier benchmark numbers (827 us to 98 us for a loop doing s = s[4:]) come from a case where the check stayed in the loop, which is the kind of site counted in the loops row above.

@dgryski
dgryski marked this pull request as ready for review October 23, 2026 00:37
@dgryski
dgryski force-pushed the bce-slice-len-cap branch 3 times, most recently from a1125d6 to b4f9904 Compare October 23, 2026 04:36
@dgryski

dgryski commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

Follow-up with measurements on a Cortex-M0+, which the earlier comment said I had not done.

On a Seeeduino XIAO (SAMD21, 48 MHz, -opt=s) I counted cycles with SysTick for small kernels that re-slice a 1 KB buffer. The counts are exactly repeatable and do not depend on code layout.

kernel         dev     this PR   change
reslice4       6970    6202      -11.0%
slipDecode     19252   16251     -15.6%
numbers        15530   15098     -2.8%
lineLengths    11803   11637     -1.4%
mixedSlices    22423   22423     0%
two control loops are unchanged

The gain matches the drop in instructions in each loop. -opt=z does not build with my local LLVM sources, so I measured -opt=s.

The 827 us to 98 us case above is at -opt=2. At -opt=z the same loop is about 8% faster.

davecheney
davecheney previously approved these changes Oct 23, 2026

@davecheney davecheney left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, wrong pr, should have posted on the other one

@davecheney
davecheney dismissed their stale review October 23, 2026 22:06

wrong PR, sorry

@davecheney davecheney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, correct review on the correct PR this time

Program Target -opt=1 -opt=z -opt=2
pdp11 pidp11-bootmon waveshare-rp2350-pizero -656 -576 -448
corewar corewar-badge gopher-badge -464 -336 -288
pico8 p8bench waveshare-rp2350-zero -112 -240 +16
pico8 p8demo waveshare-rp2350-zero -256 -304 -112

At -opt=z most of the reduction is in functions whose IR lost a compare or a whole slice check: 446 of 578 bytes for pdp11, 294 of 340 for corewar. The largest are corewar/ui.Game.pick (-280) and fmt.fmtFloat (-248 on RP2350). fmt.fmtFloat loses the same 4 compares on RP2040 but gets 52 bytes larger there. The p8bench growth at -opt=2 is machine/usb/cdc.USBCDC.Write (+34), where LLVM restructures the loop.

@davecheney davecheney added this to the 0.44 milestone Oct 23, 2026
@deadprogram

Copy link
Copy Markdown
Member

@dgryski please rebase aagainst latest dev and resolve merge conflicts. Thank you.

When slicing a slice without a high or max index, the high bound is the
length and the max bound is the capacity of the same slice. The check
high > max is then always false because len <= cap. LLVM often cannot
know this, so s[i:] can keep an extra comparison that blocks loop
optimizations.

Do not emit high > max in that case. Also do not emit max > cap when max
is the capacity itself, as in s[a:b]. The remaining checks are
unchanged.

On a synthetic loop that consumes a byte slice with s = s[4:] this is 8x
faster at -opt=2 and 8% faster at -opt=z, as the median over 6 code
layouts. Code size on the testdata programs changes by less than 0.4% in
either direction, measured on wasip1.

Add compiler tests for the emitted checks and runtime tests for the
panic cases. Update the expected binary sizes.
Use the sizes measured in CI for pico2. The row for size-contains is new on
dev and the slice change affects it.
@dgryski

dgryski commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

PTAL

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.

3 participants