fastcat - A Faster `cat` Implementation Using Splice

Matthias Endler

fastcat - splice를 이용한 더 빠른 cat 구현

원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

많은 사람들이 유명한 유닉스 명령어의 내부 동작에 대해 한 편 더 써 달라고 했다. 사실 아무도 부탁한 적은 없지만, 도입부로는 그럴듯하다. 이전에 쓴 yesls에 대한 글은 아마 이미 읽어보셨을 것이다 — 정말 대단하다.

어쨌든 오늘은 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 myfile

cat 구현하기

다음은 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 성능을 향상시킬 수 있다(참고).

Noun Project의 Aleksandr Vector가 제작한 파일 아이콘. Noun Project의 useiconic.com이 제작한 터미널 아이콘.
출처: File icon by Aleksandr Vector from the Noun Project. Terminal icon by useiconic.com from the Noun Project.

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는 파일 디스크립터 중 하나가 파이프여야 하므로 파일을 표준 출력으로 바로 복사할 수는 없다. 이를 우회하는 방법은 리더와 라이터(rdwr)로 구성된 파이프를 만드는 것이다. 파일을 라이터 쪽 파이프로 보내고, 파이프에서 읽어 그 데이터를 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배 이상 빠르다.

운영체제 지원

  • LinuxAndroid는 완전히 지원된다.
  • 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 자체를 아예 피하는 방법을 먼저 고려해 보자).

조금만 더 손보면 fcatnetcat과 유사하게 한 네트워크 카드에서 다른 네트워크 카드로 패킷을 직접 라우팅하는 데도 사용할 수 있다.

배운 점

  • 베어 메탈에 가까워질수록 어렵게 얻은 추상화는 무너지고, 다시 로우 레벨 시스템 프로그래밍으로 돌아가게 된다.
  • 빠른 cat 말고도 느린 cat이 필요한 경우가 있다. 바로 오래된 컴퓨터다. 그런 용도로는 — 예상했겠지만 — slowcat이 있다.

그건 그렇고, 왜 GNU cat이 리눅스에서 splice를 사용하지 않는지는 여전히 모르겠다. 🤔 fcat의 소스 코드는 Github에 있다. 기여는 언제나 환영이다!

각주

1. 이 코드를 더 관용적으로 만들어 준 독자 Freeky에게 감사드립니다.
2. 힌트를 준 독자 masklinn에게 감사드립니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글