Mengapa GCC tidak dapat berasumsi bahwa std :: vector :: size tidak akan berubah dalam loop ini?

14

Saya mengklaim rekan kerja yang if (i < input.size() - 1) print(0);akan dioptimalkan dalam lingkaran ini sehingga input.size()tidak dibaca di setiap iterasi, tetapi ternyata ini bukan masalahnya!

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

Menurut Compiler Explorer dengan opsi gcc, -O3 -fno-exceptionskami sebenarnya membaca input.size()setiap iterasi dan menggunakan leauntuk melakukan pengurangan!

        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

Menariknya, di Rust optimasi ini memang terjadi. Sepertinya iakan diganti dengan variabel jyang dikurangi setiap iterasi, dan tes i < input.size() - 1diganti dengan sesuatu seperti 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);
        }
    }
}

Di Penjelajah Kompiler , rakitan yang relevan terlihat seperti ini:

        cmpq    %r12, %rbx
        jae     .LBB0_4

Aku memeriksa dan saya cukup yakin r12adalah xs.len() - 1dan rbxadalah meja. Sebelumnya ada addfor rbxdan di movluar loop ke r12.

Kenapa ini? Sepertinya jika GCC mampu menyejajarkan size()dan operator[]seperti yang terjadi, itu harus bisa tahu bahwa size()tidak berubah. Tapi mungkin pengoptimal GCC menilai bahwa tidak layak menariknya ke dalam variabel? Atau mungkin ada beberapa efek samping lain yang mungkin membuat ini tidak aman - adakah yang tahu?

Jonathan Chan
sumber
1
Juga printlnmungkin metode yang kompleks, compiler mungkin mengalami kesulitan pembuktian bahwa printlntidak bermutasi vektor.
Mooing Duck
1
@ MoingDuck: Utas lain adalah data-race UB. Compiler dapat dan menganggap itu tidak terjadi. Masalahnya di sini adalah panggilan fungsi non-inline cout.operator<<(). Kompiler tidak tahu bahwa fungsi kotak hitam ini tidak mendapatkan referensi ke std::vectordari global.
Peter Cordes
@PeterCordes: Anda benar bahwa utas lainnya bukan penjelasan mandiri, dan kompleksitas printlnatau operator<<kuncinya.
Mooing Duck
Kompiler tidak mengetahui semantik dari metode eksternal ini.
user207421

Jawaban:

10

Panggilan fungsi non-inline ke cout.operator<<(int) adalah kotak hitam untuk pengoptimal (karena perpustakaan hanya ditulis dalam C ++ dan semua pengoptimal melihat adalah prototipe; lihat diskusi dalam komentar). Itu harus mengasumsikan setiap memori yang mungkin dapat ditunjukkan oleh var global telah dimodifikasi.

(Atau std::endltelepon. BTW, mengapa memaksakan flout of cout pada saat itu alih-alih hanya mencetak '\n'?)

misalnya untuk semua yang diketahuinya, std::vector<int> &inputadalah referensi ke variabel global, dan salah satu dari panggilan fungsi tersebut memodifikasi global var tersebut . (Atau ada global di vector<int> *ptrsuatu tempat, atau ada fungsi yang mengembalikan pointer ke static vector<int>dalam beberapa unit kompilasi lain, atau cara lain bahwa suatu fungsi bisa mendapatkan referensi ke vektor ini tanpa melewati referensi oleh kami.

Jika Anda memiliki variabel lokal yang alamatnya tidak pernah diambil, kompilator dapat mengasumsikan bahwa panggilan fungsi non-inline tidak dapat memutasinya. Karena tidak akan ada cara bagi variabel global untuk memegang pointer ke objek ini. ( Ini disebut Escape Analysis ). Itu sebabnya kompiler dapat menyimpan size_t iregister di seluruh panggilan fungsi. ( int ihanya bisa dioptimalkan karena dibayangi olehsize_t i dan tidak digunakan sebaliknya).

Itu bisa melakukan hal yang sama dengan lokal vector (yaitu untuk pointer base, end_size dan end_capacity.)

ISO C99 memiliki solusi untuk masalah ini: int *restrict foo. Banyak C ++ mengkompilasi mendukung int *__restrict fooberjanji bahwa memori yang ditunjuk oleh fooadalah hanya diakses melalui pointer itu. Paling umum berguna dalam fungsi yang mengambil 2 array, dan Anda ingin menjanjikan kompilator mereka tidak tumpang tindih. Jadi ia dapat melakukan vektor-otomatis tanpa menghasilkan kode untuk memeriksanya dan menjalankan fallback loop.

OP berkomentar:

Di Rust, referensi yang tidak dapat diubah adalah jaminan global bahwa tidak ada orang lain yang mengubah nilai yang Anda miliki referensi (setara dengan C ++ restrict)

Itu menjelaskan mengapa Rust bisa melakukan optimasi ini tetapi C ++ tidak bisa.


Mengoptimalkan C ++ Anda

Tentunya Anda harus menggunakan auto size = input.size(); sekali di bagian atas fungsi Anda sehingga kompiler tahu itu loop invarian. Implementasi C ++ tidak menyelesaikan masalah ini untuk Anda, jadi Anda harus melakukannya sendiri.

Anda mungkin juga perlu const int *data = input.data();mengangkat banyak pointer data dari std::vector<int>"blok kontrol" juga. Sangat disayangkan bahwa mengoptimalkan dapat membutuhkan perubahan sumber yang sangat non-idiomatik.

Karat adalah bahasa yang jauh lebih modern, dirancang setelah pengembang kompiler belajar apa yang mungkin dalam praktek untuk kompiler. Ini benar-benar menunjukkan dengan cara lain, juga, termasuk mengekspos beberapa hal keren yang dapat dilakukan CPU melalui i32.count_ones, memutar, memindai, dll. Sangat bodoh bahwa ISO C ++ masih tidak mengekspos semua portable ini, kecuali std::bitset::count().

Peter Cordes
sumber
1
Kode OP masih memiliki tes jika vektor diambil berdasarkan nilai. Jadi, meskipun GCC dapat mengoptimalkan dalam hal itu, ia tidak melakukannya.
kenari
1
Standar mendefinisikan perilaku operator<<untuk tipe operan tersebut; jadi dalam Standard C ++ itu bukan kotak hitam dan kompiler dapat menganggap itu melakukan apa yang dikatakan dokumentasi. Mungkin mereka ingin mendukung pengembang perpustakaan menambahkan perilaku tidak standar ...
MM
2
Pengoptimal dapat diberi makan perilaku yang mandat standar, maksud saya adalah bahwa optimasi ini diizinkan oleh standar tetapi vendor kompiler memilih untuk menerapkan dalam cara Anda menggambarkan dan melepaskan optimasi ini
MM
2
@ MM Itu tidak mengatakan objek acak, saya katakan vektor implementasi didefinisikan. Tidak ada dalam standar yang melarang implementasi dari memiliki vektor implementasi yang didefinisikan bahwa operator << memodifikasi dan memungkinkan mengakses vektor ini dengan cara implementasi yang ditentukan. coutmemungkinkan objek dari kelas yang ditentukan pengguna yang berasal dari streambufdikaitkan dengan aliran menggunakan cout.rdbuf. Demikian pula objek yang berasal dari ostreamdapat dikaitkan dengan cout.tie.
Ross Ridge
2
@PeterCordes - Saya tidak akan terlalu percaya diri tentang vektor lokal: segera setelah fungsi anggota keluar dari garis, penduduk setempat secara efektif melarikan diri karena thispointer secara implisit disahkan. Ini bisa terjadi dalam praktiknya secepat konstruktor. Pertimbangkan loop sederhana ini - Saya hanya memeriksa loop utama gcc (dari L34:ke jne L34), tetapi sudah pasti berperilaku seolah-olah anggota vektor telah lolos (memuatnya dari memori setiap iterasi).
BeeOnRope