我有一个用C语言编写的代码,其执行方式与人类相同,因此例如,如果我有两个数组A[0..n-1]
和B[0..n-1]
,则该方法将执行C[0]=A[0]+B[0]
,C[1]=A[1]+B[1]
...
即使解决方案使用内在函数,我也需要帮助以加快此功能的运行时间。
我的主要问题是我有一个非常大的依赖性问题,因为迭代i+1
取决于迭代i
的进位,只要我使用10为底,那么如果{{1 }}和A[0]=6
,B[0]=5
必须为C[0]
,并且我要携带1
才能进行下一次添加。
我能做的更快的代码是这个:
1
但是我也尝试了这些方法,结果却变得更慢:
void LongNumAddition1(unsigned char *Vin1, unsigned char *Vin2,
unsigned char *Vout, unsigned N) {
for (int i = 0; i < N; i++) {
Vout[i] = Vin1[i] + Vin2[i];
}
unsigned char carry = 0;
for (int i = 0; i < N; i++) {
Vout[i] += carry;
carry = Vout[i] / 10;
Vout[i] = Vout[i] % 10;
}
}
我一直在Google进行研究,发现一些伪代码与我已经实现的伪代码相似,而且在GeeksforGeeks内部,还有一个针对此问题的实现,但它也较慢。
你能帮我吗?
答案 0 :(得分:6)
如果您不想更改数据格式,可以尝试SIMD。
RpcRetryingCaller
这是每位数〜2条指令。您需要添加代码来处理尾端。
这是算法的一个贯穿部分。
首先,我们将数字与最后一次迭代的进位相加:
typedef uint8_t u8x16 __attribute__((vector_size(16)));
void add_digits(uint8_t *const lhs, uint8_t *const rhs, uint8_t *out, size_t n) {
uint8_t carry = 0;
for (size_t i = 0; i + 15 < n; i += 16) {
u8x16 digits = *(u8x16 *)&lhs[i] + *(u8x16 *)&rhs[i] + (u8x16){carry};
// Get carries and almost-carries
u8x16 carries = digits >= 10; // true is -1
u8x16 full = digits == 9;
// Shift carries
carry = carries[15] & 1;
__uint128_t carries_i = ((__uint128_t)carries) << 8;
carry |= __builtin_add_overflow((__uint128_t)full, carries_i, &carries_i);
// Add to carry chains and wrap
digits += (((u8x16)carries_i) ^ full) & 1;
// faster: digits = (u8x16)_mm_min_epu8((__m128i)digits, (__m128i)(digits - 10));
digits -= (digits >= 10) & 10;
*(u8x16 *)&out[i] = digits;
}
}
我们计算哪些数字将产生进位(≥10),哪些将传播(= 9)。无论出于何种原因,SIMD的值为-1。
lhs 7 3 5 9 9 2
rhs 2 4 4 9 9 7
carry 1
+ -------------------------
digits 9 7 9 18 18 10
我们将carries 0 0 0 -1 -1 -1
full -1 0 -1 0 0 0
转换为整数并将其移位,还将carries
转换为整数。
full
现在,我们可以将它们加在一起以传播进位。请注意,只有最低位是正确的。
_ _ _ _ _ _
carries_i 000000001111111111110000
full 111100001111000000000000
要注意两个指标:
_ _ _ _ _ _
carries_i 111100011110111111110000
(relevant) ___1___1___0___1___1___0
的最低位设置为carries_i
。这个广场上有一个提包。
digit ≠ 9
的最低位设置为 un ,并且为carries_i
。在这个方块上有一个进位,重置了位。
我们用digit = 9
计算得出,然后加到(((u8x16)carries_i) ^ full) & 1
。
digits
然后我们删除所有已经携带的10s。
(c^f) & 1 0 1 1 1 1 0
digits 9 7 9 18 18 10
+ -------------------------
digits 9 8 10 19 19 10
我们还跟踪可能在两个地方发生的执行情况。
答案 1 :(得分:4)
提高速度的候选人:
优化
请确保已启用编译器的速度优化设置。
restrict
编译器不知道更改Vout[]
不会影响Vin1[], Vin2[]
,因此在某些优化中受到限制。
使用restrict
表示Vin1[], Vin2[]
不受写入Vout[]
的影响。
// void LongNumAddition1(unsigned char *Vin1, unsigned char *Vin2, unsigned char *Vout, unsigned N)
void LongNumAddition1(unsigned char * restrict Vin1, unsigned char * restrict Vin2,
unsigned char * restrict Vout, unsigned N)
注意:这限制了调用者使用Vout
重叠的Vin1, Vin2
来调用函数。
const
还可以使用const
来帮助优化。 const
还允许const
数组作为Vin1, Vin2
传递。
// void LongNumAddition1(unsigned char * restrict Vin1, unsigned char * restrict Vin2,
unsigned char * restrict Vout, unsigned N)
void LongNumAddition1(const unsigned char * restrict Vin1,
const unsigned char * restrict Vin2,
unsigned char * restrict Vout,
unsigned N)
unsigned
unsigned/int
是用于整数数学的“ goto”类型。而不是unsigned char CARRY
中的char CARRY
或unsigned
来使用uint_fast8_t
或<inttypes.h>
。
%
替代
sum = a+b+carry; if (sum >= 10) { sum -= 10; carry = 1; } else carry = 0;
@pmg等。
注意:我希望LongNumAddition1()
返回最终的进位额。
答案 2 :(得分:2)
要提高bignum加法的速度,应将更多的十进制数字打包到数组元素中。例如:您可以使用uint32_t
代替unsigned char
并一次存储9位数字。
另一个提高性能的技巧是您要避免分支。
这是未经测试的代码修改版本:
void LongNumAddition1(const char *Vin1, const char *Vin2, char *Vout, unsigned N) {
char carry = 0;
for (int i = 0; i < N; i++) {
char r = Vin1[i] + Vin2[i] + CARRY;
carry = (r >= 10);
Vout[i] = r - carry * 10;
}
}
以下是一次处理9位数字的修改版本:
#include <stdint.h>
void LongNumAddition1(const uint32_t *Vin1, const uint32_t *Vin2, uint32_t *Vout, unsigned N) {
uint32_t carry = 0;
for (int i = 0; i < N; i++) {
uint32_t r = Vin1[i] + Vin2[i] + CARRY;
carry = (r >= 1000000000);
Vout[i] = r - carry * 1000000000;
}
}
您可以在GodBolt's Compiler Explorer上查看gcc和clang生成的代码。
这是一个小型测试程序:
#include <inttypes.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
int LongNumConvert(const char *s, uint32_t *Vout, unsigned N) {
unsigned i, len = strlen(s);
uint32_t num = 0;
if (len > N * 9)
return -1;
while (N * 9 > len + 8)
Vout[--N] = 0;
for (i = 0; i < len; i++) {
num = num * 10 + (s[i] - '0');
if ((len - i) % 9 == 1) {
Vout[--N] = num;
num = 0;
}
}
return 0;
}
int LongNumPrint(FILE *fp, const uint32_t *Vout, unsigned N, const char *suff) {
int len;
while (N > 1 && Vout[N - 1] == 0)
N--;
len = fprintf(fp, "%"PRIu32"", Vout[--N]);
while (N > 0)
len += fprintf(fp, "%09"PRIu32"", Vout[--N]);
if (suff)
len += fprintf(fp, "%s", suff);
return len;
}
void LongNumAddition(const uint32_t *Vin1, const uint32_t *Vin2,
uint32_t *Vout, unsigned N) {
uint32_t carry = 0;
for (unsigned i = 0; i < N; i++) {
uint32_t r = Vin1[i] + Vin2[i] + carry;
carry = (r >= 1000000000);
Vout[i] = r - carry * 1000000000;
}
}
int main(int argc, char *argv[]) {
const char *sa = argc > 1 ? argv[1] : "123456890123456890123456890";
const char *sb = argc > 2 ? argv[2] : "2035864230956204598237409822324";
#define NUMSIZE 111 // handle up to 999 digits
uint32_t a[NUMSIZE], b[NUMSIZE], c[NUMSIZE];
LongNumConvert(sa, a, NUMSIZE);
LongNumConvert(sb, b, NUMSIZE);
LongNumAddition(a, b, c, NUMSIZE);
LongNumPrint(stdout, a, NUMSIZE, " + ");
LongNumPrint(stdout, b, NUMSIZE, " = ");
LongNumPrint(stdout, c, NUMSIZE, "\n");
return 0;
}
答案 3 :(得分:2)
在不考虑特定系统的情况下讨论手动优化总是毫无意义的。如果我们假设您拥有某种主流的32位数据缓存,指令缓存和分支预测功能,那么:
避免多个循环。您应该能够将它们合并为一个,从而大大提高性能。这样,您不必多次触摸相同的内存区域,您将减少分支的总数。程序必须检查每个i < N
,因此减少检查量应可提供更好的性能。而且,这可以提高数据缓存的可能性。
以最大的对齐字长执行所有操作。如果您有32位苦味,则应该能够一次使该算法处理4个字节,而不是逐字节处理。这意味着以某种方式换出memcpy
的逐字节分配,一次执行4个字节。库质量代码就是这样做的。
正确定义参数。您真的应该熟悉 const正确性一词。 Vin1
和Vin2
并未更改,因此应将其更改为const
,不仅是为了提高性能,还为了程序安全性和可读性/可维护性。
类似地,如果可以保证参数没有指向重叠的内存区域,则可以restrict
限定所有指针。
除法在许多CPU上都是一项昂贵的操作,因此,如果可以更改算法以摆脱/
和%
,则可以这样做。如果该算法是逐字节完成的,则可以牺牲256个字节的内存来保存查找表。
(这假设您可以在ROM中分配这样的查找表,而不会引入等待状态依赖性等)。
将进位更改为32位类型可能在某些系统上提供更好的代码,而在另一些系统上效果更差。当我在x86_64上进行尝试时,它通过一条指令给出了稍差一点的代码(相差很小)。
答案 4 :(得分:2)
第一个循环
for (int i = 0; i < N; i++) {
Vout[i] = Vin1[i] + Vin2[i];
}
由编译器自动向量化。但是下一个循环
for (int i = 0; i < N; i++) {
Vout[i] += carry;
carry = Vout[i] / 10;
Vout[i] = Vout[i] % 10;
}
包含一个loop-carried dependence,它实际上对整个循环进行序列化(考虑将1加到99999999999999999-它只能按顺序计算,一次只能计算1位)。循环依赖是现代计算机科学中最大的头痛之一。
这就是第一个版本速度更快的原因-它是部分矢量化的。其他版本则不是这种情况。
如何避免循环依赖?
作为base-2设备的计算机在使用base-10算术时非常糟糕。不仅浪费空间,而且在每个数字之间都造成人为的进位依赖。
如果您可以将数据从base-10表示形式转换为base-2表示形式,则由于计算机可以在一次迭代中轻松地对多个位进行二进制加法运算,因此使计算机添加两个数组变得更加容易。例如,对于64位计算机,性能较好的表示形式可能是uint64_t
。请注意,对于SSE,带有进位的流式加法仍然存在问题,但是那里也存在一些选项。
不幸的是,对于C编译器来说,很难通过进位传播生成有效的循环。因此,例如,libgmp
不是使用C而是使用ADC(带进位加法)指令以汇编语言实现bignum加法。顺便说一下,libgmp
可以直接替代项目中许多bignum算术函数。