Some notes on performance of callable structs

Maybe I should be a bit more concrete about the problem I was trying to solve. Julia has pretty good support for function pipelines or higher order functions that take other functions as arguments, like

arr2 = map(x -> foo(1, x), arr1)

or

# from my implementation of a circular buffer for an exercism exercise
Base.getindex(cb::CircularBuffer, i) =
   # wraps the index over the internal array
    checkedindex(cb,i) |>
    x -> getindex(cb.data, x)

These are already pretty nice, but there are a lot of attempts out there to make them even more ergonomic and concise, like Underscores.jl or the aforementioned PartialFunctions.jl, with which these could be rewritten as

arr2 = map(foo $ 1, arr1)

and

# from my implementation of a circular buffer for an exercism exercise
Base.getindex(cb::CircularBuffer, i) =
    checkedindex(cb,i) |>
    getindex $ cb.data

I personally really love this style of generating small new functions, and I am certain I am not alone in this given the many attempts to retrofit such features onto julia (afaik there has been a lengthy discussion to essentially make the __ syntax from Underscores.jl a native language feature). It is quite amazing that julia allows you to just ‘add new syntax’ like this, and in many cases this can even happen completely without loss of performance, due to the way functions are integrated in the type system.

However, in cases where a constant literal is partially applied to a function, like in my map(x -> foo(1, x), arr1) example, the native arrow syntax will create bytecode where the 1 is inlined into the code (as mentioned by @Benny), while for the map(foo $ 1, arr1) this does not happen, leading to slightly reduced performance in some cases. I spend a bit of time thinking about how one could rewrite/improve the definition of the $ function to remove these last drawbacks of partial application compared to the native -> syntax, but so far I think there is none. By stuffing the 1 in a struct that acts as a function we ‘hide’ the fact that it is really just a literal constant from the compiler.

Yes, but that is the whole point. With a native anonymous function the compiler ‘sees’ which values are literals and which are variables and from which scope they come. By using these function structs every value is treated the same, no matter where they come from. That it is in fact better can be seen from the benchmark I gave (you can replace the Fix1(expensiveFirst,1) with expensiveFirst $ 1, they are functionally the same).

@nsaiko :I skimmed over the linked thread. The topics are related I think (constant propagation/folding. BTW thanks to your comment I realized I should read up these terms and understand the difference) but as I understood it it’s author did not get the answer he was looking for, which would reinforce my statement that there exists no solution.
I read your explanations here, and I understand the difference between closed_function_without_run_time_dispatch and closed_function_with_run_time_dispatch, it is similar in concept to Fix1 vs ValueFix1 ,The reason this does not help me in my case is that when I define the function $(f, x) I can assume that the nature of f is known, but whether x is a compile time known literal ( like in map(foo $ 1, arr1)) or a variable that can vary at runtime (like in checkedindex(cb,i) |> getindex $ cb.data) is not, so I must assume it is a variable and treat it accordingly. I have to admit I have never used the Base.@assume_effects macro. Not sure if that may help out here.

I also want to emphasize that this thread is absolutely not a complaint, I would agree that the way the type system allows you to ‘talk’ to the compiler and make it evaluate stuff at compile time is absolutely busted, and way ahead of any other programming language I have ever worked with (I have heard Zig has some interesting comptime mechanic?). I just happened to run across this problem of extending the syntax without increasing the runtime cost which I could not solve and decided to share it.

1 Like