10배 빠르게 통계 처리하기

자바로 개발된 많은 엔터프라이즈 응용프로그램들은 일반적으로 데이터베이스에 의존하여 데이터를 처리하지만, 처리해야 할 데이터가 너무 크거나 실시간 집계가 필요한 경우 자바로 직접 개발하여 통계를 내기도 합니다. 현재는 데스크탑에서도 4개 이상의 멀티코어를 사용하기 때문에, 서버에서는 수십 개의 스레드가 데이터를 처리하여 집계하는 경우를 흔하게 볼 수 있습니다.

중급 이상의 개발자라면 프로세서가 지원하는 CAS (Compare-And-Swap) 인스트럭션을 기반으로 한 AtomicInteger나 AtomicLong 클래스를 이용하여 병렬 집계 처리를 하겠지만, 자바 8은 멀티코어 환경에서 성능이 더 나은 옵션을 제공합니다. 바로 LongAdder 클래스입니다.

AtomicLong 테스트 코드

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class Benchmark {
	private AtomicLong counter = new AtomicLong(0);

	public class Counter implements Runnable {
		@Override
		public void run() {
			for (int i = 0; i < 10000000; i++)
				counter.incrementAndGet();
		}
	}
	
	public void run() throws InterruptedException {
		int cores = Runtime.getRuntime().availableProcessors();
		ExecutorService pool = Executors.newFixedThreadPool(cores);

		long begin = System.currentTimeMillis();
		for (int i = 0; i < cores; i++)
			pool.submit(new Counter());

		pool.shutdown();
		pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
		long end = System.currentTimeMillis();
		
		System.out.println(counter.get());
		System.out.println((end - begin) + "ms");
	}

	public static void main(String[] args) throws Exception {
		new Benchmark().run();
	}
}

LongAdder 테스트 코드

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.LongAdder;

public class Benchmark {
	private LongAdder counter = new LongAdder();

	public class Counter implements Runnable {
		@Override
		public void run() {
			for (int i = 0; i < 10000000; i++)
				counter.increment();
		}
	}

	public void run() throws InterruptedException {
		int cores = Runtime.getRuntime().availableProcessors();
		ExecutorService pool = Executors.newFixedThreadPool(cores);

		long begin = System.currentTimeMillis();
		for (int i = 0; i < cores; i++)
			pool.submit(new Counter());

		pool.shutdown();
		pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
		long end = System.currentTimeMillis();
		
		System.out.println(counter.sum());
		System.out.println((end - begin) + "ms");
	}

	public static void main(String[] args) throws Exception {
		new Benchmark().run();
	}
}

측정 결과 #1

  • 인텔 코어 i7-4770 3.4GHz, 4 코어, 8 스레드
  • AtomicLong: 1546ms, 1542ms, 1534ms
  • LongAdder: 155ms, 141ms, 143ms

측정 결과 #2

  • 인텔 제온 E5-2687W v3 3.1GHz, 20 코어, 40 스레드
  • AtomicLong: 6701ms, 7126ms, 6880ms
  • LongAdder: 351ms, 349ms, 332ms

성능 차이의 원인

멀티스레드 응용에서 AtomicLong을 이용해서 집계를 수행하는 경우, 모든 코어의 CPU 사용율이 100%에 근접하기 때문에 최대 속도로 동작하고 있다고 단순하게 생각하기 쉽습니다. 실제로는 같은 메모리 변수에 대해 다수의 코어가 경합을 벌이게 되면서, 값 변경이 성공하게 될 때까지 무한히 재시도 하는 상태가 됩니다. 스레드 수가 많을수록 더 극심한 경합이 발생하고 성능이 더 떨어집니다.

LongAdder는 여러 개의 카운터로 구성된 배열을 이용해서 코어 간 경합이 발생할 확률을 떨어뜨립니다. 마지막에 각 카운터를 합산하여 최종 결과를 계산하는 방식으로, 집계를 수행할 때는 경합을 최소화하고 정확한 결과를 얻을 수 있습니다.

LongAdder는 멀티스레드 집계 상황에서 성능이 좋은 대신, 메모리를 상대적으로 많이 사용합니다. 아래 코드에서 보이듯이 LongAdder는 경합이 발생하면 2배씩 카운터 배열을 확장합니다.

else if (cellsBusy == 0 && casCellsBusy()) {
    try {
        if (cells == cs)        // Expand table unless stale
            cells = Arrays.copyOf(cs, n << 1);
    } finally {
        cellsBusy = 0;
    }
    collide = false;
    continue;                   // Retry with expanded table
}

집계 카운터가 많은 경우, 이러한 메모리 사용량은 받아들이기 어려울 수 있습니다. 로그프레소는 하드웨어 환경에 따라 적절한 메모리를 사용하면서 백오프(Backoff)를 이용해 경합을 감소시키는 알고리즘을 사용합니다.