.NET、Mono与Java、C++性能测试大PK
任何计算设备硬件资源都是有限的,越多的程序和服务竞争资源,用户的体验越糟糕(通常表现为延迟较长),性能下降的部分原因是因为安装了不需要的组件,还有部分原因是程序内部的设计问题,如让程序随系统启动而启动,或不管你是否会使用它,都让它在后台运行着,这些运行着但又未使用的进程都会抢占有限的系统资源。
虽然我见过一些有关程序性能测试的文章,但却未见过对程序的启动时间进行测试的,更别说是不同编程语言(框架),或同一框架的不同版本了,但这种测试结果对于选择特定硬件系统后,确定编程语言是非常有帮助的。本文将介绍当前比较流行的语言(框架) -.NET,Java,Mono和C++程序的启动性能对比,所有测试都是在它们各自的默认设置下进行的。但.NET,Mono,Java托管代码和C++原生代码谁的启动时间最短,谁的性能最好呢?首先来看一下热启动的对比结果吧!
图 1 Mono,Java,.NET和C++程序热启动性能对比(值越小越好)
由于测试中有诸多因素会影响结果,为了使测试结果显得更公平,我们只使用了一些简单的,可重复的测试,所有语言都可执行这些测试。
首先我们要测试的是从进程创建到进入main函数所花的时间,简称为“启动时间”,要精确地测试出启动时间是很困难的,有时只有凭用户的感觉,接下来测量了内存占用情况,内核和用户消耗的处理器时间。
如何计算启动时间
在下面的内容中,凡是提到操作系统API,我指的操作系统都是指WindowsXP,由于没有现成的操作系统API可以获得程序的启动时间,因此我用了自己发明的方法来计算,我使用了简单的进程间通信机制来解决这个问题,创建进程时将创建时间作为一个命令行参数传递给测试进程,执行到退出代码时返回当前时间和创建时间的差,具体步骤说明如下:
在调用者进程(BenchMarkStartup.exe)中获得当前的UTC系统时间;
启动测试进程,将前面获得的进程创建时间作为参数传递给它;
在分支进程中,获得main函数开始执行时的当前系统UTC时间;
在同一进程中,计算并调整时间差;
执行到退出代码时返回时间差;
在调用者进程(BenchMarkStartup.exe)中捕捉退出代码。
本文会使用到两个启动时间:冷启动时间和热启动时间,冷启动表示系统重启后,程序的第一次启动时间,热启动时间表示程序关闭后,再次启动所花的时间。冷启动需要的时间往往会长一些,因为需要加载I/O组件,热启动可以利用操作系统的预取功能,因此热启动的时间要短得多。
影响性能的因素
对于托管的运行时,与原生代码比起来,JIT编译器将会消耗额外的CPU时间和内存。特别是对于冷启动时间的对比可能会有失公允,C++原生代码肯定会占有优势,而托管型的Mono,Java和.NET代码需要更长的加载时间。另外,如果其它程序加载了你需要的库,I/O操作也会减少,启动时间也会得到改善。在Java方面,也有一些启动加速程序,如Java QuickStarter,Jinitiator,为了公平起见,应该禁用它们。缓存和预取功能也应该留给操作系统去管理,不要浪费不必要的资源。
C++性能测试代码
C++测试代码是直接由调用者进程调用的,当它获得一个命令行参数时,它会将其转换成__int64来表示FILETIME,其值是从1601/1/1到现在的100 毫微秒间隔数,因此我们可以获得时间差,以毫秒数返回,用32位大小就足够了。
int _tmain(int argc, _TCHAR* argv[])
{
FILETIME ft;
GetSystemTimeAsFileTime(&ft);
static const __int64 startEpoch2 = 0; // 1601/1/1
if( argc < 2 )
{
::Sleep(5000);
return -1;
}
FILETIME userTime;
FILETIME kernelTime;
FILETIME createTime;
FILETIME exitTime;
if(GetProcessTimes(GetCurrentProcess(), &createTime, &exitTime,&kernelTime, &userTime))
{
__int64 diff;
__int64 *pMainEntryTime = reinterpret_cast<__int64 *>(&ft);
_int64 launchTime = _tstoi64(argv[1]);
diff = (*pMainEntryTime -launchTime)/10000;
return (int)diff;
}
else
return -1;
}
下面是创建测试进程的代码,传递给它的是初始时间,返回的是启动时间。第一个调用计算冷启动时间,后面的调用计算的是热启动时间。
DWORD BenchMarkTimes( LPCTSTR szcProg)
{
ZeroMemory( strtupTimes, sizeof(strtupTimes) );
ZeroMemory( kernelTimes, sizeof(kernelTimes) );
ZeroMemory( preCreationTimes, sizeof(preCreationTimes) );
ZeroMemory( userTimes, sizeof(userTimes) );
BOOL res = TRUE;
TCHAR cmd[100];
int i,result = 0;
DWORD dwerr = 0;
PrepareColdStart();
::Sleep(3000);//3秒延迟
for(i = 0; i <= COUNT && res; i++)
{
STARTUPINFO si;
PROCESS_INFORMATION pi;
ZeroMemory( &si, sizeof(si) );
si.cb = sizeof(si);
ZeroMemory( &pi, sizeof(pi) );
::SetLastError(0);
__int64 wft = 0;
if(StrStrI(szcProg, _T("java")) && !StrStrI(szcProg, _T(".exe")))
{
wft = currentWindowsFileTime();
_stprintf_s(cmd,100,_T("java -client -cp .\\.. %s \"%I64d\""),szcProg,wft);
}
else if(StrStrI(szcProg, _T("mono")) && StrStrI(szcProg, _T(".exe")))
{
wft = currentWindowsFileTime();
_stprintf_s(cmd,100,_T("mono %s \"%I64d\""), szcProg,wft);
}
else
{
wft = currentWindowsFileTime();
_stprintf_s(cmd,100,_T("%s \"%I64d\""), szcProg,wft);
}
// 启动子进程
if( !CreateProcess( NULL,cmd,NULL,NULL,FALSE,0,NULL,NULL,&si,&pi ))
{
dwerr = GetLastError();
_tprintf( _T("CreateProcess failed for '%s' with error code %d:%s.\n"),szcProg, dwerr,GetErrorDescription(dwerr) );
return dwerr;
//中断;
}
//等待20秒,或直到子进程退出
dwerr = WaitForSingleObject( pi.hProcess, 20000 );
if(dwerr != WAIT_OBJECT_0)
{
dwerr = GetLastError();
_tprintf( _T("WaitForSingleObject failed for '%s' with error code %d\n"),szcProg, dwerr );
// 关闭进程和线程处理
CloseHandle( pi.hProcess );
CloseHandle( pi.hThread );
break;
}
res = GetExitCodeProcess(pi.hProcess,(LPDWORD)&result);
FILETIME CreationTime,ExitTime,KernelTime,UserTime;
if(GetProcessTimes(pi.hProcess,&CreationTime,&ExitTime,&KernelTime,&UserTime))
{
__int64 *pKT,*pUT, *pCT;
pKT = reinterpret_cast<__int64 *>(&KernelTime);
pUT = reinterpret_cast<__int64 *>(&UserTime);
pCT = reinterpret_cast<__int64 *>(&CreationTime);
if(i == 0)
{
_tprintf( _T("cold start times:\nStartupTime %d ms"),result);
_tprintf( _T(", PreCreationTime: %u ms"), ((*pCT)- wft)/ 10000);
_tprintf( _T(", KernelTime: %u ms"), (*pKT) / 10000);
_tprintf( _T(", UserTime: %u ms\n"), (*pUT) / 10000);
_tprintf( _T("Waiting for statistics for %d warm samples"), COUNT);
}
else
{
_tprintf( _T("."));
kernelTimes[i-1] = (int)((*pKT) / 10000);
preCreationTimes[i-1] = (int)((*pCT)- wft)/ 10000;
userTimes[i-1] = (int)((*pUT) / 10000);
strtupTimes[i-1] = result;
}
}
else
{
printf( "GetProcessTimes failed for %p", pi.hProcess );
}
// 关闭进程和线程处理
CloseHandle( pi.hProcess );
CloseHandle( pi.hThread );
if((int)result < 0)
{
_tprintf( _T("%s failed with code %d: %s\n"),cmd, result,GetErrorDescription(result) );
return result;
}
::Sleep(1000); //1秒延时
}
if(i <= COUNT )
{
_tprintf( _T("\nThere was an error while running '%s',last error code = %d\n"),cmd,GetLastError());
return result;
}
double median, mean, stddev;
if(CalculateStatistics(&strtupTimes[0], COUNT, median, mean, stddev))
{
_tprintf( _T("\nStartupTime: mean = %6.2f ms, median = %3.0f ms,standard deviation = %6.2f ms\n"),
mean,median,stddev);
}
if(CalculateStatistics(&preCreationTimes[0], COUNT, median, mean,stddev))
{
_tprintf( _T("PreCreation: mean = %6.2f ms, median = %3.0f ms,standard deviation = %6.2f ms\n"),
mean,median,stddev);
}
if(CalculateStatistics(&kernelTimes[0], COUNT, median, mean, stddev))
{
_tprintf( _T("KernelTime : mean = %6.2f ms, median = %3.0f ms,standard deviation = %6.2f ms\n"),
mean,median,stddev);
}
if(CalculateStatistics(&userTimes[0], COUNT, median, mean, stddev))
{
_tprintf( _T("UserTime : mean = %6.2f ms, median = %3.0f ms,standard deviation = %6.2f ms\n"),
mean,median,stddev);
}
return GetLastError();
}
注意启动Mono和Java程序的命令行与.NET或原生代码有些不同,我也没有使用性能监视计数器。
如果你想知道我为什么没有使用GetProcessTimes提供的创建时间,我可以告诉你有两个原因。首先,对于.NET和Mono,需要DllImport,对于Java需要JNI,这样就使程序变得更加臃肿了;第二个原因是我发现创建时间不是CreateProcessAPI被调用的真正时间。
从本地硬盘运行测试时,由这两个因素引起的时间会相差0-10毫秒,如果是从网络驱动器运行,时间会有数百毫秒的出入,如果是从软盘上运行,甚至可能达到几秒。我把这个时间差叫做预创建时间,我猜测这是因为操作系统没有考虑创建新进程时,从存储介质读取文件所花的时间所致,因为只在冷启动时有这个差异,而热启动就没有。
.NET和Mono C#性能测试代码
在调用的.NET代码中计算启动时间和C++有点不同,它使用了DateTime中的FromFileTimeUtc辅助方法。
private const long TicksPerMiliSecond = TimeSpan.TicksPerSecond / 1000;
static int Main(string[] args)
{
DateTime mainEntryTime = DateTime.UtcNow;//100 nanoseconds units since 1601/1/1
int result = 0;
if (args.Length > 0)
{
DateTime launchTime = System.DateTime.FromFileTimeUtc(long.Parse(args[0]));
long diff = (mainEntryTime.Ticks - launchTime.Ticks) / TicksPerMiliSecond;
result = (int)diff;
}
else
{
System.GC.Collect(2, GCCollectionMode.Forced);
System.GC.WaitForPendingFinalizers();
System.Threading.Thread.Sleep(5000);
}
return result;
}
使用Mono
要使用Mono必须先从这里下载并安装好Mono,然后修改环境变量PATH,增加C:\PROGRA~1\MONO-2~1.4\bin\,注意你使用的Mono版本号可能会有些不同,另外,安装时可以不选中GTK#和XSP组件,因为本次测试用不着它们,为了简化编译操作,我特意写了一个buildMono.bat批处理文件,已包含在本文提供的下载包中。
使用更多.NET版本
我还包括了1.1,2.0,3.5和4.0版本的C#VisualStudio项目,如果你只需运行二进制文件,需要下载和安装对应的运行时,生成(Build)时需要VisualStudio2003和VisualStudio2010,或如果你喜欢使用命令生成,还需要特定的SDK。为了强制加载目标运行时版本,我为所有.NET执行文件创建了配置文件,内容如下,不同的地方就是版本号:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v1.1.4322" />
</startup>
</configuration>
Java性能测试代码
首先要从这里下载并安装Java SDK,同样也需要向PATH环境变量添加Java路径,在开始生成前,还需要设置javac.exe的编译路径,如:
set path=C:\Program Files\Java\jdk1.6.0_16\bin;%path%
在本文提供的压缩包中,我提供了一个buildJava.bat批处理文件来帮助你完成生成操作,Java性能测试代码如下:
public static void main(String[] args)
{
long mainEntryTime = System.currentTimeMillis();//miliseconds since since 1970/1/1
int result = 0;
if (args.length > 0)
{
//FileTimeUtc adjusted for java epoch
long fileTimeUtc = Long.parseLong(args[0]);//100 nanoseconds units since 1601/1/1
long launchTime = fileTimeUtc - 116444736000000000L;//100 nanoseconds units since 1970/1/1
launchTime /= 10000;//miliseconds since since 1970/1/1
result = (int)(mainEntryTime - launchTime);
}
else
{
try
{
System.gc();
System.runFinalization();
Thread.sleep(5000);
}
catch (Exception e)
{
e.printStackTrace();
}
}
java.lang.System.exit(result);
}
由于Java缺乏测量持续时间的解决方案,我不得不使用毫秒,其它框架可以提供更细粒度的时间单位,但毫秒在这次的测试中已经够用了。
获取内存使用情况和处理器时间
Windows进程有许多层面都会使用内存,我将仅限于测量专用字节,最小工作集和峰值工作集。如果你想知道没有参数时,调用的进程为什么会等待5秒,现在你应该有答案了。在等待2秒后,调用者将使用下面的代码测量内存使用情况:
BOOL PrintMemoryInfo( const PROCESS_INFORMATION& pi)
{
//wait 2 seconds while the process is sleeping for 5 seconds
if(WAIT_TIMEOUT != WaitForSingleObject( pi.hProcess, 2000 ))
return FALSE;
if(!EmptyWorkingSet(pi.hProcess))
printf( "EmptyWorkingSet failed for %x\n", pi.dwProcessId );
BOOL bres = TRUE;
PROCESS_MEMORY_COUNTERS_EX pmc;
if ( GetProcessMemoryInfo( pi.hProcess, (PROCESS_MEMORY_COUNTERS*)&pmc,sizeof(pmc)) )
{
printf( "PrivateUsage: %lu KB,", pmc.PrivateUsage/1024 );
printf( " Minimum WorkingSet: %lu KB,", pmc.WorkingSetSize/1024 );
printf( " PeakWorkingSet: %lu KB\n", pmc.PeakWorkingSetSize/1024 );
}
else
{
printf( "GetProcessMemoryInfo failed for %p", pi.hProcess );
bres = FALSE;
}
return bres;
}
最小工作集是调用的进程占用的内存由EmptyWorkingSet API收缩后,我计算出的一个值。
测试结果
这些测试产生的结果很多,我只挑选了与本文主题相关的一些数据,并将热启动的测试结果也一并展示出来了,如图1所示。如果你以调试模式执行测试,产生的结果会更多,对于热启动,我执行了9次测试,而冷启动只有一次,我只采用了中间值(即去掉了最高分和最低分),处理器内核和用户时间被归结到一块儿,总称为CPU时间,下表的结果是来自一台奔四3.0GHz,2GB内存的Windows XP机器的测试结果。
运行时 |
冷启动时间(ms) |
冷启动CPU时间(ms) |
热启动时间(ms) |
热启动CPU时间(ms) |
专用字节(KB) |
最小工作集(KB) |
峰值工作集(KB) |
.Net 1.1 |
1844 |
156 |
93 |
93 |
3244 |
104 |
4712 |
.Net 2.0 |
1609 |
93 |
78 |
93 |
6648 |
104 |
5008 |
.Net 3.5 |
1766 |
125 |
93 |
77 |
6640 |
104 |
4976 |
.Net 4.0 |
1595 |
77 |
46 |
77 |
7112 |
104 |
4832 |
Java 1.6 |
1407 |
108 |
94 |
92 |
39084 |
120 |
11976 |
Mono 2.6.4 |
1484 |
156 |
93 |
92 |
4288 |
100 |
5668 |
CPP code |
140 |
30 |
15 |
15 |
244 |
40 |
808 |
注意其中.NET 2.0和.NET4.0的热启动时间比热启动CPU时间要低,你可能认为这违背了基本的物理定律,但需要注意这里的CPU时间指的是进程的整个生命周期,而启动时间仅仅指进入到main函数时的时间,通过这我们知道可以通过一些优化提高这些框架的启动速度,正如你前面看到的,C++由于没有框架,因此优势很明显,调用者进程通过预加载一些通用dll使启动更快。
我没有所有运行时的历史数据,但从.NET各版本的表现来看,越新的版本会通过消耗更多的内存来提速,如下图所示。
图 2 .NET框架不同版本程序热启动时性能表现(值越小越好)
为托管运行时使用原生镜像
除了C++原生代码外,所有运行时都使用了中间代码,下一步如果可能应该尝试生成原生镜像,并再次评估它们的性能,Java没有一个易于使用的工具来完成这项任务,GCJ只能完成一半的任务,而且它还不是官方运行时的一部分,因此我会忽略它。Mono有一个类似的功能叫做Ahead ofTime(AOT),遗憾的是,AOT尚不能在Windows上工作。.NET从一开始就支持原生代码生成,ngen.exe就是运行时的一部分。
为了方便你,我在本文提供的压缩包中提供了一个make_nativeimages.bat批处理文件,用它快速生成测试用程序集的原生镜像。下表展示了.NET框架各版本原生镜像的测试结果。
运行时 |
冷启动时间(ms) |
冷启动CPU时间(ms) |
热启动时间(ms) |
热启动CPU时间(ms) |
专用字节(KB) |
最小工作集(KB) |
峰值工作集(KB) |
.Net 1.1 |
2110 |
140 |
109 |
109 |
3164 |
108 |
4364 |
.Net 2.0 |
1750 |
109 |
78 |
77 |
6592 |
108 |
4796 |
.Net 3.5 |
1859 |
140 |
78 |
77 |
6588 |
108 |
4800 |
.Net 4.0 |
1688 |
108 |
62 |
61 |
7044 |
104 |
4184 |
我们似乎又再次遇到违背物理定律的事情了,上表显示原生编译的程序集冷启动时间更高,不必大惊小怪,因为加载原生镜像也需要大量的I/O操作,从测试结果来看,它比加载框架所用的时间更多。
运行测试
你可以将测试的可执行文件作为一个参数传递给BenchMarkStartup.exe运行一个特殊的测试,对于Java,包名必须匹配目录结构,因此JavaPerf.StartupTest需要一个..\JavaPerf文件夹。
我在本文提供的压缩包中提供了一个runall.bat批处理文件,但它无法捕捉现实的冷启动时间。
如果你想执行真实的测试,你可以手动重启,或在夜间每隔20-30分钟调度执行release文件夹的benchmark.bat批处理文件,然后从文本日志文件获得结果。重启机器后,它将会运行所有运行时的真实测试。
最新的计算机通常会控制CPU频率以节约能源,但这可能会影响到测试结果,因此在运行测试之前,除了前面我已经提到的事情外,你还必须将电源使用方案设置为“高性能”,以便获得一致的结果。
小结
如果你有条件下载文后提供的压缩包按照本文介绍的内容亲自做一下对比测试,相信你对托管运行时和原生代码有更深刻的认识,如果你正在犹豫不决地选择开发平台,本文也可以帮助你确定清晰的方向,另外,你还可以参照本文创建其它运行时或UI测试。
本文使用到的测试源代码和批处理文件从这里下载,我还对Java和Mono专门制作了一个压缩包,从这里下载。
原文名:Benchmark start-up and system performance for .Net, Mono, Java and C++ native code