Почему GCC не может предположить, что std :: vector :: size не изменится в этом цикле?

14

Я обратился к коллеге, который if (i < input.size() - 1) print(0);будет оптимизирован в этом цикле, чтобы input.size()он не читался на каждой итерации, но оказалось, что это не так!

void print(int x) {
    std::cout << x << std::endl;
}

void print_list(const std::vector<int>& input) {
    int i = 0;
    for (size_t i = 0; i < input.size(); i++) {
        print(input[i]);
        if (i < input.size() - 1) print(0);
    }
}

Согласно Compiler Explorer с опциями gcc -O3 -fno-exceptionsмы фактически читаем input.size()каждую итерацию и используем leaдля выполнения вычитания!

        movq    0(%rbp), %rdx
        movq    8(%rbp), %rax
        subq    %rdx, %rax
        sarq    $2, %rax
        leaq    -1(%rax), %rcx
        cmpq    %rbx, %rcx
        ja      .L35
        addq    $1, %rbx

Интересно, что в Rust такая оптимизация действительно происходит. Похоже, iзаменяется переменной, jкоторая уменьшается на каждой итерации, а тест i < input.size() - 1заменяется чем-то вроде j > 0.

fn print(x: i32) {
    println!("{}", x);
}

pub fn print_list(xs: &Vec<i32>) {
    for (i, x) in xs.iter().enumerate() {
        print(*x);
        if i < xs.len() - 1 {
            print(0);
        }
    }
}

В Compiler Explorer соответствующая сборка выглядит так:

        cmpq    %r12, %rbx
        jae     .LBB0_4

Я проверил, и я уверен, что r12есть xs.len() - 1и rbxесть счетчик. Раньше есть addдля rbxи за movпределами цикла в r12.

Почему это? Похоже, что если GCC способен встроить size()и, operator[]как это было сделано, он должен быть в состоянии знать, что size()не меняется. Но, может быть, оптимизатор GCC считает, что не стоит вытаскивать его в переменную? Или может быть есть какой-то другой возможный побочный эффект, который сделает это небезопасным - кто-нибудь знает?

Джонатан Чан
источник
1
Также println, вероятно, это сложный метод, у компилятора могут возникнуть проблемы с доказательством того, что printlnон не изменяет вектор.
мычат Duck
1
@MooingDuck: еще один поток будет гонка данных UB. Компиляторы могут и предполагают, что этого не происходит. Проблема здесь заключается в вызове не встроенной функции cout.operator<<(). Компилятор не знает, что эта функция черного ящика не получает ссылку на std::vectorглобальный объект.
Питер Кордес
@PeterCordes: вы правы, что другие темы не являются самостоятельным объяснением, а сложность printlnили operator<<является ключевой.
мычат Duck
Компилятор не знает семантику этих внешних методов.
user207421

Ответы:

10

Не встроенный вызов функции cout.operator<<(int)- это черный ящик для оптимизатора (поскольку библиотека только что написана на C ++ и оптимизатор видит только прототип; см. Обсуждение в комментариях). Он должен предполагать, что любая память, на которую может указывать глобальная переменная, была изменена.

(Или std::endlвызов. Кстати, зачем вызывать сброс в этой точке вместо того, чтобы просто печатать '\n'?)

Например, все, что он знает, std::vector<int> &inputявляется ссылкой на глобальную переменную, и один из этих вызовов функций изменяет эту глобальную переменную . (Или где-то есть глобальный объект vector<int> *ptr, или есть функция, которая возвращает указатель на a static vector<int>в каком-то другом модуле компиляции, или каким-то другим способом, которым функция может получить ссылку на этот вектор, не передавая ссылку на него нами.

Если бы у вас была локальная переменная, адрес которой никогда не был взят, компилятор мог бы предположить, что вызовы не встроенных функций не могли изменить его. Потому что ни одна глобальная переменная не может содержать указатель на этот объект. ( Это называется анализом побега ). Вот почему компилятор может хранить size_t iв регистре вызовы функций. ( int iможно просто оптимизировать, потому что он скрыт size_t iи не используется в противном случае).

Это может сделать то же самое с локальным vector(то есть для указателей base, end_size и end_capacity.)

ISO C99 имеет решение этой проблемы: int *restrict foo. Многие C ++ компилирует поддерживает int *__restrict fooобещание , что память , на которую указывает fooэто только доступ через этот указатель. Чаще всего полезно в функциях, которые принимают 2 массива, и вы хотите пообещать компилятору, что они не перекрываются. Таким образом, он может автоматически векторизовать без генерации кода, чтобы проверить это и запустить запасной цикл.

ОП комментарии:

В Rust не изменяемая ссылка - это глобальная гарантия того, что никто не изменяет значение, на которое у вас есть ссылка (эквивалент C ++ restrict)

Это объясняет, почему Rust может сделать эту оптимизацию, а C ++ - нет.


Оптимизация вашего C ++

Очевидно, вы должны использовать auto size = input.size();один раз в верхней части вашей функции, чтобы компилятор знал, что это инвариант цикла. Реализации C ++ не решают эту проблему за вас, поэтому вы должны сделать это сами.

Вам также может понадобиться const int *data = input.data();поднять нагрузку указателя данных из std::vector<int>«блока управления». К сожалению, оптимизация может потребовать очень неидиоматических изменений источника.

Rust - гораздо более современный язык, разработанный после того, как разработчики компиляторов узнали, что было возможно на практике для компиляторов. Это действительно показывает и другие способы, в том числе переносное раскрытие некоторых полезных вещей, которые процессоры могут делать через i32.count_ones, вращать, сканировать биты и т. Д. Действительно глупо, что ISO C ++ до сих пор не раскрывает ничего из этого, кроме std::bitset::count().

Питер Кордес
источник
1
Код OP по-прежнему имеет тест, если вектор взят по значению. Так что, хотя GCC может оптимизировать в этом случае, он не делает этого.
грецкий орех
1
Стандарт определяет поведение operator<<для этих типов операндов; поэтому в стандарте C ++ это не черный ящик, и компилятор может предположить, что он делает то, что говорится в документации. Может быть, они хотят поддержать разработчиков библиотек, добавив нестандартное поведение ...
ММ
2
Оптимизатор мог бы работать в соответствии с требованиями стандарта, я хочу сказать, что эта оптимизация разрешена стандартом, но поставщик компилятора решает реализовать способ, который вы описываете, и отказаться от этой оптимизации
MM
2
@MM Это не случайный объект, я сказал вектор, определенный реализацией. В стандарте нет ничего, что запрещало бы реализации иметь определенный вектором реализации, который оператор << изменяет, и разрешать доступ к этому вектору определенным способом реализации. coutпозволяет объекту пользовательского класса, производному от которого, streambufбыть связанным с использованием потока cout.rdbuf. Точно так же объект, полученный из, ostreamможет быть связан с cout.tie.
Росс Ридж
2
@PeterCordes - я не был бы настолько уверен в локальных векторах: как только какая-либо функция-член выходит из-под линии, локальные объекты эффективно экранируются, потому что thisуказатель неявно передается. Это может произойти на практике уже в конструкторе. Рассмотрим этот простой цикл - я проверил только основной цикл gcc (от L34:до jne L34), но он определенно ведет себя так, как будто члены вектора экранированы (загружая их из памяти каждую итерацию).
BeeOnRope