Littleknown details of Math.Round() implementation in .Net
Recently, I had to analyze one client’s bug report for our program, where the client pointed out an error in the report of one penny. It would seem that it is difficult to imagine a programmer or an IT specialist in general who does not know how the rounding function works. However, almost twenty years of development experience is not a panacea in this case. Having dealt with the root of the problem, I looked for materials on the Russian and Englishlanguage Internet, and if there are thematic selections of materials in English, I did not find them in Russian, and even more so on Habre. Therefore, I hasten to share the collected and systematized material with Habra readers.
(UPD) Mini disclaimer – the article was originally written in 2013, it was lying in the sandbox, I recently dug it up on occasion and published it. Since then, something has changed a little, for example, starting with .Net Core 3.0, the rounding modes are not 2, but 5. And in Excel 2016, the problem no longer occurs, although another one does.
Contents
Saying
If you don’t like details, skip to the next section.
It all started when customers reported a penny discrepancy in the report. The program rounded the value 7.145 to 7.14. I checked all the calculations on the calculator, got 7.145, I was glad that, at least, the bug is repeated.
The first suspicion fell on the use of incorrect data types (float or double instead of decimal). Checked the code again, no problem found. Created a new unit test for this situation, ran it, the test failed quite expectedly. Then he started the debugger and began to watch all the stages of calculations (a rather complicated formula is used there, the value is rounded at the end, I decided to check what happened before rounding). Before rounding, I see the correct value of 7.145. I don’t believe my eyes, I put the value in the watchwindow, check the type again (Decimal, everything is as it should be), add rounding in the watchwindow, I get 7.14. I pinched myself just in case and began to substitute different values in Math.Round with my hands, to check what else could be wrong. When 7.155 quite expectedly rounded to 7.16, and 7.165 also to 7.16, I began to suspect that it was not me who was crazy. Went to MSDN and found…
How rounding is implemented in .Net
Math.Round supports two rounding modes:

System.MidpointRounding.AwayFromZero is the usual rounding method, +0.5 rounds to 1 and 0.5 rounds to 1. That is, to the side with a large modulus.

System.MidpointRounding.ToEven – “even” rounding, 0.5 is rounded to 0 and 1.5 to 2.
The Math.Round method itself has several overloaded options: for Double and for Decimal, with and without specifying the number of decimal places in the result, with and without specifying the type of rounding.
The most interesting thing is that the System.MidpointRounding.ToEven rounding algorithm is used by default. It is written about this in MSDN, of course, but personally, for example, I believe that the principle “if nothing helps, finally read the instructions” is quite logical and I follow it to the best of my ability.
After digging around on the Internet, I gathered information about
Why do you need several rounding modes
MSDN limits itself to the remark “It conforms to IEEE Standard 754, section 4.” (It corresponds to IEEE standard 754, section 4.) The standard itself is devoted to the general representation of fractional numbers in computer memory and contains quite a lot of information about why these rounding modes are needed. Moreover, only the current version from 2008 contains a mention of the “even” rounding mode, but the previous one (from 1985) was not yet aware of it. Wikipedia, in its article on rounding, calls even rounding “banking” and explains what problem it solves.
If we take a consecutive series of numbers (assuming that the input values of the rounding function are distributed more or less uniformly and randomly), then with ordinary rounding there is a cumulative rounding error. Below is an example that I sketched in Excel.
As you can see, on 20 numbers, the usual rounding mode gives an error of 1 (almost 5%). For me personally, this reminded me again that “selfevident conclusions” are usually wrong – my everyday consciousness always assumed that 5 times rounding to 0 and 5 times rounding to 1, so the sums of the original sequence and the rounded one must converge. It does not.
Another interesting story about this rounding mode was told to me by the same client who reported this bug. Previously, she worked as a mathematics teacher at school for a long time and about 30 years ago she saw the moment when the curriculum and, in particular, the teaching of rounding rules were changed. Until that moment, at school they gave rounding up to the even, and then everyone began to be given a simpler and less correct scheme. The Soviet school was then ahead of the American engineering community.
Excerpt of lessons
To be honest, after diving into the problem I was a bit confused 😉 In my opinion, the .Net developers planted a stink bomb here. Such behavior does not fit into the general scheme of other products of the same Microsoft.

The ROUND function in MSSQL can do rounding only in the usual “from scratch” mode.

The ROUND function in MS Excel can only round from zero in the usual mode.

MS Excel has an ODD function that rounds to even, but it only rounds to integers, the precision cannot be specified.

The Math.Round function in .Net suddenly rounds “even” by default. A little less than all programmers use rounding without specifying a mode, and a little less than all customers when checking a product compare it with calculations in Excel where ROUND is used. Fun with particularly picky customers is guaranteed.

There is no global rounding management mechanism (like locale settings, date format, for example) in .Net.
For ourselves, in the product, we decided that the error is small and rare, it is easier to make rounding as in Excel, than to explain to everyone that they have considered it wrong all their life. Moved the rounding logic to its own wrapper class, which can be controlled via client settings. If a client is fundamentally correct, it will be possible to include bank rounding and not bother others.
Sweet
A little later, another good report about rounding came from the same customers.
If you calculate on a calculator, then 1.58*25%=0.395 should be rounded to 0.40 in Excel, but this does not happen. If the number is entered by hand, it is rounded correctly. In this case, of course, the error occurs because the formulas in Excel are calculated using inaccurate data types. If you show more signs, the picture becomes clearer.
Everything seems clear and understandable, but the trust in Excel has noticeably decreased. It turns out that the tool, which perceived the same “everyday consciousness” as a source of reference data to which the results of the program should converge, is no longer so.
UPD (23.03.2024):
The example above does not repeat in all versions of Excel (there is an article from Microsoft), for example, in the desktop version of Excel 2016 (build 17328.20184) it does not repeat, but it reproduces a nonzero result when multiplying 1 by 0 perfectly:
The moral of this whole story is as follows:
Dear colleagues, be attentive and careful!