Сбой с icc: может ли компилятор изобрести запись там, где ее не было в абстрактной машине?

Рассмотрим следующую простую программу:

#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_repl функцией 1. Например, 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-подобным" кодом, но я хотел максимально упростить анализ и сделать его полностью автономным.


person BeeOnRope    schedule 04.02.2019    source источник
comment
Хм, я не вижу здесь UB. Надеюсь, комментарии-ответы станут актуальными ответами ...   -  person Baum mit Augen    schedule 05.02.2019
comment
Но ... строковый литерал не изменен, потому что он не содержит символа /, и все изменения основаны на наличии символа /. Это действительно включает интерпретацию того, что фактически никогда не изменялось. Оптимизатор предполагает, что выполнение логической операции над строкой безопасно, но на самом деле в данном случае это не так. Увлекательный вопрос; Я очень хочу увидеть, что скажут ответы.   -  person Cody Gray    schedule 05.02.2019
comment
Является ли мой код допустимым, C ++ - конечно, законным в том смысле, что у вас может быть код, который устанавливает указатель на NULL, а затем обрабатывает его как массив. Допустимо для компиляции, не определено для выполнения. C ++ позволяет передавать const в неконстантную функцию или преобразовывать int в число с плавающей запятой, или все виды другого поведения, связанного с разбиением стека и генерацией исключений. Вы предполагаете, что компилятор достаточно умен, чтобы понять, что он ничего не может сделать с вашей неконстантной функцией, но это не гарантируется. Это не обещание языка спасти вас здесь.   -  person Dave S    schedule 05.02.2019
comment
@DaveS Как это UB? Пожалуйста, ответьте в разделе ответов, чтобы мы могли проголосовать за ваш пост должным образом.   -  person Baum mit Augen    schedule 05.02.2019
comment
Я утверждаю, что это UB, потому что вы передаете константную строку неконстантной функции, которая включает оператор присваивания. Мы, люди, знаем, что нет необходимости выполнять оптимизированный код для функции, компилятор не был достаточно умен, чтобы это понять.   -  person Dave S    schedule 05.02.2019
comment
Разве это не тот вопрос, на который вы ссылаетесь (например, этот)? То же самое, верно - у вас есть объявленный объект const, который мы передаем дескриптору функции, которая может изменить его, но на самом деле не делает?   -  person Barry    schedule 05.02.2019
comment
@DaveS Ваше утверждение о том, что простое присутствие присваивания в мертвом пути кода уже вызывает UB, требует обоснования.   -  person Baum mit Augen    schedule 05.02.2019
comment
Я думаю, что вопрос шире - независимо от константности, разрешено ли компилятору писать в память, когда записи не ожидается?   -  person rustyx    schedule 05.02.2019
comment
@rustyx: В общем, изобретение записи (неатомарная загрузка с последующим сохранением того же значения) - это огромный запрет для компиляторов. Но это более очевидная проблема, когда C ++ читает один массив и условно записывает в другой. Если исходный код C ++ читает каждый символ, это будет UB для другого потока, который будет записывать его одновременно. Пока эта загрузка / blendv / store не выходит за пределы конца указателя + длины (и не атомарно читает / перезаписывает некоторую память, которая может быть блокировкой или чем-то еще), это может быть законным из памяти C ++ 11 модель потокобезопасности POV.   -  person Peter Cordes    schedule 05.02.2019
comment
@DaveS - чтобы быть очень точным, когда я говорю «законно», я имею в виду компилировать и выполнять с дополнительным аргументом и без него. Я думаю, это подразумевает вопрос, который в основном говорит о поведении во время выполнения, но в любом случае есть ваше пояснение.   -  person BeeOnRope    schedule 05.02.2019
comment
@Peter, у меня была такая же мысль, поэтому я сформировал этот пример вокруг константной строки и раздела .rodata, а не аргумента о безопасности потоков. Он также имеет то преимущество, что проблема очевидна: он всегда дает сбой, а не более абстрактную проблему, связанную с условиями гонки, которые может быть трудно проиллюстрировать. Тем не менее, возможно, все еще удастся найти способ изменить пример таким образом, чтобы возникла проблема с моделью памяти.   -  person BeeOnRope    schedule 05.02.2019
comment
Вспоминая Барри, не следует ли вам просто отправить отчет об ошибке после вашего предыдущего вопроса?   -  person Passer By    schedule 05.02.2019
comment
@PasserBy - ну, когда я писал этот другой вопрос, я не знал о поведении icc: меня особенно интересовал вопрос const_cast (оказывается, этот вопрос может быть дубликатом). Сегодня кто-то указал мне, что icc на самом деле векторизует этот тип кода, и поэтому я создал этот пример, который дает сбой: теперь мой вопрос касается этого конкретного кода и валидности оптимизации и допустимости сбоя. Да, аспект const_cast является его частью, поэтому я связал другой вопрос (чтобы предотвратить все, что вы не можете сделать с помощью комментариев const).   -  person BeeOnRope    schedule 05.02.2019
comment
Речь идет о поведении icc именно в этом сценарии: может быть, есть какая-то другая причина, по которой разрешена их оптимизация? Что касается регистрации ошибки, возможно ли это вообще? icc - это коммерческий продукт с закрытым исходным кодом, и у меня нет никаких контрактов на поддержку. Не похоже, что существует какой-либо очевидный способ для публики, чтобы сообщать об ошибках. В любом случае у меня ограниченный интерес к icc: это было скорее о, может ли компилятор это сделать, или это непослушно? тип вещи.   -  person BeeOnRope    schedule 05.02.2019
comment
@PeterCordes - даже если массивы различны, icc по-прежнему записывает в целевой массив. Это просто кажется полностью сломанным, не только с точки зрения модели памяти, но и того, что я передаю в nullptr для второго или массива, или более короткого массива или чего-то еще? Просто похоже, что эта векторизация на основе смешивания сломана.   -  person BeeOnRope    schedule 05.02.2019
comment
Хорошо, да, это просто сломано на 100%. Нелегко придумать случай, когда это нарушит что-то, что кто-то мог действительно написать в программе, предназначенной для использования, но очень легко придумать сценарии, в которых вы можете законно запустить эту функцию с str1, которая никогда не совпадает, и ICC ломает вещи. Помимо nullptr, вы можете передать указатель на std::atomic<T> или мьютекс, где неатомарное чтение / перезапись ломает вещи, изобретая записи. re: Reporting: отчет об ошибке google для ICC находит их форум, например: software.intel.com/en-us/forums/intel-c-compiler/topic/753767   -  person Peter Cordes    schedule 05.02.2019
comment
Для будущих читателей: если вы хотите позволить компиляторам выполнять автоматическую векторизацию таким образом, вы можете написать такой источник, как str2[i] = x ? replacement : str2[i];, который всегда записывает строку. Теоретически оптимизирующий компилятор может превратить его в условную ветвь в скалярной очистке или что-то еще, чтобы избежать ненужного загрязнения памяти. (Или если нацелена на ISA, например ARM32, где возможно предикатное хранилище, а не только операции выбора ALU. Или x86 с замаскированными хранилищами AVX512, где это действительно было бы безопасно.)   -  person Peter Cordes    schedule 05.02.2019
comment
Intel любит слишком много спекулировать.   -  person Language Lawyer    schedule 05.02.2019
comment
@Peter Действительно, и gcc, и clang векторизуют циклы, если вы пишете их с безусловным хранилищем с условными данными. Однако я видел множество других случаев, когда они не векторизовались, даже если вы пытались научить их, что к памяти обращаются, поэтому это не всегда срабатывает. AVX2 имеет маскировку загрузки и хранения, но только с 4- и 8-байтовой степенью детализации (VPMASKMOV).   -  person BeeOnRope    schedule 05.02.2019
comment
что-то изменится, если вы используете статические или встроенные функции?   -  person phön    schedule 05.02.2019
comment
@ phön - на самом деле функция в моем примере встроена. Отрывок сборки, который я показываю, взят из встроенного тела внутри main (), а не из автономной бесплатной функции. Материал с argv необходим из-за этого встраивания, иначе компилятор полностью опускает код замены.   -  person BeeOnRope    schedule 05.02.2019
comment
Связанный вопрос: Что делает компилятор C ++, чтобы гарантировать, что разные, но смежные участки памяти можно безопасно использовать в разных потоках? - см. мой ответ на него, в частности, который частично касается, как вы выразились он, придуманный пишет. (Обратите внимание, что в этом случае компилятор знает, что c не может быть const_casted const объектом, и поэтому возможны некоторые оптимизации в однопоточном MM.)   -  person Arne Vogel    schedule 05.02.2019


Ответы (1)


Насколько я могу судить, ваша программа хорошо сформирована и не имеет неопределенного поведения. Абстрактная машина C ++ никогда не назначает объект const. Невыбранного if() достаточно, чтобы "спрятать" / "защитить" вещи, которые были бы UB, если бы они выполнялись. Единственное, от чего if(false) не может вас спасти, - это плохо сформированная программа, например синтаксические ошибки или попытки использовать расширения, которых нет в этом компиляторе или целевой архитектуре.

Обычно компиляторам не разрешается изобретать операции записи с преобразованием if в автономный код.

Отбрасывание const является законным, если вы на самом деле не назначаете его через него. например для передачи указателя на функцию, которая не является корректной по константе и принимает входные данные только для чтения с указателем, отличным от const. Ответ, который вы указали на Разрешено ли отбрасывать константу для объекта, определенного константой, если он фактически не изменен? правильно.


Поведение ICC здесь не свидетельствует о наличии UB в ISO C ++ или C. Я думаю, что ваши доводы правильны, и они четко определены. Вы обнаружили ошибку ICC. Если кому-то интересно, сообщите об этом на их форумах: https://software.intel.com/en-us/forums/intel-c-compiler. Существующие отчеты об ошибках в этом разделе своего форума были приняты разработчиками, например этот.


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

Таким образом, мы не можем доверять генерации кода ICC, чтобы сообщить нам что-либо о том, когда мы вызвали UB, потому что это приведет к сбою кода даже в явно юридических случаях.

Godbolt: ICC19.0.1 -O2 -march=skylake (Старый ICC понимал только такие параметры, как -xcore-avx2, но современный ICC понимает тот же -march, что и GCC / clang.)

#include <stddef.h>

void replace(const char *str1, char *str2, size_t len) {
    for (size_t i = 0; i < len; i++) {
        if (str1[i] == '/') {
            str2[i] = '_';
        }
    }
}

Он проверяет перекрытие между str1[0..len-1] и str2[0..len-1], но для достаточно большого len и отсутствия перекрытия он будет использовать этот внутренний цикл:

..B1.15:                        # Preds ..B1.15 ..B1.14                //do{
        vmovdqu   ymm2, YMMWORD PTR [rsi+r8]                    #6.13   // load from str2
        vpcmpeqb  ymm3, ymm0, YMMWORD PTR [rdi+r8]              #5.24   // compare vs. str1
        vpblendvb ymm4, ymm2, ymm1, ymm3                        #6.13   // blend
        vmovdqu   YMMWORD PTR [r8+rsi], ymm4                    #6.13   // store to str2
        add       r8, 32                                        #4.5    // i+=32
        cmp       r8, rax                                       #4.5
        jb        ..B1.15       # Prob 82%                      #4.5   // }while(i<len);

Что касается безопасности потоков, хорошо известно, что изобретение записи с помощью неатомарного чтения / перезаписи небезопасно.

Абстрактная машина C ++ никогда не касается str2, что делает недействительными любые аргументы для однострочной версии о невозможности гонки данных UB, потому что чтение str в то же время, когда другой поток пишет, что это уже UB. Даже C ++ 20 std::atomic_ref не меняет этого, потому что мы читаем через неатомарный указатель.

Но что еще хуже, str2 может быть nullptr. Или указывает на конец объекта (который, как правило, хранится в конце страницы), с str1, содержащим символы, которые не записываются после конца str2 / страница произойдет. Мы могли бы даже сделать так, чтобы на новой странице находился только самый последний байт (str2[len-1]), чтобы он был на один конец действительного объекта. Создание такого указателя даже законно (если вы не deref). Но было бы законно передать str2=nullptr; код за if(), который не запускается, не вызывает UB.

Или другой поток выполняет ту же функцию поиска / замены параллельно с другим ключом / заменой, который будет записывать только разные элементы str2. Неатомарная загрузка / сохранение неизмененных значений будет происходить после изменения значения из другого потока. В соответствии с моделью памяти C ++ 11 определенно разрешено, чтобы разные потоки одновременно касались разных элементов одного и того же массива. Модель памяти C ++ и условия гонки для массивов char. (Вот почему размер char должен быть равен наименьшей единице памяти, которую целевая машина может записывать без неатомарного RMW. внутренний атомарный RMW для хранения байтов в кеше, тем не менее, прекрасен и не останавливает выполнение инструкций по хранению байтов быть полезным.)

(Этот пример допустим только с отдельной версией str1 / str2, потому что чтение каждого элемента означает, что потоки будут читать элементы массива, а другой поток может быть в середине записи, что является UB гонки данных.)

Как упоминал Херб Саттер в atomic<> Оружие: модель памяти C ++ и современное оборудование Часть 2: Ограничения на компиляторы и оборудование (включая распространенные ошибки); генерация кода и производительность на x86 / x64, IA64, POWER, ARM и др .; расслабленная атомика; volatile: устранение неатомарного генератора кода RMW было постоянной проблемой для компиляторов после стандартизации C ++ 11. Мы почти достигли цели, но в очень агрессивных и менее распространенных компиляторах, таких как ICC, явно все еще есть ошибки.

(Однако я почти уверен, что разработчики компиляторов Intel сочтут это ошибкой.)


Некоторые менее правдоподобные (чтобы увидеть в реальной программе) примеры, которые также могут сломаться:

Помимо nullptr, вы можете передать указатель на (массив) std::atomic<T> или мьютекс, где неатомарное чтение / перезапись ломает вещи, изобретая записи. (char* может иметь псевдоним что угодно).

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


Для будущих читателей: если вы хотите, чтобы компиляторы выполняли автоматическую векторизацию следующим образом:

Вы можете написать такой источник, как str2[i] = x ? replacement : str2[i];, который всегда записывает строку в абстрактную машину C ++. IIRC, что позволяет gcc / clang векторизовать так же, как ICC, после небезопасного преобразования if в blend.

Теоретически оптимизирующий компилятор может превратить его обратно в условную ветвь в скалярной очистке или что-то еще, чтобы избежать ненужного загрязнения памяти. (Или, если нацелен на ISA, такой как ARM32, где возможно предикатное хранилище, вместо только операций выбора ALU, таких как x86 cmov, PowerPC isel или AArch64 csel. Предиктированные инструкции ARM32 архитектурно являются NOP, если предикат ложен).

Или, если компилятор x86 решил использовать хранилища с маской AVX512, это также сделало бы безопасным векторизацию, как это делает ICC: хранилища с масками подавляют ошибки и никогда не сохраняют элементы, для которых маска ложна. (При использовании регистра маски с загрузкой и сохранением AVX-512 возникает ли ошибка из-за недопустимого доступа к замаскированным элементам?).

vpcmpeqb k1, zmm0, [rdi]   ; compare from memory into mask
vmovdqu8 [rsi]{k1}, zmm1   ; masked store that only writes elements where the mask is true

ICC19 фактически делает это (но с индексированными режимами адресации) с -march=skylake-avx512. Но с векторами ymm, потому что 512-битное значение max turbo слишком сильно, чтобы того стоить, если вся ваша программа не использует AVX512, в любом случае на Skylake Xeon.

Поэтому я думаю, что ICC19 безопасен при векторизации с помощью AVX512, но не AVX2. Если нет проблем в его коде очистки, где он делает что-то более сложное с vpcmpuq и kshift / kor, загрузкой с нулевой маской и сравнением с маской с другим регистром маски.


В AVX1 есть замаскированные магазины (vmaskmovps/pd) с подавлением ошибок и всем остальным, но до AVX512BW нет более узкой детализации чем 32 бита. Целочисленные версии AVX2 доступны только с детализацией dword / qword, vpmaskmovd/q.

person Peter Cordes    schedule 05.02.2019
comment
Я не думаю, что вам даже нужно использовать объекты char aliasing std::atomic, чтобы столкнуться с проблемой модели памяти: представьте сценарий, в котором некоторый поток T2 правильно синхронизировал эксклюзивный доступ к str2 (например, потому что он был опубликован с T1 на T2 через какой-то механизм) и пишет. Тем временем T1 выполняет функцию замены с помощью str2, но это разрешено, потому что вы договорились, что доступ не происходит: но изобретенный T2 записывает, тупит! - person BeeOnRope; 05.02.2019
comment
В качестве альтернативы рассмотрим случай, когда T1 и T2 совместно используют некоторый массив char, но по соглашению T1 всегда обращается только к нечетным элементам, а T2 - к четным. Это разрешено, потому что элементы массива являются отдельными объектами для модели памяти (например, это означает, что при записи байтов нельзя использовать RMW большего слова). Теперь один или оба T1 / 2 используют общий массив как str2, организуя через str1 только для доступа к своим элементам str2. Кажется законным. Иногда он может взорваться, потому что слияние фактически представляет собой широкий RMW, который будет сбивать соседние элементы. Никакого std :: atomic aliasing через char не требуется! - person BeeOnRope; 05.02.2019
comment
@BeeOnRope: о, да, да, другой поток может просто выполнять тот же поиск / замену с ключом, который, как известно, записывает непересекающийся набор элементов. Забыл об этом случае. - person Peter Cordes; 05.02.2019
comment
Это хороший ответ, но мне трудно его принять, потому что я чувствую, что в нем нет четкого ответа на вопрос заголовка. Может быть, пара предложений в начале ответит на вопрос? Первое предложение меня немного сбивает с толку, может быть, его нужно прояснить. Я полагаю, вы говорите что-то вроде того, как ICC компилирует этот код, не свидетельствует о наличии UB в вашем коде на C ++ ..., верно? - person BeeOnRope; 18.02.2019
comment
@BeeOnRope: хороший момент. Добавил раздел в начале. Возможно, это преувеличение, что if(false) может спасти вас от всего, кроме плохо сформированного кода, но я не могу придумать контрпримера. - person Peter Cordes; 18.02.2019