Чому GCC не може припустити, що розмір std :: vector :: не зміниться в цьому циклі?


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);
    }
}

Відповідно до Провідника компілятора з параметрами 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

Цікаво, що в Русті така оптимізація все ж відбувається. Схоже, 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);
        }
    }
}

У Провіднику компілятора відповідна збірка виглядає так:

        cmpq    %r12, %rbx
        jae     .LBB0_4

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

Чому це? Схоже, якщо GCC здатний вписати size()і operator[]як це зробив, він повинен мати можливість знати, що size()не змінюється. Але, можливо, оптимізатор GCC вважає, що не варто його витягувати в змінну? А може, є якийсь інший можливий побічний ефект, який би зробив це небезпечним - хтось знає?


1
Також println, ймовірно, складний метод, компілятор може мати проблеми з доведенням, що printlnне мутує вектор.
Mooing Duck

1
@MooingDuck: Ще однією темою буде UB-перегони даних. Компілятори можуть і припускати, що цього не відбувається. Проблема тут - виклик функції, що не вбудовується cout.operator<<(). Компілятор не знає, що ця функція чорного поля не отримує посилання на std::vectorглобальну.
Пітер Кордес

@PeterCordes: ви праві, що інші потоки - це не окреме пояснення, а складність printlnабо operator<<є ключовим.
Mooing Duck

Компілятор не знає семантики цих зовнішніх методів.
користувач207421

Відповіді:


10

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

(Або std::endlдзвінок. BTW, навіщо примушувати флеш кут у цій точці, а не просто друкувати '\n'?)

наприклад, за всіма відомостями, std::vector<int> &inputце посилання на глобальну змінну, і один з цих викликів функцій модифікує цей глобальний var . (Або є vector<int> *ptrдесь глобальний , або є функція, яка повертає вказівник на a static vector<int>в якомусь іншому блоці компіляції, або іншим способом, щоб функція могла отримати посилання на цей вектор, не передавши нам посилання на нього.

Якщо у вас була локальна змінна, адреса якої ніколи не була прийнята, компілятор міг би припустити, що виклики функції, що не вбудовуються, не могли її вимкнути. Тому що жодна глобальна змінна не зможе утримувати покажчик на цей об’єкт. ( Це називається аналіз втечі ). Ось чому компілятор може зберігати size_t iв реєстрі всі функції викликів. ( int iможе просто оптимізуватися, тому що це затіненоsize_t i і не використовується в іншому випадку).

Це можна було зробити з місцевим vector (тобто для базових, кінцевих розмірів та покажчиків кінцевих можливостей.)

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

Зауваження ОП:

У Rust незмінна посилання - це глобальна гарантія того, що ніхто більше не мутує значення, на яке ви посилаєтесь (еквівалентно C ++ restrict)

Це пояснює, чому Руст може зробити цю оптимізацію, але 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<<цих типів операндів; тож у Standard C ++ це не чорна скринька, і компілятор може припустити, що він виконує те, що говорить документація. Можливо, вони хочуть підтримати розробників бібліотеки, додаючи нестандартну поведінку ...
ММ

2
Оптимізатор міг би живитись поведінкою, яку вимагає стандарт, маю на увазі, що ця оптимізація дозволена стандартом, але постачальник компілятора вирішує реалізувати так, як ви описуєте та відмовитесь від цієї оптимізації
ММ

2
@MM Це не сказав випадковий об'єкт, я сказав вектор, визначений реалізацією. У стандарті немає нічого, що забороняє імплементації мати визначений реалізацією вектор, який оператор << модифікує та дозволяє отримати доступ до цього вектора визначеним способом реалізації. coutдозволяє об’єкт класу, визначеного користувачем, похідний від, streambufщоб бути пов'язаний з потоком за допомогою cout.rdbuf. Аналогічно об'єкт, похідний від, ostreamможе бути пов'язаний з cout.tie.
Росс Ридж

2
@PeterCordes - Я не був би таким впевненим у відношенні локальних векторів: як тільки будь-яка функція-член виходить із-за межі, місцеві жителі фактично втекли, оскільки thisвказівник неявно передається. Це може статися на практиці вже в конструкторі. Розглянемо цей простий цикл - я перевіряв лише основний цикл gcc (від L34:до jne L34), але він, безумовно, веде себе так, ніби втекли векторні учасники (завантажуючи їх із пам'яті кожну ітерацію).
BeeOnRope
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.