This comes up a lot in my code style. I’ve been doing this thing which is to store methods inside a struct as closures. (sort of like oo programming I guess?). This is a lot more convenient than storing the captured values.
mutable struct ViralPopulation
time::Float64 # current time
fitness_total::Float64 # current fitness. Divided by cap gives φ
pop::Array{Virus,1} # State of population length is the total population size
capacity::Int # carrying capacity
r::Float64 # rate of decay of neutralized viruses
λ::Float64 # noise of system
fitness_function::Function
mutation_operator::Function
end
The mutation_operator is generated when the struct is initialized in a somewhat expensive operation:
function mutation_operator_closure(ab_profile_list, ν)
let mut0 = mapreduce(y -> map(x->x[1],y), vcat, ab_profile_list), #relatively expensive mallocs
mut1 = mapreduce(y -> map(x->x[2],y), vcat, ab_profile_list),
tot = sum(mut0) + sum(mut1)
# this is a let-block to capture a fixed
function mutate!(vp::ViralPopulation) # attempt to mutate a virus at index ind
if rand() < tot*ν # if there is a shot at mutation
ind = rand(1:length(vp.pop)) # choose a random virus
@inbounds v = vp.pop[ind]
#mutvec = mut[ (1:2:2*l) .+ bitarray(v.genotype,l)] # get the mutational parameters
ii = bitmask_toggle_choose(v.genotype, mut0, mut1, tot)
if ii > 0
# flippy the dippy and make a new virus
new_genotype = flip(v.genotype, 2^(ii-1))
new_fitness = vp.fitness_function(new_genotype)
new_escape = vp.escape_function(new_genotype)
vp.fitness_total += new_fitness - v.fitness
vp.pop[ind] = Virus(new_genotype,new_fitness,new_escape)
end
end
end
return mutate!
end
end
In profiling I get what looks like type inference issues in the part of my code when I call v.muation_operator:
Can I make my closure type secure, or do I have to send around the parameters as tuples? What do I need to do to score that last little bit of sweet sweet performance?
Q: Does this mean that every instance of ViralPopulation will create a new type? I mean I don’t have that many floating around, but a little worried about what happens when one of them goes out of scope… do types get gc’ed? “Each function is it’s own type.” Holy moly I have so many questions.
Yes, but note that the array there, which is possibly the only memory-hungry data involved, will be shared if you happen to change the functions of your population:
I’m not sure that they get GCed. If not, the type defs are small, so unless you got heaps and heaps, that is not an issue. At least it’s not an issue that I encountered.
@mauro3, thanks you saved me days of work, and taught me something cool.
Julia is a deceptively easy language. You can get a cool project working and fast in a couple days and still not fully understand functions and types after a couple years of work.
@lmiq I have to admit, I didn’t realize that pure structs also take arrays as references!
Honestly not 100% sure if in my case I should have the mutable here or not. .time gets updated in place, but I could also use a Ref{Float64}. I sort of feel a much more important distinction isn’t mutable but what isbits returns you know?
ps: Array{Virus,1} is the same as Vector{Virus}. Be careful that Virus should be a concrete type here, or that vector would be abstract as well. If Virus is abstract (has parameters, you should better parameterize the population struct as well for the type of Virus).
For these purposes, I’d recommend to not use a generic closure at all and define a callable type instead:
struct MutationOperator
mut0::Vector{Float64}
mut1::Vector{Float64}
tot::Float64
ν::Float64
end
function (op::MutationOperator)(vp::ViralPopulation)
mut0, mut1, tot, ν = op.mut0, op.mut1, op.tot, op.ν
if rand() < tot*ν # if there is a shot at mutation
ind = rand(1:length(vp.pop)) # choose a random virus
@inbounds v = vp.pop[ind]
ii = bitmask_toggle_choose(v.genotype, mut0, mut1, tot)
if ii > 0
# flippy the dippy and make a new virus
new_genotype = flip(v.genotype, 2^(ii-1))
new_fitness = vp.fitness_function(new_genotype)
new_escape = vp.escape_function(new_genotype)
vp.fitness_total += new_fitness - v.fitness
vp.pop[ind] = Virus(new_genotype,new_fitness,new_escape)
end
end
end
function MutationOperator(ab_profile_list, ν)
let mut0 = mapreduce(y -> map(x->x[1],y), vcat, ab_profile_list), #relatively expensive mallocs
mut1 = mapreduce(y -> map(x->x[2],y), vcat, ab_profile_list),
tot = sum(mut0) + sum(mut1)
return MutationOperator(mut0, mut1, tot, ν)
end
end
@Vasily_Pisarev that was definitely my reflex (well sort of, I didn’t know you could define functions that way! …but the struct for the dereferenced variables at least) when I thought about how to solve this. But it looks like, at least in this case, this function call is as fast as it can be after the annotation from @mauro3 !
I was worried I’d be paying compile costs every instance, but julia-1.6 @time shows no compilation after the first run and gc is ~1%, and I’m hitting the necessary rand() in profiling 98% of the time the closure is called. It looks like circa 1.6, the compiler understands closures well enough to handle this at top performance with just the right type annotation (and a let block of course). To me it is a more elegant solution.
Yes, that’s because the compiler generates almost the same callable type under the hood for the closure.
I think I see, yeah, and defining a struct certainly brings that warm fuzzy feeling of knowing exactly what is being allocated. And were I to rewrite my code from scratch, maybe the callable struct is nicer because the action at the function call is more transparent.
Either way it’s a really nice design pattern. You generally want to separate the dynamics from the integration control and confine your parameters to the dynamics. It’s great to see how Julia lets you do that with the flexibility of dynamic dispatch but with no loss of performance with sufficiently typed input. In the past, I passed gigantic structs around because of worries about closure performance. Now I have at least two ways to not do that, so thanks