Оптимизация в мире 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.
Статья опубликована с разрешения автора.