Что и почему
FP16 - это формат IEEE, в котором количество битов уменьшено по сравнению с традиционным форматом с плавающей запятой (например, 32 бита = ключевое слово «float», которое мы используем в C / C ++). Основная причина использования этого FP16 с пониженной точностью заключается в том, что при использовании FP16 доступно аппаратное ускорение (если вас устраивает потеря точности) и есть двукратная экономия места.
В частности, некоторые графические процессоры предлагают ускорение от 2X до 8X на FP16 по сравнению с FP32. Несмотря на это, мы часто придерживаемся FP32 (как новички), потому что начало работы с FP16 может быть немного сложным, в основном из-за:
- В языке C / C ++ нет встроенной поддержки FP16 - очевидно, версия C / C ++ для ARM имеет довольно хорошую поддержку, но на x86 нам нужно использовать либо специальные инструкции, либо библиотеки для преобразования из FP32- ›FP16 и обратно (что мы освещаем в этой статье)
- Как новичок - легко испугаться всех этих преобразований типов, особенно при написании кода хоста / процессора и передаче данных / указателей на сторону устройства / графического процессора.
Как
Поэтому в этой статье я постараюсь дать краткое введение о том, как писать код, использующий FP16. Первая проблема при написании ядер FP16 связана с написанием кода хоста, и для этого у нас есть 2 варианта создания массивов FP16 на ЦП.
Вариант №1:
uint16_t: Если подумать, FP16 на самом деле может быть сохранен на стороне хоста / процессора как unsigned short int (который также использует 16 бит), но нам нужно какой-то механизм для преобразования из float в этот 16-битный формат. На машинах X86 это может быть достигнуто с помощью встроенных функций преобразования Intel FP16, доступных как часть emmintrin.h - если вас это особенно интересует, вы можете прочитать больше здесь, но обратите внимание что для этого требуется icc (или компилятор Intel C ++ для использования библиотеки, которую nvcc может использовать в качестве основного компилятора - посмотрите здесь и здесь).
Вариант №2:
__half: это тип данных, который доступен как часть библиотеки NVIDIA FP16 “cuda_fp16.h”. На мой взгляд, это был самый простой способ заставить FP16 работать на стороне процессора, поскольку он легко предоставляет функции для преобразования в формат FP16 и из него в другие. Подробнее об этой опции я расскажу здесь, но для получения более подробной информации о самой библиотеке и предоставляемых ею функциях загляните здесь.
Например - взгляните на базовый пример кода ниже, он показывает, насколько легко использовать тип данных __half без проблем как для CPU, так и для GPU.
#include <iostream> #include <stdint.h> #include <cuda_fp16.h> using namespace std; #define gpuErrchk(ans) { gpuAssert((ans), __FILE__, __LINE__); } inline void gpuAssert(cudaError_t code, const char *file, int line, bool abort=true) { if (code != cudaSuccess) { fprintf(stderr,"GPUassert: %s %s %d\n", cudaGetErrorString(code), file, line); if (abort) exit(code); } } __global__ void half_plus1(__half *in_array) { const int idx = threadIdx.x + blockDim.x*blockIdx.x; in_array[idx] = __float2half(__half2float(in_array[idx]) + 1.0); } int main(void) { const int n = 64; __half *h_in, *d_in; h_in = (__half*) malloc(n*sizeof(__half)); gpuErrchk( cudaMalloc(&d_in, n*sizeof(__half)) ); for (int i=0; i<n; i++) h_in[i] = __float2half(1.5); gpuErrchk( cudaMemcpy(d_in, h_in, n*sizeof(__half), cudaMemcpyHostToDevice) ); dim3 block_dims(2,1,1); dim3 thread_dims(32,1,1); half_plus1<<<block_dims, thread_dims>>>(d_in); gpuErrchk( cudaPeekAtLastError() ); gpuErrchk( cudaDeviceSynchronize() ); gpuErrchk( cudaMemcpy(h_in, d_in, n*sizeof(__half), cudaMemcpyDeviceToHost) ); for (int i=0; i<n; i++) { if(__half2float(h_in[i]) != 2.5) { cout<< "Mismatch at " << i << " Expected = 2.5 " << "Actual = " << __half2float(h_in[i]) << endl; exit(1); } } cout << "TEST PASSES" << endl; cudaFree(d_in); free(h_in); return 0; }
Представление
Тип данных Half Float или FP16 дает нам два основных преимущества, а именно:
1. Экономия места
2. Ускорение арифметических операций (поскольку FP16 работает в 2 раза быстрее, чем FP32)
Но один из важных моментов, который следует отметить, заключается в том, что в архитектуре графического процессора NVIDIA (и, скорее всего, всех других графических процессорах) для достижения хорошей пропускной способности памяти критически важно использовать тип данных __half2, а не __half на стороне графического процессора. Это потому что :
- С __half и 32 потоками / warp - мы достигаем только ~ 64B / Load, тогда как для достижения хорошей пропускной способности нам нужно стремиться достичь как минимум 128B / Load (это выгодно как с точки зрения L2, так и с точки зрения шаблона доступа DRAM). В идеале мы хотели бы использовать еще большие нагрузки векторов, чего можно добиться, поместив несколько __half или __half2 в структуру, а затем преобразовав их как uint4 или float4s.
- Кроме того, чтобы получить вдвое большую математическую производительность, нам нужно упаковать 2 значения __half в регистры вместе, чтобы использовать 2X арифметические инструкции FP16 (подробнее здесь здесь и здесь). Упаковка необходима, поскольку это формат, в котором FPU ожидает присутствия данных.
Итак, по причинам, указанным выше, код меняется на:
__global__ void half2_plus1(__half2 *in_array) { const int idx = threadIdx.x + blockDim.x*blockIdx.x; in_array[idx] = __hadd2(in_array[idx], __float2half2_rn(1.0)); } int main(void) { const int n = 64; __half2 *h_in, *d_in; h_in = (__half2*) malloc(n*sizeof(__half2)); gpuErrchk( cudaMalloc(&d_in, n*sizeof(__half2)) ); for (int i=0; i<n; i++) h_in[i] = __float2half2_rn(1.5); gpuErrchk( cudaMemcpy(d_in, h_in, n*sizeof(__half2), cudaMemcpyHostToDevice) ); dim3 block_dims(2,1,1); dim3 thread_dims(32,1,1); half2_plus1<<<block_dims, thread_dims>>>(d_in); gpuErrchk( cudaPeekAtLastError() ); gpuErrchk( cudaDeviceSynchronize() ); gpuErrchk( cudaMemcpy(h_in, d_in, n*sizeof(__half2), cudaMemcpyDeviceToHost) ); for (int i=0; i<n; i++) { if((__high2float(h_in[i]) != 2.5) || (__low2float(h_in[i]) !=2.5)) { cout<< "Mismatch at " << i << " Expected = 2.5 " << "Actual = " << __half2float(h_in[i].x) << " " << __half2float(h_in[i].y) << endl; exit(1); } } cout << "TEST PASSES" << endl; cudaFree(d_in); free(h_in); return 0; }
Также обратите внимание, что некоторые функции, такие как __hadd2, поддерживаются только начиная с определенных версий архитектуры GPU, поэтому не забудьте добавить флаг «-arch» во время компиляции nvcc.