-
Notifications
You must be signed in to change notification settings - Fork 2.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix math.round floating point bug #14757
Conversation
Did some tests: Average for 10 runs of the below functions:
The new one is unfortunately slightly slower, but better than the previous, buggy one? Code:
|
This is a perfect candidate for unit testing. https://github.com/minetest/minetest/blob/master/builtin/common/tests/misc_helpers_spec.lua |
By this you mean the test code I wrote or something different? |
By this I mean |
One "dumb" way to test correctness is to test against a naive rounding based on stringification (note: you need to stringify to IIRC 17 significant digits). Then you can plug in edge cases as well as random numbers. I agree that at least a simple unit test for the previous incorrect behavior should be added - if only to prevent a regression in the future, where someone thinks this can be simplified and optimized by using I'm fairly certain that this implementation is correct however: I believe It does not suffer from the issue of the previous implementation (losing the least significant bit to floating point rounding when doing As for performance: I tested with the following variation of your code: local function old_round(x)
if x >= 0 then
return math.floor(x + 0.5)
end
return math.ceil(x - 0.5)
end
local function new_round(x)
local int, frac = math.modf(x)
if frac >= 0.5 then
return int + 1
end
if frac <= -0.5 then
return int - 1
end
return int
end
local a = {}
for _ = 1, 100000 do
table.insert(a, math.random() + math.random(-100000000, 100000000))
end
local function test_old()
local t1 = os.clock()
local sum = 0
for i = 1, #a do
sum = sum + old_round(a[i])
end
assert(sum ~= 123456)
return (os.clock() - t1) * 1000
end
local function test_new()
local t1 = os.clock()
local sum = 0
for i = 1, #a do
sum = sum + new_round(a[i])
end
assert(sum ~= 123456)
return (os.clock() - t1) * 1000
end
local function test_avg(f, name)
local sum = 0
for _ = 1, 100 do
sum = sum + f()
end
print(name..": "..sum.." ms")
end
test_avg(test_old, "Old")
test_avg(test_new, "New") This differs from your benchmark in that it aims to reduce the benchmarking overhead by using a numeric for loop and simply summing the rounded values, which should be cheaper than putting them in a table. It also uses I see no need to test this in Minetest; it can be directly tested on the Lua 5.1 / LuaJIT interpreters. On Lua 5.1, there is no notable difference on my machine. On LuaJIT however, the latter is ~3x slower. This doesn't improve if the number of iterations is increased, suggesting this is not an unfortunate GC cycle or JIT kicking in at an unfortunate time. |
I experimented a bit, and I think I've found a way to implement this which has comparable performance, and even performs better on PUC Lua 5.1: function math.round(x)
local frac = x % 1
local int = x - frac
return int + ((frac >= 0.5) and 1 or 0)
end Note that since e.g. This handles +/- 0.49999999999999994 correctly. It also seems to work for random numbers. Modulo is defined as I am not yet certain that this implementation handles all edge cases correctly, but it is promising. We could also write this as function math.round(x)
local int = math.floor(x)
local frac = x - int
return int + ((frac >= 0.5) and 1 or 0)
end This loses the performance edge on PUC however, due to the |
Tried your math.random using the % operator and its behavior is slightly different from Minetest's previous implementation. Your new one:
Old:
My new:
Edit: I think the results should be symmetrical relative to 0, otherwise we get different results for negative vs positive numbers. Think about flipping/rotating a vector. You can get different results depending on whether you first negate and then round, or first round then negate. These produce different results and could introduce reproducibility bugs. |
I agree, thanks for pointing out this edge case. Here's one fixed version: function math.round(x)
if x < 0 then
local int = math.ceil(x)
local frac = x - int
return int - ((frac <= -0.5) and 1 or 0)
end
local int = math.floor(x)
local frac = x - int
return int + ((frac >= 0.5) and 1 or 0)
end Unfortunately, this loses some performance on JIT. It still seems to perform better than the
On PUC however, it's a bit slower:
|
I wanted to note two things:
|
eb1654b
to
390185b
Compare
Updated with appgurueu's version. I'll write a unit test another day. |
390185b
to
d693a07
Compare
Added a bare bones unit test. Haven't tested it because was too lazy to install that busted thing. Otherwise I think this is ready to go. |
I think this function shown at stackoverflow should also work for positive function round(n)
return math.floor((math.floor(n*2) + 1)/2)
end It performs the following operations if I understand it correctly:
|
That would do, but
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unittest passes and matches the old math.round behavior expect for the last two asserts.
IIRC, out math.round was designed to do exactly the same rounding as if you'd pass e.g. an unrounded vector to inline v3s16 doubleToInt(v3d p, double d)
{
return v3s16(
(p.X + (p.X > 0 ? d / 2 : -d / 2)) / d,
(p.Y + (p.Y > 0 ? d / 2 : -d / 2)) / d,
(p.Z + (p.Z > 0 ? d / 2 : -d / 2)) / d);
} |
Does that include the buggy rounding of 0.49999999999999994 to 1 ? Edit: I'm too dumb to do the C++ thing |
Looks like it. ¯\_(ツ)_/¯ |
Well, if that's what your |
A quick
so we can basically get rid of
So we'd get inline v3s16 doubleToInt(v3d p)
{
return {std::round(p.X), std::round(p.Y), std::round(p.Z)};
} |
Using |
HybridDog, appgurueu's math.random works fine. Is there any advantage of using what you just proposed? |
if x < 0 then
local int = math.ceil(x)
local frac = x - int
return int - ((frac <= -0.5) and 1 or 0)
end
local int = math.floor(x)
local frac = x - int
return int + ((frac >= 0.5) and 1 or 0) To shave more lines, why not remove the if statement in favour of: local int = x < 0 and math.ceil(x) or math.floor(x)
local frac = x - int
return x < 0 and int - ((frac <= -0.5) and 1 or 0) or int + ((frac >= 0.5) and 1 or 0) A bit more complicated to read, but should be completely functional and the behaviour nonetheless the same. I haven't tested the code myself yet though Is the if statement intentional? Seems weird to define frac and int twice in one function |
It is possible to "shorten" this, but that's not the point. I want this to be somewhat readable and performant. I think the current form achieves this well enough.
These definitions do not conflict since they are in different scopes. If you prefer it, you could write it using |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that fixing the C++ doesn't have to happen in this PR.
(It's also potentially a bit trickier / more work than anticipated: doubleToInt
isn't the only relevant function, there's also floatToInt
. And there the d
parameter is unfortunately used due to our BS
BS.)
fixes #14755
Nothing to do
This PR is Ready for Review.
How to test
Try these: