The test for numerical precision added in #226 uncovers that float32 may not be enough for 4096x4096 detectors to give sufficient numerical precision with standard reduce operations. We might have to use better algorithms like Kahan summation or calculate with float64.
For now, the test is "cheated" by using 1.0 as value, which doesn't give numerical errors.
Relevant links about error boundaries in BLAS:
https://www.netlib.org/lapack/lug/node108.html
https://software.intel.com/en-us/mkl-developer-reference-c-error-analysis
Perhaps we can introduce a job option to perform the calculations with float64/complex128, i.e. convert the data in ApplyMasksTask.__call__()? That way the user can decide between fast and precise math. IMO this is better than Kahan summation because it should improve the situation for all reduction operations, not only the sum, and works with all libraries that support float64/complex128.
Another point is that the situation is particularly bad if all values are equal because all values have the same systematic error. If the values scatter, the situation should be much better.
Edit: Tested that, but couldn't get the test to pass. We should stick with the option for higher precision.
I propose to use butterfly summation. My tests show that it provides accuracy as well as better speed compared to straight summation.
Examples: fdncs2 (preserves array data) or fdncs2m (modifies array data)
in https://github.com/ju-bar/JMultiSliceLib/blob/master/src/JMultiSlice.cpp
I've used a small test program
https://github.com/ju-bar/TestSum
for checking different summation algorithms concerning accuracy and speed. Here is the result:
https://github.com/ju-bar/TestSum/blob/master/TestSum/output.txt
Hm, interesting! Thank you for testing this @ju-bar.
Since we use the summation only indirectly as part of the numpy or torch implementation of the dot product, it would have to be changed there. I'm a bit curious how that is implemented there and if the BLAS implementation or torch could actually be improved with a better sum.
Discussions with @sk1p: The most pragmatic solution for LiberTEM is probably passing an option to the dataset to convert the values to float32 or float64 (resp. complex64 or complex128) for users to decide between speed and accuracy. The dataset is the best place because, for example, K2IS has to be unpacked anyway and we can unpack right into the desired type, eliminating a copy step. Going to float64 rather than changing the sum algorithm will improve the accuracy of all calculations in all libraries, not only the sum in specific places.
The most pragmatic solution for LiberTEM is probably passing an option to the dataset to convert the values to float32 or float64 (resp. complex64 or complex128) for users to decide between speed and accuracy.
In the low-level DataSet interfaces, this is now possible: you can specify your desired dtype for get_tiles(...). This parameter sets the dtype for the initial buffer inside of the dataset (conversion is done while reading the data); we just need to expose this to the user.
For mask application, we also need to specify the desired mask dtype (currently, the dataset determines the mask dtype, which is not always what we want). Together with the data dtype, we can then determine the result dtype (see also: np.result_type). I'm not sure about the efficiency of doing np.dot(f4, c8) vs. converting on reading and then doing np.dot(c8, c8) - in my tests they were surprisingly close.
While working on an implementation, I was running into an additional thing to consider: Since our masks are generated with factories, we don't know what dtype they have until they are generated. However, we do need this information on the client to allocate the result buffer, in case the masks contain complex values, for example.
As a solution, I'd propose to allow passing length and dtype as optional parameters with a mask job. If the user sets them properly, she can avoid generating the mask stack on the client just to check what is in it. Most notably, this will be the case for many analyses where we know exactly what's going to be in the mask stack. However, if the mask stack is quick to generate and the user likes convenience, the parameters can be omitted because they are autodetected from an ephemeral mask stack.
As a general design principle, we need a forward and a backward channel for dtype determination: We should allow user overrides, and we should allow autodetection from dataset and mask dtypes.
As far as I am aware, the dtype has to be considered at the following points:
Channels:
We should probably be able to query independently what the native dtype of a dataset or maks stack is, and what dtype it is configured to produce. A dataset should be able to produce different dtypes depending on what a job requests.
Here are some thoughts on what behavior could be correct: