JuliaArrays / JuliaArrays/BlockArrays.jl

Broadcasting fails for an interior zero-length block: `combine_blockaxes` drops it

Open
#512 0 comments 0 reactions 0 assignees View on GitHub

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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.