Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Scala 2.9의 Parallel Collection

Scala 2.9에 새로 추가된 대표적인 기능으로는 Parallel Collection이 있는데 Scala 2.9+ 의 새로운 기능들에서는 글이 길어져서 따로 포스팅합니다. par 메서드를 사용하면 모든 컬렉션을 병렬 컬렉션으로 바꾸어서 foreach, map, filter등의 대량의 수행작업에 멀티코어 프로세서를 이용할 수 있습니다. 컬렉션으로 처리하는 문제에 따라 다르겠지만 par가 병렬컬렉션을 만들면서 데이터셋을 기반으로 복사를 할수도 있는데 데이터셋자체는 공유하기 때문에 상수의 수행시간을 갖게 됩니다.

이용가능한 병렬컬렉션은 다음과 같습니다.

  • parallel arrays - scala.collection.parallel.mutable.ParArray
  • parallel ranges - scala.collection.parallel.immutable.ParRange
  • parallel hash maps - scala.collection.parallel.mutable.ParHashMap
  • parallel hash sets - scala.collection.parallel.mutable.ParHashSet
  • parallel hash tries - scala.collection.parallel.immutable.{ParHashMap, ParHashSet}
  • parallel vectors - scala.collection.parallel.immutable.ParVector

병렬컬렉션을 순차 컬렉션으로 바뀌려면 seq메서드는 사용하는데 항상 효율적(O(1))입니다. 단순히 par메서드만 붙혀주면 병렬컬렉션을 사용할 수 있지만 잘 파악이 안되서 몇가지 테스트를 해보았습니다.


scala> (1 to 5) foreach println
1
2
3
4
5

일반적인 컬렉션의 경우 위처럼 동작합니다.


scala> (1 to 5).par foreach println
3
4
5
2
1

병렬컬렉션이기 때문에 위처럼 순서가 달라지는 것을 볼 수 있습니다.(실행할 때마다 달라집니다.) 병렬로 동작하는 건 확인했는데 실제로 빠른가가 궁금해서 테스트해보았습니다. REPL의 테스트는 왠지 정확치 않은것 같아서 main클래스를 만들어서 컴파일 후에 테스트를 했습니다.



첫번째 테스트

object SeqForeach extends App {
  val startTime = System.currentTimeMillis

  (1 to 10000000).toList foreach(_ > 0) 

  val endTime = System.currentTimeMillis

  println("elapsed: " + (endTime - startTime) + "ms")
}

일반적인 컬렉션을 테스트 하기 위한 클래스입니다. 컬렉션을 사용하기 전후의 시간을 계산해서 출력하였습니다.


object ParForeach extends App {
  val startTime = System.currentTimeMillis

  (1 to 10000000).toList.par foreach(_ > 0)

  val endTime = System.currentTimeMillis

  println("elapsed: " + (endTime - startTime) + "ms")
}

병렬컬렉션 용 테스트이고 .par만 붙혀주었습니다.

작은 크기로는 제대로 테스트가 되지 않을것 같아서 컬렉션의 크기를 늘렸더니 Heap 사이즈를 1기가로 늘려야만 테스트가 가능했습니다. 아래는 그 결과입니다.(그래프까지 그리기는 좀 귀찮아서..)

SeqForeach
elapsed: 5916ms
elapsed: 5718ms
elapsed: 5892ms
elapsed: 5653ms
elapsed: 5652ms

ParForeach
elapsed: 8380ms
elapsed: 6536ms
elapsed: 6449ms
elapsed: 6528ms
elapsed: 6399ms

테스트 결과가 좀 이상합니다. (ㅡㅡ;;) 예상과는 다르게 병렬컬렉션이 오히려 더 오래 걸립니다. 그래서 컬렉션을 병렬로 만드는데 시간이 걸려서 이런 결과가 나온게 아닐까 의심해서 코드를 다시 작성했습니다.



두번째 테스트

object SeqForeach2 extends App {
  val col = (1 to 10000000).toList 
  val startTime = System.currentTimeMillis

  col foreach(_ > 0)

  val endTime = System.currentTimeMillis

  println("elapsed: " + (endTime - startTime) + "ms")
}


object ParForeach2 extends App {
  val col = (1 to 10000000).toList.par 
  val startTime = System.currentTimeMillis

  col foreach(_ > 0)

  val endTime = System.currentTimeMillis

  println("elapsed: " + (endTime - startTime) + "ms")
}

아까랑 동일하지만 컬렉션을 시간을 재기 전에 생성하고 실제 동작에 대해서만 시간계산을 하였습니다.

SeqForeach2
elapsed: 141ms
elapsed: 146ms
elapsed: 141ms
elapsed: 142ms
elapsed: 172ms

ParForeach2
elapsed: 181ms
elapsed: 169ms
elapsed: 171ms
elapsed: 171ms
elapsed: 198ms

컬렉션의 생성시간을 제외하니까 시간을 확 줄어들었지만 마찬가지로 병렬컬렉션이 시간이 더 오래 걸립니다. 그래서 Array로 다시 테스트를 해보았습니다.



세번째 테스트

object SeqSum extends App {
  val a = new Array[BigInt](10000000)
  for (i <- 0 until a.size) a(i) = i

  val startTime = System.currentTimeMillis

  a.sum 

  val endTime = System.currentTimeMillis

  println("elapsed: " + (endTime - startTime) + "ms")
}

일반적인 천만개짜리 배열을 만들어서 합계를 구하는 계산을 수행했습니다.


object ParSum extends App {
  val a = new Array[BigInt](10000000)
  for (i <- 0 until a.size) a(i) = i
  var b = a.par
  val startTime = System.currentTimeMillis

  b.sum

  val endTime = System.currentTimeMillis

  println("elapsed: " + (endTime - startTime) + "ms")
}

동일한데 par만 붙혔습니다. 그 결과는 아래와 같습니다.

SeqSum
elapsed: 1055ms
elapsed: 1041ms
elapsed: 1034ms
elapsed: 1040ms
elapsed: 1075ms

ParSum
elapsed: 857ms
elapsed: 899ms
elapsed: 866ms
elapsed: 824ms
elapsed: 837ms

이번에는 병렬컬렉션이 약간 더 빠르게 나옵니다.(정확한 이유는 모르겠지만 리스트의 foreach로 테스트를 하면서 뭔가 다른 영향이 있던게 아닐까 추측할 뿐입니다.) 좀 더 자료를 찾아보니 Scala에 Benckmark trait가 있다는 것을 알게 되었습니다.



네번째 테스트

package Test

import scala.testing.Benchmark

class TestCollection[T <: AnyRef](col :T, f: T=> Unit) extends Benchmark {
  override def run = f(col)
}

그래서 다른 소스를 참고하여 위와같은 테스트 클래스를 만들었습니다. Benchmark 트레이트를 상속받고 run 메서드를 오버라이드해서 메서드 바디를 구현하면 이 클래스를 이용해서 runBenchmark(noTimes: Int)메서드실행하면 noTimes만큼 run메서드를 수행하고 밀리초로 수행시간을 List로 리턴해 줍니다. 파라미터는 2개를 받게 만들었습니다. 첫번째는 테스트할 클래스이고 두번째는 수행할 로직을 담고 있는 함수입니다. 여러가지 컬렉션을 받을 수 있도록 하기 위해서 covariance를 사용했습니다.(covariance를 사용할 일이 생길줄이야 ㅡㅡ;;) 사실 모든 컬렉션을 받도록 하고 싶었는데 어떻게 해야하는지 몰라서(Collection의 최상위 타입이 따로 없는것 같아서) 그냥 AnyRef를 사용했습니다.


import Test._
import scala.collection.GenIterable

object TestList extends App {
  val totalRuns = 10
  val list = List.range(0, 9999999)
  def func(col:GenIterable[Int]) { col.foreach(_ > 0) }

  println("Time to run Normal Collection: " + new TestCollection(list, func).runBenchmark(totalRuns))
  println("Time to run Parallel Collection: " + new TestCollection(list.par, func).runBenchmark(totalRuns))
}

이제 벤치마크용 클래스가 있으므로 일반적인 컬렉션과 병렬 컬렉션을 한꺼번에 테스트하도록 만들었습니다. 앞에서 컬렉션을 생성하고 func함수에 foreach를 수행하도록 만들어서 new TestCollection에 넘겨주었고 총 10번을 수행하도록 하였습니다.

Time to run Normal Collection: List(144, 151, 118, 140, 118, 144, 120, 140, 118, 177)
Time to run Parallel Collection: List(183, 149, 97, 140, 96, 149, 97, 161, 106, 137)

사실 앞에서 테스트 했던것과 거의 동일한 수행을 Benchmark를 이용해서 한 것인데 아까와는 다르게 병렬 컬렉션이 좀더 빠르게 나옵니다. 일부 더 느린 경우도 생기긴 하지만 병렬이라고 절대적으로 빠른건 아닐텐니 전체적으로는 좀더 빠르게 나옵니다.(아까와는 왜 다른 결과인지 잘 모르겠습니다.)



다섯번째 테스트

import Test._
import scala.collection.parallel.mutable._

object TestSum extends App {
  val totalRuns = 10
  val a = new Array[BigInt](10000000)
  for (i <- 0 until a.size) a(i) = i
  def func(col:Array[BigInt]) { col.sum }
  def func2(col:ParArray[BigInt]) { col.sum }

  println("Time to run Normal Collection: " + new TestCollection(a, func).runBenchmark(totalRuns))
  println("Time to run Parallel Collection: " + new TestCollection(a.par, func2).runBenchmark(totalRuns))
}

이번에 위에서 테스트한 sum 테스트를 TectCollection을 이용해서 다시 작성했습니다.

Time to run Normal Collection: List(1040, 857, 817, 811, 828, 844, 817, 851, 853, 863)
Time to run Parallel Collection: List(867, 854, 828, 833, 819, 826, 788, 828, 810, 832)

거의 비슷하긴 하지만 그래도 병렬 컬렉션이 약간(?)이나마 더 빠른것 같긴 합니다.


내부 구현이나 병렬컬렉션에 대해서 깊게 알지는 못해서 더 많은 테스트는 어렵기 때문에 이렇게 사용한다 정도로만 봐주시면 될것 같습니다.



그 밖에 자료
Scala Days 2010에서 Aleksandar Prokopec가 발표한 Scala Parallel Collections영상에서 병렬컬렉션이 어떻게 동작하는지 설명하고 있는데 영어 발음이 이상해서(좋아도 잘 못알아듣지만) 알아듣기 힘들기는 하지만 설명하는 내용은 봐줄만 합니다. 저 영상에서는 벤치마크 결과가 병렬컬렉션이 압도적으로 높게 나오는데 테스트 PC의 환경탓인지 벤치마크 코드를 공개하진 않아서 비교해 보지는 못했네요.  그리고 스칼라가 구현한 병렬컬렉션에 대한 기술리포트인 A Generic Parallel Collection Framework가 공개되어 있습니다.



[참고 자료]
Scala 2.9.0 Parallel Collections
Parallel Arrays in Scala
2011/09/12 02:48 2011/09/12 02:48