ARM64 어셈블리 렉서의 성능을 측정하던 중 10만 개가 넘는 작은 파일을 읽을 때 디스크가 아니라 시스템 콜 오버헤드가 병목이 된다는 사실을 발견하고, tar.gz 아카이브로 파일 수를 줄이면 왜 크게 빨라지는지 분석한다.
URL: https://modulovalue.com/blog/syscall-overhead-tar-gz-io-performance/
Dart 코드를 공식 스캐너보다 2배 빠르게 처리하는 ARM64 어셈블리 렉서를 만들었다(정확히는 내가 만든 파서 생성기로 어셈블리를 생성했지만, 이 글의 주제는 그게 아니다). 이 성과는 작은 성능 차이를 신뢰성 있게 측정하기 위한 통계적 방법을 사용해 얻었다.
그 다음 104,000개 파일에 대해 벤치마크를 돌려 보니, 내 렉서는 병목이 아니었다. 병목은 I/O였다. 그리고 이 과정에서 왜 pub.dev가 패키지를 tar.gz로 저장하는지(적어도 한 가지 이유를) 우연히 이해하게 됐다.
내 렉서를 공식 Dart 스캐너와 비교 벤치마크하고 싶었다. 내 머신의 pub 캐시에는 Dart 파일이 104,000개 있었고 총합 1.13GB였다. 완벽한 테스트 코퍼스였다. 그래서 다음을 하는 벤치마크를 작성했다.
충분히 단순하다.
결과는 다음과 같았다.
| 지표 | ASM 렉서 | 공식 Dart |
|---|---|---|
| 렉싱 시간 | 2,807 ms | 6,087 ms |
| 렉싱 처리량 | 402 MB/s | 185 MB/s |
내 렉서는 2.17배 빨랐다. 성공! 그런데 잠깐:
| 지표 | ASM 렉서 | 공식 Dart |
|---|---|---|
| I/O 시간 | 14,126 ms | 14,606 ms |
| 총 시간 | 16,933 ms | 20,693 ms |
| 전체 가속비 | 1.22x | - |
전체 가속비는 1.22배에 불과했다. 렉싱을 2.17배 개선했지만 I/O에 대부분 잠식되어 버렸다. 파일을 읽는 데 걸린 시간이 렉싱 시간의 5배였다.
내 MacBook에는 5~7GB/s 읽기가 가능한 NVMe SSD가 있다. 그런데 측정된 값은 80MB/s였다. 이론상 최대치의 1.5%에 불과하다.
문제는 디스크가 아니었다. 시스템 콜(syscall)이었다.
파일이 104,000개면 운영체제는 다음을 실행해야 한다.
open() 호출read() 호출close() 호출30만 번이 넘는 시스템 콜이다. 각 시스템 콜에는 다음이 포함된다.
각 시스템 콜은 대략 15마이크로초 정도 든다. 이를 300,000번 곱하면 실제 디스크 I/O가 시작되기 전, 순수 오버헤드만 0.31.5초가 된다. 여기에 파일시스템 메타데이터 조회, 디렉터리 순회까지 더하면 시간이 어디로 사라지는지 이해할 수 있다.
몇 가지를 시도했지만 큰 도움이 되지 않았다. 파일을 메모리 매핑하면 파일별 mmap/munmap 오버헤드 때문에 오히려 더 나빠졌다. Dart의 파일 읽기를 직접 FFI 시스템 콜(open/read/close)로 바꿔도 5% 개선에 그쳤다. 문제는 Dart의 I/O 레이어가 아니라, 시스템 콜의 절대 개수였다.
예전에 pub.dev를 여러 번 미러링한 적이 있는데, 모든 패키지가 tar.gz 아카이브로 저장된다는 것을 봤다. 왜 그런지는 제대로 이해하지 못했지만, 이번 문제가 그 사실을 떠올리게 했다.
시스템 콜이 문제라면 해법은 시스템 콜을 줄이는 것이다. 104,000개 파일 대신 1,351개 파일(패키지당 하나)만 있다면 어떨까?
그래서 캐시된 각 패키지를 tar.gz로 패키징하는 스크립트를 작성했다.
104,000개 개별 파일 -> 1,351개 tar.gz 아카이브
비압축 1.13 GB -> 압축 169 MB (6.66배 압축률)
| 지표 | 개별 파일 | tar.gz 아카이브 |
|---|---|---|
| 파일/아카이브 수 | 104,000 | 1,351 |
| 디스크 상 데이터 | 1.13 GB | 169 MB |
| I/O 시간 | 14,525 ms | 339 ms |
| 압축 해제 시간 | - | 4,507 ms |
| 렉싱 시간 | 2,968 ms | 2,867 ms |
| 총 시간 | 17,493 ms | 7,713 ms |
I/O 가속비는 42.85배였다. 104,000개의 랜덤 파일 대신 1,351개의 연속적인 파일을 읽자 I/O가 14.5초에서 339ms로 줄었다.
전체 가속비는 2.27배였다. 압축 해제 오버헤드가 있어도 아카이브 방식이 2배 이상 빨랐다.
이게 바로 시스템 콜 오버헤드가 드러난 것이다. 30만+ 시스템 콜에서 대략 4천 개 시스템 콜(1,351개 아카이브 각각에 대해 open/read/close)로 줄이면서 대부분의 오버헤드가 사라졌다.
또한 파일시스템 여기저기에 흩어진 104,000개 파일을 읽는 것보다, 1,351개 파일을 순차적으로 읽는 쪽이 캐시 친화적이다. OS는 효과적으로 prefetch할 수 있고, SSD는 작업을 배치 처리할 수 있으며, 페이지 캐시도 따뜻하게 유지된다.
pub.dev의 archive 패키지를 사용했을 때 gzip 압축 해제 속도는 약 250MB/s였다. 이제 새 병목은 압축 해제다.
압축 해제 최적화에는 큰 노력을 들이지 않았다. 네이티브 zlib을 사용하는 FFI 기반 해법은 훨씬 빠를 수 있다. lz4나 zstd 같은 현대적 대안도 도움이 될 수 있다.
소스 코드는 압축이 잘 된다. 1.13GB의 Dart 코드는 169MB로 줄었다. 이는 디스크에서 읽어야 할 데이터 양이 줄어든다는 뜻이며, 빠른 SSD에서도 도움이 된다.


이번 실험은 pub.dev의 패키지 포맷을 우연히 설명해 준다. dart pub get을 실행하면 개별 파일이 아니라 tar.gz 아카이브를 다운로드한다. 이유는 이제 명확하다.
같은 원리는 npm(tar.gz), Maven(JAR/ZIP), PyPI(wheel/tar.gz) 등 사실상 모든 패키지 매니저에 적용된다.
현대 스토리지는 빠르다. NVMe SSD는 초당 기가바이트 단위로 지속 처리할 수 있다. 하지만 그 속도는 큰 파일을 순차 접근할 때에만 제대로 끌어낼 수 있다. 수천 개의 작은 파일이 개입되는 순간, 시스템 콜 오버헤드가 지배적이 된다.
이 문제는 다음에 중요하다.
더 최적화한다면 다음을 할 것이다.
렉서를 벤치마크하려고 시작했는데, 결국 시스템 콜 오버헤드를 배우게 됐다. 렉서는 2배 빨라졌다. I/O 최적화는 43배 빨라졌다.
servermeta_net의 지적에 따르면 Linux에는 두 가지 접근이 있다. 추측 실행 완화(speculative execution mitigations)를 비활성화하는 방법(시스템 콜이 많은 시나리오에서 성능이 좋아질 수 있음), 그리고 비동기 I/O를 위한 io_uring 사용이다.
이 벤치마크는 macOS에서 수행했기 때문에 io_uring을 쓸 수 없지만, Linux의 이런 기능은 흥미롭다. Linux에서 I/O 성능을 어떻게 최적화할 수 있는지 후속 글을 써볼 만하다.
tsanderdev의 지적에 따르면 macOS의 kqueue가 이 워크로드에서 성능을 개선할 가능성이 있다.
kqueue는 Linux의 io_uring과 동일하지는 않다(공유 링 버퍼를 통한 동일한 시스템 콜 배치 기능이 없다). 하지만 동기 I/O보다는 나을 수 있다. 아직 벤치마크하진 않았다.
tsanderdev는 SQLite가 작은 파일이 잔뜩 있는 디렉터리보다 훨씬 빠를 수 있는 이유도 이것이라고 언급했다. SQLite를 옵션으로 완전히 잊고 있었다.
파일 내용을 SQLite DB에 저장하면 시스템 콜 오버헤드를 제거하면서도(타르볼은 제공하지 못하는) 개별 파일에 대한 랜덤 액세스를 제공할 수 있다.
이는 내가 여러 번 들었던 사실도 설명한다. Apple은 앱에서 SQLite를 광범위하게 사용하는데, 본질적으로 SQLite 데이터베이스 안에서 파일시스템을 시뮬레이션하고 있다는 것이다. NVMe를 가진 현대 Mac에서 100,000개 파일을 읽는 데 14초가 걸린다면, 더 느린 구형 머신에서는 어땠을지 상상해 보라. 시스템 콜 오버헤드는 훨씬 더 가혹했을 것이다. 파일시스템 대신 SQLite를 쓰면 그 시스템 콜을 아예 피할 수 있다. 탐구할 가치가 있다.
matthieum은 배치 컴파일러에서 흔히 쓰는 트릭을 제안했다. free, close, munmap을 절대 호출하지 말고, 프로세스 종료 시 OS가 자원을 회수하게 하라는 것이다. 컴파일러(또는 렉서 벤치마크) 같은 1회성 배치 프로세스에서는 어차피 OS가 회수할 자원을 굳이 신중히 해제할 이유가 없다는 주장이다.
GabrielDosReis는 단서를 달았다. 워크로드에 따라 close가 필요할 수도 있고, 그렇지 않으면 파일 디스크립터가 고갈될 수 있다. macOS에서는 다음으로 한도를 확인할 수 있다.
$ launchctl limit maxfiles
maxfiles 256 unlimited
$ sysctl kern.maxfilesperproc
kern.maxfilesperproc: 61440
첫 번째 숫자(256)는 프로세스당 소프트 리밋, 두 번째 숫자는 하드 리밋이다. kern.maxfilesperproc는 커널의 프로세스당 최대치를 보여준다. 파일이 104,000개면 close를 생략하는 것은 최대 한도조차 초과한다.
여기서 더 나아간 최적화도 있다. 래퍼(wrapper) 프로세스를 두는 방식이다. 래퍼가 워커 프로세스를 실행해 모든 일을 시킨다. 워커가 완료 신호를 보내면(stdout 또는 파이프), 래퍼는 분리(detached)된 자식 프로세스를 기다리지 않고 즉시 종료한다. 래퍼를 기다리던 스크립트는 바로 다음 단계로 진행할 수 있고, OS는 백그라운드에서 비동기적으로 워커의 자원을 회수한다. 이 접근은 이전엔 생각해 보지 못했지만 시도해볼 만하다.
Dart 팀의 Bob Nystrom이 설명하기를, pub.dev의 포맷 선택에 대한 내 추측은 부분적으로 틀렸다. HTTP 요청 수 감소와 대역폭 절감은 분명히 의사결정 요인이었고, 서버 저장공간 감소도 마찬가지였다. 원자성도 중요하지만, 아카이브가 문제를 완전히 해결하진 않는다. 다운로드와 추출이 실패할 수도 있기 때문이다.
하지만 I/O 성능 이점(더 빠른 추출, 시스템 콜 오버헤드 감소)은 고려되지 않았을 가능성이 높다. pub는 다운로드 직후 아카이브를 즉시 추출하며, 추출 이점은 pub get 동안 단 한 번만 발생한다. 그리고 그 단일 추출은 꽤 비싼 과정 전체에서 매우 작은 부분이며, pub는 pubspec을 제외하면 파일을 다시 읽지 않는다. 내가 측정한 성능 이점은 아카이브에서 반복적으로 읽을 때에만 적용되는데, pub는 그렇게 동작하지 않는다.
이건 흥미로운 질문을 만든다. 만약 pub가 아카이브를 아예 추출하지 않는다면?
Dart Analyzer처럼 수백 개 의존성을 가진 대형 프로젝트를 클린(비증분) 컴파일하면, 컴파일러는 여러 패키지에 걸친 수천 개 파일에 접근해야 한다. 패키지를 랜덤 액세스를 지원하는 아카이브 포맷(예: ZIP)으로 유지한다면, 파일을 열고 닫는 시스템 콜 오버헤드를 줄일 수 있을지도 모른다.
파일시스템 여기저기에 흩어진 수천 번의 open/read/close 대신, 패키지 아카이브당 한 번 open을 하고 그 안에서 seek하는 방식이 된다. 압축 해제 오버헤드가 시스템 콜 절감 효과보다 더 클지는 불확실하지만, 큰 의존성 트리에서 클린 빌드가 잦은 빌드 시스템이라면 탐구할 가치가 있을지 모른다.
Simon Binder는 dart:io에 이미 zlib 기반 gzip 지원이 포함돼 있으니, 압축 해제에 package:archive를 쓸 필요가 없다고 지적했다.
dart:io는 tar 아카이브를 지원하지 않기 때문에, 나는 package:archive로 전부 처리했고 dart:io의 gzip 지원만 따로 섞어 쓰는 생각은 하지 못했다. tar 추출은 package:archive에 맡기되 압축 해제는 dart:io의 GZipCodec을 쓰면 성능이 더 좋아질 수 있다. 더 큰 코퍼스를 렉싱할 때 이 접근을 시도해볼 생각이다.
vanderZwan은 ZIP 파일이 SQLite 같은 랜덤 액세스 이점을 제공할 수 있다고 지적했다. 이는 TAR와 ZIP의 근본적인 아키텍처 차이를 드러낸다.
TAR는 1979년에 순차 테이프 드라이브를 위해 설계되었다. 각 파일의 메타데이터는 파일 내용 바로 앞의 헤더에 저장되며, 중앙 인덱스가 없다. 특정 파일을 찾으려면 아카이브를 순차적으로 읽어야 한다. tar.gz로 압축되면 전체 스트림이 한 덩어리로 압축되므로, 어떤 파일이든 접근하려면 그 이전까지를 전부 풀어야 한다.
TAR는 POSIX에 의해 표준화되었고(POSIX.1-1988의 ustar, POSIX.1-2001의 pax), 문서화가 잘 되어 있으며, 유닉스 파일 속성을 완전하게 보존한다.
ZIP은 1989년에 설계되었고, 아카이브 끝에 중앙 디렉터리(central directory)를 둔다. 이 디렉터리는 각 파일 위치의 오프셋을 포함해 랜덤 액세스를 가능하게 한다. 중앙 디렉터리를 한 번 읽으면 원하는 파일로 바로 seek할 수 있다.
또한 ZIP은 파일별로 개별 압축되므로 필요한 파일만 풀 수 있다. 그래서 JAR, OpenDocument, EPUB 등이 내부적으로 ZIP을 사용한다.
| 측면 | TAR | ZIP |
|---|---|---|
| 랜덤 액세스 | 불가(순차만) | 가능(중앙 디렉터리) |
| 표준화 | POSIX 표준 | PKWARE가 관리하는 명세 |
| 유닉스 권한 | 완전 보존 | 제한적 지원 |
| 압축 | 외부(gzip, zstd 등) | 내장(파일별) |
널리 쓰이는 유닉스 네이티브 포맷 중에서, 랜덤 액세스와 유닉스 메타데이터를 제대로 결합한 것은 없어 보인다. TAR는 완전한 유닉스 의미론으로 순차 액세스를 제공한다. ZIP은 랜덤 액세스를 제공하지만 MS-DOS에서 출발했기 때문에 유닉스 권한 지원이 일관되지 않다.
우리가 부족한 것은 일종의 “유닉스를 위한 ZIP”이다. 랜덤 액세스와 함께 소유권, 권한, 확장 속성(xattr), ACL까지 제대로 지원하는 포맷.
가장 가까운 답은 dar(Disk ARchive)다. 이는 현대적 기능을 갖춘 tar 대체물로 설계되었다. 아카이브 끝에 카탈로그 인덱스를 저장해 O(1) 파일 추출을 가능하게 하며, 확장 속성과 ACL을 포함해 전체 유닉스 메타데이터를 보존하고, 파일별 압축(알고리즘 선택 가능)을 지원한다. 또한 전체 아카이브 없이도 빠르게 탐색할 수 있도록 카탈로그를 분리할 수 있다.
하지만 dar는 tar나 zip만큼 널리 쓰이진 못했다.
내 렉서 벤치마크에서는 어차피 모든 파일을 처리하므로 랜덤 액세스는 도움이 되지 않는다. 하지만 아카이브 내부의 특정 파일에 접근해야 하는 유스케이스에서는 이 아키텍처 차이가 중요하다.