Skip to content

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

Merged
giordano merged 3 commits into
mainfrom
vc/horner-index
Oct 21, 2026
Merged

giordano merged 3 commits into
mainfrom
vc/horner-index

Conversation

@vchuravy

@vchuravy vchuravy commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #213: compute the linear index of Cartesian indexing in Horner form.

Before: static_size_linear_index computes the column-major index as a sum of strided terms, 1 + (i - 1) + n1 * (j - 1) + (n1 * n2) * (k - 1).

After: Horner form, i + n1 * ((j - 1) + n2 * (k - 1)). Static dimensions still give constant factors. This affects all FixedSizeArrays, not only those with static dimensions.

History

The first StaticSizeArray version in #210 used Horner form. When any dimension could be static, it switched to the strided sum, with this rationale: "Unlike the Horner form, the terms of indices that don't change in a loop can be hoisted out of it if the dimensions are dynamic." A review commit later dropped that comment. The measurements below show the concern is small.

Trixi.jl

This is the volume integral of Trixi.jl's 2D elixir_euler_ec.jl, with the flux-differencing kernel inlined. Setup: 4096 elements, polydeg 3, Julia 1.12, 1 thread pinned to one core, Vector backend.

volume integral rhs_hyperbolic!
strided sum (#213) 2578–2588 µs 3076–3108 µs
Horner (this PR) 2476–2489 µs 2986–2990 µs

That is about −3.9% and −3.3%. Each row covers 3 processes run with JULIA_LLVM_ARGS="-align-all-nofallthru-blocks=6". Without forced alignment, the mean gain over 6 processes is 1.5% (2566 → 2527 µs), because the timing of this kernel swings by up to about 6% from one process to the next, depending on where the code is placed. In the kernel IR, the integer shifts go from 21 to 15.

Microbenchmarks: static and dynamic dimensions

I compared both formulas in one process on the same Memory, computing dst = 2 * src and similar operations on a 32×32×32 array (8×64×64 for the pairs kernel). I tested all 8 static/dynamic patterns of the 3 dimensions (d = dynamic, s = static). The table shows Horner time / strided time, minimum of 200 calls:

loop ddd sdd dsd dds ssd sds dss sss
i innermost (column-major) 1.003 0.997 1.003 1.000 1.002 0.992 0.997 1.006
k innermost (row-major) 0.999 0.997 1.027 1.003 1.017 0.999 1.017 0.992
j innermost 1.000 1.001 1.002 1.003 0.986 0.999 1.003 1.005
stencil along j, k 1.000 1.009 1.010 0.996 0.986 1.006 0.996 0.988
node pairs i, ii 0.982 1.000 1.000 0.984 1.000 1.000 1.013 1.017
  • In most cases the two forms are within ±2%. The integer op counts in the IR differ a lot (e.g. 16 vs 80) without a timing effect, because LLVM rewrites both into the same address increments in loops.
  • Trade-off: with the last index innermost (row-major order) and mixed static/dynamic dimensions, Horner form is 1.7–2.7% slower in 3 of 8 patterns. This is the case the old comment was about; it is an uncommon loop order for column-major arrays.
  • All-dynamic arrays (ddd, i.e. every plain FixedSizeArray) show no difference.

Tests: the full suite, including the FileCheck tests for indexing, passes on Julia 1.10 and 1.12.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D5z6TVTAFfLGSFafUoLzuo

@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.44%. Comparing base (c6df886) to head (7422115).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #217      +/-   ##
==========================================
+ Coverage   94.40%   94.44%   +0.03%     
==========================================
  Files           8        8              
  Lines         322      324       +2     
==========================================
+ Hits          304      306       +2     
  Misses         18       18              

☔ 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.

Base automatically changed from vc/fixed-size-array-dims to main October 20, 2026 20:56
`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
- 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 giordano left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The bot is happy about performance

PR #217 doesn't change performance for fully dynamic or fully static arrays. With mixed static and dynamic dimensions it's neutral or slightly better, and one stencil case is 10% faster. I found no meaningful slowdown.

Setup: Julia 1.13.2, -O3, Zen 4 (Threadripper 7995WX), pinned to one core. Float64 arrays of size 32×32×32, @inbounds. I tested all 8 dynamic/static patterns: d is an Int dimension, s is a Val(32) dimension, and ddd is a plain FixedSizeArray.

LLVM IR (instruction count, main → PR)

kernel ddd sdd / sds dsd / dss ssd sss
scalar getindex/setindex! 18 → 18 (same) 15 → 15 15 → 15 12 → 13 12 → 13
loop with i innermost 252 → 252 315 → 246 (integer ops 87 → 18) same 258 → 255 252 → 249
loop with j innermost same 198 → 190 same 261 → 302 255 → 296
stencil along j, k ≈ same 331 → 298 (integer ops 130 → 92) 338 → 332 274 → 255 267 → 248
random gather same same same 182 → 118 182 → 118
  • Fully dynamic: for scalar indexing, LLVM already turns main's sum of strided terms into the Horner form (%reass.mul), so both versions give the same IR.
  • Fully static: main uses 3 shl instructions folded into the address; the PR adds the shifts together first. In native code that's 10 vs 11 instructions, with one extra mov.
  • Mixed patterns: this is where the counts differ, sometimes a lot, but the cycle count barely moves (see below).

Timing (PR/main)

Separate processes gave noisy results: the stencil times varied by up to 2× between runs of the same configuration. So I compared both formulas in one process, swapping the method back and forth 4 times. Bold cells moved in the same direction in every repetition.

kernel ddd sdd dsd dds ssd sds dss sss
loop with i innermost 1.00 1.03 0.97 0.97 1.00 1.00 1.00 1.00
loop with k innermost 1.01 1.00 1.00 0.99 0.98 0.99 1.00 0.99
stencil 0.96 1.02 1.00 1.00 1.00 0.90 1.00 1.01
loop with j innermost* 1.00 0.99 1.02 1.00 1.00 0.99 1.00 0.97
random gather* 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00

* These two kernels were timed in separate processes (3 per version), where they were stable to within about 2%.

  • Fully dynamic and fully static: within ±1%. The +1% on ddd with k innermost is consistent but tiny.
  • Mixed: the stencil on sds is 10% faster in every repetition. ssd with k innermost is 2% faster. sdd with i innermost might be 1–3% slower, but that signal is weak. The PR description reported up to 2.7% slowdown with k innermost for mixed patterns; I didn't reproduce it.
  • Random gather: the index can't be turned into pointer increments here, and the 35% fewer instructions for ssd/sss made no difference in time. The loads dominate.

The IR-count changes are big in places, but the measured effect is ±1–3%, apart from a 10% gain for the stencil on sds.

@giordano
giordano merged commit 98b7f3c into main Oct 21, 2026
19 checks passed
@giordano
giordano deleted the vc/horner-index branch October 21, 2026 13:41
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