정적 메서드 델리게이트가 더 느린 현상

상식적으로 봤을 때…
인스턴스 메서드를 호출하는 델리게이트 vs 정적 메서드를 호출하는 델리게이트
둘이 대결하면 후자가 빠르지 않겠습니까?
(인스턴스는 활용하지 않는다고 가정)
그래서 다음과 같이 테스트를 해봤습니다.

MyDelegate.cs

namespace MyDelegate
{
    delegate void MyCallback(int[] ints, int i);
    internal class MyDelegate
    {
        static public MyDelegate myDelegate = new MyDelegate();
        static public MyCallback myCallback = null;
        static public MyCallback myStaticCallback = Test;
        public int testMember;
        public static void Test(int[] ints, int i)
        {
            if (i < ints.Length)
            {
                ints[i] = i;
            }
        }
        public void Test2(int[] ints, int i)
        {
            if (i < ints.Length)
            {
                ints[i] = i;
            }
        }

        public static void Init()
        {
            myCallback = myDelegate.Test2;
        }
    }
}

테스트를 시행한 코드(Program.cs)

MyDelegate.MyDelegate.Init();
Random random = new Random();

const int repeat = 15000;
int[] array1 = new int[repeat];
int[] array3 = new int[repeat];

var a = CostTester.Test(() =>
{
    for (int j = 0; j < repeat; j++)
    {
        for (int i = 0; i < repeat; i++)
        {
            MyDelegate.MyDelegate.myCallback(array1, i);
        }
    }
});

var c = CostTester.Test(() =>
{
    for (int j = 0; j < repeat; j++)
    {
        for (int i = 0; i < repeat; i++)
        {
            MyDelegate.MyDelegate.myStaticCallback(array3, i);
        }
    }
});

Console.WriteLine("인스턴스메서드 델리게이트: " + "테스트값:" + array1[random.Next(array1.Length)] + ", 소요 시간:" + a.ms
+"\n정적 메서드 델리게이트: " + "테스트값:" + array3[random.Next(array3.Length)] + ", 소요 시간:" + c.ms);

CostTester.cs

namespace MyDelegate
{
    internal class CostTester
    {
        public delegate void TestFunction();
        static Stopwatch sw = new Stopwatch();
        public static (long ms, long ticks) Test(TestFunction testFunction)
        {
            sw.Restart();
            testFunction();
            sw.Stop();
            return (sw.ElapsedMilliseconds, sw.ElapsedTicks);
        }
    }
}

테스트 결과는 항상 근소하게 인스턴스 메서드 쪽이 이깁니다.
정적 메서드가 인자도 하나 덜 전달해도 되고… 더 나을 거라 생각했는데…
도대체 왜 그런 걸까요…?

1개의 좋아요

와우 저도 처음 알게 된 내용이네요! 관련된 설명을 찾았습니다.

요약

  • 델리게이트를 호출할 때 내부적으로 JIT 컴파일 여부에 따라 교체되는 "stub"을 생성하여 호출한다.
  • 인스턴스 타겟의 경우 this(인스턴스)를 통해 호출한 시점의 함수 파라메터 값이 구성된 레지스터 배치를 그대로 사용하여 바로 타겟 메소드를 call하는 간단한 stub이 생성된다.
  • 정적 타겟의 경우 this 없이 호출되므로 메소드 call을 위해 파라메터 값을 레지스터에 재구성하는 코드를 포함하여 훨씬 복잡하게 생성되기 때문에 인스턴스 타겟 호출에 비해 느리다.

제대로 이해한 것인지는 모르지만 원문에서는 32비트 stub이 64비트 stub보다 간단하게 생성되어 32비트에서는 별 차이가 나지 않는다고 하는 것 같습니다. (테스트 결과 좀 더 빠르기는 하나 여전히 차이가 남 ㅋ)

(참고)

원문의 질문 코드에 double 형식이 포함되어 있어 MXX레지스터와 FPU이야기가 나오는데 감안하시고 읽어보셔야 할 것 같네요.

private double AddNotStatic(double x, double y) => x + y;
private static double AddStatic(double x, double y) => x + y;
const int loops = 1000000000;
for (int i = 0; i < loops; i++)
{
    double funcResult = _func.Invoke(1d, 2d);
}

DeepL로 번역한 주요 내용

이 글에는 빠른 코드 작성에 관심이 있는 C# 프로그래머라면 반드시 알아야 할 꽤 괜찮은 프로그래밍 조언이 담겨 있습니다.

하지만 실제로 정적 메서드를 호출하는 델리게이트는 실제로 느려집니다.

속도가 느린 이유를 알기 위해서는 지터가 생성하는 머신 코드를 살펴봐야 합니다.

"스텁"에 대해 조금 이야기해야겠습니다. 스텁은 지터가 생성하는 코드에 추가하여 CLR이 동적으로 생성하는 작은 기계어 코드 조각입니다. 스텁은 인터페이스를 구현하는 데 사용되며, 클래스의 메소드 테이블에 있는 메소드의 순서가 인터페이스 메소드의 순서와 일치하지 않아도 되는 유연성을 제공합니다. 그리고 이 질문의 주제인 델리게이트에도 중요합니다. 스텁은 적시 컴파일에도 중요한데, 스텁의 초기 코드는 메서드가 호출될 때 컴파일되기 위한 지터의 진입점을 가리킵니다. 그 후 스텁이 교체되어 이제 지터된 대상 메서드를 호출합니다. 정적 메서드 호출을 느리게 만드는 것은 스텁이며, 정적 메서드 대상에 대한 스텁은 인스턴스 메서드에 대한 스텁보다 더 정교합니다.

                funcResult += _func.Invoke(1d, 2d);
0000001a  mov         rax,qword ptr [rsi+8]               ; rax = _func              
0000001e  mov         rcx,qword ptr [rax+8]               ; rcx = _func._methodBase (?)
00000022  vmovsd      xmm2,qword ptr [0000000000000070h]  ; arg3 = 2d
0000002b  vmovsd      xmm1,qword ptr [0000000000000078h]  ; arg2 = 1d
00000034  call        qword ptr [rax+18h]                 ; call stub

64비트 메서드 호출은 처음 4개의 인자를 레지스터에 전달하고, 추가 인자는 스택을 통해 전달합니다(여기서는 그렇지 않음). 인수가 부동 소수점이기 때문에 여기서는 XMM 레지스터가 사용됩니다. 이 시점에서 지터는 메서드가 정적인지 인스턴스인지 아직 알 수 없으며, 이는 이 코드가 실제로 실행될 때까지 알 수 없습니다. 그 차이를 숨기는 것이 스텁의 역할입니다. 인스턴스 메서드일 것이라고 가정하기 때문에 arg2와 arg3에 주석을 달았습니다.

CALL 명령어에 중단점을 설정하면 두 번째로 호출될 때(스텁이 더 이상 지터를 가리키지 않게 된 후) 해당 명령어를 살펴볼 수 있습니다.

  00007FFCE66D0100 jmp 00007FFCE66D0E40  

아주 간단하게 델리게이트 대상 메서드로 바로 이동합니다. 이것은 빠른 코드가 될 것입니다. 지터가 인스턴스 메서드에서 올바르게 추측했고 델리게이트 객체가 이미 RCX 레지스터에 “this” 인수를 제공했기 때문에 특별한 작업을 수행할 필요가 없습니다.

두 번째 테스트를 진행하여 똑같은 작업을 수행하여 인스턴스 호출에 대한 스텁을 살펴봅니다. 이제 스텁이 매우 달라졌습니다:

000001FE559F0850  mov         rax,rsp                 ; ?
000001FE559F0853  mov         r11,rcx                 ; r11 = _func (?)
000001FE559F0856  movaps      xmm0,xmm1               ; shuffle arg3 into right register
000001FE559F0859  movaps      xmm1,xmm2               ; shuffle arg2 into right register
000001FE559F085C  mov         r10,qword ptr [r11+20h] ; r10 = _func.Method 
000001FE559F0860  add         r11,20h                 ; ?
000001FE559F0864  jmp         r10                     ; jump to _func.Method

코드가 약간 불안정하고 최적이 아니며 Microsoft는 아마도 여기에서 더 나은 작업을 수행 할 수 있으며 주석을 올바르게 달았는지 100 % 확신하지 못합니다. 불필요한 mov rax,rsp 명령은 인수가 4 개 이상인 메서드에 대한 스텁에만 관련이있는 것 같습니다. 추가 명령이 왜 필요한지 모르겠습니다. 가장 중요한 세부 사항은 XMM 레지스터 이동이며, 정적 메서드에는 “this” 인수가 없기 때문에 이를 재구성해야 합니다. 코드가 느려지는 것은 바로 이 재구성 요구 사항입니다.

x86 지터로 동일한 연습을 할 수 있으며, 이제 정적 메서드 스터브는 다음과 같습니다:

04F905B4  mov         eax,ecx  
04F905B6  add         eax,10h  
04F905B9  jmp         dword ptr [eax]      ; jump to _func.Method

64비트 스텁보다 훨씬 간단하기 때문에 32비트 코드가 속도 저하를 거의 겪지 않습니다. 32비트 코드와 64비트 코드가 크게 다른 이유 중 하나는 32비트 코드는 FPU 스택에서 부동소수를 전달하므로 재구성할 필요가 없다는 점입니다. 적분이나 객체 인수를 사용할 때 반드시 더 빨라지는 것은 아닙니다.

매우 난해한 내용인데, 여러분을 아직 잠들게 하지 않았기를 바랍니다. 제가 주석을 잘못 달았을 수도 있고, 스텁과 CLR이 가능한 한 빠르게 코드를 만들기 위해 델리게이트 객체 멤버를 요리하는 방식을 완전히 이해하지 못했을 수도 있습니다. 하지만 여기에는 확실히 괜찮은 프로그래밍 조언이 있습니다. 인스턴스 메서드를 델리게이트 대상으로 사용하는 것이 좋으며, 이를 정적으로 만드는 것은 최적화가 아닙니다.

9개의 좋아요

와 이렇게 빠르게 답변이 나올줄은 몰랐는데 정말 감사합니다. 구글링을 나름 해봐도 잘 안나오던데… 제가 상상 못했던 이유였네요.
감사합니다!!!

2개의 좋아요