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를 최소한으로 줄이기도 합니다. 어쨌건 이 포스팅에서 예로 든 로그와 비슷한 패턴에 대해 참고가 되길 바랍니다. (부족한 부분은 언제든 따끔하게 지적해주시기 바랍니다. ^^)

로그 수집/전송 단계를 외부 프로세스로 분리해야 하는 이유

이 포스팅에서 다루고자 하는 대상은 로그를 생성하는 어플리케이션과 로그를 저장하는 시스템이 서로 별개라서, ‘로그 전송’ 단계가 있는 경우입니다. 자바를 예를 들면, log4j에서 DBAppender를 사용하여 외부의 DB에 로그와 관련된 정보를 저장하는 경우가 해당됩니다.  다만, 그저 단순히 로컬 디스크에 로그를 저장하고 그치고 마는 경우라도 참고할만한 내용이 있을 것입니다.

log4j 같이 inner-process 타입 로깅의 경우 어플리케이션과 같은 프로세스 내에서 로그를 기록합니다. 이 방식에서는 어플리케이션 프로세스의 상태가 로그 저장에 영향을 줄 수 있습니다. 가령 예를 들어 Out of memory exception이 발생한 경우, 이 OOME를 유발한 행위에 대한 로그가 실제로 기록되는지는 보장할 수 없습니다. 저장될 로그의 내용이 프로세스 내부의 어떤 버퍼에 의해 관리되고 있다면 더욱 그렇습니다. 또한, 어딘가에 임시로 기록이 되었다고 하더라도, 어플리케이션이 재기동하기전까지는 다른 계층으로 전송되지 않는다는 점이 문제점으로 남습니다.

단순 유실/즉시성의 문제를 넘어 다른 문제도 존재합니다. 웹서비스에서는 대부분의 로그가 사용자로부터의 Request 요청과 밀접하게 연관되어 있습니다. Request 요청 횟수가 많아지면 당연히 그에 따라 로그도 증가합니다. 문제는 DDoS 같은 상황에서 웹서버의 부하가 인프라 전체의 부하로 확대될 수 있다는 점입니다. 로그 전송 단계에 주의를 기울이지 않은 시스템에서는 웹서버의 작은 부하가 전체 인프라를 다운시켜버리는 상황도 종종 발생합니다. 따라서 한 계층의 부하가 다른 계층으로 전파되지 않도록, 중간에서 부하를 제어할 수 있는 메커니즘이 필요합니다.

그리고 서비스와 로깅에 필요한 컴퓨팅 리소스를 적절히 분배하는 것 또한 중요합니다. 한 머신에 node.js 의 인스턴스가 10개 떠 있다고 하더라도, 로그를 처리하는 프로세스는 1개면 충분한 경우가 있다는 것이죠.

일반적으로 inner-process 로깅의 경우 형태는 단순하지만, 앞서 언급한 상황에 효율적으로 대응하기가 어렵고 한계도 있습니다. 대신 outer-process 형태의 로깅은 하드웨어의 장애가 아닌 이상 이런 이슈에 대해 비교적 수월하게 대응을 할 수 있습니다.

이런 측면에서 flume, fluentd 등은 활용성이 높은 솔루션입니다. 특히, 이 두 오픈소스는 유연하게 구성을 할 수 있고, 플러그인을 통한 확장성이 좋아서 outer-process 로깅 패턴을 적용하는데 좋은 출발점이 될 수 있으니 참고하시기 바랍니다.