LINUX.ORG.RU

На самом деле, UB оказалось не нужно

 , , ,


1

7

Привет, ЛОР!

Как тебе известно, языки Си и Си++ кишат неопределённым поведением, случаи которого вызывают много боли у программистов на этих языках. Обычно это оправдывается возможностью для оптимизаций, но почему-то никто не был в состоянии привести примеры того, насколько связанные с неопределённым поведением оптимизации действительно улучшают производительность кода. До нынешнего момента.

Исследователи из университета Бухареста и технического университета Лиссабона провели испытания с инструментарием LLVM, в котором была отключена эксплуатация UB, и оказалось, что производительность кода в результате изменилась крайне незначительно, а в некоторых случаях даже улучшилась. В дополнение, в случаях, где без эксплуатации UB производительность просела, можно зачастую было применить другие оптимизации, спасающие ситуацию.

Ссылка: https://web.ist.utl.pt/nuno.lopes/pubs/ub-pldi25.pdf

В общем, по всему выходит, что тысячи и тысячи людей уже десятки лет страдают абсолютно зря, и все эти ужасы на самом деле были абсолютно впустую. Такие дела, ЛОР.

★★★★★

Последнее исправление: hateyoufeel (всего исправлений: 1)
Ответ на: комментарий от anonymous

Метафора красивая, аргумент так себе. Мы знаем, что в C/C++ UB взялся не из-за теории заговора, а из-за понятных технических причин. Только жить от этого легче не становится.

kaldeon
()
Ответ на: комментарий от kaldeon

Мы-то знаем, а вот автор (и его клон) с поразительным упорством делают вид, что нет. Вообще так долго держать интригу и неопределённость, тролль он или дурак, мало пациентов ЛОР способны. 😊

anonymous
()
Ответ на: комментарий от anonymous

Ты норм начал но потом начал писать чушь.

Да, UB никто не вставлял специально из вредности.

Нет, UB с указателями вовсе не потому что нет проверок. И UB с знаковым переполнением тоже не из-за этого. Они как раз из-за того, что компилятор делает специальный лишний код. Разумеется, он делает его не для того чтобы испортить твою прогу, а для того чтобы попытаться её оптимизировать, а порча получается побочным эффектом.

UB знакового переполнения это вовсе не о том, что компилятор не знает чему равно (int)0x7FFFFFFF+1. Оно про то, что компилятор, встретив if((int)0x7FFFFFFF+1>5) { ... }, это сложение вообще считать не будет, а скажет: вот у нас 0x7FFFFFFF, оно явно больше пяти, и мы к нему что-то ещё прибавляем - сумма конечно тоже будет больше пяти, и соптимизирует это условие в безусловное true. Достаточно всего лишь не делать таких необоснованных оптимизаций, честно посчитать что слева выходит большое отрицательное число, и никакого UB там не останется. Кстати, эта чушь отключается флагом -fno-strict-overflow - надо не забывать везде его указывать если оптимизации включаешь.

С выходом за границу массива, в целом, аналогично. Если у тебя код

int f(int j) {
  char x[5];
  x[j] = 7;
  if(j>5) { ... }
  ...
}
то компилятор может посчитать что раз упоминается x[j], значит j не может быть некорректным индексом, а значит условие «j>5» всегда ложно и можно его даже не компилировать. Вот оно UB. А то, что при передаче j=10 семёрка запишется в какую-то не ту ячейку памяти - это не UB, это вполне себе понятное поведение, хоть и скорее всего портящее работу программы.

firkax ★★★★★
()
Ответ на: комментарий от luke

define мощных, вон, DC-ROMA II будет по-мощнее моего ноута.

Которые сгодятся хотя бы для CI. Химеровцы жаловались недавно, что ничего тупо нет. Даже 64-ядерный чип настолько медленный, что просто не смешно.

hateyoufeel ★★★★★
() автор топика
Ответ на: комментарий от vtVitus

Возможно стоит ввести «два» стандарта один широкий, обратно совместим и соответственно с ub, а другой узкий под чисто конкретные железки, которые занимают 99% рынка и без ub.

Прикол тут в том, что если взять и явно описать требуемое поведение, совместимость не пострадает вообще ни с чем.

hateyoufeel ★★★★★
() автор топика
Ответ на: комментарий от luke

Вопрос тут скорее не для конечного потребителя, а для производителя. Сэкономишь на штате юристов как минимум, ну и белые дяди из Арма за попу не схватят если ты какой-нибудь risc-v стартап купишь с их SoC и впихнёшь в свой Пежо.

Да нет, производителю в среднем тоже посрать. Единственный бонус у RISC-V в сравнение с ARM – его санкциями не запретить. А в остальном, что в одном надо IP-блоки покупать, что в другом. То на то и выходит.

hateyoufeel ★★★★★
() автор топика
Ответ на: комментарий от firkax

И UB с знаковым переполнением тоже не из-за этого. Они как раз из-за того, что компилятор делает специальный лишний код. Разумеется, он делает его не для того чтобы испортить твою прогу, а для того чтобы попытаться её оптимизировать, а порча получается побочным эффектом.

Объясни мне пожалуйста, почему оптимизация знакового переполнения добавляет бесконечный цикл вот сюда:

#include <iostream>

int main() {
    char buf[50] = "y";
    for (int j = 0; j < 9; ++j) {
        std::cout << (j * 0x20000001) << std::endl;
        if (buf[0] == 'x') break;
    }
}

Собирать gcc с -O3. Проверено на GCC 11 и 14. Если что, пример синтетический, но вытащен из реального кода, где такое вот выстрелило.

hateyoufeel ★★★★★
() автор топика
Последнее исправление: hateyoufeel (всего исправлений: 1)
Ответ на: комментарий от hateyoufeel

Думаю, что компилятор полагается на то, что результат j * 0x20000001 не может вызвать переполнения, а потому j не может превысить значение, необходимое для выхода из цикла. Там прикол в том, что даже уже при -O1 компилятор не делает отдельного итерирования j и вычисления j * 0x20000001, а преобразует сначала код в

for (int j = 0; j < 0x120000009; j += 0x20000001) {
    std::cout << j << std::endl;
}

но 0x120000009 превышает возможное значение для int j, а потому компилятор решает, что оно недостижимо, соответственно j < 0x120000009 == false, и условие выхода из цикла можно выбросить. Если написать хотя бы 0x20000001L, то значение long укладывается в допустимые рамки, и цикл остаётся.

static_lab ★★★★★
()
Ответ на: комментарий от static_lab

Там прикол в том, что даже уже при -O1 компилятор не делает отдельного итерирования j и вычисления j * 0x20000001, а преобразует сначала код в

Да-да. Вопрос в том, нахера он это делает-то. Потому что на производительность это не то чтобы сильно влияет. Как и написано в PDF по ссылке, оптимизации вокруг UB в основном достаточно бесполезны.

Если написать хотя бы0x20000001L, то значение long укладывается в допустимые рамки, и цикл остаётся.

И вот поэтому большая часть сишных и плюсовых проектов собираются с -fwrapv. Потому что это UB проще выключить чем пытаться не отстрелить себе случайно член.

hateyoufeel ★★★★★
() автор топика
Последнее исправление: hateyoufeel (всего исправлений: 2)
Ответ на: комментарий от hateyoufeel

Потому что на производительность это не то чтобы сильно влияет. Как и написано в PDF по ссылке, оптимизации вокруг UB в основном достаточно бесполезны.

Так этот шаг с UB никак не связан. Просто сложение дешевле умножения, а далее используется только умноженное на константу значение. Это значит, что можно не инкрементить счётчик на единицу, а сразу прибавлять константу.

При -O0 компилятор делает «умножение в столбик»:

mov     edx, DWORD PTR [rbp-4]
mov     eax, edx
sal     eax, 29
add     eax, edx
...
add     DWORD PTR [rbp-4], 1

Уже при -O1 заменяет инкрементом на константу:

add     ebx, 536870913

Очевидно, что это это разумная оптимизация, потому что одна операция сложения эффективнее, даже если бы счётчик сразу лежал в edx, а не на стеке.

static_lab ★★★★★
()
Ответ на: комментарий от luke

Химеровцы

Кто это? Какие-то ноунеймы.

Насрать 10 раз кто это. Вопрос такой: есть ли в открытой продаже SoC, достаточная для того, чтобы прогонять на ней CI средне-крупного проекта, такого как линуксовый дистр, и при этом не ждать сборки по полтора месяца? Ответ: нет, нету.

На ARM64 есть. На AMD/Intel, ясен хер, тоже есть. А вот на RISC-V нету почему-то.

hateyoufeel ★★★★★
() автор топика
Последнее исправление: hateyoufeel (всего исправлений: 1)
Ответ на: комментарий от hateyoufeel

Вопрос такой: есть ли в открытой продаже SoC, достаточная для того, чтобы прогонять на ней CI средне-крупного проекта, такого как линуксовый дистр, и при этом не ждать сборки по полтора месяца?

А кто за это заплатит? Никому линукс на RISC-V пока не упёрся, вот и нет тебе серверов. А так, вон, ЕС дал денег на разработку HPC, через пару лет мб и сервера появятся.

luke ★★★★★
()
Ответ на: комментарий от luke

Вопрос такой: есть ли в открытой продаже SoC, достаточная для того, чтобы прогонять на ней CI средне-крупного проекта, такого как линуксовый дистр, и при этом не ждать сборки по полтора месяца?

А кто за это заплатит? Никому линукс на RISC-V пока не упёрся, вот и нет тебе серверов.

Спасибо, я про это и пишу: не взлетает RISC-V, разве что в сраном ебмеддеде, который никто особо в глаза не видит.

hateyoufeel ★★★★★
() автор топика
Ответ на: комментарий от static_lab

Очевидно, что это это разумная оптимизация, потому что одна операция сложения эффективнее, даже если бы счётчик сразу лежал вedx, а не на стеке.

Очевидно кому? Есть бенчмарки? Какую роль это играет в цикле из 10 итераций, который проще вообще развернуть и не долбиться с циклом, ветвлением, счётчиком и прочим говном?

hateyoufeel ★★★★★
() автор топика
Ответ на: комментарий от vtVitus

По мне от ub особо ни кто не страдает, но то что их уже давно надо определись тоже думаю ясно. Возможно стоит ввести «два» стандарта один широкий, обратно совместим и соответственно с ub, а другой узкий под чисто конкретные железки, которые занимают 99% рынка и без ub. И в параметрах компилятора и передавать чего хочется прт сборке. В какомто таком ключе - и тем и сем.

-fpermissive хватит всем

slackwarrior ★★★★★
()
Ответ на: комментарий от hateyoufeel

Да-да. Вопрос в том, нахера он это делает-то.

Очевидно, чтобы сэкономить несколько тактов проца.

Потому что на производительность это не то чтобы сильно влияет. Как и написано в PDF по ссылке, оптимизации вокруг UB в основном достаточно бесполезны.

Верно. Ну, точнее, если код изначально адекватный, то подобные оптимизации скоорсти ему особо не прибавят. Если код писала веб-макака, не имеющая представления о производительности вообще, то возможно эти оптимизации там где-то и сильно сэкономят. Конкретно в данном случае компилятору не следовало заменять одно условие другим, игнорируя переполнение. Хороший программист сам может это сделать, если надо и допустимо.

И вот поэтому большая часть сишных и плюсовых проектов собираются с -fwrapv. Потому что это UB проще выключить чем пытаться не отстрелить себе случайно член.

А почему wrapv а не no-strict-overflow? Короче писать? Я просто читал доки gcc когда-то и выбрал второй вариант почему-то. Ну и да, полностью согласен что это вредное поведение, и авторы gcc очень плохо поступили что сделали его дефолтно включёным в -O2. Лучше бы оставили только в -O3, которое всё равно для нормальных прог бесполезно. Хотя, O1 O2 обычно тоже разница не особо заметна.

firkax ★★★★★
()
Ответ на: комментарий от firkax

Да-да. Вопрос в том, нахера он это делает-то.

Очевидно, чтобы сэкономить несколько тактов проца.

Оно точно их экономит? А если другие более безопасные оптимизации использовать вместо этой?

Верно. Ну, точнее, если код изначально адекватный, то подобные оптимизации скоорсти ему особо не прибавят. Если код писала веб-макака, не имеющая представления о производительности вообще, то возможно эти оптимизации там где-то и сильно сэкономят.

Если код изначально адекватный, эта оптимизация не поможет. А если писала макака, получится вот такая вот сломанная срань. В любом случае, конкретно эта оптимизация оказывается в среднем бесполезной.

Конкретно в данном случае компилятору не следовало заменять одно условие другим, игнорируя переполнение. Хороший программист сам может это сделать, если надо и допустимо.

На самом деле, давно пора сделать -fwrapv дефолтным поведением, включить его в стандарт, а всех дрочащих на one’s complement слать в член. Таким образом, сделав переполнение определённым поведением, а не неопределённым. О чём вся дискуссия и есть: UB в языке вреда приносит куда больше чем пользы.

А почему wrapv а не no-strict-overflow? Короче писать?

Потому что в ядре написано wrapv.

hateyoufeel ★★★★★
() автор топика
Ответ на: комментарий от firkax

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

Оптимизации - это эквивалентные преобразования программы.

Неопределенное поведение (UB) - это фактически противоречивая аксиома в теории.

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

В общем, пишите на Coq, Agde и тп, если хотите избавиться от противоречий, т.е. UB.

anonymous
()
Ответ на: комментарий от anonymous

Дело как раз в том, что при -O2 -O3 оптимизации - не эквивалентные. И как раз то место, где эта неэквивалентность может появиться, и называют UB.

противоречивую программу

Это ещё что за сказки?

firkax ★★★★★
()
Ответ на: комментарий от firkax

оптимизации - не эквивалентные

Сперва надо доказать, что стандарт Си непротиворечив. После доказать непротиворечивость реализации стандарта в рассматриваемом компиляторе. Потом только можно начать обсуждение эквивалентности оптимизаций.

противоречивую программу

Это ещё что за сказки?

Есть формальная верификация программы?

anonymous
()
Ответ на: комментарий от anonymous

Ясно, ты очередной графоман. Не выдумывай сложности, всё просто.

Например, вот такая конструкция:

  pt[5] = 7;
означает: взять значение pt, которое должно быть типа указателя или приводимого к нему (так или иначе это число), прибавить к нему sizeof(*pt)*5, интерпретировать байты памяти по получившемуся адресу как переменную типа разыменованного типа pt и записать туда число 7. Важно отметить, что, даже если pt объявлено как массив, его длина никакого влияния на описанное выше не производит, используется только адрес его начала и тип элемента.

Оптимизатор может превратить эту операцию в запись в регистр или вообще исключить из кода. Это, очевидно, неэквивалентное преобразование, несмотря на то, что в большинстве случаев видимый результат работы программы не меняется.

firkax ★★★★★
()
Последнее исправление: firkax (всего исправлений: 1)
Ответ на: комментарий от firkax

Это, очевидно, неэквивалентное преобразование

«Очевидно» - это не доказательство, а слово, междометие.

несмотря на то, что в большинстве случаев видимый результат работы программы не меняется.

Тут появляются некие подозрения в «очевидности».

anonymous
()
Ответ на: комментарий от anonymous

«Очевидно» - это не доказательство, а слово, междометие.

И что? Я тебе не собирался ничего доказывать, я тебе сообщил банальный факт, который ты, кажется, не в состоянии осознать. Прекращай свою наукообразную графоманию, речь не про неё.

firkax ★★★★★
()
Ответ на: комментарий от firkax

Это ты устроил графоманию с тем что ты хочешь увидеть в синтаксической конструкции языка, которая не имеет смысла вне контекста программы. Что, если pt = NULL? если «static const *»? Си - это не макроассемблер.

anonymous
()
Ответ на: комментарий от anonymous

Если pt==NULL то надо прибавить 5*sizeof(*pt) к NULL и обратиться к этим байтам. const вообще не влияет на действия, это синтаксическое удобство для поиска багов программистом.

которая не имеет смысла вне контекста программы

Имеет, конечно. В том и дело, что эта строчка, при прямой трактовке, имеет совершенно однозначный смысл. Все его изменения могут происходить только по вине неэквивалентного оптимизатора.

firkax ★★★★★
()
Последнее исправление: firkax (всего исправлений: 1)
Ответ на: комментарий от firkax

Если pt==NULL

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

Если даже не NULL. Если указатель не принадлежит массиву, то тоже любая арифметика - UB. Вне массива арифметика указателей не имеет смысла. Насколько знаю.

anonymous
()
Ответ на: комментарий от EXL

Давно пора все UB привести к определённости в новых стандартах языка

В стандартах не надо. В компиляторах надо. Как вариант ввести уровни стандарта и UB определить на уровне платформы.

no-such-file ★★★★★
()
Ответ на: комментарий от anonymous

Бухарест - столица и крупнейший город Румынии (19 миллионов)
Лиссабон - столица, крупнейший город и главный порт Португалии (10 миллионов)

Это примерно как «студенты из Московской и Питерской областей».

Shushundr ★★★★
()
Ответ на: комментарий от hateyoufeel

Оно точно их экономит? А если другие более безопасные оптимизации использовать вместо этой?

А если вообще нахрен не оптимизировать код? Намного лучше будет. Нужно только, что бы его писали не макаки.

Если код изначально адекватный, эта оптимизация не поможет.

А если цикл в одной функции, а умножение в другой, но при инлайне оказался в цикле. Всё равно не оптимизировать? Но что делать, когда сАмЫй БыСтРыЙ яП оСтАлЬнЫе СоСуТ окажется тормозным говном, потому что из-за своей семантики не может оптимизироваться так, как другие ЯП?

unC0Rr ★★★★★
()
Ответ на: комментарий от anonymous

Всё то что ты описал - это опять исключительно происки неэквивалентного оптимизатора. Если же без него, то будет например так:

short *pt;
pt = NULL;
pt[5] = 7;

1) читаем содержимое pt - там (численно) ноль

2) прибавляем к нему sizeof(*pt)*5: sizeof(short) равен двум, 2*5=10, 0+10 = 10 - итоговый адрес будет 10

3) интерпретируем байты оперативной памяти 10 и 11 как переменную типа short, записываем туда число 7, в зависимости от LE/BE архитектуры соответственно запишется либо 7,0 либо 0,7

Даже более того, можно писать так:

*(short*)10 = 7
*(short*)NULL = 7
Обе эти строчки обозначают вполне понятные действия - записать в байты 10,11 и записать в байты 0,1.

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

firkax ★★★★★
()
Ответ на: комментарий от firkax

неэквивалентного

Ты хочешь сделать эквивалентными язык Си и ассемблер. Даже приравнять каждой синтаксический конструкции Си эквивалентную синтаксическую конструкцию asm.

Пиши на асме.

/thread

anonymous
()
Ответ на: комментарий от anonymous

Не «я хочу», а Си для этого и делали. Это потом набежали всякие стандартные графоманы со своими UB. Но не надо вводиться ими в заблуждение, они описывают только логику работы оптимизатора, а не самого языка.

Пиши на асме.

На Си код короче получается и быстрее писать. Но иногда и на асме пишу, да.

firkax ★★★★★
()
Последнее исправление: firkax (всего исправлений: 1)
Ответ на: комментарий от firkax

Это потом набежали всякие стандартные графоманы со своими UB

UB без оптимизаций никуда не исчезает. Стандарт языка не определяет семантику программы в некоторых случаях. Всё.

А оптимизации без учёта UB не позволят даже поместить int i в регистр. Вдруг программист захочет поменять значение этой переменной используя адресную арифметику с соседней переменной? А злобный оптимизатор посчитал что это UB и не может произойти и поместил переменную в регистр.

red75prim ★★★
()
Последнее исправление: red75prim (всего исправлений: 1)
Ответ на: комментарий от red75prim

А оптимизации без учёта UB не позволят даже поместить int i в регистр. Вдруг программист захочет поменять значение этой переменной используя адресную арифметику с соседней переменной? А злобный оптимизатор посчитал что это UB и не может произойти и поместил переменную в регистр.

А здесь не нужно UB. Можно такую херню просто считать ошибкой и вешать компиляцию.

Собственно, компилятор, выкидывающий ошибку компиляции при наличии UB, будет вполне соответствовать стандарту. Многие случаи UB уже рассматриваются как ошибка компиляции, например незакрытые кавычки.

hateyoufeel ★★★★★
() автор топика
Ответ на: комментарий от red75prim

Ещё один адепт комитетов и по совместительству врун.

Стандарт - не истина в последней инстанции, он (С89) лишь подведение итогов разработки компиляторов в предыдущих годах. Последующие так вообще не более чем рекомендации.

А оптимизации без учёта UB не позволят даже поместить int i в регистр. Вдруг программист захочет поменять значение этой переменной используя адресную арифметику с соседней переменной?

Позволят, конечно. Расположение переменных в стеке/памяти не обязано быть каким-то конкретным (кроме взаимного расположения полей структур, да и там сохраняется только порядок, а выравнивания могут быть разными). Так что искать методом тыка соседние переменные в памяти - задача безнадёжная. Только не надо тут говорить что это и есть UB. Ведь точно так же sizeof(int) не обязан быть конкретным - где-то он 2, где-то 4, где-то, может быть 8, но никто не говорит что вычисление sizeof(int) это UB.

Вот если программа где-то вычисляет &i и использует, то да - надо её класть в память, и регистровая оптимизация тут недопустима, а компилятор, который её сделает - забагованный.

firkax ★★★★★
()
Ответ на: комментарий от firkax

Стандарт - не истина в последней инстанции, он (С89) лишь подведение итогов разработки компиляторов в предыдущих годах. Последующие так вообще не более чем рекомендации.

Да нет, ты неправ. Тебе уже много раз писали, что разработчики компиляторов ориентируются в первую очередь на то, что написано в стандарте. И уже потом на всё остальное. Поэтому багзиллы gcc и llvm заполнены воплями «АААААААА НОВЫЙ КОМПИЛЯТОР СЛАМАЛ МОЙ КОД ВЫ ФСЕ НИПРАВЫ ЭТА НИНАСТАЯЩИЙ СИ».

hateyoufeel ★★★★★
() автор топика