리눅스 커널 GCC 빌드와 Clang 빌드에 따른 Slab 메모리 차이 분석

커널을 Clang으로 빌드하면 GCC 빌드와 메모리 사용량이 달라질까. 컴파일러가 바뀌면 인라이닝과 코드 생성이 달라지니 커널이 잡는 슬랩(slab) 메모리도 따라 달라질 것 같지만, 실제로 재보면 어느 쪽이 얼마나 다른지 숫자로 답한 자료를 찾기 어렵다. 커널 6.12 defconfig를 GCC 13.3과 Clang 18.1로 각각 빌드해 동일한 QEMU 환경에서 빌드당 11회씩 부팅하며 /proc/slabinfo/sys/kernel/slab을 비교했다. 이 글에서는 그 측정 결과와, 차이가 나 보이는 항목이 실제로는 무엇 때문인지 정리한다.

실험 구성

항목
커널linux-6.12 (kernel.org 타르볼), x86_64 defconfig
GCCgcc 13.3.0 (Ubuntu 24.04) + GNU ld 2.42
Clangclang 18.1.3 + ld.lld 18.1.3 (make LLVM=1)
실행 환경QEMU 8.2 TCG, -machine q35 -cpu max -m 2048 -smp 2
루트busybox-static initramfs (두 빌드 동일 파일 사용)
수집 시점부팅 후 sleep 5 — 비동기 초기화가 끝난 뒤
반복빌드당 11회(측정 1: 5회, 측정 2: 6회), 총 22회 부팅

두 빌드가 같은 커널이어야 비교가 성립하므로, Clang 빌드는 GCC의 .config를 그대로 받아 olddefconfig로 맞췄다.

# 소스 하나로 두 빌드를 따로 만든다 (O= 로 출력 디렉토리 분리)
cd ~/kbuild/linux-6.12
make O=../build-gcc defconfig
cp ../build-gcc/.config ~/kbuild/config.gcc
make O=../build-gcc -j6 bzImage

# Clang 빌드는 GCC의 .config 를 그대로 받아 olddefconfig 로 맞춘다
cp ~/kbuild/config.gcc ../build-clang/.config
make O=../build-clang LLVM=1 olddefconfig
make O=../build-clang LLVM=1 -j6 bzImage

그래도 남는 차이가 있다. 컴파일러 능력으로 자동 결정되는 심볼은 사용자가 맞출 수 없다.

$ diff config.gcc config.clang | grep -E '^[<>]' | grep -v '^[<>] #'
< CONFIG_CC_VERSION_TEXT="gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0"
< CONFIG_CC_IS_GCC=y
...
< CONFIG_CC_NO_ARRAY_BOUNDS=y
< CONFIG_CC_NO_STRINGOP_OVERFLOW=y
< CONFIG_CC_HAS_NAMED_AS=y
< CONFIG_CC_HAS_NAMED_AS_FIXED_SANITIZERS=y
< CONFIG_USE_X86_SEG_SUPPORT=y
> CONFIG_HAS_LTO_CLANG=y
> CONFIG_HAVE_CFI_ICALL_NORMALIZE_INTEGERS_CLANG=y
> CONFIG_CC_HAS_SANE_FUNCTION_ALIGNMENT=y
> CONFIG_CC_HAS_RANDSTRUCT=y
> CONFIG_CC_HAS_KASAN_SW_TAGS=y
> CONFIG_HAVE_KMSAN_COMPILER=y

대부분 “이 컴파일러로 무엇을 켤 수 있는가”를 표시하는 심볼이라 생성 코드에 영향이 없다. 실제 코드가 달라지는 것은 CONFIG_USE_X86_SEG_SUPPORT 하나로, GCC 13은 named address space를 지원해 per-CPU 접근을 %gs 세그먼트 주소지정으로 직접 뽑지만 Clang 18은 이 기능이 없어 기존 방식으로 컴파일된다. LTO와 CFI는 양쪽 모두 꺼진 상태다.

측정 방법

initramfs의 init이 부팅 직후 상태를 그대로 찍고 전원을 내린다.

#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
sleep 5                       # 비동기 초기화가 끝나기를 기다린다

echo "===MARK counts"
echo "sysfs_nodes $(find /sys | wc -l)"
echo "kthreads $(ls /proc | grep -c "^[0-9]")"
echo "slab_caches $(ls /sys/kernel/slab | wc -l)"

echo "===MARK meminfo"
grep -E "MemTotal|MemFree|Slab|SReclaimable|SUnreclaim|KernelStack" /proc/meminfo

echo "===MARK slabinfo"
cat /proc/slabinfo

echo "===MARK objsize"
for d in /sys/kernel/slab/*/; do
  n=$(basename $d)
  printf "%s %s %s %s\n" "$n" "$(cat $d/object_size)" "$(cat $d/slab_size)" "$(cat $d/objs_per_slab)"
done
echo "===MARK end"
poweroff -f
#!/bin/bash
# usage: boot.sh <gcc|clang> <반복 횟수>
b=$1; n=$2
for i in $(seq 1 $n); do
  timeout 400 qemu-system-x86_64 -machine q35 -cpu max \
    -kernel ~/kbuild/build-$b/arch/x86/boot/bzImage \
    -initrd ~/kbuild/initramfs.cpio.gz \
    -append "console=ttyS0 no_timer_check" \
    -nographic -m 2048 -smp 2 -no-reboot < /dev/null \
    > ~/kbuild/out-$b-$i.log 2>&1
done

슬랩 오브젝트 크기는 완전히 동일하다

먼저 캐시별 오브젝트 크기부터 봤다. /sys/kernel/slab의 305개 항목에 대해 object_size, slab_size, objs_per_slab을 전부 뽑아 두 빌드를 비교했다.

$ wc -l < objsize.gcc
305
$ diff objsize.gcc objsize.clang && echo "완전히 동일"
완전히 동일

$ grep -E '^(task_struct|inode_cache|kernfs_node_cache|dentry) ' objsize.gcc
dentry 192 192 21
inode_cache 592 600 13
kernfs_node_cache 136 136 30
task_struct 5896 5952 5

한 바이트도 다르지 않다. 슬랩 오브젝트 크기는 sizeof(struct ...)로 정해지고, 구조체 레이아웃은 x86-64 psABI가 규정하므로 컴파일러가 임의로 바꿀 수 없기 때문이다. 컴파일러가 레이아웃에 개입하는 경우는 __randomize_layout(randstruct)처럼 명시적으로 켜는 기능뿐인데, 이번 두 빌드 모두 꺼져 있다.

총 사용량은 반복 부팅 변동폭 안에서 겹친다

/proc/meminfoSlab 값을 빌드당 5회 부팅에서 모았다.

빌드평균5회 측정값 (kB)
GCC7866 kB7920 / 7892 / 7936 / 7808 / 7776
Clang7776 kB7696 / 7652 / 7872 / 7768 / 7892

평균 차이는 90 kB(1.1%)인데, 같은 빌드를 다시 부팅하기만 해도 160 kB씩 흔들린다. 즉 두 값은 구분되지 않는다. 캐시별로 봐도 마찬가지다.

캐시GCCClang차이GCC 변동폭Clang 변동폭
kernfs_node_cache1547.2 KB1540.2 KB-7.011.75.8
task_struct435.9 KB441.8 KB+5.80.029.1
kmalloc-1k380.8 KB371.2 KB-9.616.016.0
kmalloc-96354.4 KB353.6 KB-0.80.03.9
ftrace_event_field335.3 KB335.3 KB0.00.00.0
kmalloc-2k332.8 KB352.0 KB+19.232.00.0
radix_tree_node221.7 KB193.2 KB-28.547.344.5

부팅 타이밍의 영향을 받지 않는 ftrace_event_field는 양쪽 모두 5회 내내 6132개로 완전히 같다. 반대로 차이가 커 보이는 radix_tree_node는 자기 자신의 변동폭이 차이보다 크다. 11회 부팅 전체에서 GCC 쪽 값의 범위와 Clang 쪽 값의 범위가 아예 겹치지 않는 캐시는 하나도 없었다.

차이처럼 보였던 kernfs_node_cache의 정체

처음에는 kernfs_node_cache가 체계적으로 다른 것처럼 보였다. /proc/slabinfo의 두 번째 컬럼(num_objs)이 GCC 12005개, Clang 12250개로 갈렸기 때문이다. 그런데 첫 번째 컬럼(active_objs)과 실제 sysfs 노드 수를 같이 보면 이야기가 달라진다.

지표GCC (6회)Clang (6회)
sysfs 노드 수 (find /sys | wc -l)11534 (전 회차 동일)11534 (전 회차 동일)
active_objs11630 (11580~11699)11634 (11595~11655)
num_objs12005 (11580~12420)12250 (12210~12330)
빈 슬롯 (num - active)0~788591~675

살아있는 오브젝트 수는 두 빌드가 사실상 같고, sysfs 노드 개수는 아예 11534개로 완전히 일치한다. 갈린 것은 num_objs, 즉 슬랩이 미리 잡아둔 슬롯 수였다. kernfs_node_cache는 오브젝트 136바이트, 슬랩 1장(4KB)당 30슬롯이므로 슬롯은 30개 단위로만 늘어나고, 마지막에 몇 장을 더 잡아둔 상태로 관측됐는지는 부팅 중 할당·해제 순서에 따라 달라진다. 컴파일러가 아니라 관측 시점의 문제다.

num_objs로 두 커널을 비교하면 이렇게 없는 차이가 보인다. 슬랩 비교는 active_objs를 기준으로 해야 한다.

체계적으로 다른 것: 슬랩이 아니라 커널 이미지

반면 전 회차에서 100% 재현되는 차이도 있다. MemTotal이다.

$ size build-gcc/vmlinux build-clang/vmlinux
   text	   data	    bss	    dec	    hex	filename
26582841	10649754	1581060	38813655	2503fd7	build-gcc/vmlinux
28708153	10472905	1658880	40839938	26f2b02	build-clang/vmlinux

GCC   MemTotal: 2023648 kB   (11회 전부 동일)
Clang MemTotal: 2021656 kB   (11회 전부 동일)

이미지 총합은 Clang 쪽이 2,026,283바이트(+1979 kB) 크고, MemTotal은 정확히 1992 kB 작다. 커널 이미지가 차지한 물리 메모리는 MemTotal에서 아예 빠지므로 두 값이 맞아떨어진다. 즉 이 실험에서 컴파일러가 만든 메모리 차이는 슬랩이 아니라 상주 이미지 크기에서 나왔다.

부수적으로 커널 로그의 used greatest stack depth도 갈렸다. 22회 부팅에서 관측된 최대 스택 사용은 GCC 빌드가 여유 13552바이트, Clang 빌드가 13384바이트로 Clang 쪽이 168바이트 더 깊게 썼다. 다만 커널 스택은 슬랩이 아니라 별도 할당이고 KernelStack 총량은 스레드 수로 정해지므로, 이 차이가 슬랩 사용량으로 이어지지는 않는다.

측정 자체가 슬랩을 늘린다

측정 2에서는 sysfs 노드 수를 세려고 find /sys를 넣었는데, 이것만으로 Slab 총량이 7.8MB에서 18MB로 뛰었다. 어느 캐시가 늘었는지 보면 이유가 분명하다.

캐시find 전find 후증가
inode_cache424개11962개+6761 KB
kmalloc-rcl-192672개12327개+2185 KB
ftrace_event_field6132개17800개+638 KB
proc_inode_cache17개144개+83 KB
합계+9.6 MB

inode_cache 증가분 11538개는 sysfs 노드 수 11534개와 거의 정확히 일치한다. sysfs는 디렉토리를 lookup할 때 inode를 실체화하므로, 읽는 행위가 곧 커널 객체를 만드는 것이다. 두 빌드에 같은 스크립트를 돌렸으니 비교 자체는 유효하지만, 이 숫자를 “부팅 직후 슬랩 사용량”으로 읽으면 안 된다.

주의사항

  • 슬랩을 재는 스크립트가 /sys/proc를 훑으면 그 행위가 슬랩을 늘린다. 수집 코드는 최소한으로 유지하고, 부득이하면 비교 대상 모두에 똑같이 적용할 것.
  • /proc/slabinfonum_objs로 커널 두 개를 비교하지 말 것. 슬랩 페이지 단위로 잡아둔 빈 슬롯까지 세기 때문에 수백 개 단위로 흔들린다. 비교는 active_objs로 한다.
  • 1회 부팅 결과로 결론 내지 말 것. 같은 커널을 다시 부팅해도 Slab 총량이 160 kB 이상 흔들리므로, 최소 5회는 돌려 변동폭을 먼저 확인한 다음 차이를 판단해야 한다.
  • make defconfig를 컴파일러별로 새로 돌리면 .config가 달라져 서로 다른 커널을 비교하게 된다. 한쪽 .config를 복사해 olddefconfig로 맞춘 뒤 남은 차이를 확인하고 시작할 것.
  • KVM을 못 쓰는 환경이라 QEMU TCG로 측정했다. 타이밍 의존적인 부팅 경로가 실제 하드웨어와 다를 수 있으므로 절대값보다 두 빌드의 상대 비교로만 읽어야 한다.
  • 이번 결과는 LTO와 CFI가 모두 꺼진 defconfig 기준이다. CONFIG_CFI_CLANG처럼 자료구조에 손대는 옵션을 켜면 이야기가 달라질 수 있어 별도 측정이 필요하다.

마무리

컴파일러를 GCC에서 Clang으로 바꿔도 슬랩 오브젝트 크기는 305개 캐시 전부 동일하고, 실제 사용량 차이도 반복 부팅 변동폭 안에 묻힌다. 구조체 레이아웃을 ABI가 고정하고 있으니 당연한 결과인데, 이를 확인하는 과정에서 num_objs를 보면 없는 차이가 보인다는 점이 더 실용적인 교훈이었다. 컴파일러 교체로 실제 늘어난 메모리는 슬랩이 아니라 커널 이미지 2MB였다.

참고

답글 남기기