Parsim Protobuff pada> 2 Gb / s: Bagaimana Saya Belajar Menyukai Rekursi Ekor di C





Sebuah fitur hebat baru - baru ini ditambahkan ke jalur utama compiler Clang . Dengan menggunakan atribut [[clang::musttail]]



or , __attribute__((musttail))



Anda sekarang bisa mendapatkan panggilan tail terjamin di C, C ++, dan Objective-C.



int g(int);
int f(int x) {
    __attribute__((musttail)) return g(x);
}

      
      





( Kompiler online )



Biasanya panggilan ini dikaitkan dengan pemrograman fungsional, tetapi saya hanya tertarik pada mereka dari sudut pandang kinerja. Dalam beberapa kasus, dengan bantuan mereka, Anda bisa mendapatkan kode yang lebih baik dari compiler - setidaknya dengan teknologi kompilasi yang tersedia - tanpa menggunakan assembler.



Penggunaan pendekatan ini dalam mengurai protokol serialisasi memberikan hasil yang luar biasa: kami dapat mengurai dengan kecepatan lebih dari 2 Gb / dtk. , lebih dari dua kali lebih cepat dari solusi terbaik sebelumnya. Speedup ini akan berguna dalam banyak situasi, jadi tidak benar jika membatasi dirinya hanya pada pernyataan "tail calls == double speedup". Namun, tantangan ini adalah elemen kunci yang memungkinkan peningkatan kecepatan tersebut.



Saya akan memberi tahu Anda apa keuntungan dari panggilan ekor, bagaimana kita mengurai protokol yang menggunakannya, dan bagaimana kita dapat memperluas teknik ini ke penerjemah. Saya pikir berkat itu, interpreter dari semua bahasa utama yang ditulis dalam C (Python, Ruby, PHP, Lua, dll.) Dapat memperoleh peningkatan kinerja yang signifikan. Kelemahan utama terkait dengan portabilitas: sekarang ini musttail



adalah ekstensi compiler non-standar. Meskipun saya berharap ini akan berhasil, perlu beberapa saat sebelum ekstensi menyebar cukup luas agar kompiler C di sistem Anda mendukungnya. Saat membangun, Anda dapat mengorbankan efisiensi dengan imbalan portabilitas jika ternyata musttail



tidak tersedia.



Dasar-dasar Tail Call



Tail call adalah panggilan ke fungsi apa pun di posisi ekor, tindakan terakhir sebelum fungsi tersebut mengembalikan hasil. Saat mengoptimalkan panggilan tail, compiler mengkompilasi instruksi untuk panggilan tail jmp



, bukan call



. Ini tidak melakukan tindakan overhead yang biasanya memungkinkan callee untuk g()



kembali ke pemanggil f()



, seperti membuat stack frame baru atau meneruskan alamat pengirim. Sebaliknya, ini f()



merujuk langsung g()



seolah-olah itu adalah bagian dari dirinya sendiri, dan g()



mengembalikan hasilnya langsung ke pemanggil f()



. Optimasi ini aman karena stack frame f()



tidak lagi diperlukan setelah dimulainya panggilan tail, karena variabel lokal tidak dapat diakses f()



.



Meski terlihat sepele, pengoptimalan ini memiliki dua fitur penting yang membuka kemungkinan baru untuk penulisan algoritme. Pertama, dengan menjalankan n panggilan ekor berurutan, tumpukan memori dikurangi dari O (n) menjadi O (1). Ini penting karena tumpukan terbatas dan luapan dapat merusak program. Jadi, tanpa pengoptimalan ini, beberapa algoritme berbahaya. Kedua, ini jmp



menghilangkan overhead call



dan sebagai hasilnya pemanggilan fungsi menjadi seefisien cabang lainnya. Kedua fitur ini memungkinkan panggilan ekor untuk digunakan sebagai alternatif yang efisien untuk struktur kontrol berulang konvensional seperti for



dan while



.



Ide ini sama sekali tidak baru dan berasal dari tahun 1977, ketika Guy Steele menulis seluruh makalah di mana dia berpendapat bahwa panggilan prosedur lebih bersih daripada arsitektur GOTO



, sementara mengoptimalkan panggilan ekor tidak kehilangan kecepatan. Itu adalah salah satu "Karya Lambda" yang ditulis antara tahun 1975 dan 1980, yang merumuskan banyak ide di balik Lisp dan Skema.



Pengoptimalan panggilan ekor bukanlah hal baru, bahkan untuk Clang. Dia dapat mengoptimalkannya sebelumnya, seperti GCC dan banyak kompiler lainnya. Faktanya, atribut musttail



dalam contoh ini tidak mengubah output compiler sama sekali: Clang telah mengoptimalkan panggilan tail untuk -O2



.



Baru di sini adalah jaminan . Meskipun compiler sering berhasil dalam mengoptimalkan tail call, ini adalah hal yang "terbaik" dan Anda tidak dapat mengandalkannya. Secara khusus, pengoptimalan pasti tidak akan berfungsi dalam build yang tidak dioptimalkan: compiler online . Dalam contoh ini, panggilan ekor dikompilasi menjadi call



, jadi kita kembali ke tumpukan ukuran O (n). Inilah mengapa kita membutuhkan musttail



: Hingga kami memiliki jaminan dari compiler bahwa panggilan tail kami akan selalu dioptimalkan di semua mode assembly, tidak aman untuk menulis algoritme dengan panggilan seperti itu untuk iterasi. Dan tetap berpegang pada kode yang hanya berfungsi dengan pengoptimalan diaktifkan adalah kendala yang cukup sulit.



Masalah dengan loop interpreter



Kompiler adalah teknologi yang luar biasa, tetapi tidak sempurna. Mike Pall, penulis LuaJIT, memutuskan untuk menulis penerjemah LuaJIT 2.x dalam bahasa assembly, bukan C, dan dia menyebut ini faktor utama yang membuat penerjemah begitu cepat . Paul kemudian menjelaskan secara lebih rinci mengapa kompiler C kesulitan menemukan loop interpreter utama . Dua poin utama:



  • , .
  • , .


Pengamatan ini mencerminkan dengan baik pengalaman kami dalam mengoptimalkan penguraian protokol serialisasi. Dan panggilan ekor akan membantu kita menyelesaikan kedua masalah tersebut.



Anda mungkin merasa aneh untuk membandingkan loop interpreter dengan parser protokol serialisasi. Namun, kemiripan tak terduga mereka ditentukan oleh sifat dari format kabel protokol: ini adalah sekumpulan pasangan nilai kunci di mana kunci berisi nomor bidang dan tipenya. Kuncinya berfungsi seperti opcode di interpreter: ia memberi tahu kita operasi apa yang perlu dilakukan untuk mengurai bidang ini. Nomor bidang dalam protokol dapat berurutan apa pun, jadi Anda harus siap untuk melompat ke bagian mana pun dari kode kapan saja.



Adalah logis untuk menulis pengurai seperti itu menggunakan perulangan while



dengan ekspresi bertingkat switch



... Ini telah menjadi pendekatan terbaik untuk mem-parsing protokol serialisasi selama masa pakai protokol. Misalnya, berikut adalah kode parsing dari versi C ++ saat ini . Jika kami mewakili aliran kontrol secara grafis, kami mendapatkan skema berikut:





Tetapi diagram tersebut tidak lengkap, karena masalah dapat muncul di hampir setiap tahap. Jenis bidang mungkin salah, atau datanya mungkin rusak, atau kita mungkin melompat ke akhir buffer saat ini. Diagram lengkapnya terlihat seperti ini:





Kita harus tetap berada di jalur cepat (biru) selama mungkin, tetapi ketika dihadapkan pada situasi yang sulit, kita harus menanganinya menggunakan kode fallback. Jalur tersebut biasanya lebih panjang dan lebih kompleks daripada jalur cepat, jalur tersebut melibatkan lebih banyak data dan sering menggunakan panggilan canggung ke fungsi lain untuk menangani kasus yang bahkan lebih kompleks.



Secara teori, jika Anda menggabungkan skema ini dengan profil, kompilator akan mendapatkan semua informasi yang dibutuhkan untuk menghasilkan kode yang paling optimal. Namun dalam praktiknya, dengan ukuran fungsi dan jumlah koneksi ini, seringkali kita harus berkutat dengan compiler. Ini mengeluarkan variabel penting yang ingin kita simpan di register. Ini menerapkan manipulasi frame stack yang ingin kita bungkus di sekitar panggilan ke fungsi fallback. Ini menggabungkan jalur kode identik yang ingin kita pisahkan untuk prediksi cabang. Semuanya terlihat seperti mencoba bermain piano dengan sarung tangan.



Memperbaiki loop interpreter dengan panggilan ekor



Alasan yang dijelaskan di atas sebagian besar merupakan pengungkapan ulang dari pengamatan Paulus tentang siklus utama penafsir . Namun alih-alih menggunakan assembler, kami menemukan bahwa arsitektur yang disesuaikan dapat memberi kami kontrol yang kami butuhkan untuk mendapatkan kode yang hampir optimal dari C. Saya mengerjakan ini dengan kolega saya Gerben Stavenga, yang menghasilkan sebagian besar arsitektur. Pendekatan kami mirip dengan interpreter WebAssembly wasm3 , yang menyebut pola ini "metamachine" .



Kami menempatkan kode untuk parser 2- gigabit kami di upb, pustaka protobuf kecil di C. Ia bekerja sepenuhnya dan melewati semua tes untuk kepatuhan dengan protokol serialisasi, tetapi belum diterapkan di mana pun, dan arsitekturnya belum diimplementasikan dalam versi C ++ dari protokol. Tetapi ketika ekstensi datang ke Clang musttail



(dan upb diperbarui untuk menggunakannya ), salah satu hambatan utama untuk implementasi penuh pengurai cepat kami turun.



Kami telah beralih dari satu fungsi parsing besar dan menerapkan fungsi kecilnya sendiri untuk setiap operasi. Setiap fungsi ekor memanggil operasi berikutnya secara berurutan. Misalnya, di sini adalah fungsi untuk mem-parsing satu bidang dengan ukuran tetap (kodenya disederhanakan dibandingkan dengan upb, saya telah menghapus banyak detail kecil dari arsitektur).



Kode
#include <stdint.h>
#include <stddef.h>
#include <string.h>

typedef void *upb_msg;
struct upb_decstate;
typedef struct upb_decstate upb_decstate;

// The standard set of arguments passed to each parsing function.
// Thanks to x86-64 calling conventions, these will be passed in registers.
#define UPB_PARSE_PARAMS                                          \
  upb_decstate *d, const char *ptr, upb_msg *msg, intptr_t table, \
      uint64_t hasbits, uint64_t data
#define UPB_PARSE_ARGS d, ptr, msg, table, hasbits, data

#define UNLIKELY(x) __builtin_expect(x, 0)
#define MUSTTAIL __attribute__((musttail))

const char *fallback(UPB_PARSE_PARAMS);
const char *dispatch(UPB_PARSE_PARAMS);

// Code to parse a 4-byte fixed field that uses a 1-byte tag (field 1-15).
const char *upb_pf32_1bt(UPB_PARSE_PARAMS) {
  // Decode "data", which contains information about this field.
  uint8_t hasbit_index = data >> 24;
  size_t ofs = data >> 48;

  if (UNLIKELY(data & 0xff)) {
    // Wire type mismatch (the dispatch function xor's the expected wire type
    // with the actual wire type, so data & 0xff == 0 indicates a match).
    MUSTTAIL return fallback(UPB_PARSE_ARGS);
  }

  ptr += 1;  // Advance past tag.

  // Store data to message.
  hasbits |= 1ull << hasbit_index;
  memcpy((char*)msg + ofs, ptr, 4);

  ptr += 4;  // Advance past data.

  // Call dispatch function, which will read the next tag and branch to the
  // correct field parser function.
  MUSTTAIL return dispatch(UPB_PARSE_ARGS);
}

      
      







Untuk fungsi yang kecil dan sederhana, Clang menghasilkan kode yang hampir tidak mungkin dikalahkan:



upb_pf32_1bt:                           # @upb_pf32_1bt
        mov     rax, r9
        shr     rax, 24
        bts     r8, rax
        test    r9b, r9b
        jne     .LBB0_1
        mov     r10, r9
        shr     r10, 48
        mov     eax, dword ptr [rsi + 1]
        mov     dword ptr [rdx + r10], eax
        add     rsi, 5
        jmp     dispatch                        # TAILCALL
.LBB0_1:
        jmp     fallback                        # TAILCALL

      
      





Perhatikan bahwa tidak ada prolog atau epilog, tidak ada preemption register, tidak ada penggunaan stack sama sekali. Keluar hanya jmp



dari panggilan dua ekor, tetapi tidak ada kode yang diperlukan untuk meneruskan parameter, karena argumen sudah ada di register yang benar. Mungkin satu-satunya peningkatan yang mungkin kita lihat di sini adalah lompatan bersyarat untuk panggilan ekor, jne fallback



bukan panggilan jne



berikutnya jmp



.



Jika Anda melihat pembongkaran kode ini tanpa informasi simbolis, Anda tidak akan menyadari bahwa ini adalah keseluruhan fungsi. Ini juga bisa menjadi unit dasar dari fungsi yang lebih besar. Dan itulah yang kami lakukan: kami mengambil interpreter loop, yang merupakan fungsi besar dan kompleks, dan memprogramnya blok demi blok, meneruskan aliran kontrol di antara mereka melalui panggilan tail. Kami memiliki kendali penuh atas distribusi register di batas setiap blok (setidaknya enam register), dan karena fungsinya cukup sederhana dan tidak mendahului register, kami telah mencapai tujuan kami untuk menyimpan status paling penting di sepanjang semua puasa. jalur.



Kami secara independen dapat mengoptimalkan setiap urutan instruksi. Dan kompilator juga akan menangani semua urutan secara independen, karena mereka terletak di fungsi yang terpisah (jika perlu, Anda dapat mencegah penyejajaran dengan noinline



). Ini adalah cara kami menyelesaikan masalah yang dijelaskan di atas, di mana kode dari jalur fallback menurunkan kualitas kode jalur cepat. Dengan menempatkan jalur lambat dalam fungsi yang benar-benar terpisah, stabilitas kecepatan jalur cepat dapat dijamin. Perakit cantik tetap tidak berubah, tidak terpengaruh oleh perubahan apa pun di bagian lain parser.



Jika kita menerapkan pola ini pada contoh Paulus dari LuaJIT , maka secara kasar kita bisa menghubungkan assembler tulisan tangannya dengan sedikit penurunan kualitas kode :



#define PARAMS unsigned RA, void *table, unsigned inst, \
               int *op_p, double *consts, double *regs
#define ARGS RA, table, inst, op_p, consts, regs
typedef void (*op_func)(PARAMS);
void fallback(PARAMS);

#define UNLIKELY(x) __builtin_expect(x, 0)
#define MUSTTAIL __attribute__((musttail))

void ADDVN(PARAMS) {
    op_func *op_table = table;
    unsigned RC = inst & 0xff;
    unsigned RB = (inst >> 8) & 0xff;
    unsigned type;
    memcpy(&type, (char*)&regs[RB] + 4, 4);
    if (UNLIKELY(type > -13)) {
        return fallback(ARGS);
    }
    regs[RA] += consts[RC];
    inst = *op_p++;
    unsigned op = inst & 0xff;
    RA = (inst >> 8) & 0xff;
    inst >>= 16;
    MUSTTAIL return op_table[op](ARGS);
}

      
      





Assembler yang dihasilkan:



ADDVN:                                  # @ADDVN
        movzx   eax, dh
        cmp     dword ptr [r9 + 8*rax + 4], -12
        jae     .LBB0_1
        movzx   eax, dl
        movsd   xmm0, qword ptr [r8 + 8*rax]    # xmm0 = mem[0],zero
        mov     eax, edi
        addsd   xmm0, qword ptr [r9 + 8*rax]
        movsd   qword ptr [r9 + 8*rax], xmm0
        mov     edx, dword ptr [rcx]
        add     rcx, 4
        movzx   eax, dl
        movzx   edi, dh
        shr     edx, 16
        mov     rax, qword ptr [rsi + 8*rax]
        jmp     rax                             # TAILCALL
.LBB0_1:
        jmp     fallback

      
      





Satu-satunya peningkatan yang saya lihat di sini selain di atas adalah jne fallback



bahwa untuk beberapa alasan compiler tidak ingin menghasilkan jmp qword ptr [rsi + 8*rax]



, malah memuat rax



dan kemudian mengeksekusi jmp rax



. Ini adalah masalah pengkodean kecil yang saya harap Clang akan segera diperbaiki tanpa terlalu banyak kesulitan.



Batasan



Salah satu kelemahan utama dari pendekatan ini adalah bahwa semua urutan bahasa assembly yang indah ini secara serempak dinantikan dengan tidak adanya panggilan ekor. Setiap panggilan yang tidak disesuaikan akan membuat bingkai tumpukan dan mendorong banyak data ke tumpukan.



#define PARAMS unsigned RA, void *table, unsigned inst, \
               int *op_p, double *consts, double *regs
#define ARGS RA, table, inst, op_p, consts, regs
typedef void (*op_func)(PARAMS);
void fallback(PARAMS);

#define UNLIKELY(x) __builtin_expect(x, 0)
#define MUSTTAIL __attribute__((musttail))

void ADDVN(PARAMS) {
    op_func *op_table = table;
    unsigned RC = inst & 0xff;
    unsigned RB = (inst >> 8) & 0xff;
    unsigned type;
    memcpy(&type, (char*)&regs[RB] + 4, 4);
    if (UNLIKELY(type > -13)) {
        // When we leave off "return", things get real bad.
        fallback(ARGS);
    }
    regs[RA] += consts[RC];
    inst = *op_p++;
    unsigned op = inst & 0xff;
    RA = (inst >> 8) & 0xff;
    inst >>= 16;
    MUSTTAIL return op_table[op](ARGS);
}

      
      





Tiba-tiba kami mendapatkan ini
ADDVN:                                  # @ADDVN
        push    rbp
        push    r15
        push    r14
        push    r13
        push    r12
        push    rbx
        push    rax
        mov     r15, r9
        mov     r14, r8
        mov     rbx, rcx
        mov     r12, rsi
        mov     ebp, edi
        movzx   eax, dh
        cmp     dword ptr [r9 + 8*rax + 4], -12
        jae     .LBB0_1
.LBB0_2:
        movzx   eax, dl
        movsd   xmm0, qword ptr [r14 + 8*rax]   # xmm0 = mem[0],zero
        mov     eax, ebp
        addsd   xmm0, qword ptr [r15 + 8*rax]
        movsd   qword ptr [r15 + 8*rax], xmm0
        mov     edx, dword ptr [rbx]
        add     rbx, 4
        movzx   eax, dl
        movzx   edi, dh
        shr     edx, 16
        mov     rax, qword ptr [r12 + 8*rax]
        mov     rsi, r12
        mov     rcx, rbx
        mov     r8, r14
        mov     r9, r15
        add     rsp, 8
        pop     rbx
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        pop     rbp
        jmp     rax                             # TAILCALL
.LBB0_1:
        mov     edi, ebp
        mov     rsi, r12
        mov     r13d, edx
        mov     rcx, rbx
        mov     r8, r14
        mov     r9, r15
        call    fallback
        mov     edx, r13d
        jmp     .LBB0_2

      
      







Untuk menghindari hal ini, kami mencoba memanggil fungsi lain hanya melalui panggilan sebaris atau ekor. Ini bisa membosankan jika operasi memiliki banyak tempat di mana situasi yang tidak biasa yang bukan merupakan kesalahan dapat terjadi. Misalnya, ketika kita mengurai protokol serialisasi, variabel integer seringkali satu byte, tetapi yang lebih panjang bukan merupakan kesalahan. Menyebariskan penanganan situasi seperti itu dapat menurunkan kualitas jalur cepat jika kode fallback terlalu rumit. Namun panggilan ekor dari fungsi fallback tidak membuatnya mudah untuk kembali ke operasi saat menangani kelainan, jadi fungsi fallback harus bisa menyelesaikan operasi. Hal ini menyebabkan duplikasi dan kerumitan kode.



Idealnya masalah ini dapat diselesaikan dengan menambahkan __attribute __ ((melestarikan_most))dalam fungsi fallback diikuti dengan panggilan normal, bukan panggilan ekor. Atribut tersebut preserve_most



membuat callee bertanggung jawab untuk memelihara hampir semua register. Ini memungkinkan Anda mendelegasikan tugas mendahului register ke fungsi fallback, jika perlu. Kami bereksperimen dengan atribut ini, tetapi mengalami masalah misterius yang tidak dapat kami selesaikan. Mungkin kami membuat kesalahan di suatu tempat, kami akan kembali ke masalah ini di masa mendatang.



Batasan utama musttail



adalah tidak portabel. Saya sangat berharap atribut ini akan berakar, itu akan diterapkan di GCC, Visual C ++ dan kompiler lain, dan suatu hari bahkan akan distandarisasi. Tetapi ini tidak akan segera terjadi, tetapi apa yang harus kita lakukan sekarang?



Saat ekspansi musttail



tidak tersedia, Anda perlu mengeksekusi setidaknya satu yang benar return



tanpa panggilan tail untuk setiap iterasi teoritis dari loop . Kami belum mengimplementasikan fallback seperti itu di pustaka upb, tetapi menurut saya ini akan berubah menjadi makro yang, bergantung pada ketersediaan, musttail



akan membuat panggilan ekor atau hanya kembali.



All Articles