Skip to content

Compute the linear index of Cartesian indexing in Horner form - #206

Closed
vchuravy wants to merge 3 commits into
JuliaArrays:mainfrom
vchuravy:vc/horner-index
Closed

vchuravy wants to merge 3 commits into
JuliaArrays:mainfrom
vchuravy:vc/horner-index

Conversation

@vchuravy

Copy link
Copy Markdown
Contributor

getindex/setindex! with N Int indices now compute the linear index in Horner form, 1 + (i1 - 1) + s1 * ((i2 - 1) + s2 * ((i3 - 1) + ...)), as StrideArraysCore.jl does for dense arrays. Previously they used Base's sum of stride products, 1 + (i1 - 1) + (i2 - 1) * s1 + (i3 - 1) * (s1 * s2) + .... Element access is unchanged (parent(A)[l] after checkbounds), so bounds checking, @inbounds, effects and non-CPU parents behave as before. Calls with fewer or more indices than N still use the generic AbstractArray methods.

Why the order matters

Both forms give the same value. With the Horner form, all elements that differ only in the first index share the nested part s1 * (...) of the offset. A kernel that reads u[v, i, j, k, e] for v in 1:5 therefore needs one base address plus constant offsets. With the forward sum, especially after a size check such as axes(u) == ax || throw(...) has made the dimensions compile-time constants, LLVM reassociates the sum differently for each element and computes a separate offset (add + shl + gep) for each of them. That costs instructions and registers. With this PR the optimized IR of the Trixi.jl kernel below has the same shape as with StrideArraysCore.PtrArray: add/shl go from 276/117 to 152/57.

Performance

MWE from #201 (kernel! with a size check and 5-variable accesses), FixedSizeArray backed by a Vector, 1 thread, instructions per call (perf counters) and minimum time:

Julia v1.4.2 this PR
1.10.12 3.156 M, 158 μs 3.538 M, 185 μs (slower, see below)
1.12.7 3.820 M 3.787 M (−0.9%)
1.13.1 3.795 M, 185.7 μs 3.524 M, 169.4 μs
1.14.0-DEV.3384 3.794 M, 183.8 μs 3.433 M, 166.1 μs

Trixi.jl (trixi-framework/Trixi.jl#3357, which uses FixedSizeArrays for the solution arrays; TreeMesh3D Taylor-Green vortex, polydeg 3, 1 thread), Julia v1.13.1:

  • rhs!: 24.73 M → 24.41 M instructions, 2387 → 2315 μs
  • volume integral: 19.92 M → 19.55 M instructions, time unchanged
  • interface flux: −3.8% time

On nightly the volume integral improves by about 0.5%. On 1.12.7 it needs 1.4% more instructions (21.21 → 21.49 M) at the same time.

Julia v1.10: with LLVM 15 the Horner form is slower in the MWE (+12% instructions, +17% time, consistent over repeated runs). I have not found out why yet. If that matters, the methods could be defined only for VERSION >= v"1.11" or v"1.13". I left them ungated on purpose and am happy to add a gate if you prefer.

General microbenchmarks: sum, iteration, broadcast, copyto!, views and CartesianIndices loops are unchanged. A 2-d stencil needs 3% (1.13) to 19% (nightly) fewer instructions, and a 2-d matvec 10% fewer on nightly.

Follows #201/#202 (eager size), which made the dimensions available to LLVM after a size check in the first place.

Tests

The new test set "Cartesian indexing with Ints" compares a[I...] and a[I...] = x against LinearIndices for N = 1–5 with odd sizes, including size-1 dimensions. It covers Memory- and Vector-backed arrays and Float64/Int16 elements, plus out-of-bounds errors in each dimension and trailing indices. The full test suite passes on 1.10.12, 1.12.7, 1.13.1 and 1.14.0-DEV.3384. The 2 broken tests on 1.13/nightly are already broken on main. Enzyme gradients through Cartesian getindex/setindex! were checked on 1.12 (forward and reverse, Memory- and Vector-backed).

This PR was prepared with assistance from Claude Code (Anthropic's AI coding assistant). I reviewed the changes.

🤖 Generated with Claude Code

getindex/setindex! with N Int indices compute
l = 1 + (i1-1) + s1*((i2-1) + s2*((i3-1) + ...)), as StrideArraysCore.jl
does for dense arrays, instead of Base's forward sum with stride products,
then index the parent with the linear index as before.

Assisted-by: Claude Code (Opus 5.5)
Assisted-by: Claude Code (Opus 5.5)
@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.85714% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.46%. Comparing base (c15fd6e) to head (a2a36e4).

Files with missing lines Patch % Lines
src/FixedSizeArray.jl 92.85% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #206      +/-   ##
==========================================
- Coverage   93.50%   93.46%   -0.04%     
==========================================
  Files           6        6              
  Lines         231      245      +14     
==========================================
+ Hits          216      229      +13     
- Misses         15       16       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@giordano

Copy link
Copy Markdown
Collaborator

Is this worth a FileCheck test? 👀

@giordano

Copy link
Copy Markdown
Collaborator

Julia v1.10: with LLVM 15 the Horner form is slower in the MWE (+12% instructions, +17% time, consistent over repeated runs). I have not found out why yet. If that matters, the methods could be defined only for VERSION >= v"1.11" or v"1.13". I left them ungated on purpose and am happy to add a gate if you prefer.

If it makes things slower, then yeah, a version guard would be better. And FileCheck tests to make sure the important properties are preserved 😃

@oscardssmith

Copy link
Copy Markdown
Member

should base switch also?

@vchuravy

Copy link
Copy Markdown
Contributor Author

Ah its not a clear win. It only really seems to win when only the last dimension is dynamic and the rest static.

@vchuravy

Copy link
Copy Markdown
Contributor Author

Replaced by #217

@vchuravy vchuravy closed this Oct 21, 2026
vchuravy added a commit that referenced this pull request Oct 21, 2026
- Test Cartesian `getindex`/`setindex!` against `LinearIndices` for 1-5
  dimensions with odd sizes, size-1 dimensions, `Memory`/`Vector`
  backends, a static first dimension, and out-of-bounds indices
  (ported from #206).
- FileCheck test: all elements `u[v, i, j, k]` along the first dimension
  share one offset, with no multiplication after the first load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
giordano pushed a commit that referenced this pull request Oct 21, 2026
* Compute the linear index of Cartesian indexing in Horner form

`static_size_linear_index` now computes `i + n1 * ((j - 1) + n2 * (k - 1))` instead of
the sum of strided terms `1 + (i - 1) + n1 * (j - 1) + (n1 * n2) * (k - 1)`, which needs
fewer integer operations. In the flux differencing volume integral of Trixi.jl (2D,
4096 elements, 1 thread), this is 3.5% faster with a `Vector` backend (2553 to 2464 us)
and 2.5% faster with a `MemoryRef` backend (#212).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5z6TVTAFfLGSFafUoLzuo

* Add tests and a CHANGELOG entry for the Horner-form linear index

- Test Cartesian `getindex`/`setindex!` against `LinearIndices` for 1-5
  dimensions with odd sizes, size-1 dimensions, `Memory`/`Vector`
  backends, a static first dimension, and out-of-bounds indices
  (ported from #206).
- FileCheck test: all elements `u[v, i, j, k]` along the first dimension
  share one offset, with no multiplication after the first load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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