Статьи

Почему актеры Scala медленнее, чем Jetlang и / или Groovy ++?

После публикации этой статьи Хоссам Карим предложил несколько улучшений, которые значительно улучшили производительность теста Scala. Это заставило меня убрать «в 15-20 раз медленнее» из первоначального заголовка статьи и включить обновленный код ниже.

Асинхронная передача сообщений — старая и отличная идея. Вместо того, чтобы синхронизировать объекты и иметь дело с тупиками, мы пытаемся изолировать объекты и позволить им обмениваться сообщениями. Erlang доказал, что он является чрезвычайно мощным инструментом, применимым к широкому кругу масштабируемых критически важных приложений.

В ландшафте JVM большой интерес к этому подходу начался после представления актеров Scala. Мое личное (может быть, ошибочное) впечатление, что основной интерес к Scala вырос именно благодаря библиотеке актеров.

Но хорошо ли играют актеры Scala? Когда вы обмениваетесь миллионами внутренних сообщений, вы должны верить, что это происходит как можно быстрее.

В Erlang передача сообщений и планирование процессов встроены в виртуальную машину. В JVM контексты переключения между потоками и очередями блокировки являются дорогостоящими операциями. Это был вопрос, который мы пытались понять при разработке архитектуры передачи сообщений Groovy ++.

Мы начали с эталона, который нас шокировал. Цель этой статьи — описать этот тест, показать три различных реализации. Один для Scala, один для красивой библиотеки Jetlang ( http://code.google.com/p/jetlang/ ) с использованием Groovy ++ и один для нашего собственного прототипа обмена сообщениями Groovy ++.

Несмотря на то, что прототип реализации, который имеется у нас в стандартной библиотеке Groovy ++, заметно быстрее, чем в Jetlang (20-40%), это не обязательно много значит. Обе реализации очень похожи по духу, и, скорее всего, некоторые идеи из нашей реализации могут ускорить Jetlang или наоборот, поддержка должна иметь функции от Jetlang, которые мы не реализовали, но которые могут снизить нашу производительность.

Дело в том, что актеры Scala медленнее как минимум в 10 раз. На некоторых вариациях эталонных тестов они были в 15-20 раз медленнее.

Так что же делает тест?

Мы хотим измерить среднюю скорость отправки и получения сообщений. Таким образом, мы выбираем вариант хорошо известного эталонного кольца.

  • У нас есть 10000 актеров, проиндексированных 0..9999
  • Когда объект получает сообщение, он пересылает сообщение следующему объекту подряд
  • Мы начинаем с отправки строки «Привет» первым 500 объектам подряд

Таким образом, отправлено и получено чуть менее 50 миллионов сообщений. Мы измеряем, сколько времени требуется, чтобы построить все эти объекты, отправлять и получать сообщения


Вот код, использующий Jetlang.
Формально говоря, мы должны реализовывать его на чистой Java, но, поскольку мы знаем, что код, созданный Groovy ++, должен работать так же быстро, как и код, созданный javac, мы выбираем более выразительный и менее многословный язык. Было очень приятно, что примитивы Jetlang проще сопоставляются с синтаксисом Groovy ++

def start = System.currentTimeMillis()

def pool = Executors.newFixedThreadPool(Runtime.runtime.availableProcessors())
def fiberFactory = new PoolFiberFactory(pool)

def channels = new MemoryChannel[10000]
CountDownLatch cdl = [channels.length*500]
for (i in 0..<channels.length) {
def fiber = fiberFactory.create()
def channel = new MemoryChannel()
channel.subscribe(fiber) {
if (i < channels.length-1)
channels[i+1].publish(it)
cdl.countDown()
}
channels [i] = channel
fiber.start()
}
for(i in 0..<500) {
channels[i].publish("Hi")
for (j in 0..<i)
cdl.countDown()
}

assert(cdl.await(100,TimeUnit.SECONDS))
pool.shutdown()
println (System.currentTimeMillis() - start)

Groovy ++ передача сообщений очень похожа на Jetlang. Основное отличие состоит в том, что мы предпочитаем (по крайней мере, пока) не разделять волокна (потребителей сообщений) и каналы сообщений (где публиковать сообщения). Это дает нам возможность использовать некоторые упрощенные структуры данных для внутреннего использования и может быть причиной того, что реализация Groovy ++ немного быстрее, чем реализация Jetlang. Может случиться так, что мы изменим это позже.

def start = System.currentTimeMillis()
def pool = new ChannelExecutor(Runtime.runtime.availableProcessors())
def channels = new ExecutingChannel [10000]
CountDownLatch cdl = [channels.length*500]
for (i in 0..<channels.length) {
ExecutingChannel channel = {
if (i < channels.length-1)
channels[i+1] << it
cdl.countDown()
}
channel.executor = pool
channels [i] = channel
}

for(i in 0..<500) {
channels[i] << "Hi"
for (j in 0..<i)
cdl.countDown()
}

assert(cdl.await(100,TimeUnit.SECONDS))
assert(pool.shutdownNow().empty)
pool.awaitTermination(0L,TimeUnit.SECONDS)
println(System.currentTimeMillis()-start)

А вот код с актерами Scala.

object FiberRingScala {
def main(args: Array[String]) {
val start = System.currentTimeMillis()
val channels = new Array[Actor](10000)
val cdl = new CountDownLatch(channels.length * 500)

var i: Int = 0
while (i < channels.length) {
val channel = actor {
loop {
react {
case x:Any =>
if (i < channels.length -1)
channels(i+1) ! x
cdl.countDown()
}
}
}
channels(i) = channel
i += 1
}
i = 0
while (i < 500) {
channels(i) ! "Hi"
var j : Int = 0
while (j < i) {
cdl.countDown()
j = j+1
}
i += 1
}

cdl.await(1000, TimeUnit.SECONDS)
Scheduler.shutdown

println(System.currentTimeMillis() - start)
}
}

Кажется, очень похоже на код для обмена сообщениями Jetlang и Groovy ++, верно?

Результаты сравнительного анализа (миллисекунды, чем меньше число, тем лучше)

2155 — Jetlang

1682 — Groovy ++

47911 — Скала


Что-то не так с производительностью Scala.
Кажется, в 22 раза медленнее по сравнению с Jetlang. Мы полагаем, что причина в том, что Scala пытается эмулировать поведение Эрланга, а не использовать модель обмена сообщениями, которая естественным образом подходит для JVM.

Вот оптимизация, изобретенная Вацлавом Печем (Вацлавом Печем) (создателем и руководителем блестящей библиотеки GPars), который ускоряет код Scala в два раза до 22484, что все еще в 13 раз медленнее, чем обмен сообщениями в Groovy ++. Именно тот факт, что такая оптимизация помогает и помогает так серьезно, заставляет нас думать, что что-то не так в подходе Scala к актерам.

object FiberRingScala {
def main(args: Array[String]) {
val start = System.currentTimeMillis()
val channels = new Array[Actor](10000)
val cdl = new CountDownLatch(channels.length * 500)

var i: Int = 0
while (i < channels.length) {
val channel = actor {
handle(i, channels, cdl)
}
channels(i) = channel
i += 1
}
i = 0
while (i < 500) {
channels(i) ! "Hi"
var j : Int = 0
while (j < i) {
cdl.countDown()
j = j+1
}
i += 1
}

cdl.await(1000, TimeUnit.SECONDS)
Scheduler.shutdown

println(System.currentTimeMillis() - start)
}

def handle(i:Int, channels:Array[Actor], cdl:CountDownLatch) :Unit = {
react {
case x:Any =>
if (i < channels.length -1)
channels(i+1) ! x
cdl.countDown()
handle(i, channels, cdl)
}
}
}

Что изменилось по сравнению с предыдущей версией? Почти ничего на самом деле — мы заменили цикл / реагировать на реакцию, где вызовы метода реакции реагируют снова. Не очень интуитивно понятно, но понятно (если вы являетесь создателем библиотеки для передачи сообщений как Вацлав или действительно ваш). Похоже, мы сохранили одно исключение управления потоком (только предположим). Дело в том, что в Erlang вы имеете дело с легковесным процессом. В JVM у вас есть объекты обратного вызова почти бесплатно, но иллюзия облегченного процесса становится чрезвычайно дорогой

Не спрашивайте меня, что такое исключение управления потоком. Мне нравится идея как интересное животное в зоопарке трюков с параллелизмом, но я не хочу, чтобы она стала слишком популярной

Вот это на сегодня. Вы можете найти исходный код и библиотеки, используемые в этой статье, по адресу http://code.google.com/p/groovypptest/source/browse/#svn/trunk/JetLangSample/src/org/mbte/groovypp/samples/jetlang.

Пожалуйста, дайте нам знать, если у вас есть идея, как ускорить любой из примеров выше.

Надеюсь было интересно и до следующего раза.

 ОБНОВЛЕНИЕ: Код ниже, предложенный Хоссамом Каримом, выполнен в 5221 мс на моей установке

Я, вероятно, опубликую отдельную статью, почему это происходит. Первое впечатление, что, как обычно, умный планировщик очень помогает. Также может быть, что Reactor имеет более умную реализацию 

 

package org.mbte.groovypp.samples.jetlang

import java.util.concurrent.{TimeUnit, CountDownLatch}
import scala.actors.Actor._
import scala.actors.scheduler.ForkJoinScheduler
import scala.actors.Reactor
import scala.actors.Scheduler

class FiberRingActor
(i: Int, channels: Array[Reactor], cdl: CountDownLatch)
extends Reactor {
def act = FiberRingScala2.handle(i, channels, cdl)
override def scheduler = FiberRingScala2.fjs
}

object FiberRingScala2 {
val fjs = new ForkJoinScheduler(2, 2, false)
fjs.start()

def main(args: Array[String]) {
val start = System.currentTimeMillis()
val channels = new Array[Reactor](10000)
val cdl = new CountDownLatch(channels.length * 500)

var i: Int = 0
while (i < channels.length) {
/*
val channel = actor {
handle(i, channels, cdl)
}
*/
val channel =
new FiberRingActor(i, channels, cdl)
channel.start
channels(i) = channel
i += 1
}
i = 0
while (i < 500) {
channels(i) ! "Hi"
var j : Int = 0
while (j < i) {
cdl.countDown()
j = j+1
}
i += 1
}

cdl.await(1000, TimeUnit.SECONDS)
Scheduler.shutdown

println(System.currentTimeMillis() - start)
}

def handle(i:Int, channels:Array[Reactor], cdl:CountDownLatch) :Unit = {
react {
case x:Any =>
if (i < channels.length -1)
channels(i+1) ! x
cdl.countDown()
handle(i, channels, cdl)
}
}
}