Devin.KR

Rust · 심화

트레이트와 동시성으로 깊어지는 Rust

성능 - 할당 줄이기와 측정

Vec::with_capacity, Cow 로 복사 피하기, 문자열 슬라이스 활용, 릴리스 빌드와 std::time::Instant 측정(출력에는 결정적 값만), 반복자와 루프 성능

개발자KR · 원고 갱신

이 장에서 배우는 것

프로그램이 느려지는 원인은 대개 계산량보다 메모리 할당과 불필요한 복사다. Rust 는 값을 어디에 두고 언제 복사하는지가 코드에 드러나는 언어라서, 줄일 곳도 코드에서 찾을 수 있다. 이 장에서는 창고 주문 로그를 읽어 SKU 별 수량을 합산하는 작은 프로그램으로 할당을 줄이는 방법을 익힌다. 그다음 줄인 효과를 어떻게 측정하는지, 측정 결과를 어떻게 읽는지 본다.

  • Vec::with_capacity 로 재할당 횟수를 줄이고, 용량(capacity)과 길이(length)를 구분한다.
  • Cow 로 "고칠 때만 복사"하는 함수를 만든다.
  • 문자열 슬라이스로 새 String 없이 줄과 필드를 다룬다.
  • 릴리스 빌드와 std::time::Instant 로 측정하고, 출력에는 결정적인 값만 남기는 방법을 안다.
  • 반복자와 for 루프의 성능을 같은 조건에서 비교하는 방법을 안다.

문제 상황

창고 시스템이 하루 동안 쌓인 주문 로그 파일을 읽는다. 한 줄이 sku-001:5 형태이고, 입력하는 사람에 따라 SKU 가 소문자이거나 앞뒤에 공백이 붙는다. 처음 만든 코드는 줄마다 to_string() 으로 새 문자열을 만들고, 결과를 담는 Vec 은 빈 채로 시작해 한 개씩 push 했다. 로그가 수십 줄일 때는 문제가 없었다. 수백만 줄이 되자 처리 시간의 상당 부분이 할당과 복사에 쓰였다.

이때 가장 먼저 할 일은 코드를 고치는 것이 아니라 어디서 할당하는지 파악하는 것이다. 이 장의 순서도 같다. 할당이 생기는 지점을 알고, 줄이고, 측정으로 확인한다. 측정 없이 고치면 읽기 어려워지기만 하고 빨라지지 않는 경우가 흔하다.

할당을 줄이는 세 가지 방법

Vec::with_capacity 로 재할당 줄이기

Vec 은 힙에 연속된 버퍼를 잡는다. 버퍼가 가득 찬 상태에서 push 하면 더 큰 버퍼를 새로 할당하고, 기존 원소를 옮기고, 옛 버퍼를 해제한다. 증가 폭은 표준 라이브러리 구현이 정하며 문서가 보장하는 값이 아니므로 외우지 않는다. 알아 둘 점은 원소 수를 미리 알면 이 과정을 없앨 수 있다는 것이다.

push 만 반복하면 재할당마다 원소를 옮기지만 with_capacity 는 버퍼를 한 번만 잡는다.

Vec::with_capacity(n) 은 원소 n 개가 들어갈 버퍼를 먼저 잡는다. 길이는 여전히 0 이다. 용량은 "재할당 없이 담을 수 있는 개수"이고, 길이는 "지금 담긴 개수"다. 이 둘을 혼동하면 아래 "자주 틀리는 것"에서 보는 패닉이 난다. 정확한 개수를 모를 때는 상한이나 추정치를 넘겨도 된다. 이 장의 코드는 줄 수를 세어 상한으로 쓴다. 유효하지 않은 줄이 있으면 남는 용량이 생기는데, 그 정도의 낭비는 재할당보다 싸다. 남는 용량은 shrink_to_fit 으로 돌려줄 수 있다.

Cow 로 복사 피하기

Cow(clone on write)는 열거형이다. Cow::Borrowed(&str) 는 원본을 빌려 가리키고, Cow::Owned(String) 은 새로 만든 값을 소유한다. 함수가 입력을 고쳐야 할 때도 있고 그대로 돌려줘도 될 때도 있다면, 반환 타입을 Cow<str> 로 두면 된다. 고친 경우에만 할당하고 나머지는 원본을 그대로 빌려 준다.

소문자가 있을 때만 새 String 을 만들고, 없으면 원본을 그대로 빌려 준다.

호출하는 쪽은 Cow 가 Deref 를 구현하므로 &str 처럼 쓴다. 나중에 소유한 String 이 꼭 필요해지면 into_owned() 를 부른다. 이미 Owned 이면 그대로 꺼내고, Borrowed 일 때만 복사한다. 제자리에서 수정해야 하면 to_mut() 가 필요할 때 한 번만 복사해 가변 참조를 준다. 자세한 API 는 표준 라이브러리 문서에서 확인할 수 있다.

Cow 는 공짜가 아니다. 열거형이라 분기가 한 번 생기고, 값이 빌림을 품으면 수명 매개변수가 구조체 정의로 퍼진다. 입력의 대부분이 이미 정규화된 상태일 때 가장 효과가 크다. 거의 항상 고쳐야 하는 입력이라면 처음부터 String 을 반환하는 편이 단순하다.

문자열 슬라이스 활용하기

lines(), trim(), split_once(), split() 은 모두 원본 문자열의 일부를 가리키는 &str 을 돌려준다. 할당이 없다. 줄을 읽고 필드를 나누는 단계에서 to_string() 이나 String::from 을 쓰지 않아도 되는 경우가 많다. 슬라이스는 원본이 살아 있는 동안만 유효하다는 점은 수명을 다룬 장에서 본 규칙 그대로다. 여기서는 로그 문자열이 main 이 끝날 때까지 살아 있으므로 파싱 결과가 원본을 빌려도 안전하다.

직접 바이트 위치로 슬라이스할 때는 UTF-8 문자 경계에 맞아야 한다. 경계가 아닌 위치에서 자르면 패닉이 난다. split_once 처럼 구분자를 찾아 자르는 메서드는 경계를 대신 맞춰 주므로 안전하다.

세 기법이 줄이는 비용과 쓰는 조건
기법줄이는 비용알맞은 경우주의할 점
Vec::with_capacity재할당과 원소 이동원소 수나 상한을 미리 안다길이는 0 이므로 인덱스로 쓸 수 없다
Cow변경이 없을 때의 복사대부분의 입력이 그대로 통과한다수명 매개변수가 타입에 퍼진다
문자열 슬라이스필드마다 만드는 String원본이 결과보다 오래 산다원본이 사라지면 쓸 수 없다

측정하기

릴리스 빌드와 Instant

성능 수치는 cargo run --release 로 만든 최적화 빌드에서 봐야 한다. 기본 cargo run 은 디버그 빌드라서 최적화가 꺼져 있고, 반복자 어댑터 같은 추상화가 그대로 함수 호출로 남아 수치가 크게 달라진다. 디버그 빌드에서 얻은 수치로 설계를 정하면 안 된다.

시간은 std::time::Instant 로 잰다. Instant::now() 로 시작 시점을 잡고 elapsed() 로 지난 시간을 Duration 으로 얻는다. Instant 는 시스템 시계가 바뀌어도 거꾸로 가지 않는 단조 시계다. 다만 측정값은 실행할 때마다 다르다. 이 책의 예제는 출력이 늘 같아야 하므로, 시간 값은 화면에 찍지 않고 결과 값(합계, 일치 여부)만 출력한다. 시간을 직접 보고 싶다면 아래처럼 표준 에러로 찍는 줄을 임시로 넣는다.

eprintln!("루프: {:?}, 반복자: {:?}", t_loop, t_iter);

측정에는 두 가지 함정이 있다. 하나는 컴파일러가 결과를 쓰지 않는 계산을 통째로 지우는 것이다. std::hint::black_box 로 입력을 감싸면 컴파일러가 값의 내용을 가정하지 못하게 막는다. 다른 하나는 한 번만 재는 것이다. 첫 실행은 캐시가 비어 있어 느리고 다른 프로세스의 영향도 받는다. 같은 측정을 여러 번 반복해 가장 작은 값이나 중앙값을 본다.

반복자와 루프

반복자 체인은 느리다는 인상이 있지만 릴리스 빌드에서는 대개 손으로 쓴 루프와 같은 기계어로 컴파일된다. 반복자는 경계 검사를 슬라이스 순회 안에서 한 번에 처리하고, for i in 0..len 에서 data[i] 로 접근하는 루프는 컴파일러가 증명하지 못하면 인덱스마다 경계를 검사한다. 그래서 반복자 쪽이 같거나 더 빠른 경우가 많다. 다만 이것은 일반적인 경향이지 보장이 아니다. 내 코드에서 어떤지는 측정으로 확인한다. 이 장의 예제에서는 두 방식이 같은 합계를 내는지 확인하고, 시간은 직접 실행해서 비교해 보도록 한다.

반복자에서 흔한 낭비는 중간 결과를 collect 로 Vec 에 모았다가 다시 순회하는 것이다. 어댑터를 이어서 한 번에 소비하면 중간 Vec 할당이 사라진다.

성능 작업의 순서와 각 단계에서 쓰는 도구
단계할 일도구확인할 것
1느린 곳 찾기릴리스 빌드, Instant전체 중 얼마를 차지하는가
2할당 지점 찾기코드 읽기to_string, clone, collect
3줄이기with_capacity, Cow, 슬라이스결과가 이전과 같은가
4다시 측정black_box, 반복 측정실제로 빨라졌는가

완성 코드

아래는 src/main.rs 하나로 된 프로그램이다. 주문 로그를 파싱해 SKU 를 정규화하고, SKU 별 합계를 내고, 용량 확보와 루프·반복자 비교를 보여 준다.

use std::borrow::Cow;
use std::collections::HashMap;
use std::hint::black_box;
use std::time::{Duration, Instant};

const ORDER_LOG: &str =
    "sku-001:5\nSKU-002:3\n sku-001:2 \nSKU-003:10\nSKU-002:4\nbad-line\nSKU-004:x\n";

struct OrderLine<'a> {
    sku: Cow<'a, str>,
    qty: u32,
}

fn normalize_sku(raw: &str) -> Cow<'_, str> {
    if raw.bytes().any(|b| b.is_ascii_lowercase()) {
        Cow::Owned(raw.to_ascii_uppercase())
    } else {
        Cow::Borrowed(raw)
    }
}

fn parse_line(line: &str) -> Option<OrderLine<'_>> {
    let (sku, qty) = line.trim().split_once(':')?;
    let qty = qty.parse().ok()?;
    Some(OrderLine {
        sku: normalize_sku(sku),
        qty,
    })
}

fn parse_all(log: &str) -> Vec<OrderLine<'_>> {
    let mut lines = Vec::with_capacity(log.lines().count());
    for line in log.lines() {
        if let Some(parsed) = parse_line(line) {
            lines.push(parsed);
        }
    }
    lines
}

fn totals<'a>(lines: &'a [OrderLine<'_>]) -> Vec<(&'a str, u32)> {
    let mut map: HashMap<&str, u32> = HashMap::new();
    for line in lines {
        *map.entry(&*line.sku).or_insert(0) += line.qty;
    }
    let mut sorted: Vec<_> = map.into_iter().collect();
    sorted.sort();
    sorted
}

fn sku_number(sku: &str) -> Option<&str> {
    sku.split_once('-').map(|(_, number)| number)
}

fn fill_grow(n: u32) -> Vec<u32> {
    let mut v = Vec::new();
    for i in 0..n {
        v.push(i);
    }
    v
}

fn fill_reserved(n: u32) -> Vec<u32> {
    let mut v = Vec::with_capacity(n as usize);
    for i in 0..n {
        v.push(i);
    }
    v
}

fn sum_even_loop(data: &[u32]) -> u64 {
    let mut total = 0u64;
    for i in 0..data.len() {
        if data[i] % 2 == 0 {
            total += u64::from(data[i]);
        }
    }
    total
}

fn sum_even_iter(data: &[u32]) -> u64 {
    data.iter()
        .filter(|&&x| x % 2 == 0)
        .map(|&x| u64::from(x))
        .sum()
}

fn measure<T>(f: impl FnOnce() -> T) -> (T, Duration) {
    let start = Instant::now();
    let value = f();
    (value, start.elapsed())
}

fn main() {
    let lines = parse_all(ORDER_LOG);
    let owned = lines
        .iter()
        .filter(|l| matches!(l.sku, Cow::Owned(_)))
        .count();
    println!("유효한 주문 줄: {}", lines.len());
    println!("복사한 SKU: {}, 빌린 SKU: {}", owned, lines.len() - owned);
    for (sku, qty) in totals(&lines) {
        println!("{sku} (번호 {}): {qty}", sku_number(sku).unwrap_or("-"));
    }

    let mut buf: Vec<u32> = Vec::with_capacity(100);
    let before = buf.capacity();
    buf.extend(0..100);
    println!(
        "용량 확보: {}, 100개 추가 뒤 재할당 없음: {}",
        before >= 100,
        buf.capacity() == before
    );
    println!("증가 방식 결과 동일: {}", fill_grow(1000) == fill_reserved(1000));

    let data = fill_reserved(1_000_000);
    let slice: &[u32] = &data;
    let (a, t_loop) = measure(|| sum_even_loop(black_box(slice)));
    let (b, t_iter) = measure(|| sum_even_iter(black_box(slice)));
    println!("루프 합계: {a}");
    println!("반복자 합계: {b}");
    println!("두 방식 결과 일치: {}", a == b);
    if t_loop.max(t_iter).as_secs() >= 60 {
        println!("측정이 비정상적으로 오래 걸렸다");
    }
}

줄별 해설

ORDER_LOG 는 일곱 줄이다. 소문자 SKU, 이미 대문자인 SKU, 앞뒤 공백이 붙은 줄, 콜론이 없는 줄, 수량이 숫자가 아닌 줄을 섞어 두었다.

OrderLine<'a> 는 Cow<'a, str> 를 필드로 가진다. 수명 매개변수 'a 는 Borrowed 일 때 가리키는 원본 로그의 수명이다. Owned 일 때는 자기 값을 소유하므로 수명과 무관하지만 타입은 같아야 해서 매개변수가 남는다.

normalize_sku 는 소문자 바이트가 하나라도 있으면 to_ascii_uppercase 로 새 String 을 만들어 Owned 로 감싼다. 없으면 입력 슬라이스를 그대로 Borrowed 에 넣는다. 반환 타입의 '_ 는 입력 참조의 수명을 따른다.

parse_line 은 trim 으로 공백을 걷어내고 split_once(':') 로 둘로 나눈다. 둘 다 슬라이스를 돌려주므로 할당이 없다. ? 는 콜론이 없을 때와 숫자 변환이 실패할 때 None 을 돌려준다. qty 의 타입은 구조체 필드 u32 에서 추론된다.

parse_all 은 log.lines().count() 로 줄 수를 세어 용량을 확보한다. 줄 수는 유효한 줄 수의 상한이다. 줄 수를 세는 순회가 한 번 더 생기지만 문자열 스캔만 하고 할당은 하지 않는다. 이 예제는 일곱 줄 중 다섯 줄만 유효하므로 용량이 두 칸 남는다.

totals 는 키를 &str 로 둔 HashMap 에 수량을 더한다. 키 문자열을 복사하지 않고 OrderLine 안의 문자열을 빌린다. 반환값이 lines 슬라이스를 빌리므로 수명 'a 를 명시했다. OrderLine 쪽의 수명은 '_ 로 두어 반환값과 무관함을 나타낸다. HashMap 순회 순서는 정해져 있지 않으므로 벡터로 모아 정렬한 뒤 출력한다.

sku_number 는 "SKU-001" 에서 하이픈 뒤의 "001" 을 슬라이스로 돌려준다. 새 문자열이 생기지 않는다.

fill_grow 와 fill_reserved 는 같은 결과를 만들되 용량 확보 여부만 다르다. main 에서 두 결과가 같은지 비교한다.

sum_even_loop 는 인덱스 루프, sum_even_iter 는 filter, map, sum 체인이다. 클로저 매개변수 |&&x| 는 filter 가 원소의 참조에 대한 참조를 넘기기 때문에 필요하다.

measure 는 클로저를 한 번 실행하고 결과와 걸린 시간을 튜플로 돌려준다. black_box 로 입력 슬라이스를 감싸 컴파일러가 합계를 미리 계산하지 못하게 했다. 시간은 변수에 받아 두기만 하고 마지막의 아주 느린 경우를 알리는 분기에서만 쓴다. 정상 실행에서는 아무것도 출력하지 않으므로 출력이 결정적이다.

짝수의 합은 0, 2, 4, …, 999998 의 합으로 249999500000 이다. 두 방식의 결과가 이 값과 같은지 출력에서 확인할 수 있다.

실행 결과

$ cargo run --release
유효한 주문 줄: 5
복사한 SKU: 2, 빌린 SKU: 3
SKU-001 (번호 001): 7
SKU-002 (번호 002): 7
SKU-003 (번호 003): 10
용량 확보: true, 100개 추가 뒤 재할당 없음: true
증가 방식 결과 동일: true
루프 합계: 249999500000
반복자 합계: 249999500000
두 방식 결과 일치: true

디버그 빌드(cargo run)에서도 출력은 같다. 달라지는 것은 표준 에러로 찍어 본 시간 값뿐이다.

실무에서 자주 틀리는 것

디버그 빌드로 성능을 판단한다

틀린 방식은 cargo run 으로 시간을 재고 "반복자가 루프보다 몇 배 느리다"고 결론짓는 것이다. 디버그 빌드는 인라인과 루프 최적화가 꺼져 있어 어댑터 호출이 그대로 남는다.

$ cargo run          # 최적화 없음: 비교에 쓰지 않는다
$ cargo run --release   # 최적화 빌드에서 비교한다

용량을 길이로 착각한다

with_capacity 는 공간만 잡을 뿐 원소를 만들지 않는다. 인덱스로 값을 넣으면 패닉이 난다.

let mut v: Vec<u32> = Vec::with_capacity(3);
v[0] = 1;   // 패닉: index out of bounds: the len is 0 but the index is 0

고친 코드는 push 로 채우거나, 0 으로 채운 길이 3 짜리가 필요하면 vec![0; 3] 을 쓴다.

let mut v: Vec<u32> = Vec::with_capacity(3);
v.push(1);

Cow 를 쓰면서 항상 복사한다

반환 타입만 Cow 이고 안에서 늘 Owned 를 만들면 복사를 피하는 효과가 없다.

fn normalize_sku(raw: &str) -> Cow<'_, str> {
    Cow::Owned(raw.to_ascii_uppercase())   // 이미 대문자여도 매번 할당한다
}
fn normalize_sku(raw: &str) -> Cow<'_, str> {
    if raw.bytes().any(|b| b.is_ascii_lowercase()) {
        Cow::Owned(raw.to_ascii_uppercase())
    } else {
        Cow::Borrowed(raw)
    }
}

중간 Vec 을 만들고, 한 번만 잰다

합계를 구하려고 collect 로 짝수만 모은 Vec 을 만든 뒤 다시 합치는 코드는 불필요한 할당을 한다. 또 한 번 잰 시간으로 우열을 가리는 것도 위험하다.

let evens: Vec<u32> = data.iter().copied().filter(|x| x % 2 == 0).collect();
let total: u64 = evens.iter().map(|&x| u64::from(x)).sum();
let total: u64 = data
    .iter()
    .filter(|&&x| x % 2 == 0)
    .map(|&x| u64::from(x))
    .sum();

측정은 같은 코드를 여러 번 돌려 가장 작은 값이나 중앙값으로 비교한다.

한눈에 보기

이 장에서 쓴 도구와 한 줄 요약
도구역할핵심 규칙이 장의 예
Vec::with_capacity(n)버퍼를 미리 잡는다길이는 0, 용량은 n 이상parse_all
Cow<'_, str>필요할 때만 복사한다고치면 Owned, 아니면 Borrowednormalize_sku
&str 슬라이스원본의 일부를 가리킨다원본이 더 오래 살아야 한다split_once, trim
--release최적화 빌드성능 비교는 여기서만cargo run --release
Instant, black_box시간 측정, 계산 제거 방지시간은 출력하지 않고 여러 번 잰다measure

연습 문제

  1. parse_all 의 Vec::with_capacity 를 Vec::new() 로 바꿔도 출력이 같은지 설명하고, 그래도 with_capacity 를 쓰는 이유를 말하라.
  2. 공백을 모두 제거한 문자열을 돌려주되, 공백이 없으면 복사하지 않는 fn strip_spaces(s: &str) -> Cow<'_, str> 를 작성하라.
  3. 한 번 측정한 시간으로 "반복자가 루프보다 빠르다"고 결론짓는 것이 왜 위험한지 두 가지 이유를 들어라.
  4. totals 가 HashMap<String, u32> 를 썼다면 어떤 비용이 늘어나는지, 반환 시그니처는 어떻게 달라지는지 말하라.

정답과 해설

  1. 출력은 같다. with_capacity 는 결과 값이 아니라 할당 횟수에 영향을 준다. Vec::new() 는 용량 0 에서 시작해 push 가 용량을 넘을 때마다 재할당하고 원소를 옮긴다. 줄 수가 상한이라는 정보를 이미 알고 있으므로 한 번에 잡는 편이 낫다.
  2. use std::borrow::Cow;
    
    fn strip_spaces(s: &str) -> Cow<'_, str> {
        if s.contains(' ') {
            Cow::Owned(s.replace(' ', ""))
        } else {
            Cow::Borrowed(s)
        }
    }
    공백이 있을 때만 replace 가 새 String 을 만든다. 없으면 입력을 그대로 빌린다.
  3. 첫째, 한 번의 측정에는 캐시 상태와 다른 프로세스의 영향이 섞여 있어 값이 흔들린다. 둘째, 먼저 실행한 쪽이 데이터를 캐시에 올려 두면 나중 쪽이 유리해져 실행 순서가 결과를 바꾼다. 여러 번 반복하고 순서를 바꿔 가며 가장 작은 값이나 중앙값을 비교해야 한다. 디버그 빌드에서 쟀다면 그것도 이유가 된다.
  4. 키가 String 이면 SKU 마다 새 문자열을 할당해 복사해야 한다. 반환 타입도 Vec<(String, u32)> 가 되어 호출하는 쪽이 소유권을 받는다. 입력 로그가 결과보다 오래 사는 이 예제에서는 &str 키가 더 싸다. 반대로 결과가 입력보다 오래 살아야 하면 String 이 맞다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.