Optimizing the Particle Life: From 400 to 4M particles
programmingattack.com
programmingattack.com
Sidenote:
I’m often too self conscious to put anything technical online with my real name on it, probably a bit of imposter syndrome or something. I think based on
> For one of my programming classes this semester, my task was writing a program in Python. It was only an introductory course, so it didn’t have to be anything fancy.
the author is sort of early on in their career. I often suspect I’d be better off if I’d gotten over myself and thrown more early experiments online. I dunno, maybe this stuff just isn’t a big deal for some people, but I have to applaud the bravery.
(Currently trying to take this advice as well)
One friend started telling me to get over myself and publish, then he reworded it to:
“Get over being under yourself.”
For some reason that one stuck with me and gave me the strength to ship my first few pieces of writing.
[0] https://pubs.aip.org/aip/jcp/article-abstract/122/17/174105/...
- Github repo https://github.com/Caranea/gpulife
- Finished particle simulator https://webgpulife.netlify.app/
Having an interest in the space for many years (on-and-off; life gets busy sometimes) - I've often lamented the lack of good pixel-level performance optimizations in graphics cards. Everything seems hell-bent on polygons.
Some years ago, DirectDraw on Windows was an excellent way to optimize the graphics portion of these types of things, but that all went away as "3D Is The Way" mentality took over.
My explorations of Processing.* were neat, but lacked the IDE and library support I like as a developer.
Just fire up OpenCL (or CUDA, if you must) and start executing some data-parallel kernels.
As the original simulation shows, the idea is to calculate the difference and proximity of the pixels. So the drawing part is just a projection...
The ease of getting a memory buffer pointer, setting pixels within my calculation loop with simple offsets was very simple and compelling. I think I should look deeper into OpenCL/CUDA for this use-case, as I mentioned in the other response to my comment.
Thanks!
I daresay it's actually easier to render things this way than getting lost in the weeds with API docs etc. A simple Mandelbrot hello-world example should be easy to understand and get compiling, and you can go from there.
I thought you could also do numeric calculations with compute shaders.
I guess all that I'd need would be in-memory buffer of my field, then a single function to copy the buffer (or section of the buffer if I want larger than screen fields) to the video buffer.
Cutting numpy out by making the line "t = [0.0]*l" gets it down to 17059 ms, without attempting any other optimisations. Using that plus pypy (so you get the JIT that you have with javascript/v8) gets it down to 958 ms.
To show the cost of those translations, sticking with numpy. If we switch that inner loop to:
temp_t_i = t[i]
temp_t_i += 0.02 * j
temp_t_i *= 0.03 * j
temp_t_i -= 0.04 * j
temp_t_i /= 0.05 * (j+1)
temp_t_i = temp_t_i
It speeds us up from 47552 ms to 28251 ms. Almost half the execution time. That's still doing two hops back and forth, though. If you cut it down to a single line it's even faster at 18458 ms, cutting execution time down to about a third of the original example. Pypy isn't able to help here at all, this is sort of a pathological case for it.edit: I'll add, I'm not that good with numpy, rarely use it myself. Not sure if it's possible to do that inner loop all within numpy somehow. I imagine that'd be a lot faster still.
Numba is designed to speed up code where looping is unavoidable, i.e. the code can't be (easily) expressed as array OPs.
Do you have a source for this? I have not seen it in the numba docs.
If you want to look at the sort of things compilers do though, take a look at “common subexpression elimination”
import numpy as np
l = 10_000
t = np.empty(l, dtype=np.float32)
j = np.arange(l)
t = 0.02 \* j
t *= (0.03 * j)
t -= (0.04 \* j)
t /= 0.05 \* (j + 1)The fact that they ran the C code without the optimisation flags and compared it that way makes me think Javascript was what they actually wanted to write this one in anyway.
Haven't looked closely at the code or tried it, but with -O3, -fopenmp and a well-placed pragma the performance could increase many-fold.
Heck, with NVC++ you could offload that thing to a GPU with minimal effort and have it flying at the memory bandwidth limit.
Edit for Twirrim: on this system (Ryzen 7, gcc 11): "-O3": 350ms; "-O3 -march=native": 208ms; "-O2": 998ms; "-O2 -march=native": 1040ms.
Edit 2: Interestingly, changing the C from float to double produces a 3.5x speedup, taking the time elapsed (with "-O3 -march=native") to 58ms, or about 12x faster than JS. This also makes what it's computing closer to the JavaScript version.
(But to be sure, I just ran it again with an output and got the same value.)
No flags: 1843ms
-march=native: 2183 ms
-O2: 423 ms
-O2 -march=native: 250 ms
-O3: 425 ms
-O3 -march=native: 255 ms
O3 doesn't seem to be helping in my case.
No. Float is half the size of double.
PS: I do not use C beyond reading some of its code for inspiration, so kinda unaware
A modern C optimizing compiler is going to go absolutely HAM on this sort of code snippet if you tell it to and do next to nothing if you don't - that's, roughly, the explanation for this little discrepancy.
FWIW, without -O, with -O, and with -O4, I get 2500ms, 1500ms, and 550ms respectively. I didn't bother to look at the .S to see the code improvements. (Of course, I edited the code to output the results, otherwise, it just optimized out everything.)
O(1) :)
Since I was already set on writing in-browser particle life, I didn't benchmark C code with different flags.
t[i] += 0.02 * (float)j;
to: t[i] += 0.02f * (float)j;
I believe this helps because 0.02 is a double and doing double * float and then converting the result to float can produce a different answer to just doing float * float. The compiler has to do the slow version because that's what you asked for.Adding the -ffast-math switch appears to make no difference. I'm never sure what -ffast-math does exactly.
Minimal case on Godbolt:
https://godbolt.org/z/W18YsnMY5 - without the f
https://godbolt.org/z/oc1s8WKeG - with the f
In principle, not quite. The real/unavoidable(-by-the-compiler) problem is that 0.02 is a not a diadic rational (not representable exactly as some integer over a power of two). So its representation (rounded to 52 bits) as a double is a different real number than its representation (rounded to 23 bits) as a float. (This is the same problem as rounding pi or e to a double/float, but people tend to forget that it applies to all diadic irrationals, not just regular irrationals.)
If, instead of `0.02f` you replaced `0.02` with `(double)0.02f` or `0.015625`, the optimization should in theory still apply (although missed optimization complier bugs are of course possible).
But in this case the compiler still misses the optimization with '(double)0.02f'. https://godbolt.org/z/az7819nKM
I think this is because the optimization isn't safe. I wrote a program to find a counter example to your claim that "the optimization should in theory still apply". It found one. Here's the code:
#include <stdio.h>
#include <stdlib.h>
float mul_as_float(float t) {
t += 0.02f * (float)17;
return t;
}
float mul_as_double(float t) {
t += (double)0.02f * (float)17;
return t;
}
int main() {
while (1) {
unsigned r = rand();
float t = *((float*)&r);
float result1 = mul_as_float(t);
float result2 = mul_as_double(t);
if (result1 != result2) {
printf("Counter example when t is %f (0x%x)\n", t, *((unsigned*)&t));
printf("result1 is %f (0x%x)\n", result1, *((unsigned*)&result1));
printf("result2 is %f (0x%x)\n", result2, *((unsigned*)&result2));
return 0;
}
}
}
It outputs: Counter example when t is 0.000000 (0x3477d43f)
result1 is 0.340000 (0x3eae1483)
result2 is 0.340000 (0x3eae1482)
What do you think? t += (float)((double)0.02f * (float)17);
to achieve the "and then converting the result [of the multiplication] to float" (rather than keeping it a double for the addition) from your original comment. (With the above line in mul_as_double, your test code no longer finds a counterexample, at least when I ran it.)If you ask for higher-precision intermediates, even implicitly, floating-point compliers will typically give them to you, hoped-for efficiency of single-precision be damned.
Me too when I am away from C for a while. The topic has been on HN [3]
* Enable the use of SIMD instructions
* alter the behavior regarding NaN (you can't even check for NaN afterwards with isnan(f))
* alter the associativity of expression a+(b+c) might become (a+b)+c which seems inconspicuous at first, but there are exceptions (as example see [1] under -fassociative-math)
* change subnormals to zero (even if your program isn't compiled with this option, but a library you link to your program).
A nice overview from which I summarize is in [1] which contains a link to [2] with this nice text:
"If a sufficiently advanced compiler is indistinguishable from an adversary, then giving the compiler access to -ffast-math is gifting that enemy nukes. That doesn’t mean you can’t use it! You just have to test enough to gain confidence that no bombs go off with your compiler on your system"
[1] https://simonbyrne.github.io/notes/fastmath/
[2] https://discourse.julialang.org/t/when-if-a-b-x-1-a-b-divide...
The results are just some particles in an extremely basic pattern, there isn't a lot of payoff.
What it was meant to show is that V8 is *good enough* to even consider it for the job :)
Do you really not think this is a mistake? Most people would see C getting outperformed and realize there is something wrong. I'm shocked anyone would both this then try to rationalize it after.
Also if someone wants to put their first CS project on the internet, then label it that way and not as some sort of blog post about them doing something unique.