Skip to content

internal/analysis: don't rewrite loops whose names the new code would capture - #16

Closed
thesyncim wants to merge 10 commits into
fix-negative-constant-offsetfrom
reserved-names
Closed

thesyncim wants to merge 10 commits into
fix-negative-constant-offsetfrom
reserved-names

Conversation

@thesyncim

@thesyncim thesyncim commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Stacked on mauri870#2's branch (fix-negative-constant-offset).

The rewritten code adds names that start with an underscore, some of them declared before or inside the loop. It also calls len, min, max and copy, converts with uint64 and the element types, and refers to the packages simd and unsafe. The function, its package or an import can give any of those names another meaning, and then the vector loop silently computes something else. These all go wrong on master:

func F(dst, a []int32, _i int) {
	for i := 0; i < _i; i++ {
		dst[i] = a[i] + 1
	}
}

The vector loop's condition reads loopvec's _i instead of the parameter, so it never runs. In a package that declares func len(s []int32) int { return cap(s) / 4 }, the vector loop takes its length from that len and stops a quarter of the way. And a sum written as for n := range n { s += a[n] }, or as for n := n - 1; n >= 0; n--, finishes its last elements with for n := _i; n < n; n++, which never runs, so the result is short.

A loop that would otherwise be rewritten is now left alone, with its own reason, when:

  • its function uses an identifier that starts with an underscore, other than _. Rather than track which names each part of the emitter uses, this covers the names loopvec adds now and any it adds later;
  • one of the names the code relies on, looked up from the loop, resolves to something other than the predeclared one or the imported package. The lookup starts at the loop's position, so a max declared after the loop doesn't count. The overlap helper's own names (uintptr, int, bool, any, false) are checked at package level, where it is declared. The toolexec wrapper now records scopes for this;
  • the loop itself declares one of those names, or a name its range operand or initial values use (for n := range n, for n := n - 1; ..., for len := range a).

These checks run last, so a loop that can't be vectorized anyway keeps the reason that matters.

The second commit fixes the same kind of bug in fixBlankParams. It renames _ parameters to _p0, _p1, ... to work around a compiler bug with simd code, but it did that in every function of a rewritten file, so func Offset(_ int) int { return _p0 } started returning its argument instead of a package-level _p0. Now it only renames in the functions loopvec rewrote, which can't mention such names.

No loop in std, gonum or goav1 stops being vectorized. reserved_names.txt and blank_params.txt cover each case. A rewrite that doesn't type-check still falls back in toolexec; toolexec_falls_back_when_rewrite_does_not_compile.txt used a parameter named simd to get one, which is now rejected earlier, so it uses a package that declares _loopvecOverlap itself instead.

thesyncim and others added 10 commits November 3, 2026 21:05
…ro (#2)

loopvec writes a `_simd.go` that doesn't compile for a loop like this:

```go
for i := 0; i < 16; i++ {
	dst[i] = a[i-20] + 1
}
```

The length check comes out as `_ = a[16-21]`:

```
invalid argument: index 16 - 21 (constant -5 of type int) must not be negative
```

toolexec falls back, but `-split` and `-w` leave the broken file behind.

A loop like that panics as soon as it runs, and so does one whose first
index is a negative constant (`a[i-1]` starting at 0). Both are now
rejected with their own reason, the same way a constant limit of zero or
less already is. `a[i-1]` starting at 1 is still rewritten.

The checks parsed the start and limit as text, so named constants and
constant expressions (`const s0 = 0; for i := s0; ...`, `for i := 1 - 1;
...`) slipped through. Constant bounds are now written out as their
value. A side effect is that the short-loop rule now sees named constant
trip counts too: in [goav1](https://github.com/thesyncim/goav1), a
3-iteration `for x := range restorationExtraHorz` is no longer
vectorized. The vectorized set of std and gonum doesn't change.

Co-authored-by: Mauri de Souza Meneguzzo <mauri870@gmail.com>
`toolexec_reproducible.txt` checks that two separate builds of a
rewritten package give the same archive. It doesn't cover the failure
that motivated #11. With PGO, `go test` compiles a package once for a
main package's binary and again for its test binary. It then compiles
the test against one copy and links the other, and the linker reports a
fingerprint mismatch when the two differ.

`toolexec_pgo.txt` reproduces that. A main package with a `default.pgo`
has a test that imports the rewritten package, so the test binary gets a
PGO copy of its own. The profile is written by a small program inside
the script, because a CPU profile with no samples is enough to turn PGO
on. Without #11, `go test ./...` fails with the same link error seen in
goav1:

```
link: fingerprint mismatch: example.com/app/vec has d0f77d59987fdb10, import from example.com/app/cmd/bench expecting 205082f863ae44a6
```

`toolexec_reproducible.txt` is also brought in line with the other
toolexec scripts:

- it checks the wrapper's log against a `log.want` section;
- it checks that both builds print nothing;
- it limits the log to the package under test with
`LOOPVEC_TOOLEXEC_LOG_PKGS`.

Tested locally:

- With #11's `compile.go` reverted, both scripts fail. The PGO one fails
with the link error above, and the reproducible one at `cmp a1.a a2.a`.
- With #11 in place, both pass, with the same result on three repeated
runs.
- `make test` and `make test-qemu-arm64` pass.
- `-update` leaves both scripts unchanged.
)

Follow-up to #2.

#2 started replacing every constant start or limit with its value, so
that the checks parsing `Trip.Start`/`Trip.Limit` as text would also see
named constants. The emitted file is compiled for every build, though,
and a named constant can have a different value in another one. With
`const width = 32` for amd64 and `64` for arm64, `loopvec -split` on
amd64 hard-coded `32` into the vector loop of `for i := 0; i < width;
i++`, while the scalar fallback kept `width`. The arm64 build would then
vectorize only half the elements. `plusOne` and `i > low` loops had
always done the same for `<=` limits and loops that count down.

What changes:

- **Constant names are kept.** `Trip` carries the values of a constant
start and limit. The checks read those values instead of parsing text:
the negative-index check, the short-loop rule and the copy check. A
constant is written as its value only when it's built from literals
alone (`16`, `1 - 1`). A constant that uses a name keeps the name. `i <=
width-1` comes out as `width`.
- **Dead code removed.** `negativeSource` could no longer return true
after #2.
- **The negative-index check can't overflow.** It negates the offset
instead of adding it to the bound. It also runs in the same pass over
the loop body as the stored-offset checks.
- **Bounds pushed past the largest int are rejected.** A positive offset
that pushes a constant start or limit past the largest int is rejected
as that bound. `for i := 0; i < math.MaxInt64; i++ { dst[i] = a[i+1] }`
used to produce an overlap window of `math.MaxInt64 + 1`, which doesn't
compile.
- **Tests:**
- `offset_negative_constant.txt` now checks the rewritten source and
runs the loops under both builds.
- The rejected loops moved to `offset_negative_constant_no_rewrite.txt`,
which adds a loop counting down to 0, a range over a slice, and the two
loops pushed past the largest int.
- `bound_arch_constant.txt` covers the GOARCH case for `<`, `<=`,
counting down and `>`.
- **AGENTS.md** documents the new rejections and the rule for keeping
names.

Tested locally:

- `bound_arch_constant.txt` fails against #2's `internal/loopir`, which
writes `_ = dst[32-1]` where the test expects `_ = dst[width-1]`.
- `make test` and `make test-qemu-arm64` pass.
- `make test-update` and `make fix` leave no diff, and `make lint`
reports 0 issues.
- `loopvec -json` gives identical output before and after over std (1663
loops) and gonum (3252 loops).
In 2196e07 I added `test-qemu-arm64` without a `-timeout`, so the root
package gets `go test`'s default of 10 minutes. On CI, the arm64 qemu
step has taken anywhere from 237 to 438 seconds. The tip job of #12,
which adds one more toolexec script, hit the limit:

```
panic: test timed out after 10m0s
	running tests:
		TestScripts/flag_precedence (2m3s)
		TestScripts/min_max_int (2m10s)
```

Nothing was stuck. The emulated suite is just slow, and runner speed
varies. Locally under qemu, `toolexec_pgo` takes about as long as the
other toolexec scripts, around 25 seconds of wall time with the scripts
running in parallel.

This change passes `-timeout=30m` to the target. `make test-qemu-arm64`
passes locally with the change.

After this merges, #12 needs a rerun, or a rebase onto master.
This bumps the pinned toolchain from go1.27.1 to go1.27.3, the latest
1.27 release. It supersedes the go1.27.3 commit in #1.

That commit bumped only the Makefile. The script harness doesn't read
the Makefile: `script_test.go` runs every script with the toolchain
`go.mod` names (`goModToolchain`), and `actions/setup-go` reads the same
file. With only the Makefile changed, the scripts would still have run
on go1.27.1, including the `flag_methods_ice.txt` check behind the claim
that the crash is still in 1.27.3.

This PR:

- **Pins the toolchain in two places.** The Makefile now pins
`go1.27.3`, and `go.mod` gets a `toolchain go1.27.3` line.
- **Keeps the minimum at 1.27.1.** The `go` line stays at `1.27.1`, the
first release whose simd works with what loopvec generates: go1.27.0
compiles `ShiftAllRight` with a `uint8` count.
- **Regenerates `caps_gen.go`.** go1.27.3's simd package has the same
methods as 1.27.1's, so only the header comment changes.
- **Updates the version in `BUGS.md` and `flag_methods_ice.txt`.** The
`-methods` internal compiler error (golang/go#80657) still happens on
go1.27.3, and the script checks that now.

Tested locally:

- `make ci` passes on this commit: `test-update` leaves the goldens
unchanged, and the race suite, both arm64 qemu runs, bench fuzz seeds,
tsvc, fix and lint all pass.
- The script harness reports `GOTOOLCHAIN=go1.27.3`, and
`flag_methods_ice.txt` still gets the internal compiler error.
`printDiff` wrote both sources to temporary files and ran the system
`diff`. That made `-d` depend on the host. Windows has no `diff`. GNU
and BSD diff printed different headers until #10 labeled them. And the
error was ignored, so a missing or failing `diff` printed nothing while
loopvec still exited 0.

This change computes the unified diff in process with
[github.com/pmezard/go-difflib](https://github.com/pmezard/go-difflib)
(BSD-3, about 1,300 importing packages, mostly through testify). The
labels stay the same, with both sides named after the file path and
three lines of context. The temporary files and the `exec` call are
gone. The library's own `SplitLines` adds an empty line after a final
newline, which would show up as a context line past the end of the file,
so `printDiff` splits the lines itself.

go-difflib can align a hunk differently from GNU diff. For example, it
may put an added line on the other side of an unchanged blank line or
`}`. The patches still apply the same way. I checked this by running
`loopvec -d std` with the old and new code: all 17 files that get a diff
apply with `patch -F0` and give exactly the same source as GNU diff's
patches. Since the output no longer depends on the machine,
`flag_diff.txt` now checks it whole instead of line by line. Its patch
also applies with `patch -F0`.

`make ci` passes.
…alar build (#17)

`activation_functions.txt` hashes float bits and compares both builds
with `testmain/output`, which was recorded on amd64. It passes on arm64
only because none of its loops creates a NaN: they just pass the input
NaN through. With an alpha of 0, `LeakyReLU` computes `0 * -Inf`. The
resulting NaN has the sign bit set on amd64 and clear on arm64, so the
script fails on arm64 against the recording. This change gives it #8's
treatment: the recording is compared only on amd64, and on every target
the simd build is compared with the scalar build of the same machine.

It also changes the three scripts from #8:

- They compared the simd build with the recording on amd64 as well. That
adds nothing: on amd64 the scalar build matches the recording and the
simd build matches the scalar build.
- That comparison came last, so it was the one `-update` recorded. A
simd build that differed from the scalar one would have been written
into the recording. Now only the scalar build is compared with the
recording, and that's what `-update` records.
- Two of their comments named the square root of a negative number,
though those scripts have no square root.

Tested locally:

- I made temporary copies of the old and new `activation_functions.txt`
with alpha set to 0, recorded the expected output on amd64, and ran both
on arm64 under qemu. The old script fails at `cmp stdout
testmain/output`, and the new one passes.
- The four scripts pass on amd64 and on arm64 under qemu, and `-update`
leaves them unchanged.
Issue 80657 is closed now that CL 839405 is in tip; 1.27.3 still
crashes, which flag_methods_ice.txt pins.

The section on 80835 said the effect on Intel was unknown. Others have
since measured it in the issue: Skylake-X pays 2x to 3x on scalar code
after an AVX-512 kernel, Sapphire Rapids and Granite Rapids lose about
15 points of peak on a kernel until ClearAVXUpperBits is added, and
adding it to every short leaf function costs about 0.8 ns per call.
Add those, along with loopvec's own measurements: GOAMD64=v3 does not
remove the penalty, the clear costs nothing after a pure vector loop,
and one rewritten fill made scalar loops in gonum's Dlantb 7x slower.

loopvec does not emit the clear yet, so say "none yet".
… capture

The rewritten code adds names that begin with an underscore (_i,
_count, _w1, broadcast vectors declared before the loop, ...) and relies
on predeclared names (len, min, max, copy, uint64, the element types) and
on the simd and unsafe packages. Each can mean something else in the
function, and then the vector loop silently computes something else.
All of these already go wrong on master:

  func F(dst, a []int32, _i int) { for i := 0; i < _i; i++ { ... } }

reads loopvec's _i in the loop condition and never runs; with a
package-level func len(s []int32) int the vector loop takes its window
length from that len; and a reduction written as for n := range n, or
for n := n - 1; n >= 0; n--, finishes with for n := _i; n < n; n++,
which never runs.

A loop that would otherwise be rewritten is now left alone, each with
its own reason, when its function uses an identifier that begins with an
underscore, other than _; when one of the names the code relies on,
looked up from the loop, is declared again around it, in the package, or
by an import; or when the loop declares such a name itself, or a name
its range operand or initial values use. The names the overlap helper
uses (uintptr, int, bool, any, false) are checked at package level,
where loopvec declares it. The lookup needs scopes, so the toolexec
wrapper records them. No loop in std, gonum or
github.com/thesyncim/goav1 stops being vectorized.
fixBlankParams works around a compiler bug that rejects _ parameters in
functions with simd code by renaming them _p0, _p1, ..., but it renamed
them in every function of a rewritten file. In a function that reads a
package-level _p0, the parameter then hid it:

  var _p0 = 5
  func Offset(_ int) int { return _p0 }

became func Offset(_p0 int) int { return _p0 } and returned its
argument. Only the functions loopvec rewrote are renamed now. They
mention no name that begins with an underscore, so the new names cannot
capture anything.
@thesyncim
thesyncim removed this pull request from stack #20 November 4, 2026 15:50
@thesyncim

Copy link
Copy Markdown
Owner Author

This is upstream now as mauri870#19.

@thesyncim thesyncim closed this Nov 4, 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.

2 participants