Mutability, scope, and separation of concerns in library code

By: Julia on μβ

Re-posted from: https://matbesancon.xyz/post/2021-12-11-mutability-library/

It has been about a year since I joined the Zuse Institute
to work on optimization methods and computation.
One of the key projects of the first half of 2021 has been on building up
FrankWolfe.jl,
a framework for nonlinear optimization in Julia using Frank-Wolfe
methods. You can find a paper introducing the package here.
This was an opportunity to experiment with different design choices
for efficient, scalable, and flexible optimization tools
while keeping the code simple to read and close to the algorithms.

I will list down a few roads we went on, experimenting what reads and works best to achieve these goals.

Table of Contents

No mutability

Probably the simplest pattern to follow. It is also a good one when
the created objects are light, ideally stack-allocated.

This is typically the code you would write to get the leanest version
of the Frank-Wolfe algorithm:

x = initialize(feasible_set)
while !terminated
    direction = grad(x)
    v = compute_extreme_point(feasible_set, direction)
    γ = find_stepsize(stepsize_strategy, x, v)
    x = x + γ * (v - x)
    terminated = check_termination(x)
end

This program is highly generic, users can define their own
grad function, and typically implement
compute_extreme_point and find_stepsize methods for their custom
feasible set and step size strategy types.
If you push it further, you can use a custom abstract vector type
for x and v.
Not a vector in the programming sense, you can use weird vector spaces
as long as addition and scaling are defined.

What would be a problem then? If you have seen high-performance code
before, you are probably screaming at the allocations happening all over the place. Every line is allocating a new object in memory,
first this direction, then the extreme point v.
The worst might be the x update step which allocates three vectors
because of intermediate expressions.
If you come from another performance-enabling programming environment
(Fortran, C, C++, Rust), what I am saying is probably obvious.
If you come from interpreted languages like Python or R, you may wonder why bothering about these? If you do not need performance, indeed maybe you shouldn’t bother but when developing a library, users will probably expect not
to have to rewrite your code for a larger-scale use case.
Also, these interpreted languages are typically slow across the board
when performing operations in the language itself and not moving them
to external kernels written in a compiled language (or being lucky with Numba).
In Julia, operations will typically be as fast as they can get if you pay
attention to minor things, so the bottleneck quickly becomes
the allocations of large objects.
The other thing people may oppose is that it is the role of the compiler
to take high-level expressions and reformulate them to avoid allocations.
This is a common argument among some functional programming circles,
everything is immutable because the compiler will figure everything out.
To some extent, this is true of course but pushing too much program
transformation to the compiler introduces some complexity
on all users, not just the ones focusing on performance.
You may typically get bitten by iterators methods (filter, map)
in Rust yielding a result of a custom type which changes if
a type annotation is given first.
Without this type annotation, when expecting a consistent type
to be inferred, one can get an error complaining about a weird
type generated by the chaining of all operations.
Finally, pushing this on the compiler means that you expect it to optimize
your code consistently and always in the way you would do it, because in
most cases “overriding” the compiler behaviour is far from trivial
and even verifying the decisions the compiler took will require inspecting
lowered emitted code (down to LLVM IR, assembly).

Finally, worrying about performance of the inner loop is also a consequence
of the nature of the algorithm itself: Frank-Wolfe, as typical for
first-order methods, will perform a lot of iterations that are relatively
cheap, as opposed to, say Interior Point Methods which will typically
converge in few iterations but with each one of them doing significant
work. In the latter case, allocating a few vectors might be fine because
linear algebra will dominate runtime, but not in FW where each
individual operation is relatively cheap compared to allocations.

Passing containers

This would be the typical signature of C functions, receiving almost all
heap-allocated containers as arguments.
A typical example would be replacing the gradient computation with:

grad!(storage, x)

which would compute the gradient at x in-place in the storage container.
Note the ! which is just a Julia idiom to indicate a function that mutates
one of its arguments. Adding storage arguments to function calls is also
used in Optim.jl
for the definition of a gradient or Hessian or in
DifferentialEquations.jl to pass the function describing a dynamic.

This has the strong advantage of making it possible for users to reduce
their memory consumption and runtime. This also means that composing calls
can be made performant: typically, library developers pay attention
to hot loops which they spend more time optimizing. But what if your main
top-level algorithm is someone else’s hot loop? Then they need to be able
to control that setup cost in some way.

Why not use these additional arguments and in-place notation everywhere then?

Consider the same gradient with multiple intermediate expressions that must be held in different structures, where should one hold the storage?
Adding more storage:

grad!(storage1, storage2, storage3, x)

means users would need to implement one with a very large number of arguments
all the time which they wouldn’t use.
Remember we cannot just bundle all the storage containers into one
because the “main” one is supposed to then contain the actual gradient
value at x.
Alternatively, all additional storage elements could be put as keyword arguments, but it also quickly makes for obscure signatures.

Dedicated workspace

This is the approach taken in Nonconvex.jl, all temporary containers required by an algorithm are defined
as a type specific to that algorithm:

struct MyAlgorithm end

mutable struct MyAlgorithmWorkspace
    v::Vector{Float64}
end

prepare_workspace(::MyAlgorithm, x) = MyAlgorithmWorkspace(similar(x))

function run_algorithm(alg, x0; workspace = prepare_workspace(alg, x0))
    compute_step!(workspace.v, x0)
end

This pattern avoids the monstrous signature with 10+ arguments
that are not “real” inputs of the function in a mathematical sense,
lets a good default for most users of letting the keyword be initialized
but allows more advanced users to pass down the workspace if required.
The workspace of a given algorithm can also contain multiple arguments
of different types without requiring changes to the other algorithms.
This is exactly the path experimented for the step size computation
in FrankWolfe.jl#259.

Functors

Sadly, the workspace pattern is not a silver bullet, even if it covers a lot of cases.
When one needs not only some internal workspace, but also returning a large object?

The Frank-Wolfe is also composed of different building blocks, gradient computation,
linear oracle, step size. Should we have a dedicated workspace for each of them?
That would also be a load we place on all advanced users defining a new component;
acceptable for the oracles which typically have a dedicated type, but it quickly becomes
awkward for something like the objective function.
Would we expect users to do something like the following:

using LinearAlgebra
f(x, workspace) = dot(x, workspace.A, x)
struct FWorkspace{T}
    A::Matrix{T}
end
build_objective_workspace(::typeof(f), x) = ...

It reads oddly and will be hard to explain to users relatively new to Julia
while not being a very advanced feature for the package, defining an objective function is
part of the workflow.

A typical pattern would be to use closures for such parameters:

function build_objective(A)
    f(x) = dot(x, A, x)
    function grad!(storage, x)
        storage .= 2 * A * x
    end
    return (f, grad!)
end

(f, grad!) = build_objective(A)
# ...

The two functions close over a common parameter A which can be accessed from within it.
But what if you need to access A outside build_objective, once the functions are created?

You actually can do f.A, but it’s definitely a hack using an implementation
detail more than a feature, do not reproduce this at home!
And users or library contributors might also be confused when trying to see where the f.A
accessor is defined.

Instead, we should transparently define the fields we access and use functors or callable structures.

struct GradientWithMatrix{T} <: Function
    A::Matrix{T}
end

# defines the behaviour of a call to GradientWithMatrix
function (gm::GradientWithMatrix)(storage, x)
    storage .= 2 * gm.A * x
end

grad! = GradientWithMatrix(ones(3,3))
# ...

grad! is named like our previous function and can be called in the same way,
but it is also a struct of type GradientWithMatrix{Float64}.
Furthermore, this parameterized gradient type can be set up by the user, so we,
the package developers, let the user implement an allocation-free version by setting up their gradient only
once.

This pattern could also become handy for dynamic parameters evolving with iterations,
like a number of gradient calls:

mutable struct GradientWithIterationCounter{T} <: Function
    A::Matrix{T}
    counter::Int
end

GradientWithIterationCounter(A) = GradientWithIterationCounter(A, 0)

# a call to GradientWithMatrix increases the counter and updates the storage
function (gm::GradientWithMatrix)(storage, x)
    gm.counter += 1
    storage .= 2 * gm.A * x
end

grad! = GradientWithMatrix(ones(3,3))
# ...

This allows us, in a non-intrusive way for the algorithm code, to add an iteration tracking feature
to Frank-Wolfe.

Thanks Wikunia for proofreading and feedback!

News features in DataFrames.jl 1.3: part 1

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2021/12/10/rowreduce.html

Introduction

A few days ago we have released DataFrames.jl 1.3.0.
In the coming weeks I will discuss new features introduced in this release.
Each post will be devoted to a single topic.

Today I start with performance improvement of aggregation of rows of a data
frame since recently a related interesting question was asked on Slack.

The post was written under Julia 1.7.0 and DataFrames.jl 1.3.0.

A typical scenario of row aggregation

Let us start with a square data frame that has 10,000 rows and columns.
In the following examples I will omit printing the computed output to reduce
the length of the post.

julia> using DataFrames

julia> df = DataFrame(rand(10_000, 10_000), :auto);

Assume you want to compute the sum of values in each row of the data frame.

Here is a simple, but inefficient way to do it (I will run the @time macro
twice to show the times including compilation and after compilation):

julia> @time sum.(eachrow(df));
 29.653202 seconds (784.98 M allocations: 13.199 GiB, 5.93% gc time, 0.42% compilation time)

julia> @time sum.(eachrow(df));
 30.152483 seconds (784.69 M allocations: 13.183 GiB, 5.69% gc time)

Now let us use the select function to achieve the same:

julia> @time select(df, AsTable(:) => ByRow(sum));
  1.424650 seconds (4.62 M allocations: 274.349 MiB, 4.04% gc time, 96.05% compilation time)

julia> @time select(df, AsTable(:) => ByRow(sum));
  0.064406 seconds (19.66 k allocations: 1.140 MiB)

The performance improvement is significant.

Here is a list of functions for
which the fast path of aggregation is implemented for the form
AsTable(cols) => fun [=> destination] of the DataFrames.jl mini-language:

  • sum, ByRow(sum), ByRow(sum∘skipmissing);
  • length, ByRow(length), ByRow(length∘skipmissing);
  • mean, ByRow(mean), ByRow(mean∘skipmissing);
  • ByRow(var), ByRow(var∘skipmissing);
  • ByRow(std), ByRow(std∘skipmissing);
  • ByRow(median), ByRow(median∘skipmissing);
  • minimum, ByRow(minimum), ByRow(minimum∘skipmissing);
  • maximum, ByRow(maximum), ByRow(maximum∘skipmissing);
  • fun∘collect and ByRow(f∘collect) where f is any function.

You might be curious about the last form. The optimization is that if you have
any function f that takes a vector and makes its reduction it will be efficiently
executed when it is composed with combine. Let us use the extrema function
as an example:

julia> @time extrema.(eachrow(df));
 30.513559 seconds (884.87 M allocations: 14.683 GiB, 6.22% gc time, 0.27% compilation time)

julia> @time extrema.(eachrow(df));
 30.887099 seconds (884.69 M allocations: 14.673 GiB, 6.25% gc time)

julia> @time select(df, AsTable(:) => ByRow(extrema∘collect));
  1.984904 seconds (925.93 k allocations: 812.528 MiB, 1.19% gc time, 18.85% compilation time)

julia> @time select(df, AsTable(:) => ByRow(extrema∘collect));
  1.515307 seconds (49.17 k allocations: 764.989 MiB, 0.78% gc time)

Note that the collect part is important. If we have not used it the result
would be as follows:

julia> @time select(df, AsTable(:) => ByRow(extrema));
 58.041291 seconds (297.92 M allocations: 9.731 GiB, 0.95% gc time, 78.49% compilation time)

julia> @time select(df, AsTable(:) => ByRow(extrema));
 12.495241 seconds (295.00 M allocations: 9.617 GiB, 4.51% gc time)

What is the difference? With collect we are processing a vector, while without it
a NamedTuple is passed to extrema. The result gets computed, but, as you
can see in the output of @time the compilation time for the first call is huge,
and also after compilation it is faster to work with collect version.

A use-case from practice

The task recently asked on Slack is the following. We have again a data frame
that has 10,000 rows and columns, but this time we have 50% of missing values
randomly scattered in it. What we want to do is to fill missing values in each
row with row means of non-missing values.

Let us first generate the data frame (I start a fresh session again:

julia> using DataFrames

julia> using Statistics

julia> df = DataFrame(rand([1.0, missing], 10_000, 10_000), :auto) .* (1:10_000);

I first show you how I would have done this operation before DataFrames.jl 1.3
release if I wanted to avoid excessive memory allocation. First I make a vector
of columns of this data frame without copying them:

julia> cols = identity.(eachcol(df));

Note that I broadcast identity to make the element type of the cols vector concrete.

Now we compute a vector of of fill values for each row:

julia> @time fill_vals = [(mean(skipmissing(v[i] for v in cols))) for i in 1:nrow(df)];
  2.167842 seconds (178.27 k allocations: 8.515 MiB, 5.18% compilation time)

julia> @time fill_vals = [(mean(skipmissing(v[i] for v in cols))) for i in 1:nrow(df)];
  2.212677 seconds (160.38 k allocations: 7.513 MiB, 4.38% compilation time)

As you can see this step is quite fast. If we skipped the identity call things
would be slower:

julia> @time fill_vals = [(mean∘skipmissing)(v[i] for v in eachcol(df)) for i in 1:nrow(df)];
 16.641022 seconds (395.08 M allocations: 6.638 GiB, 6.65% gc time, 0.77% compilation time)

julia> @time fill_vals = [(mean∘skipmissing)(v[i] for v in eachcol(df)) for i in 1:nrow(df)];
 15.949596 seconds (395.07 M allocations: 6.637 GiB, 5.16% gc time, 0.78% compilation time)

If we just iterated rows things also would be even slower:

julia> @time fill_vals = (mean∘skipmissing).(eachrow(df));
 25.953399 seconds (634.99 M allocations: 10.218 GiB, 5.18% gc time, 0.51% compilation time)

julia> @time fill_vals = (mean∘skipmissing).(eachrow(df));
 25.739528 seconds (634.72 M allocations: 10.203 GiB, 4.91% gc time)

Now let us check how fast the select machinery we have just learned works:

julia> @time fill_vals = select(df, AsTable(:) => ByRow(mean∘skipmissing) => :fill_vals).fill_vals;
  1.773402 seconds (4.42 M allocations: 260.296 MiB, 3.73% gc time, 69.85% compilation time)

julia> @time fill_vals = select(df, AsTable(:) => ByRow(mean∘skipmissing) => :fill_vals).fill_vals;
  0.549109 seconds (19.66 k allocations: 1.217 MiB)

As a final step let us check the performance if we kept the data in a matrix instead
(I am not counting the cost of conversion of a data frame to a matrix here):

julia> mat = Matrix(df);

julia> @time (mean∘skipmissing).(eachrow(mat));
  1.581645 seconds (7 allocations: 468.906 KiB)

julia> @time (mean∘skipmissing).(eachrow(mat));
  1.547717 seconds (7 allocations: 468.906 KiB)

julia> mat2 = permutedims(mat);

julia> @time (mean∘skipmissing).(eachcol(mat2));
  0.689801 seconds (344.41 k allocations: 19.172 MiB, 19.95% compilation time)

julia> @time (mean∘skipmissing).(eachcol(mat2));
  0.553592 seconds (7 allocations: 468.906 KiB)

As you can see the performance of select is comparable to the performance
on a matrix when we work on columns (which means we perform the operation using
the fact that Julia uses column major storage of matrices).

To conclude the task we produce a new table with imputed values:

julia> @time coalesce.(df, fill_vals);
  0.989387 seconds (370.37 k allocations: 780.629 MiB, 5.10% gc time, 14.19% compilation time)

julia> @time coalesce.(df, fill_vals);
  0.842541 seconds (149.03 k allocations: 768.343 MiB, 4.01% gc time)

Before I finish let me comment how you could have filled missing values in
columns with means of columns:

julia> @time select(df, names(df) .=> (x -> coalesce.(x, mean(skipmissing(x)))), renamecols=false);
  1.192647 seconds (1.89 M allocations: 861.187 MiB, 7.53% gc time, 40.42% compilation time)

julia> @time select(df, names(df) .=> (x -> coalesce.(x, mean(skipmissing(x)))), renamecols=false);
  0.971668 seconds (1.29 M allocations: 826.755 MiB, 1.91% gc time, 21.57% compilation time)

Next week in the post I will discuss how you could have replaced names(df)
selector in the last expression with All() selector (and how it
is implemented).

Conclusions

In this post you have learned how to perform fast reductions over rows of
a data frame by using the new features of the mini-language implementation.
I have shown you that the implementation we have is quite efficient. Also since
we support the f∘collect composition it is quite extensible to any custom
reduction function that accepts vectors.