Skip to content

ssa: isolate index range panic path - #2344

Open
visualfc wants to merge 2 commits into
xgo-dev:mainfrom
visualfc:codex/check-index-range-fast-path
Open

ssa: isolate index range panic path#2344
visualfc wants to merge 2 commits into
xgo-dev:mainfrom
visualfc:codex/check-index-range-fast-path

Conversation

@visualfc

@visualfc visualfc commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Move index bounds panics off the normal execution path and lower them through dedicated signed and unsigned runtime
helpers.

Previously, every checked index operation called:

CheckIndexRange(outOfRange, index, signed, length)

even when the index was valid. The helper carried a dynamic condition and a signedness flag into the runtime.

This change branches on the bounds-check result in generated code and calls a runtime panic helper only from the
failure path:

func PanicIndex(x int, y int)
func PanicIndexU(x uint, y int)

This matches Go's runtime.panicIndex / runtime.panicIndexU lowering model.

Motivation

The previous lowering kept a runtime call on the successful index path:

%out_of_range = ...
call void @runtime.CheckIndexRange(
i1 %out_of_range,
i64 %index,
i1 %signed,
i64 %length
)

The runtime call received information that was already known by the compiler:

  • The bounds-check condition
  • Whether the index was signed
  • A constant true once the failure path was isolated

This unnecessarily complicated the runtime interface and the generated fast path.

Implementation

Index lowering now:

  1. Computes the existing out-of-range condition.
  2. Branches to a dedicated failure block.
  3. Selects the panic helper at compile time based on index signedness.
  4. Calls PanicIndex or PanicIndexU only from the failure block.
  5. Continues directly to the index operation on the successful path.

Signed indexes are lowered to:

%out_of_range = ...
br i1 %out_of_range, label %panic, label %continue

panic:
call void @runtime.PanicIndex(i64 %index, i64 %length)
br label %continue

Unsigned indexes use:

call void @runtime.PanicIndexU(i64 %index, i64 %length)

The actual argument width follows the target architecture through int and uint.

Runtime Changes

Remove the obsolete runtime helper:

func CheckIndexRange(
outOfRange bool,
index int64,
signed bool,
length int,
)

Index bounds failures now use the existing interfaces:

func PanicIndex(x int, y int)
func PanicIndexU(x uint, y int)

This removes the runtime condition and signedness parameters while preserving signed and unsigned bounds-error
formatting.

Source Line Information

The panic block intentionally retains a continuation edge instead of ending immediately with unreachable.

Although PanicIndex and PanicIndexU do not return at runtime, placing unreachable directly after the call caused
LLVM's return-address line information to resolve to the following source line. This broke recovered bounds-panic
stack traces and TestRuntimeStatementLineInfo.

Keeping a branch after the panic call gives the return address an instruction with the correct debug location. Runtime
behavior is unchanged because the panic helpers never return.

Bounds-Check Modes

The existing -B behavior is preserved:

  • Normal builds emit the conditional panic path.
  • Builds with bounds checks disabled emit neither PanicIndex nor PanicIndexU.
  • Mandatory checks unrelated to index bounds, such as nil dereference and unsafe builtin checks, remain unchanged.

Test Coverage

LLVM IR fixtures were updated to verify:

  • The bounds predicate branches to a dedicated failure block.
  • Signed indexes call PanicIndex.
  • Unsigned indexes call PanicIndexU.
  • Both helpers use the two-argument runtime interface.
  • The failure block retains the expected continuation branch.
  • Constant and dynamic indexes preserve the correct signedness.
  • Array, slice, string, generic, reflect, runtime, libc, and libgo indexing paths use the new lowering.
  • Disabled bounds checks do not emit either panic helper.
  • Recovered panic stacks report the original bounds-expression line.
 核心测试形式:

  for i := 0; i < b.N; i++ {
      sum += values[indexes[i&(datasetSize-1)]]
  }

  同时覆盖动态切片读、动态切片写和动态字符串读。输入在计时前构造,结果写入全局 sink,防止访问被删除。

  测试环境:

  - Apple M5,darwin/arm64
  - 基线:upstream/main (7f954d618)
  - PR:d3fa29e51
  - -benchtime=500ms -count=10
  - LLGO_BUILD_CACHE=off
  - 表中取 10 轮中位数,数值越低越好

   Benchmark            upstream/main             PR      变化     加速
  ━━━━━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━━━  ━━━━━━━━━━━━━  
   DynamicSliceRead       0.938 ns/op    0.337 ns/op    -64.1%    2.79x
  ───────────────────  ───────────────  ─────────────  
   DynamicSliceWrite      0.926 ns/op    0.309 ns/op    -66.6%    3.00x
  ───────────────────  ───────────────  ─────────────  
   DynamicStringRead      0.927 ns/op    0.327 ns/op    -64.8%    2.84x

  这个 microbenchmark 是动态索引密集场景,每轮包含索引表访问和目标对象访问,因此反映的是边界检查热路径优化效果,不应直接等同于整个应用的性能提升。

@visualfc
visualfc force-pushed the codex/check-index-range-fast-path branch from 49a9eab to d3fa29e Compare August 17, 2026 02:47
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

LLGo baseline benchmarks

76aaf3e0076c | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Build vs base Run vs base
Linux cprintf 19368 B -0.1% (better) 220.000 ms -5.0% (better) 998.725 us +2.8% (worse)
Linux fmtprintf 1869816 B -0.6% (better) 1.751 s -4.5% (better) 2.446 ms -0.6% (better)
Linux println 68776 B -0.2% (better) 210.808 ms -10.5% (better) 1.150 ms -7.7% (better)
macOS cprintf 84672 B +0.0% 361.179 ms -5.4% (better) 3.446 ms +23.9% (worse)
macOS fmtprintf 1892448 B +0.0% (worse) 3.869 s +81.0% (worse) 30.308 ms +171.9% (worse)
macOS println 121360 B -0.0% (better) 363.179 ms -11.3% (better) 3.895 ms -37.8% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 7.197 ns/op -0.3% (better)
Linux BenchmarkMergeCompilerFlags 88.670 ns/op -1.7% (better)
Linux BenchmarkMergeLinkerFlags 59.680 ns/op -2.8% (better)
Linux BenchmarkChannelBuffered 35.120 ns/op -1.1% (better)
Linux BenchmarkChannelHandoff 17178 ns/op +0.7% (worse)
Linux BenchmarkDefer 25.020 ns/op -1.5% (better)
Linux BenchmarkDirectCall 0.924 ns/op -2.7% (better)
Linux BenchmarkGlobalRead 0.922 ns/op -1.3% (better)
Linux BenchmarkGlobalWrite 4.489 ns/op -2.5% (better)
Linux BenchmarkGoroutine 23757 ns/op +8.5% (worse)
Linux BenchmarkInterfaceCall 5.039 ns/op +7.2% (worse)
Linux BenchmarkRuntimeGetG 0.918 ns/op -3.2% (better)
macOS BenchmarkLookupPCRandom 15.410 ns/op +34.5% (worse)
macOS BenchmarkMergeCompilerFlags 193.100 ns/op +96.3% (worse)
macOS BenchmarkMergeLinkerFlags 110.200 ns/op +59.1% (worse)
macOS BenchmarkChannelBuffered 26.820 ns/op +0.6% (worse)
macOS BenchmarkChannelHandoff 5625 ns/op -2.9% (better)
macOS BenchmarkDefer 37.660 ns/op +19.1% (worse)
macOS BenchmarkDirectCall 1.205 ns/op +3.0% (worse)
macOS BenchmarkGlobalRead 1.260 ns/op +18.9% (worse)
macOS BenchmarkGlobalWrite 1.291 ns/op +19.0% (worse)
macOS BenchmarkGoroutine 50018 ns/op +72.5% (worse)
macOS BenchmarkInterfaceCall 5.561 ns/op -17.3% (better)
macOS BenchmarkRuntimeGetG 2.348 ns/op +4.7% (worse)

Compared with 0f480253029a measured in the same runner job.

@fennoai fennoai Bot 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.

Review: isolate CheckIndexRange in an unreachable failing branch

The core change in ssa/datastruct.go swaps the always-emitted InlineCall(CheckIndexRange, check, ...) for a hoisted IfThen(check, { Call(CheckIndexRange, true, ...); Unreachable() }). This is correct and a genuine fast-path win:

  • Semantics preserved — the panic predicate (check = signed idx < 0 OR unsigned idx >= max) is unchanged. The branch is taken on exactly the same out-of-bounds set as before, and inside it CheckIndexRange(true, ...) always panics, so no out-of-bounds index can slip through.
  • Unreachable is soundpanicBounds never returns, and IfThen (ssa/stmt_builder.go:230) detects the unreachable terminator and correctly suppresses the jump to the merge block, so no dead fallthrough is emitted.
  • PerformanceInlineCall does not actually inline (it's b.Call with a TODO), so the old code emitted an unconditional runtime call on every indexed access. The new form keeps the call off the in-bounds path and gives LLVM a clean cold/no-return branch to sink.
  • The updated test assertion accurately pins the intended br/call/unreachable IR shape.

All findings below are minor/advisory; none are blocking.

Comment thread ssa/datastruct.go Outdated
Comment thread ssa/datastruct.go Outdated
Comment thread ssa/bounds_checks_test.go Outdated
@visualfc
visualfc force-pushed the codex/check-index-range-fast-path branch from d3fa29e to 7d6fc56 Compare August 17, 2026 03:47
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@visualfc
visualfc force-pushed the codex/check-index-range-fast-path branch from 7d6fc56 to 57aabe2 Compare August 17, 2026 04:41
@visualfc
visualfc force-pushed the codex/check-index-range-fast-path branch 2 times, most recently from 65383fd to 64b5f53 Compare August 17, 2026 07:44
@visualfc
visualfc force-pushed the codex/check-index-range-fast-path branch from 64b5f53 to 76aaf3e Compare August 17, 2026 11:46
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