fastcat - splice를 이용한 더 빠른 cat 구현
원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기
많은 사람들이 유명한 유닉스 명령어의 내부 동작에 대해 한 편 더 써 달라고 했다. 사실 아무도 부탁한 적은 없지만, 도입부로는 그럴듯하다. 이전에 쓴 yes와 ls에 대한 글은 아마 이미 읽어보셨을 것이다 — 정말 대단하다.
어쨌든 오늘은 cat에 대해 이야기해보자. 이 명령어는 파일을 이어 붙이는 용도로 쓰이지만, 더 흔하게는 파일 내용을 화면에 출력하는 용도로 남용되곤 한다.
# Concatenate files, the intended purpose
cat input1.txt input2.txt input3.txt > output.txt
# Print file to screen, the most common use-case
cat myfilecat 구현하기
다음은 Ruby로 작성한 단순한 cat이다:
#!/usr/bin/env ruby
def cat(args)
args.each do |arg|
IO.foreach(arg) do |line|
puts line
end
end
end
cat(ARGV)이 프로그램은 각 파일을 순회하며 내용을 한 줄씩 출력한다. 참 쉽다! 그런데 이 도구는 과연 얼마나 빠를까?
벤치마크를 위해 2GB짜리 랜덤 파일을 하나 빠르게 만들었다.
멋진 pv (Pipe Viewer) 도구를 이용해 우리가 만든 단순한 구현과 시스템 기본 구현의 속도를 비교해 보자. 모든 테스트는 웜 캐시(파일이 메모리에 있는 상태)에서 5회 실행한 평균이다.
# Ruby 2.5.1
> ./rubycat myfile | pv -r > /dev/null
[196MiB/s]나쁘지 않은 것 같은데? 시스템의 cat과 비교하면 어떨까?
cat myfile | pv -r > /dev/null
[1.90GiB/s]어라, GNU cat이 우리가 만든 조그마한 Ruby cat보다 10배나 빠르다. 💎🐈🐌
Ruby cat을 조금 더 빠르게 만들기
우리의 단순한 Ruby 코드는 조금 손볼 수 있다. 알고 보니 라인 버퍼링이 결국 성능을 떨어뜨린다1:
#!/usr/bin/env ruby
def cat(args)
args.each do |arg|
IO.copy_stream(arg, STDOUT)
end
end
cat(ARGV)rubycat myfile | pv -r > /dev/null
[1.81GiB/s]와… 별로 애쓴 것도 없는데, 1971년부터 최적화되어 온 도구의 속도에 벌써 근접했다. 🎉
하지만 너무 일찍 자축하기 전에, 더 빠르게 만들 수 있는지 확인해 보자.
Splice
처음 cat에 대해 글을 쓰게 된 계기는 Hacker News에서 사용자 wahern이 남긴 이 댓글이었다:
GNU yes나 GNU cat 둘 다 splice(2)를 사용하지 않는다는 게 놀랍네요.
이 splice라는 것이 파일 출력을 더 빠르게 만들 수 있을까? — 궁금해졌다.
splice는 2006년에 리눅스 커널에 처음 도입되었고, 리누스 토르발스 본인이 쓴 훌륭한 요약도 있지만, 나는 매뉴얼 페이지의 설명을 더 선호한다:
splice()는 커널 주소 공간과 사용자 주소 공간 사이에서 복사 없이 두 파일 디스크립터 사이에서 데이터를 이동시킨다. 파일 디스크립터
fd_in에서 파일 디스크립터fd_out으로 최대len바이트의 데이터를 전송하며, 이때 파일 디스크립터 중 하나는 반드시 파이프를 가리켜야 한다.
더 깊이 파고들고 싶다면 리눅스 커널의 해당 소스 코드를 보면 되지만, 지금은 세세한 부분을 다 알 필요는 없다. 대신 C 구현의 헤더만 살펴보자:
#include <fcntl.h>
ssize_t splice (int fd_in, loff_t *off_in, int fd_out,
loff_t *off_out, size_t len,
unsigned int flags);더 쉽게 풀어보자면, 전체 src 파일을 dst로 복사하는 방법은 다음과 같다:
const ssize_t r = splice (src, NULL, dst, NULL, size, 0);이 방식의 멋진 점은 모든 과정이 리눅스 커널 내부에서 일어난다는 것이다. 즉, 단 한 바이트도 유저 공간(우리 프로그램이 실행되는 곳)으로 복사하지 않는다. 이상적으로 splice는 페이지를 재매핑하는 방식으로 동작하며 실제로 데이터를 복사하지 않으므로, I/O 성능을 향상시킬 수 있다(참고).

Rust에서 splice 사용하기
나는 C 프로그래머가 아니며, 더 안전한 인터페이스를 제공하는 Rust를 선호한다. Rust로는 같은 기능이 이렇게 구현된다:
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn splice(
fd_in: RawFd,
off_in: Option<&mut libc::loff_t>,
fd_out: RawFd,
off_out: Option<&mut libc::loff_t>,
len: usize,
flags: SpliceFFlags,
) -> Result<usize>참고로 리눅스 바인딩을 직접 구현한 것은 아니다. 대신 *nix API에 대한 Rust 친화적인 바인딩을 제공하는 nix라는 라이브러리를 사용했다.
다만 한 가지 주의할 점이 있다: splice는 파일 디스크립터 중 하나가 파이프여야 하므로 파일을 표준 출력으로 바로 복사할 수는 없다. 이를 우회하는 방법은 리더와 라이터(rd와 wr)로 구성된 파이프를 만드는 것이다. 파일을 라이터 쪽 파이프로 보내고, 파이프에서 읽어 그 데이터를 stdout으로 밀어 넣는다.
성능 향상을 위해 16384바이트(214)의 비교적 큰 버퍼를 사용한 것을 볼 수 있다.
extern crate nix;
use std::env;
use std::fs::File;
use std::io;
use std::os::unix::io::AsRawFd;
use nix::fcntl::{splice, SpliceFFlags};
use nix::unistd::pipe;
const BUF_SIZE: usize = 16384;
fn main() {
for path in env::args().skip(1) {
let input = File::open(&path).expect(&format!("fcat: {}: No such file or directory", path));
let (rd, wr) = pipe().unwrap();
let stdout = io::stdout();
let _handle = stdout.lock();
loop {
let res = splice(
input.as_raw_fd(),
None,
wr,
None,
BUF_SIZE,
SpliceFFlags::empty(),
).unwrap();
if res == 0 {
// We read 0 bytes from the input,
// which means we're done copying.
break;
}
let _res = splice(
rd,
None,
stdout.as_raw_fd(),
None,
BUF_SIZE,
SpliceFFlags::empty(),
).unwrap();
}
}
}그럼 속도는 얼마나 빠를까?
fcat myfile | pv -r > /dev/null
[5.90GiB/s]헉. 시스템 cat보다 3배 이상 빠르다.
운영체제 지원
- Linux와 Android는 완전히 지원된다.
- OpenBSD에도
sosplice라는 일종의 splice 구현이 있다. 다만 직접 테스트해보진 않았다. - macOS에서는 splice와 가장 유사한 것이 그 상위 호환이라 할 수 있는 sendfile로, 커널 내부에서 파일을 소켓으로 전송할 수 있다. 안타깝게도 파일에서 파일로의 전송은 지원하지 않는다.2 비슷한 인터페이스를 가진
copyfile도 있지만, 안타깝게도 제로 카피가 아니다. (처음에는 제로 카피인 줄 알았지만, 내가 틀렸다.) - Windows는 제로 카피 파일 간 전송을 제공하지 않는다( TransmitFile API를 이용한 파일-소켓 전송만 가능하다).
그럼에도 프로덕션 수준의 구현이라면 이를 지원하는 시스템에서는 splice를 활성화하고, 그 외에는 일반적인 구현을 폴백으로 사용하면 될 것이다.
좋긴 한데, 도대체 왜 필요할까?
나도 모르겠다. 아마 필요 없을 것이다. 병목은 다른 곳에 있을 테니까. 그렇다고는 해도 많은 사람들이 다음과 같이 cat을 이용해 데이터를 다른 프로세스로 파이프한다
# Count all lines in C files
cat *.c | wc -l또는
cat kittens.txt | grep "dog"이런 경우 cat이 병목이라면 fcat을 써 보자(하지만 그 전에 cat 자체를 아예 피하는 방법을 먼저 고려해 보자).
조금만 더 손보면 fcat은 netcat과 유사하게 한 네트워크 카드에서 다른 네트워크 카드로 패킷을 직접 라우팅하는 데도 사용할 수 있다.
배운 점
- 베어 메탈에 가까워질수록 어렵게 얻은 추상화는 무너지고, 다시 로우 레벨 시스템 프로그래밍으로 돌아가게 된다.
- 빠른 cat 말고도 느린 cat이 필요한 경우가 있다. 바로 오래된 컴퓨터다. 그런 용도로는 — 예상했겠지만 — slowcat이 있다.
그건 그렇고, 왜 GNU cat이 리눅스에서 splice를 사용하지 않는지는 여전히 모르겠다. 🤔 fcat의 소스 코드는 Github에 있다. 기여는 언제나 환영이다!
각주
1. 이 코드를 더 관용적으로 만들어 준 독자 Freeky에게 감사드립니다.↩
2. 힌트를 준 독자 masklinn에게 감사드립니다.↩
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기