리눅스 minor·major 페이지 폴트: ru_minflt와 ru_majflt가 세는 것

리눅스 getrusage의 ru_minflt와 ru_majflt는 페이지 폴트를 I/O 필요 여부로 나눕니다. 두 숫자가 세는 것과, major 폴트 수를 디스크 읽기 횟수로 읽으면 안 되는 이유를 man-pages 6.19 기준으로 정리합니다.

linuxmemorypage-faultgetrusageperformance

/usr/bin/time -v로 명령을 실행하면 출력 중간에 Major (requiring I/O) page faults와 Minor (reclaiming a frame) page faults라는 두 줄이 나옵니다. 이 글은 리눅스에서 이 두 숫자가 무엇을 세는지, 그리고 major 폴트 수를 디스크 읽기 횟수로 읽으면 왜 틀리는지를 다룹니다.

근거 문서는 Linux man-pages 6.19의 getrusage(2), proc_pid_stat(5), mmap(2), time(1), 리눅스 커널 문서(7.3.0-rc6 빌드)의 pagemap 문서, GNU time 1.10 매뉴얼입니다. 여기서 설명하는 필드 의미는 리눅스 기준입니다. POSIX.1은 getrusage()를 규정하지만 필드는 ru_utime과 ru_stime만 정하므로, 폴트 카운터의 의미를 다른 유닉스 계열에 그대로 옮길 수는 없습니다.

기준은 “I/O가 필요했는가”

getrusage(2)의 struct rusage에서 두 필드에는 이런 주석이 달려 있습니다.

long   ru_minflt;        /* page reclaims (soft page faults) */
long   ru_majflt;        /* page faults (hard page faults) */

man 페이지의 설명은 다음과 같습니다.

  • ru_minflt: I/O 없이 처리된 페이지 폴트의 수. 재할당을 기다리는 페이지 목록에서 페이지 프레임을 “회수(reclaim)“해서 I/O를 피한다고 설명합니다.
  • ru_majflt: 처리하는 데 I/O가 필요했던 페이지 폴트의 수.

실행 중인 프로세스의 같은 값은 /proc/<pid>/stat의 10번째 필드(minflt)와 12번째 필드(majflt)에 있습니다. proc_pid_stat(5)는 각각을 디스크에서 메모리 페이지를 읽어 올 필요가 없었던 minor 폴트, 그럴 필요가 있었던 major 폴트로 설명합니다. 11번째 cminflt와 13번째 cmajflt는 이 프로세스가 wait로 회수한 자식 프로세스들의 값입니다.

즉 리눅스 문서가 두 종류를 나누는 기준은 하나입니다. 그 폴트를 처리하는 데 I/O(디스크에서 페이지 읽기)가 필요했는지입니다. 처리에 오래 걸렸는지, 접근한 메모리가 파일과 연결되어 있었는지는 기준이 아닙니다.

폴트가 생기는 이유: 요구 페이징

mmap()은 호출한 프로세스의 가상 주소 공간에 새 매핑을 만듭니다. 매핑이 생겼다고 그 범위의 페이지가 모두 메모리에 올라와 있는 것은 아닙니다. proc_pid_stat(5)의 rss 설명은 상주 집합 크기(RSS)가 아직 요구에 의해 적재되지 않은(demand-loaded) 페이지와 스왑아웃된 페이지를 포함하지 않는다고 적고 있습니다.

mmap(2)의 MAP_POPULATE 플래그(Linux 2.5.46부터)는 같은 사실을 반대쪽에서 보여 줍니다. 이 플래그는 매핑의 페이지 테이블을 미리 채우고(prefault), 파일 매핑이면 파일 read-ahead를 일으켜 나중에 페이지 폴트에서 블록되는 일을 줄입니다. 매핑을 채우지 못해도 mmap() 호출 자체는 실패하지 않습니다. 이런 플래그가 따로 있다는 점에서, 기본 동작에서는 mmap()이 반환된 시점에 페이지가 채워져 있다고 기대할 수 없다는 결론이 나옵니다(플래그 설명에서 끌어낸 추론). MAP_LOCKED 설명도 미리 채우기가 실패할 수 있어서 major 폴트가 나중에 일어날 수 있다고 하고, major 폴트를 허용할 수 없다면 mmap()과 mlock(2)를 함께 쓰라고 권합니다.

그래서 아직 준비되지 않은 페이지에 처음 접근하면 폴트가 나고 커널이 그 폴트를 처리합니다. 처리에 디스크 I/O가 필요했다면 major, 아니었다면 minor로 셉니다.

major 폴트 수가 디스크 읽기 횟수가 아닌 이유

파일 입력은 다른 카운터에 잡힙니다. struct rusage에는 폴트 카운터와 별도로 ru_inblock(Linux 2.6.22부터)이 있고, getrusage(2)는 이를 파일시스템이 입력을 수행해야 했던 횟수로 정의합니다. GNU time은 이 값을 %I(파일시스템 입력)로 따로 출력합니다. GNU time 매뉴얼의 grep 실행 예시에는 46inputs와 (43major+251minor)pagefaults가 함께 나옵니다. 두 숫자는 서로 다른 필드이고, 문서는 둘 사이에 일대일 관계를 정의하지 않습니다. read(2)로 파일을 읽는 프로그램은 매핑된 페이지에 접근해서 데이터를 얻는 것이 아니므로, 그 읽기가 major 폴트 수에 나타난다고 가정할 근거가 없습니다(위 정의에서 끌어낸 추론).

디스크에서 온 데이터라도 이미 메모리에 있으면 minor입니다. 기준은 그 폴트를 처리할 때 I/O가 필요했는가입니다. 파일 매핑의 페이지라도 폴트 시점에 데이터가 이미 메모리에 있어 I/O 없이 처리됐다면 minor로 셉니다(정의에서 끌어낸 추론). 예를 들어 같은 프로그램을 연달아 두 번 실행했는데 두 번째 실행의 major 폴트가 줄었다면, 첫 실행에서 읽은 파일 데이터가 아직 메모리에 남아 있었다는 설명과 맞습니다. 다만 숫자 하나로 그 이유를 확정할 수는 없고, 어떤 페이지가 메모리에 있었는지는 아래에 소개하는 별도 수단으로 확인해야 합니다. free가 보여 주는 메모리 항목은 free의 available 글에서 따로 다뤘습니다.

도구마다 다른 표현

출력 라벨과 도구 문서의 설명은 getrusage(2)의 정의와 표현이 조금 다릅니다.

  • time(1)의 GNU 1.7 설명은 %F를 페이지를 디스크에서 읽어 와야 하는 폴트로, %R을 유효하지 않지만 아직 다른 가상 페이지가 가져가지 않은 페이지에 대한 폴트로 설명합니다. 이 경우 페이지의 데이터는 여전히 유효하고 시스템 테이블만 갱신하면 된다고 합니다.
  • GNU time 1.10 매뉴얼은 %F를 I/O가 필요한(I/O-requiring) 폴트, 즉 페이지가 실제로 주 메모리 밖으로 옮겨졌던 경우로 설명합니다. -v 출력 라벨은 Major (requiring I/O) page faults와 Minor (reclaiming a frame) page faults입니다.

time(1)의 BUGS 절은 측정 항목 선택이 대부분 4.2BSD나 4.3BSD가 제공하던 데이터에서 영감을 받았다고 밝히고, getrusage(2)도 구조체 정의를 4.3BSD Reno에서 가져왔다고 적습니다. “프레임 회수” 같은 표현은 이 계보에서 온 것으로 보입니다(추론). 리눅스에서 숫자를 해석할 때는 getrusage(2)와 proc_pid_stat(5)의 기준, 즉 I/O가 필요했는지를 쓰면 됩니다.

숫자가 말해 주지 않는 것

  • 시간. 두 필드는 횟수입니다. struct rusage에는 폴트 처리에 쓴 시간을 담는 필드가 없습니다. major 폴트가 많아도 그것이 실행 시간을 얼마나 늘렸는지는 별도의 시간 측정과 함께 봐야 합니다. time의 real/user/sys 해석은 real, user, sys 글에 정리했습니다.
  • 구간. /proc/<pid>/stat의 값은 프로세스가 지금까지 일으킨 폴트의 누적값입니다. 특정 작업의 영향을 보려면 작업 전후로 두 번 읽어 차이를 봐야 합니다(누적값이라는 설명에서 끌어낸 방법). getrusage(2)에 따르면 자원 사용량은 execve(2)를 거쳐도 유지되므로, exec 이전에 쌓인 값이 포함될 수 있습니다.
  • 자식 프로세스. RUSAGE_CHILDREN과 cminflt/cmajflt는 종료되고 wait로 회수된 자식만 포함합니다. 아직 실행 중인 자식의 폴트는 들어가지 않습니다.
  • 어느 페이지인지. 카운터는 어느 주소에서 폴트가 났는지 알려 주지 않습니다. 매핑의 어느 페이지가 지금 페이지 캐시에 상주하는지는 mincore(2)로 확인할 수 있다고 mmap(2)의 NOTES 절이 안내합니다. 커널 문서의 /proc/<pid>/pagemap은 가상 페이지마다 64비트 값을 주며, 63번 비트가 page present, 62번 비트가 page swapped입니다. 단 Linux 4.0부터 물리 프레임 번호(PFN)는 CAP_SYS_ADMIN 권한이 있어야 얻을 수 있습니다. 두 수단 모두 현재 상태를 보여 줄 뿐, 과거에 폴트가 났는지는 알려 주지 않습니다.
  • 스왑. getrusage(2)는 ru_nswap을 리눅스에서 쓰이지 않는(unmaintained) 필드로 분류하고, 이런 필드는 커널이 0으로 채운다고 설명합니다. proc_pid_stat(5)의 nswap도 유지되지 않는 필드입니다. GNU time 매뉴얼은 모든 유닉스가 모든 값을 측정하지는 않아 일부 값이 0으로 나올 수 있다고 적습니다. GNU time의 Swaps 값이 이 필드에서 온다면 리눅스에서는 늘 0이 되므로, Swaps: 0을 스왑이 없었다는 증거로 읽으면 안 됩니다. GNU time이 이 값을 어디서 가져오는지는 이 글이 확인한 문서에 직접 나와 있지 않으므로 이 부분은 추론입니다.

직접 확인하기

명령 하나를 실행하고 GNU time으로 값을 보는 방법입니다.

/usr/bin/time -v ./your-program
/usr/bin/time -f '%F major, %R minor' ./your-program

bash 같은 일부 셸에는 내장 time이 있어서, 이런 옵션을 쓰려면 time(1)이 예로 드는 /usr/bin/time처럼 경로를 지정해야 할 수 있습니다.

이미 실행 중인 프로세스는 /proc/<pid>/stat에서 읽습니다. 1234를 대상 PID로 바꿔 쓰세요.

python3 -c 'import sys; s = open("/proc/%s/stat" % sys.argv[1]).read(); f = s[s.rindex(")") + 2:].split(); print("minflt", f[7], "majflt", f[9])' 1234

2번 필드 comm은 괄호로 감싼 실행 파일 이름이라 공백이 섞일 수 있습니다. 그래서 공백으로 바로 나누지 않고 마지막 ) 뒤부터 나눕니다. 그 뒤의 첫 값이 3번 필드 state이므로 인덱스 7이 10번 minflt, 인덱스 9가 12번 majflt입니다. 작업 전후로 두 번 실행해 차이를 구하면 그 구간에 생긴 폴트 수가 됩니다.

블로그로