시스템이 느려졌을 때 “CPU가 바쁜지” “메모리가 병목인지”를 구분하는 건 생각보다 어렵다. top의 CPU 사용률은 코어가 뭔가는 하고 있다는 것만 보여줄 뿐, 그 사이클이 실제 연산에 쓰였는지 메모리 응답을 기다리며 멈춰 있었는지는 알려주지 않는다. ARM PMU(Performance Monitoring Unit)는 코어 안에서 사이클 단위로 이런 구분을 가능하게 하는 하드웨어 카운터 집합이다. 이 글에서는 ARM PMU로 실제로 모니터링할 수 있는 이벤트 범주를 정리하고, 그중에서도 CPU 실행 효율과 메모리 접근 병목을 구분하는 방법을 perf 예제와 함께 다룬다.
ARM PMU로 모니터링할 수 있는 것들
PMUv3가 정의하는 아키텍처 이벤트는 크게 네 범주로 나눠서 보면 이해하기 쉽다.
- CPU 실행:
CPU_CYCLES,INST_RETIRED(실행된 명령어 수),BR_MIS_PRED(분기 예측 실패),STALL_FRONTEND/STALL_BACKEND(파이프라인이 프론트엔드/백엔드 문제로 멈춘 사이클). - 메모리 계층:
L1D_CACHE/L1D_CACHE_REFILL(L1 데이터 캐시 접근/미스),L2D_CACHE/L2D_CACHE_REFILL,LL_CACHE/LL_CACHE_MISS(라스트레벨 캐시). - 주소 변환:
DTLB_WALK/ITLB_WALK— TLB 미스로 페이지 테이블을 직접 걸어야 했던 횟수. - 버스/메모리 접근:
BUS_ACCESS,MEM_ACCESS_RD— 코어 바깥, 즉 인터커넥트나 메모리 컨트롤러까지 나간 접근을 읽기/쓰기로 구분해서 셀 수 있다.
어떤 이벤트가 실제로 노출되는지는 코어 마이크로아키텍처(Cortex-A72, A76, Neoverse N1 등)마다 다르다. perf list로 지금 쓰는 칩에서 실제 지원하는 이벤트 이름을 먼저 확인하는 게 안전하다.
perf list | grep -iE "l1d|l2d|ll-cache|tlb|stalled|branch"
CPU 성능 관점 — IPC와 스톨 사이클로 병목 구분
가장 기본적인 CPU 효율 지표는 사이클당 명령어 수(IPC, Instructions Per Cycle)다. IPC가 낮다는 건 코어가 매 사이클 일을 못 하고 있다는 뜻인데, 그 이유가 명령어를 못 가져와서(frontend)인지 실행 결과나 메모리 응답을 기다려서(backend)인지는 stalled-cycles-frontend/stalled-cycles-backend로 구분한다.
perf stat -e cycles,instructions,stalled-cycles-frontend,stalled-cycles-backend,branch-misses ./target_program
해석 기준은 단순하다. backend 스톨 비중이 크면 실행 유닛이 데이터(주로 메모리 응답)를 기다리는 상황이고, frontend 스톨 비중이 크면 명령어 캐시 미스나 분기 예측 실패로 파이프라인에 명령어 자체가 못 들어오는 상황이다. Neoverse 서버 칩처럼 이벤트가 더 풍부한 코어에서는 ARM이 만든 topdown-tool로 이 구분을 더 세분화한 계층형(top-down) 분석도 가능한데, 바로 다음 절에서 다룬다.
topdown-tool로 한 단계 더 들어가기 — Neoverse의 2단계 분석
앞서 본 stalled-cycles-frontend/backend 비교는 병목이 “명령어 공급 쪽”인지 “실행/메모리 쪽”인지만 갈라준다. Neoverse 서버 코어처럼 마이크로아키텍처 이벤트가 풍부하게 노출된 칩에서는, ARM이 만든 topdown-tool(Arm Telemetry Solution의 일부)로 그 뒤를 더 파고들 수 있다. 방법론은 2단계로 구성된다.
- 1단계: IPC, frontend 정체율, backend 정체율을 수집해 병목이 어느 범주인지 큰 그림을 확인한다.
- 2단계: 1단계 결과에 따라 세부 지표로 좁힌다. frontend 중심이면 명령어 TLB, L1 명령어 캐시, 분기 예측 지표를, backend 중심이면 데이터 TLB, 메모리 시스템, 데이터 캐시, 명령어 믹스를 살펴본다.
MPKI(1,000개 명령어당 캐시 미스 수)나 미스 비율 같은 지표는 계산식을 직접 세울 필요 없이 도구가 내장해서 바로 보여준다.
설치
AArch64 리눅스, Python 3.7 이상, Linux perf가 필요하다.
sudo apt update
sudo apt install python-is-python3 python3-pip python3-venv \
python3-packaging linux-tools-generic linux-tools-$(uname -r) -y
git clone https://git.gitlab.arm.com/telemetry-solution/telemetry-solution.git
python -m venv topdown-venv
source topdown-venv/bin/activate
cd telemetry-solution/tools/topdown_tool
pip install -e .
topdown-tool --help
지표 그룹별 실행
-m 옵션으로 지표 그룹을 지정하고 --cpu로 대상 코어를 지정한다. 1단계에는 General/Cycle_Accounting을, 2단계에는 병목 방향에 맞는 그룹을 쓴다.
| 목적 | 명령어 |
|---|---|
| 1단계 — 전반적 지표(IPC 등) | topdown-tool --cpu neoverse-n1 -m General ./stride |
| 1단계 — 사이클 회계(frontend/backend 정체율) | topdown-tool --cpu neoverse-n1 -m Cycle_Accounting ./stride |
| 2단계 — L1 데이터 캐시 | topdown-tool --cpu neoverse-n1 -m L1D_Cache_Effectiveness ./stride |
| 2단계 — L2 캐시 | topdown-tool --cpu neoverse-n1 -m L2_Cache_Effectiveness ./stride |
| 2단계 — 라스트레벨 캐시 | topdown-tool --cpu neoverse-n1 -m LL_Cache_Effectiveness ./stride |
| 2단계 — 명령어 믹스 | topdown-tool --cpu neoverse-n1 -m Operation_Mix ./stride |
| 2단계 — 분기 예측 | topdown-tool --cpu neoverse-n1 -m Branch_Effectiveness ./stride |
| 2단계 — 명령어 TLB | topdown-tool --cpu neoverse-n1 -m ITLB_Effectiveness ./stride |
| 2단계 — 데이터 TLB | topdown-tool --cpu neoverse-n1 -m DTLB_Effectiveness ./stride |
실제 최적화 사례 — 소프트웨어 프리페칭
ARM이 이 방법론 예제로 쓰는 stride 애플리케이션은 처음에는 backend 정체가 지배적으로 나온다. 2단계에서 데이터 TLB와 메모리 시스템 지표를 확인해 원인을 좁힌 뒤, 컴파일 시점에 소프트웨어 프리페치 힌트를 넣는 방식으로 최적화했다.
g++ -g -O3 -DENABLE_PREFETCH -DDIST=100 stride.cpp -o stride
DIST는 몇 번째 다음 접근을 미리 당겨올지 정하는 프리페치 거리인데, 이 예제에서는 40 이상에서 효과가 포화되고 100을 최종값으로 썼다. 최적화 전후 지표 비교는 다음과 같다.
| 지표 | 최적화 전 | 최적화 후 |
|---|---|---|
| 명령어 수 | 100억 | 140억 |
| 사이클 | 450억 | 220억 |
| IPC | 0.22 | 0.63 |
| 실행 시간 | 20.1초 | 9.9초 |
프리페치를 넣느라 실제로 실행되는 명령어 수는 늘었지만(100억 → 140억), 그 대신 메모리 응답을 기다리며 멈춰 있던 사이클이 크게 줄어 IPC가 약 3배 뛰고 실행 시간은 절반 가까이 줄었다. backend 정체 → 데이터 TLB/메모리 시스템 지표 확인 → 프리페칭이라는 흐름 자체가 앞서 설명한 2단계 방법론을 그대로 따른 결과다. 다만 이런 개선 폭은 메모리 대역폭이 넉넉한 서버급 시스템에서는 더 작게 나올 수 있다는 점은 감안해야 한다.
메모리 성능 관점 — 캐시 미스율과 TLB 워크
backend 스톨이 높게 나왔다면 다음 확인할 건 캐시 계층이다. L1 캐시부터 라스트레벨 캐시까지 미스율을 단계별로 보면 어느 레벨에서 데이터가 새는지 알 수 있다.
perf stat -e L1-dcache-loads,L1-dcache-load-misses,cache-references,cache-misses,dTLB-loads,dTLB-load-misses ./target_program
미스율은 각각 다음과 같이 계산한다.
- L1 캐시 미스율 =
L1-dcache-load-misses/L1-dcache-loads× 100 - 라스트레벨 캐시 미스율 =
cache-misses/cache-references× 100 - DTLB 미스율 =
dTLB-load-misses/dTLB-loads× 100
L1 미스율은 낮은데 라스트레벨 캐시 미스율이 높다면 워킹 셋이 L1/L2에는 안 들어가지만 그래도 온칩 캐시로는 커버되는 상태고, 라스트레벨 캐시 미스율까지 높다면 실제 DRAM까지 나가는 빈도가 잦다는 뜻이라 메모리 접근 패턴(순차 접근으로 바꾸기, 데이터 구조 재배치 등)을 손봐야 할 신호다. DTLB 미스율이 유독 높다면 메모리 영역이 너무 넓게 흩어져 있다는 뜻이라 huge page 적용을 검토해볼 만하다.
실전 예제 — CPU 바운드 vs 메모리 바운드 비교
같은 반복 횟수라도 접근 패턴에 따라 PMU 카운터가 완전히 다르게 나온다는 걸 직접 비교해보면 위 지표들이 바로 와닿는다. 먼저 캐시에 우호적인 연산 위주 코드다.
#include <stdio.h>
int main(void) {
volatile long sum = 0;
for (long i = 0; i < 500000000L; i++) {
sum += i * 3 - (i >> 2);
}
printf("sum=%ld\n", sum);
return 0;
}다음은 캐시 라인 크기(64바이트)보다 훨씬 넓게 흩어진 주소를 무작위로 접근해 매번 캐시 미스를 유도하는 코드다.
#include <stdio.h>
#include <stdlib.h>
#define SIZE (256 * 1024 * 1024) // 256MB, LLC보다 훨씬 크게
int main(void) {
char *buf = malloc(SIZE);
volatile long sum = 0;
unsigned int seed = 12345;
for (long i = 0; i < 20000000L; i++) {
seed = seed * 1103515245 + 12345;
long idx = (seed >> 4) % SIZE;
sum += buf[idx];
}
printf("sum=%ld\n", sum);
free(buf);
return 0;
}두 프로그램을 각각 컴파일해서 같은 이벤트 조합으로 측정한다.
gcc -O2 -o cpu_bound cpu_bound.c
gcc -O2 -o memory_bound memory_bound.c
perf stat -e cycles,instructions,stalled-cycles-backend,cache-references,cache-misses ./cpu_bound
perf stat -e cycles,instructions,stalled-cycles-backend,cache-references,cache-misses ./memory_bound
정확한 수치는 코어 종류와 클럭에 따라 달라지지만, 경향은 뚜렷하게 갈린다. cpu_bound는 IPC(instructions/cycles)가 1에 가깝거나 그 이상으로 높고 cache-misses는 거의 0에 수렴한다. memory_bound는 반대로 stalled-cycles-backend 비중이 전체 사이클의 상당 부분을 차지하고 cache-misses가 cache-references에 근접할 만큼 치솟는다 — 무작위 접근이라 사실상 매번 미스가 나기 때문이다. 같은 반복 로직이라도 메모리 접근 패턴 하나로 PMU 카운터가 이렇게 갈린다는 걸 직접 확인하는 게 이 실습의 핵심이다.
시스템 전체 모니터링
지금까지는 프로세스 하나를 기준으로 봤지만, 시스템 전체를 주기적으로 관찰하고 싶을 때는 -a(전체 CPU)와 -I(간격, 밀리초) 옵션을 함께 쓴다.
# 전체 시스템에서 1초 간격으로 계속 출력
sudo perf stat -a -I 1000 -e cycles,instructions,cache-misses
특정 cgroup만 골라서 보고 싶다면(컨테이너 하나만 격리해서 확인하는 경우 등) -G 옵션으로 cgroup을 지정할 수 있다.
sudo perf stat -e cycles,cache-misses -G my_container.slice -a -I 1000
주의사항
- PMU가 동시에 지원하는 하드웨어 카운터 개수는 코어 구현마다 다르지만 보통 6개 안팎이다. 이벤트를 그보다 많이 한꺼번에 넣으면 커널이 시분할로 번갈아 측정(멀티플렉싱)하면서 추정치로 채우기 때문에 정확도가 떨어진다 —
perf stat출력의 이벤트별(XX.XX%)표시가 실제 측정된 시간 비율이다. - 이벤트 이름과 지원 여부는 마이크로아키텍처마다 달라서, 예제의 이벤트명이 특정 보드에서는
perf list에 안 잡힐 수 있다. 그 경우 비슷한 뜻의 다른 이름(예:l2d_cache_refill계열)이 있는지부터 찾아봐야 한다. - 비root 사용자로 측정이 막히면
perf_event_paranoid값을 확인해야 한다(cat /proc/sys/kernel/perf_event_paranoid로 확인 후sudo sysctl kernel.perf_event_paranoid=1로 완화).
마무리
ARM PMU는 CPU 실행 효율(IPC, 스톨 사이클)과 메모리 접근 병목(캐시 미스율, TLB 워크)을 같은 하드웨어 카운터 체계 안에서 함께 볼 수 있게 해준다. “느리다”는 증상 하나를 두고 막연히 코드를 고치기보다는, backend 스톨 비중과 캐시 미스율부터 순서대로 확인하면 CPU 연산을 줄여야 하는 문제인지 메모리 접근 패턴을 바꿔야 하는 문제인지 빠르게 갈릴 수 있다.
참고
- Learn the Arm Neoverse N1 performance analysis methodology (Arm Learning Paths)
- Install topdown-tool — Arm Learning Paths
- Optimize the application — stride 예제 소프트웨어 프리페칭 사례 (Arm Learning Paths)
- Top-Down Analysis — perf: Linux profiling with performance counters
- drivers/perf/arm_pmuv3.c (Linux kernel source)
- perf-stat(1) — Linux manual page