Repository navigation
Add O(1) fast path to AbstractVectorOfArray getindex - #668
ChrisRackauckas-Claude wants to merge 3 commits into
Conversation
getindex(A::AbstractVectorOfArray, I::Vararg{Int,N}) bounds-checked via
checkbounds(A, I...), which calls size(A). size(A) scans every inner
array (maximum(size(u,d) for u in VA.u)) to find the ragged maximum,
so every scalar getindex was O(n) in the number of inner arrays.
Add a fast path: if the index is inside the selected inner array's own
bounds, it is always a valid index into A, so return it directly
without computing size(A). Falls back to the existing checkbounds +
zero-padding path for anything outside the selected inner array's own
bounds (truly out-of-range indices, and ragged reads that must return
zero), so behavior for ragged arrays is unchanged. N == 1 (where u's
elements are themselves scalars) skips the fast path since size(A) is
already O(1) there.
At n=1000 inner arrays: A[i,j] 1219ns -> 4.4ns; scalar iteration over
the whole array 2.42ms -> 4.4us (both Julia 1.10 and 1.12).
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.294
Agent-Model: claude-sonnet-5
Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Round-1 review (Cursor, pr-668/round1): the fast path used checkbounds(Bool, u_col, inner_I...), which respects the selected column's own axes. AbstractVectorOfArray's index contract is 1-based over size(A, d) regardless of the inner array's own axes, so for an OffsetArray column (or any non-1-based inner array) this wrongly accepted indices that must throw -- e.g. O0[0,1] with inner arrays on axes 0:2 returned a value instead of BoundsError, same for O2[4,1] on axes 2:4. Replace the checkbounds(u_col, ...) check with an explicit 1-based bound against size(u_col, d): size(u_col, d) <= size(A, d) always holds, so this is a sufficient in-bounds check that matches the slow path's contract exactly, without touching the inner array's own axes at all. Falls back to the existing checkbounds(A, I...) path -- unaffected by this change -- for anything the fast check doesn't clear, so the known OffsetArray garbage-read issue (SciML#675) is neither fixed nor worsened here. Verified against master bit-for-bit on both Julia 1.10 and 1.12: O0[0,1] and O2[4,1] both throw BoundsError (matching master exactly, previously returned 1.0/3.0); in-bounds OffsetArray reads return the same (garbage, per SciML#675) values as master. The plain-Array win is unaffected: A[i,j] at n=1000 is still 1219ns -> 4.4ns. Also: the master CountingVec call count is 1001 (not 1000 as the PR body said) -- one extra size() call from the ragged zero-pad check on the selected column; the existing `counter[] < n` test already covers this correctly in either direction. A minimal OffsetVec test type (avoiding a new OffsetArrays dependency) with O0[0,1]/O2[4,1] regression tests is added to test/Core/basic_indexing.jl, reproducing the review's finding against round 1 (0/2 pass) and passing against this fix (2/2) on both Julia versions. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.294 Agent-Model: claude-sonnet-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Round-2 review (Grok 4.7, pr-668/round2): the column gate followed
checkbounds(Bool, u, col), which respects VA.u's own axes. The
constructors (VectorOfArray(vec::AbstractVector{VT}) and the
AbstractArray overload) store a non-1-based container (e.g. an
OffsetVector) as-is, while A's own column dimension is 1-based over
length(VA.u) via size/checkbounds(A, I...). So for a container with
axes 0:3 or 2:5, a column outside 1:length(u) (but still inside
axes(u)) wrongly returned a value instead of throwing -- the same
mistake round 2 fixed for inner arrays, one level up, for the
container itself.
Add 1 <= col <= length(u) to the fast-path gate, alongside the
existing checkbounds(Bool, u, col) (both are still needed: a column
inside 1:length(u) but outside u's own axes -- e.g. column 1 on a
container with axes 2:5 -- must still throw, which it does because
checkbounds(Bool, u, col) catches it and the fallback's own u[col]
throws natively). Both checks are O(1), so the fast path keeps its
cost.
Verified bit-for-bit against master on both Julia 1.10 and 1.12 across
the reviewer's full grid: columns outside 1:length(u) (C0[1,0],
C0[2,0], C0[CartesianIndex(1,0)], C2[1,5], C2[2,5]) throw BoundsError,
matching master exactly (round-2 code returned 1.0/2.0/1.0/7.0/8.0 for
these); columns inside 1:length(u) but outside axes(u) (C2[1,1],
C0[1,4], C2[1,6]) still throw, matching master; columns inside both
return the stored value, matching master (C0[1,1]==3.0, C0[1,3]==7.0,
C2[1,2]==1.0, C2[1,4]==5.0). The plain-Array win is unaffected:
A[i,j] at n=1000 is still 1219ns -> 4.4ns.
Also tightens the existing O(1) call-count regression test from
`counter[] < n` to the measured exact count (`== 1`), per the
review's non-blocking note that `< n` is a weak lock (any count up to
999 would also pass).
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Agent-Harness: Claude Code 2.1.294
Agent-Model: claude-sonnet-5
Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Independent review (Devin CLI 3000.11.3, model fusion-claude-opus-5-5-high-sidekick-swe-2-medium): MERGE, risk medium. Full reviewVERDICT: MERGE Medium rather than low: this is the hot scalar Blocking findingsNone. Non-blocking findings
What I ranAll runs used Julia 1.12.7 unless marked LTS (1.10.12). TMPDIR and scratch were under the review directory.
What I did not verify
Links
🤖 Posted by an AI agent — harness: Devin CLI 3000.11.3 (review), Claude Code 2.1.285 (posting) · model: fusion-claude-opus-5-5-high-sidekick-swe-2-medium |
What changed and why
getindex(A::AbstractVectorOfArray{T,N}, I::Vararg{Int,N})(src/vector_of_array.jl) bounds-checks via@boundscheck checkbounds(A, I...), andcheckbounds(A, I...)callssize(A).size(A)is(maximum(size(u,d) for u in VA.u for d in 1:N-1)..., length(VA.u))— it scans every inner array to find the ragged maximum. So every scalarA[i, j]wasO(n)in the number of inner arrays, even for the overwhelmingly common non-ragged case.Added a fast path: if the index is inside the selected inner array's own bounds, it is always a valid index into
A, so return directly without computingsize(A). Anything outside the selected inner array's own bounds (a genuinely out-of-range index, or a ragged read that must return zero) falls back to the existingcheckbounds(A, I...)+ zero-padding path unchanged.N == 1, whereu's elements are scalars rather than arrays, skips the fast path sincesize(A)is already O(1) there.Round 2 (addressing Cursor's review): the round-1 fast path checked bounds with
checkbounds(Bool, u_col, inner_I...), which respects the selected column's own axes rather thanA's 1-based contract. Fixed by checking1 <= inner_I[d] <= size(u_col, d)directly, which never touches the inner array's own axes.Round 3 (this update, addressing Grok 4.7's review): round 2 fixed the inner-array axis mistake but left the identical mistake one level up, in the column gate:
checkbounds(Bool, u, col)respectsVA.u's own axes, butVectorOfArray(vec::AbstractVector{VT})and theAbstractArrayconstructor overload store a non-1-based container (e.g. anOffsetVector) as-is, whileA's own column dimension is 1-based overlength(VA.u)(viasize(A)/checkbounds(A, I...)). So for a container with axes0:3or2:5, a column outside1:length(u)(but still inside the container's ownaxes(u)) wrongly returned a value instead of throwing. Fixed by adding1 <= col <= length(u)to the gate, alongside the existingcheckbounds(Bool, u, col)— both checks are still needed: a column inside1:length(u)but outside the container's own axes (e.g. column1on a container with axes2:5) must still throw, which it does becausecheckbounds(Bool, u, col)catches that case and the fallback's ownu[col]throws natively there too, matching master.Before/after
n = 1000inner arrays,Vector{Float64}of length 2 each (Julia 1.12.7; 1.10.12 numbers are within noise of these):Ragged correctness unchanged throughout all three rounds (
R[3,2] == 0.0zero-pad,R[4,1]/R[1,4]BoundsError).Round-3 verification — bit-for-bit identical to master on both Julia 1.10.12 and 1.12.7, across the reviewer's full grid (a
VectorOfArraybuilt directly over a non-1-basedOffsetVector-style container of fourVector{Float64}columns,C0on axes0:3,C2on axes2:5):Failing-before / passing-after
Round 2's test (
getindex O(1) fast path keeps BoundsError/ragged semantics): tightened the call-count assertion fromcounter[] < nto the measured exact countcounter[] == 1, per the round-2 review's non-blocking note that< nis a weak lock (anything up to 999 would also have passed).10/10pass on this branch.Round 2's
OffsetVecinner-array test (getindex fast path rejects a non-1-based inner axis (OffsetArray)): unaffected by round 3, still2/2.New round-3 test —
test/Core/basic_indexing.jl::"getindex fast path rejects a non-1-based VA.u container", covering the reviewer's exact grid above:Before (round-2 code, commit
1a353f1, kept the new test):(5 failures: exactly the 5 out-of-
1:length(u)column cases that round 2 got wrong)After (this commit,
bf2e5fe):12/12pass, on both Julia 1.10.12 and 1.12.7.Full
GROUP=Corerun (both Julia versions, after round 3): all testsets green.GROUP=QAon 1.12.7: 20/20 pass.GROUP=QAon 1.10.12 hits the same pre-existing, unrelatedAquaambiguity failure documented in #667 (confirmed present on unmodified master). Runic and typos: clean on both changed files.What was not verified
getindexfast path with no public-API surface change.JLArray/CuArray) were not benchmarked.src/vector_of_array.jl/test/Core/basic_indexing.jlbut different methods — no semantic conflict expected, not tested.Anything a reviewer should push back on
A's 1-based contract) at two different levels of this one function — the inner array in round 2, the container in round 3. Worth a careful third look for whether there's a third place the same assumption is hiding (I don't see one — the function only indexesuonce bycolandu_colonce byinner_I, which is now exactly two gates — but two misses in a row on the same kind of mistake is a reason to look again rather than assume round 3 is the last one).Please ignore until reviewed by @ChrisRackauckas.
Risk assessment
src/vector_of_array.jl, onegetindexmethod used throughout scalarAbstractVectorOfArray/AbstractDiffEqArrayindexing (so touches a hot path), but the change is a pure fast-path addition that falls back to the existing, unmodified logic whenever it doesn't apply — no behavior change for any input, now verified against both OffsetArray inners and OffsetVector containers. No public API signature change.GROUP=Corefull pass on Julia 1.10 and 1.12.🤖 Generated with Claude Code
https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv