brunch

You can make anything
by writing

C.S.Lewis

by 채규병 Dec 07. 2020

어떻게 R 속도를 높일 수 있을까

컴퓨터는 잘못이 없다


오픈소스인 R은 무료입니다. 그리고 웬만한 기능은 라이브러리가 있어 편하게 구현할 수 있습니다. 그러다 보니 시스템에서도 R을 활용하고자 하는 니즈가 많아졌습니다. 하지만 저는 R이 규모가 큰 애플리케이션에 적합한 언어는 아니라고 생각합니다. 왜냐하면 R은 정말 정말 정말 느리기 때문입니다. 분석을 위한 언어로써 활용 가치가 크지만 대규모 데이터 처리 속도는 암울합니다. 물론 data.table, dplyr 등 편의와 속도를 크게 개선한 좋은 패키지가 나왔습니다. 하지만 이러한 패키지도 큰 데이터 셋을 분석하는 용도로는 좋아졌지만, 애플리케이션화 하기에는 부족함이 많습니다(아니, 느립니다).


하지만 R은 그 범용성 때문에 여전히 매력적인 언어입니다. 분석하기에도 정말 편리하고요. R도 몇 가지 원칙만 지킨다면 속도를 높일 수 있습니다. 몇 가지 최적화를 통해서 12시간 걸리던 R 프로그램을 2시간으로 줄였던 경험을 공유하고자 합니다. R로 애플리케이션을 만들지 않더라도, 분석할 때에 속도 때문에 고민한 적이 있는 분에게 도움이 되었으면 좋겠습니다.




컴퓨터를 바꾸자

농담이 아닙니다. 하드웨어 성능이 떨어지면 아무리 코드를 바꾸어도 속도는 개선되지 않을 수 있습니다. 코드가 느리다면 자신의 하드웨어가 어떤 스펙을 가지고 있는지 살펴봅시다. 요즘 딥러닝 등으로 GPU, RAM과 CPU의 코어 수 등에 관심도 높아지고 있어 쉽게 설명한 자료도 많습니다. 간단한 CPU 코어와 RAM에 대한 설명은 참고자료(8)를 보면 좋습니다.



자신이 사용하고 있는 하드웨어를 확인하는 것이 왜 중요할까요? 저는 이번 프로젝트에서 샌드박스라고 VM을 할당받아서 사용하고 있었습니다. 그런데 기존의 프로그램이 개발되었던 프로젝트는 A 샌드박스를 사용하고 있었지만, 저는 B 샌드박스를 할당받았습니다.  그런데 똑같은 코드를 돌려도 너무나 느린 겁니다. 알고 보니 A 샌드박스는 RAM이 144GB에 8 코어였고, B는 96GB에 4 코어였습니다. 이것도 모르고 기존 코드대로 8 코어로 병렬 처리를 돌리니 속도도 느리고, 메모리 문제도 발생했습니다. 꼭 자신의 하드웨어 성능을 확인해보는 걸 잊지 맙시다.


R에서는 운영체제에 따라 명령어를 실행해볼 수 있는 system()이라는 함수가 구현되어 있습니다. 운영체제에 맞게 자신의 하드웨어 스펙을 확인해보면 되겠습니다. 아래 코드는 리눅스에서 현재 R 프로세스에 할당된 VM 크기와 여유 메모리를 확인하는 예시입니다.



system("cat /proc/", Sys.getpid(), "/status | grep VmSize")
system("free -g")


parallel 패키지는 코어를 확인해주는 함수를 가지고 있습니다. 본인 컴퓨터의 코어를 확인해봅시다. 두 코드가 다른 결과를 보여줄 텐데 이에 대해서는 logical core를 구글에 검색해서 공부해봅시다.


library(parallel)
parallel::detectCores()
parallel::detectCores(logical = FALSE)





코드를 다시 보자

사실 R 뿐만 아니라 모든 프로그래밍 언어가 그렇듯 컴퓨터는 잘못이 없습니다. 컴퓨터는 단지 인간이 제대로 명령을 안 내려서 비효율적으로 계산하고 있을 뿐이지요. 코드가 느리다면 정말 이 계산이 필요한 지부터 생각해볼 필요가 있습니다.


    정말로 이 계산이 필요한가?  

    C 혹은 C++로 바꾸자  

    반복문에 주의하자  



정말로 이 계산이 필요한가?

사실 대부분의 계산은 필요가 없습니다. 필요 없는 계산을 하는 사람이 있을까 싶지만 생각보다 많은 사람들이 비슷한 실수를 하고 있습니다.


아래 코드를 봅시다.

idx <- order(distRN)[1:1]
top_dist <- distRN[idx]



위 코드는 어떤 결과를 보고 싶었던 걸까요?

distRN이라는 거리를 계산한 벡터에서 제일 근접한 거리, 즉 제일 작은 거리를 도출하기 위한 코드입니다. 이를 위해 order() 함수를 통해서 distRN을 순서대로 정렬하고 제일 앞에 있는 인덱스를 찾습니다. 여기서 정렬은 굉장히 리소스를 많이 잡아먹는 행위입니다. 모든 벡터의 값을 하나씩 비교해봐야 하고 매번 정렬하면서 새로운 벡터도 생성해야 합니다. 그러나 which.min() 함수를 사용하면 정렬한 벡터를 생성하지 않고도 원하는 결과를 얻을 수 있습니다.


top_dist <- distRN[which.min(distRN)]



이처럼 컴퓨팅 방식 변경 말고도 본질적으로 정말 해야만 하는 계산인지 생각해봐야 합니다. 저는 매장과 SOHO(소상공인) 사이의 거리를 계산하고 있었는데요. 400만 개의 SOHO가 있었습니다. 그런데 과연 주어진대로 400만 번 거리 계산을 수행하야만 할까요? 한 건물에 있는 SOHO는 그 거리가 몇 미터 이내입니다. 거의 차이가 없죠. 게다가 비즈니스적으로도 이러한 몇 미터 차이는 아무 의미가 없습니다. 왜냐하면 영업점 별로 영업 타깃을 선정할 때에 구역별로 선정하기 때문입니다. 그래서 건물별 좌표를 확인해보니 125만 개가 되었습니다. 즉 한 건물에 있는 SOHO는 한 좌표로 보겠다는 겁니다. 이렇게 해서 275만 번의 계산을 줄일 수 있었습니다. 왜 이 계산이 필요하지라고 질문했을 뿐인데 70%의 계산을 줄일 수 있었습니다.



C 혹은 C++로 바꾸자

R은 고급언어입니다. 더 Fancy 해서 고급언어가 아니라 C나 C++ 보다 기계어와 멀기 때문입니다. 사용자가 C처럼 메모리 할당에 신경 쓸 필요도 없고 많은 부분이 편의성을 위해 자동화되어 있습니다. 하지만 그만큼 시키지도 않은 일을 R에서는 수행하고 있다는 뜻입니다. 예를 들어, R의 데이터 객체는 참조 변수가 아니라 값을 복사합니다. 이러한 부분을 보완하기 위해서 Rcpp 등의 패키지에서는 R에서도 C++ 함수를 작성할 수 있게 해 줍니다. 본인의 느린 코드 부분을 이처럼 다른 언어로 바꾸는 것도 속도를 높이는 방법입니다.


하지만 C로 함수를 짜고 있을 시간이 없는 경우가 대부분입니다. 편하려고 R을 쓰는데 C로 함수를 짜야한다면 아이러니입니다. 그러니 우리는 C로 잘 짜인 함수가 있는 라이브러리를 가져다가 쓰면 됩니다. 저의 경우에는 거리 계산하는 라이브러리를 변경하여 속도를 높였습니다. 기존에는 geosphere의 distMeeus() 함수를 사용했습니다. 하지만 이 패키지는 R의 내장 함수만 쓰는 라이브러리입니다. 하여 C로 작성된 geodist 라이브러리의 geodist() 함수로 교체했습니다. 또한 하나의 라이브러리만 고집하지 말고, 더 빠르다고 알려진 라이브러리를 가져오는 것도 방법입니다. plyr 대신에 dplyr이나 data.table의 함수가 더 빠릅니다.


특히 rbind를 사용한다면, data.tablerbindlist() 함수로 교체하는 것을 추천합니다. 데이터를 다루다 보면 데이터셋끼리 합쳐야 하는 경우가 많습니다. 하지만 cbind처럼 열 합치기보다 행 합치기가 정말로 느립니다. 아래 링크는 dplyrbind_rows()까지 포함해서 속도를 비교하고 있습니다. 꼭 읽어보시길 바랍니다.


https://rstudio-pubs-static.s3.amazonaws.com/406521_7fc7b6c1dc374e9b8860e15a699d8bb0.html



반복문에 주의하자

R에서 for 문은 느리기로 악명이 높습니다. foreach 패키지를 활용하는 것을 추천합니다. 하지만 foreach를 사용한다고 해서 무조건 빨라지지 않습니다. 자신이 반복문 안에서 어떤 것을 하고 있는지 꼭 살펴봅시다.


우선 반복문 안에서 객체 생성 최대한 자제해야 합니다. 반복문 안에서 변수를 할당하는 것보다 밖에서 원하는 데이터를 벡터화하고 조회하는 것이 빠릅니다. R에서 벡터의 인덱싱은 강력합니다. 또한 객체를 생성해야 한다면 data.frame은 피합니다. 저는 값-키로 구성되는 Map 객체인 environment 객체를 활용했습니다. listMap처럼 사용 가능하지만 차이점이 있습니다. R 매뉴얼인 참고자료(10)는 이렇게 말합니다.


Unlike most other R objects, environments are not copied when passed to functions or used in assignments.


R은 언제나 참조 변수가 아닌 값을 복사하기 때문에 객체를 생성할 때 리소스를 많이 사용합니다. 하지만 environment 객체를 사용함으로써 값 복사에 사용되는 리소스를 최대한 아끼고자 했습니다.





다른 코어도 써보자

기본적으로 R은 단일 프로세스로서 단일 코어에서 계산이 됩니다. 하지만 병렬 처리도 가능합니다. 어렵지도 않습니다. 이미 구현된 패키지를 가져다가 쓰면 됩니다. 여러 가지가 있지만 doSNOW, doParallel, parallel 이 3가지 패키지를 추천합니다. 사용법도 직관적이고 쉽고, 옵션도 꽤 다양하게 줄 수 있습니다.


물론 병렬 처리를 한다고 해서 무조건 빨라지지 않습니다. 동시에 계산을 하기 위해서 한 가지 작업을 쪼개야 합니다. 그리고 이 쪼갠 작업의 결과를 다시 합치는 추가적인 리소스가 필요하기 때문입니다. 기존 코드는 8 코어에서 210만 개의 프로세스를 생성했습니다. 그냥 거리 계산 한 건을 하나의 프로세스로 잡은 것이지요. 그래서 하나의 계산보다 병렬 처리를 위한 사전 작업이 더 오래 걸리는 문제가 생겼습니다.


total_dt <- ldply(1:nrow(data), function(x) {
   ...
  }, .parallel = TRUE)




저는 이러한 병렬 처리의 사전 작업을 줄이기 위해서, 건 바이 건이 아닌 Chunk로 묶어서 처리하고자 했습니다. 210만 개를 8개의 Chunk로 쪼갭니다. 그리고 이를 각각 코어에 맡겼습니다. 그래서 합치는 작업은 8번만 수행이 되는 것이지요. 어차피 8개의 코어라면 8배 속도 개선을 생각하는 것이 최선일 것입니다. 이렇게 미리 쪼개고 병렬 처리하는 것이 의도대로 잘 동작하는 편입니다.



total_dt <- foreach(chunk = isplitVector(1:nrow(data), chunks=8), .combine = rbind) %dopar% { 
 dt <- foreach(x = chunk, .combine = rbind, .inorder = F) %do% {
      ...
    }
  return(dt)
}




다만, 크게 쪼개는 것에서 각 프로세스의 메모리를 잘 생각해야 합니다. 8개라고 이야기는 했지만 추후에 32개로 변경했습니다.


왜 8개 코어인데도 32개로 나누어서 작업을 했을까요? 사실 처음 데이터는 90만 개였는데 추후에 125만 개로 늘어났습니다. 이처럼 데이터가 많아지면 서 8개로만 쪼개면 하나의 벡터에 몇 기가씩 할당되는 경우가 생겼습니다. 그래서 메모리 오버플로우가 났습니다. 결국 각 노드는 수행되었지만 메모리 부족으로 바인드에 실패를 했습니다. 기껏 오래 돌렸지만 결과를 못 얻는 악순환이 발생한 겁니다. 그래서 차라리 코어를 몇 번 더 타더라도 메모리를 플러시 했습니다. 사용하고 플러시하고 사용하는 식으로 해서 잡고 있는 메모리 양을 줄였습니다. 이렇게 함으로써 프로세스 간 메모리 경합도 안 일어나고 훨씬 안정적으로 작업을 할 수 있게 되었습니다.




다른 Tool도 활용해보자

대량의 데이터 구조 변경(join)은 R의 전문 영역이 아닙니다. 오히려 Hadoop 기반의 Impala가 전문이죠. R에서만 모든 것을 하려고 하기보다는 각 언어나 프로그램의 장점을 살려서 작업하는 것이 좋습니다. R에서 생성하는 데이터는 최대한 열을 줄이고 압축했습니다. 그리고 Impala에서 조인을 통해 원하는 형태로 데이터를 만들었습니다. R에서 4개의 열로 880만 건인 결과를, 고객이 원하는 형태로 만들면 2700만 건이 되었습니다. dplyr의 left_join() 함수도 빠르긴 하지만, 여전히 R에서 리소스를 사용하는 것은 굉장히 비싼 일입니다. 또한 이러한 방식은 네트워크 속도나 안정성에서도 이점을 얻을 수 있습니다. 2700만 건을 DB에 밀어 넣기보다는 880만 건을 밀어 넣는 것이 더 빠르겠죠?




살펴보자

처음 개선했던 코드입니다. 반복문 안에서 여전히 객체를 생성하고 있으며 if 문도 사용하고, data.frame도 사용합니다. 그리고 foreach를 사용하긴 했지만 rbind를 사용하는군요.


그 다음은 이를 개선한 코드입니다.

객체 생성을 최대한 줄이고자 했으며, 계산하는 로직과 rbind 하는 로직을 나누어서 최대한 각각의 패키지가 잘할 수 있는 것을 차용하려고 했습니다.



https://gist.github.com/qqplot/5312fcce9c90e3a14f27568145611393






참고 자료  

1. Getting Started with doMC and foreach (https://cran.r-project.org/web/packages/doMC/vignettes/gettingstartedMC.pdf)

2. R에서 병렬 처리 하기([https://cinema4dr12.tistory.com/entry/Data-Science-Data-Mining-with-R-R%EC%97%90%EC%84%9C-%EB%B3%91%EB%A0%AC%EC%B2%98%EB%A6%AC-%ED%95%98%EA%B8%B0#6)

3. R에서 코드를 병렬 처리하는 방법 [https://devlab.neonkid.xyz/2019/02/10/R/R%EC%97%90%EC%84%9C-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%B3%91%EB%A0%AC%EC%B2%98%EB%A6%AC-%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95/]

4. High performance computing in R using doSNOW package http://biostat.mc.vanderbilt.edu/wiki/pub/Main/MinchunZhou/HPC_SNOW.rwn.pdf

5. 사용자 관점에서의 R 병렬 컴퓨팅 https://cinema4dr12.tistory.com/1024

6. https://sodocumentation.net/ko/r/topic/1677/%EB%B3%91%EB%A0%AC-%EC%B2%98%EB%A6%AC

7. cores 개수의 결정 https://thebook.io/006723/ch05/07/01-01/

8. https://arxiv.org/pdf/1503.00855.pdf

9. http://adv-r.had.co.nz/Environments.html

10. https://cran.r-project.org/doc/manuals/R-lang.html#Environment-objects

11. https://www.r-bloggers.com/2013/04/faster-higher-stonger-a-guide-to-speeding-up-r-code-for-busy-people/

12. https://stackoverflow.com/questions/2908822/speed-up-the-loop-operation-in-r

13. http://www.burns-stat.com/pages/Tutor/R_inferno.pdf






브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari