用三元操作符替代if-else以降低CPU分支预测惩罚实现Unity内函数13倍提速

测试对象

1,C# (Unity脚本)
2,C# DLL(mcs build的动态链接库再导入Unity)
3,C Native Code(LLVM编译后导入Unity)

被测试函数源码

两个随机数数组进行大小比较,一个数组保存大数,另一个保存小数。

C# 和 C#DLL:

	public void Minmax1_CSharp(double[] a,double[] b,int n){
		int i;
		for (i = 0; i < n; i++) {
			if (a [i] > b [i]) {
				double t = a [i];
				a [i] = b [i];
				b [i] = t;
			}
		}
	}

	public void Minmax2_CSharp(double[] a,double[] b,int n){
		int i;
		for (i = 0; i < n; i++) {
			double min = a [i] < b [i] ? a [i] : b [i];
			double max = a [i] < b [i] ? b [i] : a [i];
			a [i] = min;
			b [i] = max;
		}
	}

C:

extern void Minmax1(double a[],double b[],int n){
    int i;
    for (i = 0; i < n; i++) {
        if (a [i] > b [i]) {
            double t = a [i];
            a [i] = b [i];
            b [i] = t;
        }
    }
}

extern void Minmax2(double a[],double b[],int n){
    int i;
    for (i = 0; i < n; i++) {
        double min = a [i] < b [i] ? a [i] : b [i];
        double max = a [i] < b [i] ? b [i] : a [i];
        a [i] = min;
        b [i] = max;
    }
}

Unity测试脚本:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Runtime.InteropServices;

public class ConditionalTrans : MonoBehaviour {

	private double[] array1_double=new double[1000000];
	private double[] array2_double=new double[1000000];

	void Start(){
		int pTime;
		int cTime;
		//C测试=====================================================
		for (int i = 0; i < 1000000; i++) {
			array1_double [i] = (double)Random.Range (1f, 100000f);
			array2_double [i] = (double)Random.Range (1f, 100000f);
		}
		
		pTime = System.Environment.TickCount;
		Minmax1 (array1_double,array2_double,1000000);
		cTime =  System.Environment.TickCount;
		Debug.Log ("MM1:Time:"+(cTime-pTime));

		for (int i = 0; i < 1000000; i++) {
			array1_double [i] = (double)Random.Range (1f, 100000f);
			array2_double [i] = (double)Random.Range (1f, 100000f);
		}
		
		pTime = System.Environment.TickCount;
		Minmax2 (array1_double,array2_double,1000000);
		cTime =  System.Environment.TickCount;
		Debug.Log ("MM2:Time:"+(cTime-pTime));

		for (int i = 0; i < 1000000; i++) {
			array1_double [i] = (double)Random.Range (1f, 100000f);
			array2_double [i] = (double)Random.Range (1f, 100000f);
		}
		//C#测试=====================================================
		pTime = System.Environment.TickCount;
		Minmax1_CSharp (array1_double,array2_double,1000000);
		cTime =  System.Environment.TickCount;
		Debug.Log ("MM1_CS:Time:"+(cTime-pTime));

		for (int i = 0; i < 1000000; i++) {
			array1_double [i] = (double)Random.Range (1f, 100000f);
			array2_double [i] = (double)Random.Range (1f, 100000f);
		}

		pTime = System.Environment.TickCount;
		Minmax2_CSharp (array1_double,array2_double,1000000);
		cTime =  System.Environment.TickCount;
		Debug.Log ("MM2_CS:Time:"+(cTime-pTime));
		//C#DLL测试=====================================================
		LibTest6.MyClass mc = new LibTest6.MyClass ();
		for (int i = 0; i < 1000000; i++) {
			array1_double [i] = (double)Random.Range (1f, 100000f);
			array2_double [i] = (double)Random.Range (1f, 100000f);
		}

		pTime = System.Environment.TickCount;
		mc.CSDLL_Minmax1(array1_double,array2_double,1000000);
		cTime =  System.Environment.TickCount;
		Debug.Log ("MM1_CSDLL:Time:"+(cTime-pTime));

		for (int i = 0; i < 1000000; i++) {
			array1_double [i] = (double)Random.Range (1f, 100000f);
			array2_double [i] = (double)Random.Range (1f, 100000f);
		}

		pTime = System.Environment.TickCount;
		mc.CSDLL_Minmax2 (array1_double,array2_double,1000000);
		cTime =  System.Environment.TickCount;
		Debug.Log ("MM2_CSDLL:Time:"+(cTime-pTime));
	}
	//C#测试函数
	public void Minmax1_CSharp(double[] a,double[] b,int n){
		int i;
		for (i = 0; i < n; i++) {
			if (a [i] > b [i]) {
				double t = a [i];
				a [i] = b [i];
				b [i] = t;
			}
		}
	}
	//C#测试函数
	public void Minmax2_CSharp(double[] a,double[] b,int n){
		int i;
		for (i = 0; i < n; i++) {
			double min = a [i] < b [i] ? a [i] : b [i];
			double max = a [i] < b [i] ? b [i] : a [i];
			a [i] = min;
			b [i] = max;
		}
	}
	//C测试函数
	[DllImport("C_Plugin",ExactSpelling=true,EntryPoint="Minmax1")]
	private static extern void Minmax1(double[] a,double[] b,int n);
	//C测试函数
	[DllImport("C_Plugin")]
	private static extern void Minmax2(double[] a,double[] b,int n);
}

测试环境:

CPU: 2.6 GHz Intel Core i5 双核
SRAM:L1 i-Cache 32K/d-Cache 32K, L2 256K, L3 3M
内存:8 GB 1600 MHz DDR3
IDE: C —Xcode Version 9.1 (9B55)
C#—Mono Develop 5.9.6
Unity: 2017.1.1f1 Personal

编译器与优化级别:

C#
编译器:mcs/.NET JIT
优化级别:未知

C# DLL
编译器:mcs/.NET JIT
优化级别:未知

C Native
编译器:LLVM
优化级别: -O3

测试结果:

两组一百万个double随机数比较大小后对调:
1,C#: 21毫秒(minmax1) 26毫秒(minmax2)
2,C# DLL: 15毫秒(minmax1) 24毫秒(minmax2)
3,C Native: 5毫秒(minmax1) 2毫秒(minmax2)
测试结果截图:
这里写图片描述

分析各版本各函数生成的CIL/机器码:

先了解下C#的编译流程
C#脚本:源码先被mcs编译成CIL(微软中间语言),保存在Assembly-CSharp.dll中,在Unity运行时,.NET JIT编译器根据需要实时的将CIL编译成机器码,并由机器执行。
C# DLL:C#DLL中的内容仍然还是CIL,同上,在运行时由JIT实时编译成机器码。
由于JIT是实时编译,在mac不知道如何获取它生成的机器码。mono还有一个AOT编译器,它可以将CIL进行预编译,以下将会分析AOT生成的机器码,优化级别为-optimize=all。但实际上由于两种编译器的运行机制不同导致的优化策略不同,生成的机器码可能不一样,只能希望是相差不大了。

由最慢向最快(C#–>C#DLL–>C)分析:

1,C# Minmax2

同一个Monodevelop编译,同3

2,C# Minmax1

同4

3,C# DLL Minmax2

CIL代码:
这里写图片描述
IL_0041是for循环起始处,以b开头并以ILxxx结尾的指令都是branch分支跳转,for循环内有4次分支跳转,看来是将三元操作符翻译成了4个if。

从CIL码手动生成的机器码:
这里写图片描述
红线范围内是for循环,循环内四次跳转,忠实的执行了CIL版本的逻辑,comisd指令比较寄存器低64位,没有什么优化。

4,C# DLL Minmax1

CIL代码:
这里写图片描述
在IL_0002处开始的循环中只有一次分支跳转,与源码逻辑相同。

机器码:
这里写图片描述
循环内只有一次comisd比较,一次循环内的分支跳转,与CIL看起来一样。

5,C Minmax1

反汇编出来的机器码:
这里写图片描述
循环内有两次comisd比较以及跳转(源码是一次),两次跳转的原因没大看懂,不确定是不是循环展开,但是jbe判断两个数大小然后有个交换好像是这么个意思-_-;。rax保存的应该是n,通过加fffffffffffffffe溢出后减1进行循环。

6,C Minmax2

反汇编出来的机器码:
这里写图片描述
将源码的三元操作符译为了minsd/maxsd,在循环内无任何分支跳转,并且使用了3个xmm寄存器,目测是减少了读写相关性,提高了指令并行度,并且有二路循环展开。

总结:

1,在机器码层面,预测难度很大的分支跳转次数与函数运行速度成反比

C#: 21毫秒(minmax1)/1次跳转 26毫秒(minmax2)/4次跳转
C# DLL:15毫秒(minmax1)/1次跳转 24毫秒(minmax2)/4次跳转
C Native :5毫秒(minmax1)/2次跳转 2毫秒(minmax2)/0次跳转

对x86的乱序处理器来说(core i5/i7),指令控制单元以最快的速度发射指令到执行单元,执行单元并行的执行指令,并将执行结果返回给控制单元,指令控制单元为了追求速度遇到分支跳转时(if-else)不会等待条件判断的结果,而是预测一个结果(既是跳转或不跳转),并继续发射指令,这些在比较结果出来之前而发射的指令会正常执行但不会写入内存,当比较结果出来时,如果预测正确则写入内存,如果预测错误,必须全部取消并且回到分支处重新取指,这一过程在时间上的损耗既是分支预测惩罚。当分支结果比较好预测时,这种分支预测策略是相当合理的(在另一组测试中,每个数组1中的变量都比数组2中的变量大1,除了C Natice Minmax2,所有版本的函数运行速度全部变快了)。但当遇到此案例这种根本无法预测的情况时,CPU只能进行赌博式投机预测,不可避免的大量分支预测惩罚影响了程序的运行速度。

2, C# DLL 要比 C#脚本快那么一点。

但是考虑到一,一个案例代表不了一般性,二,优势过于微弱。更保守的判断是在不同情况下C# DLL与Unity脚本各有优劣。

3,C Native+LLVM-O3编译在速度上有绝对优势

不论是测试数据上,还是生成的机器码在理论上的优化程度上C都是完胜。

4,源码层面上使用三元操作符不一定优于if-else

以逻辑的角度来看,三元操作符转化为min/max或cmov等CPU指令应该是理所当然的,但是由于当前的编译器的扭曲代码能力太强,源码所产生的机器码通常是由编译器的分析能力决定的。在此案例中,mcs编译器的智能明显还是有限的(或者是CIL语言的局限性)。由于三元操作符在CIL层面就已经被译为了分支跳转,JIT对此问题的优化逻辑成了未知数。
Xcode的LLVM在-O3优化级别下合理的将三元操作符转化为了min/max指令,但是在其他优化级别依然会错误的选择分支跳转。

————————————————————————————————————————————————————
参考:
深入理解计算机系统—R.E Bryant,D.R.Hallaron

维护日志:
2020-2-2:review

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值