Allocation when assigning to a slice

Hi,
Why does the second version allocate ? Is there a clean way to do that without allocating ?

z = collect(1:10000)

function test1(z)
    z .-= 1
end
4.898 μs (0 allocations: 0 bytes)

function test2(z)
    z[56:end] .-= 1
end
20.458 μs (3 allocations: 77.84 KiB)

function test3(z)
    for i in 56:length(z)
        z[i] -= 1
    end
end
14.115 μs (0 allocations: 0 bytes)

solves the allocations (although I think that shouldn’t be necessary)

1 Like

It’s totally necessary, because:

z[56:end] .-= 1

is equivalent to

z[56:end] .= z[56:end] .- 1

and the slice z[56:end] on the right-hand side allocates a copy.

I also find it a lot clearer in most cases to just put @views on the whole function rather than on individual lines.

@views function test4(z)
    z[56:end] .-= 1
end

giving

julia> @btime test4($z);
  1.681 μs (0 allocations: 0 bytes)
3 Likes

Why not to

z[56:end] .= @view(z[56:end]) .- 1

which does not allocate? That is my point, in general the syntax is such that slices on the left do not create copies.

Thanks for the insightful answer

this feels like an optimization we should do since this is pretty annoying if not counter intuitive

3 Likes

Because A -= B in Julia has always been equivalent to A = A - B, and .-= is the same?

1 Like

Of course that is an argument, but slices on the left are different, so that could be different as well. But it may be that parsing that becomes impossible when one goes to more general cases.

It would be a breaking change. e.g. it would change the answer in:

x = [3,2,1]
x[1:2] .-= sort!(x)[1:2]

There was a long debate early in the history of Julia about whether we should change slices to default to views (range indexing should produce a subarray, not a copy · Issue #3701 · JuliaLang/julia · GitHub). Ultimately it was decided not to change because (a) attempting to change the default turned out to be a source of too many subtle breaking bugs, (b) it wasn’t always a performance win, and (c) the introduction of @views made it relatively painless to opt-in for a large block of code, e.g. a whole function.

4 Likes

Simpler one:

julia> x = [1,2,3]
3-element Vector{Int64}:
 1
 2
 3

julia> x[2:-1:1] .= @view(x[1:2])
2-element view(::Vector{Int64}, 2:-1:1) with eltype Int64:
 1
 2

julia> x[2:-1:1] .= x[1:2]
2-element view(::Vector{Int64}, 2:-1:1) with eltype Int64:
 2
 1

(and not only breaking, but possibly completely unpredictable :slight_smile: ).

2 Likes

That being said, it’s possible that the compiler could at some point recognize that it can safely replace copies with views in certain cases, as commented by @StefanKarpinski here. Doing so would probably privilege “built-in” array types and functions that the compiler can analyze easily, however, and the design of Julia has always favored optimizations (e.g. loop fusing for dot operations) and syntax (e.g. @views) that apply equally well to user-constructed types and functions.

4 Likes

I feel like the at least literal values on the rhs of .+= should be optimized. Even if we just do a patchy work in, say, parser/lowering stage would be helpful.

Hard case that requires analysis in compiler passes can be deferred

2 Likes

There’s not much that can be special-cased for array literals during lowering since vect is just a generic function.

I find it hard to believe that array .+= literal is a performance-critical step in much real-world code, to the point of being worth special-casing in the compiler.

2 Likes