Оптимизация компилятора приводит к неверным результатам

Я занимаюсь разработкой на ядре Zynq Ultrascale+ ARM, и у меня возникла странная проблема: включение оптимизации компилятора приводит к преждевременному выходу из цикла. Компилятор — «aarch64-none-elf-g++».

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

#include <cstdint>
#include <string>
#include <vector>

class TestClass
{
private:
    std::vector<int> command_buffer;
public:
    TestClass() : command_buffer({}) {}
    void read_command();
};

void TestClass::read_command()
{
    printf("Before %lu\n\r", 67ul);

    size_t s = 42;
    while (true)
    {
        s = this->command_buffer.size();

        if (s > 0)
        {
            break;
        }
    }

    printf("Size %lu:\n\r", s);
}

int main()
{
    TestClass tester{ };
    tester.read_command();
}

По сути, я ожидаю, что программа будет непрерывно работать в цикле, проверяя размер вектора до тех пор, пока он не станет отличным от нуля. Поскольку я никогда не добавляю в вектор никаких элементов, он должен зацикливаться вечно. Когда уровень оптимизации установлен на -O0, все работает как положено. Однако установка значения -O2 приводит к немедленному выходу программы из цикла и печати размера вектора, равного 0.

Вывод консоли без оптимизации (-O0):

Раньше: 67

Вывод консоли с оптимизацией (-O2):

Раньше: 67
После: 0

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

.LC0:
    .string "Before: %lu\n\r"
    .align  3
.LC1:
    .string "After: %lu\n\r"
    .text
    .align  2
    .p2align 4,,11
    .global _ZN9TestClass12read_commandEv
    .type   _ZN9TestClass12read_commandEv, %function
_ZN9TestClass12read_commandEv:
.LFB2157:
    .cfi_startproc
    stp x29, x30, [sp, -32]!
    .cfi_def_cfa_offset 32
    .cfi_offset 29, -32
    .cfi_offset 30, -24
    adrp    x1, .LC0
    mov x29, sp
    str x19, [sp, 16]
    .cfi_offset 19, -16
    mov x19, x0
    add x0, x1, :lo12:.LC0
    mov x1, 67
    bl  printf
    ldp x2, x1, [x19]
    adrp    x0, .LC1
    ldr x19, [sp, 16]
    add x0, x0, :lo12:.LC1
    ldp x29, x30, [sp], 32
    .cfi_restore 30
    .cfi_restore 29
    .cfi_restore 19
    .cfi_def_cfa_offset 0
    sub x1, x1, x2
    asr x1, x1, 2
    b   printf
    .cfi_endproc

Есть идеи, почему это происходит?

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

BoP 19.08.2024 19:48

К вашему сведению, вам нужно использовать только синтаксис this->, чтобы различать параметры и члены. Хорошее руководство/стиль кодирования устранит необходимость.

Thomas Matthews 19.08.2024 19:49

Я не понимаю. vector никогда не меняет свой размер, поэтому его размер постоянен, поэтому цикл можно исключить.

Thomas Matthews 19.08.2024 19:52

На моем Mac на базе ARM с чипом M3 приведенный выше код генерирует сбой исключения «ловушки трассировки».

selbie 19.08.2024 20:20

Прогресс_гарантия

Jarod42 19.08.2024 20:20

@ThomasMatthews Я предположил, что, поскольку вектор никогда не меняет размер, код после цикла будет недоступен и, следовательно, не должен выполняться. Я не знал о неопределенном поведении.

Matthew Dvorsky 19.08.2024 22:42
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
3
6
69
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Код имеет неопределенное поведение.

В соответствии с текущей спецификацией стандарта C++ компилятору разрешено предполагать (например, в целях оптимизации), что поток в конечном итоге всегда будет:

  • прекратить,
  • вызвать функцию ввода-вывода библиотеки,
  • оценить доступ volatile,
  • оценить атомарную операцию или
  • оценить операцию синхронизации

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

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

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

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


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

Если вы предполагаете, что другой поток изменит размер вектора, то это и есть ваша настоящая проблема. std::vector не является потокобезопасным. Любая попытка изменить его из другого потока, пока показанный поток выполняет цикл, будет считаться гонкой данных и вызывать неопределенное поведение.

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

Насколько я могу судить, единственным реальным вариантом использования бесконечного цикла такого типа, который не удовлетворяет допущениям, является остановка потока на неопределенный срок. Однако в этом случае это будет просто записано как while(true);, и даже это имеет смысл только в автономном виде, поскольку в противном случае цикл над std::this_thread::sleep_for или std::this_thread::yield, вероятно, был бы более подходящим. (Хотя они также не считаются действиями прогресса в списке вверху.)


На самом деле я пропустил, что изменения для тривиальных бесконечных циклов уже были включены в черновик для C++26.

Благодаря этим изменениям в C++26 приведенный выше список действий расширен и теперь включает вызовы std::this_thread::yield.

Более того, некоторые конкретные конструкции циклов, такие как while(true);, о которых я упоминал выше, теперь называются тривиальными бесконечными циклами и также добавляются в список и больше не имеют неопределенного поведения. Примечательно, что в отличие от C это применимо только к циклам с пустыми операторами итерации.

За исключением автономных реализаций (где is определяется реализацией), тривиальные бесконечные циклы заменяются циклом, имеющим то же поведение, что и while(true) std::this_thread::yield();.

Спасибо за подробное объяснение! Я не осознавал, что это неопределенное поведение в C++, поскольку мне это кажется довольно неинтуитивным. Я согласен, что пример цикла не имеет никакого смысла, кроме как проиллюстрировать то, что я считал ошибкой. Первоначально я собирался обновить подпрограмму службы прерываний и добавить элементы в вектор, думая, что, поскольку это будет один производитель и один потребитель, все будет в порядке, если я удостоверюсь, что вектор никогда не придется перераспределять, но я не сделал этого. Я еще не реализовал это, когда обнаружил эту проблему.

Matthew Dvorsky 19.08.2024 22:39

@MatthewDvorsky Нет, доступ к вектору в обработчике прерывания также является UB. Обработчик прерываний, как и обработчик сигналов, должен обращаться к общим объектам только в том случае, если они относятся к типу volatile sig_atomic_t или являются атомарными объектами без блокировки. См. en.cppreference.com/w/cpp/utility/program/signal#Signal_hand‌​ler. Компилятор разрешен и будет оптимизировать при условии, что в обработчике сигнала или прерывания больше ничего не происходит. Очевидным примером такой оптимизации является сохранение размера вектора в регистре, даже если цикл не оптимизирован.

user17732522 19.08.2024 22:42

(В дополнение к вышесказанному компиляторы обычно предоставляют нестандартные гарантии атомарности и/или порядка памяти для некоторых других volatile доступа в зависимости от деталей архитектуры.)

user17732522 19.08.2024 22:44

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

user17732522 19.08.2024 22:49

Другие вопросы по теме