윈도우 운영체제의 시간 함수 (5) - TSC(Time Stamp Counter)와 QueryPerformanceCounter
지난 글에서 말로만 설명한 TSC를 코딩으로도 실습해 보겠습니다. ^^
TSC는 CPU 차원에서 제공되는 카운터로 CPU가 리셋된 이후 동작한 CPU 사이클의 수를 "Time Stamp Counter"라는 64비트 레지스터에 보관하고 rdtsc/rdtscp 기계어로 접근할 수 있습니다.
Visual C++의 경우 이를 위해 __rdtsc 내부 함수를 제공합니다.
__rdtsc (ReaD Time Stamp Counter)
; https://learn.microsoft.com/en-us/cpp/intrinsics/rdtsc
아래는 간단한 사용법입니다.
#include "stdafx.h"
#include <Windows.h>
int main()
{
int count = 10;
while (count -- > 0)
{
printf("%I64d\n", ::__rdtsc());
}
return 0;
}
별로 의미가 없지만 출력 결과는 이렇습니다.
173595388146208
173595388675124
173595388799672
173595389066244
173595389245210
173595389459434
173595389709418
...[생략]...
__rdtsc 외에도 ReadTimeStampCounter 함수를 제공하는데 이는 단순한 매크로 함수에 불과합니다.
#define ReadTimeStampCounter() __rdtsc()
출력 결과에서 본 것처럼, "CPU 사이클의 수"이기 때문에 여러분들의 CPU가 몇 GHz냐에 따라 이 수치를 밀리초 등의 단위로 바꾸는 방법이 달라지게 됩니다.
일단, 정말로 TSC가 "CPU 사이클의 수"인지 다음과 같은 코드로 대략 확인할 수 있습니다.
#include "stdafx.h"
#include <Windows.h>
#include <ctime>
#pragma comment(lib, "winmm.lib")
int main()
{
int count = 100;
timeBeginPeriod(1); // timeGetTime의 정밀도를 1ms로 높이고,
const DWORD msDuration = 1000;
const int iterations = 6;
for (int i = 0; i < iterations; ++i)
{
__int64 rdtscStart = __rdtsc(); // 현재의 cpu cycle 수를 구하고,
DWORD startTick = timeGetTime();
for (;;) // 약 1초 동안 지연,
{
DWORD tickDuration = timeGetTime() - startTick;
if (tickDuration >= msDuration)
{
break;
}
}
__int64 rdtscElapsed = __rdtsc() - rdtscStart; // 1초가 지난 후 cpu cycle 수를 구함
printf(" elapsed: %I64d, %0.4fGHz\n", rdtscElapsed, rdtscElapsed / 1000.0 / 1000 / 1000);
}
timeEndPeriod(1);
return 0;
}
제 컴퓨터에서 출력 결과는 다음과 같습니다.
elapsed: 3408820574, 3.4088GHz
elapsed: 3404523320, 3.4045GHz
elapsed: 3408260224, 3.4083GHz
elapsed: 3407562582, 3.4076GHz
elapsed: 3407761180, 3.4078GHz
elapsed: 3406738008, 3.4067GHz
실제로 제 컴퓨터는 3.4GHz의 i5 CPU인데 대략 비슷한 값을 보이고 있습니다.
이전 글에서도 언급했지만, TSC 레지스터가 cpu cycle의 수를 담고 있기 때문에 이후 Turbo-Boost 기능이 추가된 CPU의 경우 이것이 문제가 됩니다. 실행 중에 동적으로 CPU 사이클이 3.4GHz에서 3.8GHz 등으로 변화할 수 있기 때문에 특정 함수의 성능을 측정하기 위해 사용한 __rdtsc의 반환 값을 어떻게 해석해야 할지 난감하게 된 것입니다.
이런 문제를 다행히 CPU 제조사에서 해결을 해주었는데, 동적으로 변화하는 CPU Cycle 수를 TSC 레지스터에 반영하지 않고 CPU SPEC에 명시된 (제 경우 3.4GHz) 사이클 기준으로 CPU 내부에서 적절하게 변환해 주는 기능을 추가한 것입니다. 이름하여 "Invariant TSC"라고 합니다.
실제로
sysinternals의 coreinfo.exe를 이용해 자신의 CPU에 이런 기능이 적용되었는지 쉽게 확인할 수 있습니다.
C:\>coreinfo
Coreinfo v3.31 - Dump information on system CPU and memory topology
Copyright (C) 2008-2014 Mark Russinovich
Sysinternals - www.sysinternals.com
Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz
Intel64 Family 6 Model 94 Stepping 3, GenuineIntel
Microcode signature: 00000023
...[생략]...
INVPCID - Supports INVPCID instruction
PDCM - Supports Performance Capabilities MSR
RDTSCP * Supports RDTSCP instruction
TSC * Supports RDTSC instruction
TSC-DEADLINE - Local APIC supports one-shot deadline timer
TSC-INVARIANT * TSC runs at constant rate
xTPR * Supports disabling task priority messages
...[생략]...
다음의 글은, "Invariant TSC" 기능이 있는 환경에서 "Turbo Boost" On/Off에 상관없이 TSC의 값이 일관성 있게 나온다는 것을 테스트하고 있습니다.
rdtsc in the Age of Sandybridge
; https://randomascii.wordpress.com/2011/07/29/rdtsc-in-the-age-of-sandybridge/
추가로 coreinfo.exe의 출력 결과 중에 "RDTSCP" 항목을 잠깐 설명해 보겠습니다. 지난 글에서 언급한 것처럼, 명령어의 실행 순서가 보장받지 못하는 환경에서도 rdtsc를 사용하면 값의 왜곡이 발생한다고 했는데요. 이런 경우의 대안으로
직렬화가 수행되는 명령어를 넣어주면 되는데 대표적인 예로 cpuid가 있어서 다음과 같이 한다고 합니다.
unsigned __int64 inline GetRDTSC() {
__asm {
; Flush the pipeline
XOR eax, eax
CPUID
; Get RDTSC counter in edx:eax
RDTSC
}
}
CPU 제조사에서는 이를 하나로 묶는 명령어를 이후 추가하게 되었는데 그것이 바로 rdtscp입니다.
참고로, TSC 레지스터를 읽어들이는 것은 Ring 0 수준에서만 가능하도록 바꿀 수도 있습니다. 물론, Ring 3 수준에서 읽는 것을 막고 싶다면 그것을 수행하는 작업을 대행하는 device driver를 만들면 됩니다.
어쨌든, 대충 살펴보는 것만으로 TSC를 직접 사용하려면 환경에 따라 고려해야 할 것이 많다는 사실이 별로 탐탁지 않으실 것입니다. ^^
이런저런 문제로 인해, 마이크로소프트는 TSC를 직접 사용하는 것을 지양하고 대신 TSC의 단점들을 운영체제 차원에서 보완한
QueryPerformanceCounter를 사용할 것을 권장하고 있으며 문서상으로도
QPC가 다중 코어/프로세서 및 하이퍼스레딩에서조차도 신뢰할 수 있는 값을 제공한다고 명시하고 있습니다.
QueryPerformanceCounter는 TSC와 개념이 비슷하므로 마찬가지로 사용 방법도 간단합니다.
#include "stdafx.h"
#include <Windows.h>
#include <ctime>
#pragma comment(lib, "winmm.lib")
__int64 GetQPCTime()
{
LARGE_INTEGER qpcTime;
QueryPerformanceCounter(&qpcTime);
__int64 t = __rdtsc();
return qpcTime.QuadPart;
}
__int64 GetQPCFreq()
{
LARGE_INTEGER qpcRate;
QueryPerformanceFrequency(&qpcRate);
return qpcRate.QuadPart;
}
int main()
{
timeBeginPeriod(1);
const DWORD msDuration = 1000;
const int iterations = 6;
const __int64 qpcFreqency = GetQPCFreq();
for (int i = 0; i < iterations; ++i)
{
__int64 qpcStart = GetQPCTime();
DWORD startTick = timeGetTime();
for (;;)
{
DWORD tickDuration = timeGetTime() - startTick;
if (tickDuration >= msDuration)
{
break;
}
}
__int64 qpcElapsed = GetQPCTime() - qpcStart;
printf("%I64d / %I64d == %0.4f\n", qpcElapsed, qpcFreqency, ((double)qpcElapsed / qpcFreqency));
}
timeEndPeriod(1);
return 0;
}
(업데이트 2022-04-22: 위의 소스 코드는 이제 다르게 동작합니다. 보다 자세한 사항은 "
Windows 10부터 바뀐 QueryPerformanceFrequency, QueryPerformanceCounter" 글에서 다루므로 참고하세요.)
위의 코드를 수행한 결과는 이렇습니다.
3327949 / 3328129 == 0.9999
3327319 / 3328129 == 0.9998
3329828 / 3328129 == 1.0005
3325995 / 3328129 == 0.9994
3329377 / 3328129 == 1.0004
3325932 / 3328129 == 0.9993
거의 1초에 가까운 지연 시간을 확인할 수 있습니다.
QueryPerformanceFrequency가 반환한 것은 초당 tick 수입니다. 따라서 만약 이 값이 (위의 출력 결과에서는 3,328,129) 3,125,000이라면, 주기 값 T = 1 / 3,125,000 = 0.000000320(320 nanoseconds)가 됩니다. 즉, QueryPerformanceCounter 값의 차이가 1이라면 (이론상) 320나노 초가 지난 시간임을 의미합니다. 당연하겠지만, 이런 시스템에서는 시간값의 정밀도를 320나노 초 이하로 내리는 것은 불가능합니다.
알아본 김에 마지막으로 C++의 clock 함수를 설명해 보겠습니다.
clock
; https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/clock
이 함수 역시 내부적으로는 (윈도우 환경에서라면) QueryPerformanceCounter를 사용하고, 따라서 (대부분의 경우) TSC를 사용하는 것과 다름없습니다. 실제로 Windows SDK가 설치된 경우 다음과 같은 경로를 통해 소스 코드를 보시면 확인할 수 있습니다.
// C:\Program Files (x86)\Windows Kits\10\Source\10.0.10240.0\ucrt\time\clock.cpp
extern "C" clock_t __cdecl clock()
{
if (start_count == -1)
return -1;
LARGE_INTEGER current_count;
if (!QueryPerformanceCounter(¤t_count))
return -1;
long long const result = current_count.QuadPart - start_count;
if (result < 0)
return -1;
long long const scaled_result = scale_count(result);
// Per C11 7.27.2.1 ("The clock function")/3, "If the processor time used...
// cannot be represented, the function returns the value (clock_t)(-1)."
if (scaled_result > LONG_MAX)
return -1;
return static_cast<clock_t>(scaled_result);
}
extern "C" int __cdecl __acrt_initialize_clock()
{
LARGE_INTEGER local_frequency;
LARGE_INTEGER local_start_count;
if (!QueryPerformanceFrequency(&local_frequency) ||
!QueryPerformanceCounter(&local_start_count) ||
local_frequency.QuadPart == 0)
{
source_frequency = -1;
start_count = -1;
return 0;
}
source_frequency = local_frequency.QuadPart;
start_count = local_start_count.QuadPart;
return 0;
}
단지, clock 함수는 1초 당 1000 clocks가 되는 것을 기준으로 QueryPerformanceCounter의 단위를 좀 더 다듬었다고 보시면 됩니다.
// time.h
#define CLOCKS_PER_SEC ((clock_t)1000)
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
참고로, 이번 5부에 걸친 내용은 시스템 하위의 것을 포함하고 있기 때문에 언제든지 deprecated 될 수 있습니다.
실제로, 웹상에 있는 많은 자료들이 초기 TSC 구현의 것을 모델로 설명한 것들이 많아서 이제는 진실이 아닌 것이 되어버렸는데, (일례로 QueryPerformanceCounter는 스레드 친화도를 설정해야 정확한 값이 보장된다는 식) 이 글 역시 시간 앞에서 자유로울 수 없습니다. (음... 그러니까 미리... ^^ 쉴드를 쳐봅니다.)
마지막으로, 정리를 해보면.
- GetTickCount/GetTickCount64는 대충 20ms 정도의 별로 중요하지 않은 시간 간격을 측정하는 용도라면 좋습니다.
- timeGetTime은 1ms 단위의 시간 측정에 좋습니다. 대신 목적에 따라 timeBeginPeriod, timeEndPeriod를 이용해 정밀도를 조정해야 할 필요가 있습니다. (아울러 조정된 정밀도만큼, 전력 소비가 늘어난다는 단점)
- TSC는 높은 신뢰성을 요구하지 않는 환경에서는 정밀함과 속도를 만족합니다.
- QueryPerformanceCounter는 TSC의 단점을 보완하지만, 구형 컴퓨터에 설치된 Windows XP를 고려해야 한다면 timeGetTime을 사용합니다.
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]