pahole과 BTF로 구조체 메모리 레이아웃 확인하기 — 패딩부터 커널 라이브 구조체까지

구조체 필드 순서가 메모리 낭비와 캐시 효율에 영향을 준다는 건 다들 안다. 문제는 “그래서 지금 이 구조체가 얼마나 낭비하고 있는지”를 눈으로 확인하기가 쉽지 않다는 것이다. pahole은 DWARF 디버그 정보나 커널의 BTF(BPF Type Format)를 읽어서 구조체의 실제 메모리 레이아웃 — 필드 오프셋, 패딩 구멍, 캐시라인 경계까지 — 을 그대로 보여준다. 이 글에서는 직접 만든 구조체로 패딩 낭비를 확인하고 필드 순서만 바꿔 크기를 줄인 뒤, KFENCE 글에서 다뤘던 실제 커널 구조체를 라이브 BTF로 들여다본다.

필드 순서가 만드는 낭비

타입 크기가 뒤죽박죽인 구조체를 하나 만들었다.

struct bad_layout {
	char a;
	long b;
	char c;
	int d;
	char e;
};

struct bad_layout g;

int main(void)
{
	return (int)sizeof(g);
}

디버그 정보를 포함해 컴파일한 뒤 pahole로 들여다본다.

$ gcc -g -O0 -c bad_struct.c -o bad_struct.o
$ pahole bad_struct.o

struct bad_layout {
	char                       a;                    /*     0     1 */

	/* XXX 7 bytes hole, try to pack */

	long int                   b;                    /*     8     8 */
	char                       c;                    /*    16     1 */

	/* XXX 3 bytes hole, try to pack */

	int                        d;                    /*    20     4 */
	char                       e;                    /*    24     1 */

	/* size: 32, cachelines: 1, members: 5 */
	/* sum members: 15, holes: 2, sum holes: 10 */
	/* padding: 7 */
	/* last cacheline: 32 bytes */
};

실제 필드가 담는 데이터는 15바이트뿐인데 구조체 전체 크기는 32바이트다. char a 다음에 long b가 오면서 8바이트 정렬을 맞추느라 7바이트 구멍이 생기고, char c 다음 int d도 같은 이유로 3바이트가 샌다.

필드만 재배치해서 절반으로 줄이기

규칙은 단순하다. 큰 타입부터 작은 타입 순으로 배치하면 대부분의 정렬 구멍이 사라진다.

struct good_layout {
	long b;
	int d;
	char a;
	char c;
	char e;
};
$ gcc -g -O0 -c good_struct.c -o good_struct.o
$ pahole good_struct.o

struct good_layout {
	long int                   b;                    /*     0     8 */
	int                        d;                    /*     8     4 */
	char                       a;                    /*    12     1 */
	char                       c;                    /*    13     1 */
	char                       e;                    /*    14     1 */

	/* size: 16, cachelines: 1, members: 5 */
	/* padding: 1 */
	/* last cacheline: 16 bytes */
};

필드 타입은 그대로 두고 순서만 바꿨는데 32바이트에서 16바이트로, 정확히 절반이 됐다. 구멍(hole)은 완전히 사라지고 끝에 1바이트 패딩만 남았다.

커널 BTF로 실제 구조체 들여다보기

CONFIG_DEBUG_INFO_BTF=y로 빌드된 커널은 자기 자신의 타입 정보를 /sys/kernel/btf/vmlinux에 그대로 노출한다. 별도로 디버그 심볼이 붙은 vmlinux 파일을 구하지 않아도, pahole -C로 이 파일을 바로 조회할 수 있다. 앞서 다룬 KFENCE의 내부 구조체를 들여다봤다.

$ sudo pahole -C kfence_metadata /sys/kernel/btf/vmlinux

struct kfence_metadata {
	struct list_head           list;                 /*     0    16 */
	struct callback_head       callback_head;        /*    16    16 */
	raw_spinlock_t             lock;                 /*    32     4 */
	enum kfence_object_state   state;                /*    36     4 */
	long unsigned int          addr;                 /*    40     8 */
	size_t                     size;                 /*    48     8 */
	struct kmem_cache *        cache;                /*    56     8 */
	/* --- cacheline 1 boundary (64 bytes) --- */
	long unsigned int          unprotected_page;     /*    64     8 */
	struct kfence_track        alloc_track;          /*    72   536 */
	/* --- cacheline 9 boundary (576 bytes) was 32 bytes ago --- */
	struct kfence_track        free_track;           /*   608   536 */
	/* --- cacheline 17 boundary (1088 bytes) was 56 bytes ago --- */
	u32                        alloc_stack_hash;     /*  1144     4 */

	/* XXX 4 bytes hole, try to pack */

	/* --- cacheline 18 boundary (1152 bytes) --- */
	struct slabobj_ext         obj_exts;             /*  1152     8 */

	/* size: 1160, cachelines: 19, members: 12 */
	/* sum members: 1156, holes: 1, sum holes: 4 */
	/* last cacheline: 8 bytes */
};

KFENCE가 감시하는 객체 하나당 메타데이터가 1160바이트, 19개 캐시라인에 걸쳐 있다는 걸 실제로 확인할 수 있다. 대부분은 할당/해제 시점의 스택트레이스를 담는 alloc_track/free_track(각각 536바이트)이 차지한다. 캐시라인 경계 주석 덕분에 어떤 필드가 서로 다른 캐시라인에 걸쳐 있는지도 바로 보인다.

주의사항

  • CONFIG_DEBUG_INFO_BTF가 꺼진 커널에는 /sys/kernel/btf/vmlinux 자체가 없다. 이 경우 커널 빌드 시 생성된 vmlinux(디버그 심볼 포함) 파일 경로를 pahole -C <struct> vmlinux처럼 직접 지정해야 한다.
  • 구멍(hole)이 있다고 항상 나쁜 건 아니다. 멀티코어 환경에서 서로 다른 CPU가 자주 갱신하는 필드를 일부러 별도 캐시라인에 떨어뜨려 false sharing을 막으려고 의도적으로 패딩을 넣는 경우도 있다. pahole 결과를 기계적으로 “구멍=버그”로 해석하면 안 된다.
  • pahole -C로 지정한 이름이 anon union/anon struct 안에 중첩되어 있으면 원하는 정의가 바로 안 나올 수 있다. -E(모든 타입 확장)나 --class_dump 같은 옵션과 조합해 찾아야 하는 경우가 있다.
  • 큰 구조체를 재배치할 때는 크기만 보지 말고 실제로 자주 같이 접근되는 필드가 같은 캐시라인에 있는지도 함께 고려해야 한다. 무작정 크기순으로만 정렬하면 접근 지역성이 오히려 나빠질 수 있다.

마무리

필드 순서만 바꿔 구조체 크기를 정확히 절반으로 줄인 것처럼, “정렬 때문에 낭비되는 바이트”는 감이 아니라 pahole로 바로 눈에 보이는 숫자다. 더 나아가 커널이 BTF를 내장하고 있는 요즘은 별도 디버그 빌드 없이도 라이브 커널의 아무 구조체나 이렇게 들여다볼 수 있다는 게 큰 장점이다. 메모리 사용량이나 캐시 미스가 의심스러운 구조체가 있다면, 최적화를 시도하기 전에 먼저 pahole로 실제 레이아웃부터 확인하는 게 순서다.

참고

답글 남기기