정밀 시간 측정(precise time measurement)이란 코드 구간의 실행 시간을 나노초, 필요하다면 CPU 사이클 단위까지 재현 가능하게 재는 기법을 말합니다. µs 단위 최적화를 다루는 작업에서는 이 측정 자체가 흔들리면 그 위에서 내리는 모든 판단이 흔들립니다. 측정 도구의 오버헤드가 측정 대상보다 크거나, 시계 소스가 코어 이동이나 주파수 변화에 영향을 받는다는 사실을 모르고 결과를 해석하면, 실제로는 존재하지 않는 회귀나 개선을 “발견"하게 됩니다. 이 장은 RDTSC/RDTSCP로 사이클을 직접 읽는 방법과 clock_gettime으로 대표되는 OS 표준 시계 API를 나란히 놓고, 어떤 상황에 어떤 도구를 쓰고 무엇을 주의해야 하는지를 정리합니다.
이 장을 읽기 전에
선행 챕터: 이 장은 Realtime 스케줄링(챕터 05)에서 다룬 “스케줄링 정책이 지연 분포를 바꾼다"는 관점을 전제로, 이번에는 그 변화를 어떻게 정확히 잴 것인가로 초점을 옮깁니다. CPU 클럭·사이클이라는 개념과 시스템 콜이 사용자 공간과 커널 공간을 오간다는 사실만 알면 충분하고, syscall 자체의 비용 구조는 챕터 02: Syscall 비용과 최소화 기법에서 이미 다뤘으므로 여기서는 반복하지 않습니다.
이 장의 깊이: 중급 수준으로, RDTSC/RDTSCP의 동작 원리와 직렬화 문제, clock_gettime의 clock id별 의미와 vDSO 경로, invariant TSC와 코어 간 동기화, 그리고 이 셋을 섞어 쓸 때 생기는 함정을 다룹니다. 다루지 않는 것: 스레드를 특정 코어에 고정하는 방법 자체는 챕터 03: CPU Pinning/Affinity 전략에, NUMA 환경에서의 스레드 배치는 챕터 04: NUMA CPU Affinity·스레드 배치에 위임합니다. out-of-order 실행이 명령어 순서를 어떻게 재배치하는지의 마이크로아키텍처 내부 동작은 Tr.05 CPU 파이프라인 기초를 참고하고, 퍼블릭 클라우드에서 TSC가 신뢰할 수 없어지는 상황(steal time, live migration)은 챕터 18: 클라우드 환경 꼬리 지연에서 별도로 다룹니다.
당신의 수준에 맞는 경로
| 수준 | 읽을 부분 | 핵심 목표 |
|---|---|---|
| 초보자 | “RDTSC의 등장과 clock_gettime의 표준화” ~ “clock_gettime과 CLOCK_MONOTONIC 계열” | 두 측정 수단이 왜 나뉘어 존재하는지, 기본 API가 무엇인지 이해 |
| 중급자 | “Invariant TSC와 TSC 동기화” ~ “흔한 오개념 교정” | 코어 간 TSC 동기화 조건과 자주 틀리는 지점을 구분 |
| 전문가 | “판단 기준” ~ “비판적 시각” | 상황별 시계 선택과 크로스 플랫폼·가상화 환경의 한계 판단 |
RDTSC의 등장과 clock_gettime의 표준화 (역사·배경)
RDTSC(Read Time-Stamp Counter)는 Intel Pentium 세대부터 모든 x86 프로세서에 존재해 온 명령어로, 프로세서가 리셋된 이후 누적된 클럭 사이클 수를 64비트 값으로 반환합니다. 초기 TSC는 실제 코어 클럭에 그대로 연동되어 있었기 때문에, SpeedStep 같은 전력 관리 기능으로 클럭이 바뀌면 “사이클 수 = 경과 시간"이라는 전제가 깨졌습니다. Intel은 Nehalem(2008년)부터, AMD는 Family 10h(Barcelona/Phenom, 2008년경)부터 P-state·C-state와 무관하게 일정한 속도로 증가하는 invariant TSC를 도입해 이 문제를 해결했습니다. 같은 2008년, 측정 도중 스레드가 다른 코어로 이주했는지 원자적으로 감지할 수 있도록 RDTSCP가 Intel Nehalem과 AMD Barcelona에 함께 추가되었습니다. 반면 clock_gettime은 CPU 세대와 무관하게 OS가 제공하는 표준 API입니다. IEEE POSIX 실시간 확장에서 유래해 리눅스에서는 커널 2.6대에 정착했고, 이후 vDSO(virtual dynamic shared object) 경로가 추가되면서 대부분의 clock id에서 실제 시스템 콜 없이 값을 반환할 수 있게 되었습니다. 즉 RDTSC 계열은 “하드웨어 카운터를 직접 읽는” 접근이고, clock_gettime은 “OS가 여러 시계 소스를 추상화해 표준 단위(초·나노초)로 돌려주는” 접근이라는 점에서 출발선이 다릅니다.
RDTSC와 RDTSCP: 사이클 단위로 시간을 읽는다는 것
RDTSC는 비직렬화(non-serializing) 명령어입니다. CPU는 이전 명령어들이 모두 실행 완료되기를 기다리지 않고, RDTSC 자체도 이후 명령어보다 먼저 실행된다는 보장이 없습니다. out-of-order 실행 엔진 입장에서는 RDTSC도 그저 재배치 가능한 명령어 중 하나이기 때문에, 측정 구간 앞뒤에 아무 조치도 하지 않으면 실제로 측정하려던 코드보다 먼저 혹은 나중에 카운터를 읽어버릴 수 있습니다. 이를 막으려면 RDTSC 앞에 LFENCE를 두어 이전 명령어의 로드가 끝난 뒤에만 읽게 하거나, RDTSCP를 쓰면 됩니다. RDTSCP는 완전한 직렬화 명령어는 아니지만 “이전의 모든 명령어가 실행되고 이전의 모든 로드가 전역적으로 보이게 될 때까지” 대기한 뒤에 카운터를 읽으므로, 측정 시작·종료 지점에서 순서를 지키는 용도로는 충분합니다. RDTSCP는 여기에 더해 IA32_TSC_AUX 레지스터 값을 함께 반환하는데, OS가 이 레지스터에 코어 또는 소켓 식별자를 넣어 두면 측정 시작과 종료 시점의 aux 값을 비교해 그 사이 스레드가 다른 코어로 이주했는지 알 수 있습니다. 코어가 바뀌었다면 그 측정값은 버리는 것이 안전한데, 코어마다 TSC 오프셋이 미세하게 다를 수 있기 때문입니다.
| |
이렇게 얻은 값은 “사이클 수"이지 “시간"이 아닙니다. 사이클을 나노초로 바꾸려면 TSC 주파수를 알아야 하는데, invariant TSC가 있는 CPU에서도 이 주파수는 CPUID나 모델별 MSR에서 직접 구하거나, 아래처럼 clock_gettime을 기준 삼아 한 번 보정(calibration)해서 얻는 경우가 흔합니다.
| |
보정 대기 시간이 너무 짧으면(수 ms 이하) clock_gettime 자체의 해상도·잡음이 보정 오차로 그대로 들어가므로, 실무에서는 수백 ms 이상 대기하거나 여러 번 측정해 중앙값을 쓰는 편이 안전합니다. “RDTSCP가 clock_gettime보다 빠르다"는 주장을 그대로 받아들이기보다, 대상 환경에서 두 방식의 호출 자체에 드는 비용을 직접 격리해 재는 것이 안전합니다. 아래는 Google Benchmark로 두 호출의 순수 오버헤드만 비교하는 스켈레톤입니다.
| |
g++ -O2 bench.cpp -lbenchmark -lpthread로 빌드해 x86-64 Linux, GCC 13 환경에서 돌리면(TSC clocksource가 활성화된 경우) BM_Rdtscp가 BM_ClockGettimeMonotonic보다 대체로 몇 배 빠르게 나오는 경우가 흔하지만, VVAR 읽기·시퀀스 락 검증 비용이 더해지는 clock_gettime 쪽도 진짜 시스템 콜과는 자릿수가 다르게 작습니다. 정확한 배율은 CPU 세대·커널 버전·가상화 여부에 따라 달라지므로 반드시 대상 환경에서 재현합니다.
clock_gettime과 CLOCK_MONOTONIC 계열
clock_gettime(clockid_t clk_id, struct timespec *tp)는 초·나노초 두 필드로 이루어진 timespec을 채워 반환하는 POSIX 표준 API입니다. 자주 쓰는 clock id 중 CLOCK_REALTIME은 벽시계 시간이라 시스템 시각이 조정되면 값이 앞뒤로 튈 수 있고, CLOCK_MONOTONIC은 임의의 시작점부터 단조 증가하지만 NTP가 시스템 클럭 속도를 미세 조정(slewing)하는 영향은 받을 수 있어 완전히 “생 하드웨어” 값은 아닙니다. CLOCK_MONOTONIC_RAW는 이 주파수 보정의 영향을 받지 않는 하드웨어 기반 값을 제공하는 리눅스 전용 clock id로, 리눅스 2.6.28부터 존재했지만 vDSO 경로로 가속된 것은 리눅스 5.3부터입니다. 즉 그 이전 커널에서 CLOCK_MONOTONIC_RAW를 쓰면 매 호출이 실제 시스템 콜로 빠졌다는 뜻이므로, 커널 버전이 낮은 환경에서는 CLOCK_MONOTONIC보다 훨씬 느릴 수 있습니다. 구간 실행 시간 측정에는 대체로 CLOCK_MONOTONIC이 기본 선택이고, 스레드·프로세스별 CPU 점유 시간이 필요하면 CLOCK_THREAD_CPUTIME_ID/CLOCK_PROCESS_CPUTIME_ID를 쓰되 이 값들은 대개 vDSO 경로가 없어 진짜 시스템 콜입니다.
| |
리눅스에서 clock_gettime(CLOCK_MONOTONIC, ...)을 호출하면 glibc는 대부분 실제 시스템 콜로 커널에 진입하지 않고, 커널이 매 프로세스에 매핑해 둔 읽기 전용 VVAR 페이지에서 시퀀스 카운터와 배율·오프셋을 읽은 뒤 TSC 값을 조합해 나노초로 변환합니다. TSC 기반 clocksource를 쓸 수 없는 상황(오래된 하드웨어, 일부 가상화 환경)에서만 이 vDSO 경로가 진짜 시스템 콜로 폴백합니다.
flowchart TD
app["애플리케이션 코드"] --> choice{"어떤 API로 시간을 읽는가"}
choice -->|"RDTSC/RDTSCP"| cpuReg["CPU 레지스터 직접 읽기(사용자 공간, 링 3)"]
choice -->|"clock_gettime"| vdso["glibc → vDSO 함수 호출"]
vdso --> vdsoCheck{"TSC 기반clocksource인가"}
vdsoCheck -->|"예"| vvarRead["VVAR 페이지 읽기 +TSC 값 조합·변환"]
vdsoCheck -->|"아니오"| syscallFallback["실제 syscall(커널 진입)"]
cpuReg --> calib["보정 계수로ns 변환 필요"]
vvarRead --> nsResult["나노초 단위 결과"]
syscallFallback --> nsResult
calib --> nsResult
Invariant TSC와 TSC 동기화
invariant TSC는 CPUID.80000007H:EDX 비트 8로 광고되며, 리눅스는 이를 확인해 X86_FEATURE_CONSTANT_TSC·X86_FEATURE_NONSTOP_TSC 기능 플래그를 켜고 /proc/cpuinfo에 constant_tsc, nonstop_tsc 문자열로 노출합니다. 이 플래그가 있다는 것은 “이 코어의 TSC가 P-state·C-state와 무관하게 일정한 속도로 증가한다"는 뜻일 뿐, 다른 코어·다른 소켓의 TSC와 값이 맞춰져 있다는 뜻은 아닙니다. 여러 CPU의 TSC가 동기화되어 있으리라는 구조적 보장은 원래 없으며, 부팅 시점에 BIOS/펌웨어가 각 코어의 카운터를 리셋해 맞추고 리눅스 커널이 check_tsc_sync_source류 검사로 코어 간 오프셋을 확인하는 절차를 거칩니다. 이 검사에서 허용 범위를 넘는 skew가 발견되면 커널은 TSC를 신뢰할 수 없는 clocksource로 표시하고 HPET 등으로 대체합니다. 현재 시스템이 어떤 clocksource를 쓰고 있는지는 다음처럼 직접 확인할 수 있습니다.
| |
현재 clocksource가 tsc가 아니라면(예: hpet), 그 시스템에서는 clock_gettime의 vDSO 고속 경로 자체가 비활성화되어 있다는 뜻이고, RDTSC를 직접 읽는 값도 코어 간 비교에 안전하지 않다는 신호로 받아들여야 합니다. 가상화 환경에서는 하이퍼바이저가 RDTSC/RDTSCP 트래핑 여부와 오프셋 필드를 별도로 관리하며, 라이브 마이그레이션처럼 물리적으로 다른 머신으로 옮겨가는 상황까지 얽히면 문제가 한층 복잡해지는데, 이 클라우드 특유의 조건은 챕터 18에서 다룹니다.
흔한 오개념 교정
“invariant TSC가 있으면 모든 코어의 TSC 값을 그냥 빼서 비교해도 된다”는 틀린 전제입니다. invariant는 “이 코어에서 속도가 일정하다"는 보장이지 “모든 코어가 같은 원점에서 같은 속도로 간다"는 보장이 아닙니다. 단일 소켓 최신 서버에서는 실무적으로 잘 맞는 경우가 많지만, 멀티소켓 구성이나 오래된 하드웨어, 일부 가상화 환경에서는 코어 간 오프셋이 벌어질 수 있으므로 RDTSCP의 aux 값으로 코어 이주를 감지하거나 애초에 스레드를 한 코어에 고정하는 편이 안전합니다.
“clock_gettime은 시스템 콜이니 RDTSC보다 훨씬 느리다”도 절반만 맞습니다. TSC 기반 clocksource가 활성화된 현대 리눅스에서 CLOCK_MONOTONIC은 vDSO 경로를 타므로 실제 커널 진입 없이 사용자 공간에서 끝납니다. VVAR 페이지 읽기와 시퀀스 락 검증 때문에 RDTSCP 단독 호출보다는 비용이 더 들지만, 컨텍스트 스위치를 동반하는 진짜 시스템 콜과는 성격이 다릅니다. 다만 CLOCK_MONOTONIC_RAW는 리눅스 5.3 이전 커널에서, 혹은 TSC를 신뢰할 수 없는 환경에서는 실제로 시스템 콜로 떨어질 수 있다는 점은 구분해야 합니다.
“std::chrono::high_resolution_clock을 쓰면 가장 정밀하고 항상 단조롭다”는 표준이 보장하지 않는 내용입니다. C++ 표준은 이 시계를 “구현체가 제공하는 가장 짧은 틱 주기의 시계"로만 정의할 뿐, system_clock의 별칭이어도 되고 steady_clock의 별칭이어도 됩니다. 실제로 libstdc++ 계열은 high_resolution_clock을 system_clock에 별칭 지정하는 경우가 흔해 is_steady가 false가 될 수 있고, 이 경우 시스템 시각 조정에 따라 측정값이 거꾸로 갈 수도 있습니다. 구간 측정 목적이라면 이름의 인상과 무관하게 std::chrono::steady_clock을 기본으로 선택하는 편이 안전합니다.
판단 기준: 언제 어떤 시계를 쓸 것인가
| 상황 | 권장 | 비권장 |
|---|---|---|
| 수십–수백 사이클 단위의 초미세 구간 측정 | RDTSCP + 보정 계수 + 코어 고정 | 보정 없는 RDTSC 값을 시간처럼 취급 |
| 일반적인 함수/구간 실행 시간 측정 | clock_gettime(CLOCK_MONOTONIC) 또는 std::chrono::steady_clock | high_resolution_clock을 무조건 신뢰 |
| 로그에 남길 실제 시각(벽시계) | CLOCK_REALTIME / std::chrono::system_clock | 시각 조정 영향을 받는 걸 모르고 구간 계산에 사용 |
| NTP 주파수 보정 영향까지 배제하려는 정밀 비교 | CLOCK_MONOTONIC_RAW (커널 5.3+ 확인 후) | 구형 커널에서 그대로 사용해 매번 syscall 유발 |
| 스레드·프로세스별 CPU 점유 시간 | CLOCK_THREAD_CPUTIME_ID/CLOCK_PROCESS_CPUTIME_ID | 벽시계 델타로 CPU 사용량을 추정 |
| 멀티소켓·가상화·구형 하드웨어 | 코어 이주 감지(RDTSCP aux) 또는 clock_gettime로 폴백 | invariant TSC만 믿고 코어 간 직접 비교 |
자주 하는 실수
- RDTSC 앞뒤에 직렬화 장치 없이 값을 읽는 것: out-of-order 실행이 측정 구간 경계를 흐트러뜨립니다.
RDTSCP를 쓰거나LFENCE를 명시적으로 넣습니다. - 워밍업 없이 첫 호출을 측정에 포함하는 것: vDSO 매핑이나 캐시가 아직 데워지지 않은 첫 호출은 이상치가 되기 쉬우므로 버립니다.
- 보정 계수를 한 번 구해 영구 상수로 박아 두는 것: invariant TSC가 없는 CPU나 가상화 환경에서는 이 값이 바뀔 수 있으므로 장시간 실행되는 프로세스라면 주기적으로 재보정합니다.
- 디버그 빌드나 최적화가 꺼진 바이너리로 오버헤드를 측정하는 것: 컴파일러 플래그가 다르면 인라인 여부와 함께 오버헤드 수치 자체가 달라집니다.
비판적 시각: 한계와 트레이드오프
RDTSC/RDTSCP를 직접 읽는 접근은 x86 계열에 강하게 종속적입니다. ARM에는 CNTVCT_EL0 같은 유사한 카운터가 있지만 레지스터 이름과 접근 방식, 직렬화 규칙이 다르므로 x86 코드를 그대로 재사용할 수 없고, 이식성을 조금이라도 신경 쓴다면 그 대가로 OS 표준 API를 우회한 만큼의 유지보수 부담을 떠안게 됩니다. 코어 고정과 invariant TSC 확인 같은 전제 조건을 매번 검증하지 않으면, 클라우드 인스턴스를 옮기거나 커널을 업그레이드했을 때 조용히 잘못된 결과를 내는 코드가 되기 쉽습니다. 반대로 clock_gettime/std::chrono만 고집하면 이식성과 안전성은 얻지만, vDSO 경로가 비활성화된 환경(오래된 커널, 일부 가상화·컨테이너 설정)에서는 매 호출이 진짜 시스템 콜이 되어 나노초 단위보다 미세한 구간을 측정하려는 목적 자체가 무너질 수 있습니다. 실무에서는 “평상시에는 OS 표준 API로 충분한지 먼저 확인하고, 프로파일링으로 그 오버헤드 자체가 병목이라는 근거가 나온 뒤에만 RDTSC 직접 접근으로 내려간다"는 순서가 합리적이라는 시각이 우세하지만, 이 판단 기준 자체도 하드웨어 세대와 커널 버전에 따라 달라지므로 대상 환경에서 반드시 재검증해야 합니다.
마무리
- RDTSC가 비직렬화 명령어라는 점과,
RDTSCP/LFENCE로 순서를 강제하는 이유를 설명할 수 있다. -
CLOCK_REALTIME,CLOCK_MONOTONIC,CLOCK_MONOTONIC_RAW의 차이와 NTP 보정의 영향 범위를 구분할 수 있다. - invariant TSC가 “속도 일정"을 보장할 뿐 “코어 간 동기화"를 보장하지 않는다는 점을 설명할 수 있다.
-
high_resolution_clock이 항상 steady하지 않을 수 있다는 점을 알고 구간 측정에는steady_clock을 우선 고려할 수 있다. - 상황별로 RDTSCP·clock_gettime·std::chrono 중 무엇을 선택할지 판단 기준 표로 결정할 수 있다.
이전 장: Realtime 스케줄링 (챕터 05)
다음 장에서는 커널 바이패스를 다룹니다. 정밀하게 측정하는 법을 갖췄으니, 이제 그 측정으로 확인할 대상 중 하나인 “커널을 거치지 않고 데이터 경로를 단축한다"는 접근의 범위와 한계를 개요 수준에서 살펴봅니다.
→ 커널 바이패스 개요 (챕터 07)
![Featured image of post [OS 06] 정밀 시간 측정](/post/os-optimization/precise-time-measurement-rdtsc-clock-gettime/wordcloud_hu_6bfdc832881ada60.webp)
![[OS 06] NUMA CPU Affinity·스레드 배치](/post/os-optimization/numa-cpu-affinity-thread-placement/wordcloud_hu_27980902404e7114.webp)
![[OS 06] Realtime 스케줄링](/post/os-optimization/realtime-scheduling-sched-ext-eevdf/wordcloud_hu_4bea26addda21753.webp)
![[OS 06] 정밀 시간 측정](/post/os-optimization/precise-time-measurement-rdtsc-clock-gettime/wordcloud_hu_880594b44c30cd79.webp)
![[OS 06] 커널 바이패스 개요](/post/os-optimization/kernel-bypass-overview/wordcloud_hu_592d027bde39b991.webp)
![[OS 06] io_uring 개요](/post/os-optimization/io-uring-overview-fundamentals/wordcloud_hu_f460bb626e150c9c.webp)
![[IO 09] Memory-mapped I/O](/post/io-optimization/memory-mapped-io-mmap-usage-pitfalls/wordcloud_hu_90e9960d7992e6aa.webp)
![[OS 06] Syscall 비용과 최소화 기법](/post/os-optimization/syscall-cost-minimization/wordcloud_hu_eaaaec6f806e1dbe.webp)
![[Rust] Comprehensive Rust 무료 강의 정리 및 코스 구조](/post/2022-12-30-comprehensive-rust/wordcloud_hu_d1420ff38434cdb6.webp)
![[Memory 04] NUMA 메모리 할당·지역성](/post/memory-optimization/numa-memory-allocation-locality/wordcloud_hu_53b01927857b8220.webp)