Live data from Hacker News

Optimizing the Particle Life: From 400 to 4M particles

programmingattack.com

51–60 of 71 posts

Re: Optimizing the Particle Life: From 400 to 4M particles

#51
post #30

The thing that strikes me as particularly weird about this is the use of numpy there. In other languages, they're using native code. In python they're reaching out to numpy, which is a great library, but not awesome inside a hot loop unless you're keeping the operation you're carrying out within numpy itself. This means, right in that hot loop, they're doing a lot of translating of numbers between python representati…

Yeah, an all-numpy version runs in less than 1ms on my M1 air 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)

So rather than spend 10-20 minutes reading about numpy, the author wrote 3 other implementations...?

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.

Re: Optimizing the Particle Life: From 400 to 4M particles

#52
post #14

Earlier quoted context omitted.

For fun and frolics: 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.

They didn't use any optimization flags.

Yeah, I was just trying to show the difference. Doing it without optimisation flags is an utterly bewildering decision by the author.

Re: Optimizing the Particle Life: From 400 to 4M particles

#53
post #6

Why was C slower though?

Simply compiling it with -O3 produces something which completes in half the time of the JavaScript version (350ms for C, 750ms for JS), so perhaps that. 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=nati…

Seems crazy to me that double would produce that kind of speed up. Is float getting emulated somehow? Don't they end up the same size?

Re: Optimizing the Particle Life: From 400 to 4M particles

#55

Earlier quoted context omitted.

One optimization for the C code is to put "f" suffixes on the floating point constants. For example convert this line: 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.…

> I believe this helps because 0.02 is a double and [...] can produce a different answer 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 sa…

[deleted]

Re: Optimizing the Particle Life: From 400 to 4M particles

#57

Earlier quoted context omitted.

One optimization for the C code is to put "f" suffixes on the floating point constants. For example convert this line: 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.…

> I believe this helps because 0.02 is a double and [...] can produce a different answer 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 sa…

OK, right, that's the clarity of thought I was missing.

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 
    #include 

    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?

Re: Optimizing the Particle Life: From 400 to 4M particles

#58

Earlier quoted context omitted.

> I believe this helps because 0.02 is a double and [...] can produce a different answer 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 sa…

OK, right, that's the clarity of thought I was missing. 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 #include float mul_as_float(float t) { t += 0…

On my machine, the complier constant-folds the multiplication, producing a single-precision add for `mul_as_float` and a convert-t-to-double, double-precision-add, convert-sum-to-single for `mul_as_double`. I missed the `+=` in your original comment, but adding a float to a double does implicitly promote it like that, so you'd actually need:

  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.

Re: Optimizing the Particle Life: From 400 to 4M particles

#59

Earlier quoted context omitted.

OK, right, that's the clarity of thought I was missing. 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 #include float mul_as_float(float t) { t += 0…

On my machine, the complier constant-folds the multiplication, producing a single-precision add for `mul_as_float` and a convert-t-to-double, double-precision-add, convert-sum-to-single for `mul_as_double`. I missed the `+=` in your original comment, but adding a float to a double does implicitly promote it like that, so you'd actually need: t += (float)((double)0.02f * (float)17); to achieve the "and then converting…

Ah right, yep.

Re: Optimizing the Particle Life: From 400 to 4M particles

#60

Earlier quoted context omitted.

Negative again, a series of array operations which are individually idiomatic numpy like this will run very very fast in numba as it can coalesce the ops into a single pass through memory. Numpy can't do this and has to pass through the array for each individually array operation. There's nothing wrong with straight numpy but if you want it compiled-C fast for the whole ensemble of array ops, you need a JIT.

Ah, I was under the impression that using array OPs inside a numba njit gave worse performance. Has this changed, or is my memory tricking me? Do you have a source for this? I have not seen it in the numba docs.

Numba replaces ops with LLVM byte code equivalent so the compiler will optimise out and coalesce the operations as part of the optimisation stage.

If you want to look at the sort of things compilers do though, take a look at “common subexpression elimination”

Post reply on HN