.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
留言列表