Skip to content

Fix getindex invalidation from AbstractArray + BlockIndices#499

Merged
dlfivefifty merged 2 commits intoJuliaArrays:masterfrom
ChrisRackauckas-Claude:fix-getindex-invalidation
Feb 27, 2026
Merged

Fix getindex invalidation from AbstractArray + BlockIndices#499
dlfivefifty merged 2 commits intoJuliaArrays:masterfrom
ChrisRackauckas-Claude:fix-getindex-invalidation

Conversation

@ChrisRackauckas-Claude
Copy link
Contributor

Summary

  • getindex(::AbstractArray{T,N}, ::BlockIndices{N}) caused 12,082 method invalidations when loading BlockArrays
  • Removed the AbstractArray fallback and added specialized unblock methods in views.jl that handle BlockIndices on non-blocked axes (e.g. Base.OneTo) by decomposing into block-level + sub-indexing
  • The LayoutArray specializations (covering all AbstractBlockArray types) and AbstractBlockedUnitRange methods are unchanged

Context

When profiling the TTFX of ModelingToolkit's DAE pipeline, BlockArrays was identified as the #2 source of method invalidations. The getindex(::AbstractArray{T,N}, ::BlockIndices{N}) method invalidated compiled instances of getindex for all AbstractArray types because BlockIndices <: AbstractArray, triggering recompilation of fancy-indexing paths.

Changes

  • src/blockaxis.jl: Removed getindex(b::AbstractArray{T,N}, K::BlockIndices{N}) fallback (line 10)
  • src/views.jl: Added two unblock specializations:
    • For AbstractUnitRange{<:Integer} axes: decomposes BlockIndices into block() + regular sub-indices
    • For AbstractBlockedUnitRange axes: preserves original direct-indexing behavior

Design rationale

The AbstractArray fallback was needed because unblock in to_indices recursively calls getindex(axis, BlockIndices). For non-blocked axes like Base.OneTo, there was no handler, causing infinite recursion without the fallback. The fix intercepts at the unblock level instead, using getindex(::AbstractUnitRange, ::Block{1}) (which returns the whole range for non-blocked types) followed by standard sub-indexing.

Test plan

  • Full test suite passes locally (2,318 pass, 2 broken (pre-existing), 0 fail)

🤖 Generated with Claude Code

The method getindex(::AbstractArray{T,N}, ::BlockIndices{N}) caused
12,082 method invalidations when loading BlockArrays, because it
invalidated compiled instances of getindex for all AbstractArray types.

Remove the AbstractArray fallback and instead add specialized unblock
methods in views.jl that handle BlockIndices on non-blocked axes
(e.g. Base.OneTo) by decomposing into block-level indexing followed by
sub-indexing. The existing getindex(::AbstractUnitRange, ::Block{1})
handles the block-level step, avoiding the recursive dispatch that
previously required the AbstractArray fallback.

The LayoutArray specializations (which cover all AbstractBlockArray
types) and the AbstractBlockedUnitRange methods remain unchanged.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@dlfivefifty
Copy link
Member

@ChrisRackauckas looks fine assuming the tests pass but I had two comments:

  1. Ideally there would be a test to avoid a future regression
  2. Are we confident that it doesn't cause a (runtime) performance regression?

@ChrisRackauckas
Copy link
Member

There aren't great ways to test for invalidations.

The previous commit removed the broad getindex(::AbstractArray, ::BlockIndices)
method but this caused a regression for OneTo[BlockIndexRange] which went from
returning UnitRange (O(1)) to Vector (O(n)) through the Base._getindex path.

Add a narrow getindex(::AbstractUnitRange{<:Integer}, ::BlockIndices{1}) that
handles plain ranges efficiently without causing the mass invalidation that the
AbstractArray method caused. AbstractBlockedUnitRange has its own more-specific
method that takes precedence.

Benchmarks show no regression on any path:
- OneTo[BlockIndexRange]: identical perf and return type (UnitRange)
- Vector[BlockIndexRange]: 2x faster (82ns vs 167ns, halved allocations)
- BlockArray ops: identical
- mul! operations: identical

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude
Copy link
Contributor Author

Runtime Performance Benchmarks

I ran comprehensive benchmarks comparing the fix against the original code (Julia 1.12.4). All benchmarks use BenchmarkTools.@benchmark with 3s per benchmark.

Summary: No regression anywhere. 2x speedup on one path.

Benchmark Original (ns) Fixed (ns) allocs bytes Return Type
Unchanged paths (LayoutArray / Block):
BA[Block(2)[1:10], Block(1)[1:20]] 560 494 2 1680 Same
BA[Block(2,1)] 1100 1101 3 10136 Same
BlockedAxis[Block(2)[1:10]] 46 47 1 32 Same (UnitRange)
BlockedAxis[Block(2)] 47 46 1 32 Same (UnitRange)
Changed paths (new unblock + UnitRange method):
OneTo(100)[Block(1)[1:50]] 46 49 1 32 Same (UnitRange)
OneTo(100)[Block(1)[10:90]] 45 46 1 32 Same (UnitRange)
OneTo(1000)[Block(1)[1:500]] 45 46 1 32 Same (UnitRange)
Vector(100)[Block(1)[1:50]] 167 82 2 (was 4) 480 (was 1408) Same (Vector)
view(A,100x50)[Block(1)[..]] 298 337 2 1680 Same (Matrix)
End-to-end operations:
mul!(view(C,:,1:50), A, B, 1, 2) 27050 26800 7 40280 Same
mul!(BlockArray, BA, BA) 68420 68170 1 32 Same
sum(BlockMatrix(100x100)) 135059 135329 1 16 Same
norm(BlockVector(100)) 908 950 2 48 Same

Key observations:

  1. All existing paths are within noise — BlockArray indexing, BlockedUnitRange indexing, views, mul!, reductions
  2. OneTo[BlockIndexRange] is identical — same ~46ns timing, same return type (UnitRange{Int64}), same allocations (1, 32 bytes). This is handled by the new narrow getindex(::AbstractUnitRange{<:Integer}, ::BlockIndices{1}) method
  3. Vector[BlockIndexRange] is 2x faster — 167ns → 82ns. The original code did Vector[Block(1)][1:50] which copies the entire vector first, then sub-indexes. The new to_indices path indexes directly with a BlockSlice, avoiding the intermediate full copy
  4. Correctness checks all passOneTo(n)[Block(1)[a:b]] == a:b, Vector[Block(1)[1:50]] == Vector[1:50], etc.

Design notes:

The initial version of this PR caused a regression for OneTo[BlockIndexRange] (returned Vector instead of UnitRange with O(n) allocation). This was fixed by adding a narrow getindex(::AbstractUnitRange{<:Integer}, ::BlockIndices{1}) method which is specific enough to avoid the mass invalidation that the original getindex(::AbstractArray{T,N}, ::BlockIndices{N}) caused, while preserving the efficient decomposition into block() + sub-indices.

Types that are not LayoutArray (and thus affected by the change): Vector, Matrix, SubArray, UnitRange, OneTo, and all other non-block arrays. Only AbstractBlockArray subtypes are LayoutArray.

Benchmark script
using BlockArrays, LinearAlgebra, BenchmarkTools
BenchmarkTools.DEFAULT_PARAMETERS.seconds = 3

A_med = BlockArray(rand(100, 100), [25, 25, 25, 25], [50, 50])
v_med = BlockArray(rand(100), [25, 25, 25, 25])
ax_med = axes(A_med, 1)
ot100 = Base.OneTo(100)
ot1000 = Base.OneTo(1000)
R_med = rand(100, 100)
r_med = rand(100)
V_med = view(Array(A_med), :, 1:50)

function run_bench(label, f)
    f(); val = f(); T = typeof(val)
    b = @benchmark $f()
    t = round(Int, median(b).time)
    println("$(rpad(label, 52)) $(lpad(t, 8))  $(b.allocs)  $(b.memory)  $T")
end

run_bench("BA[Block(2)[1:10], Block(1)[1:20]]", () -> A_med[Block(2)[1:10], Block(1)[1:20]])
run_bench("BA[Block(2,1)]", () -> A_med[Block(2,1)])
run_bench("BlockedAxis[Block(2)[1:10]]", () -> ax_med[Block(2)[1:10]])
run_bench("OneTo(100)[Block(1)[1:50]]", () -> ot100[Block(1)[1:50]])
run_bench("OneTo(1000)[Block(1)[1:500]]", () -> ot1000[Block(1)[1:500]])
run_bench("Vector(100)[Block(1)[1:50]]", () -> r_med[Block(1)[1:50]])
run_bench("view(A,100x50)[Block(1)[1:10],Block(1)[1:20]]", () -> V_med[Block(1)[1:10], Block(1)[1:20]])
C = rand(100,50); A = rand(100,50); B = rand(50,50)
run_bench("mul!(view(C,:,1:50), A, B, 1, 2)", () -> mul!(view(copy(C),:,1:50), A, B, 1, 2))

@ChrisRackauckas
Copy link
Member

Seems like there is no regression from this and it passes tests.

@codecov
Copy link

codecov bot commented Feb 27, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.39%. Comparing base (7ebbea7) to head (3cef108).
⚠️ Report is 8 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #499      +/-   ##
==========================================
- Coverage   94.52%   94.39%   -0.13%     
==========================================
  Files          19       19              
  Lines        1789     1819      +30     
==========================================
+ Hits         1691     1717      +26     
- Misses         98      102       +4     

☔ View full report in Codecov by Sentry.
📢 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.

@dlfivefifty
Copy link
Member

The performance benchmark tests are amazing! Do I need to pay for Claude to do this myself?

@dlfivefifty dlfivefifty merged commit 69a37e8 into JuliaArrays:master Feb 27, 2026
17 of 18 checks passed
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