Jika saya memiliki integer 64-bit yang saya tafsirkan sebagai array integer 8-bit yang dikemas dengan 8 elemen. Saya perlu mengurangi konstanta 1
dari setiap integer yang dikemas sambil menangani overflow tanpa hasil dari satu elemen yang mempengaruhi hasil dari elemen lain.
Saya memiliki kode ini saat ini dan berfungsi tetapi saya membutuhkan solusi yang melakukan pengurangan setiap integer 8-bit yang dikemas secara paralel dan tidak membuat akses memori. Pada x86 saya bisa menggunakan instruksi SIMD seperti psubb
itu mengurangi bilangan bulat 8-bit secara paralel tetapi platform yang saya koding tidak mendukung instruksi SIMD. (RISC-V dalam hal ini).
Jadi saya mencoba melakukan SWAR (SIMD dalam register) untuk secara manual membatalkan propagasi carry antara byte a uint64_t
, melakukan sesuatu yang setara dengan ini:
uint64_t sub(uint64_t arg) {
uint8_t* packed = (uint8_t*) &arg;
for (size_t i = 0; i < sizeof(uint64_t); ++i) {
packed[i] -= 1;
}
return arg;
}
Saya pikir Anda bisa melakukan ini dengan operator bitwise tapi saya tidak yakin. Saya mencari solusi yang tidak menggunakan instruksi SIMD. Saya mencari solusi dalam C atau C ++ yang cukup portabel atau hanya teori di baliknya sehingga saya dapat mengimplementasikan solusi saya sendiri.
Jawaban:
Jika Anda memiliki CPU dengan instruksi SIMD yang efisien, SSE / MMX
paddb
(_mm_add_epi8
) juga dapat digunakan.Jawaban Peter Cordes juga menjelaskan sintaks vektor GNU C (gcc / clang), dan keamanan untuk UB alias ketat. Saya sangat menyarankan untuk meninjau jawaban itu juga.Melakukannya sendiri dengan
uint64_t
sepenuhnya portabel, tetapi masih membutuhkan kehati-hatian untuk menghindari masalah penyelarasan dan UB yang ketat saat mengaksesuint8_t
array dengan auint64_t*
. Anda meninggalkan bagian itu dari pertanyaan dengan memulai dengan data Anda diuint64_t
sudah, tetapi untuk GNU C amay_alias
typedef memecahkan masalah (lihat jawaban Peter untuk itu ataumemcpy
).Kalau tidak, Anda dapat mengalokasikan / mendeklarasikan data Anda sebagai
uint64_t
dan mengaksesnya melaluiuint8_t*
saat Anda ingin masing-masing byte.unsigned char*
diizinkan untuk alias apa pun sehingga menghindari masalah untuk kasus spesifik elemen 8-bit. (Jikauint8_t
ada sama sekali, mungkin aman untuk menganggapnya sebagaiunsigned char
.)Perhatikan bahwa ini adalah perubahan dari algoritma yang salah sebelumnya (lihat riwayat revisi).
Ini dimungkinkan tanpa perulangan untuk pengurangan sewenang-wenang, dan menjadi lebih efisien untuk konstanta yang dikenal
1
di setiap byte. Trik utamanya adalah mencegah carry-out dari setiap byte dengan mengatur bit tinggi, lalu mengoreksi hasil pengurangan.Kami akan sedikit mengoptimalkan teknik pengurangan yang diberikan di sini . Mereka mendefinisikan:
dengan
H
didefinisikan sebagai0x8080808080808080U
(yaitu MSBs dari setiap integer yang dikemas). Untuk penurunan,y
adalah0x0101010101010101U
.Kita tahu bahwa
y
semua MSB-nya sudah jelas, jadi kita bisa melewati salah satu langkah mask (yaituy & ~H
sama sepertiy
dalam kasus kami). Perhitungan dilanjutkan sebagai berikut:x
MSB menjadi 1, sehingga pinjaman tidak dapat disebarkan melewati MSB ke komponen berikutnya. Sebut ini input yang disesuaikan.0x01010101010101
dari input yang dikoreksi. Ini tidak menyebabkan pinjaman antar-komponen berkat langkah 1. Sebut ini hasil yang disesuaikan.Operasi dapat ditulis sebagai:
Lebih disukai, ini digarisbawahi oleh kompiler (gunakan arahan kompiler untuk memaksakan ini), atau ekspresi ditulis sebaris sebagai bagian dari fungsi lain.
Testcases:
Detail kinerja
Inilah rakitan x86_64 untuk satu pemanggilan fungsi. Untuk kinerja yang lebih baik, harus digarisbawahi dengan harapan bahwa konstanta dapat hidup dalam register selama mungkin. Dalam loop ketat di mana konstanta hidup dalam register, penurunan aktual membutuhkan lima instruksi: atau + tidak + dan + tambahkan + xor setelah optimasi. Saya tidak melihat alternatif yang akan mengalahkan optimasi kompiler.
Dengan beberapa pengujian IACA dari cuplikan berikut:
kita dapat menunjukkan bahwa pada mesin Skylake, melakukan penurunan, xor, dan bandingkan + lompatan dapat dilakukan pada hanya di bawah 5 siklus per iterasi:
(Tentu saja, pada x86-64 Anda baru saja memuat atau
movq
masuk ke reg XMMpaddb
, jadi mungkin lebih menarik untuk melihat bagaimana kompilasi untuk ISA seperti RISC-V.)sumber
uint8_t
diizinkan untukuint8_t
data alias . Penelepon fungsi Anda (yang perlu mendapatkanuint8_t
data ke auint64_t
) adalah orang-orang yang harus khawatir tentang alias ketat! Jadi mungkin OP seharusnya mendeklarasikan / mengalokasikan arrayuint64_t
karenachar*
diizinkan untuk alias apa pun di ISO C ++, tetapi tidak sebaliknya.Untuk RISC-V Anda mungkin menggunakan GCC / dentang.
Fakta menyenangkan: GCC mengetahui beberapa trik bithack SWAR ini (diperlihatkan dalam jawaban lain) dan dapat menggunakannya untuk Anda ketika menyusun kode dengan vektor asli GNU C untuk target tanpa instruksi SIMD perangkat keras. (Tapi dentang untuk RISC-V akan secara naif membuka gulungannya ke operasi skalar, jadi Anda harus melakukannya sendiri jika Anda ingin kinerja yang baik di seluruh kompiler).
Salah satu keuntungan sintaksis vektor asli adalah bahwa ketika menargetkan mesin dengan SIMD perangkat keras, ia akan menggunakannya daripada secara otomatis membuat vektor bithack Anda atau sesuatu yang mengerikan seperti itu.
Ini membuatnya mudah untuk menulis
vector -= scalar
operasi; sintaks Just Works, menyiarkan secara implisit alias membentangkan skalar untuk Anda.Perhatikan juga bahwa
uint64_t*
beban dariuint8_t array[]
UB adalah aliasing yang ketat, jadi berhati-hatilah dengan itu. (Lihat juga Mengapa strib glibc perlu sangat rumit untuk berjalan cepat? Re: membuat SWAR bithacks ketat-alias aman dalam C murni). Anda mungkin ingin sesuatu seperti ini mendeklarasikanuint64_t
bahwa Anda dapat mengarahkan penunjuk untuk mengakses objek lain, seperti caranyachar*
kerjanya di ISO C / C ++.gunakan ini untuk mendapatkan data uint8_t menjadi uint64_t untuk digunakan dengan jawaban lain:
Cara lain untuk melakukan aliasing-safe load adalah dengan
memcpy
menjadiuint64_t
, yang juga menghilangkanalignof(uint64_t
) persyaratan perataan. Tetapi pada ISA tanpa beban yang tidak selaras efisien, gcc / dentang tidak sejajar dan dioptimalkanmemcpy
ketika mereka tidak dapat membuktikan bahwa pointer selaras, yang akan menjadi bencana bagi kinerja.TL: DR: taruhan terbaik Anda adalah untuk mendeklarasikan data Anda sebagai
uint64_t array[...]
atau mengalokasikannya secara dinamisuint64_t
, atau lebih disukaialignas(16) uint64_t array[];
Itu memastikan keselarasan ke setidaknya 8 byte, atau 16 jika Anda menentukanalignas
.Karena
uint8_t
hampir pastiunsigned char*
, aman untuk mengakses byteuint64_t
viauint8_t*
(tetapi tidak sebaliknya untuk array uint8_t). Jadi untuk kasus khusus di mana jenis elemen sempit iniunsigned char
, Anda dapat menghindari masalah aliasing ketat karenachar
khusus.Contoh sintaks vektor asli GNU C:
GNU C vektor asli selalu diizinkan untuk alias dengan jenis yang mendasarinya (misalnya
int __attribute__((vector_size(16)))
bisa dengan aman aliasint
tetapi tidakfloat
atauuint8_t
atau apa pun.Untuk RISC-V tanpa SIM HW, Anda dapat menggunakannya
vector_size(8)
untuk mengekspresikan granularity yang dapat Anda gunakan secara efisien, dan melakukan dua kali lebih banyak vektor yang lebih kecil.Tetapi
vector_size(8)
mengkompilasi dengan sangat bodoh untuk x86 dengan GCC dan dentang: GCC menggunakan bithacks SWAR dalam register GP-integer, dentang membongkar ke elemen 2-byte untuk mengisi register XMM 16-byte kemudian mengemasnya kembali. (MMX sangat usang sehingga GCC / dentang bahkan tidak repot menggunakannya, setidaknya tidak untuk x86-64.)Tetapi dengan
vector_size (16)
( Godbolt ) kita mendapatkan yang diharapkanmovdqa
/paddb
. (Dengan semua vektor yang dihasilkan olehpcmpeqd same,same
). Dengan-march=skylake
kita masih mendapatkan dua ops XMM terpisah dan bukan satu YMM, jadi sayangnya kompiler saat ini juga tidak "auto-vectorize" ops vektor ke dalam vektor yang lebih luas: /Untuk AArch64, itu tidak terlalu buruk untuk digunakan
vector_size(8)
( Godbolt ); ARM / AArch64 asli dapat bekerja dalam potongan 8 atau 16 byte dengand
atauq
register.Jadi Anda mungkin ingin
vector_size(16)
mengompilasi jika Anda ingin kinerja portabel di x86, RISC-V, ARM / AArch64, dan POWER . Namun, beberapa SPA lain melakukan SIMD dalam register integer 64-bit, seperti MIPS MSA.vector_size(8)
membuatnya lebih mudah untuk melihat asm (nilai data hanya satu register): Godbolt compiler explorerSaya pikir itu ide dasar yang sama dengan jawaban non-looping lainnya; mencegah carry kemudian memperbaiki hasilnya.
Ini adalah 5 instruksi ALU, lebih buruk daripada jawaban atas yang saya pikir. Tapi sepertinya latensi jalur kritis hanya 3 siklus, dengan dua rantai 2 instruksi masing-masing mengarah ke XOR. @Reinstate Monica - jawaban comp - mengkompilasi ke rantai dep 4 siklus (untuk x86). Throughput loop 5 siklus dihambat oleh juga termasuk naif
sub
di jalur kritis, dan loop tidak bottleneck pada latensi.Namun, ini tidak berguna dengan dentang. Ia bahkan tidak menambah dan menyimpan dalam urutan yang sama saat dimuat sehingga bahkan tidak melakukan pipelining perangkat lunak yang baik!
sumber
Saya akan menunjukkan bahwa kode yang Anda tulis benar-benar membuat vektor ketika Anda mulai berurusan dengan lebih dari satu uint64_t tunggal.
https://godbolt.org/z/J9DRzd
sumber
__vector_loop(index, start, past, pad)
konstruk yang implementasi dapat perlakukan sebagaifor(index=start; index<past; index++)
[artinya implementasi apa pun dapat memproses kode menggunakannya, hanya dengan mendefinisikan makro], tetapi yang akan memiliki semantik yang lebih longgar untuk mengundang kompiler untuk memproses sesuatu dalam setiap ukuran power-of-two chunk hinggapad
, memperpanjang start ke bawah dan berakhir ke atas jika mereka belum kelipatan dari ukuran chunk. Efek samping di dalam setiap chunk tidak akan terjadi, dan jikabreak
terjadi dalam loop, repetisi lain ...restrict
sangat membantu (dan akan lebih membantu jika Standar mengakui konsep "setidaknya berdasarkan potensi", dan kemudian didefinisikan "berdasarkan" dan "setidaknya berpotensi berdasarkan" secara langsung tanpa kasus sudut yang konyol dan tidak dapat dikerjakan) proposal saya juga akan memungkinkan kompiler untuk melakukan lebih banyak eksekusi dari loop daripada yang diminta - sesuatu yang akan sangat menyederhanakan vektorisasi, tetapi tidak ada ketentuan yang dibuat oleh Standar.Anda dapat memastikan pengurangan tidak meluap dan kemudian memperbaiki bit tinggi:
sumber
splat(0x01)
dansplat(0x80)
, bukannya mendapatkan satu dari yang lain dengan shift. Bahkan menulisnya seperti itu di sumber godbolt.org/z/6y9v-u tidak menahan tangan kompiler untuk membuat kode yang lebih baik; itu hanya propagasi konstan.Tidak yakin apakah ini yang Anda inginkan tetapi melakukan 8 pengurangan secara paralel satu sama lain:
Penjelasan: Bitmask dimulai dengan 1 di masing-masing angka 8-bit. Kami mengatasinya dengan argumen kami. Jika kita memiliki 1 di tempat ini, kita mengurangi 1 dan harus berhenti. Ini dilakukan dengan mengatur bit yang sesuai ke 0 di new_mask. Jika kita memiliki 0, kita mengaturnya ke 1 dan harus melakukan carry, jadi bitnya tetap 1 dan kita menggeser topeng ke kiri. Anda sebaiknya memeriksa sendiri apakah pembuatan topeng baru berfungsi sebagaimana dimaksud, saya kira begitu, tetapi pendapat kedua tidak akan buruk.
PS: Saya benar-benar tidak yakin jika cek
mask_cp
tidak null dalam loop dapat memperlambat program. Tanpa itu, kode akan tetap benar (karena topeng 0 tidak melakukan apa-apa) dan akan lebih mudah bagi kompiler untuk melakukan loop membuka gulungan.sumber
for
tidak akan berjalan secara paralel, apakah Anda bingungfor_each
?Anda dapat melakukannya dengan operasi bitwise menggunakan di atas, dan Anda hanya perlu membagi integer Anda menjadi 8 bagian bit untuk mengirim 8 kali ke fungsi ini. Bagian berikut ini diambil dari Cara membagi angka 64-bit menjadi delapan nilai 8-bit?dengan saya menambahkan fungsi di atas
Ini valid C atau C ++ terlepas dari bagaimana seseorang menemukan ini
sumber
for_each(std::execution::par_unseq,...
bukanTidak akan mencoba untuk membuat kode, tetapi untuk pengurangan dengan 1 Anda dapat mengurangi dengan kelompok 8 1s dan kemudian periksa untuk memastikan bahwa LSB dari hasil telah "terbalik". Setiap LSB yang belum diaktifkan menunjukkan bahwa carry terjadi dari 8 bit yang berdekatan. Seharusnya dimungkinkan untuk menentukan urutan ANDs / ORs / XOR untuk menangani hal ini, tanpa cabang apa pun.
sumber
Fokus bekerja pada setiap byte sepenuhnya sendiri, lalu taruh kembali di tempatnya.
sumber