十六、代码校验(5)

本章概要

  • 基准测试
    • 微基准测试
    • JMH 的引入

基准测试

我们应该忘掉微小的效率提升,说的就是这些 97% 的时间做的事:过早的优化是万恶之源。—— Donald Knuth

如果你发现自己正在过早优化的滑坡上,你可能浪费了几个月的时间(如果你雄心勃勃的话)。通常,一个简单直接的编码方法就足够好了。如果你进行了不必要的优化,就会使你的代码变得无谓的复杂和难以理解。

基准测试意味着对代码或算法片段进行计时看哪个跑得更快,与下一节的分析和优化截然相反,分析优化是观察整个程序,找到程序中最耗时的部分。

可以简单地对一个代码片段的执行计时吗?在像 C 这样直接的编程语言中,这个方法的确可行。在像 Java 这样拥有复杂的运行时系统的编程语言中,基准测试变得更有挑战性。为了生成可靠的数据,环境设置必须控制诸如 CPU 频率,节能特性,其他运行在相同机器上的进程,优化器选项等等。

微基准测试

写一个计时工具类从而比较不同代码块的执行速度是具有吸引力的。看上去这会产生一些有用的数据。比如,这里有一个简单的 Timer 类,可以用以下两种方式使用它:

  1. 创建一个 Timer 对象,执行一些操作然后调用 Timerduration() 方法产生以毫秒为单位的运行时间。
  2. 向静态的 duration() 方法中传入 Runnable。任何符合 Runnable 接口的类都有一个函数式方法 run(),该方法没有入参,且没有返回。
import static java.util.concurrent.TimeUnit.*;public class Timer {private long start = System.nanoTime();public long duration() {return NANOSECONDS.toMillis(System.nanoTime() - start);}public static long duration(Runnable test) {Timer timer = new Timer();test.run();return timer.duration();}
}

这是一个很直接的计时方式。难道我们不能只运行一些代码然后看它的运行时长吗?

有许多因素会影响你的结果,即使是生成提示符也会造成计时的混乱。这里举一个看上去天真的例子,它使用了 标准的 Java Arrays 库(后面会详细介绍):

import java.util.*;public class BadMicroBenchmark {static final int SIZE = 250_000_000;public static void main(String[] args) {try { // For machines with insufficient memorylong[] la = new long[SIZE];System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> n)));System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> n)));} catch (OutOfMemoryError e) {System.out.println("Insufficient memory");System.exit(0);}}
}

在这里插入图片描述

main() 方法的主体包含在 try 语句块中,因为一台机器用光内存后会导致构建停止。

对于一个长度为 250,000,000 的 long 型(仅仅差一点就会让大部分机器内存溢出)数组,我们比较了 Arrays.setAll()Arrays.parallelSetAll() 的性能。这个并行的版本会尝试使用多个处理器加快完成任务(尽管我在这一节谈到了一些并行的概念,但是在 并发编程 章节我们才会详细讨论这些 )。然而非并行的版本似乎运行得更快,尽管在不同的机器上结果可能不同。

BadMicroBenchmark.java 中的每一步操作都是独立的,但是如果你的操作依赖于同一资源,那么并行版本运行的速度会骤降,因为不同的进程会竞争相同的那个资源。

import java.util.*;public class BadMicroBenchmark2 {static final int SIZE = 5_000_000;public static void main(String[] args) {long[] la = new long[SIZE];Random r = new Random();System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> r.nextLong())));System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> r.nextLong())));SplittableRandom sr = new SplittableRandom();System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> sr.nextLong())));System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> sr.nextLong())));}
}

在这里插入图片描述

SplittableRandom 是为并行算法设计的,它当然看起来比普通的 RandomparallelSetAll() 中运行得更快。 但是看上去还是比非并发的 setAll() 运行时间更长,有点难以置信(也许是真的,但我们不能通过一个坏的微基准测试得到这个结论)。

这只考虑了微基准测试的问题。Java 虚拟机 Hotspot 也非常影响性能。如果你在测试前没有通过运行代码给 JVM 预热,那么你就会得到“冷”的结果,不能反映出代码在 JVM 预热之后的运行速度(假如你运行的应用没有在预热的 JVM 上运行,你就可能得不到所预期的性能,甚至可能减缓速度)。

优化器有时可以检测出你创建了没有使用的东西,或者是部分代码的运行结果对程序没有影响。如果它优化掉你的测试,那么你可能得到不好的结果。

一个良好的微基准测试系统能自动地弥补像这样的问题(和很多其他的问题)从而产生合理的结果,但是创建这么一套系统是非常棘手,需要深入的知识。

JMH 的引入

截止目前为止,唯一能产生像样结果的 Java 微基准测试系统就是 Java Microbenchmarking Harness,简称 JMH。本书的 build.gradle 自动引入了 JMH 的设置,所以你可以轻松地使用它。

你可以在命令行编写 JMH 代码并运行它,但是推荐的方式是让 JMH 系统为你运行测试;build.gradle 文件已经配置成只需要一条命令就能运行 JMH 测试。

JMH 尝试使基准测试变得尽可能简单。例如,我们将使用 JMH 重新编写 BadMicroBenchmark.java。这里只有 **@State ** 和 **@Benchmark ** 这两个注解是必要的。其余的注解要么是为了产生更多易懂的输出,要么是加快基准测试的运行速度(JMH 基准测试通常需要运行很长时间):

// validating/jmh/JMH1.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// Increase these three for more accuracy:
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH1 {private long[] la;@Setuppublic void setup() {la = new long[250_000_000];}@Benchmarkpublic void setAll() {Arrays.setAll(la, n -> n);}public void parallelSetAll() {Arrays.parallelSetAll(la, n -> n);}
}

“forks” 的默认值是 10,意味着每个测试都运行 10 次。为了减少运行时间,这里使用了 **@Fork ** 注解来减少这个次数到 1。我还使用了 **@Warmup ** 和 **@Measurement ** 注解将它们默认的运行次数从 20 减少到 5 次。尽管这降低了整体的准确率,但是结果几乎与使用默认值相同。可以尝试将 **@Warmup @Measurement ** 和 **@Fork ** 都注释掉然后看使用它们的默认值,结果会有多大显著的差异;一般来说,你应该只能看到长期运行的测试使错误因素减少,而结果没有多大变化。

需要使用显式的 gradle 命令才能运行基准测试(在示例代码的根目录处运行)。这能防止耗时的基准测试运行其他的 gradlew 命令:

gradlew validating:jmh

这会花费几分钟的时间,取决于你的机器(如果没有注解上的调整,可能需要几个小时)。控制台会显示 results.txt 文件的路径,这个文件统计了运行结果。注意,results.txt 包含这一章所有 jmh 测试的结果:JMH1.javaJMH2.javaJMH3.java

因为输出是绝对时间,所以在不同的机器和操作系统上结果各不相同。重要的因素不是绝对时间,我们真正观察的是一个算法和另一个算法的比较,尤其是哪一个运行得更快,快多少。如果你在自己的机器上运行测试,你将看到不同的结果却有着相同的模式。

我在大量的机器上运行了这些测试,尽管不同的机器上得到的绝对值结果不同,但是相对值保持着合理的稳定性。我只列出了 results.txt 中适当的片段并加以编辑使输出更加易懂,而且内容大小适合页面。所有测试中的 Mode 都以 avgt 展示,代表 “平均时长”。Cnt(测试的数目)的值是 200,尽管这里的一个例子中配置的 Cnt 值是 5。Unitsus/op,是 “Microseconds per operation” 的缩写,因此,这个值越小代表性能越高。

我同样也展示了使用 warmups、measurements 和 forks 默认值的输出。我删除了示例中相应的注解,就是为了获取更加准确的测试结果(这将花费数小时)。结果中数字的模式应该仍然看起来相同,不论你如何运行测试。

下面是 JMH1.java 的运行结果:

Benchmark Score

JMH1.setAll 196280.2

JMH1.parallelSetAll 195412.9

即使像 JMH 这么高级的基准测试工具,基准测试的过程也不容易,练习时需要倍加小心。这里测试产生了反直觉的结果:并行的版本 parallelSetAll() 花费了与非并行版本的 setAll() 相同的时间,两者似乎都运行了相当长的时间。

当创建这个示例时,我假设如果我们要测试数组初始化的话,那么使用非常大的数组是有意义的。所以我选择了尽可能大的数组;如果你实验的话会发现一旦数组的大小超过 2亿5000万,你就开始会得到内存溢出的异常。然而,在这么大的数组上执行大量的操作从而震荡内存系统,产生无法预料的结果是有可能的。不管这个假设是否正确,看上去我们正在测试的并非是我们想测试的内容。

考虑其他的因素:

C:客户端执行操作的线程数量

P:并行算法使用的并行数量

N:数组的大小:**10^(2_k)_,通常来说,k=1…7 足够来练习不同的缓存占用。

Q:setter 的操作成本

这个 C/P/N/Q 模型在早期 JDK 8 的 Lambda 开发期间浮出水面,大多数并行的 Stream 操作(parallelSetAll() 也基本相似)都满足这些结论:**N_Q_(主要工作量)对于并发性能尤为重要。并行算法在工作量较少时可能实际运行得更慢。

在一些情况下操作竞争如此激烈使得并行毫无帮助,而不管 **N_Q_ 有多大。当 C 很大时,P 就变得不太相关(内部并行在大量的外部并行面前显得多余)。此外,在一些情况下,并行分解会让相同的 C 个客户端运行得比它们顺序运行代码更慢。

基于这些信息,我们重新运行测试,并在这些测试中使用不同大小的数组(改变 N):

// validating/jmh/JMH2.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH2 {private long[] la;@Param({"1","10","100","1000","10000","100000","1000000","10000000","100000000","250000000"})int size;@Setuppublic void setup() {la = new long[size];}@Benchmarkpublic void setAll() {Arrays.setAll(la, n -> n);}@Benchmarkpublic void parallelSetAll() {Arrays.parallelSetAll(la, n -> n);}
}

**@Param ** 会自动地将其自身的值注入到变量中。其自身的值必须是字符串类型,并可以转化为适当的类型,在这个例子中是 int 类型。

下面是已经编辑过的结果,包含精确计算出的加速数值:

JMH2 BenchmarkSizeScore %Speedup
setAll10.001
parallelSetAll10.0360.028
setAll100.005
parallelSetAll103.9650.001
setAll1000.031
parallelSetAll1003.1450.010
setAll10000.302
parallelSetAll10003.2850.092
setAll100003.152
parallelSetAll100009.6690.326
setAll10000034.971
parallelSetAll10000020.1531.735
setAll1000000420.581
parallelSetAll1000000165.3882.543
setAll100000008160.054
parallelSetAll100000007610.1901.072
setAll10000000079128.752
parallelSetAll10000000076734.6711.031
setAll250000000199552.121
parallelSetAll250000000191791.9271.040
可以看到当数组大小达到 10 万左右时,parallelSetAll() 开始反超,而后趋于与非并行的运行速度相同。即使它运行速度上胜了,看起来也不足以证明由于并行的存在而使速度变快。

setAll()/parallelSetAll() 中工作的计算量起很大影响吗?在前面的例子中,我们所做的只有对数组的赋值操作,这可能是最简单的任务。所以即使 N 值变大,**N_Q_ 也仍然没有达到巨大,所以看起来像是我们没有为并行提供足够的机会(JMH 提供了一种模拟变量 Q 的途径;如果想了解更多的话,可搜索 Blackhole.consumeCPU)。

我们通过使方法 f() 中的任务变得更加复杂,从而产生更多的并行机会:

// validating/jmh/JMH3.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH3 {private long[] la;@Param({"1","10","100","1000","10000","100000","1000000","10000000","100000000","250000000"})int size;@Setuppublic void setup() {la = new long[size];}public static long f(long x) {long quadratic = 42 * x * x + 19 * x + 47;return Long.divideUnsigned(quadratic, x + 1);}@Benchmarkpublic void setAll() {Arrays.setAll(la, n -> f(n));}@Benchmarkpublic void parallelSetAll() {Arrays.parallelSetAll(la, n -> f(n));}
}

f() 方法提供了更加复杂且耗时的操作。现在除了简单的给数组赋值外,setAll()parallelSetAll() 都有更多的工作去做,这肯定会影响结果。

JMH2 BenchmarkSizeScore %Speedup
setAll10.012
parallelSetAll10.0470.255
setAll100.107
parallelSetAll103.8940.027
setAll1000.990
parallelSetAll1003.7080.267
setAll1000133.814
parallelSetAll100011.74711.391
setAll1000097.954
parallelSetAll1000037.2592.629
setAll100000988.475
parallelSetAll100000276.2643.578
setAll10000009203.103
parallelSetAll10000002826.9743.255
setAll1000000092144.951
parallelSetAll1000000028126.2023.276
setAll100000000921701.863
parallelSetAll100000000266750.5433.455
setAll2500000002299127.273
parallelSetAll250000000538173.4254.272

可以看到当数组的大小达到 1000 左右时,parallelSetAll() 的运行速度反超了 setAll()。看来 parallelSetAll() 严重依赖数组中计算的复杂度。这正是基准测试的价值所在,因为我们已经得到了关于 setAll()parallelSetAll() 间微妙的信息,知道在何时使用它们。

这显然不是从阅读 Javadocs 就能得到的。

大多数时候,JMH 的简单应用会产生好的结果(正如你将在本书后面例子中所见),但是我们从这里知道,你不能一直假定 JMH 会产生好的结果。 JMH 网站上的范例可以帮助你开始。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/106062.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Vitis导入自制IP导致无法构建Platform

怎么还有这种问题( 解决Vitis导入自制IP导致无法构建Platform – TaterLi 个人博客 Vitis报错:fatal error: xxx.h: No such file or directory._ly2lj的博客-CSDN博客 在指定位置黏入以上代码即可: INCLUDEFILES$(wildcard *.h) LIBSOUR…

基于springboot养老院管理系统开题报告

一、项目简介 本项目是一款基于Spring Boot的养老院管理系统,旨在为养老院提供一个全方位的解决方案,包括老人信息管理、医疗服务管理、饮食管理、活动管理等模块。通过系统的数字化管理,将老人的生活照料和医学护理更加科学化、规范化&…

【AI视野·今日Robot 机器人论文速览 第五十四期】Fri, 13 Oct 2023

AI视野今日CS.Robotics 机器人学论文速览 Fri, 13 Oct 2023 Totally 45 papers 👉上期速览✈更多精彩请移步主页 Interesting: 📚AI与机器人安全, 从攻击界面、伦理法律和人机交互层面进行了论述。(from 密西西比大学) 📚机器人与图机器学…

Centos Docker部署Redis集群三主三从

一、安装Docker yum install docker-engine 二、编辑节点配置文件 创建文件夹 cd /home # 节点一:6370端口 mkdir -p redis-cluster/redis-6370/conf mkdir -p redis-cluster/redis-6370/data # 节点二:6371端口 mkdir -p redis-cluster/redis-6371/co…

华为云云耀云服务器L实例评测|企业项目最佳实践之建议与总结(十二)

华为云云耀云服务器L实例评测|企业项目最佳实践系列: 华为云云耀云服务器L实例评测|企业项目最佳实践之云服务器介绍(一) 华为云云耀云服务器L实例评测|企业项目最佳实践之华为云介绍(二) 华为云云耀云服务器L实例评测&#xff5…

Java基础面试-接口和抽象类的区别

抽象类 详细描述 类和类之间具有共同特征,将这些共同特征抽取出来,形成的就是抽象类。如果一个类没有足够的信息来描述一个具体的对象,这个类就是抽象类。因为类本身就是不存在的,所以抽象类无法创建对象,也就是无法…

2021年12月 Python(二级)真题解析#中国电子学会#全国青少年软件编程等级考试

Python编程(1~6级)全部真题・点这里 C/C编程(1~8级)全部真题・点这里 一、单选题(共25题,每题2分,共50分) 第1题 执行以下程序 a[33,55,22,77] a.sort() for i in a:print(i)运行…

Nginx:反向代理(示意图+配置)

示意图: 反向代理 反向代理(Reverse Proxy)是代理服务器的一种,它代表服务器接收客户端的请求,并将这些请求转发到适当的服务器。当请求在后端服务器完成之后,反向代理搜集请求的响应并将其传输给客户端。…

MFC-对话框

目录 1、模态和非模态对话框: (1)、对话框的创建 (2)、更改默认的对话框名称 (3)、创建模态对话框 1)、创建按钮跳转的界面 2)、在跳转的窗口添加类 3&#xff0…

java_方法引用和构造器引用

文章目录 一、方法引用1.1、方法引用的理解1.2、格式1.3、举例 二、构造器引用2.1、格式2.2、例子2.3、数组引用 一、方法引用 1.1、方法引用的理解 方法引用,可以看做是基于lambda表达式的进一步刻画当需要提供一个函数式接口的实例时,可以使用lambda…

牛客周赛 Round 15_B

题目描述 对于一个小写字母而言,游游可以通过一次操作把这个字母变成相邻的字母。a和b邻,b和c相邻,以此类推。特殊的,a和z也是相邻的。可以认为,小写字母的相邻规则为一个环。 游游拿到了一个仅包含小写字母的字符串…

了解油封对汽车安全的影响?

油封也称为轴封或径向轴封,是车辆发动机、变速箱和其他各种机械系统中的重要部件。它们的主要功能是阻止重要发动机部件的液体(例如油或冷却剂)泄漏,同时防止污染物进入。这些看似简单的任务,但对汽车的安全性和可靠性有着深远的影响。 油封…

docker安装nessus

注册地址:https://zh-tw.tenable.com/products/nessus/nessus-essentials 临时邮箱:http://24mail.chacuo.net/ 帮助文档:https://docs.tenable.com/nessus/Content/DeployNessusDocker.htmdocker pull tenableofficial/nessusdocker run --name "my-nessus" -d -p 8…

vscode用密钥文件连接ssh:如果一直要输密码怎么办

commandshiftP:打开ssh配置文件 加上这么一段,host就是你给主机起的名字 对IdentityFile进行更改,改成相应的密钥文件 然后commandshiftP链接到主机就可以了 但是有时候它会让输入密码 这是由于你给这个IdentityFile的权限太多了&#xf…

npm 常用指令总结

1. 初始化包 一个存放了代码的文件夹,如果里面有 package.json 文件,则可以把这个文件夹称之为包。 npm init -y 注意: 由于包名不能有中文,不能有大写,不能和未来要下载的包重名. 所以我们快速初始化包时,我们的文件夹也不能违反前面说的规则.(因为默认会将文件夹的名称,作…

数据结构 - 2(顺序表10000字详解)

一:List 1.1 什么是List 在集合框架中,List是一个接口,继承自Collection。 Collection也是一个接口,该接口中规范了后序容器中常用的一些方法,具体如下所示: Iterable也是一个接口,Iterabl…

【Vue面试题二十三】、你了解vue的diff算法吗?说说看

文章底部有个人公众号:热爱技术的小郑。主要分享开发知识、学习资料、毕业设计指导等。有兴趣的可以关注一下。为何分享? 踩过的坑没必要让别人在再踩,自己复盘也能加深记忆。利己利人、所谓双赢。 面试官:你了解vue的diff算法吗&…

Vue-3.1缓存组件keep-alive

问题:从首页卡片点到详情页,又点返回,数据重新加载了->希望回到原来的位置 原因:路由跳转后,组件被销毁了,返回回来组件又被重建了,所以数据重新被加载了 解决:利用keep-alive将…

【Vue面试题二十一】、Vue中的过滤器了解吗?过滤器的应用场景有哪些?

文章底部有个人公众号:热爱技术的小郑。主要分享开发知识、学习资料、毕业设计指导等。有兴趣的可以关注一下。为何分享? 踩过的坑没必要让别人在再踩,自己复盘也能加深记忆。利己利人、所谓双赢。 面试官:Vue中的过滤器了解吗&am…

Java字符串String

【char】类型代表字符类型,【String】类型代表字符串类型; 1.String类 1.1 声明字符串 在Java中字符串必须包含在一对双引号(“ ")之内。双引号包含的都是字符串。 声明字符串语法: String str; //声明字符串语…