Рассмотрим следующую простую программу:
#include <cstring>
#include <cstdio>
#include <cstdlib>
void replace(char *str, size_t len) {
for (size_t i = 0; i < len; i++) {
if (str[i] == '/') {
str[i] = '_';
}
}
}
const char *global_str = "the quick brown fox jumps over the lazy dog";
int main(int argc, char **argv) {
const char *str = argc > 1 ? argv[1] : global_str;
replace(const_cast<char *>(str), std::strlen(str));
puts(str);
return EXIT_SUCCESS;
}
Он берет (необязательную) строку в командной строке и печатает ее, заменяя символы / на _. Эта функция замены реализуется функцией c_repl1. Например, a.out foo/bar печатает:
foo_bar
Элементарные вещи до сих пор, не так ли?
Если вы не укажете строку, будет удобно использовать глобальную строку Быстрая коричневая лиса прыгает через ленивую собаку, которая не содержит символов / и поэтому не подвергается замене.
Конечно, строковые константы — это const char[], поэтому сначала мне нужно отбросить константность — это const_cast, который вы видите. Поскольку строка на самом деле никогда не изменяется, у меня сложилось впечатление, что это это законно.
gcc и clang компилируют двоичный файл с ожидаемым поведением, с передачей строки в командной строке или без нее. Однако icc дает сбой, когда вы не предоставляете строку:
icc -xcore-avx2 char_replace.cpp && ./a.out
Segmentation fault (core dumped)
Основной причиной является основной цикл для c_repl, который выглядит следующим образом:
400c0c: vmovdqu ymm2,YMMWORD PTR [rsi]
400c10: add rbx,0x20
400c14: vpcmpeqb ymm3,ymm0,ymm2
400c18: vpblendvb ymm4,ymm2,ymm1,ymm3
400c1e: vmovdqu YMMWORD PTR [rsi],ymm4
400c22: add rsi,0x20
400c26: cmp rbx,rcx
400c29: jb 400c0c <main+0xfc>
Это векторизованная петля. Основная идея состоит в том, что 32 байта загружаются, а затем сравниваются с символом /, формируя значение маски с набором байтов для каждого совпадающего байта, а затем существующая строка смешивается с вектором, содержащим 32 символа _, эффективно заменяя только символы /. Наконец, обновленный регистр записывается обратно в строку с помощью инструкции vmovdqu YMMWORD PTR [rsi],ymm4.
Это последнее хранилище дает сбой, потому что строка доступна только для чтения и размещена в разделе .rodata двоичного файла, который загружается с использованием страниц только для чтения. Конечно, хранилище было логичным «нет операции», записывая в ответ те же символы, которые он читал, но процессору все равно!
Является ли мой код законным C++, и поэтому я должен обвинить icc в неправильной компиляции, или я где-то лезу в болото UB?
1 Тот же сбой из-за той же проблемы происходит с std::replace на std::string, а не с моим "C-подобным" кодом, но я хотел максимально упростить анализ и сделать его полностью автономным.
Но... строковый литерал изменен нет, потому что он не содержит символа /, а все модификации основаны на наличии символа /. Это действительно включает интерпретацию «на самом деле никогда не модифицировалась». Оптимизатор полагает, что выполнять операцию логический для строки безопасно, но на самом деле в данном случае это не так. Увлекательный вопрос; Мне не терпится увидеть, что скажут ответы.
«Является ли мой код законным C++» — конечно, допустимым в том смысле, что вы можете иметь код, который устанавливает указатель в NULL, а затем обрабатывает его как массив. Допустимо для компиляции, не определено для выполнения. C++ позволяет вам передать const в неконстантную функцию, или преобразовать int в число с плавающей запятой, или использовать все виды других действий, приводящих к разрушению стека и генерации исключений. Вы предполагаете, что компилятор достаточно умен, чтобы понять, что он ничего не может сделать в вашей неконстантной функции, но это не гарантируется. Это не обещание языка спасти вас здесь от самих себя.
@DaveS Как это UB? Пожалуйста, ответьте в разделе ответов, чтобы мы могли правильно проголосовать за ваш пост.
Я утверждаю, что это UB, потому что вы передаете константную строку неконстантной функции, которая включает оператор присваивания. Мы, люди, знаем, что нет необходимости выполнять оптимизированный код для функции, компилятор не был достаточно умен, чтобы понять это.
Разве это не тот же вопрос, на который вы ссылаетесь (например, Вот этот)? То же самое верно - у вас есть объявленный объект const, который мы передаем в дескриптор функции, которая мог бы изменяет его, но на самом деле не изменяет?
@DaveS Ваше утверждение о том, что простое присутствие присваивания в пути мертвого кода уже вызывает UB, нуждается в обосновании.
Я думаю, что вопрос шире - независимо от константности, разрешено ли компилятору записывать в память, когда запись не ожидается?
@rustyx: В общем, изобретение записи (неатомарная загрузка и последующее сохранение того же значения) является огромным запретом для компиляторов. Но это более очевидная проблема, когда C++ читает один массив и условно записывает в другой. Если источник C++ читает каждый символ, это будет UB для другого потока, который будет писать его одновременно. Пока эта загрузка/blendv/store не выходит за пределы указателя+длины (и неатомарно считывает/переписывает некоторую память, которая может быть блокировкой или чем-то еще), это может быть законным из памяти С++ 11 модель потокобезопасности POV.
@DaveS - чтобы быть очень явным, когда я говорю «законно», я имею в виду компилировать и выполнять с дополнительным аргументом и без него. Я думаю, что это подразумевает вопрос, который в основном говорит о поведении во время выполнения, но в любом случае есть ваше разъяснение.
@Peter, у меня была такая же мысль, поэтому я сформировал этот пример вокруг константной строки и раздела .rodata, а не аргумента о безопасности потоков. У него также есть то преимущество, что проблема очевидна: он всегда дает сбой, а не более абстрактная проблема, связанная с условиями гонки, которую может быть трудно проиллюстрировать. Тем не менее, можно найти способ изменить пример таким образом, чтобы возникла проблема с моделью памяти.
Повторяя Барри, разве вы не должны просто отправить отчет об ошибке после вашего предыдущего вопроса?
@PasserBy - хорошо, когда я писал этот другой вопрос, я не знал о поведении icc: меня интересовал именно вопрос const_cast (хотя оказывается, что вопрос тот может быть дубликатом). Сегодня кто-то указал мне, что icc на самом деле векторизует этот тип кода, и поэтому я создал этот пример, который дает сбой: теперь мой вопрос касается этого конкретного кода, валидности оптимизации и допустимости сбоя. Да, аспект const_cast является его частью, поэтому я связал другой вопрос (чтобы предотвратить все «вы не можете сделать это с постоянными комментариями»).
Речь идет о поведении icc именно в этом сценарии: может быть, есть какая-то другая причина, по которой разрешена их оптимизация? Насчет регистрации ошибки, это вообще возможно? icc — это коммерческий продукт с закрытым исходным кодом, и у меня нет контракта на поддержку. Не похоже, что существует какой-либо очевидный способ для публики, чтобы сообщать об ошибках. В любом случае, у меня ограниченный интерес к icc: это было больше похоже на «о, может ли компилятор сделать это, или это плохо?» тип вещи.
@PeterCordes — даже если массивы различны, icc по-прежнему пишет в целевой массив. Это кажется совершенно неправильным не только с точки зрения модели памяти, но и того, что я передаю в nullptr для второго или массива, или более короткого массива, или чего-то еще? Просто кажется, что эта векторизация на основе смешивания нарушена.
Хорошо, да, это просто 100% сломано. Нелегко придумать случай, когда это ломает что-то, что кто-то на самом деле мог написать в программе, предназначенной для использования, но очень легко придумать сценарии, в которых вы можете законно запустить эту функцию со строкой str1, которая никогда не совпадает, и ICC ломает вещи. Помимо nullptr, вы можете передать указатель на std::atomic<T> или мьютекс, где неатомарное чтение/перезапись ломает вещи, изобретая записи. re: отчет: Google для отчета об ошибке ICC находит их форум, например: software.intel.com/en-us/forums/intel-c-compiler/topic/753767
Для будущих читателей: если вы хотите, чтобы компиляторы автоматически векторизовали таким образом, вы можете написать исходный код, например str2[i] = x ? replacement : str2[i];, который всегда записывает строку. Теоретически оптимизирующий компилятор может превратить его в условную ветвь в скалярной очистке или что-то еще, чтобы избежать ненужного загрязнения памяти. (Или, если вы нацелены на ISA, например ARM32, где возможно предварительное хранилище, а не только операции выбора ALU. Или x86 с маскированными хранилищами AVX512, где это действительно было бы будет безопасным.)
Intel слишком любит спекулировать.
@Peter Действительно, и gcc, и clang векторизуют циклы, если вы пишете их с безусловным хранилищем с условными данными. Однако я видел много других случаев, когда они не векторизуются, даже если вы пытаетесь научить их, что доступ к памяти осуществляется, поэтому это не всегда работает. AVX2 имеет маскированные загрузки и сохранения, но только с гранулярностью 4 и 8 байт (VPMASKMOV).
что-то изменится, если вы используете статическую или встроенную функцию?
@phön - на самом деле в моем примере функция встроена. Фрагмент сборки, который я показываю, взят из встроенного тела внутри main(), а не из отдельной бесплатной функции. Материал с argv необходим из-за этого встраивания, иначе компилятор полностью опускает код замены.
Связанный вопрос: Что делает компилятор C++, чтобы обеспечить безопасное использование разных, но смежных участков памяти в разных потоках? - см., в частности, мой ответ на него, который частично касается, как вы выразились, «изобретенной записи». (Обратите внимание, что в этом случае, однако, компилятор знает, что c не может быть const_cast-объектом const, и, следовательно, некоторые оптимизации возможны в однопоточном MM.)





Хм, я не вижу здесь никакого УБ. Надеюсь, ответы на комментарии станут реальными ответами...