Unexpected memory allocation behavior

Hello. I am using julia 1.7.1.

I am facing trouble trying to understand an unexpected memory allocation in the below simplified and self-contained program:

struct VerticalLine{I}
    i_min::I
    i_max::I
    j::I
end

struct ParallelVerticalLines{I}
    i::I
    j::I
    height::I
    width::I
end

function put_pixel!(image::AbstractMatrix, i, j, color)
    image[i, j] = color
    return nothing
end

function draw!(f::Function, image::AbstractMatrix, shape::VerticalLine, color)
    i_min = shape.i_min
    i_max = shape.i_max
    j = shape.j

    for i in i_min:i_max
        f(image, i, j, color)
    end

    return nothing
end

function draw!(f::Function, image::AbstractMatrix, shape::ParallelVerticalLines, color)
    i_min = shape.i
    i_max = shape.i + shape.height - one(shape.height)
    # i_max = shape.i

    j_min = shape.j
    j_max = shape.j + shape.width - one(shape.width)
    # j_max = shape.j

    shape1 = VerticalLine(i_min, i_max, j_min)
    draw!(f, image, shape1, color)

    shape2 = VerticalLine(i_min, i_max, j_max)
    draw!(f, image, shape2, color)

    return nothing
end

const shape = ParallelVerticalLines(1, 1, 3, 3)

const image1 = zeros(UInt32, 32, 32)
const color1 = 0x00ffffff

draw!(put_pixel!, image1, shape, color1)

println(@allocated draw!(put_pixel!, image1, shape, color1))

const image2 = falses(32, 32)
const color2 = true

draw!(put_pixel!, image2, shape, color2)

println(@allocated draw!(put_pixel!, image2, shape, color2))

Here are my observations that are puzzling to me:

  1. The draw! function allocates memory in the UInt32 case but not in the Bool case.
  2. When I comment out i_max = shape.i + shape.height - one(shape.height) and j_max = shape.j + shape.width - one(shape.width), and replace them with i_max = shape.i and j_max = shape.j respectively, the memory allocation vanishes.
  3. When I comment out shape2 = VerticalLine(i_min, i_max, j_max) and draw!(f, image, shape2, color), the memory allocation again vanishes.
  4. @code_warntype draw!(put_pixel!, image1, VerticalLine(1, 3, 1), color1) and @code_warntype draw!(put_pixel!, image2, VerticalLine(1, 3, 1), color2) have a Union type inferred for an automatically named local variable. I don’t understand what this local variable is and why it is not inferred correctly.

@code_warntype draw!(put_pixel!, image1, shape, color1) and @code_warntype draw!(put_pixel!, image2, shape, color2) shows that all types seem to be inferred. I don’t see any obvious type instability that might be causing the above issues.

It would be great if someone could help me explain the reasons behind my observations. Thanks!

Here is the output of versioninfo(), if needed:

julia> versioninfo()
Julia Version 1.7.1
Commit ac5cc99908 (2021-12-22 19:35 UTC)
Platform Info:
  OS: Linux (x86_64-pc-linux-gnu)
  CPU: Intel(R) Core(TM) i7-6500U CPU @ 2.50GHz
  WORD_SIZE: 64
  LIBM: libopenlibm
  LLVM: libLLVM-12.0.1 (ORCJIT, skylake)

Try the above, because of

https://docs.julialang.org/en/v1/manual/performance-tips/#Be-aware-of-when-Julia-avoids-specializing

(didn’t test)

4 Likes

I get a warning because you define image1 and image2 as const and later assign values to them. Removing the const changes the picture, both allocate

  340.092 ns (4 allocations: 96 bytes)
  321.888 ns (2 allocations: 64 bytes)

Still puzzling why the number of allocations changes, same for the number of bytes in relation to UInt32 and Bool.

Thanks a lot for your help @lmiq ! Doing as you suggested doesn’t allocate any memory.

1 Like

Hello @Bardo , I am running the program as a script, where I do not get the warning you mention. The warning might occur in case you include the script more than once in the REPL.

I am only assigning image1 and image2 once in the script, and that is while defining them.

Bingo.

@lmiq How does your type hint help here? - F is not used elsewhere.
How could one have found this solution?

I found that not long time ago by asking something similar here (but it is on the performance tips, as I llinked above).

That is a special case of non-specialization: the function is just passed around to another function. Since functions have each their own type, having a specialized method (a whole compilation) for each type of function would be counter-productive. Thus, the default is to not specialize, and that probably boxes the function, creating the allocation. In terms of performance that is probably harmless, unless in very special cases where the function is passed around to another function within a critical loop (and even there, I don’t think it had a measurable performance penalty in my case). But, if one wants the function to specialize to every different type of function input, the parameterization of the type (::F where F syntax) is a way to force that, and it is fine except if the use case expects many different functions to be passed around.

3 Likes