>
>
>
Оптимизация в мире 64-битных ошибок

Андрей Карпов
Статей: 671

Оптимизация в мире 64-битных ошибок

В предыдущей записи блога я обещал рассказать, почему сложно демонстрировать 64-битные ошибки на простых примерах. Разговор касался operator[] и я говорил, что в простых случая может работать даже явно некорректный код.

Сейчас я приведу такой пример:

class MyArray
{
public:
  char *m_p;
  size_t m_n;
  MyArray(const size_t n)
  {
    m_n = n;
    m_p = new char[n];
  }
  ~MyArray() { delete [] m_p; }
  char &operator[](int index)
    { return m_p[index]; }
  char &operator()(ptrdiff_t index)
    { return m_p[index]; }
  ptrdiff_t CalcSum()
  {
    ptrdiff_t sum = 0;
    for (size_t i = 0; i != m_n; ++i)
      sum += m_p[i];
    return sum;
  }
};
void Test()
{
  ptrdiff_t a = 2560;
  ptrdiff_t b = 1024;
  ptrdiff_t c = 1024;
  MyArray array(a * b * c);
  for (ptrdiff_t i = 0; i != a * b * c; ++i)
    array(i) = 1;
  ptrdiff_t sum1 = array.CalcSum();
  for (int i = 0; i != a * b * c; ++i)
    array[i] = 2;
  ptrdiff_t sum2 = array.CalcSum();
  if (sum1 != sum2 / 2)
    MessageBox(NULL, _T("Normal error"),
        _T("Test"), MB_OK);
  else
    MessageBox(NULL, _T("Fantastic"),
        _T("Test"), MB_OK);
}

Вот кратко, что делает этот код:

  • Создает массив размером 2.5 гигабайта (более INT_MAX элементов).
  • Заполняет массив единицами, используя корректный operator() с параметром ptrdiff_t.
  • Считает сумму всех элементов и помещает ее в переменную sum1.
  • Заполняет массив двойками, используя некорректный operator[] с параметром int. Теоретически int не позволяет нам обратиться к элементам с номерами более чем INT_MAX. Есть и вторая ошибка, допущенная в цикле "for (int i = 0; i != a * b * c; ++i)". В качестве индекса мы также используем int. Эта двойная ошибка сделана, чтобы компилятор не выдавал предупреждений о преобразовании 64-битного значения в 32-битное. Фактически должно произойти переполнение и обращение к элементу с отрицательным номером, что повлечет аварийное завершение программы. Это кстати и происходит в debug-версии.
  • Считает сумму всех элементов и помещает ее в переменную sum2.
  • Если (sum1 == sum2 / 2), то произошло невозможное, и выводится сообщение "Fantastic".

Несмотря на две ошибки в приведенном коде он успешно работает в 64-битном release-варианте и выводит сообщение "Fantastic"!

Давайте теперь разберемся почему. Дело в том, что компилятор угадал наше желание заполнить массив значением 1 и 2. И в обоих случаях оптимизировал наш код с использованием вызова функции memset:

Вывод первый - компилятор в плане оптимизации кода молодец. Вывод второй - следует не терять бдительность.

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

void Test()
{
  ptrdiff_t a = 2560;
  ptrdiff_t b = 1024;
  ptrdiff_t c = 1024;
  ptrdiff_t n = a * b * c;
  MyArray array(n);
  for (ptrdiff_t i = 0; i != n; ++i)
    array(i) = 1;
  ptrdiff_t sum1 = array.CalcSum();
  for (int i = 0; i != n; ++i)
    array[i] = 2;
  ptrdiff_t sum2 = array.CalcSum();
  ...
}

В этот раз release-версия аварийно завершится. Посмотрим на ассемблерный код.

Для корректного operator() компилятор вновь построил код с вызовом memset. Эта часть, как и раньше, отлично работает. А вот в коде, где используется operator[], происходит выход за рамки массива, так как условие "i != n" не выполняется. Это не совсем тот код, который я хотел создать, но в простом коде это очень сложно реализовать, а большой отрывок кода сложно рассматривать. В любом случае это не меняет сути. Код начал аварийно завершаться, как это и должно быть.

Почему я так много времени посвятил данной тематике? Видимо меня мучает проблема, что я не могу демонстрировать 64-битные ошибки на простых примерах. Я пишу что-то простое для демонстрации, но обидно, что если это действительно попробовать, то оно работает в release-варианте. А следовательно ошибки как бы и нет. Но они есть и очень коварны и сложны в обнаружении. Специально повторюсь. Подобные ошибки легко пропустить при отладке и прогоне юнит-тестов на debug версии. Редко у кого хватить терпения отлаживать программу или ждать тесты, когда они обрабатывают гигабайты. Release-версия может пройти большое серьезное тестирование. А следующая сборка, где что-то незначительно поправлено, или используется новая версия компилятора будет неработоспособна на большом объеме данных.

По поводу диагностики данной ошибки смотрите предыдущий пост, где описывается новое предупреждение V302.