tcmalloc 사용법

스레드를 늘렸는데 처리량이 비례해서 오르지 않고, 프로파일을 떠보면 _int_malloc이나 __lll_lock_wait 같은 함수가 상위에 올라오는 경우가 있다. glibc의 malloc은 아레나(arena) 단위로 락을 잡기 때문에, 짧은 객체를 자주 할당·해제하는 코드에서는 이 락이 병목이 된다. tcmalloc은 스레드마다 로컬 캐시를 두어 대부분의 할당에서 락을 아예 건드리지 않는 할당자로, 코드를 고치지 않고 LD_PRELOAD 한 줄로 바꿔 끼울 수 있다. 이 글에서는 Ubuntu 24.04에서 tcmalloc을 적용하는 방법과, 같은 벤치마크로 glibc와 비교한 처리량·메모리 사용량, 그리고 잘못 튜닝했을 때 오히려 느려지는 사례를 정리한다.

설치와 라이브러리 선택

sudo apt install -y libgoogle-perftools-dev google-perftools
ls /usr/lib/x86_64-linux-gnu/libtcmalloc*.so.4
/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4
/usr/lib/x86_64-linux-gnu/libtcmalloc_and_profiler.so.4
/usr/lib/x86_64-linux-gnu/libtcmalloc_debug.so.4
/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4
라이브러리포함 기능쓰는 곳
libtcmalloc_minimal할당자만성능만 원할 때 (권장 기본값)
libtcmalloc할당자 + 힙 프로파일러 + 힙 체커메모리 사용처를 함께 보고 싶을 때
libtcmalloc_and_profiler위 + CPU 프로파일러gperftools 프로파일링까지 할 때
libtcmalloc_debug경계 검사 등 디버그 빌드힙 오염 추적

적용 방법

기존 바이너리를 그대로 두고 바꿔 끼우려면 LD_PRELOAD면 된다. 실제로 로드됐는지는 LD_DEBUG=libs로 확인한다.

LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 ./myapp

# 정말 적용됐는지 확인
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 LD_DEBUG=libs ./myapp 2>&1 >/dev/null | grep tcmalloc
34102:	calling init: /usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4
     34102:	calling fini: /usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 [0]

빌드 단계에서 링크할 수도 있다. 이때는 컴파일러가 malloc/free 호출을 자체 최적화로 치환하지 않도록 -fno-builtin-*를 함께 준다.

gcc -O2 -fno-builtin-malloc -fno-builtin-calloc \
    -fno-builtin-realloc -fno-builtin-free \
    -o myapp myapp.c -lpthread -ltcmalloc_minimal

ldd myapp | grep tcmalloc
libtcmalloc_minimal.so.4 => /lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 (0x000076229f642000)

측정용 벤치마크

스레드마다 16B~4KB 크기의 블록을 무작위로 할당·해제하며 실제로 첫 바이트를 건드리는 워크로드다. 처리량과 함께 최대 RSS를 남겨 메모리 트레이드오프도 같이 본다.

#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#define SLOTS 128

static int nthreads, iterations;

static void *worker(void *arg)
{
    unsigned seed = (unsigned)(size_t)arg;
    void *slot[SLOTS] = {0};

    for (int i = 0; i < iterations; i++) {
        int idx = rand_r(&seed) % SLOTS;
        size_t sz = 16 + rand_r(&seed) % 4080;   /* 16B ~ 4KB */
        free(slot[idx]);
        slot[idx] = malloc(sz);
        if (slot[idx])
            ((char *)slot[idx])[0] = (char)i;    /* 실제로 페이지를 건드린다 */
    }
    for (int i = 0; i < SLOTS; i++)
        free(slot[i]);
    return NULL;
}

static long rss_kb(void)
{
    FILE *f = fopen("/proc/self/status", "r");
    char line[256];
    long kb = 0;
    while (f && fgets(line, sizeof(line), f))
        if (sscanf(line, "VmHWM: %ld kB", &kb) == 1)
            break;
    if (f) fclose(f);
    return kb;
}

int main(int argc, char **argv)
{
    nthreads   = argc > 1 ? atoi(argv[1]) : 4;
    iterations = argc > 2 ? atoi(argv[2]) : 2000000;

    pthread_t th[64];
    struct timespec t0, t1;

    clock_gettime(CLOCK_MONOTONIC, &t0);
    for (long i = 0; i < nthreads; i++)
        pthread_create(&th[i], NULL, worker, (void *)(i + 1));
    for (int i = 0; i < nthreads; i++)
        pthread_join(th[i], NULL);
    clock_gettime(CLOCK_MONOTONIC, &t1);

    double sec = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
    printf("threads=%2d  elapsed=%6.3fs  %8.2f Mops/s  peak RSS=%ld KB\n",
           nthreads, sec, nthreads * (double)iterations / sec / 1e6, rss_kb());
    return 0;
}

glibc와 비교

6코어 장비에서 스레드당 1000만 회씩 돌린 결과다.

for n in 1 4 12; do ./mallocbench $n 10000000; done
for n in 1 4 12; do LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 ./mallocbench $n 10000000; done
할당자스레드경과 시간처리량최대 RSS
glibc10.573초17.47 Mops/s2,276 KB
glibc40.691초57.91 Mops/s3,804 KB
glibc121.637초73.28 Mops/s8,164 KB
tcmalloc_minimal10.136초73.37 Mops/s8,844 KB
tcmalloc_minimal40.162초247.37 Mops/s11,488 KB
tcmalloc_minimal120.382초314.07 Mops/s18,372 KB
tcmalloc (풀버전)10.129초77.68 Mops/s9,568 KB
tcmalloc (풀버전)40.161초248.94 Mops/s12,180 KB
tcmalloc (풀버전)120.461초260.34 Mops/s18,556 KB

스레드가 하나일 때도 4.2배 빠르다. 즉 이 차이는 락 경합만이 아니라 할당 경로 자체의 길이에서도 온다. 대신 최대 RSS는 12스레드에서 8.1MB → 18.4MB로 2.2배 늘었다 — 스레드 캐시가 메모리를 미리 쥐고 있기 때문이며, 속도를 메모리로 사는 구조다.

glibc가 느린 이유 확인

glibc는 스레드가 락 경합을 만나면 아레나를 늘려 회피한다. MALLOC_ARENA_MAX로 그 수를 강제로 줄이면 경합이 그대로 드러난다.

for a in 1 4 8; do MALLOC_ARENA_MAX=$a ./mallocbench 12 10000000; done
MALLOC_ARENA_MAX=1  : threads=12  elapsed=39.666s      3.03 Mops/s  peak RSS=7976 KB
MALLOC_ARENA_MAX=4  : threads=12  elapsed=11.845s     10.13 Mops/s  peak RSS=8036 KB
MALLOC_ARENA_MAX=8  : threads=12  elapsed= 1.942s     61.79 Mops/s  peak RSS=6148 KB
(제한 없음)         : threads=12  elapsed= 1.637s     73.28 Mops/s  peak RSS=8164 KB

아레나 하나에 12스레드를 몰아넣으면 3.03 Mops/s로 24배 느려진다. 아레나를 늘리는 것이 곧 락을 쪼개는 것이고, tcmalloc은 이 구조를 스레드 로컬 캐시로 한 단계 더 밀어붙인 셈이다.

스레드 캐시 크기 튜닝

TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES는 모든 스레드 캐시의 합계 상한이다. 현재 값은 MallocExtension으로 직접 물어볼 수 있다.

#include <gperftools/malloc_extension.h>
#include <cstdio>

int main()
{
    size_t v = 0;
    const char *keys[] = {
        "tcmalloc.max_total_thread_cache_bytes",
        "tcmalloc.current_total_thread_cache_bytes",
        "tcmalloc.pageheap_free_bytes",
    };
    for (auto k : keys)
        if (MallocExtension::instance()->GetNumericProperty(k, &v))
            printf("%-45s = %zu (%.1f MB)\n", k, v, v / 1048576.0);
    return 0;
}
$ g++ -O2 -o tcprops tcprops.cc -ltcmalloc_minimal && ./tcprops
tcmalloc.max_total_thread_cache_bytes         = 33554432 (32.0 MB)
tcmalloc.current_total_thread_cache_bytes     = 8 (0.0 MB)
tcmalloc.pageheap_free_bytes                  = 950272 (0.9 MB)

이 환경의 기본 상한은 32MB다. 메모리를 아끼겠다고 이 값을 줄이면 캐시 미스가 중앙 힙 락으로 몰린다.

for sz in 1048576 16777216 67108864; do
  LD_PRELOAD=$TCM TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES=$sz ./mallocbench 12 10000000
done
캐시 상한경과 시간처리량최대 RSS
1 MB9.754초12.30 Mops/s16,480 KB
16 MB0.394초304.25 Mops/s18,232 KB
32 MB (기본값)0.382초314.07 Mops/s18,372 KB
64 MB0.342초350.81 Mops/s16,172 KB

1MB로 조이면 glibc(73.28 Mops/s)보다도 6배 느려진다. 반대로 64MB로 키우면 12% 더 빨라지면서 RSS는 오히려 줄었다 — 캐시가 넉넉하면 중앙 힙과 주고받는 횟수가 줄어 단편화가 덜하기 때문이다. 값을 바꿀 때는 반드시 실제 워크로드로 재봐야 한다.

환경 변수이 환경의 값효과
TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES32MB스레드 캐시 총량 상한
TCMALLOC_RELEASE_RATE1.0해제한 메모리를 OS에 돌려주는 속도 (0이면 안 돌려줌)
TCMALLOC_SAMPLE_PARAMETER미설정힙 샘플링 간격(바이트). 설정해야 샘플링이 켜진다
HEAPPROFILE지정하면 힙 프로파일을 주기적으로 덤프

힙 프로파일 보기

풀버전 libtcmalloc에는 힙 프로파일러가 들어 있다. HEAPPROFILE에 경로를 주면 실행 중 프로파일을 덤프하고, google-pprof로 읽는다.

LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 \
  HEAPPROFILE=/tmp/hp ./mallocbench 4 3000000

google-pprof --text ./mallocbench /tmp/hp.0001.heap
Dumping heap profile to /tmp/hp.0023.heap (Exiting, 5 kB in use)
threads= 4  elapsed=28.820s      0.42 Mops/s  peak RSS=13884 KB

Total: 1.0 MB
     1.0  99.9%  99.9%      1.0  99.9% worker
     0.0   0.1% 100.0%      0.0   0.1% allocate_dtv
     0.0   0.0% 100.0%      1.0  99.9% clone3
     0.0   0.0% 100.0%      0.0   0.1% main
     0.0   0.0% 100.0%      1.0  99.9% start_thread

할당의 99.9%가 worker에서 나온 것이 바로 잡힌다. 대신 같은 워크로드가 248 Mops/s에서 0.42 Mops/s로 떨어졌다 — 프로파일러는 모든 할당의 콜스택을 뜨므로, 운영 중인 서비스에 그냥 켜면 안 된다.

주의사항

  • 메모리를 더 쓴다: 위 측정에서 RSS가 2.2배 늘었다. 컨테이너 메모리 제한이 빡빡한 환경에서는 TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES와 함께 한도를 다시 잡아야 한다.
  • 튜닝은 양날: 캐시를 1MB로 줄이면 glibc보다 6배 느려진다. 기본값에서 시작해 실측으로만 움직인다.
  • glibc도 요즘은 빠르다: glibc 2.26의 tcache 도입 이후 단일 스레드 격차는 크게 줄었다. 여기서 4배가 난 것은 16B~4KB를 초당 수천만 번 할당하는 극단적 워크로드이기 때문으로, 할당 빈도가 낮은 애플리케이션에서는 차이가 거의 없다.
  • valgrind와 함께 못 쓴다: valgrind는 자체 할당자를 끼우므로 tcmalloc과 충돌한다. 메모리 오류를 잡을 때는 LD_PRELOAD를 빼고 돌린다.
  • 정적 링크 시 순서 주의: -ltcmalloc은 링크 라인 뒤쪽에 두고 -fno-builtin-*를 빠뜨리지 않는다. 빠뜨리면 컴파일러가 인라인한 할당 경로가 glibc로 남아 두 할당자가 섞인다.
  • fork 안전성: 멀티스레드 상태에서 fork()exec() 없이 할당을 계속하는 코드는 할당자 종류와 무관하게 위험하다. tcmalloc으로 바꿨더니 재현 빈도만 달라지는 경우가 있다.

마무리

tcmalloc은 코드를 한 줄도 안 고치고 LD_PRELOAD만으로 시험해 볼 수 있는 몇 안 되는 성능 개선책이다. 할당이 잦은 멀티스레드 워크로드라면 이 글의 측정처럼 4배 이상 차이가 날 수 있지만, 그만큼 메모리를 더 쓰고 캐시 설정을 잘못 잡으면 glibc보다 느려지기도 한다. 도입 전에 자기 워크로드로 처리량과 RSS를 함께 재보고, 원인이 정말 할당자인지는 프로파일로 먼저 확인하는 순서가 안전하다.

참고

답글 남기기