Memoize Pkg? Bad use of Metaprogramming?

Been doing some long-running computations. I mean … long. So I am doing the obvious thing, storing results to disk along the way. For reproducibility reasons I’d like to be able to memoize lots of intermediate results along the way. Even better, if I could show the code to generate the results but not have to re-run it or have it isolated from the execution flow. So I came up with a solution, it’s something like I once did with @kolia a while ago, but I’d like some feedback.

using JLD2
macro memoize(cache_var)
    filename = String(cache_var) * ".jld2"
    if !isfile( filename )
        @eval @save $filename $cache_var
    else
        @eval @load $filename $cache_var
    end
end

macro memoize(cache_var, fn)
    filename = String(cache_var) * ".jld2"
    if !isfile( filename )
        @eval $fn
        @eval @save $filename $cache_var
    else
        @eval @load $filename $cache_var
    end
end

macro free_memo(cache_var)
    filename = String(cache_var) * ".jld2"
    if !isfile( filename )
        @warn "Cache is already empty."
    else
        rm(filename)
    end
end
@memoize a
a

@memoize a begin
    a = 123
    a += 456
    a /= 5
    println("Hey we ran this code!")
end

@free_memo a

Main points:

  • is there already a package that does this? if so link please?
  • Bad use of metaprogramming?
  • Too dangerous to bundle up, correct, and share (incorrect use could cost a user a lot of time or electrons)?

Feel free to dartboard anything else bothersome. The word “cache” will likely upset some people here, and I foresee concerns about scope and where files are stored :D.

I’m afraid so. A macro body should:

  1. Never use eval()
  2. Never have side effects

Your macro does both. It’s important to understand that the body of the macro is run during the compilation process (when the code is lowered from the parser output). That means that your macro is going to try to look for files, run functions, create files, and delete files all during compilation. There’s little to no chance that’s actually what you want.

The good news is: this is easy to fix. You don’t need a macro at all! Just turn your macros into functions and then their effects will happen exactly when you’d expect (when the function is run).

5 Likes

I should add: if you really want to be able to use the shorthand syntax of something like:

@memoize a begin...end

instead of a more functional form like a = memoize("a") do .... end then it’s totally appropriate to use a macro. The key is that you need to make sure that the macro produces code that does the work rather than trying to do the work itself.

For example, here’s a badly written macro that tries to create a variable named a by calling a function f:

julia> macro make_a()
         @eval a = f()
       end

This is deceptive, because it kind of works in global scope:

julia> f() = 1
f (generic function with 1 method)

julia> @make_a()
1

julia> a
1

But on closer inspection, it falls apart.

First of all, the macro does the wrong thing in a local scope, since @eval creates a new global variable named a. For example (in a new Julia session):

julia> macro make_a()
         @eval a = f()
       end
@make_a (macro with 1 method)

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

julia> function foo()
         @make_a()
         println("a = ", a)
       end
foo (generic function with 1 method)

julia> foo()
a = 2

julia> a
2

The a variable has “leaked” out of the function f(), which is definitely not good.

Furthermore, the macro doesn’t even work depending on which order we define our functions. Try this in a new Julia session:

julia> macro make_a()
         @eval a = f()
       end
@make_a (macro with 1 method)

julia> function foo()
         @make_a()
         println("a = ", a)
       end
ERROR: LoadError: UndefVarError: f not defined
Stacktrace:
 [1] top-level scope at none:1
 [2] eval(::Module, ::Any) at ./boot.jl:331
 [3] @make_a(::LineNumberNode, ::Module) at ./REPL[1]:2
in expression starting at REPL[2]:2

We can’t even define the function foo() because the macro body is trying to call f() during compilation. Again, not what you want.

The fix is to make the macro produce code that does what you want:

julia> macro make_a()
         quote
           $(esc(:a)) = f()
         end
       end
@make_a (macro with 1 method)

julia> function foo()
         @make_a()
         println("a = ", a)
       end
foo (generic function with 1 method)

julia> f() = 3
f (generic function with 1 method)

julia> foo()
a = 3

julia> a
ERROR: UndefVarError: a not defined

Now we can define foo() before f() because we’re no longer trying to call f() at compilation time, and a no longer leaks out into global scope.

8 Likes

Yea I figured it wasn’t good, didn’t know about the side effects thing - but it does make a lot of sense. I now see why most of their “good uses” are for code generation. Good information thank you. I tend not to write macros so I am pretty weak with metaprogramming stuff. I can make them functions though no problem :).

I’m still very interested to know if I’ve duplicated functionality available elsewhere though. This seems like something useful for DrWatson or similar.

1 Like

https://github.com/marius311/Memoization.jl maybe?

1 Like

@jling this might work, thanks! I wanted to have things kind of “script” like but ready-made solutions are nice. I don’t think you can store the memoized function to disk though? but maybe? I know you can serialize Julia functions(and compositions of functions). Not sure. Something to look that though.

You should have a look at DrWatson.jl, especially at the function produce_or_load(): https://juliadynamics.github.io/DrWatson.jl/dev/save/#DrWatson.produce_or_load

1 Like

I don’t know of any implementation of this in Julia, but I wrote a python one last winter for a project.

1 Like

DrWatson to the rescue. Yea this does look like it does what I’d need. Basically need to phrase every block as a function call. Which is a-okay. Now ideally you could do this with a block of code too - but I am starting to see why that is a difficult ask. I may mark this as a solution, but I want to do a little brainstorming to see if I can’t sneak my way around to something a little more bespoke.

@oscar_smith - yea the last time I wrote something like this it was also in python. It worked pretty well too.

Thanks for a very clear and helpful explanation.

1 Like

Edit - fixes on this iteration are two posts below
@rdeits - does this fix the main issues? Now each function just returns a quoted expression and no evals happen. Now no side effects happen in the macros, but they happen in the scope they are requested (ie in a function call or package space).

using JLD2
macro memoize(cache_var)
    filename = String(cache_var) * ".jld2"
    if !isfile( filename )
        quote
            @save $filename $cache_var
        end
    else
        quote
            @load $filename $cache_var
        end
    end
end

macro memoize(cache_var, fn)
    filename = String(cache_var) * ".jld2"
    if !isfile( filename )
        quote
            $fn
            @save $filename $cache_var
        end
    else
        quote
            @load $filename $cache_var
        end
    end
end

macro free_memo(cache_var)
    filename = String(cache_var) * ".jld2"
    if !isfile( filename )
        @warn "Cache is already empty."
    else
        rm(filename)
    end
end

@memoize a
a

@memoize a begin
    a = 123
    a += 456
    a /= 5
    println("Hey we ran this code!")
end

The isfile check will run when the macro is expanded. It seems that should be part of the expression returned as well.

1 Like

Derrp yes that should be in the returned expression itself. No worries I’ll take another crack at it. Thanks. Sorry no coffee in me yet. I also forgot to quote the remove file in the free_memo macro - yikes.

Here this should be better

using JLD2

macro memoize(cache_var)
    filename = String(cache_var) * ".jld2"
    quote
        if !isfile( $filename )
            @save $filename $cache_var
        else 
            @load $filename $cache_var
        end
    end
end

macro memoize(cache_var, fn)
    filename = String(cache_var) * ".jld2"
    quote    
        if !isfile( $filename )
            $fn
            @save $filename $cache_var
        else
            @load $filename $cache_var
        end
    end
end

macro free_memo(cache_var)
    filename = String(cache_var) * ".jld2"
    quote
        if !isfile( $filename )
            @warn "Cache is already empty."
        else
            rm($filename)
        end
    end
end

Yeah, that’s much better. I think the only thing you’re missing is that you need to escape the symbols provided to the macro, e.g. $(esc(var)) instead of $var. The section of the manual on macro hygiene has more info about escaping.

2 Likes

I really appreciate your input on this thread. I learned a bit, and some of the dangling things from the metaprogramming tutorial on Julia’s documentation have sunk in. Really appreciate it.

The same sentiment goes to Kristoffer and everyone else who contributed.

1 Like