Clang 빌드된 커널에 LTO 옵션 적용 시 Slab 메모리 비교

앞선 두 글에서 GCC/Clang 빌드와 KCFI 빌드의 슬랩(slab) 사용량을 비교할 때 쓴 테스트 커널은 모듈을 하나도 올리지 않은 상태였다. LTO(Link Time Optimization)도 같은 방식으로 재면 부팅 직후 슬랩은 Clang 빌드와 차이가 없다. 그런데 같은 커널에 모듈 128개를 올리고 나면 ThinLTO 쪽 Slab 증가량이 Clang보다 4.4MB 크다. 슬랩 자료구조가 바뀌어서가 아니라, LTO로 빌드한 .ko에 함수마다 .text.* 섹션이 그대로 남고 그 섹션 하나하나가 /sys/module/<모듈>/sections 아래 sysfs 파일이 되기 때문이다. 이 글에서는 커널 6.12에서 이 현상을 재현하고, scripts/module.lds.S 한 곳을 고치면 차이가 사라지는 것까지 확인한다.

LTO 모듈에는 함수마다 .text 섹션이 남는다

lld는 LTO 코드 생성 단계에서 -ffunction-sections/-fdata-sections를 항상 켠다. 쪼개진 섹션을 다시 합치는 것은 scripts/module.lds.S의 몫인데, 6.12에는 .text 규칙이 빠져 있다.

#ifdef CONFIG_LTO_CLANG
	/*
	 * With CONFIG_LTO_CLANG, LLD always enables -fdata-sections and
	 * -ffunction-sections, which increases the size of the final module.
	 * Merge the split sections in the final binary.
	 */
	.bss : {
		*(.bss .bss.[0-9a-zA-Z_]*)
		*(.bss..L*)
	}

	.data : {
		*(.data .data.[0-9a-zA-Z_]*)
		*(.data..L*)
		CODETAG_SECTIONS()
	}

	.rodata : {
		*(.rodata .rodata.[0-9a-zA-Z_]*)
		*(.rodata..L*)
	}
#else

주석은 “쪼개진 섹션을 최종 바이너리에서 병합한다”고 말하지만 정작 .text 병합 규칙이 없다. 같은 .config로 빌드한 efivarfs.ko를 비교하면 결과가 그대로 드러난다.

$ objdump -h ko-clang/efivarfs.ko | awk '/^ *[0-9]+ /{print $2}' | grep -c '^\.text'
1
$ objdump -h ko-ltothin/efivarfs.ko | awk '/^ *[0-9]+ /{print $2}' | grep '^\.text' | head -5
.text
.text.efivarfs_get_inode
.text.efivarfs_valid_name
.text.efivarfs_create.llvm.4100031353185135043
.text.efivarfs_unlink.llvm.4100031353185135043
$ objdump -h ko-ltothin/efivarfs.ko | awk '/^ *[0-9]+ /{print $2}' | grep -c '^\.text'
40

같은 .config에서 모듈로 뺀 131개 .ko 전체를 세면 이렇다.

빌드131개 .ko 전체 섹션그중 .text*xfs.kobtrfs.kof2fs.ko
Clang (LTO 없음)3,394133414240
ThinLTO16,91513,7743,7782,291983
Full LTO16,95913,8183,7782,3011,017

모듈 개수가 아니라 함수 개수에 비례한다. xfs.ko 하나에만 .text 섹션이 3,742개 생긴다.

섹션 수가 곧 sysfs 파일 수이고, 그게 슬랩이다

CONFIG_KALLSYMS가 켜져 있으면 모듈을 적재할 때 add_sect_attrs()가 비어있지 않은 SHF_ALLOC 섹션마다 sysfs 바이너리 속성을 하나씩 만든다.

	/* Count loaded sections and allocate structures */
	for (i = 0; i < info->hdr->e_shnum; i++)
		if (!sect_empty(&info->sechdrs[i]))
			nloaded++;
	size[0] = ALIGN(struct_size(sect_attrs, attrs, nloaded),
			sizeof(sect_attrs->grp.bin_attrs[0]));
	size[1] = (nloaded + 1) * sizeof(sect_attrs->grp.bin_attrs[0]);
	sect_attrs = kzalloc(size[0] + size[1], GFP_KERNEL);
	...
		sattr->battr.attr.name =
			kstrdup(info->secstrings + sec->sh_name, GFP_KERNEL);
	...
	ret = sysfs_create_group(&mod->mkobj.kobj, &sect_attrs->grp);

섹션 하나가 만드는 할당은 네 개다. sysfs 파일이 생기면서 kernfs_node가 하나 더 붙고, 이름 문자열은 모듈 쪽과 kernfs 쪽에서 각각 한 번씩 복사된다.

할당캐시섹션당 크기
섹션 이름 kstrdupkmalloc-32/kmalloc-6432~64B
sysfs 파일의 kernfs_nodekernfs_node_cache136B
kernfs_node 이름 kstrdupkmalloc-32/kmalloc-6432~64B
module_sect_attr + 포인터모듈당 kzalloc 한 번104B

재현: 모듈 128개 적재

같은 .config에 파일시스템·네트워크·crypto 모듈을 =m으로 추가해 세 빌드를 만들고, .ko를 전부 넣은 초기 램디스크로 QEMU를 띄웠다. 적재 순서를 모르므로 전체를 5회 반복해 insmod한다.

mount -t proc none /proc
mount -t sysfs none /sys
sleep 5
grep -E "MemTotal|^Slab|SUnreclaim" /proc/meminfo   # 적재 전
cat /proc/slabinfo
for p in 1 2 3 4 5; do
  for m in /lib/modules/*.ko; do
    insmod $m 2>/dev/null
  done
done
grep -E "MemTotal|^Slab|SUnreclaim" /proc/meminfo   # 적재 후
cat /proc/slabinfo
for d in /sys/module/*/sections; do
  m=$(echo $d | cut -d/ -f4)
  c=$(ls -a $d | wc -l)
  echo "$m $((c-2))"                              # . 과 .. 를 뺀다
done
poweroff -f
qemu-system-x86_64 -machine q35 -cpu max \
  -kernel build-ltothin/arch/x86/boot/bzImage \
  -initrd ir2-ltothin.cpio.gz \
  -append "console=ttyS0 no_timer_check slab_nomerge" \
  -nographic -m 3072 -smp 2 -no-reboot

캐시 병합이 켜져 있으면 kernfs_node_cache가 다른 캐시에 흡수돼 /proc/slabinfo에서 이름으로 보이지 않으므로 slab_nomerge로 부팅했다. 적재 전 값은 세 빌드가 같고, 적재 후에만 갈린다.

[적재 전] Clang / ThinLTO 동일
kernfs_node_cache  14520  14520    136   30    1 : tunables    0    0    0 : slabdata    484    484      0
kmalloc-64          1408   1408     64   64    1 : tunables    0    0    0 : slabdata     22     22      0
kmalloc-32          1280   1280     32  128    1 : tunables    0    0    0 : slabdata     10     10      0

[적재 후] Clang
kernfs_node_cache  26370  26370    136   30    1 : tunables    0    0    0 : slabdata    879    879      0
kmalloc-64          2496   2496     64   64    1 : tunables    0    0    0 : slabdata     39     39      0
kmalloc-32          5207   5248     32  128    1 : tunables    0    0    0 : slabdata     41     41      0

[적재 후] ThinLTO
kernfs_node_cache  39750  39750    136   30    1 : tunables    0    0    0 : slabdata   1325   1325      0
kmalloc-64         12160  12160     64   64    1 : tunables    0    0    0 : slabdata    190    190      0
kmalloc-32         21886  21888     32  128    1 : tunables    0    0    0 : slabdata    171    171      0
빌드sections 파일 수Slab 증가kernfs_node_cache 증가kmalloc-32 증가kmalloc-64 증가
Clang (LTO 없음)3,014+9,704 kB+11,850+3,927+1,088
ThinLTO16,325+14,104 kB+25,230+20,606+10,752
Full LTO16,369+14,640 kB+25,260+22,270+9,344
ThinLTO + .text 병합 패치2,898+9,792 kB+11,760+3,971+1,111

ThinLTO는 Clang보다 sysfs 섹션 파일이 13,311개 많고, kernfs_node는 13,380개 더 잡혔다. 이름 문자열이 들어가는 kmalloc-32kmalloc-64는 합쳐서 26,343개 늘어, 섹션당 두 번 복사된다는 계산(13,311×2=26,622)과 맞는다. 슬랩 전체로는 4.4MB, 섹션 하나당 338바이트다.

module.lds.S만 고치면 사라진다

인과를 확인하려면 커널 이미지는 그대로 두고 .ko만 바꾸면 된다. 6.19의 .text 병합 블록을 6.12에 얹고 ThinLTO 모듈만 다시 링크한 뒤, 같은 bzImage로 부팅했다.

# scripts/module.lds.S 의 #ifdef CONFIG_LTO_CLANG 블록 안에 추가
	.text : {
		*(.text .text.[0-9a-zA-Z_]*)
	}

# 모듈만 다시 링크 (bzImage 는 건드리지 않는다)
make O=../build-ltothin LLVM=1 -j6 modules
$ objdump -h ko-ltothinfix/efivarfs.ko | awk '/^ *[0-9]+ /{print $2}' | grep -c '^\.text'
1

위 표의 마지막 행이 그 결과다. sysfs 섹션 파일이 2,898개로 떨어지고 Slab 증가량도 Clang과 같은 수준(+9,792 kB)으로 돌아온다. 커널 코드는 여전히 ThinLTO로 빌드된 것이므로, 4.4MB는 전적으로 링커 스크립트에서 온 비용이다.

6.1에서 빠지고 6.19에서 돌아온 규칙

.text 병합 규칙은 원래 있었다. 6.1의 KCFI 전환 커밋이 옛 CFI용 ALIGN_CFI 정렬과 함께 블록을 통째로 지우면서 같이 사라졌고, 6.19에서 라이브패치 klp-build 준비 작업으로 TEXT_MAIN 매크로를 통일하면서 LTO와 무관하게 다시 들어왔다.

버전모듈 .text 병합계기
~6.0있음(LTO일 때만)Clang LTO 지원과 함께 도입
6.1~6.18없음-fsanitize=kcfi 전환 시 블록 삭제
6.19~있음(항상)TEXT_MAIN 매크로 통일

즉 LTS인 6.1/6.6/6.12에서 LTO 커널을 쓰면 이 비용이 그대로 남아 있다.

주의사항

  • 비용은 적재한 모듈 수가 아니라 그 모듈들의 함수 수에 비례한다. 모듈을 몇 개 안 올리는 임베디드 타깃이라면 무시할 수준이고, xfs/btrfs처럼 큰 모듈을 올리는 서버라면 모듈 하나로 1MB 가까이 차이가 난다.
  • 늘어나는 것은 슬랩뿐이고 모듈 본체(vmalloc)는 거의 그대로다. lsmod 크기 합계는 Clang 11,344 kB, ThinLTO 11,584 kB로 240 kB 차이에 그친다.
  • CONFIG_KALLSYMS를 끄면 sections 디렉터리 자체가 만들어지지 않아 비용도 사라지지만, oops 백트레이스 해독을 포기하는 대가가 훨씬 크다.
  • 측정은 slab_nomerge 기준이다. 기본 설정에서는 kernfs_node_cache가 같은 크기의 다른 캐시로 병합돼 /proc/slabinfo에서 이름을 찾지 못할 수 있다. 총량(Slab) 비교에는 영향이 없다.
  • QEMU TCG에서 게스트가 간헐적으로 멈춘다. Full LTO 커널이 zsmalloc 적재에서 재현성 있게 멎어 zram/zsmalloc은 네 실행 모두에서 뺐고, 반복 측정에서는 raid456 적재 중에도 같은 증상이 나왔다. 네 구성 모두 같은 128개를 올렸으므로 비교 자체에는 영향이 없다.
  • 표의 값은 구성별 1회 부팅 결과다. 다만 커널 이미지가 같고 .ko만 다른 패치본이 Clang 기준선과 90 kB 안에서 만나므로, 4.4MB가 부팅 간 잡음일 가능성은 없다(앞선 글에서 잰 부팅 간 변동은 0.1MB 수준이다).

마무리

LTO는 슬랩 캐시의 정의도, 부팅 직후 사용량도 바꾸지 않는다. 대신 모듈을 함수 단위로 쪼갠 섹션이 그대로 sysfs 파일 수가 되고, 그 파일 수가 kernfs_node_cachekmalloc-32/64로 나타난다. 6.12에서 모듈 128개 기준 4.4MB이고, 원인은 링커 스크립트 세 줄이다. 6.19 이후 커널에서는 이미 해결됐으므로, LTS에서 LTO를 켜서 쓴다면 백포트해 볼 만한 항목이다.

참고

답글 남기기