Round a direction vector to an 8-way compass
lemire.me
lemire.me
This approach effectively divides the ‘unit square’ of possible input vectors up into a grid, based on cos(pi/8) and cos(3pi/8) gridlines, with basically 9 possible areas in each quadrant mapped to corresponding directional vectors.
It may not be a bad strategy in general to use this kind of grid model, but it needs testing with actual controllers to see what the ‘feel’ ends up being. While this mathematical model makes sense in theory, in practice there’s no reason to suspect that these cosine values are necessarily good thresholds to use to create an intuitive compass direction control that actually feels intuitive.
In general in games, math needs to be a guide, but not a straitjacket for how you approach things.
That said, Lemire’s approach rewritten as dot products might work nicely and even look a bit more clean, even if it took 3ns instead of 1.5.
Very interesting, thanks! I've noticed myself that sometimes if I'm holding the controller a bit skewed my natural impulse will be to push in directions that don't exactly map to what the controller wants. EG I think I'm pushing forward, but I'm moving forward and slightly to the left. And its not just stick drift, its my own user error.
One can do that, rotate by 45 degrees, do that again, and do some further bit manipulation to combine the two bits of the first 4-way version with those of the second into the three bits this should produce.
That would be four comparisons, a 2×2 matrix multiplication (edit: that’s a matrix-vector multiplication) with a known-ahead rotation matrix (naively, that would be 8 (edit: 4) multiplications and 4 (edit: 2) additions, but I think we can lose those multiplications because all elements of a 45 degree rotation matrix have absolute value 1/√2 and the problem is scale-invariant) and some bit twiddling.
Now, make that branchless…
Edit2: and of course, you can do both splits into quadrants by just looking at the signs of x and y. That rotates the four quadrants by 45 degrees, but that doesn’t matter.
That makes the 4-way version simply (x>0) + 2 × (y>0): two comparisons and a bit shift.
twoBits = (x > 0) + 2 × (y > 0)
x’ = x + y
y’ = x - y
twoMoreBits = (x’ > 0) + 2 × (y’ > 0)
(Some bit twiddling with ‘twoBits’ and ‘twoMoreBits’
Feel free to compute those bit pairs differently if
it makes the bit twiddling easier)Though in this case 1.5 nanoseconds vs 40 nanoseconds is small and won't matter for most games.
I could imagine other uses of this code, like a UI wind map, or some kind of randomized maze, or something else. There might be reasons to round a vector of directions all at once.
I’m not sure I agree it’s more readable. The optimised version is reasonably regular, but it’s still longer and has more constants, hence more opportunities for sign errors and the like.
It feels sort of like jumping through hoops to avoid a matrix inversion, say. Sure, matrix inversion is “slow”, but unless you’re doing it millions of times a frame it doesn’t remotely matter. If matrix inversion or atan2 is the most direct way to express a transformation, just do it that way.
One reason I have to support this kind of optimization and not reach for the trig method by default is the last time I wrote a controller system, it wasn’t limited to something as small as deciding on a single dpad-like direction, it was a much larger system that designers could author controller recognizers for, up to and including drawing shapes like a circle with the stick, or entering a time based sequence of inputs (think Contra’s famous up-down-up-down-etc-etc cheat code). If you have sequences and multiple players and high frame rate and many possible patterns to identify, that’s when efficiency of these small operations really starts to add up. I actually spent the most development time ensuring that the amount of time spent not recognizing controls was minimized, making sure that a no-op pass through the system was measured in nanoseconds and not microseconds.
1. writing a game that movement happens along 8 axes, and needs to trace lots of rays
2. several other reasons similar to (1)
3. had a clever idea to optimize a thing and went ham, for fun
Throughput can be a bit higher with vectorization and pipelining, but more or less any such optimization you can make to the transcendentals you can also make on the proposed solution.
Aren’t the benefits of using fp32 bigger than the trig instruction throughput too? Doubles eat more register space, and double the bandwidth to memory if anything ends up in memory. I just don’t see any reason whatsoever to use doubles for this operation. If we’re going to worry about nanoseconds, then these extra bytes matter too, no?
"Latency" is what I was referring to with respect to how long you have to wait to execute a data-dependant instruction. "Throughput" refers to how many you can have in flight and how fast those execute.
Latency _can_ be filled with other work, but as I mentioned, it can be filled better with other work using the faster non-trig solution. Rule of thumb, you won't squeeze in more than 4 in-flight instructions per core, so you're probably not going to eek out more 10ns average throughput for the trig solution, worse than the proposed optimization even assuming it couldn't be pipelined at all. You're right that float/int execution units are usually separate, but they do have some shared resources. If you're doing enough logic relative to these computations then yes these could sometimes be basically free.
The register/cache/RAM/disk space and bandwidth very well might wind up dominating every other consideration. As always, it depends what else you're doing and what exactly you're doing with these computations. To give a useful counter-example, consider matrix multiplication. It can be structured such that even if you have to read/write the matrices from disk because of their size it's still a CPU-bound operation because you can efficiently use each data access many times. In that case you really do just have typically a 2x slowdown for doubles because you have half the elements per vector.
Might f32 be sufficient for this problem? Almost certainly IMO. It might not matter though (or it might -- as always, measure).
I'm doing a different thing tho, dot product vs. four cardinal points.
- Use else if to avoid uncessary condition checking.
- Only check for xneg / yneg if outx / outy are non-zero.
Some examples:
1. Branches with dependencies.
static void minMax(u8* pxmin, u8* pxmax, const u8* xs, size_t n)
{
const u8 init = n > 0u ? xs[0] : (u8)0u;
u8 xmin = init
u8 xmax = init;
for (size_t i = 1u; i < n; ++i)
{
const u8 x = xs[i];
if (x < xmin) { xmin = x; }
else if (x > xmax) { xmax = x; }
}
*pxmin = xmin;
*pxmax = xmax;
}
2. Branches w/o dependencies static void minMax(u8* pxmin, u8* pxmax, const u8* xs, size_t n)
{
const u8 init = n > 0u ? xs[0] : (u8)0u;
u8 xmin = init
u8 xmax = init;
for (size_t i = 1u; i < n; ++i)
{
const u8 x = xs[i];
if (x < xmin) { xmin = x; }
if (x > xmax) { xmax = x; } /*not doing else if*/
}
*pxmin = xmin;
*pxmax = xmax;
}
Compiling with auto-vectorization enabled (-O3 for most versions of GCC, and I was using -march=native on a Zen1 AMD CPU, so AVX2 capable), version 2 is 0.05x the runtime of the version 1 (so a lot faster). Version 2 disassembly will show lots of “packed” vector instructions.3. Branchless
inline u8 minBranchless(u8 x, u8 y)
{
return x * (u8)(x <= y ? 1u : 0u)
+ y * (u8)(x > y ? 1u : 0u);
}
inline u8 maxBranchless(u8 x, u8 y)
{
return x * (u8)(x >= y ? 1u : 0u)
+ y * (u8)(x < y ? 1u : 0u);
}
static void minMax(u8* pxmin, u8* pxmax, const u8* xs, size_t n)
{
const u8 init = n > 0u ? xs[0] : (u8)0u;
u8 xmin = init
u8 xmax = init;
for (size_t i = 1u; i < n; ++i)
{
const u8 x = xs[i];
xmin = minBranchless(xmin, x);
xmax = maxBranchless(xmax, x);
}
*pxmin = xmin;
*pxmax = xmax;
}
Version 3 is probably a better comparison for the code from the article — there are only 1 or 2 joystick direction vectors that need normalization, so perhaps not enough to show major vectorization benefit, or to even prompt compiler auto-vectorization. Version 3 will most likely be compiled into cmov instructions on x86_64, which are branchless, but branches can still be better, if they can be predicted well. The test data I used was random, so cmov should be expected to be beneficial.Last time I ran comparisons, Version 2 was 0.07x the runtime of Version 3, so Version 3 was not that much faster than Version 1, but still a good improvement.
As a final note, I should say I’m not a fan of relying on compiler auto-vectorization. If you depend on the performance of vectorized code (and vectorizing code is one of the best optimizations out there), then I’m a fan of explicitly vectorizing. SSE2 is in nearly 100% of relevant x86_64 systems (first coming out in 2003). If you’re targeting consumers, AVX is almost certainly worth it. If you’re writing server code, you should be taking full advantage of AVX2 and maybe 512. For arm, NEON is the current vector extension of relevance, and you can polyfill with DLTcollab/sse2neon or something like that, until you go native. Writing vectorized code using SIMD intrinsics (#include <immintrin.h>) is preferable. Especially since compilers just cannot know how to auto-vectorize a lot of floating point code, because of strict assumptions about requirements around non-commutativity, so they almost never generate packed FMADDs.
As for the article code, I wouldn’t make any assumptions about whether the compiler is generating branches or cmov-s. I’d guess cmov-s would be faster, but I haven’t really thought about it. All I’ll say is that turning a lot of `if`s into `else if`s would make it impossible to generate cmov-s, and chaining branches just to avoid assigning to a local that’s almost certainly in a register is only going to make branching slower.
mov rax,rbx
test rcx,rcx
jnz L1
mov rax,rdx
L1: ret
A cmov instruction is a conditional move. It says, based on specific control flags, do a mov, otherwise do nothing. mov rax,rbx
test rcx,rcx
cmovz rax,rdx
ret
The conditional move is not really branching in the classic meaning of the word, because there is no jump (the instruction pointer unconditionally moves forward to the next instruction).Branches, even well-predicted ones, can hurt performance, especially if the jumped to code is not in cache (needs to be fetched). More detail can be found in this nice top-to-bottom video from Casey Muratori, which goes over modern CPU architecture (it's nothing too fancy, I think every person doing optimization should know this stuff): https://www.youtube.com/watch?v=8VakkEFOiJc
You can imagine the compiler generating either assembly from if style code or from ternary style code. Writing the arithmetic ternary style just happens to nudge the compiler into using cmov-s more often than other ways. For simple cases, a smart enough compiler could generate cmov-s from the if-else case as well, but it's not smart enough for even toy examples. Never mind that cmov-s are not unconditionally better -- a well-trained branch predictor is faster. The question is if your data is conducive to that or not -- something the compiler may never infer. So we're left to encode such information very indirectly via the code patterns, or via all sorts of __builtin_expect style intrinsics.
This all goes to show that C is no longer portable assembler, and there are all sorts of crucial resources on the CPU (cmov, SIMD, etc.) that may go unused based on slight differences in the C code (e.g., even little things that might seem correct, like using unsigned for an accumulator, can ruin its ability to generate cmov-s).
Compilers are really good at turning code that multiplies by a constant into a weird mov and lea that's cheaper than a mul, but they're still very spotty on everything else, and "everything else" covers a huge amount of the stuff that has made CPUs faster in the last 15+ years. Understanding your specific platform target(s) is still crucial if you want to do better than just "get lucky with the compiler sometimes".
Not nearly, exactly 100%. The 64-bit ABI specifically requires SSE2 support.
It's 100% of what matters, so in practice I'm in full agreement with you.
Using "else if" is a valid optimization in general, but not necessarily when your target is a branchless program. Using "else if" may help in some cases, but you must have in mind exactly which CMOV instructions will be generated by the compiler, to be sure that there is any improvement, i.e. any reduction in the total number of CMOV instructions, and that you are not increasing the length of some chains of dependent instructions (leading to a greater latency).