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

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

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 элементов).
  • Заполняет массив единицами, используя правильный оператор() с параметром ptrdiff_t.
  • Вычисляет сумму всех элементов и записывает ее в переменную sum1.
  • Заполняет массив двойками, используя неправильный оператор [] с параметром int. Теоретически int не позволяет обращаться к элементам, номера которых больше INT_MAX. В цикле for (int i = 0; i != a * b * c; ++i) есть еще одна ошибка. Здесь мы также используем int в качестве индекса. Эта двойная ошибка сделана для того, чтобы компилятор не выдавал предупреждения о преобразовании 64-битного значения в 32-битное. На самом деле должно произойти переполнение и обращение к элементу с отрицательным номером, что приведет к падению. Кстати, именно это и происходит в отладочной версии.
  • Вычисляет сумму всех элементов и записывает ее в переменную sum2.
  • Если (сумма1 == сумма2/2), значит невозможное стало правдой и вы видите сообщение «Фантастика».

Несмотря на две ошибки в этом коде, он успешно работает в 64-битной релиз-версии и выводит сообщение «Фантастика»!

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

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

Эта ошибка может быть легко обнаружена в отладочной версии, где нет оптимизации и код, записывающий двойки в массив, приводит к падению. Что опасно, этот код ведет себя некорректно только при работе с большими массивами. Скорее всего, обработка более двух миллиардов элементов данных не будет выполняться при запуске юнит-тестов для отладочной версии. И релизная версия может долго держать эту ошибку в секрете. Ошибка может возникнуть совершенно неожиданно при малейшем изменении кода. Посмотрите, что может произойти, если мы введем еще одну переменную 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();
  ...
}

На этот раз релизная версия вылетела. Посмотрите код на ассемблере.

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

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

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

Статья опубликована с разрешения автора.