CPU 캐시는 얼마나 고려해야 할까?

사실 어느 정도 미리 결론을 내려보자면, CPU 캐시까지 고려해가면서 어플리케이션을 구축해야 하는 상황은 흔하지는 않고, 또한 잘못된 선최적화를 할 수 있는 가능성이 있기 때문에 소프트웨어를 설계할 때 CPU 캐시는 중요 설계 요소로 넣지 않을 것을 권장합니다. 왜냐하면 CPU 캐시 활용을 반드시 해야만 할 정도로 소프트웨어 성능이 필요한 상황이라면, 그전에 성능 문제는 다른 아키텍처 요소(예를 들면 인프라, DB 등)에 있거나 아니면 처음부터 성능적으로 큰 설계 결함이 있는 경우일 가능성이 높기 때문입니다. (그것도 아니면 ‘이제 심심한데 CPU 캐시를 써서 성능을 올려볼까?’라는 어떤 행복한(?) 경우겠지요. ^^)

분명히 CPU 캐시는 DRAM보다는 상대적으로 빠른 액세스를 보장합니다. 하지만 많은 서비스의 서버 아키텍처에서 대부분의 병목은 네트워크, 디스크 I/O, 외부 시스템에서 발생하며 이들 자원의 액세스 속도에 비하면 DRAM에 대한 액세스 속도는 대부분의 영역에서 문제가 되지 않는다고 봐도 좋을 정도입니다. 성능이 중요한 RDBMS 같은 경우도 CPU 캐시를 활용하는 전략보다는 디스크 액세스나 Working 메모리 영역을 보다 효율화하는 쪽으로 집중을 하고 있지요. 그런 Storage 성격이 강한 어플리케이션에서 CPU Cache는 용량적인 측면에서 큰 이득을 보기가 어렵기 때문입니다.

물론, 어플리케이션 특성에 따라 CPU 캐시 전략을 잘 세우면 boost-up된 성능을 얻을 수 있다는 점은 부정할 수 없는 사실입니다. 네트워크나 디스크 I/O 같이 외부 I/O가 병목이 아니고 스레드간의 공유 메모리에 대한 액세스가 빈번한 상황이라면, 공유 메모리가 CPU 캐시 영역에 있고 없음은 큰 성능 차이로 나타날 수도 있습니다.
하지만 이런 점에도 불구하고 저의 입장은 미리 밝혔듯이, 처음부터 CPU 캐시를 중요 설계 요소로 삼지 않는 것이 좋다는 것입니다. 무엇보다 CPU 캐시 구조와 특성은 CPU 종류마다 다르기 때문에, 어떤 CPU에 맞춘 튜닝이 다른 CPU에서도 똑같이 동작하지는 않습니다. 예를 들면, 이전의 인텔 CPU는 모든 코어가 하나의 L2 캐시를 공유했지만 요즘은 각 코어마다 별도의 L2 캐시를 가지고 있고 L3 캐시를 모든 코어가 공유하는 형태입니다. 실질적으로 어플리케이션 스레드 성능에 영향을 주는 캐시는 L1-L2캐시라는 점을 감안하면 L2 캐시 구조에 따라 이런저런 차이가 있을 수 밖에 없습니다. 어쨌거나 중요한 것은 CPU 캐시 튜닝은 end-point 튜닝이기 때문에 가장 나중에 해야 할 선택이라는 점이죠.

그렇지만, CPU 캐시의 특성과 그로 인해 받을 수 있는 영향에 대해서는 알고 있는 것이 좋습니다. 어플리케이션의 올바른 성능 튜닝을 위해서도 필요하겠지만, 이 부분을 간과하게 되면 잘못된 믿음을 얻을 수도 있기 때문입니다. 흔한 사례 하나를 들어보도록 하죠.

일반적인 웹 어플리케이션을 생각해봅시다. 여러 개의 스레드가 사용자의 요청을 받고, 이 스레드는 사용자 요청을 처리하면서 처리한 내용을 로그로 기록합니다. 보통 로그를 디스크에 그때그때 기록하는 것은 비효율적이기 때문에 메모리 버퍼에 로그를 모아두었다가 한번에 이 버퍼의 내용을 디스크에 기록하게 합니다. (log4j의 경우는 AyncAppender가 이런 방식으로 작동을 합니다.)
여기서 버퍼는 여러 스레드가 동시에 접근하는 메모리 영역입니다. 즉, 이 버퍼에 대해 CPU 캐시의 이득을 충분히 활용할 수 있다면, 스레드가 버퍼에 로그 내용을 기록할 때 보다 나은 성능을 얻을 수 있습니다. 물론 DMA는 CPU 캐시의 내용을 디스크로 바로 전송할 수는 없기 때문에, 한번은 DRAM으로 CPU 캐시의 내용이 내려가야 하지만 매번 DRAM상의 실제 버퍼 영역에 로그를 저장하는 것보다는 나은 성능을 얻을 수 있습니다.

다음 이미지는 100만개의 로그를 200개의 스레드를 가지고 버퍼 크기에 따라 디스크에 기록하는데 얼만큼의 시간(ms)이 걸렸는지를 측정한 것입니다. 두 테스트 모두 디스크 성능이테스트 수치에 영향을 주지 않는 환경에서 수행되었습니다. 하지만 테스트를 수행한 서버 사양이 다르기 때문에 두 테스트간의 수치 차이는 의미가 없습니다. 한 테스트 내의 상대적인 수치를 보시면 됩니다. (그래프가 낮을 수록 좋습니다.)

1) L2 Cache – 256KB
L2 256KB

2) L2 Cache – 1MB
l2-1MB

1번 경우(L2 Cache-256KB)는 버퍼 크기가 64KB~128KB 구간에서 가장 성능이 좋게 나타납니다. L2 Cache 크기가 256KB이기는 하지만 스레드간에 공유하는 데이터가 버퍼만 있는게 아니기 때문에, 256KB에서는 128KB보다 아주 조금 부족한 성능이 나타납니다. 1024KB의 경우 512KB보다 조금 더 나은 성능을 보여주는데, 이는 이 테스트 환경에서는 블럭 크기 등의 측면에서 1MB가 더 효율적이기 때문이라고 할 수 있습니다.

L2 캐시가 1MB인 경우는 상황이 달라집니다. 1번에서 가장 성능이 좋았던 64KB~128KB 구간은 이 테스트에서는 그리 인상적이지 않습니다. 버퍼 크기를 1MB로 잡았을 때가 캐시 크기와 디스크 효율이 합쳐져서 다른 경우보다 더 나은 성능을 보여줍니다. 다만, 버퍼 외의 스레드간에 공유하는 데이터가 더 많다면 512KB쪽이 더 나은 성능을 보여줄 수도 있을 겁니다.

만약 이 테스트를 싱글스레드에서 돌렸다면 CPU 캐시의 이득은 거의 볼 수 없기 때문에, 버퍼 크기가 작을 때(4KB 혹은 그 미만) 혹은 1MB 정도에서 가장 나은 성능 수치를 얻었을 것입니다. 하지만 그 값은 실제로 적용하기에는 적합한 값이 아니겠죠. 애석하게도 시간적 여유가 부족한 현업에서는 이런 식으로 성능 테스트가 이뤄지는 경우가 많아서, 잘못 산정된 값을 적용하는 경우가 종종 있습니다. (이것이 잘못된 믿음의 한 경우입니다.)

첨언을 하자면 이 포스팅에서는 일부러 단순하고 흔한 문제를 들었는데, 실제로 이런 상황에 대응하는 사례는 복잡하고 다양합니다. 버퍼 구조를 다층화한다거나, leader-follow 패턴을 구현할 때는 일부러 leader thread와 follower 스레드를 최대한 같은 코어에 배치시켜 CPU cache miss를 최소한으로 줄이기도 합니다. 어쨌건 이 포스팅에서 예로 든 로그와 비슷한 패턴에 대해 참고가 되길 바랍니다. (부족한 부분은 언제든 따끔하게 지적해주시기 바랍니다. ^^)