이번 학기에 프로그래밍 대회 관련 코스를 수강하고 있는데, 사용하고 있는 (Kattis라는) 플랫폼이 얼마나 빠른 I/O를 요구하는지 올바른 정답들도 가끔씩 시간 초과(Time Limit Exceeded, TLE)로 거부되는 케이스가 종종 있었습니다.
그래서 Rust에서 읽고 쓰는 성능을 조금만 더 끌어올릴 수 있는지 알아보다가, stdout
(표준 출력)이 기본으로 줄단위 버퍼링(line-buffered)을 사용하고 있다는 점을 알게 됐습니다. 즉, 다음과 같은 괜찮아 보이는 코드 샘플도, 콘솔에 출력할 때 낼 수 있는 이론적인 최대 성능에 못미친단 점이죠:
use std::time::Instant;
fn main() {
let time = Instant::now();
for i in 0..500000 {
println!("{}", i);
}
let duration = time.elapsed();
println!("걸린 시간: {:?}", duration);
}
실행하면 다음과 같은 출력을 보게 됩니다:
(...)
499997
499998
499999
걸린 시간: 17.5983885s
그럼 왜 그런지 알아보겠습니다. println!()
매크로를 호출할 때마다, stdout
에 줄바꿈 문자(\n
)를 출력하는데, stdout
이 이러한 줄바꿈 문자를 볼 때마다 버퍼를 비우기 (flush) 때문입니다. 버퍼를 비우려면 시스템 콜(syscall)을 실행하는데, 버퍼의 내용을 콘솔에 출력하거나 만약 리디렉트를 해둔다면 파일에 작성하거나 어디든지 일단 보내버리죠.
문제는 시스템 콜을 실행할 때마다 드는 비용이 있는데, 프로세서가 사용자 모드에서 커널 모드로 전환하고 레지스터 값들을 이리저리 옮기고 해야 할 일이 많기 때문입니다. 그리고 이걸 5백만번이나 반복합니다! 비용이 쌓일 수 밖에 없죠.
해결 방법은 stoudt
앞에 BufWriter
를 두면 되는데, 처음 볼 땐 이상해 보이고 목적에 부합하지도 않는 듯 합니다 (원래 버퍼링을 덜 하는게 목적이었잖아요?) 자세히 보면 BufWriter
는 작성하려고 하는 내용을 별도의 버퍼에 저장하는데, BufWriter
에 .flush()
를 호출한다면 이 버퍼의 내용을 한꺼번에 stdout
에 던저버리고 똑같이 .flush()
를 불러 결과적으로 stdout
을 블록 단위로 버퍼링하듯이 만들어버립니다.
처음에는 stdout
이 BufWriter
의 버퍼에서 오는 내용 중 줄바꿈 문자를 볼 때마다 어차피 쓰기 시스템 콜을 호출할 것 같아 이 방식이 작동하지 않을 줄 알았는데, 실제로 성능이 향상되서 작동하는 것을 보여줍니다.
변경된 코드는 다음과 같습니다:
use std::io;
use std::io::{BufWriter, Write};
use std::time::Instant;
fn main() {
let time = Instant::now();
let output = io::stdout().lock();
let mut buffer = BufWriter::new(output);
for i in 0..500000 {
writeln!(buffer, "{}", i).unwrap();
}
buffer.flush().unwrap();
let duration = time.elapsed();
println!("걸린 시간: {:?}", duration);
}
거짓말인 것 같다고요?
(...)
499997
499998
499999
갈린 시간: 3.7357123s
그럼 왜 Rust는 stdout
작동 방식을 줄단위 버퍼링과 블록 단위 버퍼링 간 변경하게 해 주지 않을까요? GitHub 이슈 트래커 상 이슈가 존재하긴 합니다만, 연관된 pull request가 2022년에 닫혀버려서 지난 3년 동안 문제가 계속 방치되어 왔습니다. (만약 기존 이슈까지 본다면 거의 6년 동안 남겨진 셈이죠!)
따라서 언어의 공식 코드가 되기 전까진, 위에 나와 있는 우회방안이 crate (Rust 라이브러리) 없이 Rust에서 더 빠른 I/O를 얻는 최고의 선택지인 듯 합니다. 물론, 버퍼링 없는 raw stdout
을 얻기 위해 GitHub의 @WieeRd님께서 작성하신 이 우회방안을 시도해보시거나, ripgrep처럼 조건부 버퍼링을 해보실 수 있습니다.
Kattis가 crate 사용을 허가해줬다면 얼마나 좋았을까요.