Сбой с 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 только для чтения и размещается в разделе двоичного .rodata, который загружается с использованием страниц только для чтения. Конечно, хранилище было логичным "без операции", записывая обратно те же символы, которые оно прочитало, но процессору все равно!

Законен ли мой код C++, и поэтому я должен винить icc за его неправильную компиляцию, или я куда-то вхожу в болото UB?


1 Тот же сбой из-за той же проблемы происходит с std::replace для std::string а не с моим "C-подобным" кодом, но я хотел максимально упростить анализ и сделать его полностью автономным.

Ответы

Ответ 1

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


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

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

void replace(const+Char+*str1,+Char+*str2, size_t len) {%0A++++for (size_t я %3D+0; я < len; i++) {%0A++++++++if (str1[i%5D+=%3D+!'/!') {%0A++++++++ str2[i%5D+%3D+!'_!';%0A++++++++}%0A++++} }'),l:'5',n:'0',o:'C++ source #1',t:'0')),k:46.80860681674351,l:'4',n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:icc191,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',libraryCode:'1',trim:'1'),lang:c++,libs:!(),options:'-O2 -std=gnu+%2B11+-march=skylake',source:1),l:'5',n:'0',o:'x86-64 icc 19.0.1+(Editor #1,+Compiler+#1)+C++',t:'0')),k:53.19139318325648,l:'4',n:'0',o:'',s:0,t:'0')),l:'2',n:'0',o:'',t:'0')),version:4 rel="nofollow noreferrer">Godbolt: ICC19.0.1 -O2 -march=skylake (Старая -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 не меняет этого, потому что мы читаем std::atomic_ref указатель.

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

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

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

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

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


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

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

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


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

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

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

Или, если компилятор 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-битный макс. Турбо снижает макс. Слишком много, чтобы оно того стоило, если ваша программа не использует AVX512 в любом случае на Skylake Xeons.

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


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

Ответ 2

Я вхожу в болото UB где-нибудь?

Да, вы.

Вы можете:

  1. Ничего не делать, если пользователь не предоставляет строку или
  2. Используйте char global_str[] = "..."; , что позволит вам изменить global_str, или
  3. Сделайте копию global_str и измените ее.