Mutable struct vs closure

The length of Julia’s IR/LLVM IR/Assembly doesn’t really directly determine complexity/better/worse. You gotta benchmark to see any effect unless you know that particular IR and your machine very well. All of these forms can call functions which can hide such complexity — and in this case I’d guess the inlined version and the impure version are probably performing about the same (definitely not much much worse).

Now, yes, in this case inlining and constant propagation didn’t work because you’re accessing (and modifying!) an array of arrays with non-constant values. I’d give up on the cache and just code it straight — it’s easier to see bugs this way, too:

       function binomsum(n::Int, i::Int)
           s = 0
           for j in 0:i-1 # was this off-by-one intentional?
               s += binomial(n, j)
           end
           s
       end

Now, I think you may be able to mark this one Base.@pure. If you do so you get the same effect. If binomial got @inlined, then it can constant-propagate, but even still we aren’t using constants in our for-loop there. Probably easier to just @memoize things and not worry about the lookup branch unless you see it coming up as a major factor in a profiling run.

1 Like

Nope, at least not as far as multi-threading goes:

julia> Base.@pure function binomsum(n::Int, i::Int)
                  s = 0
                  for j in 0:i-1 # was this off-by-one intentional?
                      s += binomial(n, j)
                  end
                  s
              end

julia> Base.Threads.@threads for i ∈ 1:16
           println(binomsum(i,2))
       end
8
9
signal (11): Segmentation fault
in expression starting at no file:0
jl_apply_generic at /usr/bin/../lib/libjulia.so.1 (unknown line)
unknown function (ip: 0x7f544cd920cd)
unknown function (ip: 0xffffffffffffffff)
Allocations: 18824129 (Pool: 18819991; Big: 4138); GC: 55
Segmentation fault (core dumped)

Yup. See? Don’t use @pure. I can’t get it right, either. Mostly because my name doesn’t happen to be Jameson.

4 Likes

If @pure can’t be used, there needs to be some other way of ensuring the constant propagation.

And yes, the off by one was intentional, because I needed to subtract 1 later anyway, so it saves a step.

binomial can throw an OverflowError; that seems like it could be (part of) the reason that @pure isn’t allowed here.

1 Like

Here’s how you can get constant propagation working:

# Mark binomial as `@inline` using the definition from Base:
julia> @eval Base @inline function binomial(n::T, k::T) where T<:Integer
           ...
       end

# Define `binomsum` with some of the loops unrolled — this could be done via metaprogramming
julia> @inline function binomsum(n, i)
           i == 0 && return 0
           s = binomial(n, 0)
           i == 1 && return s
           s += binomial(n, 1)
           i == 2 && return s
           s += binomial(n, 2)
           i == 3 && return s
           s += binomial(n, 3)
           i == 4 && return s
           for j in 4:i-1
               s += binomial(n, j)
           end
           s
       end
binomsum (generic function with 1 method)

julia> f() = binomsum(5, 2)
f (generic function with 1 method)

julia> f()
6

julia> @code_warntype f()
Body::Int64
1 ─     return 6

This makes for a monstrously huge function body, though. It’d be nice if there were a better way.

1 Like

Your segfault comes from println, which is not threadsafe.

@mbauman’s binomsum is almost @pure, except for the unfortunate side-effect that binomial decides to throw overflow errors on some inputs. I think this precludes @pure?

Without this execution path, the compiler would probably figure out that it is @pure without any “helpful” annotation.

Someone should maybe implement unsafe_binomial without overflow checking, or return -1 as a guard value for overflowing things.

1 Like

Alternatively, would using a shared array or distributed array resolve the issue with array access?

To quote Tim:

help?> Base.@pure
  @pure ex
  @pure(ex)

  @pure gives the compiler a hint for the definition of a pure function,
  helping for type inference.

  A function is pure if and only if Jameson Nash says that it is pure.
2 Likes

Now that I understand this, I found that the cached version is actually thread safe.

That issue can easily be resolved simply by making sure that binomsum gets called for the needed value of n at least once before it is used in multiple threads. If the method has been called once for the relevant value of n, then the method is thread safe since it doesn’t need to extend the array in that case. In my case, it would be enough to simply call binomsum(16,1) once at the beginning, and then all other calls should be thread safe (since I only handle n<17 in my package).

const binomsum_cache = [[1]]
Base.@pure function binomsum(n::Int, i::Int)
    for k=length(binomsum_cache)+1:n
        push!(binomsum_cache, cumsum([binomial(k,q) for q=0:k]))
    end
    i ≠ 0 ? binomsum_cache[n][i] : 0
end

no problem with @threads without println

julia> binomsum(16,1);

julia> Base.Threads.@threads for i ∈ 2:16
           binomsum(i,2)
       end

One solution is to simply put binomsum(16,1) into the __init__() script, where 16 is the max value of n encountered. This ensures that issues with @pure and multi-threading are avoided.

Constant propagation. I know, it’s stupid :slight_smile:

julia> bsum(::Type{Val{N}}, ::Type{Val{K}}) where {N,K} =
       K == 0 ? 0 : N == 0 ? 1 : bsum(Val{N-1}, Val{min(K, N)}) + bsum(Val{N-1}, Val{K-1});

julia> bsum(Val{5}, Val{4})
26

julia> @code_llvm bsum(Val{5}, Val{4})
define i64 @julia_bsum_12186(%jl_value_t addrspace(10)*, %jl_value_t addrspace(10)*) {
top:
  ret i64 26
}
1 Like

how could I inspect the generated layout of a closure? I mean, I could learn how to build struct <: Function then. thanks.

no, I mean I do not understand how that “copy constructor” was called in your example. “copy constructor” is new to me.

The phrase “copy constructor” is not used so much in Julia (it comes e.g. from C++), which is why I put it in quotes.

It means a constructor in which you pass in an argument of a given type and it constructs a new object of that type by copying the fields from the argument to the new object.

My point was that by default a Vector will not be copied if you do this; you will just have a reference to the same Vector as in the original object, so if you modify one the other gets modified too. You can use the copy function if you actually want a new copy.

1 Like

You can use all the reflection things for the type:

julia> tp = typeof(closuremaker())
getfield(Main, Symbol("#inc#6"))
julia> fieldnames(tp), fieldtypes(tp), tp.name, supertype(tp), tp.mutable
((:x,), (Core.Box,), getfield(Main, Symbol("#inc#6")), Function, false)

We see that inc is an immutable struct <: Function, with a fancyful name and a single member field x, of type Core.Box.

In order to see the methods, let’s look at:

julia> tp.name.mt
# 1 method for generic function "(::inc)":
[1] (::getfield(Main, Symbol("#inc#6")))() in Main at REPL[78]:4

We see that inc has a single method without any arguments.

It would be nice if there was some pretty-printer for types in julia (give me a reconstructed julia-syntax like thing, with annotations for fieldoffsets and padding). Unfortunately I don’t know one :frowning: