JuliaArrays / JuliaArrays/BlockArrays.jl
Broadcasting fails for an interior zero-length block: `combine_blockaxes` drops it
Nobody has claimed this yet.
- Dominant language
- Julia
- Stars
- 254
- Forks
- 39
- PR merge metrics
- No merged PRs in 30d
Description
Summary
Broadcasting over BlockArrays throws a BoundsError when a block axis contains a zero-length block that is neither first nor last. The underlying cause is that combine_blockaxes is not idempotent for such axes: it deduplicates blocklasts, which silently deletes the zero-length block.
julia> using BlockArrays
julia> ax = blockedrange([1, 0, 2])
BlockedOneTo([1, 1, 3])
julia> BlockArrays.combine_blockaxes(ax, ax) # combining an axis with itself
BlockedOneTo([1, 3]) # ...is not the identity
julia> BlockArrays.blockisequal(ax, BlockArrays.combine_blockaxes(ax, ax))
false
Reproducer
using BlockArrays
for sizes in ([1, 0, 2], [1, 2, 0], [0, 1, 2], [1, 2])
A = BlockArray(zeros(sum(sizes), 1), sizes, [1])
B = BlockArray(ones(sum(sizes), 1), sizes, [1])
@show sizes, (try (A .+ B; :ok) catch e; typeof(e) end)
end
# (sizes, res) = ([1, 0, 2], BoundsError) <-- interior zero-length block
# (sizes, res) = ([1, 2, 0], :ok)
# (sizes, res) = ([0, 1, 2], :ok)
# (sizes, res) = ([1, 2], :ok)
BoundsError: attempt to access 0-element UnitRange{Int64} at index [1:2]
[1] throw_boundserror(::UnitRange{Int64}, ::Tuple{UnitRange{Int64}}) @ Base essentials.jl:15
[9] _bview(::BlockMatrix, ::BlockIndexRange, ::BlockIndexRange) @ BlockArrays blockbroadcast.jl:129
[12] _generic_blockbroadcast_copyto! @ BlockArrays blockbroadcast.jl:167
[14] _generic_blockbroadcast_copyto! @ BlockArrays blockbroadcast.jl:153
It affects the row axis, the column axis and vectors alike, and any broadcast entry point — A .+ B, A + B, Broadcast.materialize!. Non-broadcast paths are fine: copyto!(A, B) works, A[Block(2), Block(1)] and A[Block(3), Block(1)] return correctly sized blocks, and Matrix(A) .+ Matrix(B) works.
BlockArrays 1.10.0, Julia 1.12.6.
Cause
blockbroadcast.jl:42-43:
combine_blockaxes(a, b) = _BlockedUnitRange(sortedunion(blocklasts(a), blocklasts(b)))
combine_blockaxes(a::BlockedOneTo, b::BlockedOneTo) = BlockedOneTo(sortedunion(blocklasts(a), blocklasts(b)))
A zero-length block shows up as a repeated entry in blocklasts ([1, 0, 2] → blocklasts == [1, 1, 3]), and sortedunion collapses the duplicate. So the combined axis has one block fewer than the operands.
_generic_blockbroadcast_copyto! (:141-170) then goes wrong in two ways. blockisequal(axes(dest), bs) is false even though every operand has exactly the same axis, so it re-wraps in BlockedArray(dest, bs) and recurses; and the sub-block iteration is misaligned against the operands' real block structure:
A = BlockArray(zeros(3, 1), [1, 0, 2], [1])
ax = blockedrange([1, 0, 2])
collect(BlockArrays.SubBlockIterator(A, (ax, axes(A, 2)), 1))
# Block(1)[1:1], Block(2)[1:0], Block(3)[1:2] <-- correct
collect(BlockArrays.SubBlockIterator(A, (BlockArrays.combine_blockaxes(ax, ax), axes(A, 2)), 1))
# Block(1)[1:1], Block(2)[1:2] <-- Block(2) of A is the *empty* block
That last Block(2)[1:2] — indexing the zero-length block with 1:2 — is the BoundsError.
Suggested fix
Make combine_blockaxes preserve zero-length blocks, i.e. use a merge that keeps duplicate blocklasts entries when both inputs have them, rather than a set union. combine_blockaxes(ax, ax) == ax seems a reasonable invariant to hold for any ax, and a regression test asserting it for blockedrange([1, 0, 2]) would cover this class of bug.
If dropping empty blocks is deliberate, then _generic_blockbroadcast_copyto! needs to reconcile the operands' block structure with the combined one instead of assuming they agree — but the collapse also makes broadcasting silently change an array's block structure, which seems undesirable on its own.
Context
Hit via TensorKit/BlockTensorKit, where a graded SumSpace whose summands carry different sectors routinely produces exactly this axis shape: for a given coupled sector one summand contributes zero dimensions while the summands on either side contribute nonzero ones.
Reported downstream at QuantumKitHub/BlockTensorKit.jl#74.
Possibly related: #203 (handling empty blocks in mortar).
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with blockbroadcast.jl:42-43 and the _generic_blockbroadcast_copyto! path around lines 141-170, then run the reproducer for blockedrange([1, 0, 2]). Verify that combine_blockaxes preserves the interior empty block and remains idempotent, and add a regression test covering broadcasting over the affected block shapes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- julia
- Domain
- data
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100