如何从AVX内在函数中获得性能提升以计算基本统计数据?

时间:2017-06-26 21:37:37

标签: c clang sse simd avx

我的问题是关于使用AVX指令的性能与天真的方法。

我得到了同样的 - 并且正确 - 从我的AVX方法得到答案,因为我从我天真的方法得到,但AVX指令的答案需要稍长一些,所以我想知道我做错了什么/使用矢量化代码效率低下。

这个问题有点过于复杂,无法提供独立的可编辑代码单元,对此我很抱歉。但是,我有下面的功能代码片段,我希望它是相当直接和体面的样式,希望很容易跟上来处理手头的问题。

一些环境细节:

  • 这两种方法都是用Clang编译的(Apple LLVM版本8.1.0(clang-802.0.42))。
  • 我正在使用-mavx标志进行编译。
  • 我的硬件(配备Intel Core i7处理器的MacBook Pro)声称支持AVX指令。

我有一个程序,用户提供一个多行文本文件,每行包含一个以逗号分隔的数字字符串,即 n - 维向量列表,其中 n < / em>对于文件是任意的,但是(除了输入错误)与每行的 n 值相同。

例如:

0,4,6,1,2,22,0,2,30,...,39,14,0,3,3,3,1,3,0,3,2,1
0,0,1,1,0,0,0,8,0,1,...,6,0,0,4,0,0,0,0,7,0,8,2,0
...
1,0,1,0,1,0,0,2,0,1,...,2,0,0,0,0,0,2,1,1,0,2,0,0

我通过对这些向量的比较产生了一些统计分数,例如Pearson相关性,但得分函数可以是任何东西,比如简单的东西,比如算术平均值。

天真的方法

将这些向量中的每一个放入指向名为signal_t的结构的指针:

typedef struct signal {
    uint32_t n;
    score_t* data;
    score_t mean;
} signal_t;

score_t类型只是float的typedef:

typedef float score_t;

首先,我将字符串解析为floatscore_t)个值并计算算术平均值:

signal_t* s = NULL;
s = malloc(sizeof(signal_t));
if (!s) {
    fprintf(stderr, "Error: Could not allocate space for signal pointer!\n");
    exit(EXIT_FAILURE);
}
s->n = 1;
s->data = NULL;
s->mean = NAN;

for (uint32_t idx = 0; idx < strlen(vector_string); idx++) {
    if (vector_string[idx] == ',') {
        s->n++;
    }
}

s->data = malloc(sizeof(*s->data) * s->n);
if (!s->data) {
    fprintf(stderr, "Error: Could not allocate space for signal data pointer!\n");
    exit(EXIT_FAILURE);
}
char* start = vector_string;
char* end = vector_string;
char entry_buf[ENTRY_MAX_LEN];
uint32_t entry_idx = 0;
bool finished_parsing = false;
bool data_contains_nan = false;
do {
    end = strchr(start, ',');
    if (!end) {
        end = vector_string + strlen(vector_string);
        finished_parsing = true;
    }
    memcpy(entry_buf, start, end - start);
    entry_buf[end - start] = '\0';
    sscanf(entry_buf, "%f", &s->data[entry_idx++]);
    if (isnan(s->data[entry_idx - 1])) {
        data_contains_nan = true;
    }
    start = end + 1;
} while (!finished_parsing);

if (!data_contains_nan) {
    s->mean = pt_mean_signal(s->data, s->n);
}

算术平均值非常简单:

score_t pt_mean_signal(score_t* d, uint32_t len)
{
    score_t s = 0.0f;
    for (uint32_t idx = 0; idx < len; idx++) {
        s += d[idx];
    }
    return s / len;
}

天真的表现

在10k向量字符串的文件上运行这种方法时,运行时间为6.58秒。

AVX方法

我有一个名为signal_t的修改后的signal_avx_t结构:

typedef struct signal_avx {
    uint32_t n_raw;
    uint32_t n;
    __m256* data;
    score_t mean;
} signal_avx_t;

存储指向__m256地址的指针。每个__m256存储八个单精度float值。为方便起见,我定义了一个名为AVX_FLOAT_N的常量来存储这个倍数,例如:

#define AVX_FLOAT_N 8

以下是我如何解析vector-string并将其存储在__m256中。它与天真的方法非常相似,除了现在我一次将八个值读入缓冲区,将缓冲区写入__m256,然后重复直到没有更多值要写入。然后我计算平均值:

signal_avx_t* s = NULL;
s = malloc(sizeof(signal_avx_t));
if (!s) {
fprintf(stderr, "Error: Could not allocate space for signal_avx pointer!\n");
    exit(EXIT_FAILURE);
}
s->n_raw = 1;
s->n = 0;
s->data = NULL;
s->mean = NAN;

for (uint32_t idx = 0; idx < strlen(vector_string); idx++) {
    if (vector_string[idx] == ',') {
        s->n_raw++;
    }
}

score_t signal_buf[AVX_FLOAT_N];

s->n = (uint32_t) ceil((float)(s->n_raw) / AVX_FLOAT_N);
s->data = malloc(sizeof(*s->data) * s->n);
if (!s->data) {
    fprintf(stderr, "Error: Could not allocate space for signal_avx data pointer!\n");
    exit(EXIT_FAILURE);
}
char* start = id;
char* end = id;
char entry_buf[ENTRY_MAX_LEN];
uint32_t entry_idx = 0;
uint32_t data_idx = 0;
bool finished_parsing = false;
bool data_contains_nan = false;

do {
    end = strchr(start, ',');
    if (!end) {
        end = vector_string + strlen(vector_string);
        finished_parsing = true;
    }
    memcpy(entry_buf, start, end - start);
    entry_buf[end - start] = '\0';
    sscanf(entry_buf, "%f", &signal_buf[entry_idx++ % AVX_FLOAT_N]);
    if (isnan(signal_buf[(entry_idx - 1) % AVX_FLOAT_N])) {
        data_contains_nan = true;
    }
    start = end + 1;

    /* I write every eight floats to an __m256 chunk of memory */
    if (entry_idx % AVX_FLOAT_N == 0) {
        s->data[data_idx++] = _mm256_setr_ps(signal_buf[0],
                                             signal_buf[1],
                                             signal_buf[2],
                                             signal_buf[3],
                                             signal_buf[4],
                                             signal_buf[5],
                                             signal_buf[6],
                                             signal_buf[7]);
    }
} while (!finished_parsing);

if (!data_contains_nan) {
    /* write any leftover floats to the last `__m256` */
    if (entry_idx % AVX_FLOAT_N != 0) {
        for (uint32_t idx = entry_idx % AVX_FLOAT_N; idx < AVX_FLOAT_N; idx++) {
            signal_buf[idx] = 0;
        }
        s->data[data_idx++] = _mm256_setr_ps(signal_buf[0],
                                             signal_buf[1],
                                             signal_buf[2],
                                             signal_buf[3],
                                             signal_buf[4],
                                             signal_buf[5],
                                             signal_buf[6],
                                             signal_buf[7]);
    }
    s->mean = pt_mean_signal_avx(s->data, s->n, s->n_raw);
}

AVX意味着功能

这是我编写的用于生成算术平均值的函数:

score_t pt_mean_signal_avx(__m256* d, uint32_t len, uint32_t len_raw)
{
    score_t s = 0.0f;
    /* initialize a zero-value vector to collect summed value */
    __m256 v_sum = _mm256_setzero_ps();
    /* add data to collector */
    for (uint32_t idx = 0; idx < len; idx++) {
        v_sum = _mm256_add_ps(v_sum, d[idx]);
    }
    /* sum the collector values */
    score_t* res = (score_t*)&v_sum;
    for (uint32_t idx = 0; idx < AVX_FLOAT_N; idx++) {
        s += res[idx];
    }
    return s / len_raw;
}

AVX效果

在一个10k矢量字符串文件上运行基于AVX的方法时,运行时间为6.86秒,慢了约5%。无论输入的大小如何,这种差异大致是不变的。

摘要

我的期望是,通过使用AVX指令和矢量化循环,我会得到一个减速带,而不是性能会稍微差一点。

为了计算基本摘要统计信息,代码片段中是否有任何暗示误导__m256数据类型和相关内部函数的内容?

主要是,在进入较大数据集之间更复杂的评分函数之前,我想弄清楚我在这里做错了什么。感谢任何建设性的建议!

2 个答案:

答案 0 :(得分:2)

首先,我希望我们同意将文本解析为浮点数可能比算术平均值更加强大,甚至没有提到从物理存储上的文件中读取数据。如果你要做一个基准测试,你绝对应该省略阅读和解析。

这里似乎主要的问题是你在阅读时试图成为矢量化。您实际所做的是从signal_bufs的数据的不必要副本。

你必须意识到__mm256_ *不是真正的内存数据类型。它只是一个宏来确保你使用256位值的内存地址和寄存器。

所以,只需将您的signal_buf__mm256_load_ps加载到SIMD寄存器中,然后对其执行AVX魔术,或者直接依次填充s sscanf然后做同样的__mm256_load_ps魔术。

我真的不明白你为什么要使用setr。为什么需要反转算术平均值的元素顺序?或者这是你的“穷人的负荷指示”?

同样,你的浮点数学努力,特别是如果你编写的编译器甚至可以自动向量化的代码,并不是在这里花费时间。这是字符串的解析。

VOLK(向量优化内核库)有很多手写的SIMD内核,包括一个累积浮点数组的内核:

https://github.com/gnuradio/volk/blob/master/kernels/volk/volk_32f_accumulator_s32f.h

AVX代码如下所示:

static inline void
volk_32f_accumulator_s32f_a_avx(float* result, const float* inputBuffer, unsigned int num_points)
{
  float returnValue = 0;
  unsigned int number = 0;
  const unsigned int eighthPoints = num_points / 8;

  const float* aPtr = inputBuffer;
  __VOLK_ATTR_ALIGNED(32) float tempBuffer[8];

  __m256 accumulator = _mm256_setzero_ps();
  __m256 aVal = _mm256_setzero_ps();

  for(;number < eighthPoints; number++){
    aVal = _mm256_load_ps(aPtr);
    accumulator = _mm256_add_ps(accumulator, aVal);
    aPtr += 8;
  }

  _mm256_store_ps(tempBuffer, accumulator);

  returnValue = tempBuffer[0];
  returnValue += tempBuffer[1];
  returnValue += tempBuffer[2];
  returnValue += tempBuffer[3];
  returnValue += tempBuffer[4];
  returnValue += tempBuffer[5];
  returnValue += tempBuffer[6];
  returnValue += tempBuffer[7];

  number = eighthPoints * 8;
  for(;number < num_points; number++){
    returnValue += (*aPtr++);
  }
  *result = returnValue;
}

它的作用是拥有一个 8元素累加器,它连续地添加8个新元素的集合(单独),然后,最后,返回关于这8个累加器的总和。 / p>

答案 1 :(得分:0)

在矢量化部分之外存在很多低效率(@Marcus在他的回答中提到了这一点。)

不要为signal_t* s动态分配空间。它是一个非常小的固定大小的结构,你只需要其中一个,所以你应该只使用signal_t s(自动存储)并删除一个间接级别。

在进行任何转换之前,最好不要扫描,的整个字符串,因为如果字符串不适合L1缓存(32k)那么你就会失去数据重用。你转换它。

如果你不能只是动态地总结它(比如平均值),那么分配一个大缓冲区来放入转换后的数据。如果你到达字符串的末尾而没有填充缓冲区,那就好了。 realloc它的大小。 (您分配但从未触及的内存页面在大多数操作系统上基本上都是免费的。)如果在到达字符串末尾之前填满初始缓冲区大小,请使用realloc将其增长两倍。 (指数大小的增加会减少O(1)附加元素的平均成本,这就是C ++的std::vector以这种方式工作的原因。哈希表也是如此。)

如果您知道字符串的全长(例如,从文件大小),您可以使用它来估计您需要多大的缓冲区。 (例如假设每个浮点数长度为2个字节,包括',',因为它们至少会那么长,并且可以在合理范围内进行过度分配。)

如果你真的想在转换前计算逗号,你可以将它_mm_cmpeq_epi8向量化以在字符串数据向量中找到逗号,并_mm_add_epi8对这些向量进行求和0 / -1。使用_mm_sad_epu8至少每255个向量将8位元素的水平和加到64位元素中,以避免溢出。

如果您的数据就像您的简单示例,其中每个数字实际上都是1位整数,那么您可以比使用float将其转换为scanf更好。例如您可以使用整数SIMD将ASCII数字转换为0到9之间的整数。

// if we don't know the string length ahead of time,
// we could look for a '\0' on the fly with _mm256_cmpeq_epi8 / _mm256_movemask_epi
uint64_t digitstring_sum(const char*p, size_t len)
{
    const char *endp = p+len - 31;  // up to the last full-vector of string data

    __m256i sum = _mm256_setzero_si256();
    for ( ; p < endp ; p+=32 ) {
        __m256i stringdata = _mm256_loadu_si256((const __m256i*)p);
        __m256i integers = _mm256_sub_epi16(stringdata, _mm256_set1_epi16( '0'+(','<<8) ));  // turn "1,2,3,..." into 0x0100 0200 0300...

        // horizontal sum the 8-bit elements into 64-bit elements
        // There are various ways to optimize this by doing this part less frequently, but still often enough to avoid overflow.  Or doing it a different way.
        __m256i hsum = _mm256_sad_epu8(integers, _mm256_setzero_si256());  // sum(x[0] - 0, x[1] - 0, ...) = sum (x[...])

        sum = _mm256_add_epi64(sum, hsum);
    }
    // sum holds 4x 64-bit accumulators.  Horizontal sum that:
    // (this is probably more efficient than storing to memory and looping, but just barely for a vector of only 4 elements)
    __m128i hi = _mm256_extract_si128(sum, 1);
    __m128i lo = _mm256_castsi256_si128(sum);
    __m128i t1 = _mm_add_epi64(lo, hi);
    __m128i t2 = _mm_unpackhi_epi64(t1,t1);  // copy high 64 bit element to low
    __m128i t3 = _mm_add_epi64(t1, t2);
    uint64_t scalar_sum = _mm_cvtsi128_si32(t3);

    // Then a cleanup loop to handle the last partial-vector of string data.
    // or do it with an unaligned vector and some masking...

    return scalar_sum;
}

你的一些例子有多位数字,但仍然只有整数。你可以通过使用向量比较将它们解析成4个一组的整数向量来找到逗号的位置,并将该位图作为整数索引用于随机控制向量的查找表。

这变得非常复杂,但请参阅@stgatilov's answer about parsing a dotted-quad IPv4 address string into an 32-bit integer using this technique.由于pshufb_mm_shuffle_epi8)在两个单独的通道中运行,因此您最好只使用128位向量。

您希望在循环中执行此操作,并一次遍历字符串4整数。给定比较掩码结果作为整数,您可以通过剥离前4个设置位,然后使用位扫描指令/内部来找到第5个逗号的位置。剥离前4个设置位可以使用BMI1 _blsr_u32 4次完成。 (只用一条指令就可以dst = (a - 1) & a。)

或者既然你需要一个用于随机控制向量的LUT,你可以使用LUT条目中的一些无关位来保存字节数。