LLVM의 `BumpPtrAllocator`에 적용된 세 가지 최근 최적화와 그로 인한 인라이닝, 어셈블리, 컴파일 시간 영향, ABI 관련 세부 사항을 설명합니다.
BumpPtrAllocator는 LLVM의 범프 할당자(아레나 할당자)입니다. 각 할당은 슬랩 내부의 포인터를 앞으로 밀어 올리며, 할당자가 소멸할 때 모든 것이 한꺼번에 해제됩니다. 이는 Clang의 ASTContext, lld의 make<T> 객체 풀, TableGen 레코드, 그리고 많은 다른 아레나의 기반이 됩니다.
다음은 최근 세 가지 변경 전의 빠른 경로입니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 attribute((returns_nonnull)) void *Allocate(size_t Size, Align Alignment) {
BytesAllocated += Size;
uintptr_t AlignedPtr = alignAddr(CurPtr, Alignment);
size_t SizeToAllocate = Size;
#if LLVM_ADDRESS_SANITIZER_BUILD
SizeToAllocate += RedZoneSize;
#endif
uintptr_t AllocEndPtr = AlignedPtr + SizeToAllocate;
if (LLVM_LIKELY(AllocEndPtr <= uintptr_t(End)
&& CurPtr != nullptr)) {
CurPtr = reinterpret_cast<char *>(AllocEndPtr);
...
return reinterpret_cast<char *>(AlignedPtr);
}
return AllocateSlow(Size, SizeToAllocate, Alignment);
}
세 개의 변경은 표시된 세 줄을 간소화합니다.
alignAddr(CurPtr, Alignment)는 낭비입니다. 새로 증가된 포인터는 보통 이미 충분히 정렬되어 있기 때문입니다. #205240은 각 크기를 MinAlign(기본값 8)까지 올림하므로, 빠른 경로는 과대 정렬 요청에 대해서만 다시 정렬합니다. 이 기법은 Bump Allocation: Up or Down?에서 배웠습니다:
1
2
3
4
5
6
7 SizeToAllocate = alignToPowerOf2(SizeToAllocate, MinAlign);
uintptr_t AlignedPtr = uintptr_t(CurPtr);
if (Alignment.value() > MinAlign)
AlignedPtr = alignAddr(CurPtr, Alignment);
SpecificBumpPtrAllocator<T>는 대신 MinAlign = 1을 사용합니다. DestroyAll이 sizeof(T) 단위로 전진하므로, 올림이 아니라 촘촘한 배치가 필요하기 때문입니다.
첫 시도에서는 실수를 했습니다. 0이 아닌 오프셋을 nullptr에 더하면서 UBSan 진단이 발생했습니다. 계산을 uintptr_t 영역에 유지하도록 수정해 해결했습니다.
__attribute__((returns_nonnull))는 반환값이 null이 아님을 지정합니다. CurPtr와 End가 모두 null인 새 할당자에서, 예전의 Allocate(0)는 null을 반환할 수 있었습니다. 2022년에 https://reviews.llvm.org/D125040이 빠른 경로 조건에 && CurPtr != nullptr 검사를 추가했는데, 이는 이상적인 방식은 아니었습니다.
저는 다음을 시도했습니다.
1
2
3 if (LLVM_LIKELY(AlignedPtr + SizeToAllocate - 1 < uintptr_t(End))) { ... }
하지만 이후 aengelke의 제안을 채택했습니다. 실제 끝 다음 한 칸을 센티널로 저장하면(EndSentinel = realEnd + 1, 슬랩이 없을 때는 0) 두 조건을 하나의 부호 없는 비교로 합칠 수 있습니다:
1 if (LLVM_LIKELY(AllocEndPtr < EndSentinel)) { ... }
빈 할당자는 EndSentinel == 0이므로 AllocEndPtr < 0은 항상 거짓이며, null 경우는 별도 분기 없이 느린 경로로 떨어집니다.
BytesAllocated += Size는 모든 할당마다 멤버에 대해 읽기-수정-쓰기를 수행했고, 요청된 바이트 수를 보고하는 getBytesAllocated()를 뒷받침했습니다. 이는 슬랩 용량을 의미하는 getTotalMemory()와는 다릅니다. 이 값의 소비자는 통계/진단 용도뿐이었습니다. lldb의 ConstString 메모리 보고서, clangd 디버그 로그, TableGen의 dumpAllocationStats, 그리고 clang 회귀 테스트 하나가 전부였습니다. 이 멤버를 제거하고 그 소비자들을 이전하면(대부분 getTotalMemory()로) 뜨거운 경로의 저장 연산이 사라집니다.
세부 사항 하나: 레드 존과 ABI. ASan 레드 존 크기도 멤버입니다. 이를 #if LLVM_ADDRESS_SANITIZER_BUILD로 조건부 처리해 릴리스 빌드에서 제거하는 것은 ABI 함정이 됩니다. 그 매크로는 번역 단위별 이므로, ASan 계측된 TU와 비-ASan libLLVM이 구조체 레이아웃에 대해 조용히 불일치할 수 있기 때문입니다. 대신 이 멤버는 LLVM_ENABLE_ABI_BREAKING_CHECKS로 조건부 처리되는데, 이는 라이브러리 빌드별로 고정되고 링크 시점에 강제됩니다(EnableABIBreakingChecks 심볼을 통해). 그리고 레드 존 산술은 두 매크로 모두에 의해 조건부 처리됩니다.
합치면, 빠른 경로는 다음과 같이 됩니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 void *Allocate(size_t Size, Align Alignment) {
size_t SizeToAllocate = Size;
#if LLVM_ADDRESS_SANITIZER_BUILD && LLVM_ENABLE_ABI_BREAKING_CHECKS
SizeToAllocate += RedZoneSize;
#endif
SizeToAllocate = alignToPowerOf2(SizeToAllocate, MinAlign);
uintptr_t AlignedPtr = uintptr_t(CurPtr);
if (Alignment.value() > MinAlign)
AlignedPtr = alignAddr(CurPtr, Alignment);
uintptr_t AllocEndPtr = AlignedPtr + SizeToAllocate;
if (LLVM_LIKELY(AllocEndPtr < EndSentinel)) {
CurPtr = reinterpret_cast<char *>(AllocEndPtr);
...
return reinterpret_cast<char *>(AlignedPtr);
}
return AllocateSlow(Size, SizeToAllocate, Alignment);
}
전형적인 아레나 객체, 즉 Allocate<T>()를 통한 24바이트, 8정렬 노드의 할당은 여섯 개 명령의 빠른 경로로 컴파일됩니다(clang -O2, 릴리스):
1
2
3
4
5
6 mov rax, [rdi] # CurPtr (반환값이기도 함)
lea rcx, [rax + 0x18] # new = CurPtr + 24
cmp rcx, [rdi + 0x8] # EndSentinel과 비교
jae .slow
mov [rdi], rcx # CurPtr = new
ret
이는 전형적인 범프 빠른 경로와 일치합니다. 아래 방향으로 증가하는 할당자는 rax/rcx 구분이 필요 없어서 살아 있는 값이 하나 줄어들겠지만, 명령 수는 그대로입니다. LLVM이 위 방향으로 증가하는 것은 설계에 따른 것입니다. identifyObject, 할당 순서, 그리고 SpecificBumpPtrAllocator::DestroyAll의 sizeof(T) 단위 전방 순회가 모두 이를 가정합니다. 남은 차이는 명령 수가 아니라 공간입니다.
이 변경들은 Allocate를 인라이너의 비용 임계값 아래로 줄여서, 이전에는 외부 호출이었던 호출자들(예: new (Context) T)이 해당 지점에서 인라인되도록 만듭니다. 실행되는 명령 수는 감소하지만, 이는 재분배 로 나타납니다. 이제 체인이 인라인되는 객체 파일은 커지고, 다른 부분은 저장 연산이 사라져 약간 작아집니다.
성능 향상은 stage1(시스템 GCC로 빌드)보다 stage2(stage1 Clang으로 빌드)에서 더 큽니다.
main 위에서 세 변경을 모두 되돌리면 그 결합된 효과를 분리할 수 있습니다(compare):
유의미함(측정된 노이즈 대비 ≥3σ): 🟢 개선. 표시 없음 = 노이즈 범위 내.
| Configuration | instructions:u | max-rss |
|---|---|---|
| stage1-O3 | −0.04% | +0.04% |
| stage1-ReleaseThinLTO | −0.04% | −0.01% |
| stage1-ReleaseLTO-g | −0.04% | +0.06% |
| stage1-O0-g | 🟢 −0.09% | +0.25% |
| stage1-aarch64-O3 | −0.04% | +0.04% |
| stage1-aarch64-O0-g | 🟢 −0.12% | −0.01% |
| stage2-O3 | 🟢 −0.14% | −0.15% |
| stage2-O0-g | 🟢 −0.36% | −0.06% |
0 센티널로 "비어 있음"을 표현하면 null 검사를 경계 비교에 합칠 수 있습니다.Allocate가 가능하게 한 인라이닝이며, 이는 균일한 축소가 아니라 크기의 재분배 로 나타납니다.LLVM_ENABLE_ABI_BREAKING_CHECKS(링크로 강제됨)에 의존할 수는 있지만, 번역 단위별인 LLVM_ADDRESS_SANITIZER_BUILD에는 절대 의존해서는 안 됩니다.