r/ProgrammingLanguages • u/Bro8an • 1d 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?
8
u/omega1612 1d 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.
1
u/Bro8an 1d 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 16h 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.
5
u/Both-Personality7664 1d 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.
2
u/glasket_ 1d 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 1d 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
5
u/SoSKatan 1d ago edited 21h 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.
3
2
u/AdvanceAdvance 1d 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 1d ago edited 1d 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.
1
u/Toothpick_Brody 1d 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 1d ago
I know it's probably not you want, but I'm curious how well just hooking it up to a LLM would work.
1
u/matthieum 18h 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 30m ago edited 16m 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.
24
u/P-39_Airacobra 1d 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