Pages

Monday, September 20, 2010

On the hazards of integer arithmetic

A short essay on why integer arithmetic is 'bad'. I'm not out to start a holy war, just ran into some trouble having to do with some of the facets of using integers...

My simulator, as a base component, needs to compute energies according to some model. We'd like it to actually agree on what it computes with the parameter sets and models used in various literature sources. It does just fine (and has for a while) on the current NUPACK parameters, but I recently needed to use a different parameter set to test some parts. It's been a while since I used it and due to the nature of the model it's actually a different underlying implementation.

So, that's the setup. The test set is ~55k different test cases, in 6 different classes of test (the largest class, 32k, the smallest, 2). My program tested fine for nearly every test case, except exactly 2048 from the class with 33344 [why yes, I did mean 'k'] test cases, and 8 from the class with 4956 test cases.

After several tries at bucketing my cases vs the 'good' ones to figure out which part of the distribution had this failure, I ended up with these two sets:
In [122]: buckets7
Out[122]:
[{'(10, 2)': 256,
'(13, 2)': 256,
'(14, 2)': 256,
'(2, 10)': 256,
'(2, 13)': 256,
'(2, 14)': 256,
'(2, 22)': 256,
'(22, 2)': 256}]
In [123]: buckets8
Out[123]:
[{...
'(10, 1)': 2304,
'(11, 2)': 256,
'(12, 2)': 256,
'(15, 2)': 256,
'(16, 2)': 256,
'(17, 2)': 256,
'(18, 2)': 256,
'(19, 2)': 256,
'(20, 2)': 256,
'(21, 2)': 256,
'(23, 2)': 256,
'(24, 2)': 256,
'(25, 2)': 256,
'(26, 2)': 256,
'(27, 2)': 256,
'(28, 2)': 256,
'(29, 2)': 256,
'(30, 2)': 256,
... }]
Note that the ...'s are where I chopped symmetric entries, or all the ones that had a 1,x or x,1 [A large part of the test set.]. There are some very suspicious absences in the 'good' list, notably the two sets do not intersect at all!

So, what's going on here? Now we get to the hazards of integer arithmetic. As it happens, the energy for these cases has a particular dependence on the sizes [or rather the sum of the sizes, e.g. (12,2) we care about n = 12+2 = 14]. For large n, we know that energy(n) ~= energy(0) + k * log(n), for some constant k and initial value energy(0). As it happens, this approximation is not a good one for our actual physical system at small n, so what we do is get a lot of data about those small n and get really good numbers to fit those. For all the parameter sets we know that for n > 30 we can just use the logarithm approximation. Some of these sets have every value of n up to 30 specified, but depending on the quality of the set and other factors others may only have n <= 9 specified in the parameter set. Now in order to compute the energy we would prefer to just look up a value rather than take an expensive logarithm to do so, so the normal practice is to store a lookup of values energy(n) for n <= 30, doing the log if we get a n > 30 (unlikely for our systems). When the parameter set doesn't define all those, we need to compute them. This is easy though, as we can just use the logarithm approximation to figure out the rest; all we need is that same k we needed for n > 30: if the last measured parameter is at length j (so we know energy(j) but not energy(j+1) for our parameter set), we can compute lengths j+1,...30 using the log.

With me so far?

Oh, what about integers? Well, you'd think that given the use of logs and so on, these parameters might be real numbers and not integers, and in theory that's actually the case. In practice, though, all the energies are measured to 2 significant digits (e.g. '2.45 kcal/mol') and so some programmers decided that it was better to do everything in integer arithmetic. So everything gets multiplied by 100 and stored as an integer; this makes a lot of sense historically due to how cheap it is to perform computation on integers vs floating point numbers. Nowadays that's not really an issue [I think?], though.

So, let's spot the error. For the moment, assume k = 107.5 (or so) is our constant, and we know position l has the last measured value.

From simple log arithmetic, we know:
log(a/b) = log(a) - log(b)

So if we have a sequence of numbers x[0], x[1], ... x[l], and know that they are logarithmic in distribution, we can then calculate subsequent numbers:
 x[j+1] = x[j] + k * log((j+1) / j)
So for any position j < m <= 30, we can calculate:
 x[j] = x[j] + k * log((j+1)/j) + k * log((j+2)/(j+1) + ... + k * log((m) / (m-1))
      = (x[j] + k * log((j+1)/j) + k * log((j+2)/(j+1) + ...) + k * log((m) / (m-1))
      = x[m-1] + k * log(m / (m-1))

Or, grouping them the other way:
 x[m] = x[j] + k * log((j+1)/j) + k * log((j+2)/(j+1) + ... + k * log((m) / (m-1))
      = x[j] + (k * log((j+1)/j) + k * log((j+2)/(j+1) + ... + k * log((m) / (m-1)))
      = x[j] + k * log(m / j)

So, either method gives you an equivalent algorithm for generating all the values up to 30, but there's two important differences: The first method you 'remember' one piece of information as you go 'x[m-1]', and the second remembers 'j' (which is then used because you also need x[j]). When implementing this, I used the first method: our other parameter sets all are floating point numbers anyways and thus I can avoid storing 'j' in addition to the normal loop variable.

Now, even though the algorithms are equivalent on the reals, on integers (with our k) they differ by 1 in several places due to compounded rounding issues:

index 11: 434 vs 434
index 12: 443 vs 444
index 13: 452 vs 452
index 14: 460 vs 460
index 15: 467 vs 468
index 16: 474 vs 475
index 17: 481 vs 481
...
index 23: 514 vs 514
index 24: 519 vs 518
index 25: 523 vs 523
...

And there you have it. Integer rounding issues leading to a very slight disagreement in the computed energy, and causing me to track down the problem. At the precisions I deal with, the right solution probably is to convert things into floating point at the input stage - the later operations on energy all need floating point, but on the internals side it's a pain in the neck to transfer everything.

6 comments:

Mike^2 said...

I think using integer arithmetic is completely safe...... as long as your values are genuinely perfect-precision integers that fit easily into 32 bits. :) (Or I guess more recently, 64 bits.) When they're not - as in your case here - then yeah, problems.

It's always kind of fun (for certain definitions of "fun") to play "spot the pattern" with the rounding game, although this is a different variation than the situation I usually run into. I'm more familiar with trying to reverse-engineer some formula that operates on floats when I only have the resultant rounded ints; for example, the formula for crafting XP in FFXIII. There's got to be some reliable way of analyzing the - it's almost an interference pattern - the spots where the rounding obviously switched directions, but I haven't figured out how to do it yet.

On a different tangent, for some of this stuff I'd really appreciate a low-level math primitive that's a value&precision pair so you can just input values like "3.92 ± 0.005" and all the math just takes care of itself. You can do it yourself or use a library for it, of course, but it'd be nice to have it at the processor level.

Lemming said...

Hoo boy, yeah. Wow. Yeah. This kind of thing... this is pain.

First of all, the old school conventional knowledge about integer versus floating point arithmetic? Throw it out the window. Aside from possibly division (mainly in cases like division by 2^n), floating point and integer operations take the same amount of time on modern CPUs.

Another part of the problem is that, while integer arithmetic is usually "exact" (except for overflow, underflow, and the unidirectionality of division), what's going on here is actually a form a finite precision arithmetic... with a REALLY large ULP.

At that point, you've got to follow the rules of floating point arithmetic. For example, "=="is NOT your friend. There are cases I could show you where the exact same floating point math operations get called, in the exact same order, and yet precise equality is violated. Ultimately, you have to restrict yourself to checking if things are close, and the range you have to check in is always at LEAST a ULP.

So what's a ULP? It's a Unit of Least Precision. For single and double precision arithmetic, it's in the neighboorhood of 1e-8 and 1e-16 times the value stored. Here, a ULP is *one*.

Ultimately, your two sets of values are probably very similar in quality, but... they are just different approximations, at the end of the day.

Mike^2: Good, well-implemented libraries doing what you asked abound, actually. They're called "interval arithmetic" libraries, from the notion that instead of storing 3 +/- 0.5, they store (2.95, 3.05), and define all the natural arithmetic from there. There's a problem, though --- they can only really be used to compute an upper bound, and that upper bound can get way out of control very quickly. Here's a piece of example code, which while exaggerated in its behavior, exemplifies what goes wrong. Let "x" be some interval, imagine the same 3 +/- 0.5, and then...

(sorry, pre tag isn't allowed)

interval y = x;
for(int j=0; j<10; ++j) {
y = 2 * y;
y = y - x;
}

At the end, by interval arithmetic, your error bars have exploded, but in this particular case, the results in floating point arithmetic would have been exact (except for some very large values of x... details omitted).

This is kind of a straw man example, I admit, but the problem is very real, and it's partially why the field of numerical analysis exists.

Lemming said...

Just one small amendment... I think it's worth being careful about the nomenclature, and pointing out that it's not integer arithmetic, or rounding of integers, that's the problem. It's rounding to integers, rather. The computations in the middle are floating point operations, no two ways about it, they're just being stored as --- cast or rounded to integers --- at which point information is getting lost, and a ULP of error is possibly being introduced each time.

Zifnab said...

Yep, I did mean it's the combination of floating point arithmetic with the integer casts that's actually the problem - I had set this to post at 6pm if I hadn't done any more writing on it, and since I crashed after having no sleep sunday night it went out as is.

Interval arithmetic libraries can be very useful, but I haven't had a chance to use them much [most of my stuff doesn't really deal with error bars, either being exact computation or with numerical accuracy way higher than our parameter precision. If you know of a good one though, I have run into a case lately where I am doing statistical data collection and being able to track error bars over things like exponentiation would be handy. (Though really, our distribution is exponential, so if you are looking and see a standard deviation equal to the mean, that's normal...)

I do appreciate the NumPy function allclose as a suitably powerful replacement for ==, just our systems never really need to do boolean tests on energies anyways. We do need to do boolean tests on computed rates, but they are never equality tests - usually <= at worst. But all those rates are natively doubles anyways, so we haven't really been worried about it [except once a while ago when the RNG used did not have enough precision to ensure all our states were actually explored.].

Lemming said...

I'm actually grateful that Python 3k is switching to "division means division, if you want integer division you have to ask for it". Accidentally typing "/2" instead of "/2." (or better, instead of "* 0.5") in C++ has given me hard-to-find bugs a number of times.

Speaking with Mike^2 the other night, Delphi has pretty much always been this way --- its heritage (language-wise) has a bit more design rigor that Python or C/C++.

Another alternative is OCaml. I don't know if it's still the case, but when we were using it, floating point operations were the exception --- the standard four operators are integer only, and you have to postfix a dot to get the floating point versions. Not my preference (especially since I do so much numerical work), but it also cuts down on the number of surprises.

Zifnab said...

Agreed, I like it always meaning a particular thing. It makes a vague sort of sense in a strongly typed language like C that you only need one version and it's the types that define which it is; the problem is when a common situation involves a constant, and thus the type inference isn't always obvious to the programmer where it would be if it was a variable they were using. In my particular situation though, it wouldn't have helped to have either standard - the operation that was causing differences was log, taking a double, returning a double, and the rounding was always in the same place but my iterative usage created different rounding edges than the direct application of the log. Alas.