The Prospero Challenge
mattkeeter.com
mattkeeter.com
An RTX 4090 is advertised as being able to compute 82 TFLOPS.
Where do you think the extra is coming from? Is it just straightforward optimisations like constant folding? Or do you think the compiler is noticing that 1000 iterations of the loop doesn't change the answer and optimising it down to just 1 loop?
Something I saw from a cursory scan were some near duplicate instruction (haven't written code to find all instances):
a = x - c
;; some time later
b = c - x
Recognizing that b is the negation of a, you can convert the calculation of b to: b = -a
This may or may not be faster, but it does mean that we can possibly forget about c earlier (helpful for register allocation and can reduce memory accesses).Negations can sometimes be removed depending on the lifetime of the variable and its uses. Taking the above, suppose we follow it with this use of b:
d = e + b ;; or b + e
We can rewrite this as: d = e - a
Or if it had been: d = e - b
It can be rewritten to: d = e + a
And if there are no more uses of b we've eliminated both that variable and another instruction from the program. These and other patterns are things a compiler would detect and optimize.Though looking at the uses of the results from neg, I think most are used in the sequence of max/min instructions following them so it may not be possible to eliminate them as I showed above.
Even small things like converting multiplications followed by additions to FMA will reduce the operation count.
Add to that constant folding etc. and a speedup factor of ~3 is not so hard to imagine.
nvcc -arch=sm_86 prospero.cu -o prospero
cuobjdump -sass prospero | grep -E 'FFMA|FADD|FMUL|FMNMX|MUFU\.RSQ' | wc -lIf someone does know I’d love to learn why though.
The input algorithm is essentially written in SSA form, and it's easy to analyze when each "register" stops being used; then you can drop the arrays after their last use. Turns out the number of live registers never exceeds 160 at any point in time, and with this one addition the max memory usage drops to barely above 1GB.
I do not understand what this means at all, particularly the bit before the comma.
Can someone explain it differently?
Each line starts with a line number (with a leading underscore). This is followed by an instruction, and zero, one, or two arguments. The arguments can be either floating point constants or integers (with leading underscores) which represent the results of those corresponding lines earlier in the function.
The x and y coordinates of each pixel are loaded in the assembly language using the var-x and var-y instructions.
The expression is (probably) a 2d signed-distance function - https://en.wikipedia.org/wiki/Signed_distance_function
The math formula is a way to describe the contour of the shape. To tell if you're inside or outside of a shape, you plug in your (x, y) coordinate into the math formula, and if the output is > 0, then you're outside. If the output is < 0, then you're inside.
I think they call it a signed distance field. You should watch his talk that explain it. It's really great.
What is considered an acceptable preprocessing or transformation? In the limit case, a static executable that computes nothing and outputs a constant, literal image probably violates the intention of the question.
I actually don't think this is how he did it. You might notice the font is kind of weird.
I think he started with an SDF font and created the "assembly language" from that. https://en.m.wikipedia.org/wiki/Signed_distance_function
Assembly language is definitely the right analogy: it's a low-level target generated by higher-level tools. In this case, the expression came from a Python script calling this text(...) function:
https://github.com/mkeeter/antimony/blob/f6a56dd7/py/fab/sha...
The font is hand-built from geometric primitives (rectangles, circles, etc) and CSG operations (union, intersection, difference)
> What is considered an acceptable preprocessing or transformation?
I'm looking for interesting ideas, and to mine the depths of PLs / compiler / interpreter / runtime research. Just returning a fixed image isn't particularly interesting, but (for example) I just updated the site with a compile-to-CUDA example that shows off the brute force power of a modern GPU.
Fun exercise! I’ve been enjoying trying to find some new ways to approach the challenge. I managed to build a single string expression for the entire program, so it could be evaluated per-pixel in a shader, but it turns out the expression is too complex for WebGL & WebGPU and the shader fails to compile.
My next thought would be to evaluate the program at a low resolution to create a low res SDF texture for the shader to draw at a higher resolution. Some information will probably be lost, though.
By analogy, you wouldn't program in LLVM IR, but it's a useful intermediate representation for a bunch of higher-level languages. Higher-level tools can target this representation, and then they all get to use a standard set of optimizations and algorithms (fast evaluation, rendering, etc).
(I gave a recent talk that's a broad overview of this research: https://www.youtube.com/watch?v=UxGxsGnbyJ4)
> My next thought would be to evaluate the program at a low resolution to create a low res SDF texture for the shader to draw at a higher resolution.
Glad you're enjoying the challenge! You may also be interested in
https://samhenrycliff.medium.com/adapting-shakespeares-the-t...
Technically correct.
Just output the expected image in all cases.
Still technically correct. ;)
The op-codes are where you do that translation from an AST into bytecode. The entire 2nd half of the crafting interpreters book will guide you through how it's done. https://craftinginterpreters.com/a-bytecode-virtual-machine....
Composing these intervals is trivial for all operations except addition/subtraction. (For example, `mul` is less than 0 if exactly one of its arguments is less than 0, `neg` is less than 0 if its argument is not less than 0, `min` less than 0 if either of its arguments is less than 0, etc.).
And then we define add(a, b) as sub(a, neg(b)). So then we only need to work out which y values sub(a, b) will return a negative result for.
sub(a,b) is negative when a<b.
We can have the sub() node asks its 2 child nodes which values they would return a negative result for. Every y coordinate where a gives a negative result and b gives a positive result is a y coordinate where sub(a,b) is negative. Every y coordinate where a is positive and b is negative is positive. For the remaining y values I think we have to actually evaluate the children and find out which ones give a negative result.
Obviously memoise the evaluation so that we don't repeat any work, and turn the expression tree into a DAG by combining any identical nodes. Some subtrees won't contain var-x, so the memoisation of those nodes can persist across different x coordinates.
And then for each x coordinate we ask the expression tree to tell us which y coordinates give negative outputs, and plot those. It's possible that the idea would generalise to 2-dimensional intervals, not sure.
I haven't implemented this yet but I'm planning to try it out, but to be honest I don't expect it to be faster than Matt's GPU version based on recursive subdivision with interval arithmetic. Btw his talk is great https://www.youtube.com/watch?v=UxGxsGnbyJ4&t=80s
And, a second fun challenge would be to reverse engineer the input file to extract the font! I expect the file is basically a union of x/y translations of functions that plot individual letters, so it shouldn't be crazy hard to split out the top-level union, then split-out the x/y translations, and then collect the letters. It's possible that it's more complicated though. In particular, is each character translated in x/y individually, or is each character translated only in x to form each line of text, and then the line as a whole is translated in y? Or something weirder?