앞선 두 글에서 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.ko | btrfs.ko | f2fs.ko |
|---|---|---|---|---|---|
| Clang (LTO 없음) | 3,394 | 133 | 41 | 42 | 40 |
| ThinLTO | 16,915 | 13,774 | 3,778 | 2,291 | 983 |
| Full LTO | 16,959 | 13,818 | 3,778 | 2,301 | 1,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, §_attrs->grp);섹션 하나가 만드는 할당은 네 개다. sysfs 파일이 생기면서 kernfs_node가 하나 더 붙고, 이름 문자열은 모듈 쪽과 kernfs 쪽에서 각각 한 번씩 복사된다.
| 할당 | 캐시 | 섹션당 크기 |
|---|---|---|
섹션 이름 kstrdup | kmalloc-32/kmalloc-64 | 32~64B |
sysfs 파일의 kernfs_node | kernfs_node_cache | 136B |
kernfs_node 이름 kstrdup | kmalloc-32/kmalloc-64 | 32~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 -fqemu-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 |
| ThinLTO | 16,325 | +14,104 kB | +25,230 | +20,606 | +10,752 |
| Full LTO | 16,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-32와 kmalloc-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_cache와 kmalloc-32/64로 나타난다. 6.12에서 모듈 128개 기준 4.4MB이고, 원인은 링커 스크립트 세 줄이다. 6.19 이후 커널에서는 이미 해결됐으므로, LTS에서 LTO를 켜서 쓴다면 백포트해 볼 만한 항목이다.