Devin.KR

성능 측정과 메모리 - 복사가 일어나는 순간

개발자KR 조회 0

이 장에서 배우는 것

편의점 체인의 판매 분석 코드가 작은 표에서는 잘 돌아가다가 지점과 거래 건수가 늘자 느려졌다고 하자. 반복문을 모두 없애거나 컴퓨터의 메모리를 늘리기 전에, 먼저 시간이 어디에서 쓰이고 어떤 객체가 새로 만들어지는지 확인해야 한다. 실행 시간과 메모리는 연결되어 있지만 같은 문제가 아니다. 빠른 계산도 큰 임시 객체를 만들 수 있고, 메모리를 아끼는 계산도 반복 횟수가 많으면 느릴 수 있다.

앞 장에서 식을 값으로 다루었다면, 여기서는 식을 실행하는 동안 일어나는 일을 살펴본다. 관찰 도구를 먼저 익힌 뒤, 거래별 재고 금액을 계산하는 코드를 여러 방식으로 작성하고 같은 결과가 나오는지 검사한다. 측정값은 실행 환경에 따라 달라지므로, 완성 프로그램은 측정을 수행하되 화면에는 일정한 분석 결과와 검사 결과만 출력한다.

  • system.time()으로 작업 전체의 시간을 측정하고 측정 조건을 설명한다.
  • Rprof()로 실행 중인 함수들을 표본으로 관찰한다.
  • tracemem()으로 추적하는 객체의 복제 메시지를 살펴본다.
  • 결과 공간을 미리 할당하고, 벡터 계산과 반복문의 임시 객체를 비교한다.
  • 큰 데이터를 처리할 때 필요한 열과 처리 단위를 정하고 결과를 검증한다.

문제 상황

세 지점을 운영하는 편의점 체인이 거래별 판매 수량과 단가를 기록한다. 담당자는 각 거래에 대응하는 보유 수량의 금액을 계산한 뒤 지점별로 합계를 낸다. 처음에는 빈 벡터를 만들고 거래를 하나씩 읽어 계산값을 뒤에 붙였다. 거래가 수백 건일 때는 불편하지 않았지만, 거래가 쌓이자 계산을 기다리는 시간이 길어졌다.

stock_value <- numeric(0)
for (i in seq_len(nrow(sales))) {
  stock_value <- c(
    stock_value,
    sales$stock[i] * sales$unit_price[i]
  )
}

이 코드의 문제는 곱셈 자체보다 결과를 쌓는 방식에 있다. 반복할 때마다 기존 결과와 새 값을 이어 붙이는 객체가 만들어진다. 결과가 길어질수록 이미 계산한 값들을 다시 옮기는 비용이 커진다. 각 단계가 이전 결과 전체를 옮긴다고 보면, 옮기는 원소 수는 대략 1부터 거래 건수까지의 합에 비례한다.

담당자는 또 다른 문제를 발견한다. 원본을 보관하려고 backup <- sales를 실행한 뒤 작업용 데이터의 열을 바꾸었는데, 메모리 사용량이 예상보다 많이 늘었다. 이름을 하나 더 붙이는 순간과 실제로 데이터를 수정하는 순간을 구분하지 않으면, 어느 문장이 비용을 만든 것인지 판단하기 어렵다. 이 두 상황을 시간 측정과 복제 관찰로 나누어 살펴본다.

시간을 재고 병목을 찾는다

전체 시간과 구간 시간을 구분한다

system.time()은 전달한 식을 실행하고 사용 시간을 반환한다. 보통 확인하는 값은 user.self, sys.self, elapsed다. 첫째는 현재 R 프로세스가 사용자 계산에 쓴 시간, 둘째는 운영체제 작업에 쓴 시간, 셋째는 사람이 기다린 경과 시간이다. 파일 읽기처럼 대기 시간이 있는 작업에서는 경과 시간과 계산 시간이 다르게 보일 수 있다.

timing <- system.time({
  result <- sales$stock * sales$unit_price
})
timing[c("user.self", "sys.self", "elapsed")]

짧은 식을 한 번 실행한 값만으로 두 구현의 우열을 정하지 않는다. 측정 해상도 때문에 시간이 0으로 표시될 수도 있다. 입력 크기를 늘리거나 같은 작업을 여러 번 실행하되, 결과의 정확성은 별도로 확인한다. 운영체제의 다른 작업, 파일 캐시, R의 실행 상태도 측정에 영향을 준다. 몇 밀리초 차이를 코드의 본질적인 차이라고 단정하지 않는다.

system.time()의 기본 설정은 측정 전에 가비지 컬렉션(garbage collection)을 수행하도록 요청한다. 이는 사용하지 않는 객체의 공간을 회수하는 작업이다. 측정 시작 상태를 정리하는 데 도움이 되지만, 측정 중 새 객체가 많이 생기면 그 안에서도 회수가 일어날 수 있다. gc()의 결과는 관리 중인 메모리를 살펴보는 자료이며, 운영체제가 보고하는 전체 프로세스 메모리와 같은 수치가 아니다.

측정 질문에 따라 선택하는 도구
도구답하는 질문해석할 때의 제한
system.time()이 작업 전체에 시간이 얼마나 들었는가짧은 작업의 한 번 측정은 흔들린다
Rprof()시간 표본에서 어떤 함수가 자주 보이는가모든 호출을 기록하는 도구는 아니다
tracemem()추적한 객체의 복제 메시지가 발생하는가전체 메모리 할당을 기록하지 않는다
object.size()이 객체의 크기를 대략 어떻게 볼 것인가공유 관계와 최대 메모리를 모두 설명하지 않는다

프로파일은 실행 중인 함수를 표본으로 기록한다

프로파일링(profiling)은 실행 중인 작업을 일정한 간격으로 관찰하여 비용이 집중된 위치를 찾는 방법이다. Rprof()는 함수 호출 스택을 파일에 기록한다. 시작한 뒤 조사할 코드를 실행하고, Rprof(NULL)로 반드시 종료한다. 계속 켜 두면 이후 작업까지 기록에 섞인다.

profile_file <- tempfile(fileext = ".out")
Rprof(profile_file, interval = 0.01)
for (k in seq_len(100L)) {
  result <- sales$stock * sales$unit_price
}
Rprof(NULL)
profile <- summaryRprof(profile_file)
unlink(profile_file)

summaryRprof()의 by.total은 해당 함수와 그 아래에서 호출한 함수들을 포함한 시간을 보여 준다. by.self는 그 함수 자체에 속한 시간을 보여 준다. 전체 시간 열을 여러 행에 걸쳐 더하면 중첩된 호출을 여러 번 셀 수 있다. 표에서 큰 항목을 찾은 뒤 그 구간을 작게 나누어 다시 측정하는 편이 좋다.

작업이 표본 간격보다 빨리 끝나면 기록이 비어 있을 수 있다. 표본이 적을 때 나타난 순위도 쉽게 바뀐다. 반복 횟수를 늘려 관찰 시간을 확보하되, 같은 작업을 반복한 결과가 실제 업무의 입력과 실행 흐름을 대표하는지도 확인한다. 프로파일을 켠 실행과 끈 실행의 시간은 따로 비교한다.

실시간 기준의 경과 시간을 측정하는 system.time()과 달리, 이 환경에서 사용하는 기본 Rprof()의 표본은 CPU 시간을 기준으로 해석한다. 파일이나 네트워크의 대기 원인을 함수별 표만으로 모두 설명하려고 하지 않는다. 큰 파일의 읽기와 변환은 구간별 경과 시간을 함께 측정해야 한다.

전체 시간 측정과 함수 표본 관찰을 함께 사용하면 다시 측정할 구간을 좁힐 수 있다

객체를 공유하다가 수정할 때 복제를 관찰한다

b <- a를 실행했다고 해서 항상 데이터 전체가 그 순간 복제되는 것은 아니다. 두 이름이 같은 값을 가리키는 상태로 시작할 수 있다. 이후 한쪽을 수정할 때 다른 쪽의 값이 보존되도록 필요한 복제가 일어날 수 있다. 이를 수정 시 복사(copy-on-modify)라는 관점으로 이해하면, 이름을 추가하는 비용과 값을 바꾸는 비용을 구분하기 쉽다.

다만 이것은 모든 객체에 적용되는 단순한 복제 횟수 공식이 아니다. 객체 종류, 참조 상태, 함수 호출, R의 내부 구현에 따라 관찰 결과가 달라진다. 여기서는 일반 벡터와 데이터 프레임을 대상으로 생각한다. 다른 장에서 다룬 참조 방식의 객체까지 같은 설명으로 묶지 않는다.

a <- c(12, 8, 5)
token <- tracemem(a)
b <- a
b[1L] <- 0
untracemem(a)
untracemem(b)

tracemem()은 추적 식별자를 반환하고, 추적 대상이 내부적으로 복제되면 메시지를 출력한다. b <- a와 b[1L] <- 0 사이를 구분해 관찰한다. 복제 메시지에 들어 있는 식별자는 실행마다 달라질 수 있으며, 메시지의 개수와 호출 경로도 일정하다고 가정하지 않는다. 관찰을 끝내면 untracemem()으로 추적을 해제한다.

새로운 계산 결과를 만드는 것과 기존 객체를 복제하는 것은 구분해야 한다. a * 2는 결과 벡터를 새로 만들지만, 그것이 추적 중인 a의 복제 메시지로 나타난다고 보장할 수 없다. 메시지가 없다는 사실만으로 메모리 할당이 없었다고 판단하면 안 된다.

데이터 프레임은 열을 담은 리스트다. 데이터 프레임의 바깥 구조와 각 열 벡터는 따로 생각해야 한다. 바깥 구조를 추적한 결과만으로 모든 열의 복제 여부를 알 수 없다. 특정 열이 문제라면 그 열도 따로 추적한다. 열을 하나 추가하거나 바꿀 때 항상 모든 열이 함께 복제된다고 단정하지 않는다.

object.size()는 객체 크기를 비교하는 출발점으로 유용하다. 하지만 공유 중인 두 객체의 크기를 각각 구해 더한 값을 실제 점유 메모리라고 읽지는 않는다. 내부 공유, 지연된 표현, 외부 자원 등의 특성을 모두 반영하는 전체 메모리 계측값이 아니기 때문이다. 최종 결과의 크기뿐 아니라 계산 도중 함께 살아 있는 입력과 임시 객체도 고려한다.

두 이름이 공유하던 벡터를 한쪽에서 수정하면 다른 값이 보존되도록 복제가 일어날 수 있다

미리 할당하고 벡터화를 다시 검토한다

결과 길이를 알고 있다면 반복문을 시작하기 전에 공간을 만든다. 금액은 숫자 벡터로 저장하므로 numeric(n)을 사용한다. 반복 안에서는 결과를 늘리지 않고 이미 있는 위치에 값을 넣는다. 빈 입력도 처리할 수 있도록 인덱스는 seq_len(n)으로 만든다.

n <- nrow(sales)
stock_value <- numeric(n)
for (i in seq_len(n)) {
  stock_value[i] <- sales$stock[i] * sales$unit_price[i]
}

함수 안에서는 필요한 열을 반복문 밖에서 지역 변수에 담아 둘 수도 있다. 이렇게 하면 매 반복에서 데이터 프레임의 열을 꺼내는 표현을 다시 평가하지 않는다. 결과 길이가 정해져 있고 계산 규칙이 거래마다 달라지는 경우, 미리 할당한 반복문은 읽기 쉬운 선택이 된다.

현재 계산은 같은 위치의 두 숫자를 곱하는 작업이므로 sales$stock * sales$unit_price로 표현할 수 있다. 벡터화(vectorization)는 이런 반복을 R 수준의 반복문 밖에서 처리하도록 한다. 표현이 짧고 실행 비용이 줄어드는 경우가 많지만, 항상 메모리를 덜 쓰는 것은 아니다.

예를 들어 stock * unit_price * rate는 계산 과정에서 중간 벡터를 만들 수 있다. 여러 조건 벡터, 부분 집합, 정렬 결과를 한꺼번에 보관하면 최종 출력보다 훨씬 큰 메모리가 필요할 수 있다. 기본서에서 익힌 벡터화와 apply 계열도 이름만 보고 빠르다고 판단하지 않는다. 호출 비용과 자료형 변환을 포함해 같은 결과를 만드는 구현을 비교한다.

한 번에 처리할 데이터가 너무 크면 일정한 크기의 덩어리로 계산한다. 거래별 결과가 필요하면 전체 결과 공간을 미리 만들고 각 덩어리의 값을 채운다. 합계만 필요하면 거래별 결과 전체를 보관하지 않고 덩어리별 합계만 누적할 수 있다. 뒤의 완성 코드에서 덩어리 방식은 계산 중간 벡터의 길이를 제한하지만, 입력과 최종 결과는 여전히 메모리에 남는다.

CSV를 직접 다룰 때도 같은 구분이 필요하다. read.csv()로 파일 전체를 읽은 다음 나누는 것은 계산을 나누는 방법이지 입력 메모리를 줄이는 방법은 아니다. 입력 자체가 크면 연결을 열고 일정한 행 수씩 읽는 흐름을 설계해야 한다. 이때 머리글, 열 자료형, 마지막의 짧은 덩어리를 일관되게 처리한다. 사용하지 않는 열은 읽기 단계에서 제외하는 편이 효과적이다.

최적화의 순서는 먼저 결과를 고정하고, 측정으로 비용이 큰 구간을 찾고, 필요한 데이터와 임시 객체를 줄인 뒤, 다시 정확성과 성능을 확인하는 것이다. 작은 예제에서 빠른 구현이 실제 파일에서도 빠르다는 보장은 없다. 실제 업무를 대표하는 입력 크기와 열 구성을 사용해야 한다.

완성 코드

다음 내용을 main.R로 저장한다. R은 이 파일을 해석하여 실행하므로 별도 컴파일 명령은 필요하지 않다. 외부 패키지와 입력 파일을 사용하지 않는다. 시간과 프로파일은 실행 중의 관찰 자료로만 보관하고, 추적 메시지도 화면 출력에서 제외한다. 따라서 표준 출력은 일정하다. 이 예제에서는 그래프를 만들지 않는다.

메모리 추적이나 프로파일링을 지원하지 않는 R 빌드도 고려하여 기능 지원 여부를 확인한다. 프로파일의 표본 개수나 복제 메시지 개수는 검사의 합격 조건으로 삼지 않는다. 분석 결과가 같은지, 원본이 보존되는지, 빈 입력을 처리하는지를 검사한다.

make_sales <- function(n) {
  i <- seq_len(n)
  data.frame(
    branch = rep(c("A", "B", "C"), length.out = n),
    stock = rep(c(12, 8, 5), length.out = n),
    unit_price = rep(c(1000, 1500, 2000), length.out = n),
    stringsAsFactors = FALSE
  )
}

value_grow <- function(stock, price) {
  out <- numeric(0)
  for (i in seq_along(stock)) {
    out <- c(out, stock[i] * price[i])
  }
  out
}

value_preallocate <- function(stock, price) {
  out <- numeric(length(stock))
  for (i in seq_along(stock)) {
    out[i] <- stock[i] * price[i]
  }
  out
}

value_vector <- function(stock, price) {
  stock * price
}

value_chunk <- function(stock, price, chunk_size = 1000L) {
  stopifnot(
    length(chunk_size) == 1L,
    is.finite(chunk_size),
    chunk_size >= 1,
    chunk_size == floor(chunk_size)
  )
  n <- length(stock)
  out <- numeric(n)
  if (n == 0L) {
    return(out)
  }
  starts <- seq.int(1L, n, by = chunk_size)
  for (start in starts) {
    end <- min(start + chunk_size - 1, n)
    idx <- seq.int(start, end)
    out[idx] <- stock[idx] * price[idx]
  }
  out
}

check_equal <- function(actual, expected) {
  stopifnot(isTRUE(all.equal(
    actual, expected, tolerance = 0,
    check.attributes = TRUE
  )))
  invisible(TRUE)
}

observe_copy <- function() {
  if (!isTRUE(capabilities("profmem"))) {
    return(character(0))
  }
  original <- c(12, 8, 5)
  messages <- capture.output({
    token <- tracemem(original)
    changed <- original
    changed[1L] <- 0
    untracemem(original)
    untracemem(changed)
  })
  check_equal(original, c(12, 8, 5))
  check_equal(changed, c(0, 8, 5))
  messages
}

run_profile <- function(stock, price) {
  if (!isTRUE(capabilities("Rprof"))) {
    return(NULL)
  }
  path <- tempfile(fileext = ".out")
  on.exit({
    Rprof(NULL)
    unlink(path)
  }, add = TRUE)
  Rprof(path, interval = 0.01)
  for (k in seq_len(200L)) {
    result <- value_preallocate(stock, price)
  }
  Rprof(NULL)
  summaryRprof(path)
}

main <- function() {
  sales <- make_sales(12000L)
  stock <- sales$stock
  price <- sales$unit_price

  timings <- list()
  timings$grow <- system.time(
    grown <- value_grow(stock, price)
  )
  timings$preallocate <- system.time(
    allocated <- value_preallocate(stock, price)
  )
  timings$vector <- system.time(
    vectorized <- value_vector(stock, price)
  )
  timings$chunk <- system.time(
    chunked <- value_chunk(stock, price, 1000L)
  )

  check_equal(grown, vectorized)
  check_equal(allocated, vectorized)
  check_equal(chunked, vectorized)

  empty <- numeric(0)
  check_equal(value_grow(empty, empty), empty)
  check_equal(value_preallocate(empty, empty), empty)
  check_equal(value_vector(empty, empty), empty)
  check_equal(value_chunk(empty, empty), empty)

  trace_messages <- observe_copy()
  profile <- run_profile(stock, price)
  result_bytes <- as.numeric(object.size(vectorized))

  totals <- vapply(c("A", "B", "C"), function(branch) {
    sum(vectorized[sales$branch == branch])
  }, numeric(1))
  check_equal(
    unname(totals),
    c(48000000, 48000000, 40000000)
  )

  cat("거래 수: ", nrow(sales), "\n", sep = "")
  cat("네 구현의 결과 일치: TRUE\n")
  cat("빈 입력 검사: TRUE\n")
  for (branch in names(totals)) {
    cat(
      branch, " 지점 재고 금액: ",
      sprintf("%.0f", totals[[branch]]), "원\n",
      sep = ""
    )
  }
  cat(
    "전체 재고 금액: ",
    sprintf("%.0f", sum(totals)), "원\n",
    sep = ""
  )
  cat("시간·추적·프로파일 자료는 화면에 출력하지 않음\n")
  invisible(list(
    timings = timings,
    trace_messages = trace_messages,
    profile = profile,
    result_bytes = result_bytes
  ))
}

diagnostics <- main()

줄별 해설

make_sales()는 난수 없이 반복되는 거래 자료를 만든다. 각 지점은 4,000번씩 등장한다. A 지점의 거래별 금액은 12,000원, B 지점도 12,000원, C 지점은 10,000원이다. 따라서 합계의 예상값을 코드 밖에서도 계산할 수 있다. seq_len(n)은 빈 입력까지 고려하는 인덱스 생성 방식의 예로 두었으며, 이 자료 생성 함수에서는 지점과 수량 패턴만 사용한다.

value_grow()는 비교 기준으로 남겨 둔 구현이다. numeric(0)에서 시작하여 c()로 결과를 늘린다. 여기서 쓰는 자료 크기는 문제를 관찰할 수 있으면서 실행이 지나치게 길어지지 않도록 정했다. 이 구현을 실제 대규모 거래 처리의 기본값으로 선택하지 않는다.

value_preallocate()는 stock의 길이만큼 결과 공간을 만든다. 반복문은 값의 저장 위치만 바꾼다. 이 함수들과 벡터 구현은 두 입력이 같은 길이의 숫자 벡터라는 전제에서 사용한다. 완성 프로그램은 같은 데이터 프레임의 두 열을 넘겨 그 전제를 만족한다. 다른 코드에서 재사용할 때는 전제를 검사하는 것이 좋다.

value_vector()는 같은 위치의 수량과 단가를 곱한다. 두 입력의 길이를 확인하지 않으면 R의 재활용 규칙으로 의도와 다른 결과가 나올 수 있다. 특히 길이가 정확한 배수일 때는 경고 없이 재활용되므로, 경고가 없다는 사실만으로 입력이 올바르다고 판단하지 않는다.

value_chunk()의 첫 검사는 처리 단위가 양의 정수 값인지 확인한다. 빈 입력은 시작 위치를 만들기 전에 반환한다. starts는 각 덩어리의 시작 위치이고, min()은 마지막 덩어리가 벡터 끝을 넘지 않게 한다. stock[idx], price[idx]와 곱셈 결과는 덩어리 크기의 임시 객체가 될 수 있다. 덩어리 방식에도 할당 비용이 있다는 점을 기억한다.

check_equal()은 all.equal()의 결과를 isTRUE()로 판정한다. all.equal()은 다르면 설명 문자열을 반환할 수 있으므로 결과를 직접 논리값처럼 다루지 않는다. 이 예제는 작은 정수 값의 곱셈이므로 허용 오차를 0으로 두었다. 일반적인 실수 계산에서는 연산 순서에 따른 차이를 고려하여 허용 오차를 정한다.

observe_copy()는 지원 여부를 확인하고 추적 메시지를 문자 벡터로 받아 둔다. 식별자를 반환하는 tracemem() 호출은 변수에 대입해 식별자 자체가 자동 출력되지 않게 한다. 수정 후 두 객체의 추적을 해제하고, 원본과 수정본을 각각 검사한다. 테스트의 핵심은 복제 횟수가 아니라 두 값이 의도대로 보존되는 것이다.

run_profile()은 임시 파일을 만들고 종료 처리를 먼저 등록한다. 정상 실행에서는 명시적으로 프로파일을 끈 뒤 요약하고, 함수가 끝날 때 파일을 지운다. 중간에 오류가 발생해도 on.exit()가 프로파일 종료와 파일 정리를 시도한다. 여기서는 독립 실행을 전제로 하므로 기존 프로파일 세션과 함께 사용하지 않는다.

main()은 네 구현을 각각 측정하고 결과를 비교한다. 한 번씩 측정한 값은 도구 사용 예시이며 성능 순위를 확정하는 자료가 아니다. 프로파일링은 시간 측정 구간이 끝난 뒤 별도로 수행한다. vapply()는 지점마다 숫자 하나가 나와야 한다는 기대를 명시한다. 합계와 빈 입력 검사가 모두 통과한 뒤에만 정상 출력이 이어진다.

마지막 반환값은 invisible()로 감싼다. Rscript 실행에서는 진단값이 화면에 나오지 않지만, 대화형 R에서 파일을 읽으면 diagnostics$timings와 diagnostics$profile$by.total 등을 살펴볼 수 있다. 지원하지 않는 기능의 진단값은 비어 있을 수 있다. 시간, 표본, 추적 식별자는 일정하지 않으므로 예상 출력에 넣지 않는다.

실행 결과

터미널에서 다음 명령을 실행한다.

Rscript main.R

정상 실행의 표준 출력은 다음과 같다. 임시 프로파일 파일은 실행 후 삭제되며, 별도의 결과 파일은 생성하지 않는다.

거래 수: 12000
네 구현의 결과 일치: TRUE
빈 입력 검사: TRUE
A 지점 재고 금액: 48000000원
B 지점 재고 금액: 48000000원
C 지점 재고 금액: 40000000원
전체 재고 금액: 136000000원
시간·추적·프로파일 자료는 화면에 출력하지 않음

진단값을 직접 보려면 대화형 R에서 다음 코드를 실행한다. 표시되는 수치는 컴퓨터와 실행 시점에 따라 달라진다. 빠른 작업의 경과 시간이 0이거나 프로파일 요약이 비어 있어도, 그 사실만으로 분석 검사가 실패한 것은 아니다.

source("main.R")
diagnostics$timings
diagnostics$trace_messages
if (!is.null(diagnostics$profile)) {
  diagnostics$profile$by.total
}
diagnostics$result_bytes

실무에서 자주 틀리는 것

결과를 늘리는 비용과 빈 입력을 놓친다

다음 코드는 결과를 계속 늘리고, 입력 길이가 0일 때도 1:0으로 반복을 시작한다. R에서 1:0은 빈 벡터가 아니라 1과 0을 담은 벡터다.

out <- numeric(0)
for (i in 1:length(stock)) {
  out <- c(out, stock[i] * price[i])
}

결과 공간을 먼저 만들고, 입력에서 유효한 위치를 생성한다.

out <- numeric(length(stock))
for (i in seq_along(stock)) {
  out[i] <- stock[i] * price[i]
}

벡터 계산이 입력 길이를 검사해 준다고 생각한다

단가 두 개를 수량 네 개에 곱하면 값이 재활용된다. 길이가 배수이므로 경고 없이 실행되지만 거래별 단가라는 뜻은 보장되지 않는다.

stock <- c(12, 8, 5, 10)
price <- c(1000, 1500)
out <- stock * price

거래별로 대응해야 하는 입력은 길이를 검사한다. 지점별 단가를 의도적으로 재사용하려면 지점 이름으로 먼저 대응시키는 별도의 규칙을 작성해야 한다.

stock <- c(12, 8, 5, 10)
price <- c(1000, 1500, 2000, 1000)
stopifnot(
  is.numeric(stock),
  is.numeric(price),
  length(stock) == length(price)
)
out <- stock * price

프로파일을 켠 채 다음 작업을 계속한다

프로파일을 종료하지 않으면 이후 계산도 같은 기록에 들어간다. 조사한 함수만의 비용으로 해석하기 어려워진다.

Rprof("sales-profile.out")
out <- value_preallocate(stock, price)
totals <- sum(out)

측정할 구간을 함수로 감싸고 종료 처리를 등록한다. 다음 코드는 결과 파일을 남겨 나중에 요약할 수 있게 한다.

profile_once <- function(stock, price) {
  on.exit(Rprof(NULL), add = TRUE)
  Rprof("sales-profile.out")
  value_preallocate(stock, price)
}
out <- profile_once(stock, price)
totals <- sum(out)

이 짧은 실행은 표본이 없을 수도 있다. 그런 경우에는 대표적인 작업을 반복하거나 입력을 늘려 다시 관찰한다. 기능 지원 여부도 완성 코드처럼 먼저 확인한다.

공유 객체의 크기를 더해 실제 메모리로 해석한다

다음 합은 두 이름 사이의 공유를 고려한 실제 점유 메모리가 아니다. 한 객체의 수정으로 공유 상태가 바뀌면 같은 계산식의 의미도 달라진다.

backup <- sales
estimated_total <- object.size(sales) + object.size(backup)

각 크기는 객체별 참고값으로 보고, 복제 여부는 추적으로 따로 관찰한다. 다음 코드는 바깥 데이터 프레임이 아니라 관심 있는 열을 추적한다.

stock <- sales$stock
token <- tracemem(stock)
working_stock <- stock
working_stock[1L] <- 0
untracemem(stock)
untracemem(working_stock)
stopifnot(stock[1L] == sales$stock[1L])
object.size(stock)

추적 결과와 객체 크기를 함께 보더라도 전체 프로세스의 최대 메모리를 산출한 것은 아니다. 실제 업무에서는 필요한 열을 줄이고, 불필요한 중간 결과를 오래 보관하지 않는 설계가 먼저다.

한눈에 보기

판매·재고 계산에서 선택할 구현과 확인 사항
상황선택확인할 점
결과 길이를 안다numeric(n) 후 위치별 대입빈 입력과 결과 자료형
같은 위치끼리 계산한다벡터 곱셈입력 길이와 임시 벡터
중간 계산이 너무 크다덩어리별 처리마지막 덩어리와 전체 결과의 필요성
느린 위치를 모른다전체 시간 측정 후 프로파일표본 수와 대표 입력
수정할 때 메모리가 늘어난다관심 객체를 추적바깥 구조와 열을 구분
합계만 필요하다덩어리 합계 누적실수 연산의 허용 오차

외부 패키지를 쓰는 코드와 비교할 때도 계산식 자체와 자료 처리 도구를 구분한다. 이 장에서는 base R만으로 필요한 작업을 수행한다.

base R 코드와 tidyverse 코드의 표현 대응
작업base Rtidyverse 대응성능 판단
계산 열 추가sales$value <- stock * pricedplyr::mutate()추가되는 열과 중간 객체를 확인한다
조건에 맞는 행 선택sales[keep, , drop = FALSE]dplyr::filter()조건 벡터와 부분 집합의 크기를 확인한다
함수를 여러 입력에 적용lapply(), vapply()purrr::map()함수 호출과 결과 결합 비용을 측정한다

세부 동작을 확인할 때는 system.time 공식 도움말, Rprof 공식 도움말, tracemem 공식 도움말을 참고할 수 있다. 측정으로 확인한 계산의 특성을 바탕으로, 다음 장에서는 자료를 길게 또는 넓게 바꾸고 나누어 묶는 작업을 살펴본다.

연습 문제

  1. value_chunk()가 길이 7인 입력을 처리 단위 3으로 계산하게 하라. 미리 할당한 반복문과 결과가 같음을 검사하고, 마지막 덩어리의 인덱스를 설명하라.
  2. 거래별 결과를 반환하지 않고 재고 금액 합계 하나만 반환하는 sum_chunk()를 작성하라. 빈 입력은 0을 반환하고, 합계가 벡터 계산의 합과 같은지 검사하라.
  3. 미리 할당한 반복문과 벡터 계산을 각각 50번 실행하는 시간을 측정하라. 프로파일을 켜지 말고, 결과 일치 검사는 측정 구간 밖에서 수행하라. 왜 측정값을 예상 출력으로 고정할 수 없는지 설명하라.
  4. tracemem() 메시지가 없는데도 메모리 할당이 일어날 수 있는 계산을 하나 제시하라. 데이터 프레임의 바깥 구조만 추적하면 어떤 관찰을 놓칠 수 있는지도 설명하라.

정답과 해설

마지막 덩어리까지 검사한다

stock <- c(12, 8, 5, 10, 6, 9, 4)
price <- rep(1000, 7)
actual <- value_chunk(stock, price, 3L)
expected <- value_preallocate(stock, price)
check_equal(actual, expected)

시작 위치는 1, 4, 7이다. 덩어리는 1부터 3, 4부터 6, 마지막으로 7만 포함한다. 마지막 끝 위치에 min()을 사용하지 않으면 길이를 넘는 인덱스가 생겨 결측값이 계산에 들어갈 수 있다.

전체 결과 대신 합계만 누적한다

sum_chunk <- function(stock, price, chunk_size = 1000L) {
  stopifnot(
    length(stock) == length(price),
    length(chunk_size) == 1L,
    is.finite(chunk_size),
    chunk_size >= 1,
    chunk_size == floor(chunk_size)
  )
  n <- length(stock)
  total <- 0
  if (n == 0L) {
    return(total)
  }
  for (start in seq.int(1L, n, by = chunk_size)) {
    end <- min(start + chunk_size - 1, n)
    idx <- seq.int(start, end)
    total <- total + sum(stock[idx] * price[idx])
  }
  total
}
sales <- make_sales(12000L)
check_equal(
  sum_chunk(sales$stock, sales$unit_price),
  sum(sales$stock * sales$unit_price)
)
check_equal(sum_chunk(numeric(0), numeric(0)), 0)

이 함수는 거래별 결과 벡터 전체를 만들지 않는다. 입력은 계속 메모리에 있지만 추가로 보관하는 결과는 합계 하나다. 일반적인 실수 자료에서는 합산 순서가 바뀌어 작은 차이가 날 수 있으므로, 필요한 정확도에 맞는 허용 오차로 검사한다.

반복 측정에서도 결과 검사는 따로 둔다

sales <- make_sales(12000L)
stock <- sales$stock
price <- sales$unit_price
check_equal(
  value_preallocate(stock, price),
  value_vector(stock, price)
)
loop_time <- system.time({
  for (k in seq_len(50L)) {
    out_loop <- value_preallocate(stock, price)
  }
})
vector_time <- system.time({
  for (k in seq_len(50L)) {
    out_vector <- value_vector(stock, price)
  }
})
check_equal(out_loop, out_vector)

검사 시간을 구현의 계산 시간에 섞지 않았다. 반복으로 관찰 시간을 늘렸지만 입력, 실행 순서, CPU 부하와 메모리 회수의 영향은 남는다. 비교를 다시 수행하고 순서를 바꾸어 볼 수 있다. 측정값은 관찰 결과로 기록하며 모든 컴퓨터에서 같아야 하는 출력으로 취급하지 않는다.

복제 관찰과 새 결과 생성은 다르다

stock <- c(12, 8, 5)
doubled <- stock * 2
stopifnot(identical(doubled, c(24, 16, 10)))

곱셈 결과를 담을 객체가 필요하다. 원본을 복제했다는 추적 메시지가 없어도 새 결과의 할당은 발생할 수 있다. 데이터 프레임의 바깥 구조만 추적하면 개별 열 벡터에서 일어나는 복제를 충분히 관찰하지 못할 수 있다. 무엇을 추적했는지 먼저 밝히고, 메시지가 말해 주는 범위 안에서 결론을 내린다.

댓글 0

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

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