Skip to content

compiler: compare range loop index unsigned - #5999

Open
dgryski wants to merge 1 commit into
tinygo-org:devfrom
dgryski:range-loop-unsigned-compare
Open

dgryski wants to merge 1 commit into
tinygo-org:devfrom
dgryski:range-loop-unsigned-compare

Conversation

@dgryski

@dgryski dgryski commented Oct 23, 2026

Copy link
Copy Markdown
Member

A range loop over a slice has the condition (index + 1) < len(slice) with a signed compare, while the bounds check on the element is unsigned. LLVM cannot merge the two without knowing that the length is not negative. It keeps the check and adds a smax to the loop count.

The index starts at -1 and goes up by one while it is below the length, and a slice length is never negative. An unsigned compare gives the same result, so this change uses it for that loop condition. Then it matches the bounds check and LLVM removes the check. Other loops, loops over arrays and loops over strings are not changed.

Results

In the standard library, building the test binaries of the packages in make/test.mk and comparing the surviving checks with the ones gc keeps, this removes the check from 291 of the 292 range loop sites where gc removes it and LLVM did not.

At -opt=z a range loop with an early return goes from 21 to 15 instructions and loses its panic call. At -opt=2 LLVM already removed it in that case.

On a Seeeduino XIAO (SAMD21, Cortex-M0+ at 48 MHz, -opt=s) I measured cycles with SysTick. A range loop with a continue statement takes 11.1% fewer cycles, from 18,368 to 16,320. Three other range loops are unchanged and so are two control loops. The cycle counts are exactly repeatable and do not depend on code layout.

On a desktop I did not find a consistent speedup. Over 8 code layouts, some benchmarks in compress/flate and strings.Replacer are 2 to 4% faster at -opt=z, but unchanged code varies by more than that. The compress/lzw compression benchmark is 4 to 6% slower on wasip1 and at -opt=z, in every layout, and I do not know why. The machine code of the affected function is smaller.

Code size changes by -0.01% to -0.33% at -opt=z, -0.05% to -0.16% at -opt=s and -0.21% to +0.50% at -opt=2 on the programs I tried.

Testing

  • Added a compiler test for the loop condition. It passes with LLVM 20, 21 and 23.
  • Added runtime tests in testdata/slice.go for range loops over nil, empty and resliced slices, changing the slice or the index in the loop body, break and continue. The expected output comes from the reference Go toolchain. They pass on the host and on wasip1 at -opt=0, 1, 2, s and z.
  • The compiler tests that already fail on dev with these LLVM versions are unchanged.
  • I did not run the Cortex-M size tests locally. builder/testdata/binary-size.txt may need an update from the CI output.

A range loop over a slice has the condition (index + 1) < len(slice) with a
signed compare, while the bounds check on the element is unsigned. LLVM cannot
merge the two without knowing that the length is not negative, so it keeps the
check and adds a smax to the loop count.

The index starts at -1 and goes up by one while it is below the length, and a
slice length is never negative. An unsigned compare gives the same result. Use
it for this loop condition so it matches the bounds check.

In the standard library this removes the bounds check from 291 of the 292 range
loops where gc removes it and LLVM did not. At -opt=z a loop with an early
return goes from 21 to 15 instructions and loses its panic call. On a
Cortex-M0+ a range loop with a continue statement takes 11% fewer cycles.

Add a compiler test for the loop condition and runtime tests for range loop
semantics.
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