[英]Performance of “direct” virtual call vs. interface call in C#
該基准測試似乎表明,直接在對象引用上調用虛擬方法比在該對象實現的接口的引用上調用它要快。
換句話說:
interface IFoo {
void Bar();
}
class Foo : IFoo {
public virtual void Bar() {}
}
void Benchmark() {
Foo f = new Foo();
IFoo f2 = f;
f.Bar(); // This is faster.
f2.Bar();
}
來自 C++ 世界,我原以為這兩個調用會以相同的方式實現(作為簡單的虛擬表查找)並具有相同的性能。 C# 如何實現虛擬調用以及通過接口調用時顯然完成的“額外”工作是什么?
好的,到目前為止我得到的答案/評論暗示通過接口的虛擬調用有一個雙指針取消引用,而不是通過對象的虛擬調用只有一個取消引用。
所以可以請有人解釋為什么有必要嗎? C#中虛擬表的結構是什么? 它是否“平坦”(C++ 的典型特征)? 導致這種情況的 C# 語言設計中的設計權衡是什么? 我並不是說這是一個“糟糕”的設計,我只是好奇為什么它是必要的。
簡而言之,我想了解我的工具在幕后做了什么,以便我可以更有效地使用它。 如果我沒有得到更多“你不應該知道”或“使用另一種語言”類型的答案,我將不勝感激。
只是為了清楚起見,我們在這里沒有處理一些消除動態調度的 JIT 優化編譯器:我修改了原始問題中提到的基准測試,以在運行時隨機實例化一個類或另一個類。 由於實例化發生在編譯之后和程序集加載/JITing 之后,在這兩種情況下都無法避免動態調度:
interface IFoo {
void Bar();
}
class Foo : IFoo {
public virtual void Bar() {
}
}
class Foo2 : Foo {
public override void Bar() {
}
}
class Program {
static Foo GetFoo() {
if ((new Random()).Next(2) % 2 == 0)
return new Foo();
return new Foo2();
}
static void Main(string[] args) {
var f = GetFoo();
IFoo f2 = f;
Console.WriteLine(f.GetType());
// JIT warm-up
f.Bar();
f2.Bar();
int N = 10000000;
Stopwatch sw = new Stopwatch();
sw.Start();
for (int i = 0; i < N; i++) {
f.Bar();
}
sw.Stop();
Console.WriteLine("Direct call: {0:F2}", sw.Elapsed.TotalMilliseconds);
sw.Reset();
sw.Start();
for (int i = 0; i < N; i++) {
f2.Bar();
}
sw.Stop();
Console.WriteLine("Through interface: {0:F2}", sw.Elapsed.TotalMilliseconds);
// Results:
// Direct call: 24.19
// Through interface: 40.18
}
}
如果有人感興趣,這里是我的 Visual C++ 2010 如何布局一個類的實例,該類的多個繼承其他類:
代碼:
class IA {
public:
virtual void a() = 0;
};
class IB {
public:
virtual void b() = 0;
};
class C : public IA, public IB {
public:
virtual void a() override {
std::cout << "a" << std::endl;
}
virtual void b() override {
std::cout << "b" << std::endl;
}
};
調試器:
c {...} C
IA {...} IA
__vfptr 0x00157754 const C::`vftable'{for `IA'} *
[0] 0x00151163 C::a(void) *
IB {...} IB
__vfptr 0x00157748 const C::`vftable'{for `IB'} *
[0] 0x0015121c C::b(void) *
多個虛擬表指針清晰可見,並且sizeof(C) == 8
(在 32 位構建中)。
這...
C c;
std::cout << static_cast<IA*>(&c) << std::endl;
std::cout << static_cast<IB*>(&c) << std::endl;
..印刷...
0027F778
0027F77C
...表示指向同一對象內不同接口的指針實際上指向該對象的不同部分(即它們包含不同的物理地址)。
我認為Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects 一文將回答您的問題。 特別是,請參閱 * Interface Vtable Map 和 Interface Map - 部分以及以下有關 Virtual Dispatch 的部分。
JIT 編譯器可能會為您的簡單案例解決問題並優化代碼。 但不是在一般情況下。
IFoo f2 = GetAFoo();
並且GetAFoo
被定義為返回一個IFoo
,那么 JIT 編譯器將無法優化調用。
這是反匯編的樣子(漢斯是正確的):
f.Bar(); // This is faster.
00000062 mov rax,qword ptr [rsp+20h]
00000067 mov rax,qword ptr [rax]
0000006a mov rcx,qword ptr [rsp+20h]
0000006f call qword ptr [rax+60h]
f2.Bar();
00000072 mov r11,7FF000400A0h
0000007c mov qword ptr [rsp+38h],r11
00000081 mov rax,qword ptr [rsp+28h]
00000086 cmp byte ptr [rax],0
00000089 mov rcx,qword ptr [rsp+28h]
0000008e mov r11,qword ptr [rsp+38h]
00000093 mov rax,qword ptr [rsp+38h]
00000098 call qword ptr [rax]
我試過你的測試,在我的機器上,在特定的上下文中,結果實際上是相反的。
我正在運行 Windows 7 x64,並且創建了一個Visual Studio 2010控制台應用程序項目,我已將您的代碼復制到該項目中。 如果在調試模式下編譯項目並且平台目標為x86 ,輸出將如下所示:
直撥:48.38 直通接口:42.43
實際上每次運行應用程序時,它都會提供略有不同的結果,但接口調用總是更快。 我假設由於應用程序被編譯為 x86,它將由操作系統通過WoW運行。
作為完整的參考,以下是其余編譯配置和目標組合的結果。
發布模式和x86目標
直撥電話:23.02
通過接口:32.73
調試模式和x64目標
直撥:49.49
通過接口:56.97
發布模式和x64目標
直撥電話:19.60
通過接口:26.45
以上所有測試都是使用 .NET 4.0 作為編譯器的目標平台進行的。 切換到3.5,重復上述測試時,通過接口的調用總是比直接調用的時間長。
因此,上述測試使事情變得相當復雜,因為您發現的行為似乎並不總是發生。
最后,冒着讓你不高興的風險,我想補充幾點。 許多人補充說性能差異非常小,在現實世界的編程中你不應該關心它們,我同意這個觀點。 有兩個主要原因。
第一個也是宣傳最多的一個是 .NET 建立在更高級別上,以便使開發人員能夠專注於更高級別的應用程序。 數據庫或外部服務調用比虛擬方法調用慢數千甚至數百萬倍。 擁有良好的高層架構並專注於大性能消費者將始終在現代應用程序中帶來更好的結果,而不是避免雙指針取消引用。
第二個也是更模糊的是,.NET 團隊通過在更高級別上構建框架實際上引入了一系列抽象級別,即時編譯器將能夠使用這些級別在不同平台上進行優化。 他們給予底層的訪問權限越多,開發人員就越能針對特定平台進行優化,但運行時編譯器為其他平台所做的工作就越少。 這至少是理論,這就是為什么關於這個特定問題的事情不像在 C++ 中那樣被很好地記錄下來。
一般規則是:課程很快。 接口很慢。
這就是建議“使用類構建層次結構並使用接口進行層次結構內行為”的原因之一。
對於虛擬方法,差異可能很小(比如 10%)。 但是對於非虛擬方法和字段,差異是巨大的。 考慮這個程序。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace InterfaceFieldConsoleApplication
{
class Program
{
public abstract class A
{
public int Counter;
}
public interface IA
{
int Counter { get; set; }
}
public class B : A, IA
{
public new int Counter { get { return base.Counter; } set { base.Counter = value; } }
}
static void Main(string[] args)
{
var b = new B();
A a = b;
IA ia = b;
const long LoopCount = (int) (100*10e6);
var stopWatch = new Stopwatch();
stopWatch.Start();
for (int i = 0; i < LoopCount; i++)
a.Counter = i;
stopWatch.Stop();
Console.WriteLine("a.Counter: {0}", stopWatch.ElapsedMilliseconds);
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < LoopCount; i++)
ia.Counter = i;
stopWatch.Stop();
Console.WriteLine("ia.Counter: {0}", stopWatch.ElapsedMilliseconds);
Console.ReadKey();
}
}
}
輸出:
a.Counter: 1560
ia.Counter: 4587
我認為純虛函數的情況可以使用一個簡單的虛函數表,因為任何實現Bar
的Foo
派生類都會將虛函數指針更改為Bar
。
另一方面,調用接口函數 IFoo:Bar 無法查找IFoo
的虛函數表之類的東西,因為IFoo
每個實現都不需要實現Foo
所做的其他函數或接口。 所以對於虛函數表條目位置Bar
,從另一個class Fubar: IFoo
必須不匹配的虛函數表條目位置Bar
在class Foo:IFoo
。
因此,純虛函數調用可以依賴於每個派生類中虛函數表內函數指針的相同索引,而接口調用必須首先查找 this 索引。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.