Workspace IndexAlgorithms › Day 23

Inlining, Loop Transformations, and Auto-Vectorization TODO

Algorithms · Day 23 / 100 · B. Compilers, Runtimes & VMs (Day 20-35)

Concept

Inlining is the transformation that replaces a function call with the callee's body; the bigger payoff isn't removing call overhead itself but eliminating the call boundary, which unlocks follow-on optimizations like constant propagation and dead-code elimination. The trade-off is larger code size and more instruction-cache pressure, so compilers decide whether to inline using heuristics based on function size and call frequency. Loop transformations include unrolling, loop-invariant code motion (LICM), loop interchange, fusion and fission, and tiling; their common goal is to improve locality and instruction-level parallelism without breaking data dependencies. Auto-vectorization is the transformation that packs loop iterations with no cross-iteration dependencies into SIMD instructions, but it frequently fails because of possible pointer aliasing, irregular control flow, and the constraint that floating-point associativity must not be changed. So whether vectorization actually happened shouldn't be guessed — it needs to be confirmed from the compiler's optimization report or the generated assembly.

When a hot loop doesn't speed up as expected, the cause is usually not the algorithm but vectorization failing to kick in, or inlining being blocked so that every downstream optimization falls through.

Code & Formula

# 인라이닝·루프 변환·자동 벡터화 — LICM(불변식 끌어올리기)과 루프 언롤링이 연산 횟수를 어떻게 줄이는지 센다.

def naive_scale_shift(xs, a, b):
    ops = 0
    out = []
    for x in xs:
        invariant = a * b + 1     # 매 반복 다시 계산됨 (루프 불변식인데 안 끌어올림)
        ops += 2
        out.append(x + invariant)
        ops += 1
    return out, ops

def licm_scale_shift(xs, a, b):
    ops = 0
    invariant = a * b + 1         # 루프 밖으로 한 번만 끌어올림
    ops += 2
    out = []
    for x in xs:
        out.append(x + invariant)
        ops += 1
    return out, ops

def unrolled_sum(xs, factor=4):
    # 루프를 factor개씩 묶어 반복 증분/조건 검사 오버헤드를 줄인다 (unrolling)
    total, n, i, iterations = 0, len(xs), 0, 0
    while i + factor <= n:
        total += xs[i] + xs[i + 1] + xs[i + 2] + xs[i + 3]
        i += factor
        iterations += 1
    while i < n:
        total += xs[i]
        i += 1
        iterations += 1
    return total, iterations

xs = list(range(1, 21))
r1, ops1 = naive_scale_shift(xs, 3, 5)
r2, ops2 = licm_scale_shift(xs, 3, 5)
assert r1 == r2

total_unrolled, iters = unrolled_sum(xs, factor=4)
assert sum(xs) == total_unrolled

print(f"결과 동일: {r1 == r2}, naive 연산수={ops1}, LICM 연산수={ops2} "
      f"(불변식 재계산 {len(xs) - 1}회 절약)")
print(f"합계={total_unrolled}: naive 반복수={len(xs)}, unrolled(factor=4) 반복수={iters} "
      f"(루프 오버헤드 ~{len(xs) - iters}회 절약)")

Exercise

Write a simple array-sum loop and compile it with optimization-report flags on, then compare whether SIMD instructions appear in the assembly before and after telling the compiler the pointers don't alias.

Practical Connection

When looking at the performance of a matching engine or a signature-verification loop written in Go, this leads directly to the habit of checking the compiler's output for the inlining budget and whether bounds-check elimination happened.

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 23 / 100 · B. 컴파일러·런타임·VM (Day 20–35)

개념

인라이닝은 함수 호출을 피호출 함수의 본문으로 치환하는 변환으로, 호출 오버헤드 제거 자체보다 호출 경계를 없애 상수 전파·죽은 코드 제거 같은 후속 최적화를 열어 주는 효과가 더 크다. 대신 코드 크기가 늘어 명령어 캐시 압박이 생기므로 컴파일러는 함수 크기와 호출 빈도 휴리스틱으로 인라인 여부를 결정한다. 루프 변환에는 unrolling, 루프 불변식 끌어올리기(LICM), 루프 교환, 융합과 분할, 타일링이 있고 공통 목적은 데이터 의존성을 깨지 않으면서 지역성과 명령어 수준 병렬성을 높이는 것이다. 자동 벡터화는 반복 간 의존이 없는 루프를 SIMD 명령으로 묶는 변환인데, 포인터 앨리어싱 가능성, 불규칙한 제어 흐름, 부동소수점 결합법칙을 바꿔서는 안 된다는 제약 때문에 자주 실패한다. 따라서 벡터화가 걸렸는지는 추측하지 말고 컴파일러의 최적화 리포트나 생성된 어셈블리로 확인해야 한다.

핫 루프가 기대만큼 안 빨라지는 원인은 대개 알고리즘이 아니라 벡터화가 걸리지 않았거나 인라인이 막혀 후속 최적화가 전부 무산된 것이기 때문이다.

코드 · 수식

# 인라이닝·루프 변환·자동 벡터화 — LICM(불변식 끌어올리기)과 루프 언롤링이 연산 횟수를 어떻게 줄이는지 센다.

def naive_scale_shift(xs, a, b):
    ops = 0
    out = []
    for x in xs:
        invariant = a * b + 1     # 매 반복 다시 계산됨 (루프 불변식인데 안 끌어올림)
        ops += 2
        out.append(x + invariant)
        ops += 1
    return out, ops

def licm_scale_shift(xs, a, b):
    ops = 0
    invariant = a * b + 1         # 루프 밖으로 한 번만 끌어올림
    ops += 2
    out = []
    for x in xs:
        out.append(x + invariant)
        ops += 1
    return out, ops

def unrolled_sum(xs, factor=4):
    # 루프를 factor개씩 묶어 반복 증분/조건 검사 오버헤드를 줄인다 (unrolling)
    total, n, i, iterations = 0, len(xs), 0, 0
    while i + factor <= n:
        total += xs[i] + xs[i + 1] + xs[i + 2] + xs[i + 3]
        i += factor
        iterations += 1
    while i < n:
        total += xs[i]
        i += 1
        iterations += 1
    return total, iterations

xs = list(range(1, 21))
r1, ops1 = naive_scale_shift(xs, 3, 5)
r2, ops2 = licm_scale_shift(xs, 3, 5)
assert r1 == r2

total_unrolled, iters = unrolled_sum(xs, factor=4)
assert sum(xs) == total_unrolled

print(f"결과 동일: {r1 == r2}, naive 연산수={ops1}, LICM 연산수={ops2} "
      f"(불변식 재계산 {len(xs) - 1}회 절약)")
print(f"합계={total_unrolled}: naive 반복수={len(xs)}, unrolled(factor=4) 반복수={iters} "
      f"(루프 오버헤드 ~{len(xs) - iters}회 절약)")

연습

단순 배열 합산 루프를 작성해 최적화 리포트 옵션과 함께 컴파일하고, 포인터에 앨리어싱 없음을 알려 주기 전후로 어셈블리에 SIMD 명령이 생기는지 비교하기.

실무 · Verex 연결

Go로 짠 매칭 엔진이나 서명 검증 루프의 성능을 볼 때, 인라인 비용 한도와 경계 검사 제거 여부를 컴파일러 출력으로 확인하는 습관으로 곧장 이어진다.

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

← 22. 레지스터 할당(그래프 컬러링)과 스필 비용24. JIT 계층화·워밍업·역최적화(deopt) →