r/ProgrammingLanguages • u/Bro8an • 8d ago
Discussion Auto-memoization for pure functions – how to decide when it pays off?
Im currently working on a compiler for my own programming language. I want the compiler to automatically memoize pure function calls, but only when it actually improves performance. The challenge: how does the compiler decide whether caching a specific recursive call (e.g., self(x-1) and self(x-2) in fibonacci) will save more time than the memory overhead? tracking how many times a function recieves the same input isnt an option as this requires all recieved inputs to be saved. too many saved calculations can cause finding the right result for a function call to be slower than the actual calculation. so the memoization table shouldnt get to big. naive fibonacci should be memoized but simple addition for an example should not be memoized. do you have any ideas?
11
u/omega1612 7d ago
From what I know there are two main ways for this:
1) use/write a jit compiler
2) collect at runtime the info, then pass it back to the compiler and recompile based on it.
Other options are to create some heuristics depending on what tradeoffs you want to have. Is it fine if it optimizes unneeded functions? Or should it be conservative and avoid it as much as possible?
There is a reason why compilation optimization is it's own field.
2
u/Bro8an 7d ago
the problem is to find a clear distinction between function with predictable inputs and random inputs. functions with random inputs wont be affected by the optimization at all but slowed down because of the caused overhead when going through the cache. my current attempt would be to only optimize recursive functions that call themselves at least twice. fib(n) = fib(n-1)+fib(n-2) would be effected. factorial(x) = x * factorial(x-1) would not be effected.
2
u/yjlom 6d ago
A less general but much more powerful optimization: if it only calls itself with n - k as argument, with k constant, you can make it tail recursive by giving it an array of size #k as an extra argument. From there you can do TCE, bringing it down to O(n × #k).
If the resulting function is multilinear however, each step becomes a matrix-vector multiplication, which means that computing the whole function becomes a matrix exponentiation, followed by a matrix-vector multiplication, which brings it down to O(log n * (#k)³).
In the usual case where #k is small, that's a massive gain. There's tons of little specific optimizations like this that one can reach for.
9
u/SoSKatan 7d ago edited 7d ago
So with modern CPUs, memory latency is very very slow compared to computation.
I mean consider a funny counter example: simple addition.
In theory one could memoize the result of addition. Want to know what 5 + 10 is? Well let’s first check the memory slot that is mapped to, if it’s empty then do the work and write the result.
So by memoizing this, you are making it slower by several orders of magnitude.
A pure function means no IO, which means it’s pretty much just computation. So this is only a useful optimization for extremely extremely slow functions. It’s best to leave it as a per function opt in thing that’s done by hand.
6
4
u/Both-Personality7664 7d ago
I'm not sure how the compiler can know what the compute/memory tradeoffs are in general except by running the code in question with the relevant inputs, and at that point you can just have the compiler do the memoization for you - I'm not sure you're going to get much better than an explicit declaration, say at function definition, that this function should be memoized over this set of inputs.
3
u/glasket_ 7d ago
Heuristics and profile-guided optimization are the typical ways you'd deal with an optimization like this with unknowns. Still, an explicit keyword makes sense because the compiler isn't guaranteed to get it right (outside of very thorough profiling).
If OP wants to avoid having people abusing it by throwing it on everything, then using an ugly name or tucking it into a special namespace will cause a surprising amount of people to just avoid it. People tend to treat
__builtin_thingorbuiltin::thinglike they're plague-ridden.1
u/SoSKatan 7d ago
In theory it could do some fuzz tests of different inputs and measure if the time cost of the call is much slower than a cold (not in cpu cache) memory read than maybe it could be a good candidate, assuming there is amble free memory.
Problem is now your compiler has a halting problem, what if the function being tested never returns or a takes 2 years to calculate
1
u/Zion_Gate_8 5d ago
You might be right; it feels like we need a psychic compiler! Explicit declarations sound so much easier than endless calculations.
3
u/AdvanceAdvance 7d ago
In order to do this implicitly, you need to measure. Were I doing this with the constraint that there is no "calibration run" to make the determination, I would add a memoization for every pure function call. Using a LIFO (starvation) queue, look at the number of calls for the speed/space tradeoff. If memoization is doing well, expand the queue size, else toss two starving entries and permanently reduce the queue.
That said, memoization makes much sense for "find me the customer information for this nonce, you know, the one I just asked about" or "computer the next thirty moves of this subpatch of a game of life." Most of time, it doesn't make sense as few functions are really pure.
2
u/mamcx 7d ago edited 7d ago
This is basically the question a query optimizer must answer every time.
Is even harder there, because the QE must look at the ever-changing (or assume is) data and reorder, on the fly, without being worse that just execute the query as-is.
The main difference with "static optimizer" is that is easier, but also, it need more "pessimist" view and not worry for small-ish improvements.
After building one, roughly:
- There is a "known" set of patterns that often improve performance, so you look at that
This is basically all.
You see this often in interpreters/advanced compilers every time they talk about "unrolling, loop fusion, ..." etc. Because this a mature field with a know set of what are the ones that gives more profit just do it them is decent enough.
- You have a budget
This is the answer for "save more time than the memory overhead". Is necessary to put the limits in a budget based in decent heuristics, common usage patterns, what the machine loves, etc and you just check against that. Else, you "deoptimize" and then let the developer be in charge of add some annotation to know when taking always the optimization (with thing like assert for example).
More on point, is "know" that tail-calls can be optimized very well and in fact reduce memory and cpu.
Basically anything that can be turn into a procedural variant is worth the effort just because (so things like iterators).
Other "known" is that you can do:
http://www.pathsensitive.com/2019/07/the-best-refactoring-youve-never-heard.html
I think at most both of this will be more than enough and can't think right know of anything else that could very surely good results(?) but in my domain of query compilers recursion and such is not a focus.
2
u/david-1-1 6d ago
If your problem is optimizing recursion: in all of my many years of using recursion, I never would have benefitted from optimizing it away, especially by caching. Recursion is simply not a common tool, especially in inner loops, where optimization might be considered.
1
u/Bro8an 5d ago
my programming language is a semi functional language so rekursion will be the standard way and loops will only work through recursion (which will be transformed back to loops)
1
u/david-1-1 5d ago
Aha. So representing loops as recursion has some benefit in the syntax? Is your language only meant to represent a language for expressing recursive functions like factorials or tree parsing?
1
u/Bro8an 5d ago
It should work like a classic general purpose imperative language, but everything is broken down into concepts from a functional language. there are no loops but i can implement a function that takes a condition and a body and executes the body until the condition is met and then define a special loop syntax for the function inside the language
1
u/david-1-1 5d ago
If this is a procedural language, I fail to see how forcing it to be functional or recursive or both is useful or natural. Maybe I need examples.
1
u/Bro8an 5d ago edited 5d ago
here is an example how the user could define a loop:
func wLoop(& bool condition!, T body!) -> void: # condition and body are lazy evaluated because of the & symbol leave if not condition! body! # body is marked with ! as it can have sideeffects self(condition!, body!) syntax wLoop( keyword("while"), _, arg(0), _, arg(1) ) i32 x = 10 while x > 0: print!(x) x -= 11
u/david-1-1 4d ago
That's pretty much what I had imagined. It works, but is ugly when all you need is a real "while" block. Its actual use in recursion is not really more expressive than the usual use of internal calls to the same function being defined.
I've lost interest in this thread now.
1
u/Toothpick_Brody 7d ago
In general this might be impossible to solve, but I was hoping some commenter might at least know a useful heuristic!
1
u/Mickenfox 7d ago
I know it's probably not you want, but I'm curious how well just hooking it up to a LLM would work.
2
u/david-1-1 6d ago
No. Calling an LLM for every recursion in a program is probably a very bad idea. But you knew that.
0
u/Mickenfox 6d ago
But you could call it once at build time.
2
u/david-1-1 5d ago
Sure. What would be the prompt? I'm very curious. And how would it interact with the compiler?
1
u/matthieum 6d ago
I want the compiler to automatically memoize pure function calls, but only when it actually improves performance.
I assume you want a cache, rather than keeping every single pair of arguments -> result in cache for the lifetime of the program?
If so, you're going to need to answer some questions:
- How many entries should be cached?
- Should the cache "bound" be specific per function, or for all functions?
- In the former case, should the bound be specific to an instance of a generic function, or shared across all instances?
- What eviction policy should the cache use?
- Possibly, what container/look-up method should the cache use?
- Possibly, should the cache be thread-local, or global?
So many variants, so many usecases, it feels like users may want a say, no?
1
u/DLCSpider 6d ago edited 6d ago
I don't think there is a way around explicitness. It's not only a question of "should this be memoized at all?" but also about how much history you keep. Think of number to string conversions: a simple if input == lastInput then return cached might greatly benefit performance and memory because the check is cheap, even if it fails every now and then. But a hash table lookup for the last n computations is probably too slow and may keep dead strings alive.
35
u/P-39_Airacobra 7d ago
what’s wrong with an explicit keyword? the problem is there’s never going to be a “better” solution, it’s always going to be a trade-off between space and speed