Workspace IndexAlgorithms › Day 45

Advanced Profiling — Flame Graphs, perf, and PMU Counters TODO

Algorithms · Day 45 / 100 · C. Concurrency & Performance Engineering (Day 36-51)

Concept

Profiling splits broadly into instrumentation, which hooks into code, and sampling, which periodically samples the running state; perf is a sampling tool that uses the kernel's perf_events to trigger an interrupt on a timer or PMU event overflow and collect the call stack at that moment. A flame graph is a picture built by folding collected stacks that share the same prefix together — the y-axis is stack depth, and the x-axis width is only the share of samples that stack accounted for, not a time axis. So a wide frame should be read as "code that got sampled often, i.e., occupied a lot of CPU," not as "a section that took a long time." The PMU is a set of hardware counters built into the CPU that count events like cycles, instructions, cache-misses, and branch-misses; from these you can compute IPC and tell whether a bottleneck is instruction supply, memory access, or branch misprediction. Stack collection depends on preconditions like maintaining the frame pointer or DWARF unwinding, and a broken stack in an optimized build — which distorts the graph itself — is the most common pitfall in practice.

Optimizing by guesswork usually fixes the wrong spot, and CPU-bound versus memory-bound problems have completely different fixes. Counters and flame graphs force that distinction to be made from data.

Code & Formula

# 프로파일링 심화 — perf 처럼 "일정 주기마다 인터럽트를 걸어 콜스택을 표본화"하는 걸 signal 타이머로 재현한다.
# 플레임그래프의 너비는 "오래 걸린 구간"이 아니라 "표본에서 자주 잡힌 스택(=CPU 를 많이 먹은 코드)"임에 유의.

import signal
from collections import Counter

samples = []  # 각 표본: 인터럽트가 걸린 순간의 콜스택(튜플)

def on_timer_tick(signum, frame):    # perf_events 의 오버플로 인터럽트 핸들러에 해당
    stack = []
    f = frame
    while f is not None:
        stack.append(f.f_code.co_name)
        f = f.f_back
    samples.append(tuple(reversed(stack)))

def cache_miss_heavy(n):        # 캐시 미스가 잦다고 가정한(=CPU 를 오래 점유하는) 함수
    total = 0
    for i in range(n):
        total += i * i
    return total

def branch_miss_heavy(n):       # 분기 예측 실패가 잦다고 가정한 함수
    total = 0
    for i in range(n):
        total += -i if i % 7 == 0 else i
    return total

def workload():
    cache_miss_heavy(3_000_000)   # 실행 시간이 더 긴 쪽 -> 표본에서 더 넓은 프레임을 차지해야 정상
    branch_miss_heavy(500_000)

signal.signal(signal.SIGVTALRM, on_timer_tick)          # CPU(가상) 시간 기준 인터럽트 등록
signal.setitimer(signal.ITIMER_VIRTUAL, 0.001, 0.001)    # 1ms 주기 샘플링 (perf -F 1000 과 같은 아이디어)
try:
    workload()
finally:
    signal.setitimer(signal.ITIMER_VIRTUAL, 0)            # 타이머 해제
    signal.signal(signal.SIGVTALRM, signal.SIG_DFL)

# 플레임그래프 접기(fold): 같은 스택 경로를 하나로 묶어 등장 횟수(=CPU 점유 비율 proxy)를 센다.
folded = Counter(";".join(s) for s in samples if s)
total_samples = sum(folded.values())

print("folded stacks (flamegraph 입력 포맷과 동일한 'stack;stack;...;count'):")
for stack, count in folded.most_common(5):
    pct = count / total_samples * 100 if total_samples else 0
    print(f"  {count:>4} ({pct:4.1f}%)  {stack}")

cache_related = sum(c for s, c in folded.items() if "cache_miss_heavy" in s)
branch_related = sum(c for s, c in folded.items() if "branch_miss_heavy" in s)

print("\ntotal samples captured:", total_samples)
print("cache_miss_heavy 가 차지한 표본 비율:", f"{cache_related / total_samples:.0%}" if total_samples else "n/a")
print("branch_miss_heavy 가 차지한 표본 비율:", f"{branch_related / total_samples:.0%}" if total_samples else "n/a")
print("-> 실행 시간이 더 긴 함수가 더 넓은 프레임(더 많은 표본)을 차지한다:",
      cache_related >= branch_related)

Exercise

Build one program that deliberately causes lots of cache misses through array traversal and another that deliberately causes branch mispredictions, then use perf to measure IPC, cache-misses, and branch-misses on each and see how the two bottlenecks look different in the counters.

Practical Connection

When a Go-based indexer or matching engine hits a throughput ceiling, this is exactly what's used to determine whether the cause is genuine computation volume or the data structure's memory locality.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

프로파일링 심화 TODO

Algorithms · Day 45 / 100 · C. 동시성·성능 엔지니어링 (Day 36–51)

플레임그래프·perf·PMU 카운터

개념

프로파일링은 크게 코드에 훅을 심는 계측(instrumentation)과 주기적으로 실행 상태를 표본화하는 샘플링으로 나뉘고, perf는 커널의 perf_events를 통해 타이머나 PMU 이벤트 오버플로 시점에 인터럽트를 걸어 콜스택을 수집하는 샘플링 도구다. 플레임그래프는 수집된 스택들을 동일 접두사끼리 접어(fold) 그린 그림으로, y축은 스택 깊이이고 x축 너비는 그 스택이 표본에서 차지한 비율일 뿐 시간 축이 아니다. 따라서 넓은 프레임은 '오래 걸린 구간'이 아니라 '표본에서 자주 잡힌, 즉 CPU를 많이 점유한 코드'로 읽어야 한다. PMU는 CPU 내장 하드웨어 카운터로 cycles, instructions, cache-misses, branch-misses 같은 이벤트를 세며, 이로부터 IPC를 계산해 병목이 명령어 공급 쪽인지 메모리 접근 쪽인지 분기 예측 실패 쪽인지 구분할 수 있다. 스택 수집은 frame pointer 유지나 DWARF 언와인딩 같은 전제가 필요해서, 최적화 빌드에서 스택이 끊기면 그래프 자체가 왜곡된다는 점이 실무에서 가장 흔한 함정이다.

추측으로 최적화하면 대개 엉뚱한 곳을 고치게 되고, 특히 CPU 바운드와 메모리 바운드는 해법이 완전히 다르다. 카운터와 플레임그래프는 그 구분을 데이터로 강제한다.

코드 · 수식

# 프로파일링 심화 — perf 처럼 "일정 주기마다 인터럽트를 걸어 콜스택을 표본화"하는 걸 signal 타이머로 재현한다.
# 플레임그래프의 너비는 "오래 걸린 구간"이 아니라 "표본에서 자주 잡힌 스택(=CPU 를 많이 먹은 코드)"임에 유의.

import signal
from collections import Counter

samples = []  # 각 표본: 인터럽트가 걸린 순간의 콜스택(튜플)

def on_timer_tick(signum, frame):    # perf_events 의 오버플로 인터럽트 핸들러에 해당
    stack = []
    f = frame
    while f is not None:
        stack.append(f.f_code.co_name)
        f = f.f_back
    samples.append(tuple(reversed(stack)))

def cache_miss_heavy(n):        # 캐시 미스가 잦다고 가정한(=CPU 를 오래 점유하는) 함수
    total = 0
    for i in range(n):
        total += i * i
    return total

def branch_miss_heavy(n):       # 분기 예측 실패가 잦다고 가정한 함수
    total = 0
    for i in range(n):
        total += -i if i % 7 == 0 else i
    return total

def workload():
    cache_miss_heavy(3_000_000)   # 실행 시간이 더 긴 쪽 -> 표본에서 더 넓은 프레임을 차지해야 정상
    branch_miss_heavy(500_000)

signal.signal(signal.SIGVTALRM, on_timer_tick)          # CPU(가상) 시간 기준 인터럽트 등록
signal.setitimer(signal.ITIMER_VIRTUAL, 0.001, 0.001)    # 1ms 주기 샘플링 (perf -F 1000 과 같은 아이디어)
try:
    workload()
finally:
    signal.setitimer(signal.ITIMER_VIRTUAL, 0)            # 타이머 해제
    signal.signal(signal.SIGVTALRM, signal.SIG_DFL)

# 플레임그래프 접기(fold): 같은 스택 경로를 하나로 묶어 등장 횟수(=CPU 점유 비율 proxy)를 센다.
folded = Counter(";".join(s) for s in samples if s)
total_samples = sum(folded.values())

print("folded stacks (flamegraph 입력 포맷과 동일한 'stack;stack;...;count'):")
for stack, count in folded.most_common(5):
    pct = count / total_samples * 100 if total_samples else 0
    print(f"  {count:>4} ({pct:4.1f}%)  {stack}")

cache_related = sum(c for s, c in folded.items() if "cache_miss_heavy" in s)
branch_related = sum(c for s, c in folded.items() if "branch_miss_heavy" in s)

print("\ntotal samples captured:", total_samples)
print("cache_miss_heavy 가 차지한 표본 비율:", f"{cache_related / total_samples:.0%}" if total_samples else "n/a")
print("branch_miss_heavy 가 차지한 표본 비율:", f"{branch_related / total_samples:.0%}" if total_samples else "n/a")
print("-> 실행 시간이 더 긴 함수가 더 넓은 프레임(더 많은 표본)을 차지한다:",
      cache_related >= branch_related)

연습

의도적으로 캐시 미스가 많은 배열 순회 프로그램과 분기 예측이 실패하는 프로그램을 각각 만들고, perf로 IPC와 cache-misses, branch-misses를 재서 두 병목이 카운터 상에서 어떻게 다르게 보이는지 확인하라.

실무 · Verex 연결

Go로 짠 인덱서나 매칭 엔진이 처리량 한계에 부딪혔을 때, 원인이 실제 계산량인지 자료구조의 메모리 지역성인지 판단하는 데 그대로 쓰인다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 44. 테일 레이턴시46. 벤치마크 방법론 →