scala akka_对Java,Scala和Akka的JVM并发选项进行基准测试

scala akka

现在,多核计算机已成为常态。 对于当今的软件技术人员而言,必须使用并发和/或并行性才能使软件可伸缩。 无论使用哪种语言功能或库,在一个或多个内核上运行多个主题都是不可避免的。 对于基于JVM的语言,线程由线程池提供。

大型多线程程序需要与正确的线程池配置相匹配,才能以最佳性能运行。 线程池的选择和参数调整受硬件(主要是CPU数量)的影响很大。 测试设置可能会故意模拟次优线程池,以表征故障模式和竞争条件。 线程池的选择和调优应被视为测试和部署活动,而不仅仅是编程活动。 该调整将显示您的程序对线程池和配置选择的敏感程度。 有些程序非常不敏感,而其他程序则对线程池的变化做出了显着的响应。

在Java虚拟机上运行的多线程程序可以使用标准java.util.concurrent.Executor线程池或自定义线程池。 Java和Scala程序以及Akka之类的库都是如此,它们可以用任何一种语言编写的程序使用。 Akka提供了一种灵活的Executor选择和调整机制,如我在《 Akka 2.0中的可组合期货》一书中所述。 这很重要,因为期货,数据流,参与者和Scala的并行集合都由线程池支持。

期望仅阅读文档并遵循通用准则而不进行任何测试就可以选择最佳的线程池和配置类型,这是不现实的。 “在理论上,理论与实践之间没有区别。但是,在实践中却存在区别。” 尽管存在一些争议,但通常引用给Jan LA van de Snepscheut。

如果线程池与硬件不匹配,则正确编写的程序的性能可能会很差。 在具有与您所熟悉的处理器数量不同的新计算机上安装程序时,应执行线程池基准测试。 您可能会发现,在某些配置下,线程不足会在没有足够线程的情况下导致死锁或严重降低吞吐量,从而导致请求排队。 另一方面,分配比所需更多的线程可能会减少可用于其他任务的计算资源,从而导致对它们的不利影响。

并发不同于并行化。 并发任务是有状态的,通常具有复杂的交互作用,而可并行化的任务是无状态,不交互且可组合的。 您可以启动任意数量的并行任务,而不影响其他任务提供的结果,不完整的并行任务可以重新启动而不会影响结果。 通常,与并行化系统相比,对并行系统进行基准测试通常比较困难,因为线程之间的交互通常很复杂且细微。 许多(如果不是大多数)真实世界的系统混合使用可并行化和并发任务。 各种线程之间的相互作用以及它们争用资源的情况意味着正确选择和调整线程池必然是一种折衷。

设置测试负载到处都是问题,其中包括其他程序引入的CPU负载,正确分配内存,分配CPU,确定要进行基准测试的关键代码以及最大程度地降低基准测试框架开销的影响。 本文和随附的基准测试程序试图帮助您基准测试程序。

结果是自然统计

标准化的基准框架可以为选择和调整线程池提供有用的指导。 在此上下文中使用的术语“基准”表示收集有关各种线程池配置的相对优缺点的统计结果。 尽管我们通常不希望在系统级别上仅优化一项任务的绝对最小执行时间,但是比较线程池的相对性能以执行给定任务是有启发性的。

测试结果始终存在一定的不确定性,因此应基于多次运行的结果的标准偏差来计算测试精度。 标准偏差将受到计算负载类型的影响。 例如,可并行化的CPU密集型任务的标准偏差值较小,而Web蜘蛛任务由于提取的每个IP地址的网络延迟变化而具有的标准偏差值较大。

维基百科对标准差有很好的解释。 下图显示了高斯分布的Wikipedia通用图片,其中一个标准偏差用sigma(σ)表示; 每个彩色带的宽度为1个标准偏差。 用mu(μ)表示的中位数是观测值分布的中点。 如果标准偏差比中位数小,则两次测量之间的结果差异不大。

标准偏差大; 值在中位数附近紧密聚集; 每次进行测量时,结果差异很大

标准偏差小; 值围绕中位数紧密集群; 结果每次测量时变化不大。

因为多任务OS的行为本质上也是统计的,所以在任何给定的多任务环境中,长时间计算所需的时间长度应该有一些差异。 计算负载应执行多次,并对结果进行统计分析,以确定结果的平均值和标准偏差。 一旦计算负荷已被表征 ,它的行为和度量可以统计预测各种情况。

下图显示了与高斯分布相同的维基百科公共图片,该图叠加在条形图的堆叠条形图上,该条形图显示了测试运行多次后花费的时间。 红色条显示的是执行计算的平均时间,减去一个标准偏差:7.887秒; 这是正常完成测试所需的最快时间。 堆叠的蓝色条长为两个标准偏差,即670毫秒; 因此,这组结果的一个标准偏差为335毫秒。 这意味着每个测试的平均时间为7.887 + 0.335 = 8.222秒,并且在此计算环境中,测试通常需要完成的最长时间为7.887 + 0.670 = 8.557秒。

现在我们知道了如何解释这种条形图,让我们看看如何通过基准测试运行在各种类型线程池上的计算负载来生成它们。

运行基准

build.sbt中定义了运行ExecutorBenchmark随附的运行标准负载测试的JVM的命令行设置,如下所示。

-Xbatch -server -Xmx1G -Xms1G -XX:PermSize=64m -XX:MaxPermSize=64m

选择这些设置是为了使测试负载具有足够的内存。

ExecutorBenchmark会运行一次负载以预热JVM,然后再运行多次以获取具有统计意义的样本。 在对每个负载进行基准测试之前,将执行三个垃圾回收。 从下面的条形图中的结果可以看出,对于两次测试负载,各种配置之间的差异约为10%。 JVM预热后,标准偏差的大小指示测试结果的一致性程度。

(点击图片放大)

框架测试了两种用于负载实例的容器:Akka Futures和Scala并行集合。 您可以轻松扩展框架以支持其他容器来计算测试负载。

Executor基准代码演练

ExecutorBenchmark是一个用于并行任务的免费开源测试框架。 它是用Scala编写的,可以与Java和Scala加载一起使用。 您可以选择使用任意数量的线程池,并绘制结果。 您可以使用ExecutorBenchmark测试线程池的各种配置; 当糟糕的文档使您想知道线程池配置参数的真正重要性时,这特别有用。 您可以对线程池的各种实例运行基准测试,并将每个实例设置为不同的值,从而快速找出真正重要的参数。 该程序的主要组件遵循模型-视图-控制器模式:

ExecutorBenchmark.scala
包含入口点,用户定义的线程池,并将负载分配给线程池
DefaultLoads.scala
定义样品负荷
斯卡拉模型
资料模型
吉斯卡拉
Java swing用户界面使用JFreeChart显示堆叠的条形图
标杆
运行基准测试的控制器
PersistableApp.scala
定义PersistableApp特性,对于在运行之间保存和恢复状态的用户界面很有用。

入口点

执行器实例很容易设置。 下面是一个Scala代码片段,显示了如何配置一些标准Java线程池的实例。 配置了固定线程池的两个实例:一个实例配置了与执行基准测试的计算机中的处理器数量相同的线程数,而一个实例只有一个线程。

val nProcessors = Runtime.getRuntime.availableProcessors
val esFTPn = Executors.newFixedThreadPool(nProcessors)
val esFTP1 = Executors.newFixedThreadPool(1)
val esCTP  = Executors.newCachedThreadPool()
val esSTE  = Executors.newSingleThreadExecutor()

还配置了一个缓存线程池的一个实例。 所使用的构造函数创建的线程数与CPU一样多。 IO绑定的负载可能会受益于更多线程。 单线程执行程序实际上与node.js使用的执行程序相同。

此后,对Java 7中引入的Fork / Join线程池进行了改进。 改进的版本随Akka一起提供,并且很可能随Java 8一起提供。基准测试创建了改进版本的实例。 这意味着该测试可以在Java 6上运行。如果您想使用Java 7版本,只需更改import语句以引用java.util.concurrent.ForkJoinPool即可

import akka.jsr166y.ForkJoinPool

val esFJP = new ForkJoinPool()

ForkJoinPool将努力保持恒定的并行度; 这意味着,如果线程阻塞, ForkJoinPool将动态创建更多线程,以维持所需数量的繁忙线程。 默认情况下, ForkJoinPool将并行度设置为等于可用处理器数,这适用于受CPU约束的任务。 IO绑定任务将需要更多的并行性; 并行度因子是总线程与繁忙线程的比率。 调整ForkJoinPool时 ,主要任务是确定并行度因子的适当值。

Akka的线程池可以从源代码中嵌入的String中进行配置,也可以从配置文件中读取。 在配置文件中指定线程池意味着可以在测试和部署期间调整随附程序的线程池,而不必由原始程序员进行硬编码。 下面的字符串配置一个ForkJoinPool ,其初始线程数等于并行度 (3.0)乘以可用处理器数。 对ActorSystem()的调用实际上会调用ActorSystem.apply() ,后者创建ActorSystem的实例和已配置的线程池

val configString1: String = """akka {
  logConfigOnStart=off
  executor = "fork-join-executor"
  fork-join-executor {
      parallelism-min = %d
      parallelism-factor = 3.0
      parallelism-max = %d
  }
}""".format(nProcessors, nProcessors)
val system1 = ActorSystem("default1", 
  ConfigFactory.parseString(configString1))

以上线程池可用于Akka期货,参与者和数据流; 但是,Scala并行集合具有自己的线程池,该线程池始终是JVM随附的ForkJoinPool的实例。 它只有一个可配置的参数,用于启动计算的线程数:

ForkJoinTasks.defaultForkJoinPool.setParallelism(int)

入口点ExecutorBenchmark定义测试,如下面的代码块所示; 标签较短,因此适合Y轴。 大多数测试都定义为函数,这些函数存储在数据结构中并作为参数传递。 以这种方式使用的函数通常称为函子 ,以与C或C ++函数指针类似的方式使用。 每个测试规范都包含一个键值对:键是测试的函子或数字,而值是测试的名称,因为它将显示在条形图的Y轴上。

Model.ecNameMap = LinkedHashMap(
  1           -> "PC 1",      // parallel collection with 1 thread
  nProcessors -> "PC %d".format(nProcessors), // parallel collection with nProcessor threads
  system1     -> "Akka FJ",
  system2     -> "Akks TP 3",
  system3     -> "Akka TP 1", // ActorSystem thread-pool-executor & parallelism-factor=1
  esFJP       -> "Akka FJP",  // ActorSystem ForkJoinPool
  esFTP1      -> "FT 1",      // FixedThreadPool w/ nProcessors=1
  esFTPn      -> "FT %d".format(nProcessors), // FixedThreadPool w/ nProcessors
  esCTP       -> "CT",        // CachedThreadPool
  esSTE       -> "ST"         // SingleThreadExecutor
)

预定义基准负载

ExecutorBenchmarkDefaultLoads.scala中提供了两个预定义的负载。 DefaultLoads.cpuIntensive是CPU密集型测试,可将Pi计算为小数百万位。 而DefaultLoads.ioBound模拟网络蜘蛛。 除了演示ExecutorBenchmark的工作方式之外,您不应将这些负载用于任何其他用途。 相反,您应该模拟要进行基准测试的并行化任务的离散操作或操作组合。 每个动作应执行数千次。 负载是可能按顺序执行或可能以统计上一致的方式执行的动作的集合。

动作只是一个零参数方法,可以是静态方法或在对象实例上定义。 首选静态方法,因为它们会引入较少的开销。 您可以用Java或Scala编写动作方法。 DefaultLoad.scala中定义的默认负载是单一操作方法,如下所示:

/** Simulate a CPU-bound task (compute Pi to a million places) */
def cpuIntensive(): Any = calculatePiFor(0, intensity) 

private def calculatePiFor(start: Int, nrOfElements: Int): Double = {
  var acc = 0.0
  for (i <- start until (start + nrOfElements))
    acc += 4.0 * (1 - (i % 2) * 2) / (2 * i + 1)
  acc
}

/** Simulate an IO-bound task (web spider) */
def ioBound(): Any = simulateSpider(30, fetchCount)

private def simulateSpider(minDelay: Int, maxDelay: Int,  nrOfFetches: Int) {
  for (i <- 0 until nrOfFetches) {
    // simulate from minDelay to maxDelay ms latency
    Thread.sleep(random.nextInt(maxDelay-minDelay) + minDelay)
    calculatePiFor(0, 50) // simulate a tiny amount of computation
  }
}

负载是从Benchmark.scala伴随对象调用的,并作为函子提供给Benchmark构造函数,如下所示:

def apply(load: () => Any = DefaultLoad.cpuIntensive, showResult: Boolean=false) =
  new Benchmark(load, showResult)

apply方法在ExecutorBenchmark.scala的顶部隐式使用,它是入口点:

Benchmark(DefaultLoads.cpuIntensive).showGui

您可以按如下所示将更改用于Bar.java中称为foo()的静态方法的负载:

Benchmark(Bar.foo).showGui

该模型

该模型非常简单,并存储在Model.scala中scala.collection.mutable.LinkedHashMap将键(主要是Executor )与键名关联,键名显示在生成的条形图的Y轴上。 因为Scala的并行集合执行程序是不从外部访问的,对于执行器的配置参数被存储而不是存储执行程序 。 这个怪癖解释了为什么ecNameMapExecutor键定义为Any类型。

var ecNameMap = new LinkedHashMap[Any, String]

入口点ExecutorBenchmark通过设置ecNameMap定义测试,我们将在后面看到。

名为TestResult的案例类保存运行一个基准测试的结果; 同样,测试被定义​​为Any类型:

case class TestResult(test: Any, testName: String, millis: Long, result: Any)

另一个称为MeanResult的案例类保存对一系列测试进行统计分析的结果:

case class MeanResult(test: Any, testName: String, millisMean: Long, millisStdDev: Long)

结果存储在两个scala.collection.mutable.HashMap中 ,它们分别称为testResultMapWarmuptestResultMapHot ,它们分别存储用于预热JVM的测试运行的结果和实际结果:

val testResultMapWarmup = new LinkedHashMap[Any, TestResult]
val testResultMapHot    = new LinkedHashMap[Any, TestResult]

在每次运行开始时,都会重置模型:

def reset {
  ecNameMap.empty
  testResultMapHot.empty
  testResultMapWarmup.empty
}

每次运行测试时,通过调用Model.addTest()保存结果:

def addTest(test: Any, testName: String, timedResult: TimedResult[Seq[Any]], isWarmup: Boolean): TestResult = {
  val testResult = new TestResult(test, testName, timedResult.millis, timedResult.results)
  if (isWarmup)
    testResultMapWarmup += test -> testResult
  else
    testResultMapHot += test -> testResult
  testResult
}

控制器

与Java类不同,Scala类语句还定义了主要的构造函数。 控制器Benchmark.scala定义了一个带有以下签名的名为Benchmark的类。

class Benchmark (val load: () => Any, val showResult: Boolean)

上面说基准测试构造函数接受两个参数。 第一个参数是一个可以返回任何内容的no-args函数,第二个参数是一个布尔值 ,它确定结果是否应该在控制台上显示。

在类内部,定义了一个不可变的私有属性,其中包含对图形用户界面单例的引用:

private val gui = new Gui(this)

另外,定义了一个私有隐式变量,其中包含用于行使Akka Executor的测试的Akka ExecutionContext 。 将该变量声明为可变var,是因为它将在Akka ExecutionContext的每次测试中采用新值。

private implicit var dispatcher: ExecutionContext = null

在运行每个测试之前,需要重置控制器:

def reset {
  ExecutorBenchmark.reset
  Model.reset
}

这是每个测试的主要逻辑:

def run() {
  reset
  ecNameMap.keys.foreach { ec: Any =>
    val ecName: String = ecNameMap.get(ec.asInstanceOf[AnyRef]).get
    ec match {
      case system: ActorSystem =>
        dispatcher = system.dispatcher
        runAkkaFutureLoads(ec, ecName)
        system.shutdown()
      case parallelism: Int =>
        runParallelLoads(parallelism, ecName)
      case jucExecutor: Executor => // j.u.c.Executor assumed
        dispatcher = ExecutionContext.fromExecutor(jucExecutor)
        runAkkaFutureLoads(ec, ecName)
        ec.asInstanceOf[ExecutorService].shutdown()
      case unknown =>
        println("Unknown test type: " + unknown)
        return
    }
    gui.removeCategorySpaces
  }
  gui.resize
}

回想一下ecNameMap键实际上是要运行的测试,而值是测试的名称。 foreach行的内容为:“对于ecNameMap中的每个键,命名键ec。” foreach闭包的主体继续到下一行,内容为:“获取键的值(这是测试的显示名称)并将其存储在ecName中 。” match语句的内容包含三种要运行的测试类型的情况:Akka ActorSystem ,或者指示应该测试Scala并行集合的Int ,否则应使用java.util.concurrent.Executor

如果提供了Akka ActorSystem或提供了jucExecutor ,则使用Akka期货作为容器运行测试负载,然后在每次测试后关闭ActorSystemjucExecutor 。 这是Akka ActorSystem案例:

dispatcher = system.dispatcher
runAkkaFutureLoads(ec, ecName)
system.shutdown()

这是jucExecutor案:

dispatcher = ExecutionContext.fromExecutor(jucExecutor)
runAkkaFutureLoads(ec, ecName)
jucExecutor.asInstanceOf[ExecutorService].shutdown()

否则,将Scala并行集合用作测试容器,如下所示:

runParallelLoads(parallelism, ecName)

由于Scala并行集合的ForkJoinPool是内部的,因此无法显式关闭它。 每次测试完成后,它都会自动关闭。

基准测试至少运行两次; 一次使用所需的Executor来预热Java热点JIT,再一次至少一次使用预热的热点。 如果需要标准偏差,则应调用至少10次,也许是100次。 通过调用Model.addTest()创建每个测试运行并将其添加到模型中; 新测试将返回并保存为newTest1

def runAkkaFutureLoads(executor: Any, executorName: String) {
  val newTest1 = Model.addTest(executor, executorName, runAkkaFutureLoad, true)
  if (Benchmark.showWarmUpTimes) {
    val test1StdDev = 0 // we only warm up once
    gui.addValue(MeanResult(newTest1.test, newTest1.testName, newTest1.millis, test1StdDev), true)
  }
  val results: Seq[TestResult] = for (
    i <- 0 until Benchmark.numRuns;
    val result = Model.addTest(executor, executorName, runAkkaFutureLoad, false)
  ) yield TestResult(newTest1.test, executorName, result.millis, result)
  val resultsMillis = results.map(_.millis)
  val millisMean = arithmeticMean(resulstMillis: _*).asInstanceOf[Long]
  val stdDev = popStdDev(resultsMillis: _*).asInstanceOf[Long]
  gui.addValue(MeanResult(runAkkaFutureLoad, executorName, stdDev * 2L, millisMean - stdDev), false)
}

如果要显示预热时间,则将测试保存在新的MeanResult案例类中。 预热仅执行一次,一个值的平均值当然就是值本身。 实际测试在理解中运行了很多次,并且结果存储为Seq [TestResult] ,就像Java Iterable <TestResult>一样 。 算术平均值和标准偏差作为映射/归约计算。 首先,从每个结果中提取millis属性,并将其存储在resultsMillis中

val resultsMillis: Seq[Long] = results.map(_.millis)

然后,通过将结果Millis串联到一个varargs列表中,将算术平均值减小为Long,然后将其传递到算术 Mean () ,并将生成的Double转换为Long并存储为millisMean

val millisMean = arithmeticMean(resultsMillis: _*).asInstanceOf[Long]

标准偏差的计算方法类似:

val stdDev = popStdDev(resultsMillis: _*).asInstanceOf[Long]

标准偏差跨越平均值,因此最后一行导致绘制的条形为平均值减去一个标准偏差的长度,而两个标准偏差的堆叠条形显示正常预期值的范围。 新的MeanResult案例类已添加到模型,并通过以下方式显示:

gui.addValue(MeanResult(runAkkaFutureLoad, executorName, stdDev * 2L, millisMean - stdDev), false)

既然我们已经了解了排序逻辑如何处理负载,那么我们几乎准备就绪,看看如何对单个负载进行实际基准测试。 首先,我们应该知道负载的计时方式。 计时结果存储在名为TimedResult的案例类中,计时由一个名为time()的函数完成。 time()函数返回一个TimedResult ,它只是一个具有两个值的元组:

case class TimedResult[T](millis: Long, results: T)

time()函数也很简单。 这是一个接受两个参数的咖喱函数。 第一个是时间的函子block ,它可以返回任何值。 第二个参数是控制台上显示的格式化消息的前缀。

def time(block: => Any)(msg: String = "Elapsed time"): TimedResult[Any] = {
  val t0 = System.nanoTime()
  val result: Any = block
  val elapsedMs = (System.nanoTime() - t0) / 1000000
  if (Benchmark.consoleOutput)
    println(msg + ": " + elapsedMs + "ms")
  TimedResult(elapsedMs, result)
}

现在,我们准备检查如何使用time()TimedResult基准测试单个负载。 请注意,每次定时运行都以三个垃圾回收为前缀。 该加载在容器Benchmark.numInterations次上重复执行。 然后使用Akka Future.sequence()方法将结果映射为Long称为elapsedMs构造一个TimedResult [Seq [Any]]]并将其作为此方法的值返回。 我们不在乎结果列表,因为结果列表应该全部相同,因此程序中不会使用它们。

def runAkkaFutureLoad: TimedResult[Seq[Any]] = {
  System.gc(); System.gc(); System.gc()
  val t0 = System.nanoTime()
  val trFuture = time {
    for (i <- 1 to Benchmark.numInterations) yield Future { load() }
  }("Futures creation time").asInstanceOf[TimedResult[Seq[Future[Any]]]]

  val f2 = Future.sequence(trFuture.results).map { results: Seq[Any] =>
    val elapsedMs: Long = (System.nanoTime() - t0) / 1000000
    if (Benchmark.consoleOutput) {
      println("Total time for Akka future version: " + elapsedMs + "ms")
      if (showResult)
        println("Result in " + trFuture.millis + " using Akka future version: " + results)
    }
    TimedResult(elapsedMs, results)
  }
  val r = Await.result(f2, Duration.Inf)
  r.asInstanceOf[TimedResult[Seq[Any]]]
}

标定Scala并行集合的代码与上述标定Akka期货的代码非常相似。

综上所述

在知道负载的多少次迭代将产生统计上重要的数据之前,将需要进行一些实验。 标准偏差的大小将告诉您结果的紧密程度,从而表明在信任结果之前需要收集多少数据。

大型程序的设计应使它们的主要任务可以作为独立的计算负荷进行度量。 由于某些任务可能会影响其他任务,因此请务必分开并同时进行测量。

当开发必须在多种配置上运行的库或产品时,仅对一种硬件配置进行测试是不够的。 与必须严格控制硬件环境的SAAS(相对于SAAS)相比,表征必须在客户硬件上运行的程序所施加的计算负载的工作量要大得多。

  

翻译自: https://www.infoq.com/articles/benchmarking-jvm/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1

scala akka

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值