Skip to main content

TickCount 탐구

발단

이 의문이 시작된 것은 단순히 String(또는 C#과 같은 언어에서는 string으로 간소화되기도 하죠, 이하 string)과 StringBuilder, StringBuffer의 성능 차이에 대한 초급적인 언급이었습니다.

string은 실제로는 읽기 전용처럼 쓰인다는 건 초보 빼고는 다 알고 있는 사실입니다. immutable이라고 하던데, 변하지 않는다는 말이죠. 따라서 우리가 자유롭게 +나 concat을 하면 string은 그 때마다 새로운 개체를 반환하게 됩니다.

C++에서는 strcat()으로 문자열을 수정할 때 큰 영역을 미리 할당해두면 속도가 비약적으로 증가한다고 하지만, C#과 Java에서는 문자열의 수정이 어렵기 때문에 다른 방법이 필요하다는 것이고, 그 내용은 MSDN에도 잘 나와 있습니다. Java랑 C#이 판박이인 점은 코드를 복붙해보면 쉽게 알 수 있는 내용이더군요.

효과 검증

그래서 이걸 어떻게 알 수 있을까요. Java 선생님은 문자열을 파바바박 쏟아내는 반복문을 짜고, “어라, 별 차이가 없는데” 하셨지만, 실제로 큰 차이가 발생하는 것은 어느 정도 코드를 통해서도 쉽게 파악할 수 있었습니다.

 

/* string performance in C# */
            string me = "";
            Console.WriteLine("String START");
            var cnt = Environment.TickCount;
            for (int i = 0; i < 100000; i++)
            {
                me += "a";
            }
            Console.WriteLine("Time: "+(Environment.TickCount - cnt).ToString());
            Console.WriteLine("END");
            //Console.WriteLine(me);

            StringBuilder sb = new StringBuilder();
            Console.WriteLine("StringBuilder START");
            cnt = Environment.TickCount;
            for (int i = 0; i < 100000; i++)
            {
                sb.Append("a");
            }
            Console.WriteLine("Time: " + (Environment.TickCount - cnt).ToString());
            Console.WriteLine("END");

C# 코드

/* string performance */
        String me = "";
        System.out.println("String START");
        long cnt = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++)
        {
            me += "a";
        }
        System.out.println("Time: "+(System.currentTimeMillis() - cnt));
        System.out.println("END");

        StringBuffer sb = new StringBuffer();
        System.out.println("StringBuffer START");
        System.out.printf("capacity: %d, length: %d\n", sb.capacity(), sb.length());
        cnt = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++)
        {
            sb.append("a");
        }
        System.out.println("Time: " + (System.currentTimeMillis() - cnt));
        System.out.printf("capacity: %d, length: %d\n", sb.capacity(), sb.length());
        System.out.println("END");

Java코드는 다음과 같은 결과가 나왔습니다.

String START
Time: 7467
END
StringBuffer START
capacity: 16, length: 0
Time: 9
capacity: 294910, length: 200000
END

어마어마한 차이네요. 10만 개의 String을 붙이면서 7초 넘게 소요되었습니다. 한편, C#은 그나마 5초 내외로 되는게 코드 최적화의 힘인가 싶습니다. StringBuffer와 StringBuilder 모두 일의 자리나 심지어 0ms로, 찰나에 가까운 수준이라 극명한 차이를 보여주더군요.

역시 이미 사용된 Heap을 얼마나 유용하게 사용하고, 새로운 메모리 할당을 피하는 것이 중요한지 알게 되는 실험이었습니다.

새로운 의문

흥미로운 점은 Java의 StringBuffer와 달리 C#의 StringBuilder 소요 시간이 0ms라고 출력된 점입니다. 코드 한 줄도 아니고 10만인데 그게 가능한 걸까? 오차는 아닐까? 궁금해졌습니다.

당장, Environment.TickCount를 의심하기 시작했습니다. 사실 TickCount로 코드 속도를 비교하는 시대는 멀티 프로세서 시대의 도래로 종말을 맞았다는 이야기도 있습니다. 코드의 선후 관계는 보존되더라도 코드가 동시에 실행되는 타이밍의 차이는 충분히 있을 수 있다는, 쓰레드 위험이 일상화된 멀티 프로세싱의 시대니까요.

그래서 당장 C# 유저들은 무슨 방법을 쓰는가 알아보았습니다.

  • Environment.TickCount
  • GetTickCount / GetTickCount64
  • DateTime.Now(UtcNow).Ticks
  • Stopwatch
  • static Stopwatch.GetTimestamp

Environment.TickCount

가장 편하게 쓸 수 있는 Environment static class의 멤버 TickCount입니다. 컴퓨터의 UpTime을 보여주는 GetTickCount()와 다를 게 없는 특성으로, 부호 있는 정수(int)인지라, 차를 구할 때 24.9일 이상 켜진 컴퓨터에서는 특별한 조치가 필요합니다. MSDN에서는 Int32.MaxValue와 & 연산을 하여 해결하였습니다. (부호만 제거되겠군요)

GetTickCount64

Vista부터 쓸 수 있는 Win32 API인 GetTickCount64는 부호 없는 long 타입으로 반환합니다. 시스템이 켜진 시간으로부터 출력되는 밀리세컨드 값을 담기에 충분한 용량이죠. 다만, 실제로 놀지 않고 컴퓨터가 일한 시간을 구하려면 QueryUnbiasedInterruptTime을 사용해야 합니다.

DateTime.Now(UtcNow).Ticks

서기 1년 1월 1일 자정부터 100나노세컨드로 지난 시간을 나타냅니다. 윤초 같은 보정은 없다고 합니다.

Stopwatch

Stopwatch는 new 키워드 없이 인스턴스화가 가능한 Static 메소드 StartNew()를 통해 시간을 재기 시작하여 Stop()을 호출한 다음, ElapsedMilliseconds와 같은 속성으로 경과 시간을 측정할 수 있습니다.

  • IsHighResolution 속성에서 현재 컴퓨터의 하드웨어가 별도의 고해상도 성능 카운터 기능이 있는지 알 수 있는데, 현세대 컴퓨터는 대체로 True를 반환하는 것 같습니다.
  • Frequency 속성은 타이머의 틱 주기를 반환합니다.
    long frequency = Stopwatch.Frequency;
        Console.WriteLine("  Timer frequency in ticks per second = {0}",
            frequency);
        long nanosecPerTick = (1000L*1000L*1000L) / frequency;
        Console.WriteLine("  Timer is accurate within {0} nanoseconds", 
            nanosecPerTick);
    • 이와 같은 코드에서 Stopwatch 타이머의 정확도가 나타납니다.
        Timer frequency in ticks per second = 2343750
        Timer is accurate within 426 nanoseconds

static Stopwatch.GetTimestamp

Stopwatch의 인스턴스화 후 계산하는 시간을 아까워한 일부 유저는 GetTimestamp static 메소드의 성능이 더 좋지 않을까 생각하더군요.

  • 고해상도 성능 카운터가 활성화된 상태에선, 현재값을 출력합니다.
  • 일반 시스템 시계를 쓰는 경우, DateTime.Ticks와 동일한 값을 출력합니다.

벤치마킹

Repeating measurement 3 times in loop of 10,000,000:

Measured: GetTickCount64() [ms]: 228
Measured: Environment.TickCount [ms]: 50
Measured: DateTime.UtcNow.Ticks [ms]: 83
Measured: Stopwatch: .ElapsedMilliseconds [ms]: 856
Measured: static Stopwatch.GetTimestamp [ms]: 476
Measured: Stopwatch+conversion to DateTime [ms]:  811

Measured: GetTickCount64() [ms]: 219
Measured: Environment.TickCount [ms]: 50
Measured: DateTime.UtcNow.Ticks [ms]: 84
Measured: Stopwatch: .ElapsedMilliseconds [ms]: 847
Measured: static Stopwatch.GetTimestamp [ms]: 465
Measured: Stopwatch+conversion to DateTime [ms]:  799

Measured: GetTickCount64() [ms]: 235
Measured: Environment.TickCount [ms]: 51
Measured: DateTime.UtcNow.Ticks [ms]: 84
Measured: Stopwatch: .ElapsedMilliseconds [ms]: 851
Measured: static Stopwatch.GetTimestamp [ms]: 470
Measured: Stopwatch+conversion to DateTime [ms]:  791

Compare that with DateTime.Now.Ticks [ms]: 1150

General Stopwatch information:
- Using high-resolution performance counter for Stopwatch class.
- Stopwatch accuracy- ticks per microsecond (1000 ms): 2.3
 (Max. tick resolution normally is 100 nanoseconds, this is 10 ticks/microsecond.)
- Approximated capacity (maxtime) of TickCount [dd:hh:mm:ss] 25:20:31:23

Done.

단일 스레드만 쓰도록 프로세스 설정한 다음, 스레드 우선 순위를 높이고, Stopwatch를 활용하여 1000만 번 각각 호출하는데 걸린 시간입니다.

TickCount가 공통적으로 빠르지 않냐 싶은데, Stopwatch는 ElapsedMilliseconds 표시를 위해 추가로 계산하는 시간도 고려해야 할 듯 합니다.
개인적으로는 Start() Stop()을 반복하는 게 더 의미 있는 행동이 아닌가 싶기도 한데 말이죠.

 

벤치마킹 소스 코드 참고: https://stackoverflow.com/questions/243351/environment-tickcount-vs-datetime-now

결론

GetTickCount는 혼돈과 혼돈입니다. 설마 20여 일 동안 컴퓨터를 켜고 있을 멍청이가 있으리라 생각했을까요. DWORD로 부호 없애봐야 40여 일이군요.

쓰라는 거 써서 큰 이상이 생길 것 같진 않습니다. 하지만, 무엇이 오래 걸리는지 메소드와 클래스별 특징을 잘 알고 있다면, 오래 걸리는 작업을 최대한 회피하거나 미루는 방식으로 퍼포먼스를 더 향상시킬 수 있겠지요.

앞으로도 궁금한 점이 있으면 직접 실험해야겠다 마음 먹게 되는 주제였습니다.

Jinbaek Lee

일본 외노자입니다.

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다