Mengoptimalkan program untuk pipeline di Intel Sandybridge-family CPUs


322

Saya telah memeras otak saya selama seminggu mencoba menyelesaikan tugas ini dan saya berharap seseorang di sini dapat menuntun saya ke jalan yang benar. Biarkan saya mulai dengan instruksi instruktur:

Tugas Anda adalah kebalikan dari tugas lab pertama kami, yaitu untuk mengoptimalkan program bilangan prima. Tujuan Anda dalam penugasan ini adalah untuk pesimis program, yaitu membuatnya berjalan lebih lambat. Keduanya adalah program intensif CPU. Mereka membutuhkan beberapa detik untuk berjalan di PC lab kami. Anda tidak boleh mengubah algoritme.

Untuk menonaktifkan program, gunakan pengetahuan Anda tentang cara kerja pipa Intel i7. Bayangkan cara untuk memesan kembali jalur instruksi untuk memperkenalkan WAR, RAW, dan bahaya lainnya. Pikirkan cara-cara untuk meminimalkan efektivitas cache. Tidak kompeten secara iblis.

Tugas itu memberi pilihan program Whetstone atau Monte-Carlo. Komentar efektivitas cache sebagian besar hanya berlaku untuk Whetstone, tetapi saya memilih program simulasi Monte-Carlo:

// Un-modified baseline for pessimization, as given in the assignment
#include <algorithm>    // Needed for the "max" function
#include <cmath>
#include <iostream>

// A simple implementation of the Box-Muller algorithm, used to generate
// gaussian random numbers - necessary for the Monte Carlo method below
// Note that C++11 actually provides std::normal_distribution<> in 
// the <random> library, which can be used instead of this function
double gaussian_box_muller() {
  double x = 0.0;
  double y = 0.0;
  double euclid_sq = 0.0;

  // Continue generating two uniform random variables
  // until the square of their "euclidean distance" 
  // is less than unity
  do {
    x = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    y = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    euclid_sq = x*x + y*y;
  } while (euclid_sq >= 1.0);

  return x*sqrt(-2*log(euclid_sq)/euclid_sq);
}

// Pricing a European vanilla call option with a Monte Carlo method
double monte_carlo_call_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(S_cur - K, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

// Pricing a European vanilla put option with a Monte Carlo method
double monte_carlo_put_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(K - S_cur, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

int main(int argc, char **argv) {
  // First we create the parameter list                                                                               
  int num_sims = 10000000;   // Number of simulated asset paths                                                       
  double S = 100.0;  // Option price                                                                                  
  double K = 100.0;  // Strike price                                                                                  
  double r = 0.05;   // Risk-free rate (5%)                                                                           
  double v = 0.2;    // Volatility of the underlying (20%)                                                            
  double T = 1.0;    // One year until expiry                                                                         

  // Then we calculate the call/put values via Monte Carlo                                                                          
  double call = monte_carlo_call_price(num_sims, S, K, r, v, T);
  double put = monte_carlo_put_price(num_sims, S, K, r, v, T);

  // Finally we output the parameters and prices                                                                      
  std::cout << "Number of Paths: " << num_sims << std::endl;
  std::cout << "Underlying:      " << S << std::endl;
  std::cout << "Strike:          " << K << std::endl;
  std::cout << "Risk-Free Rate:  " << r << std::endl;
  std::cout << "Volatility:      " << v << std::endl;
  std::cout << "Maturity:        " << T << std::endl;

  std::cout << "Call Price:      " << call << std::endl;
  std::cout << "Put Price:       " << put << std::endl;

  return 0;
}

Perubahan yang saya buat tampaknya meningkatkan waktu berjalan kode satu detik, tetapi saya tidak sepenuhnya yakin apa yang bisa saya ubah untuk menghentikan pipa tanpa menambahkan kode. Suatu titik ke arah yang benar akan luar biasa, saya menghargai setiap tanggapan.


Pembaruan: profesor yang memberi tugas ini memposting beberapa detail

Highlights adalah:

  • Ini adalah kelas arsitektur semester kedua di community college (menggunakan buku teks Hennessy dan Patterson).
  • komputer lab memiliki CPU Haswell
  • Para siswa telah terkena CPUIDinstruksi dan cara menentukan ukuran cache, serta intrinsik dan CLFLUSHinstruksi.
  • setiap opsi kompiler diizinkan, dan begitu juga asm.
  • Menulis algoritma akar kuadrat Anda sendiri diumumkan sebagai berada di luar batas

Komentar Cowmoogun tentang meta thread menunjukkan bahwa tidak jelas optimisasi kompiler dapat menjadi bagian dari ini, dan diasumsikan-O0 , dan bahwa 17% peningkatan run-time adalah wajar.

Jadi sepertinya tujuan dari tugas ini adalah untuk membuat siswa memesan ulang pekerjaan yang ada untuk mengurangi paralelisme tingkat pengajaran atau hal-hal seperti itu, tetapi bukan hal yang buruk bahwa orang telah menggali lebih dalam dan belajar lebih banyak.


Ingatlah bahwa ini adalah pertanyaan arsitektur komputer, bukan pertanyaan tentang cara membuat C ++ lambat secara umum.


97
Saya mendengar i7 sangat buruk denganwhile(true){}
Cliff AB


5
Dengan openmp jika Anda melakukannya dengan buruk, Anda seharusnya dapat membuat utas N membutuhkan waktu lebih lama dari 1.
Flexo

9
Pertanyaan ini sekarang sedang dibahas dalam meta
Hantu Madara

3
@bluefeet: Saya menambahkan itu karena sudah menarik satu suara dekat dalam waktu kurang dari satu jam dibuka kembali. Hanya perlu 5 orang untuk datang dan VTC tanpa menyadari membaca komentar untuk melihatnya dalam diskusi tentang meta. Ada lagi suara dekat sekarang. Saya pikir setidaknya satu kalimat akan membantu menghindari siklus tutup / buka kembali.
Peter Cordes

Jawaban:


405

Bacaan latar belakang penting: pdf microarch Agner Fog , dan mungkin juga Ulrich Drepper Apa Yang Harus Ketahui Setiap Programmer Tentang Memori oleh . Lihat juga tautan lain diberi tag wiki, terutama manual pengoptimalan Intel, dan analisis David Kanter tentang arsitektur mikro Haswell, dengan diagram .

Tugas yang sangat keren; jauh lebih baik daripada yang saya lihat di mana siswa diminta untuk mengoptimalkan beberapa kodegcc -O0 , mempelajari banyak trik yang tidak penting dalam kode nyata. Dalam hal ini, Anda diminta untuk belajar tentang pipa CPU dan menggunakannya untuk memandu upaya de-optimasi Anda, bukan hanya menebak-nebak. Bagian yang paling menyenangkan dari ini adalah membenarkan setiap pesimisasi dengan "ketidakmampuan jahat", bukan niat jahat.


Masalah dengan kata-kata dan kode tugas :

Opsi khusus uarch untuk kode ini terbatas. Itu tidak menggunakan array, dan sebagian besar biaya adalah panggilan ke exp/ logfungsi perpustakaan. Tidak ada cara yang jelas untuk memiliki paralelisme tingkat instruksi yang lebih banyak atau lebih sedikit, dan rantai ketergantungan yang digerakkan loop sangat pendek.

Saya ingin melihat jawaban yang berusaha untuk memperlambat dari mengatur ulang ekspresi untuk mengubah dependensi, untuk mengurangi ILP hanya dari dependensi (bahaya). Saya belum mencobanya.

Intel Sandybridge-family CPUs adalah desain out-of-order agresif yang menghabiskan banyak transistor dan daya untuk menemukan paralelisme dan menghindari bahaya (ketergantungan) yang akan menyulitkan pipa in-order RISC klasik . Biasanya satu-satunya bahaya tradisional yang memperlambatnya adalah dependensi "benar" RAW yang menyebabkan throughput dibatasi oleh latensi.

Bahaya WAR dan WAW untuk register tidak terlalu menjadi masalah, terima kasih untuk penggantian nama register . (kecuali untukpopcnt/lzcnt/tzcnt, yang memiliki ketergantungan salah tujuan mereka pada CPU Intel , meskipun itu hanya untuk menulis. yaitu WAW ditangani sebagai bahaya RAW + tulisan). Untuk pemesanan memori, CPU modern menggunakan antrean toko untuk menunda komit ke dalam cache hingga pensiun, juga menghindari bahaya WAR dan WAW .

Mengapa mulss hanya mengambil 3 siklus di Haswell, berbeda dari tabel instruksi Agner? memiliki lebih banyak tentang register penggantian nama dan menyembunyikan latensi FMA dalam loop produk FP dot.


Nama merek "i7" diperkenalkan dengan Nehalem (penerus Core2) , dan beberapa manual Intel bahkan mengatakan "Core i7" ketika mereka tampaknya berarti Nehalem, tetapi mereka mempertahankan branding "i7" untuk Sandybridge dan kemudian mikroarsitektur. SnB adalah ketika keluarga P6 berevolusi menjadi spesies baru, keluarga SnB . Dalam banyak hal, Nehalem memiliki lebih banyak kesamaan dengan Pentium III daripada dengan Sandybridge (mis. Register baca kios dan kios baca-ROB tidak terjadi pada SnB, karena itu berubah menjadi menggunakan file register fisik. Juga cache uop dan internal yang berbeda format uop). Istilah "arsitektur i7" tidak berguna, karena tidak masuk akal mengelompokkan keluarga SnB dengan Nehalem tetapi tidak dengan Core2. (Nehalem memang memperkenalkan arsitektur cache L3 inklusif bersama untuk menghubungkan beberapa core secara bersamaan, dan juga GPU terintegrasi. Jadi level chip, penamaannya lebih masuk akal.)


Ringkasan ide-ide bagus yang bisa dibenarkan oleh ketidakmampuan jahat

Bahkan tidak kompeten secara jahat tidak mungkin untuk menambahkan pekerjaan yang jelas tidak berguna atau loop tak terbatas, dan membuat kekacauan dengan kelas C ++ / Boost berada di luar ruang lingkup tugas.

  • Multi-utas dengan penghitung loop bersama tunggal std::atomic<uint64_t>, sehingga jumlah total iterasi yang tepat terjadi. Atom uint64_t sangat buruk dengan -m32 -march=i586. Untuk poin bonus, atur agar tidak sejajar, dan melewati batas halaman dengan pemisahan yang tidak rata (bukan 4: 4).
  • Berbagi salah untuk beberapa variabel non-atom lainnya -> pipa mis-spekulasi memori-order hilang, serta kesalahan cache tambahan.
  • Alih-alih menggunakan -pada variabel FP, XOR byte tinggi dengan 0x80 untuk membalik bit tanda, menyebabkan warung penerusan toko .
  • Atur waktu setiap iterasi secara independen, dengan sesuatu yang bahkan lebih berat daripada RDTSC. misalnya CPUID/ RDTSCatau fungsi waktu yang membuat panggilan sistem. Serialisasi instruksi secara inheren pipa-tidak ramah.
  • Ubah kelipatan dengan konstanta untuk membaginya dengan kebalikannya ("untuk kemudahan membaca"). div lambat dan tidak sepenuhnya pipelined.
  • Membuat vektor multiply / sqrt dengan AVX (SIMD), tetapi gagal digunakan vzerouppersebelum panggilan ke skalar matematika-perpustakaan exp()dan log()fungsi, menyebabkan AVX <-> SSE warung transisi .
  • Simpan output RNG dalam daftar tertaut, atau dalam array yang Anda lewati tidak sesuai pesanan. Sama untuk hasil setiap iterasi, dan jumlah di akhir.

Juga tercakup dalam jawaban ini tetapi dikecualikan dari ringkasan: saran yang akan sama lambatnya pada CPU non-pipelined, atau yang tampaknya tidak dapat dibenarkan bahkan dengan ketidakmampuan jahat. misalnya banyak ide gimp-the-compiler yang menghasilkan jelas berbeda / lebih buruk asm.


Multi-utas buruk

Mungkin menggunakan OpenMP untuk loop multi-thread dengan iterasi yang sangat sedikit, dengan overhead yang jauh lebih tinggi daripada gain kecepatan. Kode monte-carlo Anda memiliki paralelisme yang cukup untuk benar-benar mendapatkan speedup, esp. jika kita berhasil membuat setiap iterasi lambat. (Setiap utas menghitung sebagian payoff_sum, ditambahkan di akhir). #omp parallelpada loop itu mungkin akan menjadi optimasi, bukan pesimisasi.

Multi-utas tetapi memaksa kedua utas untuk berbagi penghitung putaran yang sama (dengan atomicpenambahan sehingga jumlah total iterasi benar). Ini tampaknya masuk akal secara logis. Ini berarti menggunakan staticvariabel sebagai penghitung lingkaran. Membenarkan ini penggunaan atomicuntuk counter lingkaran, dan menciptakan aktual cache-garis ping-ponging (asalkan benang tidak berjalan di inti fisik yang sama dengan HyperThreading; yang mungkin tidak seperti yang lambat). Bagaimanapun, ini jauh lebih lambat daripada kasus yang tidak diperebutkan lock inc. Dan lock cmpxchg8buntuk penambahan atom, sebuah uint64_tsistem 32bit harus coba lagi dalam satu lingkaran daripada memiliki perangkat keras yang melakukan arbitrasi atom inc.

Juga buat berbagi palsu , tempat banyak utas menyimpan data pribadi mereka (misalnya status RNG) dalam byte berbeda dari baris cache yang sama. (Tutorial Intel tentang hal itu, termasuk penghitung perf untuk dilihat) . Ada aspek mikroarsitektur spesifik untuk ini : Intel CPU berspekulasi tentang kesalahan memori pemesanan tidak terjadi, dan ada acara perf-order mesin-memori untuk mendeteksi ini, setidaknya pada P4 . Penalti mungkin tidak sebesar pada Haswell. Seperti yang ditunjukkan oleh tautan itu, lockinstruksi ed mengasumsikan ini akan terjadi, menghindari spekulasi yang salah. Muatan normal berspekulasi bahwa core lain tidak akan membatalkan garis cache antara ketika beban dieksekusi dan ketika itu pensiun dalam urutan program (kecuali Anda menggunakanpause ). Berbagi sejati tanpa lockinstruksi ed biasanya bug. Akan menarik untuk membandingkan penghitung loop bersama non-atomik dengan kasing atom. Untuk benar-benar pesimis, pertahankan penghitung loop atom yang dibagikan, dan menyebabkan berbagi salah dalam baris cache yang sama atau berbeda untuk beberapa variabel lain.


Gagasan khusus uarch-random:

Jika Anda dapat memperkenalkan cabang yang tidak dapat diprediksi , itu akan membuat pesimistis kode secara substansial. CPU x86 modern memiliki jaringan pipa yang cukup panjang, sehingga biaya salah duga ~ 15 siklus (saat berjalan dari cache uop).


Rantai ketergantungan:

Saya pikir ini adalah salah satu bagian tugas yang dimaksudkan.

Kalahkan kemampuan CPU untuk mengeksploitasi paralelisme tingkat instruksi dengan memilih urutan operasi yang memiliki satu rantai ketergantungan panjang alih-alih beberapa rantai ketergantungan pendek. Kompiler tidak diperbolehkan untuk mengubah urutan operasi untuk perhitungan FP kecuali Anda menggunakan -ffast-math, karena itu dapat mengubah hasil (seperti dibahas di bawah).

Untuk benar-benar membuat ini efektif, tambah panjang rantai ketergantungan loop-carry. Tidak ada yang melompat begitu jelas, meskipun: loop seperti yang tertulis memiliki rantai ketergantungan loop-carry yang sangat pendek: hanya sebuah FP add. (3 siklus). Beberapa iterasi dapat membuat kalkulasinya dalam penerbangan sekaligus, karena mereka dapat memulai jauh sebelum payoff_sum +=pada akhir iterasi sebelumnya. ( log()dan expikuti banyak instruksi, tetapi tidak lebih dari jendela Haswell yang tidak sesuai untuk menemukan paralelisme: ukuran ROB = 192 domain-gabungan uops, dan ukuran penjadwal = 60 domain-domain tidak digunakan. Segera setelah pelaksanaan iterasi saat ini berlangsung cukup jauh untuk memberikan ruang bagi instruksi dari iterasi berikutnya untuk diterbitkan, setiap bagian dari itu yang memiliki input siap (mis. Rantai dep / independen terpisah) dapat mulai dieksekusi ketika instruksi lama meninggalkan unit eksekusi gratis (misalnya karena mereka mengalami hambatan pada latensi, bukan throughput.).

Keadaan RNG hampir pasti akan menjadi rantai ketergantungan loop-carry yang lebih panjang daripada addps.


Gunakan operasi FP yang lebih lambat / lebih banyak (khususnya divisi lebih banyak):

Bagilah dengan 2,0 alih-alih mengalikan dengan 0,5, dan seterusnya. Multiply FP banyak diselaraskan dalam desain Intel, dan memiliki satu throughput 0,5c pada Haswell dan yang lebih baru. FP divsd/ divpdhanya sebagian disalurkan melalui pipa . (Meskipun Skylake memiliki throughput yang mengesankan per 4c untuk divpd xmm, dengan latensi 13-14c, vs tidak disalurkan sama sekali di Nehalem (7-22c)).

Itu do { ...; euclid_sq = x*x + y*y; } while (euclid_sq >= 1.0);jelas menguji jarak, jadi jelas itu akan sesuai untuk sqrt()itu. : P ( sqrtbahkan lebih lambat dari div).

Seperti yang disarankan @Paul Clayton, menulis ulang ekspresi dengan asosiatif / distributif yang setara dapat memperkenalkan lebih banyak pekerjaan (selama Anda tidak menggunakan -ffast-mathuntuk memungkinkan kompilator mengoptimalkan ulang). (exp(T*(r-0.5*v*v))bisa menjadi exp(T*r - T*v*v/2.0). Perhatikan bahwa sementara matematika pada bilangan real adalah asosiatif, matematika floating point tidak , bahkan tanpa mempertimbangkan overflow / NaN (yang mengapa -ffast-mathtidak diaktifkan secara default). Lihat komentar Paul untukpow() saran tersarang yang sangat berbulu .

Jika Anda dapat menurunkan skala perhitungan ke angka yang sangat kecil, maka operasi matematika FP mengambil ~ 120 siklus tambahan untuk menjebak ke kode mikro ketika operasi pada dua angka normal menghasilkan denormal . Lihat microarch pdf Agner Fog untuk angka dan detail yang tepat. Ini tidak mungkin karena Anda memiliki banyak perkalian, jadi faktor skala akan dikuadratkan dan dialirkan hingga 0,0. Saya tidak melihat cara untuk membenarkan penskalaan yang diperlukan dengan ketidakmampuan (bahkan jahat), hanya niat jahat yang disengaja.


Jika Anda dapat menggunakan intrinsik ( <immintrin.h>)

Gunakan movntiuntuk mengusir data Anda dari cache . Diabolis: ini baru dan tidak teratur, sehingga seharusnya membiarkan CPU menjalankannya lebih cepat, bukan? Atau lihat pertanyaan terkait untuk kasus di mana seseorang dalam bahaya melakukan hal ini (untuk menulis tersebar di mana hanya beberapa lokasi yang panas). clflushmungkin tidak mungkin tanpa kedengkian.

Gunakan shuffle integer antara operasi matematika FP untuk menyebabkan penundaan bypass.

Mencampur instruksi SSE dan AVX tanpa penggunaan yang tepat vzerouppermenyebabkan warung besar di pra-Skylake (dan penalti berbeda di Skylake ). Bahkan tanpa itu, vektorisasi buruk dapat menjadi lebih buruk daripada skalar (lebih banyak siklus menghabiskan pengocokan data ke / dari vektor daripada disimpan dengan melakukan operasi add / sub / mul / div / sqrt untuk 4 iterasi Monte-Carlo sekaligus, dengan 256b vektor) . add / sub / mul unit pelaksanaan sepenuhnya pipelined dan lebar penuh, tetapi div dan sqrt pada 256b vektor tidak secepat pada 128b vektor (atau skalar), sehingga speedup tidak dramatis untukdouble.

exp()dan log()tidak memiliki dukungan perangkat keras, sehingga bagian itu akan memerlukan mengekstraksi elemen vektor kembali ke skalar dan memanggil fungsi pustaka secara terpisah, kemudian mengocok hasilnya kembali menjadi vektor. libm biasanya dikompilasi untuk hanya menggunakan SSE2, jadi akan menggunakan encoding legacy-SSE dari instruksi matematika skalar. Jika kode Anda menggunakan 256b vektor dan panggilan exptanpa melakukan yang vzeroupperpertama, maka Anda berhenti. Setelah kembali, instruksi AVX-128 ingin vmovsdmengatur elemen vektor berikutnya sebagai argumen untuk expjuga akan berhenti. Dan kemudian exp()akan berhenti lagi ketika menjalankan instruksi SSE. Inilah yang terjadi dalam pertanyaan ini , menyebabkan penurunan 10x. (Terima kasih @ZBoson).

Lihat juga eksperimen Nathan Kurz dengan lib matematika vs glibc Intel untuk kode ini . Glibc masa depan akan datang dengan implementasi vektor exp()dan sebagainya.


Jika menargetkan pre-IvB, atau esp. Nehalem, cobalah untuk membuat gcc menyebabkan warung register parsial dengan operasi 16bit atau 8bit diikuti oleh operasi 32bit atau 64bit. Dalam kebanyakan kasus, gcc akan digunakan movzxsetelah operasi 8 atau 16bit, tetapi inilah kasus di mana gcc memodifikasi ahdan kemudian membacaax


Dengan (inline) asm:

Dengan (inline) asm, Anda dapat memecah cache uop: Potongan kode 32B yang tidak muat dalam tiga baris cache 6uop memaksa switch dari cache uop ke decoder. Ketidakmampuan ALIGNmenggunakan banyak byte tunggal nopalih-alih pasangan panjang noppada target cabang di dalam lingkaran dalam mungkin melakukan trik. Atau letakkan bantalan pelurus setelah label, bukan sebelumnya. : P Ini hanya masalah jika frontend adalah bottleneck, yang tidak akan terjadi jika kita berhasil pesimisasi sisa kode.

Gunakan kode modifikasi sendiri untuk memicu pembersihan saluran (alias mesin-nuklir).

LCP warung dari instruksi 16bit dengan terlalu besar untuk muat dalam 8 bit sepertinya tidak akan berguna. Tembolok uop pada SnB dan yang lebih baru berarti Anda hanya membayar penalti decode sekali. Pada Nehalem (i7 pertama), ini mungkin bekerja untuk satu loop yang tidak sesuai dengan buffer loop 28 uop. gcc kadang-kadang akan menghasilkan instruksi seperti itu, bahkan dengan -mtune=inteldan ketika itu bisa menggunakan instruksi 32bit.


Idiom umum untuk waktu adalah CPUID(untuk bersambung)RDTSC . Waktu setiap iterasi secara terpisah dengan CPUID/ RDTSCuntuk memastikan RDTSCtidak mengatur kembali dengan instruksi sebelumnya, yang akan memperlambat segalanya a banyak . (Dalam kehidupan nyata, cara cerdas untuk mengatur waktu adalah mengatur waktu semua iterasi bersama, alih-alih menentukan waktu masing-masing secara terpisah dan menambahkannya).


Menyebabkan banyak kesalahan cache dan perlambatan memori lainnya

Gunakan a union { double d; char a[8]; }untuk beberapa variabel Anda. Menyebabkan warung penerusan toko dengan melakukan penyempitan sempit (atau Baca-Ubah-Tulis) hanya ke salah satu dari byte. (Artikel wiki itu juga membahas banyak hal mikroarsitektur lainnya untuk memuat / menyimpan antrian). misalnya membalikkan tanda doublemenggunakan XOR 0x80 hanya pada byte tinggi , bukan -operator. Pengembang yang tidak kompeten secara jahat mungkin pernah mendengar bahwa FP lebih lambat dari integer, dan dengan demikian mencoba untuk melakukan sebanyak mungkin menggunakan operasi integer. (Kompilator penargetan matematika FP yang sangat bagus dalam register SSE dapat mengkompilasi ini kexorps dengan konstanta dalam register xmm lain, tetapi satu-satunya cara ini tidak buruk untuk x87 adalah jika kompiler menyadari bahwa itu meniadakan nilai dan mengganti add berikutnya dengan subtract.)


Gunakan volatilejika Anda mengkompilasi dengan -O3dan tidak menggunakan std::atomic, untuk memaksa kompiler untuk benar-benar menyimpan / memuat ulang di semua tempat. Variabel global (bukan lokal) juga akan memaksa beberapa toko / memuat ulang, tetapi lemahnya pemesanan model memori C ++ tidak mengharuskan kompiler untuk menumpahkan / memuat ulang ke memori sepanjang waktu.

Ganti vars lokal dengan anggota struct besar, sehingga Anda dapat mengontrol tata letak memori.

Gunakan array dalam struct untuk padding (dan menyimpan angka acak, untuk membenarkan keberadaan mereka).

Pilih tata letak memori Anda sehingga semuanya masuk ke jalur berbeda di "set" yang sama di cache L1 . Ini hanya asosiatif 8 arah, yaitu setiap set memiliki 8 "cara". Garis cache adalah 64B.

Bahkan lebih baik, pisahkan 4096B dengan tepat, karena banyak yang memiliki ketergantungan salah pada toko ke halaman yang berbeda tetapi dengan offset yang sama dalam satu halaman . CPU out-of-order yang agresif menggunakan Memory Disambiguation untuk mencari tahu kapan memuat dan menyimpan dapat disusun ulang tanpa mengubah hasilnya , dan implementasi Intel memiliki false-positive yang mencegah beban memulai lebih awal. Mungkin mereka hanya memeriksa bit di bawah halaman offset, sehingga pemeriksaan dapat dimulai sebelum TLB menerjemahkan bit tinggi dari halaman virtual ke halaman fisik. Selain panduan Agner, lihat jawaban dari Stephen Canon , dan juga bagian di dekat akhir jawaban @Krazy Glew pada pertanyaan yang sama. (Andy Glew adalah salah satu arsitek mikroarsitektur P6 asli Intel.)

Gunakan __attribute__((packed))untuk membiarkan Anda meluruskan variabel sehingga mereka menjangkau garis cache atau bahkan batas halaman. (Jadi satu beban doublemembutuhkan data dari dua baris cache). Load yang tidak selaras tidak memiliki penalti dalam Intel i7 uarch apa pun, kecuali saat melintasi garis cache dan baris halaman. Perpecahan Cache-line masih membutuhkan siklus tambahan . Skylake secara dramatis mengurangi penalti untuk beban pemisah halaman, dari 100 hingga 5 siklus. (Bagian 2.1.3) . Mungkin terkait dengan bisa melakukan dua halaman berjalan secara paralel.

Pemisahan halaman pada atomic<uint64_t>harus tentang kasus terburuk , esp. jika 5 byte dalam satu halaman dan 3 byte di halaman lain, atau apa pun selain 4: 4. Bahkan membagi di tengah lebih efisien untuk pemisahan cache-line dengan 16B vektor pada beberapa uarches, IIRC. Masukkan semuanya ke dalam alignas(4096) struct __attribute((packed))(untuk menghemat ruang, tentu saja), termasuk array untuk penyimpanan untuk hasil RNG. Mencapai misalignment dengan menggunakan uint8_tatau uint16_tuntuk sesuatu sebelum konter.

Jika Anda bisa membuat kompiler menggunakan mode pengalamatan terindeks, itu akan mengalahkan uop micro-fusion . Mungkin dengan menggunakan #defines untuk mengganti variabel skalar sederhana dengan my_data[constant].

Jika Anda dapat memperkenalkan tingkat tipuan ekstra, jadi muat / simpan alamat tidak diketahui lebih awal, yang dapat menjadi pesimis lebih lanjut.


Array melintang dalam urutan yang tidak berdampingan

Saya pikir kita bisa datang dengan pembenaran tidak kompeten untuk memperkenalkan array di tempat pertama: Ini memungkinkan kita memisahkan generasi nomor acak dari penggunaan nomor acak. Hasil dari setiap iterasi juga dapat disimpan dalam sebuah array, untuk kemudian dijumlahkan (dengan ketidakmampuan lebih jahat).

Untuk "keacakan maksimum", kita dapat memiliki perulangan utas di atas array acak yang menuliskan angka acak baru ke dalamnya. Thread yang menggunakan angka acak dapat menghasilkan indeks acak untuk memuat nomor acak. (Ada beberapa perbaikan di sini, tetapi secara mikroarsitektur ini membantu untuk memuat-alamat agar diketahui lebih awal sehingga setiap latensi pemuatan yang mungkin dapat diselesaikan sebelum data yang dimuat diperlukan.) Memiliki pembaca dan penulis pada inti yang berbeda akan menyebabkan kesalahan pemesanan memori -spesifikasi menghapus saluran pipa (seperti yang dibahas sebelumnya untuk kasus berbagi-salah).

Untuk pesimisasi maksimum, lewati array Anda dengan langkah 4096 byte (yaitu 512 ganda). misalnya

for (int i=0 ; i<512; i++)
    for (int j=i ; j<UPPER_BOUND ; j+=512)
        monte_carlo_step(rng_array[j]);

Jadi pola aksesnya adalah 0, 4096, 8192, ...,
8, 4104, 8200, ...
16, 4112, 8208, ...

Ini adalah apa yang Anda dapatkan untuk mengakses array 2D seperti double rng_array[MAX_ROWS][512]dalam urutan yang salah (pengulangan baris, alih-alih kolom dalam satu baris dalam pengulangan, seperti yang disarankan oleh @JesperJuhl). Jika ketidakmampuan jahat dapat membenarkan array 2D dengan dimensi seperti itu, ketidakmampuan taman dunia nyata dengan mudah membenarkan perulangan dengan pola akses yang salah. Ini terjadi dalam kode nyata dalam kehidupan nyata.

Sesuaikan batas loop jika perlu untuk menggunakan banyak halaman berbeda daripada menggunakan kembali beberapa halaman yang sama, jika array tidak terlalu besar. Pengambilan ulang perangkat keras tidak berfungsi (juga / sama sekali) di seluruh halaman. Prefetcher dapat melacak satu stream maju dan mundur dalam setiap halaman (yang terjadi di sini), tetapi hanya akan bertindak jika bandwidth memori belum jenuh dengan non-prefetch.

Ini juga akan menghasilkan banyak kesalahan TLB, kecuali jika halaman digabung ke dalam hugepage ( Linux melakukan ini secara oportunistik untuk alokasi anonim (tidak didukung file) seperti malloc/ newyang menggunakanmmap(MAP_ANONYMOUS) ).

Alih-alih array untuk menyimpan daftar hasil, Anda bisa menggunakan daftar tertaut . Maka setiap iterasi akan membutuhkan pointer-chasing load (bahaya ketergantungan benar RAW untuk alamat-beban dari beban berikutnya). Dengan pengalokasi yang buruk, Anda mungkin dapat menyebarkan daftar node dalam memori, mengalahkan cache. Dengan pengalokasi tidak kompeten yang jahat, itu bisa menempatkan setiap node di awal halamannya sendiri. (misalnya mengalokasikan dengan mmap(MAP_ANONYMOUS)langsung, tanpa memecah halaman atau melacak ukuran objek untuk mendukung dengan benar free).


Ini bukan mikroarsitektur spesifik, dan tidak ada hubungannya dengan pipeline (sebagian besar ini juga akan menjadi perlambatan pada CPU non-pipelined).

Agak di luar topik: membuat kompiler menghasilkan kode lebih buruk / melakukan lebih banyak pekerjaan:

Gunakan C ++ 11 std::atomic<int>dan std::atomic<double>untuk kode yang paling pesimis. Instruksi MFENCE dan locked cukup lambat bahkan tanpa pertentangan dari utas lainnya.

-m32akan membuat kode lebih lambat, karena kode x87 akan lebih buruk daripada kode SSE2. Konvensi panggilan 32-bit berbasis stack mengambil lebih banyak instruksi, dan meneruskan bahkan argumen FP pada stack ke fungsi-fungsi seperti exp(). atomic<uint64_t>::operator++pada -m32membutuhkan lock cmpxchg8Bloop (i586). (Jadi gunakan itu untuk loop counter! [Evil laugh]).

-march=i386juga akan pesimis (terima kasih @Jesper). FP dibandingkan dengan fcomlebih lambat dari 686 fcomi. Pra-586 tidak menyediakan penyimpanan atom 64bit, (apalagi cmpxchg), jadi semua atomicoperasi 64bit mengkompilasi panggilan fungsi libgcc (yang mungkin dikompilasi untuk i686, daripada benar-benar menggunakan kunci). Cobalah di tautan Penjelajah Kompresor Godbolt di paragraf terakhir.

Gunakan long double/ sqrtl/ expluntuk presisi ekstra dan kelambatan ekstra dalam ABI di mana sizeof ( long double) adalah 10 atau 16 (dengan padding untuk penyelarasan). (IIRC, 64bit Windows menggunakan 8byte long doublesetara dengan double. (Pokoknya, memuat / menyimpan 10byte (80bit) operan FP adalah 4/7 uops, vs. floatatau doublehanya mengambil 1 uop untuk fld m64/m32/ fst). Memaksa x87 dengan long doublekekalahan auto-vektorisasi bahkan untuk gcc -m64 -march=haswell -O3.

Jika tidak menggunakan atomic<uint64_t>penghitung lingkaran, gunakan long doubleuntuk semuanya, termasuk penghitung putaran.

atomic<double>kompilasi, tetapi operasi baca-modifikasi-tulis seperti +=tidak didukung untuk itu (bahkan pada 64bit). atomic<long double>harus memanggil fungsi perpustakaan hanya untuk memuat / menyimpan atom. Ini mungkin sangat tidak efisien, karena x86 ISA tidak secara alami mendukung muatan / penyimpanan atom 10byte , dan satu-satunya cara yang dapat saya pikirkan tanpa mengunci ( cmpxchg16b) memerlukan mode 64bit.


Pada -O0, memecah ekspresi besar dengan menetapkan bagian ke vars sementara akan menyebabkan lebih banyak store / reload. Tanpa volatileatau sesuatu, ini tidak masalah dengan pengaturan optimisasi yang akan digunakan oleh kode nyata.

Aturan aliasing memungkinkan a charuntuk alias apa pun, jadi menyimpan melalui suatu char*memaksa kompiler untuk menyimpan / memuat kembali semuanya sebelum / sesudah byte-store, bahkan pada -O3. (Ini adalah masalah untuk kodeuint8_t auto-vektorisasi yang beroperasi pada array , misalnya.)

Coba uint16_tpenghitung putaran, untuk memaksa pemotongan ke 16bit, mungkin dengan menggunakan ukuran operan 16bit (kios potensial) dan / atau movzxinstruksi tambahan (aman). Signed overflow adalah perilaku yang tidak terdefinisi , jadi kecuali jika Anda menggunakan -fwrapvatau setidaknya -fno-strict-overflow, counter loop yang ditandatangani tidak harus diperpanjang lagi setiap iterasi , bahkan jika digunakan sebagai offset ke pointer 64bit.


Paksa konversi dari integer ke floatdan kembali lagi. Dan / atau double<=> floatkonversi. Instruksi memiliki latensi lebih dari satu, dan skalar int-> float ( cvtsi2ss) dirancang dengan buruk untuk tidak nol sisa register xmm. (gcc menyisipkan tambahan pxoruntuk memutus ketergantungan, untuk alasan ini.)


Sering atur afinitas CPU Anda ke CPU yang berbeda (disarankan oleh @Egwor). alasan jahat: Anda tidak ingin satu inti terlalu panas dari menjalankan utas Anda untuk waktu yang lama, bukan? Mungkin bertukar ke inti lain akan membiarkan turbo inti ke kecepatan clock yang lebih tinggi. (Pada kenyataannya: mereka sangat dekat satu sama lain sehingga ini sangat tidak mungkin kecuali dalam sistem multi-socket). Sekarang hanya salah tala dan melakukannya terlalu sering. Selain waktu yang dihabiskan dalam keadaan thread penyimpanan / pemulihan OS, inti baru memiliki cache L2 / L1 dingin, cache uop, dan prediktor cabang.

Memperkenalkan panggilan sistem yang tidak perlu sering dapat memperlambat Anda, apa pun itu. Meskipun beberapa yang penting tetapi sederhana seperti gettimeofdaydapat diimplementasikan di ruang pengguna dengan, tanpa transisi ke mode kernel. (glibc di Linux melakukan ini dengan bantuan kernel, karena kernel mengekspor kode di vdso).

Untuk lebih lanjut tentang overhead panggilan sistem (termasuk kesalahan cache / TLB setelah kembali ke ruang pengguna, bukan hanya konteksnya sendiri), kertas FlexSC memiliki beberapa analisis perf-counter yang hebat tentang situasi saat ini, serta proposal untuk sistem batching panggilan dari proses server multi-utas secara masif.


10
@JesperJuhl: yeah, saya akan membeli pembenaran itu. "sangat tidak kompeten" adalah ungkapan yang luar biasa :)
Peter Cordes

2
Mengubah perkalian dengan konstanta ke divisi dengan kebalikan dari konstanta mungkin secara sederhana mengurangi kinerja (setidaknya jika seseorang tidak mencoba untuk mengecoh -O3 -menghasilkan cepat). Demikian pula dengan menggunakan asosiatif untuk meningkatkan pekerjaan ( exp(T*(r-0.5*v*v))menjadi exp(T*r - T*v*v/2.0); exp(sqrt(v*v*T)*gauss_bm)menjadi exp(sqrt(v)*sqrt(v)*sqrt(T)*gauss_bm)). Asosiatif (dan generalisasi) juga bisa berubah exp(T*r - T*v*v/2.0)menjadi `pow ((pow (e_value, T), r) / pow (pow (pow (e_value, T), v), v)), - 2.0) [atau sesuatu seperti itu] Trik matematika semacam itu tidak benar-benar dianggap sebagai deoptimisasi mikroarsitektur.
Paul A. Clayton

2
Saya sangat menghargai tanggapan ini dan Agner's Fog telah sangat membantu. Saya akan membiarkan intisari ini dan mulai bekerja siang ini. Ini mungkin tugas yang paling berguna dalam hal mempelajari apa yang sebenarnya terjadi.
Cowmoogun

19
Beberapa saran itu sangat tidak kompeten sehingga saya harus berbicara dengan profesor untuk melihat apakah waktu 7 menit yang berjalan sekarang terlalu banyak baginya untuk ingin duduk untuk memverifikasi output. Masih bekerja dengan ini, ini mungkin yang paling menyenangkan yang pernah saya alami dengan sebuah proyek.
Cowmoogun

4
Apa? Tidak ada mutex? Memiliki dua juta utas berjalan secara bersamaan dengan sebuah mutex yang melindungi masing-masing dan setiap perhitungan individu (untuk berjaga-jaga!) Akan membuat superkomputer tercepat di planet ini bertekuk lutut. Yang mengatakan, saya suka jawaban jahat yang tidak kompeten ini.
David Hammen

35

Beberapa hal yang dapat Anda lakukan untuk membuat segalanya berkinerja seburuk mungkin:

  • kompilasi kode untuk arsitektur i386. Ini akan mencegah penggunaan SSE dan instruksi yang lebih baru dan memaksa penggunaan FPU x87.

  • gunakan std::atomicvariabel di mana-mana. Ini akan membuat mereka sangat mahal karena kompiler dipaksa untuk memasukkan penghalang memori di semua tempat. Dan ini adalah sesuatu yang mungkin dilakukan oleh orang yang tidak kompeten untuk "memastikan keamanan benang".

  • pastikan untuk mengakses memori dengan cara terburuk yang dapat diprediksi oleh prefetcher (utama kolom vs utama baris).

  • untuk membuat variabel Anda lebih mahal, Anda bisa memastikan mereka semua memiliki 'durasi penyimpanan dinamis' (alokasi dialokasikan) dengan mengalokasikannya dengan newdaripada membiarkan mereka memiliki 'durasi penyimpanan otomatis' (tumpukan dialokasikan).

  • pastikan bahwa semua memori yang Anda alokasikan sangat aneh selaras dan tentu saja hindari mengalokasikan halaman besar, karena hal itu akan menjadi terlalu efisien TLB.

  • apa pun yang Anda lakukan, jangan buat kode Anda dengan pengoptimal kompiler diaktifkan. Dan pastikan untuk mengaktifkan simbol debug paling ekspresif yang Anda bisa (tidak akan membuat kode berjalan lebih lambat, tetapi itu akan membuang beberapa ruang disk tambahan).

Catatan: Jawaban ini pada dasarnya hanya merangkum komentar saya bahwa @Peter Cordes sudah memasukkan jawaban yang sangat bagus. Sarankan dia mendapatkan Anda upvote jika Anda hanya punya satu untuk cadangan :)


9
Keberatan utama saya terhadap beberapa di antaranya adalah pengungkapan pertanyaan: Untuk menonaktifkan program, gunakan pengetahuan Anda tentang cara kerja pipa Intel i7 . Saya tidak merasa ada sesuatu yang spesifik tentang x87, atau std::atomic, atau tingkat tipuan tambahan dari alokasi dinamis. Mereka akan lambat pada Atom atau K8 juga. Masih upvoting, tapi itu sebabnya saya menolak beberapa saran Anda.
Peter Cordes

Itu adalah poin yang adil. Apapun, hal-hal itu masih bekerja menuju tujuan si penanya. Menghargai upvote :)
Jesper Juhl

Unit SSE menggunakan port 0, 1 dan 5. Unit x87 hanya menggunakan port 0 dan 1.
Michas

@Michas: Anda salah tentang itu. Haswell tidak menjalankan instruksi matematika SSE FP pada port 5. Sebagian besar SSE FP mengocok dan boolean (xorps / andps / orps). x87 lebih lambat, tetapi penjelasan Anda tentang alasannya sedikit salah. (Dan poin ini sepenuhnya salah.)
Peter Cordes

1
@Michas: movapd xmm, xmmbiasanya tidak memerlukan port eksekusi (ditangani pada tahap register-rename pada IVB dan yang lebih baru). Ini juga hampir tidak pernah diperlukan dalam kode AVX, karena semuanya kecuali FMA tidak merusak. Tapi cukup adil, Haswell menjalankannya pada port5 jika tidak dihilangkan. Saya belum melihat register-copy x87 ( fld st(i)), tetapi Anda tepat untuk Haswell / Broadwell: ini berjalan pada p01. Skylake menjalankannya di p05, SnB menjalankannya di p0, IvB menjalankannya di p5. Jadi IVB / SKL melakukan beberapa hal x87 (termasuk membandingkan) pada p5, tetapi SNB / HSW / BDW tidak menggunakan p5 sama sekali untuk x87.
Peter Cordes

11

Anda dapat menggunakan long doubleuntuk perhitungan. Pada x86 formatnya harus 80-bit. Hanya warisan, x87 FPU memiliki dukungan untuk ini.

Beberapa kekurangan FPU x87:

  1. Kurangnya SIMD, mungkin perlu instruksi lebih lanjut.
  2. Berbasis tumpukan, bermasalah untuk arsitektur skalar dan pipeliner super.
  3. Kumpulan register yang terpisah dan cukup kecil, mungkin memerlukan lebih banyak konversi dari register lain dan lebih banyak operasi memori.
  4. Pada Core i7 ada 3 port untuk SSE dan hanya 2 untuk x87, prosesor dapat menjalankan instruksi yang kurang paralel.

3
Untuk matematika skalar, instruksi matematika x87 sendiri hanya sedikit lebih lambat. Menyimpan / memuat operan 10byte secara signifikan lebih lambat, dan desain berbasis stack x87 cenderung memerlukan instruksi tambahan (seperti fxch). Dengan -ffast-math, kompiler yang baik mungkin membuat vektor loop monte-carlo, meskipun, dan x87 akan mencegahnya.
Peter Cordes

Saya telah sedikit memperluas jawaban saya.
Michas

1
re: 4: i7 uarch mana yang kamu bicarakan, dan instruksi apa? Haswell dapat berjalan mulssdi p01, tetapi fmulhanya di p0. addsshanya berjalan pada p1, sama dengan fadd. Hanya ada dua port eksekusi yang menangani operasi matematika FP. (Satu-satunya pengecualian untuk ini adalah bahwa Skylake menjatuhkan add unit khusus dan berjalan addssdi unit FMA pada p01, tetapi faddpada p5. Jadi dengan mencampurkan beberapa faddinstruksi bersama fma...ps, Anda secara teori dapat melakukan FLOP total yang lebih banyak / s.)
Peter Cordes

2
Perhatikan juga bahwa Windows x86-64 ABI memiliki 64bit long double, artinya masih adil double. SysV ABI memang menggunakan 80bit long double. Juga, ulang: 2: pengubahan nama register memperlihatkan paralelisme dalam register tumpukan. Arsitektur berbasis stack memerlukan beberapa instruksi tambahan, seperti fxchg, esp. saat interleaving perhitungan paralel. Jadi lebih sulit mengungkapkan paralelisme tanpa bolak-balik ingatan, daripada sulit bagi raja untuk mengeksploitasi apa yang ada. Anda tidak perlu lebih banyak konversi dari reg lain. Tidak yakin apa yang Anda maksud dengan itu.
Peter Cordes

6

Jawaban telat tapi saya rasa kami tidak cukup menyalahgunakan daftar tertaut dan TLB.

Gunakan mmap untuk mengalokasikan node Anda, sehingga sebagian besar Anda menggunakan MSB dari alamat tersebut. Ini akan menghasilkan rantai pencarian TLB yang panjang, halaman 12 bit, menyisakan 52 bit untuk terjemahan, atau sekitar 5 level yang harus dilalui setiap kali. Dengan sedikit keberuntungan mereka harus pergi ke memori setiap kali untuk pencarian 5 level plus 1 akses memori untuk sampai ke node Anda, level teratas kemungkinan besar akan berada di cache di suatu tempat, sehingga kita dapat berharap untuk akses memori 5 *. Tempatkan simpul sehingga melangkah perbatasan terburuk sehingga membaca pointer berikutnya akan menyebabkan pencarian terjemahan 3-4 lainnya. Ini juga bisa menghancurkan cache karena jumlah pencarian terjemahan yang sangat besar. Juga ukuran tabel virtual mungkin menyebabkan sebagian besar data pengguna menjadi paging ke disk untuk waktu tambahan.

Saat membaca dari daftar tertaut tunggal, pastikan untuk membaca dari awal daftar setiap kali menyebabkan keterlambatan maksimum dalam membaca satu angka.


tabel halaman x86-64 adalah 4 level untuk alamat virtual 48-bit. (PTE memiliki 52 bit alamat fisik). CPU di masa mendatang akan mendukung fitur tabel halaman 5 level, untuk ruang alamat virtual 9 bit lainnya (57). Mengapa dalam 64bit alamat virtual 4 bit pendek (panjang 48bit) dibandingkan dengan alamat fisik (panjang 52 bit)? . OS tidak akan mengaktifkannya secara default karena akan lebih lambat dan tidak membawa manfaat kecuali Anda membutuhkan banyak ruang alamat virt.
Peter Cordes

Tapi ya, ide yang menyenangkan. Anda mungkin dapat menggunakan mmapfile atau wilayah memori bersama untuk mendapatkan beberapa alamat virtual untuk halaman fisik yang sama (dengan konten yang sama), memungkinkan lebih banyak TLB kehilangan jumlah RAM fisik yang sama. Jika daftar tertaut Anda nexthanya offset relatif , Anda bisa memiliki serangkaian pemetaan halaman yang sama dengan +4096 * 1024sampai Anda akhirnya mendapatkan halaman fisik yang berbeda. Atau tentu saja membentang beberapa halaman untuk menghindari hit cache L1d. Ada caching PDE tingkat tinggi dalam perangkat keras berjalan-halaman, jadi ya sebarkan di ruang tambahan!
Peter Cordes

Menambahkan offset ke alamat lama juga membuat latensi penggunaan beban lebih buruk dengan mengalahkan [kasus khusus untuk [reg+small_offset]mode pengalamatan] ( Apakah ada penalti ketika base + offset berada di halaman yang berbeda dari basis? ); Anda akan mendapatkan sumber memori addoffset 64-bit, atau Anda akan mendapatkan beban dan mode pengalamatan yang diindeks seperti [reg+reg]. Juga lihat Apa yang terjadi setelah miss L2 TLB? - page walk menjemput L1d cache di SnB-family.
Peter Cordes
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.