HotSpot의 volatile 펜스 명령 선택 변경과 그 배경, 벤치마킹 방법론, 그리고 StoreLoad 장벽과 스택 사용 간의 미세구조적 상호작용을 살펴본다.
참고 이 글은 ePUB 및 mobi 형식으로도 볼 수 있습니다.
Java와 같은 프로그래밍 언어는 기본 하드웨어에서 실행될 수 있는 더 낮은 수준의 구성으로 변환되어야 하는 비교적 고수준의 추상화를 노출합니다. 이 변환은 컴파일러가 처리하며, 컴파일러는 기반 하드웨어의 특성과 개성을 고려한 명령을 생성해야 합니다. 고수준 프로그래머는 보통 이런 세부사항을 의식하지 않으며, 덕분에 자신이 속한 특정 도메인의 문제에 집중할 수 있습니다. 이것은 역할 분담입니다. 시스템 프로그래머는 효율적인 저수준 도구를 만들려고 하고, 애플리케이션 프로그래머는 그것을 활용하려고 합니다.
이 글은 시스템 프로그래머가 보이지 않는 곳에서 무엇을 하는지 보여 주는 한 가지 예입니다. HotSpot에서 최근 갱신된 volatile 펜스 명령 선택을 살펴보고, 그 근거를 다시 짚어 보며, 전반적으로 이런 변경에 어떻게 접근하는지를 강조하겠습니다. 좋은 전통에 따라, 벤치마킹 방법론으로도 잠시 새어 나가 보겠습니다. 늘 그렇듯, 아직 JMH에 대해 배우지 않았거나 JMH samples를 훑어보지 않았다면, 이 글의 나머지를 읽기 전에 먼저 살펴보는 것이 가장 좋은 경험이 될 수 있습니다.
Java에서 volatile 키워드는 특별한 의미를 가집니다. 이 키워드는 접근 시 동기화 액션을 발생시키는 변수를 나타냅니다. 명세의 동작적 부분에 대한 더 많은 논의는 Java Memory Model Pragmatics를 참고하세요. volatile 동작이 어지럽게 느껴진다면, 먼저 그 글을 읽고 오는 것이 좋습니다.
그렇다면 런타임/하드웨어 구현은 명세 요구사항을 어떻게 처리할까요? JSR 133 Cookbook for Compiler Writers는 샘플 구현이 취할 수 있는 보수적인 접근을 설명합니다. 여기서는 X와 Y가 {Load, Store} 중 하나인 XY 배리어라는 개념을 도입합니다. XY 배리어는 X 타입의 연산이 Y 타입의 연산과 재정렬되지 않음을 의미합니다. 이것은 컴파일러에서 프로그램 그래프를 다룰 때 매우 편리한 추상화입니다. 배리어는 주어진 타입의 연산이 특정 방향으로 펜스를 통과해 떠다니는 것을 막는 "울타리"입니다. 하드웨어도 펜스를 그런 식으로 다룹니다.
특정 구현이 메모리 펜스로 volatile 의미론을 어떻게 처리할까요? volatile int x가 주어졌을 때, volatile store는 다음과 같이 생성할 수 있습니다:
<other ops>
[StoreStore]
[LoadStore]
x = 1; // volatile store
이제 volatile store가 release store가 되었음을 주목하세요. 즉, volatile store 이전의 모든 연산은 그것보다 먼저 커밋됩니다. 이는 volatile 값을 올바르게 관찰하면, 그 이전의 store들도 관찰할 기회를 얻게 된다는 뜻입니다. 여기서 우리는 happens-before의 구현을 쌓아 올리고 있습니다.
이것의 두 번째 부분은 volatile load입니다:
int t = x; // volatile load
[LoadLoad]
[LoadStore]
<other ops>
이제 load가 acquire load가 되었음을 볼 수 있습니다. 즉, 모든 연산은 volatile load가 성공할 때까지 실행을 기다립니다.
이제 우리는 동기화 액션에 대해 순차적 일관성도 보장해야 합니다. 다시 말해, volatile 연산 자체도 재정렬되지 않도록 보장해야 합니다.
위 예에서 volatile store 뒤에 volatile load가 따라오면, volatile load가 먼저 수행되는 것을 막는 메모리 펜스가 없기 때문에 순차적 일관성이 보장되지 않습니다.
<other ops>
[StoreStore]
[LoadStore]
x = 1; // volatile store
[StoreLoad] // Case (a): Guard after volatile stores
...
[StoreLoad] // Case (b): Guard before volatile loads
int t = x; // volatile load
[LoadLoad]
[LoadStore]
<other ops>
volatile load가 분명히 대부분의 프로그램에서 우세하기 때문에, 건전한 구현은 경우 (a)를 선택하여 각 volatile store 뒤에 StoreLoad 배리어를 생성합니다.
이 배리어들은 컴파일러에게 해로운 재정렬을 끊겠다는 의도로 취급됩니다. 하드웨어 차원으로 내려가 보면, 일부 하드웨어는 이미 상당한 보장을 제공합니다. 예를 들어 Intel Software Developer Manual을 펼쳐 보면, 대부분의 x86 구현에서 다음과 같이 읽을 수 있습니다:
"" * Reads are not reordered with other reads. [Translation: LoadLoad can be no-op]* Writes are not reordered with older reads. [Translation: LoadStore can be no-op]* Writes to memory are not reordered with other writes… [Translation: StoreStore can be no-op]""
— Intel Software Development Manual; Vol 3A; 8.2.1
따라서 컴파일러에서 배리어 처리를 마치고 나면, x86 하드웨어에 전달해야 하는 배리어(= 머신 코드에 실제로 생성해야 하는 것)는 StoreLoad뿐이라는 사실이 드러납니다. 그리고 여기서부터 재미있어집니다. x86은 다음과 같은 유용한 규칙을 선언합니다:
"" * Reads or writes cannot be reordered with I/O instructions, locked instructions, or serializing instructions. * Reads cannot pass earlier LFENCE and MFENCE instructions. * Writes cannot pass earlier LFENCE, SFENCE, and MFENCE instructions. * LFENCE instructions cannot pass earlier reads. * SFENCE instructions cannot pass earlier writes. * MFENCE instructions cannot pass earlier reads or writes. ""
— Intel Software Development Manual; Vol 3A; 8.2.1
이것을 보면 mfence가 StoreLoad 배리어의 좋은 후보라고 생각할 수 있습니다. 하지만 비슷한 효과를 내는 I/O 명령, lock 접두사가 붙은 명령, 그 외의 직렬화 명령도 있습니다. Dave Dice는 몇 년 전에 StoreLoad 배리어에 lock addl %(rsp), 0을 사용하는 편이 더 낫다는 관찰을 내놓았습니다. 전용 mfence 명령이 나쁜 선택이라는 점은 의외로 보이지만, 설명은 있습니다. mfence는 일반 메모리[1]를 오가는 읽기/쓰기를 포함해 모든 읽기/쓰기를 정렬하고, 비동기 메모리 오류 등도 정렬합니다. 우리가 일반 메모리를 다루는 상황에서는, 가벼운 locked 명령이 완전한 mfence보다 더 바람직할 수 있습니다. 스택 꼭대기에 대해 lock addl을 거는 방식은 꽤 괜찮은데, 그 이유는 스택 꼭대기가 a) 각 스레드마다 고유하고, b) 가장 가까운 수준의 캐시에 이미 있을 가능성이 높기 때문입니다.
이제 Project Valhalla로 들어가 보겠습니다. 그리고 더 눈에 띄는 것은 Paul Sandoz가 Enhanced Volatiles를 Java에 도입하기 위해, "VarHandles"라는 이름의 메커니즘을 통해 접근한 방식입니다. Paul은 이 기능의 개발을 이끌기 위한 훌륭한 벤치마크 모음을 유지하고 있었고, 어느 날 제게 벤치마크 하나를 건네며 이렇게 말했습니다. "이 벤치마크가 왜 이런 특정한 방식으로 동작하는지 이해가 안 됩니다".
우리는 여러 구성에서 그것을 실행했고, 실제로 점수는 제각각이었으며, tiered compilation도 영향을 주는 것 같았고, 컴파일러 비트수도 영향을 주는 것 같았고, 인라이닝도 영향을 주는 것 같았습니다. 그러고 나서 가장 느린 실행 모드(최선보다 두 배 느림)의 perfasm 프로파일을 들여다보니, 이런 것이 보였습니다…
clks insns
1.61% 0x00007f63d8a6455e: mov %r10,(%rcx) ; reference store
0.00% 0x00007f63d8a64561: mov %rcx,%r10 ; <card mark>
0.01% 0x00007f63d8a64564: shr $0x9,%r10
0x00007f63d8a64568: movb $0x0,(%r14,%r10,1)
1.73% 2.90% 0x00007f63d8a6456d: lock addl $0x0,(%rsp) ; StoreLoad barrier
41.00% 93.96% 0x00007f63d8a64572: mov (%rsp),%rcx ; reading the spilled value from the stack
26.39% 1.48% 0x00007f63d8a64576: mov 0x68(%rcx),%r8 ;
뭔가 보이시나요? 제 눈에는 **%(rsp)**를 통한 평범한 read-after-write data dependency가 보입니다. 하드웨어 카운터가 이것을 성능 병목으로 지목해 주기 전까지는 알아차리지 못했을 겁니다. 스택 꼭대기에 addl을 거는 원래의 전제가 역효과를 낸 것입니다! 실제로 우리는 스택 위에 유용한 무언가를 가지고 있었고, 생성된 StoreLoad 배리어 주변에서는 그 사용이 불필요한 의존성 체인에 걸릴 위험에 처해 있었습니다.
이 현상은 위험할 정도로 스택 연산에 가까운 volatile store가 많이 있는 빡빡한 루프에서 아주 잘 드러납니다. 하지만 이 효과는 간헐적이며, 이 코드에 대해 내려진 구체적인 (오)컴파일 결정에 크게 의존합니다. 컴파일러를 조금만 건드려도 스택 사용이 StoreLoad 배리어에서 멀어지면서 효과가 완전히 사라질 수 있습니다. 이것이 서로 다른 컴파일러 옵션에서 관찰한 심한 성능 변동을 설명해 줍니다.
이럴 때 나노벤치마크는 비용을 정량화하는 데 매우 유용합니다. 이렇게 미묘한 효과에 대한 테스트 케이스를 구성하는 일은 다소 고통스럽지만, 특정 컴파일러가 코드를 어떻게 다루는지 이해하고 있다면 관리 가능한 수준입니다. 마치 분쇄기에 나무 통나무를 넣되, 반대편에서 쓸 만한 앤티크 의자가 나오도록 배치하는 것과 비슷합니다. 완전히 불가능한 일은 아니지만, 꽤 어렵습니다. 문제를 정량화하기 위해 사용된 매우 멋지고 날카로운 벤치마크의 한 예가 있습니다(JDK-8050147로 추적됨):
@State(Scope.Thread)
public class VolatileBarrierBench {
volatile int sink;
Integer src;
private int c;
@Param("0")
private int backoff;
/*
The benchmark below is rather fragile:
- we pass the object to non-inlinable method, and at least on Linux x86_64
calling convention the argument gets to %(rsp+0), which is important for this test;
- looping() is non-inlinable to prevent loop unrolling effects;
- consumeCPU allows for tunable backoffs;
- unboxing value before entering the consumeCPU allows to split the *load* from %(rsp),
and the subsequent barrier;
Use with great care, and mostly for producing profiled assemblies.
*/
@Setup
public void setup() throws NoSuchFieldException {
src = 42;
}
@Benchmark
public void testVolatile() {
testWith_Volatile(src);
}
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void testWith_Volatile(Integer v1) {
c = 0;
do {
int v = v1;
Blackhole.consumeCPU(backoff);
sink = v;
} while (looping());
}
@CompilerControl(CompilerControl.Mode.DONT_INLINE)
private boolean looping() {
return c++ < 100;
}
}
volatile store는 무거운 연산이고, 중간에 끼어드는 연산이 있을 때 데이터 의존성이 상쇄된다는 것도 알고 있으므로, 서로 다른 backoff에서 비용을 정량화하고 싶습니다. 여러 StoreLoad 전략을 시험하기 위해 몇 가지 선택지를 시도합니다(JDK-8050149 참조):
mfence: 5년 전의 관찰이 더 이상 유효하지 않은지, 그래서 이 효과를 피하기 위해 그냥 mfence로 전환해야 하는지를 알아보기 위함
lock addl %(rsp), 0: 기준선으로 사용
lock addl %(rsp-8), 0: **%(rsp)**를 통한 데이터 의존성을 분리하려는 시도
lock addl %(rsp-CacheLine), 0: 캐시 라인을 분리하려는 시도
lock addl %(rsp-RedZone), 0: 더 크게 떨어진 위치로 이동하는 비용을 정량화하려는 시도
이 벤치마크는 당연히 서로 다른 backoff와 측정 스레드 수에서 실행해야 합니다. 또한 여기서는 미세구조적 효과를 다루고 있으므로, 여러 플랫폼에서 효과를 정량화해야 합니다. 흥미로운 몇 가지 플랫폼을 알파벳 순서로 살펴보겠습니다:
여기서는 벌써 몇 가지를 볼 수 있습니다:
작업자 스레드 수에 따라 성능이 일관됩니다
각 특정 모드의 성능이 실행 간에도 일관됩니다
lock addl %(rsp), 0과 다른 lock addl들 사이의 격차가 backoff가 커질수록 빠르게 줄어드는 것을 볼 수 있는데, 이는 스택 사용과 lock 접두 명령 사이의 거리가 backoff와 함께 늘어나기 때문입니다
mfence는 실제로 lock 접두 명령보다 훨씬 느립니다
이 데이터는 %rsp에서 적당한 오프셋만 주어도 충분히 좋다는 점을 보여 줍니다.
이를 확인하기 위해, 같은 결과와 같은 결론을 내는 또 다른 AMD 마이크로아키텍처의 데이터 포인트를 보겠습니다. 이것은 실험이 내부적으로 일관된다는 점도 추가로 검증해 줍니다.
동기화를 다룰 때는 하위급 시스템에 대한 영향도 확인하는 것이 좋습니다. 듀얼코어 Atom 프로세서에서는 어느 경우에도 차이가 없습니다. CPU가 메모리 명령을 공격적으로 재정렬할 만큼 충분히 복잡하지 않기 때문입니다.
이제 Intel의 새로운 Haswell 마이크로아키텍처입니다. AMD 결과와 비교했을 때 몇 가지 뚜렷한 점을 볼 수 있습니다:
%(rsp) 의존성의 효과가 훨씬 더 나쁘며, 따라서 **%(rsp)**에 오프셋을 주면 큰 이득을 얻습니다. 이 효과는 더 높은 backoff에서도 더 오래 끌고 갑니다.
**lock addl %(rsp-8)**에서 흥미로운 점상 패턴과 약간 더 낮은 성능이 보이는데, 이 실험에서는 일관되게 나타납니다.
초록색 선의 점상 패턴에 대한 생성된 어셈블리를 파고들어 보면 흥미로운 발견이 나옵니다. **looping()**을 너무 빨리 호출한 나머지, 실제로 %rsp 아래의 callee save와 충돌하는 것처럼 보입니다! 이런, 그렇다면 작은 오프셋은 우리가 생각했던 만큼 안전하지 않습니다.
이미 StoreLoad 배리어에 대해 서로 다른 명령 시퀀스를 선택하는 코드가 있으므로, 가장 좋은 후보로 그냥 전환하면 끝입니다. 최선의 후보는 **%(rsp)**보다 더 아래에서 lock addl을 거는 것입니다. 여전히 모든 스레드에 대해 고유하고, 여전히 뜨거운 메모리와 인접하며, 불운한 메모리 의존성을 피할 충분한 공간이 있기 때문입니다.
하지만 일반적으로 스택 포인터 아래의 메모리에 접근하면 스택 오버플로를 일으킬 위험이 있습니다. VM은 스택 영역 둘레를 접근 불가능한 가드 페이지로 둘러쳐 스택 오버플로를 처리하고, 그 가드 페이지에 대한 접근을 가로채 스택 오버플로가 발생한 경우를 감지합니다. 또한 새 activation record를 스택에 놓을 때 스택을 실제로 프로브하는 추가 로직도 필요합니다. 가드 페이지 오류를 일으키려 하면서 스택을 아래로 건드리는 것입니다. 이 메커니즘에는 "stack banging"이라는 멋진 이름이 붙어 있으며, 스택 포인터 아래에서 무엇인가를 하려면 추가적인 banging을 런타임에 전달해야 합니다.
따라서 VM 코드에 들어간 실제 패치는 조금 더 복잡합니다. 물론 이 패치는 정기적인 성능 테스트 과정에서 여러 현실적인 워크로드로 검증되었고, 지금까지는 잘 동작하고 있습니다.
이 글에서 논의한 탐구를 바탕으로 다음과 같은 결론을 내릴 수 있습니다:
벤치마크 결과를 깊이 분석하면, 지나고 보면 자명해 보이지만 예상 밖이었던 사실들을 밝혀낼 수 있습니다.
나노벤치마크는 미세구조적 동작을 빠르게 진단하고 정량화하는 데 좋습니다. 이 경우 우리는 완전히 이해할 수 없었던 성능 지터 사례에서 출발해, 가설을 세우고, 후보 해법에 이르기까지 불과 몇 시간(전문가 시간 기준, 개인차 있음) 만에 도달했습니다.
런타임과 컴파일러를 개발할 때 내린 결정은 여전히 가장 적합한지 판단하기 위해 주기적으로 재평가되어야 합니다. 수백만 줄의 코드에서 내린 모든 결정을 계속해서 훑어볼 수는 없으므로, 현재 접근법에 반하는 증거가 튀어나오는지를 추적하기 위해 Night Watch를 계속 세워 두어야 합니다. 눈을 크게 뜨고, 포스가 함께하길 바랍니다.
스택 사용과의 StoreLoad 상호작용에 관한 이 특정한 문제는, 메모리에서 "안전한" 임시 위치를 고르는 일이 우리가 생각했던 것보다 더 어렵다는 예로 Doug Lea와 Dave Dice에 의해 언급되었고, 어쩌면 volatile store를 수행하기 위해 그냥 메모리에 대해 xchg를 생성해야 할지도 모른다는 논의로 이어졌습니다. 메모리 피연산자에 대한 xchg는 x86에서 lock 접두사를 가정하므로, 명시적인 StoreLoad 배리어를 전혀 요구하지 않으면서 필요한 효과를 제공합니다. 이것은 현재 HotSpot에서 신중한 프로토타이핑이 필요합니다. 계속 지켜봐 주세요.