Implementing Cosine in C from Scratch (2020)
austinhenley.com
austinhenley.com
By selecting an appropriate norm (in function space) you can minimize either the maximum error or the error integral over some range (e.g. the 0-\pi range).
Here's a video on the subject. You might want to watch earlier ones also for more context.
https://www.youtube.com/watch?v=tMlKZZf2Kac&list=PLdkTDauaUn...
Full disclosure, this is my university lecture on optimization that was recorded during Covid.
https://xn--2-umb.com/22/approximation
If you want a proper textbook, I recommend L.N. Trefethen (2019). “Approximation Theory and Approximation Practice.” Which is available online here:
The main idea is to use linear programming with some chosen points from the interval to look for the polynomial that approximates the chosen function over that interval. Unlike Remez, this enables control over which individual points from the interval are chosen as representatives, which enables avoiding ill-behaved points. An example of where this leads to improvements over Remez is when optimizing relative error: Remez would trip over zeros of the function, because they cause singularities in the relative error, however my algorithm works (by avoiding ill-behaved points) as long as the discontinuities are removable.
My algorithm is also a lot more flexible than Remez', for example it allows optimizing over multiple intervals instead of a single one.
The Git repo of the (still in-progress) project is here: https://gitlab.com/nsajko/FindMinimaxPolynomial.jl
The Julia package is already registered (installable from the official Julia registry): https://juliahub.com/ui/Packages/FindMinimaxPolynomial/kNIo8...
1. Their LP constraint matrix is just a Vandermonde matrix according to the linked paper, while my code formulates the LP problem in a smarter way that should make the job much easier for the LP solver. I guess this improvement would be easy to adopt on their end.
2. The linked paper focuses on tiny types like BFloat16, but they mention scalability with large data types as a future goal. My focus was on larger types like IEEE-754 binary64 (double in C) from the start.
3. They account for range reduction and output compensation! This seems really nice, I hope that whatever they did can be applied to my approach to improve it.
https://hg.hedgewars.org/hedgewars/file/tip/hedgewars/uSinTa...
I think there's even a github mirror someone made since that's where you were looking. Maybe a little out of date, but this is old code.
Some examples:
https://sourceware.org/git/?p=newlib-cygwin.git;a=blob;f=new...
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/lin...
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/lin...
1. Reduce x = x mod 2*pi
2. Reduce index = floor(x / 10^-n), and i - index = 10^n * (x mod 10^-n)
With limited input range and required precision as in the tests, you can combine these 2 range reduction steps:
1. Choose the reduced range as power of 2 instead of power of 10 for cheaper modulus operation, let say 2^-N = 2^-7.
2. Avoid the division in modd(x, CONST_2PI) by multiplying by 2^N / pi.
3. Avoid the round trip double -> int -> double by using the floor function / instruction.
Here is the updated version of cos_table_*_LERP which should have higher throughput and lower latency:
double cos_table_128_LERP(double x) {
x = fabs(x);
double prod = x * TWO_TO_SEVEN_OVER_PI;
double id = floor(prod);
double x = prod - id; /* after this step, 0 <= x < 1 */
int i = ((int)id) & 0xFF; /* i = id mod 2^8 */
return lerp(x, COS_TABLE[i], COS_TABLE[i + 1]);
}
You can also optimize lerp a bit more with the formula: lerp(w, v1, v2) = (1 - w) * v1 + w * v2 = w * (v2 - v1) + v1
We do employ this range reduction strategy in a more accurate way for trig functions in LLVM libc: - with FMA instructions: https://github.com/llvm/llvm-project/blob/main/libc/src/math/generic/range_reduction_fma.h
- without FMA instructions: https://github.com/llvm/llvm-project/blob/main/libc/src/math/generic/range_reduction.h> We can do better since the cosine values are equivalent every multiple of pi, except that the sign flips.
There is one more step to take: Each π/2 long segment has identical shape, they are just pointing up or down (like you already noticed), and left or right. So you can reduce you basic domain down to not just 0...π, but to 0...π/2.
Those who know more about this: educate me.
If your lookup table has an M-bit index, the index to the lookup table is calculated with: index = (unsigned)theta>>(N-M), where theta in the N-bit integer representing the angle.
The fractional part, which can be used for interpolation, is: theta & ((1<<(N-M))-1).
If you choose M=N=word size (16 bits is often nice), the angle can be used directly as an index. With 16-bits accuracy is typically good enough without interpolation and the entire trig operation reduces to an array indexing operation.
This minimises the overhead of converting an angle to a table index.
(Issue being that the deterministic rounding is a biased estimator of the sin function)
There are a bunch of places where randomized rounding works better. But is there any computer hardware that implements this? Obviously the internal precision (of the random element) can't be infinite, but a couple of extra bits would seem worth the complexity.
This is the approximation that I used in for the animated sinwave example for SectorC:
y ~= 100 + (x*(157 - x)) >> 7
https://github.com/xorvoid/sectorc/blob/main/examples/sinwav...
Add a conditional that applies (1-result) for some angles and you're golden.
Faster Math Functions: https://basesandframes.files.wordpress.com/2016/05/fast-math... https://basesandframes.files.wordpress.com/2016/05/fast-math...
Even faster math functions GDC 2020: https://gdcvault.com/play/1026734/Math-in-Game-Development-S...
So we really only ever need to be able to compute cos in the range 0-pi/4.
Then for further accuracy we can do the taylor expansion around pi/8. (or other approximations)
finally the number of terms for a fixed accuracy varies with the distance from pi/8,
It was a rabbit hole for sure
https://github.com/PollRobots/scheme/blob/main/scheme.wasm/s...
[Edit: above is just the range from 0-π/4, the branch cuts for cos are at https://github.com/PollRobots/scheme/blob/main/scheme.wasm/s... ]
It would have been nice to have some discussion of accuracy requirements rather than just eyeballing it and saying “more accuracy than I need”. This is a spot where inexperienced devs can easily get tangled up. An attitude like “ten digits? That’s so many! I’m only making a game, after all” is exactly the sort of thing that gets you into trouble if you start accumulating errors over time, and this is particularly easy to do with trig functions.
See here [0]
[0] https://www.reddit.com/r/calculators/comments/126st95/cos157...
Using HiPER Calc on Android
See here: https://www.hpmuseum.org/cgi-sys/cgiwrap/hpmuseum/archv017.c...
And you can compare to wolfram alphas result.
Nearly all libm implementations around are based off Sun's fdlibm from the 80's-90's, including a bunch of the variants cited below, *bsds, etc. They are basically updated slightly, but you can see their heritage in the structure of the code. The original is found on netlib these days: https://www.netlib.org/fdlibm/k_cos.c
14th order polynomial, but only uses ~7 terms. It's supposed to have error < 1 ULP. For fdlibm, it's pretty readable compared to some of the other fun ones. I seem to remember sqrt being a bit gnarly.
Implementing Cosine in C from Scratch (2020) - https://news.ycombinator.com/item?id=30844872 - March 2022 (134 comments)
Implementing cosine in C from scratch - https://news.ycombinator.com/item?id=23893325 - July 2020 (20 comments)
1. Reduce x = x mod 2pi
2. Reduce index = 10^n * (x / 10^-n), and i - index = 10^n * (x mod 10^-n)
With limited input range and required precision as in the tests, you can combine these 2 range reduction steps:
1. Choose the reduced range as power of 2 instead of power of 10 for cheaper modulus operation, let say `2^-N = 2^-7`.
2. Avoid the division in `modd(x, CONST_2PI)` by multiplying by `2^N / pi`.
3. Avoid the round trip `double -> int -> double` by using the `floor` function / instruction.
Here is the updated version of `cos_table__LERP` which should have higher throughput and lower latency:
``` double cos_table_128_LERP(double x) { x = fabs(x); double prod = x TWO_TO_SEVEN_OVER_PI;
}
```And you can even use the same lookup table for each. That means with 2 table lookups in a 32 entry table, a single multiply and add (and a few bit shifts), you can get ~9 bits of precision in your result, which is fine for most uses. It also probably makes a sin operation take ~1 clock cycle on most superscalar architectures, as long as your workload has sufficient instruction parallelism.
Note that a smaller table typically works out faster because 32 entries fit in the cache, whereas repeated random entry into a 1024 entry table would probably kick a bunch of other stuff out of the cache that you wanted.
Then, you could do a 2nd order fit like a spline instead of a straight line between your table values.
Betcha it would be crazy fast.
function move(obj) {
obj.x += obj.speed * Math.sin(obj.rotation);
obj.y += obj.speed * Math.cos(obj.rotation);
}
"Why not store the velocity as a 2D vector instead? Then you still have to use cos/sin to compute this vector, but at least you don't need it every frame, plus often you don't need to use cos/sin to compute this vector either since forces that act on the velocity themselves can have an x and y component you can directly add to it
In 2D there's no gimbal lock of course, but there's still a number that behaves differently than the others, the angle wrapping around in range 0..2pi!
And of course the article is about fast computation of sin/cos so the option of not computing it should be considered
I also think, despite various sources on the internet saying "quaternions are the solution to gimbal lock", that classical 3D vectors and 3D matrices without quaternions also perfectly solve it.
Pun intended?
I’d disagree. Quaternions are the 3D equivalent (yes, I’m aware of the subtleties here, but speaking in a rough sense) to using complex numbers to represent rotations.
Euler angles are much closer in spirit to polar coordinates, but unfortunately have gimbal lock.
Compared to 3 numbers and a series of operations which happen to rotate a 3D vector.
Regardless I think euler angles should not be taught except as a dont do this, its a bad idea. Mostly because when used in practice, in order to interpret them you must convert them to quaternion to do so, which is very telling. Gimbal lock is a secondary problem to that.
The method is different, and the OP hasn’t mentioned it — high-degree minimax polynomial approximation.
If I ever want better solutions for some programming problem, I'll write a post about it and try to get it in frontpage of HN :)
e^x=(1+x/n)^n
pick n=2^N, and that's just a bit shift and N repeated multiplications. Probably much faster and accurate.
Anyone who wants performance measures angles using n bit fixed point math, mapping 0 to 2*pi as 0 to (2^n)-1.
https://news.ycombinator.com/item?id=35381968 Cosine Implementation in C
Much better approximation with only 7 terms
https://people.inf.ethz.ch/wirth/ProjectOberon/Sources/Math....
I sure did stare at that for way too long.