Ketika panggilan fungsi eksternal lebih cepat dari panggilan C asli

Ditambahkan oleh: diskusi yang baik tentang Berita Peretas

David Yu di GitHub telah mengembangkan tes kinerja yang menarik untuk panggilan fungsi melalui berbagai antarmuka eksternal (Foreign Function Interfaces, FFI ).

Dia menciptakan file objek bersama ( .so ) dengan satu fungsi C. sederhana. Kemudian dia menulis kode untuk berulang kali memanggil fungsi ini melalui masing-masing FFI dengan dimensi waktu.

Untuk C "FFI," ia menggunakan tautan dinamis standar, bukan dlopen() . Perbedaan ini sangat penting, karena sangat mempengaruhi hasil tes. Anda bisa berdebat seberapa jujur ​​perbandingan ini dengan FFI yang sebenarnya, tetapi masih menarik untuk diukur.

Hasil benchmark paling mengejutkan adalah bahwa FFI LuaJIT secara signifikan lebih cepat daripada C. Ini sekitar 25% lebih cepat dari panggilan C asli untuk fungsi objek bersama. Bagaimana bahasa scripting yang lemah dan dinamis dapat mengambil alih dalam benchmark C? Apakah hasilnya akurat?

Sebenarnya, ini cukup logis. Tes berjalan di Linux, jadi penundaan berasal dari Tabel Tautan Prosedur (PLT). Saya menyiapkan percobaan yang sangat sederhana untuk menunjukkan efek dalam C lama:

https://github.com/skeeto/dynamic-function-benchmark

Berikut adalah hasil dari Intel i7-6700 (Skylake):

plt: 1.759799 ns/call
ind: 1.257125 ns/call
jit: 1.008108 ns/call


Ada tiga jenis panggilan fungsi:

  1. Melalui PLT.
  2. Panggilan fungsi tidak langsung (via dlsym(3) )
  3. Panggilan fungsi langsung (melalui fungsi yang dikompilasi JIT)

Seperti yang Anda lihat, yang terakhir adalah yang tercepat. Biasanya tidak digunakan dalam program C, tetapi ini adalah pilihan alami di hadapan kompiler JIT, termasuk, tentu saja, LuaJIT.

Dalam tolok ukur saya, fungsi empty() disebut:

 void empty(void) { } 

Kompilasi ke objek bersama:

 $ cc -shared -fPIC -Os -o empty.so empty.c 

Seperti dalam perbandingan PRNG sebelumnya, benchmark sebanyak mungkin memanggil fungsi ini sebelum alarm berbunyi.

Tabel Tata Letak Prosedur


Ketika suatu program atau pustaka memanggil suatu fungsi di objek lain yang dibagikan, kompiler tidak bisa tahu di mana fungsi ini akan berada dalam memori. Informasi ditemukan hanya pada saat runtime ketika program dan dependensinya dimuat ke dalam memori. Biasanya fungsi ini terletak di tempat acak, misalnya, sesuai dengan pengacakan ruang alamat (Address Space Layout Randomization, ASLR).

Bagaimana mengatasi masalah seperti itu? Nah, ada beberapa opsi.

Salah satunya adalah menandai setiap panggilan dalam metadata biner. Pembangun runtime dinamis kemudian memasukkan alamat yang benar pada setiap panggilan. Mekanisme spesifik tergantung pada model kode yang digunakan selama kompilasi.

Kelemahan dari pendekatan ini adalah memperlambat loading, meningkatkan ukuran file biner dan mengurangi pertukaran halaman kode antara proses yang berbeda. Pengunduhan diperlambat karena semua rekan panggilan dinamis perlu ditambal dengan alamat yang benar sebelum memulai program. Biner membengkak karena setiap entri membutuhkan tempat di tabel. Dan kurangnya berbagi dikaitkan dengan perubahan halaman kode.

Di sisi lain, overhead dari menjalankan fungsi dinamis dapat dihilangkan, yang memberikan kinerja seperti JIT, seperti yang ditunjukkan dalam benchmark.

Opsi kedua adalah untuk merutekan semua panggilan dinamis melalui tabel. Rekan panggil asli merujuk ke rintisan dalam tabel ini, dan dari sana ke fungsi dinamis yang sebenarnya. Dengan pendekatan ini, kode tidak perlu ditambal, yang mengarah ke pertukaran sepele antara proses. Untuk setiap fungsi dinamis, Anda perlu menambal hanya satu catatan di tabel. Selain itu, koreksi ini dapat dilakukan dengan malas , pada panggilan fungsi pertama, yang mempercepat pemuatan lebih.

Pada sistem biner ELF, tabel ini disebut Tabel Tautan Prosedur (PLT). PLT itu sendiri tidak benar-benar diperbaiki - ini ditampilkan sebagai hanya-baca untuk sisa kode. Sebagai gantinya, Global Offset Table (GOT) diperbaiki. Rintisan PLT mengambil alamat fungsi dinamis dari GOT dan secara tidak langsung melompat ke alamat itu. Untuk malas memuat alamat fungsi, entri GOT ini diinisialisasi dengan alamat fungsi yang menemukan karakter target, memperbarui GOT dengan alamat itu, dan kemudian beralih ke fungsi. Panggilan selanjutnya menggunakan alamat yang terdeteksi malas.



Kerugian dari PLT adalah biaya tambahan tambahan untuk menjalankan fungsi dinamis, yang muncul dalam benchmark. Karena tolok ukur hanya mengukur pemanggilan fungsi, perbedaannya tampak cukup signifikan, tetapi dalam praktiknya biasanya mendekati nol.

Inilah patokannya:

 /* Cleared by an alarm signal. */ volatile sig_atomic_t running; static long plt_benchmark(void) { long count; for (count = 0; running; count++) empty(); return count; } 

Karena empty() dalam objek bersama, panggilan melewati PLT.

Panggilan Dinamis Tidak Langsung


Cara lain untuk memanggil fungsi secara dinamis adalah dengan menelusuri PLT dan mendapatkan alamat fungsi target dalam program, misalnya, melalui dlsym(3) .

 void *h = dlopen("path/to/lib.so", RTLD_NOW); void (*f)(void) = dlsym("f"); f(); 

Jika alamat fungsi diterima, maka biayanya kurang dari panggilan fungsi melalui PLT. Tidak ada fungsi antara rintisan dan akses ke GOT. (Perhatian: jika program memiliki catatan PLT untuk fungsi ini, maka dlsym(3) sebenarnya dapat mengembalikan alamat rintisan).

Tetapi ini masih merupakan tantangan tidak langsung . Pada arsitektur konvensional, panggilan fungsi langsung menerima alamat relatif langsungnya. Maksudnya, tujuan dari panggilan itu adalah beberapa offset kode-keras dari titik panggilan. CPU dapat mengetahui ke mana panggilan akan pergi jauh lebih awal.

Panggilan tidak langsung memiliki lebih banyak overhead. Pertama, alamat perlu disimpan di suatu tempat. Bahkan jika itu hanya sebuah register, penggunaannya meningkatkan defisit register. Kedua, panggilan tidak langsung memprovokasi prediktor cabang di CPU, memaksakan beban tambahan pada prosesor. Dalam skenario terburuk, panggilan bahkan dapat menyebabkan pipa berhenti.

Inilah patokannya:

 volatile sig_atomic_t running; static long indirect_benchmark(void (*f)(void)) { long count; for (count = 0; running; count++) f(); return count; } 

Fungsi yang diteruskan ke tolok ukur ini diekstraksi menggunakan dlsym(3) , sehingga kompiler tidak dapat melakukan sesuatu yang rumit , seperti mengubah panggilan tidak langsung ini kembali ke langsung.

Jika loop body cukup kompleks untuk menyebabkan defisit register dan dengan demikian memberikan alamat ke stack, maka tolok ukur ini juga tidak dapat secara jujur ​​dibandingkan dengan tolok ukur PLT.

Panggilan fungsi langsung


Dua jenis panggilan fungsi dinamis yang pertama adalah sederhana dan mudah digunakan. Panggilan langsung untuk fungsi dinamis lebih sulit untuk diatur, karena mereka memerlukan perubahan kode selama eksekusi. Dalam benchmark saya, saya mengumpulkan kompiler JIT kecil untuk menghasilkan panggilan langsung.

Kuncinya adalah bahwa pada transisi eksplisit x86-64 terbatas pada kisaran 2 GB karena operan yang ditandatangani 32-bit (langsung ditandatangani). Ini berarti bahwa kode JIT harus ditempatkan hampir di sebelah fungsi target, empty() . Jika kode JIT harus memanggil dua fungsi dinamis yang berbeda, dibagi lebih dari 2 GB, maka tidak mungkin untuk membuat dua panggilan langsung.

Untuk menyederhanakan situasi, tolok ukur saya tidak khawatir tentang pilihan yang tepat atau sangat hati-hati dari alamat kode JIT. Setelah menerima alamat fungsi target, itu hanya mengurangi 4 MB, membulatkannya ke halaman terdekat, mengalokasikan sedikit memori dan menulis kode untuk itu. Jika semuanya dilakukan sebagaimana mestinya, maka untuk menemukan tempat Anda perlu memeriksa representasi Anda sendiri dari program dalam memori, dan ini tidak dapat dilakukan dengan cara yang bersih dan portabel. Linux membutuhkan parsing file virtual di bawah / proc .

Ini adalah bagaimana alokasi memori JIT saya terlihat. Ini mengasumsikan perilaku yang wajar untuk casting uintptr_t :

 static void jit_compile(struct jit_func *f, void (*empty)(void)) { uintptr_t addr = (uintptr_t)empty; void *desired = (void *)((addr - SAFETY_MARGIN) & PAGEMASK); /* ... */ unsigned char *p = mmap(desired, len, prot, flags, fd, 0); /* ... */ } 

Dua halaman menonjol di sini: satu untuk menulis, dan yang lainnya dengan kode yang tidak dapat ditulis. Seperti di perpustakaan saya untuk penutupan , di sini halaman bawah dapat ditulisi dan berisi variabel yang running yang disetel ulang ke alarm. Halaman ini harus di sebelah kode JIT untuk memberikan akses yang efektif mengenai RIP, sebagai fungsi dalam dua tolok ukur lainnya. Halaman atas berisi kode rakitan ini:

 jit_benchmark: push rbx xor ebx, ebx .loop: mov eax, [rel running] test eax, eax je .done call empty inc ebx jmp .loop .done: mov eax, ebx pop rbx ret 

call empty adalah satu-satunya instruksi yang dihasilkan secara dinamis, perlu untuk mengisi alamat relatif dengan benar (minus 5 ditunjukkan relatif ke akhir instruksi):

  // call empty uintptr_t rel = (uintptr_t)empty - (uintptr_t)p - 5; *p++ = 0xe8; *p++ = rel >> 0; *p++ = rel >> 8; *p++ = rel >> 16; *p++ = rel >> 24; 

Jika fungsi empty() tidak dalam objek umum, tetapi dalam file biner yang sama, maka ini pada dasarnya adalah panggilan langsung yang akan dihasilkan oleh kompiler untuk plt_benchmark() , dengan asumsi bahwa karena alasan tertentu ia tidak dibangun di empty() .

Ironisnya, memanggil kode yang dikompilasi JIT membutuhkan panggilan tidak langsung (misalnya, melalui pointer fungsi), dan tidak ada cara lain untuk mengatasi hal ini. Apa yang bisa saya lakukan di sini, mengkompilasi JIT fungsi lain untuk panggilan langsung? Untungnya, ini tidak masalah karena hanya panggilan langsung yang diukur dalam satu lingkaran.

Bukan rahasia


Mengingat hasil ini, menjadi jelas mengapa LuaJIT menghasilkan panggilan yang lebih efisien untuk fungsi dinamis daripada PLT, bahkan jika mereka tetap panggilan tidak langsung . Dalam patokan saya, panggilan tidak langsung tanpa PLT 28% lebih cepat daripada dengan PLT, dan panggilan langsung tanpa PLT 43% lebih cepat daripada dengan PLT. Keuntungan kecil dari program JIT ini dibandingkan dengan program asli lama yang sederhana dicapai karena penolakan mutlak pertukaran kode antara proses.

Source: https://habr.com/ru/post/id413181/


All Articles