Могу ли я детерминистически суммировать вектор произвольно расположенных чисел с плавающей запятой?

Скажем, у меня есть (возможно, большой) вектор чисел с плавающей запятой, полученный в результате некоторого процесса черного ящика. Можно ли вычислить побитовую воспроизводимую сумму этих чисел?

Если процесс «черный ящик» всегда выдает числа в одном и том же порядке, то побитовое воспроизводимое суммирование выполняется легко: просто суммируйте их слева направо.

Но если числа генерируются в случайном порядке, возможно, потому, что они возвращаются и собираются из асинхронных процессов, тогда сложнее: мне пришлось бы сортировать их числа.

Но что, если номеров еще больше, возможно, распределенных по разным машинам, так что их нежелательно перемещать?

Есть ли еще способ детерминистически суммировать их?


person Richard    schedule 08.05.2021    source источник
comment
Интересный вопрос и ответ, но было бы полезно явно указать числа с плавающей запятой. Числа, которые я суммирую, обычно представляют собой целые числа, которые не так требовательны к обслуживанию.   -  person j_random_hacker    schedule 08.05.2021
comment
@j_random_hacker: Исправлено, спасибо!   -  person Richard    schedule 08.05.2021
comment
Вы можете настроить переменную с фиксированной запятой с 2**256 цифрами для float32 (плюс немного резерва в зависимости от максимального количества входных данных) или 2**2048 цифрами для float64.   -  person chtz    schedule 09.05.2021
comment
побитовая воспроизводимость звучит так, как будто вы хотите что-то, что вы можете проверить на равенство. Иногда полезно хешировать значения вместе и выполнять проверку на равенство. Не уверен, что это уместно здесь, хотя вопрос интересный!   -  person Sam Mason    schedule 13.05.2021
comment
@SamMason: Это правильно. Цель состоит в том, чтобы передать значения через некоторый процесс, а затем проверить, являются ли выходные данные инвариантными между различными версиями кода. Однако для того, чтобы это работало, процесс должен быть детерминированным, что сложно, если задействованы определенные формы параллелизма.   -  person Richard    schedule 13.05.2021
comment
@ Ричард, почему бы тогда просто не интерпретировать числа с плавающей запятой как биты? например просто просмотреть double как uint64_t и просто суммировать целые числа? ассоциативность пришла бы бесплатно, а параллельные суммы тривиальны. вы также можете суммировать двойники, чтобы их мог увидеть человек, но тест будет просто смотреть на биты   -  person Sam Mason    schedule 13.05.2021
comment
Если бы я делал это, не читая никаких статей, я бы сгруппировал числа с одинаковым показателем (есть 11 бит, поэтому 2048 разных показателей), точно суммировал их мантиссы (как большие числа, не отбрасывая биты), а затем преобразовал обратно с плавающей запятой, затем, наконец, отсортируйте и суммируйте частичные суммы. На последнем шаге нельзя распределить/распараллелить.   -  person n. 1.8e9-where's-my-share m.    schedule 27.05.2021
comment
@SamMason: это хорошая идея, но в которой вы (весьма вероятно) отбрасываете любую возможность получить точный ответ, что часто желательно, особенно если вы собираетесь использовать свое суммированное значение в каком-либо последующем расчете.   -  person Richard    schedule 27.05.2021
comment
@н. Это кажется достойной идеей, хотя вам придется учитывать специальные значения, такие как бесконечность, NaN и субнормальное значение, а также найти хороший способ обработки переполнения мантиссы (что в противном случае привело бы к увеличению степени в стандартном поплавке).   -  person Richard    schedule 27.05.2021


Ответы (3)


Обзор: многопроходный метод изменения данных

(См. здесь для лучшего ответа.)

Да, есть способ.

Способ сделать это описан в статье Быстрое воспроизводимое суммирование с плавающей запятой Деммеля и Нгуена (2013). Я включил как последовательную, так и параллельную реализацию ниже.

Здесь мы сравним три алгоритма:

  1. Обычное суммирование слева направо: это быстро, неточно и невоспроизводимо по отношению к перестановкам в порядке ввода.
  2. суммирование Кэхана: это медленнее, очень точно (по существу O(1) в размере ввода), и, хотя он невоспроизводим в отношении перестановок в порядке ввода, ближе к воспроизводимости в узком смысле.
  3. Детерминированное суммирование: это несколько медленнее, чем суммирование Кэхана, может быть довольно точным и воспроизводимым по битам.

Обычное суммирование является неточным, потому что по мере роста суммы наименее значащие цифры прибавляемых к ней чисел молча отбрасываются. Суммирование Кэхана преодолевает это, сохраняя текущую компенсационную сумму, которая удерживает младшие значащие цифры. Детерминированное суммирование использует аналогичную стратегию для поддержания точности, но перед выполнением суммирования входные числа предварительно округляются до общего основания, чтобы их сумма могла быть вычислена точно без какой-либо ошибки округления.

Тесты

Чтобы изучить алгоритмы, мы запустим тест на 1 миллионе чисел, каждое из которых выбирается равномерно из диапазона [-1000, 1000]. Чтобы продемонстрировать как диапазон ответов алгоритмов, так и их детерминированность, входные данные будут перемешиваться и суммироваться 100 раз. Параллельные алгоритмы выполняются с использованием 12 потоков.

Правильное суммирование (определяемое с помощью суммирования Кэхана в длинном двойном режиме и выбора наиболее часто встречающегося значения)

310844.700699143717685046795

Я утверждаю, что результат, который строго демонстрирует приведенный ниже код, заключается в том, что для каждого изучаемого типа данных детерминированный алгоритм дает один и тот же результат для каждого из 100 порядков данных, и что эти результаты одинаковы как для последовательного, так и для параллельного вариантов алгоритма. алгоритм.

Результаты различных алгоритмов следующие:

Serial Float
=========================================================
Simple summation accumulation type   = float
Average deterministic summation time = 0.00462s
Average simple summation time        = 0.00144s (3.20x faster than deterministic)
Average Kahan summation time         = 0.00290s (1.59x faster than deterministic)
Deterministic value                  = 256430592.000000000 (very different from actual)
Distinct Kahan values                = 3  (with single-precision accumulator)
Distinct Simple values               = 93 (with single-precision accumulator)
Distinct Simple values               = 1  (with double-precision accumulator)

Parallel Float
=========================================================
Simple summation accumulation type   = float
Average deterministic summation time = 0.00576s
Average simple summation time        = 0.00206s (2.79x faster than deterministic)
Average Kahan summation time         = 0.00599s (0.96x faster than deterministic)
Deterministic value                  = 256430592.000000000 (very different from actual)
Distinct Kahan values                = 3  (with single-precision accumulator)
Distinct Simple values               = 89 (with single-precision accumulator)
Distinct Simple values               = 1  (with double-precision accumulator)

Serial Double
=========================================================
Average deterministic summation time = 0.00600s
Average simple summation time        = 0.00171s (3.49x faster than deterministic)
Average Kahan summation time         = 0.00378s (1.58x faster than deterministic)
Deterministic value                  = 310844.70069914375199005 (epsilon difference from actual value)
Distinct Kahan values                = 4
Distinct Simple values               = 88

Parallel Double
=========================================================
Average deterministic summation time = 0.01074s
Average simple summation time        = 0.00289s (3.71x faster than deterministic)
Average Kahan summation time         = 0.00648s (1.65x faster than deterministic)
Deterministic value                  = 310844.70069914375199005 (epsilon difference from actual value)
Distinct Kahan values                = 2
Distinct Simple values               = 83

Serial Long Double
=========================================================
Average deterministic summation time = 0.01072s
Average simple summation time        = 0.00215s (4.96x faster than deterministic)
Average Kahan summation time         = 0.00448s (2.39x faster than deterministic)
Deterministic value                  = 310844.700699143717685046795 (no discernable difference from actual)
Distinct Kahan values                = 3
Distinct Simple values               = 94

Parallel Long Double
=========================================================
Average deterministic summation time = 0.01854s
Average simple summation time        = 0.00466s (3.97x faster than deterministic)
Average Kahan summation time         = 0.00635s (2.91x faster than deterministic)
Deterministic value                  = 310844.700699143717685046795 (no discernable difference from actual)
Distinct Kahan values                = 1
Distinct Simple values               = 82

Обсуждение

Итак, что мы узнали? Из результатов с одинарной точностью мы видим, что использование накопителя двойной длины делает обычный алгоритм суммирования воспроизводимым для этого набора данных, хотя это почти наверняка не будет иметь место для произвольных наборов данных. Тем не менее, это может быть дешевым способом повышения точности, когда он работает.

Когда накопитель, используемый при обычном суммировании, имеет тот же размер, что и добавляемые числа, он работает очень плохо: мы провели 100 тестов и получили почти столько же различных результатов при обычном суммировании.

С другой стороны, суммирование Каана дает очень мало четких результатов (поэтому оно более детерминировано) и занимает примерно вдвое больше времени, чем обычное суммирование.

Детерминированный алгоритм занимает примерно в 4 раза больше времени, чем простое суммирование, и примерно в 1,5-3 раза больше времени, чем суммирование Кэхана, но дает примерно одинаковые ответы как для типов double, так и для типов long double, за исключением побитового детерминизма (он всегда получает один и тот же ответ независимо от того, как отсортированы входные данные).

Однако вычисление с плавающей запятой одинарной точности дает очень плохой ответ, в отличие от обычных вычислений одинарной точности и суммирования Кэхана, которые оказываются весьма близкими к реальному ответу. Почему это?

Документ, с которым мы работали, определяет, что если во входных данных есть n чисел и мы используем k раундов складывания (в документе рекомендуется 2, что мы и используем здесь ), то детерминированная и обычная суммы будут иметь одинаковые границы ошибки при условии

n^k * e^(k-1) << 1

где e — это эпсилон типа данных с плавающей запятой. Эти значения эпсилон:

Single-precision epsilon      = 0.000000119
Double-precision epsilon      = 0.00000000000000022
Long double-precision epsilon = 0.000000000000000000108

Подставив их в уравнение вместе с n=1M, мы получим:

Single condition      = 119000
Double condition      = 0.00022
Long double condition = 0.000000108

Итак, мы видим, что ожидается, что одинарная точность будет плохо работать для входных данных такого размера.

Еще один момент, на который стоит обратить внимание, это то, что хотя long double занимает 16 байт на моей машине, это только для целей выравнивания: истинная длина, используемая для вычислений, составляет всего 80 бит. Следовательно, два длинных двойных числа будут сравниваться численно одинаково, но их неиспользуемые биты произвольны. Для истинной побитовой воспроизводимости необходимо определить неиспользуемые биты и установить их в известное значение.

Код

//Compile with:
//g++ -g -O3 repro_vector.cpp -fopenmp

//NOTE: Always comile with `-g`. It doesn't slow down your code, but does make
//it debuggable and improves ease of profiling

#include <algorithm>
#include <bitset>               //Used for showing bitwise representations
#include <cfenv>                //Used for setting floating-point rounding modes
#include <chrono>               //Used for timing algorithms
#include <climits>              //Used for showing bitwise representations
#include <iostream>
#include <omp.h>                //OpenMP
#include <random>
#include <stdexcept>
#include <string>
#include <typeinfo>
#include <unordered_map>
#include <vector>

constexpr int ROUNDING_MODE = FE_UPWARD;
constexpr int N = 1'000'000;
constexpr int TESTS = 100;

// Simple timer class for tracking cumulative run time of the different
// algorithms
struct Timer {
  double total = 0;
  std::chrono::high_resolution_clock::time_point start_time;

  Timer() = default;

  void start() {
    start_time = std::chrono::high_resolution_clock::now();
  }

  void stop() {
    const auto now = std::chrono::high_resolution_clock::now();
    const auto time_span = std::chrono::duration_cast<std::chrono::duration<double>>(now - start_time);
    total += time_span.count();
  }
};

//Simple class to enable directed rounding in floating-point math and to reset
//the rounding mode afterwards, when it goes out of scope
struct SetRoundingMode {
  const int old_rounding_mode;

  SetRoundingMode(const int mode) : old_rounding_mode(fegetround()) {
    if(std::fesetround(mode)!=0){
      throw std::runtime_error("Failed to set directed rounding mode!");
    }
  }

  ~SetRoundingMode(){
    if(std::fesetround(old_rounding_mode)!=0){
      throw std::runtime_error("Failed to reset rounding mode to original value!");
    }
  }

  static std::string get_rounding_mode_string() {
    switch (fegetround()) {
      case FE_DOWNWARD:   return "downward";
      case FE_TONEAREST:  return "to-nearest";
      case FE_TOWARDZERO: return "toward-zero";
      case FE_UPWARD:     return "upward";
      default:            return "unknown";
    }
  }
};

// Used to make showing bitwise representations somewhat more intuitive
template<class T>
struct binrep {
  const T val;
  binrep(const T val0) : val(val0) {}
};

// Display the bitwise representation
template<class T>
std::ostream& operator<<(std::ostream& out, const binrep<T> a){
  const char* beg = reinterpret_cast<const char*>(&a.val);
  const char *const end = beg + sizeof(a.val);
  while(beg != end){
    out << std::bitset<CHAR_BIT>(*beg++);
    if(beg < end)
      out << ' ';
  }
  return out;
}



//Simple serial summation algorithm with an accumulation type we can specify
//to more fully explore its behaviour
template<class FloatType, class SimpleAccumType>
FloatType serial_simple_summation(const std::vector<FloatType> &vec){
  SimpleAccumType sum = 0;
  for(const auto &x: vec){
    sum += x;
  }
  return sum;
}

//Parallel variant of the simple summation algorithm above
template<class FloatType, class SimpleAccumType>
FloatType parallel_simple_summation(const std::vector<FloatType> &vec){
  SimpleAccumType sum = 0;
  #pragma omp parallel for default(none) reduction(+:sum) shared(vec)
  for(size_t i=0;i<vec.size();i++){
    sum += vec[i];
  }
  return sum;
}



//Kahan's compensated summation algorithm for accurately calculating sums of
//many numbers with O(1) error
template<class FloatType>
FloatType serial_kahan_summation(const std::vector<FloatType> &vec){
  FloatType sum = 0.0f;
  FloatType c = 0.0f;
  for (const auto &num: vec) {
    const auto y = num - c;
    const auto t = sum + y;
    c = (t - sum) - y;
    sum = t;
  }
  return sum;
}

//Parallel version of Kahan's compensated summation algorithm (could be improved
//by better accounting for the compsenation during the reduction phase)
template<class FloatType>
FloatType parallel_kahan_summation(const std::vector<FloatType> &vec){
  //Parallel phase
  std::vector<FloatType> sum(omp_get_max_threads(), 0);
  FloatType c = 0;
  #pragma omp parallel for default(none) firstprivate(c) shared(sum,vec)
  for (size_t i=0;i<vec.size();i++) {
    const auto tid = omp_get_thread_num();
    const auto y = vec[i] - c;
    const auto t = sum.at(tid) + y;
    c = (t - sum[tid]) - y;
    sum[tid] = t;
  }

  //Serial reduction phase

  //This could be more accurate if it took the remaining compensation values
  //from above into account
  FloatType total_sum = 0.0f;
  FloatType total_c = 0.0f;
  for(const auto &num: sum){
    const auto y = num - total_c;
    const auto t = total_sum + y;
    total_c = (t - total_sum) - y;
    total_sum = t;
  }

  return total_sum;
}



// Error-free vector transformation. Algorithm 4 from Demmel and Nguyen (2013)
template<class FloatType>
FloatType ExtractVectorNew2(
  const FloatType M,
  const typename std::vector<FloatType>::iterator &begin,
  const typename std::vector<FloatType>::iterator &end
){
  // Should use the directed rounding mode of the parent thread

  auto Mold = M;
  for(auto v=begin;v!=end;v++){
    auto Mnew = Mold + (*v);
    auto q = Mnew - Mold;
    (*v) -= q;
    Mold = Mnew;
  }

  //This is the exact sum of high order parts q_i
  //v is now the vector of low order parts r_i
  return Mold - M;
}

template<class FloatType>
FloatType mf_from_deltaf(const FloatType delta_f){
  const int power = std::ceil(std::log2(delta_f));
  return static_cast<FloatType>(3.0) * std::pow(2, power);
}

//Implements the error bound discussed near Equation 6 of
//Demmel and Nguyen (2013).
template<class FloatType>
bool is_error_bound_appropriate(const size_t N, const int k){
  const auto eps = std::numeric_limits<FloatType>::epsilon();
  const auto ratio = std::pow(N, k) * std::pow(eps, k-1);
  //If ratio << 1, then the conventional non-reproducible sum and the
  //deterministic sum will have error bounds of the same order. We arbitrarily
  //choose 1e-4 to represent this
  return ratio < 1e-3;
}

//Serial bitwise deterministic summation.
//Algorithm 8 from Demmel and Nguyen (2013).
template<class FloatType>
FloatType serial_bitwise_deterministic_summation(
  std::vector<FloatType> vec,  // Note that we're making a copy!
  const int k
){
  constexpr FloatType eps = std::numeric_limits<FloatType>::epsilon();
  const auto n = vec.size();
  const auto adr = SetRoundingMode(ROUNDING_MODE);

  if(n==0){
    return 0;
  }

  if(!is_error_bound_appropriate<FloatType>(vec.size(), k)){
    std::cout<<"WARNING! Error bounds of deterministic sum are large relative to conventional summation!"<<std::endl;
  }

  FloatType m = std::abs(vec.front());
  for(const auto &x: vec){
    m = std::max(m, std::abs(x));
  }

  FloatType delta_f = n * m / (1 - 4 * (n + 1) * eps);
  FloatType Mf = mf_from_deltaf(delta_f);

  std::vector<FloatType> Tf(k);
  for(int f=0;f<k-1;f++){
    Tf[f] = ExtractVectorNew2<FloatType>(Mf, vec.begin(), vec.end());
    delta_f = n * (4 * eps * Mf / 3) / (1 - 4 * (n + 1) * eps);
    Mf = mf_from_deltaf(delta_f);
  }

  FloatType M = Mf;
  for(const FloatType &v: vec){
    M += v;
  }
  Tf[k-1] = M - Mf;

  FloatType T = 0;
  for(const FloatType &tf: Tf){
    T += tf;
  }

  return T;
}

//Parallel bitwise deterministic summation.
//Algorithm 9 from Demmel and Nguyen (2013).
template<class FloatType>
FloatType parallel_bitwise_deterministic_summation(
  std::vector<FloatType> vec,  // Note that we're making a copy!
  const int k
){
  constexpr FloatType eps = std::numeric_limits<FloatType>::epsilon();
  const auto n = vec.size();
  const auto adr = SetRoundingMode(ROUNDING_MODE);

  if(n==0){
    return 0;
  }

  if(!is_error_bound_appropriate<FloatType>(vec.size(), k)){
    std::cout<<"WARNING! Error bounds of deterministic sum are large relative to conventional summation!"<<std::endl;
  }

  std::vector<FloatType> Tf(k);

  // Note that this reduction would require communication if we had
  // implemented this to work on multiple nodes
  FloatType m = std::abs(vec.front());
  #pragma omp parallel for default(none) reduction(max:m) shared(vec)
  for(size_t i=0;i<vec.size();i++){
    m = std::max(m, std::abs(vec[i]));
  }

  // Note that this reduction would require communication if we had
  // implemented this to work on multiple nodes
  #pragma omp declare reduction(vec_plus : std::vector<FloatType> : \
    std::transform(omp_out.begin(), omp_out.end(), omp_in.begin(), omp_out.begin(), std::plus<FloatType>())) \
    initializer(omp_priv = decltype(omp_orig)(omp_orig.size()))

  #pragma omp parallel default(none) reduction(vec_plus:Tf) shared(k,m,n,vec,std::cout)
  {
    const auto adr = SetRoundingMode(ROUNDING_MODE);
    const auto threads = omp_get_num_threads();
    const auto tid = omp_get_thread_num();
    const auto values_per_thread = n / threads;
    const auto nlow = tid * values_per_thread;
    const auto nhigh = (tid<threads-1) ? ((tid+1) * values_per_thread) : n;

    FloatType delta_f = n * m / (1 - 4 * (n + 1) * eps);
    FloatType Mf = mf_from_deltaf(delta_f);

    for(int f=0;f<k-1;f++){
      Tf[f] = ExtractVectorNew2<FloatType>(Mf, vec.begin() + nlow, vec.begin() + nhigh);
      delta_f = n * (4 * eps * Mf / 3) / (1 - 4 * (n + 1) * eps);
      Mf = mf_from_deltaf(delta_f);
    }

    FloatType M = Mf;
    for(size_t i=nlow;i<nhigh;i++){
      M += vec[i];
    }
    Tf[k-1] = M - Mf;
  }

  FloatType T = 0;
  for(const FloatType &tf: Tf){
    T += tf;
  }

  return T;
}



//Convenience wrappers
template<bool Parallel, class FloatType>
FloatType bitwise_deterministic_summation(
  const std::vector<FloatType> &vec,  // Note that we're making a copy!
  const int k
){
  if(Parallel){
    return parallel_bitwise_deterministic_summation<FloatType>(vec, k);
  } else {
    return serial_bitwise_deterministic_summation<FloatType>(vec, k);
  }
}

template<bool Parallel, class FloatType, class SimpleAccumType>
FloatType simple_summation(const std::vector<FloatType> &vec){
  if(Parallel){
    return parallel_simple_summation<FloatType, SimpleAccumType>(vec);
  } else {
    return serial_simple_summation<FloatType, SimpleAccumType>(vec);
  }
}

template<bool Parallel, class FloatType>
FloatType kahan_summation(const std::vector<FloatType> &vec){
  if(Parallel){
    return serial_kahan_summation<FloatType>(vec);
  } else {
    return parallel_kahan_summation<FloatType>(vec);
  }
}



// Timing tests for the summation algorithms
template<bool Parallel, class FloatType, class SimpleAccumType>
FloatType PerformTestsOnData(
  const int TESTS,
  std::vector<FloatType> floats, //Make a copy so we use the same data for each test
  std::mt19937 gen               //Make a copy so we use the same data for each test
){
  Timer time_deterministic;
  Timer time_kahan;
  Timer time_simple;

  //Very precise output
  std::cout.precision(std::numeric_limits<FloatType>::max_digits10);
  std::cout<<std::fixed;

  std::cout<<"Parallel? "<<Parallel<<std::endl;
  if(Parallel){
    std::cout<<"Max threads = "<<omp_get_max_threads()<<std::endl;
  }
  std::cout<<"Floating type                        = "<<typeid(FloatType).name()<<std::endl;
  std::cout<<"Floating type epsilon                = "<<std::numeric_limits<FloatType>::epsilon()<<std::endl;
  std::cout<<"Simple summation accumulation type   = "<<typeid(SimpleAccumType).name()<<std::endl;
  std::cout<<"Number of tests                      = "<<TESTS<<std::endl;
  std::cout<<"Input sample = "<<std::endl;
  for(size_t i=0;i<10;i++){
    std::cout<<"\t"<<floats[i]<<std::endl;
  }

  //Get a reference value
  std::unordered_map<FloatType, uint32_t> simple_sums;
  std::unordered_map<FloatType, uint32_t> kahan_sums;
  const auto ref_val = bitwise_deterministic_summation<Parallel, FloatType>(floats, 2);
  for(int test=0;test<TESTS;test++){
    std::shuffle(floats.begin(), floats.end(), gen);

    time_deterministic.start();
    const auto my_val = bitwise_deterministic_summation<Parallel, FloatType>(floats, 2);
    time_deterministic.stop();
    if(ref_val!=my_val){
      std::cout<<"ERROR: UNEQUAL VALUES ON TEST #"<<test<<"!"<<std::endl;
      std::cout<<"Reference      = "<<ref_val                   <<std::endl;
      std::cout<<"Current        = "<<my_val                    <<std::endl;
      std::cout<<"Reference bits = "<<binrep<FloatType>(ref_val)<<std::endl;
      std::cout<<"Current   bits = "<<binrep<FloatType>(my_val) <<std::endl;
      throw std::runtime_error("Values were not equal!");
    }

    time_kahan.start();
    const auto kahan_sum = kahan_summation<Parallel, FloatType>(floats);
    kahan_sums[kahan_sum]++;
    time_kahan.stop();

    time_simple.start();
    const auto simple_sum = simple_summation<Parallel, FloatType, SimpleAccumType>(floats);
    simple_sums[simple_sum]++;
    time_simple.stop();
  }

  std::cout<<"Average deterministic summation time = "<<(time_deterministic.total/TESTS)<<std::endl;
  std::cout<<"Average simple summation time        = "<<(time_simple.total/TESTS)<<std::endl;
  std::cout<<"Average Kahan summation time         = "<<(time_kahan.total/TESTS)<<std::endl;
  std::cout<<"Ratio Deterministic to Simple        = "<<(time_deterministic.total/time_simple.total)<<std::endl;
  std::cout<<"Ratio Deterministic to Kahan         = "<<(time_deterministic.total/time_kahan.total)<<std::endl;

  std::cout<<"Reference value                      = "<<std::fixed<<ref_val<<std::endl;
  std::cout<<"Reference bits                       = "<<binrep<FloatType>(ref_val)<<std::endl;

  std::cout<<"Distinct Kahan values                = "<<kahan_sums.size()<<std::endl;
  std::cout<<"Distinct Simple values               = "<<simple_sums.size()<<std::endl;

  int count = 0;
  for(const auto &kv: kahan_sums){
    std::cout<<"\tKahan sum values (N="<<std::fixed<<kv.second<<") "<<kv.first<<" ("<<binrep<FloatType>(kv.first)<<")"<<std::endl;
    if(count++==10){
      break;
    }
  }

  count = 0;
  for(const auto &kv: simple_sums){
    std::cout<<"\tSimple sum values (N="<<std::fixed<<kv.second<<") "<<kv.first<<" ("<<binrep<FloatType>(kv.first)<<")"<<std::endl;
    if(count++==10){
      break;
    }
  }

  std::cout<<std::endl;

  return ref_val;
}

// Use this to make sure the tests are reproducible
template<class FloatType, class SimpleAccumType>
void PerformTests(
  const int TESTS,
  const std::vector<long double> &long_floats,
  std::mt19937 &gen
){
  std::vector<FloatType> floats(long_floats.begin(), long_floats.end());

  const auto serial_val = PerformTestsOnData<false, FloatType, SimpleAccumType>(TESTS, floats, gen);
  const auto parallel_val = PerformTestsOnData<true, FloatType, SimpleAccumType>(TESTS, floats, gen);

  //Note that the `long double` type may only use 12-16 bytes (to maintain
  //alignment), but only 80 bits, resulting in bitwise indeterminism in the last
  //few bits; however, the floating-point values themselves will be equal.
  std::cout<<"########################################"<<std::endl;
  std::cout<<"### Serial and Parallel values match for "
           <<typeid(FloatType).name()
           <<"? "
           <<(serial_val==parallel_val)
           <<std::endl;
  std::cout<<"########################################\n"<<std::endl;
}



int main(){
  std::random_device rd;
  // std::mt19937 gen(rd());   //Enable for randomness
  std::mt19937 gen(123456789); //Enable for reproducibility
  std::uniform_real_distribution<long double> distr(-1000, 1000);
  std::vector<long double> long_floats;
  for(int i=0;i<N;i++){
    long_floats.push_back(distr(gen));
  }

  PerformTests<double, double>(TESTS, long_floats, gen);
  PerformTests<long double, long double>(TESTS, long_floats, gen);
  PerformTests<float, float>(TESTS, long_floats, gen);
  PerformTests<float, double>(TESTS, long_floats, gen);

  return 0;
}

Редактировать для Эрика Постпишила

Эрик говорит:

Генератор, подобный описанному мной, будет выдавать числа с примерно одинаковым квантовым числом — почти кратными делителю. Это может не включать широкий спектр показателей степени в выборках, поэтому он может плохо моделировать распределение, в котором числа округляются внутри мантиссы при добавлении. Например, если мы сложим много чисел вида 123,45, то какое-то время они будут складываться нормально, хотя ошибки округления будут расти по мере накопления частичных сумм. А вот если сложить номера форм 12345, 123,45 и 1,2345, то разные ошибки возникают раньше

Добавление 1M значений 123,45 дает одно длинное двойное значение Kahan 123450000.000000002837623469532. Детерминированное значение long double отличается от этого на -0.00000000000727595761, в то время как детерминированное значение double отличается на -0.00000001206353772047, а детерминированное значение float отличается на -3.68% (как и ожидалось, учитывая его большой эпсилон).

Случайный выбор 1M значений из набора {1.2345, 12.345, 123.45, 1234.5, 12345} дает два длинных двойных значения Kahan: A=2749592287.563000000780448317528 (N=54) и B=2749592287.563000000547617673874 (N=46). Детерминированное длинное двойное значение точно соответствует (A); детерминированное двойное значение отличается от (A) на -0.00000020139850676247; детерминированное значение с плавающей запятой отличается от (A) на -257% (опять же ожидаемое).

person Richard    schedule 08.05.2021
comment
Правильно ли я понимаю, что если n > 1/e, то нам пиздец, потому что увеличение k не поможет? - person j_random_hacker; 08.05.2021
comment
Используемое здесь распределение чисел неясно и может не моделировать реальные приложения; std::uniform_real_distribution недоопределено. Стандарт определяет его как равномерное для действительных чисел (плотность вероятности постоянна на интервале), но игнорирует тот факт, что числа с плавающей запятой не являются равномерно плотными на этом интервале. Распространенный метод создания «равномерных» распределений — создание целого числа и масштабирование до интервала — игнорирует более точные (с более низким показателем) числа с плавающей запятой и поэтому может не моделировать реалистичные результаты. - person Eric Postpischil; 08.05.2021
comment
@j_random_hacker: Да, это так. Теорема 7 статьи предлагает n*e<0.1 как хороший предел. - person Richard; 08.05.2021
comment
@EricPostpischil: не может моделировать реальные приложения. Поскольку реальные приложения могут включать в себя все, что угодно, любая модель не сможет охватить их совокупность, так что это не кажется интересной критикой. Мы не ожидаем, что время стены будет зависеть от значений, просто от того, сколько значений существует, так что это обобщает. Кроме того, границы ошибок являются строгими, поэтому, хотя я и машу им рукой для демонстрации, на самом деле они должны применяться к любому вводу. Поскольку это так, я не ожидаю, что исключение субнормальных значений будет проблематичным. Конечно, вы можете попробовать свои собственные входы :-) - person Richard; 08.05.2021
comment
Проблема не в субнормальных явлениях. Генератор, подобный описанному мной, будет выдавать числа с примерно одинаковым квантовым числом — почти кратными делителю. Это может не включать широкий спектр показателей степени в выборках, поэтому он может плохо моделировать распределение, в котором числа округляются внутри мантиссы при добавлении. Например, если мы сложим много чисел вида 123,45, то какое-то время они будут складываться нормально, хотя ошибки округления будут расти по мере накопления частичных сумм. А вот если сложить числа форм 12345, 123,45 и 1,2345, то разные ошибки возникают раньше. - person Eric Postpischil; 08.05.2021
comment
@EricPostpischil: я провел предложенный вами тест (см. отредактированный ответ) и не вижу ничего особенного. Там, где расчеты соответствуют границе n*e<0.1, мы получаем точные суммы. - person Richard; 08.05.2021
comment
Это может делить на ноль FloatType delta_f = n * m / (1 - 4 * (n + 1) * eps);, если у вас есть значения 2**21-1 float32. И он может переполниться, если m очень большой. Кроме того, я не уверен, как копирование вектора соответствует Но что, если чисел еще больше, возможно, распределенных по разным машинам, так что их нежелательно перемещать? Почему копирование нормально, а перемещение нет? - person chtz; 09.05.2021
comment
@chtz, как обсуждалось в нескольких местах выше, если бы у вас было столько (2**21-1) значений float32, вы бы все равно раздвинули границы ошибок. Код делает копию, чтобы лучше продемонстрировать числовую надежность; вектор может быть изменен на месте. Кроме того, обратите внимание, что часть вектора, обрабатываемая каждым отдельным потоком, всегда одна и та же, а данные, передаваемые между потоками, имеют порядок одного числа. Если бы вычисления были распределены между машинами, каждая машина содержала бы часть вектора, и копия была бы для каждого узла, а не между узлами. - person Richard; 09.05.2021

TL/DR: Есть простой и систематический способ, хотя и неэффективный: преобразовать все в фиксированную точку и получить точное значение. Поскольку только последний шаг преобразования обратно в число с плавающей запятой содержит округление, весь процесс не зависит от порядка слагаемых.

Идея взята из статьи Точное скалярное произведение как основной инструмент для арифметики с длинными интервалами.

Наблюдение состоит в том, что все числа с плавающей запятой также могут быть представлены как числа с фиксированной запятой соответствующей ширины. С числами с фиксированной запятой умножение и сложение могут быть точно представлены при условии, что несколько дополнительных целочисленных битов добавлены для учета переполнения.

Например, 64-битное двоичное число с плавающей запятой IEE754 может быть представлено как 2131-битное число с фиксированной запятой с 1056 битами до и 1075 битами после десятичной точки (см. Таблицу 1 в ссылке для других форматов FP, но обратите внимание, что приведенные числа в 2 раза больше). как для произведения двух поплавков).

Добавляя 64 бита для переполнения (при условии, что одновременно можно добавить максимум 2 ^ 64 числа), мы получаем ширину фиксированной точки 2195 бит или 275 байт. С точки зрения 64-битных целых чисел без знака это 18 до десятичной точки и 17 после десятичной точки.

Для float32 это только 3 до и 3 после десятичной точки, или, если вы допускаете десятичную точку между конечностями, всего требуется только 5.

Такую арифметику легко реализовать на многих языках. Как правило, динамическое управление памятью не требуется, и когда можно надежно обнаружить переполнение целого числа без знака (например, в C и C++), для эффективной реализации не требуется язык ассемблера.

Этот принцип можно применить к более сложным задачам с плавающей запятой, где необходимо получить правильное округление. Он не очень эффективен, но прост в реализации и довольно надежен (в смысле тестируемости).

РЕДАКТИРОВАТЬ: Исправлены числа, так как они были в 2 раза больше. Также добавлен случай float32 (предложено от njuffa)

person Andreas H.    schedule 09.05.2021
comment
Требуемое количество битов в накопителе с фиксированной запятой, указанное в этом ответе, кажется слишком большим примерно в два раза для простого накопления, о чем спрашивает OP. Вместо этого количество битов, по-видимому, основано на накоплении произведений операндов IEEE-754 binary64. На современных 64-битных платформах использование накопления с фиксированной запятой для binary32 операндов IEEE-754 кажется возможным с разумной эффективностью, используя пять 64-битных ветвей. - person njuffa; 10.05.2021
comment
@njuffa Вы абсолютно правы. Я должен был читать газету более внимательно. Я обновлю цифры в своем ответе. - person Andreas H.; 10.05.2021

Обзор: однопроходный метод сохранения данных

Да!

Как это сделать, описан в документе Алгоритмы эффективного воспроизводимого суммирования с плавающей запятой. Аренс, Деммель и Нгуен (2020). Я включил его реализацию ниже.

Здесь мы сравним три алгоритма:

  1. Обычное суммирование слева направо: это быстро, неточно и невоспроизводимо по отношению к перестановкам в порядке ввода.
  2. суммирование Кэхана: это медленнее, очень точно (по существу O(1) в размере ввода), и, хотя он невоспроизводим в отношении перестановок в порядке ввода, ближе к воспроизводимости в узком смысле.
  3. Воспроизводимое суммирование: это несколько медленнее, чем суммирование Кэхана, может быть довольно точным и побитово воспроизводимым.

Обычное суммирование является неточным, потому что по мере роста суммы наименее значащие цифры прибавляемых к ней чисел молча отбрасываются. Суммирование Кэхана преодолевает это, сохраняя текущую компенсационную сумму, которая удерживает младшие значащие цифры. Воспроизводимое суммирование использует аналогичную стратегию для поддержания точности, но поддерживает несколько интервалов, соответствующих разным уровням компенсации. Эти ячейки также соответствующим образом усекают значимость входных данных для получения точной воспроизводимой суммы.

Первый тест

Чтобы изучить алгоритмы, мы запустим тест на 1 миллионе чисел, каждое из которых выбирается равномерно из диапазона [-1000, 1000]. Чтобы продемонстрировать как диапазон ответов алгоритмов, так и их воспроизводимость, входные данные будут перемешиваться и суммироваться 100 раз.

Я утверждаю, что результат, который строго демонстрирует приведенный ниже код, состоит в том, что для каждого изучаемого типа данных детерминированный алгоритм дает один и тот же результат для каждого из 100 порядков данных.

Моя реализация включает в себя класс, которому передаются значения воспроизводимых аккумуляторов, но есть несколько способов передать входные данные. Мы поэкспериментируем с тремя из них:

  • 1ata тестирует режим, в котором числа передаются в аккумулятор по одному без предварительного знания максимальной величины входных данных.
  • many проверяет режим, в котором в аккумулятор передается много чисел, но без предварительного знания максимальной величины входных данных.
  • manyc тестирует режим, в котором много чисел передается в аккумулятор и в котором у нас есть предварительная информация о максимальной величине входных данных.

Результаты различных алгоритмов следующие:

Single-Precision Floats
==================================================================
Average Reproducible sum 1ata time   = 0.0115s (11.5x simple; 2.6x Kahan)
Average Reproducible sum many time   = 0.0047s (4.7x simple; 1.1x Kahan)
Average Reproducible sum manyc time  = 0.0036s (3.6x simple; 0.8x Kahan)
Average Kahan summation time         = 0.0044s
Average simple summation time        = 0.0010s
Reproducible Value                   = 767376.500000000 (0% diff vs Kahan)
Kahan long double accumulator value  = 767376.500000000
Error bound on RV                    = 15.542840004
Distinct Kahan (single accum) values = 1
Distinct Simple values               = 92


Double-Precision Floats
==================================================================
Average Reproducible sum 1ata time   = 0.0112s (10.5x simple; 2.6x Kahan)
Average Reproducible sum many time   = 0.0051s (4.7x simple; 1.2x Kahan)
Average Reproducible sum manyc time  = 0.0037s (3.4x simple; 0.8x Kahan)
Average simple summation time        = 0.0011s
Average Kahan summation time         = 0.0044s
Reproducible Value                   = 310844.70069914369378239 (0% diff vs Kahan)
Kahan long double accumulator value  = 310844.70069914369378239
Error bound on RV                    = 0.00000000048315059
Distinct Kahan (double accum) values = 2
Distinct Simple values               = 96

Второй тест

Давайте попробуем более сложный тест, чем равномерный рандом! Вместо этого давайте сгенерируем 1 миллион точек данных в диапазоне [0, 2π) и используем их для генерации значений y синусоидальной волны. Как и раньше, мы затем перемешиваем эти точки данных 100 раз и смотрим, какие результаты мы получаем. Значения времени очень похожи на приведенные выше, поэтому опустим их. Это дает нам:

Single-Precision Floats
==================================================================
Reproducible Value                   = 0.000000000
Kahan long double accumulator value  = -0.000000000
Error bound                          = 14.901161194
Distinct Kahan (single) values       = 96
Distinct Simple values               = 100
Kahan lower value                    = -0.000024229
Kahan upper value                    = 0.000025988
Simple lower value                   = -0.017674208
Simple upper value                   = 0.018800795
Kahan range                          = 0.000050217
Simple range                         = 0.036475003
Simple range / Kahan range           = 726

Double-Precision Floats
==================================================================
Reproducible Value                   = 0.00000000000002185
Kahan long double accumulator value  = 0.00000000000002185
Error bound                          = 0.00000000000000083
Distinct Kahan (double) values       = 93
Distinct Simple values               = 100
Kahan lower value                    = 0.00000000000000017
Kahan upper value                    = 0.00000000000006306
Simple lower value                   = -0.00000000004814216
Simple upper value                   = 0.00000000002462563
Kahan range                          = 0.00000000000006289
Simple range                         = 0.00000000007276779
Simple range / Kahan range           = 1157

Здесь мы видим, что воспроизводимое значение точно соответствует значению суммирования длинного двойного числа Кэхана, которое мы используем в качестве источника истины.

В отличие от наших результатов в предыдущем, более простом тесте, есть много разных значений суммирования Кэхана (где суммирование Кэхана использует тот же тип с плавающей запятой, что и данные); использование более сложного набора данных разрушило частичный детерминизм, который мы наблюдали при суммировании Кэхана на более простом тесте, даже несмотря на то, что диапазон значений, полученных с помощью суммирования Кэхана, на несколько порядков меньше, чем диапазон, полученный с помощью простого суммирования.

Таким образом, в этом случае воспроизводимое суммирование дает лучшую точность, чем суммирование Кэхана, и дает один побитовый воспроизводимый результат, а не ~100 различных результатов.

Расходы

Время. Воспроизводимое суммирование занимает примерно в 3,5 раза больше времени, чем простое суммирование, и примерно столько же времени, сколько суммирование Каана.

Память. Для использования тройного биннинга требуется 6*sizeof(floating_type) места для аккумулятора. То есть 24 байта для одинарной точности и 48 байтов для двойной точности.

Кроме того, необходима статическая таблица поиска, которую можно использовать для всех аккумуляторов данного типа данных и фолдинга. Для 3-кратного биннинга эта таблица составляет 41 число с плавающей запятой (164 байта) или 103 двойных числа (824 байта).

Обсуждение

Итак, что мы узнали?

Стандартное суммирование дает широкий диапазон результатов. Мы провели 100 тестов и получили 96 уникальных результатов для типов с двойной точностью. Это явно невоспроизводимо и хорошо демонстрирует проблему, которую мы пытаемся решить.

С другой стороны, суммирование Каана дает очень мало четких результатов (поэтому оно более детерминировано) и занимает примерно в 4 раза больше времени, чем обычное суммирование.

Воспроизводимый алгоритм занимает примерно в 10 раз больше времени, чем простое суммирование, если мы ничего не знаем о числах, которые будем складывать; однако, если мы знаем максимальную величину наших слагаемых, то воспроизводимый алгоритм занимает примерно в 3,5 раза больше времени, чем простое суммирование.

По сравнению с суммированием Кэхана воспроизводимый алгоритм иногда занимает меньше времени (!), при этом гарантируя, что мы получим идентичные побитовые ответы. Кроме того, в отличие от метода, подробно описанного в этом ответе, воспроизводимый результат соответствует результату суммирования Кахана как для одинарной, так и для двойной точности в нашем простой тест. Мы также получаем надежные гарантии того, что воспроизводимые результаты и результаты Кэхана имеют очень низкую относительную ошибку.

Какой уровень сворачивания лучше? 3, согласно графику ниже.

Время и ошибки против сворачивания

Фолд-1 очень плохой. Для этого теста значение 2 дает хорошую точность для double, но относительную ошибку 10% для float. Fold-3 дает хорошую точность для double и float с небольшим увеличением времени. Таким образом, мы хотим только fold-2 для воспроизводимого суммирования One At A Time для двойников. Переход выше, чем Fold-3, дает лишь незначительные преимущества.

Код

Полный код слишком длинный, чтобы включать его здесь; однако вы можете найти версию C++ здесь; и ReproBLAS здесь.

person Richard    schedule 27.05.2021