5.1 优化编译器的能力和局限性
- 编译器的优化的妨碍因素:内存别名使用、函数调用等,只能人为优化
【例1】内存别名使用。由于编译器不知道xp和yp是否相等,所以无法进行进一步优化
void twiddle1(long *xp, long *yp) {
*xp += *yp;
*xp += *yp;
}
void twiddle2(long *xp, long *yp) {
*xp += 2* *yp;
}
【说明】以上两个函数看似一样,twiddle2()要比twiddle1()高效,但实际不同。考虑xp == yp的情况:
- 当执行
twiddle1(),结果是将xp的值增加到4倍 - 当执行
twiddle2(),结果是将xp的值增加到3倍
【例2】内存别名使用。由于编译器不知道p和q是否相等,所以无法进行进一步优化
x = 1000; y = 3000;
*q = y; *p = x;
t1 = *q;
【说明】以上代码看似t1 = 3000。考虑指针p和q指向同一位置,则结果t1 = 1000。编译器无法优化
【例3】函数调用
long counter = 0;
long f() {
return counter++;
}
long func1() {
return f() + f() + f() + f();
}
long func2() {
return 4 *f ();
}
【说明】以上代码看似func1()等价于func2(), 实际上func1()返回6;func2()返回0。
- 为了优化函数调用,可将
f()转为内联函数,则编译期间将f()替换为函数内容
inline f() { return counter++; }
//相当于将f()的内容直接替换到func1()中。这样编译器可以进一步优化
long func1in() {
long t = counter++;
t += counter++;
t += counter++;
t += counter++;
return t;
}
5.2 衡量程序性能
- 引入CPE(Cycles Per Element, 每元素的周期数)表示程序性能
- 个人理解:即每次循环,指令运行消耗的时间。可以指导我们改进代码
【例】计算数组a[]的前置和p[]
void psum1(float a[], float p[], long n) {
long i;
p[0] = a[0];
for (i = 1; i < n; i++)
p[i] = p[i-1] + a[i];
}
void psum2(float a[], float p[], long n) {
long i;
p[0] = a[0];
for (i = 1; i < n-1; i+=2) {
float mid_val = p[i-1] + a[i];
p[i] = mid_val;
p[i+1] = mid_val + a[i+1];
}
if (i < n)
p[i] = p[i-1] + a[i];
}
【说明】psum2()使用循环展开,一次计算两个元素,效率有所提升 (具体在5.8节阐述原理)
- 这里通过最小二乘拟合,计算函数执行的元素和周期关系。
CPE=斜率值
5.3~5.6 程序优化示例
【例】书中例子,向量元素累加/累乘,核心代码如下。Op代表加法或乘法,IDENT代表初值0或1
首先问一个问题:这段代码可以优化吗?
void combine1(vec_ptr v, data_t *dest) {
long i;
*dest = IDENT;
for (i = 0; i < vec_length(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest Op val;
}
}
【优化1】消除不必要的函数调用
- 每次循环都使用
vec_length()计算向量v的大小,是不必要的,可优化
void combine2(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest Op val;
}
}
【优化2】减少函数调用
- 每次都要调用
get_vec_element()获取向量v的元素值。可优化为直接访问数组方式
void combine3(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
*dest = *dest Op data[i];
}
}
【优化3】消除不必要的内存引用
*dest需要读写内存,浪费性能。使用临时变量acc可避免这一点
void combine4(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < length; i++) {
acc = acc Op data[i];
}
*dest = acc;
}
【说明】经过优化后,性能有了明显的提高。CPE值如下:
| 函数 | 方法 | 整数加法 | 整数乘法 | 浮点数加法 | 浮点数乘法 |
|---|---|---|---|---|---|
| combine1 | 未优化 | 22.68 | 20.02 | 19.98 | 20.18 |
| combine2 | 移动vec_length | 7.02 | 9.03 | 9.02 | 11.03 |
| combine3 | 直接数据访问 | 7.17 | 9.02 | 9.02 | 11.03 |
| combine4 | 累积在临时变量中 | 1.27 | 3.01 | 3.01 | 5.01 |
【总结】可以通过降低函数调用的开销和消除不必要的内存引用,大幅提升函数效率
5.7 理解现代处理器
- 为进一步提高性能,需尽可能利用处理器的并行特性。现代处理器可以同时对多条指令求值,称为指令级并行
- 程序性能的界限:延迟界限(latency bound)、吞吐量界限(throughput bound)
- 延迟界限:程序优化后,一系列操作必须按照严格顺序执行 (指令串行运行的界限)
- 吞吐量界限:处理器单元原始的计算能力,即程序性能的理论界限
5.7.1 现代处理器操作
上图是现代处理器的简化示意图。包含指令控制单元(ICU)和执行单元(EU):
-
ICU:负责从 指令高速缓存(instruction cache) 中读取指令,并根据指令序列生成对程序数据的基本操作。通常指令事先就读取到缓存中了。
- 取指控制:从指令高速缓存中读取数据
- 指令译码:对指令分解,加载到执行单元中
- 退役单元:记录正在进行的处理,控制寄存器的更新。分支预测正确,则程序寄存器的更新实际执行;分支预测错误,则丢弃所有计算的结果
-
EU:执行这些指令(乱序执行,有很复杂的硬件支撑)。EU含多个功能单元,可流水线执行
- 流水线操作:指令在执行期间,会被分解为多个功能单元,这些单元能够并行地执行多条指令的不同部分
- 分支预测单元:当遇到分支指令时,出现两种可能:选择分支、不选择分支。此时处理器采用分支预测(branch prediction)技术,在确定分支预测正确前就执行这些操作(称为投机执行,speculative execution)。如果预测错误,则将状态重置回分支状态继续执行(预测错误会导致很大的性能开销)
- 读写内存单元:由加载和存储功能单元实现(图5-11右下角部分)
- 加载单元:内存读数据到处理器。内含加法器完成地址计算
- 存储单元:处理器写数据到内存。内含加法器完成地址计算
- 算数运算单元:专门用来执行整数和浮点数操作。有些单元能进行整数运算+浮点运算,有些不行,与处理器的设计有关系
- 转发机制:某个执行单元完成一个操作,可以将值直接传给另一个操作,不需要写入寄存器文件,节省开销(4.5.5节)
-
书中使用Intel Core i7 Haswell处理器实验,含8个功能单元:
5.7.2 功能单元的性能
根据Intel手册,不同的运算的耗时是不一样的。乘法耗时比加法耗时长,除法按照被除数和除数的不同,耗时也不同
- 延迟(latency):完成运算的耗时
- 发射(issue time):连续同类型运算的最小间隔
- 容量(capacity):CPU中该功能单元的数量
【说明】书中采用Intel Core i7 Haswell处理器,计算得到操作的延时。如做整数加法,完成运算需1个时钟周期,两次加法至少要间隔1时钟周期,CPU有4个单元可进行整数加法运算。另外,除法的延时=发射,也就是说除法操作要等上一次除法全部完成才能执行下一次除法。
- 因此,可以得到延迟界限和吞吐量界限的CPE值
- 整数加法:CPU有4个功能单元做加法器(5.7.1节)。加法指令只需执行1个时钟周期,因此连续执行加法时(延时界限)的
CPE=1。虽然4个功能单元都可以执行加法,但是每个时钟周期只有2个加载单元能从内存读取数据值,所以吞吐量界限CPE=0.5。 - 整数乘法:CPU只有1个整数乘法器,而整数乘法指令需要执行3个时钟周期。因此,连续执行乘法指令时(延时界限)的
CPE=3。而发射时间间隔是1个时钟周期,所以处理器每个时钟周期最多执行1个乘法运算,即吞吐量界限CPE=1。
- 整数加法:CPU有4个功能单元做加法器(5.7.1节)。加法指令只需执行1个时钟周期,因此连续执行加法时(延时界限)的
| 界限 | 整数加法 | 整数乘法 | 浮点数加法 | 浮点数乘法 |
|---|---|---|---|---|
| combine4 | 1.27 | 3.01 | 3.01 | 5.01 |
| 延迟界限 | 1.00 | 3.00 | 3.00 | 5.00 |
| 吞吐量界限 | 0.50 | 1.00 | 1.00 | 0.50 |
- 我们的最终目的是
增加指令级并行性,突破延迟界限,接近吞吐量界限
5.7.3 处理器操作的抽象模型(数据流图)
- 数据流图:将处理器对寄存器、功能单元的操作整理成数据流图,可以更直观地看出性能瓶颈
- 描述寄存器、功能单元的关系。上面的寄存器表示输入,下面的寄存器表示输出,箭头表示数据流向。
- 其中,被访问的寄存器可以分为4类:
- 只读寄存器:可以作为数据,也可以用来计算内存地址 。这些寄存器在循环中不会被更改(
%rax) - 只写寄存器:作为数据传送操作的目的寄存器(本例不包含)
- 局部寄存器:在单个循环内部被修改和使用,迭代与迭代间不相关(条件码寄存器)
- 循环寄存器:即作为源值,又作为目的,一次迭代产生的值会在另一个迭代中使用到(
%rdx和%xmm0)。
- 只读寄存器:可以作为数据,也可以用来计算内存地址 。这些寄存器在循环中不会被更改(
【例】书中例子,分析combine4()的数据流图
- 由于
combine4()耗时主要集中在循环部分,首先分析combine4()浮点数乘法循环部分的汇编代码:
//data+i in %rdx, acc in %xmm0, data+length in %rax
.L25:
vmulsd (%rdx), %xmm0, %xmm0 //acc = acc * data[i]
addq $8, %rdx //data++
cmpq %rax, %rdx //if i != length, loop
jne .L25
- 数据流图:画出数据流图,经历了5个处理单元。
combine4()中每次迭代要计算%xmm0,然后才能计算下一个%xmm0。循环寄存器有依赖,所以成为性能瓶颈。
- 我们调整功能单元的位置,并只保留循环寄存器及关键操作,得到图5-14(b)。最后将n次迭代拼接在一起,得到图5-15。
- 图5-15可以看出,存在两条关键路径:变量
acc乘法路径、变量i加法路径。由于浮点乘法需要5个时钟周期,整数加法需要1个时钟周期。所以乘法链是影响性能的关键。如果能够并行处理mul运算,就能突破延迟界限。
5.8 循环展开
- 循环展开:增加每次迭代计算元素的数量,减少循环的迭代次数
- 为什么循环展开可以改善性能?
- 减少程序结果无关操作的数量,比如对循环索引的计算(
i++)和条件分支(i < length) - 可以进一步优化循环逻辑,从而减少整个计算中关键路径的操作数量
- 减少程序结果无关操作的数量,比如对循环索引的计算(
【例】2x1循环展开。第一个循环每次处理两个元素,第二个循环计算剩余元素。当然,扩展到kx1循环展开也是类似的,第一个循环处理k个元素,第二个循环计算剩余元素
/* 2x1 loop unrolling */
void combine5(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = (acc Op data[i]) Op data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc Op data[i];
}
*dest = acc;
}
- 计算得到下面的优化结果:
| 函数 | 方法 | 整数加法 | 整数乘法 | 浮点数加法 | 浮点数乘法 |
|---|---|---|---|---|---|
| combine4 | 无展开 | 1.27 | 3.01 | 3.01 | 5.01 |
| combine5 | 2x1展开 | 1.01 | 3.01 | 3.01 | 5.01 |
| 3x1展开 | 1.01 | 3.01 | 3.01 | 5.01 | |
| 延迟界限 | 1.00 | 3.00 | 3.00 | 5.00 | |
| 吞吐量界限 | 0.50 | 1.00 | 1.00 | 0.50 |
【说明】发现combine5()更进一步接近了延迟界限。但是为什么不能突破呢?
- 我们根据汇编代码画出数据流图(图5-20),发现mul运算并没有并行执行,所以没有突破延迟界限
- 但是由于add运算次数减少,所以对性能有改善
//i in %rdx, acc in %xmm0, data in %rax, limit in %rbp
.L35:
vmulsd (%rax,%rdx,8), %xmm0, %xmm0 //acc = acc * data[i]
vmulsd 8(%rax,%rdx,8), %xmm0, %xmm0 //acc = acc * data[i+1]
addq $2, %rdx //i += 2
cmpq %rdx, %rbp //if limit > i, loop
jg .L35
5.9 提高并行性
5.9.1 多个累积变量
【优化1】2x2循环展开,奇数项和偶数项乘积分开运算
void combine6(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc0 = IDENT;
data_t acc1 = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc0 = acc0 OP data[i];
acc1 = acc1 Op data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc0 = acc0 Op data[i];
}
*dest = acc0 Op acc1;
}
- CPE如下:
| 函数 | 方法 | 整数加法 | 整数乘法 | 浮点数加法 | 浮点数乘法 |
|---|---|---|---|---|---|
| combine4 | 无展开 | 1.27 | 3.01 | 3.01 | 5.01 |
| combine5 | 2x1展开 | 1.01 | 3.01 | 3.01 | 5.01 |
| combine6 | 2x2展开 | 0.81 | 1.51 | 1.51 | 2.51 |
| 延迟界限 | 1.00 | 3.00 | 3.00 | 5.00 | |
| 吞吐量界限 | 0.50 | 1.00 | 1.00 | 0.50 |
- 数据流图:看到两个关键路径并行计算乘法。两条路径的mul操作没有相关,理论上效率提升2倍
【说明】将奇偶分开运算,速度进一步改进,并打破了延迟界限。那么,如果分的越细,是否运算更快呢?
- 考虑kxk循环展开。如果展开次数k越大,则CPE会越接近吞吐量界限
5.9.2 重新结合变换
【优化2】2x1a循环展开,利用加法/乘法结合律,重新结合运算
void combine7(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = acc OP (data[i] Op data[i+1]);
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc Op data[i];
}
*dest = acc;
}
- CPE如下:
| 函数 | 方法 | 整数加法 | 整数乘法 | 浮点数加法 | 浮点数乘法 |
|---|---|---|---|---|---|
| combine4 | 无展开 | 1.27 | 3.01 | 3.01 | 5.01 |
| combine5 | 2x1展开 | 1.01 | 3.01 | 3.01 | 5.01 |
| combine6 | 2x2展开 | 0.81 | 1.51 | 1.51 | 2.51 |
| combine7 | 2x1a展开 | 1.01 | 1.51 | 1.51 | 2.51 |
| 延迟界限 | 1.00 | 3.00 | 3.00 | 5.00 | |
| 吞吐量界限 | 0.50 | 1.00 | 1.00 | 0.50 |
- 数据流图:mul同样也是并行执行的
【说明】我们看到,仅仅改变了加法/乘法结合的顺序,就打破了延迟界限。同样也可以扩展成kx1a循环展开,k越大,越接近吞吐量界限:
5.9.3 SIMD
- 标量指令:单指令处理一个数据。之前的例子都是标量指令
- 向量指令:单指令处理多个数据,也称SIMD(Single-Instruction, Multiple-Data,单指令多数据),比如GPU都是用SIMD并行处理数据的
- 向量保存在特殊的向量寄存器中(AVX寄存器,
%ymm0~%ymm15)。如%ymm0寄存器是256位寄存器,可以并行执行8组32位数值或4组64位数值的加法或乘法,性能提高8或4倍。
| 方法 | long整数加法 | long整数乘法 | double浮点数加法 | double浮点数乘法 |
|---|---|---|---|---|
| 标量10x10 | 0.55 | 1.00 | 1.01 | 0.52 |
| 标量吞吐量界限 | 0.50 | 1.00 | 1.00 | 0.50 |
| 向量8x8 | 0.13 | 1.51 | 0.25 | 0.16 |
| 向量吞吐量界限 | 0.12 | 性能不佳 | 0.25 | 0.12 |
【注】AVX指令集不包含64位整数的乘法指令,因此GCC无法生成向量代码,性能不佳
【注】Intel官方提供了一套intrinsics函数支持SIMD操作,具体参考:www.intel.com/content/www…
5.11 一些限制因素
5.11.1 寄存器溢出
- 循环展开并不是越多越好,比如20x20的循环展开要比10x10循环展开性能弱。这是因为20x20的循环展开,函数局部变量需要额外使用栈上的变量,带来内存访问的开销
// 20x20循环展开的汇编代码
vmovsd 40(%rsp), %xmm0
vmulsd (%rdx), %xmm0, %xmm0
vmovsd %xmm0, 40(%rsp)
5.11.2 分支预测和预测错误处罚
- 错误分支对性能的惩罚会很大。为了尽可能避免,有以下通用原则:
- 不要过分关心可预测的分支:现代处理器的分支预测逻辑非常强大,会尽可能优化
- 书写适合用条件传送实现的代码:比如
cmov指令
【例】书中例子
void minmax1(long a[], long b[], long n) {
long i;
for (i = 0; i < n; i++) {
if (a[i] > b[i]) {
long t = a[i];
a[i] = b[i];
b[i] = t;
}
}
}
//优化后,会编译为cmov
void minmax2(long a[], long b[], long n) {
long i;
for (i = 0; i < n; i++) {
long min = a[i] < b[i] ? a[i] : b[i];
long max = a[i] < b[i] ? b[i] : a[i];
a[i] = min; b[i] = max;
}
}
5.12 理解内存性能
- 加载性能(读):测试得CPE=4.0,与L1级cache的4周期访问时间一致(见6.4节讨论)
- 存储性能(写):单个存储功能单元的写入,测试得CPE=1.00
- 如果写/读相关,那么性能会进一步降低。这是由于加载操作必须检查存储缓冲区的条目,是否有地址匹配
【例】书中例子
void write_read(long *src, long *dst, long n) {
long cnt = n;
long val = 0;
while (cnt) {
*dst = val;
val = (*src)+1;
cnt--;
}
}
【说明】
- 使用
write_read(&a[0], &a[1], 3),CPE=1.3 - 使用
write_read(&a[0], &a[0], 3),CPE=7.3,下降6个时钟周期
5.13 性能优化总结
- 高级设计:选择适当的算法和数据结构
- 基本编码原则:消除连续的函数调用、消除不必要的内存引用
- 低级优化:指令集并行
- 使用循环展开
- 使用运算重新结合等技术(例如计算多个累积变量)
- 使用SIMD并行计算
- 编写出编译器能够有效优化的代码(如:条件传送
cmov代替条件操作)
【注】性能优化的前提是不引入错误。另外,过分优化会导致代码膨胀和代码可读性降低,不利于维护
实验
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define OP_ADD
#ifdef OP_ADD //加法运算
#define IDENT 0
#define Op +
#else //乘法运算
#define IDENT 1
#define Op *
#endif
typedef long data_t;
typedef struct {
long len;
data_t *data;
} vec_rec, *vec_ptr;
typedef void(*func)(vec_ptr v, data_t *dest);
vec_ptr new_vec(long len) {
vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
data_t *data = NULL;
if (!result) return NULL;
result->len = len;
if (len > 0) {
data = (data_t *)calloc(len, sizeof(data_t));
if (!data) {
free((void *) result);
return NULL;
}
}
result->data = data;
return result;
}
void get_vec_element(vec_ptr v, long index, data_t *dest) {
*dest = v->data[index];
}
long vec_length(vec_ptr v) {
return v->len;
}
data_t *get_vec_start(vec_ptr v) {
return v->data;
}
void combine1(vec_ptr v, data_t *dest) {
long i;
*dest = IDENT;
for (i = 0; i < vec_length(v); i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest Op val;
}
}
void combine2(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
data_t val;
get_vec_element(v, i, &val);
*dest = *dest Op val;
}
}
void combine3(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
*dest = IDENT;
for (i = 0; i < length; i++) {
*dest = *dest Op data[i];
}
}
void combine4(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
data_t *data = get_vec_start(v);
data_t acc = IDENT;
for (i = 0; i < length; i++) {
acc = acc Op data[i];
}
*dest = acc;
}
void combine5(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = (acc Op data[i]) Op data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc Op data[i];
}
*dest = acc;
}
void combine6(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc0 = IDENT;
data_t acc1 = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc0 = acc0 Op data[i];
acc1 = acc1 Op data[i+1];
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc0 = acc0 Op data[i];
}
*dest = acc0 Op acc1;
}
void combine7(vec_ptr v, data_t *dest) {
long i;
long length = vec_length(v);
long limit = length-1;
data_t *data = get_vec_start(v);
data_t acc = IDENT;
/* Combine 2 elements at a time */
for (i = 0; i < limit; i+=2) {
acc = acc Op (data[i] Op data[i+1]);
}
/* Finish any remaining elements */
for (; i < length; i++) {
acc = acc Op data[i];
}
*dest = acc;
}
void calc_time(vec_ptr v, data_t *dest, char *name, func f) {
clock_t start, finish;
start = clock();
f(v, dest);
finish = clock();
printf("%s time: %lf\n", name, (double)(finish-start) / CLOCKS_PER_SEC);
}
int main() {
vec_ptr vec = new_vec(100000000);
data_t result;
calc_time(vec, &result, "combine1", combine1);
calc_time(vec, &result, "combine2", combine2);
calc_time(vec, &result, "combine3", combine3);
calc_time(vec, &result, "combine4", combine4);
calc_time(vec, &result, "combine5", combine5);
calc_time(vec, &result, "combine6", combine6);
calc_time(vec, &result, "combine7", combine7);
return 0;
}
【结果】在本人PC上实验(Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz),结果如下:
# long加法
combine1 time: 0.436511
combine2 time: 0.196069
combine3 time: 0.171531
combine4 time: 0.066199
combine5 time: 0.033421
combine6 time: 0.029695
combine7 time: 0.031934
# long乘法
combine1 time: 0.455121
combine2 time: 0.205159
combine3 time: 0.250897
combine4 time: 0.094907
combine5 time: 0.097381
combine6 time: 0.049683
combine7 time: 0.051188
# double加法
combine1 time: 0.438897
combine2 time: 0.287528
combine3 time: 0.288729
combine4 time: 0.128072
combine5 time: 0.128553
combine6 time: 0.063788
combine7 time: 0.064442
# double乘法
combine1 time: 0.444133
combine2 time: 0.291083
combine3 time: 0.285829
combine4 time: 0.128456
combine5 time: 0.127877
combine6 time: 0.063897
combine7 time: 0.063655