Un viaje de expedición desde un fragmento del código fuente de Dubbo hasta la predicción de rama de CPU

En todas las épocas, las personas que pueden aprender no serán tratadas mal.

Hola a todos, soy que sí.

Esta vez originalmente planeé escribir un artículo relacionado con RocketMQ, pero me interrumpieron y no me lo esperaba.

Es una coincidencia que estuve mirando el código fuente de Dubbo recientemente, y encontré un código muy extraño, así que tengo este artículo, echemos un vistazo a este código, al que pertenece  ChannelEventRunnable, este ejecutable es creado por el hilo de Dubbo IO. La tarea se lanza al grupo de subprocesos comerciales para su procesamiento.

1240

Vea state == ChannelState.RECEIVED si lo ve, saque  un si independiente, y el otro estado todavía se juzga en el interruptor.

1240

Estaba escudriñando de un lado a otro en mi mente en ese momento, pensando en lo que estaba mal con esto, pero mi conocimiento era superficial y confuso.

¡Así comenzó una ola de aventuras!

Resulta ser predicción de rama de CPU

Por supuesto, cuando encuentre un problema, debe preguntar a los motores de búsqueda. En términos generales, buscaré en todos los motores de búsqueda principales al mismo tiempo. No decimos quién es mejor que nadie. En algunos casos, Du Niang sigue siendo bueno. Adelante, Google está detrás.

Por lo general, me gusta buscar cosas en el sitio web oficial primero y luego abandonar la búsqueda si no puedo encontrarlas, así que primero busco así  site:xxx.com key.

1240

Ves que esto está ahí, ¡perfecto!

Echemos un vistazo a lo que dice este blog en el sitio web oficial y luego analicemos la ola en detalle.

Blog del sitio web oficial de Dubbo

Las CPU modernas admiten la predicción de ramas y la canalización de instrucciones. La combinación de estos dos puede mejorar en gran medida la eficiencia de la CPU. Para saltos simples, la CPU puede realizar una mejor predicción de ramas. Pero para el salto del interruptor, la CPU no tiene muchas formas. El cambio se basa esencialmente en el índice, toma la dirección de la matriz de direcciones y luego salta.

En otras palabras, si es una instrucción de salto, si es una instrucción de salto simple, la CPU puede usar la predicción de bifurcación para pre-ejecutar la instrucción , y el interruptor primero necesita encontrar la dirección correspondiente basada en el valor en una estructura de matriz similar, y luego saltar. En este caso, la predicción de la CPU no ayudará.

Luego, después de que se establece un canal, su estado es ChannelState.RECEIVED en más del 99,9% de los casos , por lo que este estado se selecciona para que el mecanismo de predicción de rama de la CPU se pueda utilizar para mejorar la eficiencia de ejecución del código.

Y también dado el código de Benchmark, que es generar aleatoriamente estados de 100 W, y el 99,99% es ChannelState.RECEIVED, y luego compararlos de las siguientes dos formas (el nombre del ejemplo en el sitio web oficial de benchSwitch es incorrecto, no lo inicié. Fue descubierto después de revisar el artículo).

1240

Aunque el blog también da sus resultados de comparación, todavía lo ejecuto localmente para ver cómo son los resultados.De hecho, JMH no recomienda ejecutar en el IDE, pero soy vago y ejecuto directamente en el IDE.

1240

De los resultados, es cierto que la eficiencia de ejecución del código independientemente de si es mayor (tenga en cuenta que el rendimiento se mide aquí). El blog también propone que esta técnica se puede colocar en lugares con requisitos de rendimiento estrictos, es decir, en circunstancias normales, no hay necesidad de hacer este especial. .

Hasta ahora sabemos que esta conclusión es correcta, pero aún necesitamos analizar la onda en profundidad. Primero, tenemos que ver la diferencia entre los métodos de ejecución de if y switch, y luego ver qué hacen la predicción de rama de CPU y la canalización de instrucciones. Sí, ¿por qué hay estas dos cosas?

si vs cambiar

Tomemos una demostración simple para ver la eficiencia de ejecución de if y switch. De hecho, es agregar un código que está todo controlado por if else, switch y if + switch no se mueven, y ver qué tan eficiente es la comparación entre ellos (recibido en este momento) Más del 99,9%).

1240

Eche un vistazo a los resultados de la ejecución:

1240

Buen chico, lo ejecuté varias veces. El if completo es mucho mejor que el interruptor if +, así que ¿debería cambiarse el código fuente a if else? Mira el alto rendimiento, no parecerá if Switch, una vez más, parece un poco anodino.

Me pusieron en un valor de estado generado aleatoriamente , ejecútelo nuevamente y vea qué sucede:

1240

Después de ejecutarlo muchas veces, el rendimiento de if fue el más alto y todo el if fue el mejor.

Descompilar si y cambiar

En mi impresión, este cambio debería ser mejor que si. Si no se considera la predicción de rama de CPU, cuando es así desde la perspectiva del código de bytes, echemos un vistazo al código de bytes generado por cada uno.

Eche un vistazo a la descompilación de switch primero y luego intercepte las partes clave.

1240

Es decir, el conmutador genera un conmutador de tabla. Después de que el getstatic anterior obtenga el valor, puede verificar directamente la tabla de acuerdo con el índice y luego saltar a la fila correspondiente para la ejecución, es decir, la complejidad del tiempo es O (1).

Por ejemplo, si el valor es 1, salte a la línea de ejecución 64, si es 4, salte a la línea 100.

Hay algunos pequeños detalles sobre el interruptor. Cuando los valores en swtich son discontinuos y la brecha es grande, se genera el interruptor de búsqueda . Según la declaración en línea, es una dicotomía para la consulta (no la he verificado). La complejidad del tiempo es O ( logn), no se puede encontrar directamente según el índice.Creo que la búsqueda generada debería dividirse en dos partes porque está ordenada por valor.

1240

Y cuando  los valores en el interruptor no son continuos pero el espacio es relativamente pequeño, las tablas aún se generarán pero algunos valores se completarán . Por ejemplo, en este ejemplo, los valores en mi interruptor son 1, 3, 5, 7 y 9, que se llenan automáticamente con 2 , 4, 6, 8 se refieren a la línea omitida de forma predeterminada.

1240

Veamos el resultado de la descompilación de if nuevamente :

1240

Se puede ver que si saca variables y condiciones para comparar cada vez, mientras que switch toma una variable y luego salta directamente a la fila correcta después de buscar en la tabla Desde esta perspectiva, la eficiencia de switch debería ser mejor que si. Por supuesto, si pasa el primer juicio, entonces vaya directamente, y los siguientes juicios no se ejecutarán.

所以从生成的字节码角度来看 switch 效率应该是大于 if 的,但是从测试结果的角度来看 if 的效率又是高于 switch 的,不论是随机生成 state,还是 99.99% 都是同一个 state 的情况下。

首先 CPU 分支预测的优化是肯定的,那关于随机情况下 if 还是优于 switch 的话这我就有点不太确定为什么了,可能是 JIT 做了什么优化操作,或者是随机情况下分支预测成功带来的效益大于预测失败的情形?

难道是我枚举值太少了体现不出 switch 的效果?不过在随机情况下 switch 也不应该弱于 if 啊,我又加了 7 个枚举值,一共 12 个值又测试了一遍,结果如下:

1240

好像距离被拉近了,我看有戏,于是我背了波 26 个字母,实不相瞒还是唱着打的字母。

1240

扩充了分支的数量后又进行了一波测试,这次 swtich 争气了,终于比 if 强了。

1240

题外话:
我看网上也有对比 if 和 switch 的,它们对比出来的结果是 switch 优于 if,首先 jmh 就没写对,定义一个常量来测试 if 和 switch,并且测试方法的 result 写了没有消费,这代码也不知道会被 JIT 优化成啥样了,写了几十行,可能直接优化成 return 某个值了。

小结一下测试结果

对比了这么多我们来小结一下。

首先对于热点分支将其从 switch 提取出来用 if 独立判断,充分利用 CPU 分支预测带来的便利确实优于纯 swtich,从我们的代码测试结果来看,大致吞吐量高了两倍。

在热点分支的情形下改成纯 if 判断而不是 if + swtich的情形下,吞吐量提高的更多。是纯 switch 的 3.3 倍,是 if + switch 的 1.6 倍。

在随机分支的情形下,三者差别不是很大,但是还是纯 if 的情况最优秀。

但是从字节码角度来看其实 switch 的机制效率应该更高的,不论是 O(1) 还是 O(logn),但是从测试结果的角度来说不是的。

在选择条件少的情况下 if 是优于 switch 的,这个我不太清楚为什么,可能是在值较少的情况下查表的消耗相比带来的收益更大一些?有知道的小伙伴可以在文末留言。

在选择条件很多的情况下 switch 是优于 if 的,再多的选择值我就没测了,大伙有兴趣可以自己测测,不过趋势就是这样的。

CPU 分支预测

接下来咱们再来看看这个分支预测到底是怎么弄的,为什么会有分支预测这玩意,不过在谈到分支预测之前需要先介绍下指令流水线(Instruction pipelining),也就是现代微处理器的 pipeline。

CPU 本质就是取指执行,而取指执行我们来看下五大步骤,分别是获取指令(IF)、指令解码(ID)、执行指令(EX)、内存访问(MEM)、写回结果(WB),再来看下维基百科上的一个图。

1240

当然步骤实际可能更多,反正就是这个意思需要经历这么多步,所以说一次执行可以分成很多步骤,那么这么多步骤就可以并行,来提升处理的效率。

所以说指令流水线就是试图用一些指令使处理器的每一部分保持忙碌,方法是将传入的指令分成一系列连续的步骤,由不同的处理器单元执行,不同的指令部分并行处理。

就像我们工厂的流水线一样,我这个奥特曼的脚拼上去了马上拼下一个奥特曼的脚,我可不会等上一个奥特曼的都组装完了再组装下一个奥特曼。

1240

当然也没有这么死板,不一定就是顺序执行,有些指令在等待而后面的指令其实不依赖前面的结果,所以可以提前执行,这种叫乱序执行

我们再说回我们的分支预测。

这代码就像我们的人生一样总会面临着选择,只有做了选择之后才知道后面的路怎么走呀,但是事实上发现这代码经常走的是同一个选择,于是就想出了一个分支预测器,让它来预测走势,提前执行一路的指令。

1240

那预测错了怎么办?这和咱们人生不一样,它可以把之前执行的结果全抛了然后再来一遍,但是也有影响,也就是流水线越深,错的越多浪费的也就越多,错误的预测延迟是10至20个时钟周期之间,所以还是有副作用的。

简单的说就是通过分支预测器来预测将来要跳转执行的那些指令,然后预执行,这样到真正需要它的时候可以直接拿到结果了,提升了效率。

分支预测又分了很多种预测方式,有静态预测、动态预测、随机预测等等,从维基百科上看有16种。

1240

我简单说下我提到的三种,静态预测就是愣头青,就和蒙英语选择题一样,我管你什么题我都选A,也就是说它会预测一个走势,一往无前,简单粗暴。

动态预测则会根据历史记录来决定预测的方向,比如前面几次选择都是 true ,那我就走 true 要执行的这些指令,如果变了最近几次都是 false ,那我就变成 false 要执行的这些指令,其实也是利用了局部性原理。

随机预测看名字就知道了,这是蒙英语选择题的另一种方式,瞎猜,随机选一个方向直接执行。

还有很多就不一一列举了,各位有兴趣自行去研究,顺便提一下在 2018 年谷歌的零项目和其他研究人员公布了一个名为 Spectre 的灾难性安全漏洞,其可利用 CPU 的分支预测执行泄漏敏感信息,这里就不展开了,文末会附上链接。

之后又有个名为 BranchScope 的***,也是利用预测执行,所以说每当一个新的玩意出来总是会带来利弊。

至此我们已经知晓了什么叫指令流水线和分支预测了,也理解了 Dubbo 为什么要这么优化了,但是文章还没有结束,我还想提一提这个 stackoverflow 非常有名的问题,看看这数量。

1240

为什么处理有序数组要比非有序数组快?

这个问题在那篇博客开头就被提出来了,很明显这也是和分支预测有关系,既然看到了索性就再分析一波,大伙可以在脑海里先回答一下这个问题,毕竟咱们都知道答案了,看看思路清晰不。

就是下面这段代码,数组排序了之后循环的更快。

1240

然后各路大神就蹦出来了,我们来看一下首赞的大佬怎么说的。

一开口就是,直击要害。

You are a victim of branch prediction fail.

紧接着就上图了,一看就是老司机。

1240

他说让我们回到 19世纪,一个无法远距离交流且无线电还未普及的时候,如果是你这个铁路交叉口的扳道工,当火车快来的时候,你如何得知该扳哪一边?

火车停车再重启的消耗是很大的,每次到分叉口都停车,然后你问他,哥们去哪啊,然后扳了道,再重启就很耗时,怎么办?猜!

1240

猜对了火车就不用停,继续开。猜错了就停车然后倒车然后换道再开。

所以就看猜的准不准了!搏一搏单车变摩托。

然后大佬又指出了关键代码对应的汇编代码,也就是跳转指令了,这对应的就是火车的岔口,该选条路了。

1240

后面我就不分析了,大伙儿应该都知道了,排完序的数组执行到值大于 128 的之后肯定全部大于128了,所以每次分支预测的结果都是对了!所以执行的效率很高。

而没排序的数组是乱序的,所以很多时候都会预测错误,而预测错误就得指令流水线排空啊,然后再来一遍,这速度当然就慢了。

所以大佬说这个题主你是分支预测错误的受害者。

最终大佬给出的修改方案是咱不用 if 了,惹不起咱还躲不起嘛?直接利用位运算来实现这个功能,具体我就不分析了,给大家看下大佬的建议修改方案。

1240

最后

Este artículo está casi completo. Hoy, comencé el viaje de aventura a partir de un fragmento de código Dubbo, analicé la onda if y cambié, a partir de los resultados de la prueba, la optimización de Dubbo no es lo suficientemente completa, y debería cambiarse todo a la estructura if else.

Si bien swtich es superior a if en términos de código de bytes, pero a partir de los resultados de la prueba, puede mostrar una ventaja cuando hay muchas ramas. En general, no puede superar a if.

Entonces también sé qué es la canalización de instrucciones. Esto en realidad se basa en la realidad. La canalización es lo suficientemente rápida. Entonces, la ejecución previa de la predicción de rama también es una forma de mejorar la eficiencia. Por supuesto, debe adivinar correctamente, de lo contrario, los efectos secundarios de los errores de predicción de rama no se pueden ignorar. Por tanto, los requisitos para los predictores de rama también son muy elevados.

También escribo un paquete para el código de prueba de JMH, y puedes obtenerlo ingresando "predicción de rama" en el backend de los compañeros de clase que quieren ejecutar. Si crees que este artículo es bueno, revísalo. Si hay algún error, comunícate conmigo.

Escanea el código para seguir mi cuenta pública ~

image.png

Supongo que te gusta

Origin blog.51cto.com/11226237/2544090
Recomendado
Clasificación