c++ - Programación consciente de sucursales
performance optimization (8)
Estoy leyendo que la mala predicción de una rama puede ser un cuello de botella caliente para la ejecución de una aplicación. Como puedo ver, la gente a menudo muestra un código ensamblador que revela el problema y declara que los programadores generalmente pueden predecir a dónde puede ir una sucursal la mayor parte del tiempo y evitar errores en las ramificaciones.
Mis preguntas son:
1- ¿Es posible evitar errores de predicción de ramas usando alguna técnica de programación de alto nivel (es decir, sin ensamblaje )?
2- ¿Qué debo tener en cuenta para producir código compatible con las sucursales en un lenguaje de programación de alto nivel (estoy interesado principalmente en C y C ++)?
Ejemplos de código y puntos de referencia son bienvenidos!
1- ¿Es posible evitar errores de predicción de ramas usando alguna técnica de programación de alto nivel (es decir, sin ensamblaje)?
¿Evitar? Talvez no. ¿Reducir? Ciertamente...
2- ¿Qué debo tener en cuenta para producir código compatible con las sucursales en un lenguaje de programación de alto nivel (estoy interesado principalmente en C y C ++)?
Vale la pena señalar que la optimización para una máquina no es necesariamente la optimización de otra. Teniendo esto en cuenta, la optimización guiada por perfil es razonablemente buena para reordenar las sucursales, según el aporte de prueba que le dé. Esto significa que no necesita hacer ninguna programación para realizar esta optimización, y debe estar adaptada a la máquina en la que esté perfilando. Obviamente, los mejores resultados se obtendrán cuando su entrada de prueba y la máquina que usted describe coincidan más o menos con las expectativas comunes ... pero también son consideraciones para otras optimizaciones, relacionadas con la predicción de sucursal u otras.
a menudo ... y afirman que los programadores generalmente pueden predecir a dónde podría ir una sucursal
(*) Los programadores experimentados a menudo recuerdan que los programadores humanos son muy malos para predecir eso.
1- ¿Es posible evitar errores de predicción de ramas usando alguna técnica de programación de alto nivel (es decir, sin ensamblaje)?
No en c ++ estándar o c. Al menos no para una sola sucursal. Lo que puede hacer es minimizar la profundidad de sus cadenas de dependencia para que la predicción equivocada de la sucursal no tenga ningún efecto. La CPU moderna ejecutará ambas rutas de código de una rama y descartará la que no fue elegida. Sin embargo, hay un límite en esto, por lo que la predicción de ramas solo importa en cadenas de dependencia profundas.
Algunos compiladores proporcionan una extensión para sugerir la predicción manualmente, como __builtin_expect in gcc. Aquí hay una pregunta de sobre esto. Aún mejor, algunos compiladores (como gcc) admiten el perfilado del código y detectan automáticamente las predicciones óptimas. Es inteligente usar perfiles en lugar de trabajo manual debido a (*).
2- ¿Qué debo tener en cuenta para producir código compatible con las sucursales en un lenguaje de programación de alto nivel (estoy interesado principalmente en C y C ++)?
En primer lugar, debe tener en cuenta que la predicción errónea de una sucursal solo le afectará en la parte más crítica del rendimiento de su programa y no debe preocuparse hasta que haya medido y encontrado un problema.
Pero, ¿qué puedo hacer cuando un generador de perfiles (valgrind, VTune, ...) dice que en la línea n de foo.cpp recibí una penalización de predicción de bifurcación?
Lundin dio un consejo muy sensato
- Mida para averiguar si es importante.
- Si es importante, entonces
- Minimice la profundidad de las cadenas de dependencia de sus cálculos. Cómo hacerlo puede ser bastante complicado y más allá de mi experiencia, y no hay mucho que puedas hacer sin sumergirte en el ensamblaje. Lo que puede hacer en un lenguaje de alto nivel es minimizar el número de controles condicionales (**). De lo contrario, estará a merced de la optimización del compilador. Evitar cadenas de dependencia profundas también permite un uso más eficiente de procesadores superescalares fuera de orden.
- Haga que sus ramas sean consistentemente predecibles. El efecto de eso se puede ver en esta pregunta de . En la pregunta, hay un ciclo sobre una matriz. El bucle contiene una rama. La rama depende del tamaño del elemento actual. Cuando se ordenaron los datos, se pudo demostrar que el bucle es mucho más rápido cuando se compila con un compilador particular y se ejecuta en una CPU en particular. Por supuesto, mantener todos los datos ordenados también costará el tiempo de CPU, posiblemente más que las predicciones erróneas de rama, por lo tanto, mida .
- Si todavía es un problema, use la optimización guiada por perfil (si está disponible).
Orden de 2. y 3. puede ser cambiado. Optimizar tu código a mano es mucho trabajo. Por otro lado, recopilar los datos del perfil también puede ser difícil para algunos programas.
(**) Una forma de hacerlo es transformar sus bucles, por ejemplo, desenrollándolos. También puede dejar que el optimizador lo haga automáticamente. Sin embargo, debe medir, porque desenrollar afectará la forma en que interactúa con la memoria caché y bien puede terminar siendo una pesimismo.
Branchless no siempre es mejor, incluso si ambos lados de la rama son triviales. Cuando la predicción de bifurcación funciona, es más rápida que una dependencia de datos transportada por bucle .
Ver indicador de optimización de gcc -O3 hace que el código sea más lento que -O2 para un caso en el que gcc -O3
transforma un código if()
en un caso sin sucursales en el que es muy predecible, lo que lo hace más lento.
A veces confías en que una condición es impredecible (por ejemplo, en un algoritmo de ordenación o búsqueda binaria). O le importa más que el peor de los casos no sea 10 veces más lento que el de que el caso rápido sea 1.5 veces más rápido.
Es más probable que algunos modismos se compilen en una forma sin cmov
(como una instrucción de movimiento condicional cmov
x86).
x = x>limit ? limit : x; // likely to compile branchless
if (x>limit) x=limit; // less likely to compile branchless, but still can
La primera forma siempre escribe en x
, mientras que la segunda forma no modifica x
en una de las ramas. Esta parece ser la razón por la que algunos compiladores tienden a emitir una rama en lugar de un cmov
para la versión if
. Esto se aplica incluso cuando x
es una variable int
local que ya está en vivo en un registro, por lo que "escribir" no implica almacenar en la memoria, simplemente cambiar el valor en un registro.
Los compiladores aún pueden hacer lo que quieran, pero he descubierto que esta diferencia en la expresión idiomática puede marcar la diferencia. Dependiendo de lo que esté probando, ocasionalmente es mejor ayudar al compilador a enmascarar y a Y en lugar de hacer un viejo cmov
. Lo hice en esa respuesta porque sabía que el compilador tendría lo que necesitaba para generar la máscara con una sola instrucción (y al ver cómo lo hacía clang).
TODO: ejemplos en http://gcc.godbolt.org/
Como advertencia, no soy un asistente de micro-optimización. No sé exactamente cómo funciona el predictor de ramas de hardware. Para mí es una bestia mágica contra la que juego tijeras-papel-piedra y parece ser capaz de leer mi mente y vencerme todo el tiempo. Soy un tipo de diseño y arquitectura.
Sin embargo, como esta pregunta era sobre una mentalidad de alto nivel, podría contribuir con algunos consejos.
Perfilado
Como dije, no soy un asistente de arquitectura de computadora, pero sí sé cómo crear un código de perfil con VTune y medir cosas como las errores de predicción de sucursales y las fallas de caché y hacerlo todo el tiempo en un campo crítico para el rendimiento. Esa es la primera cosa que debería considerar si no sabe cómo hacerlo (creación de perfiles). La mayoría de estos hotspots de micro nivel se descubre mejor en retrospectiva con un generador de perfiles en la mano.
Eliminación de ramas
Mucha gente brinda excelentes consejos de bajo nivel sobre cómo mejorar la predictibilidad de sus sucursales. Incluso puede intentar ayudar manualmente al predictor de bifurcación en algunos casos y también optimizar para la predicción de bifurcación estática (escribir declaraciones if
para comprobar primero los casos comunes, por ejemplo). Aquí hay un artículo completo sobre los detalles esenciales de Intel: https://software.intel.com/en-us/articles/branch-and-loop-reorganization-to-prevent-mispredicts .
Sin embargo, hacer esto más allá de un caso común básico / anticipación de caso raro es muy difícil de hacer y casi siempre es mejor guardarlo para después después de medirlo. Es demasiado difícil para los humanos poder predecir con precisión la naturaleza del pronosticador de bifurcación. Es mucho más difícil de predecir que cosas como errores de página y errores de caché, e incluso esos son casi imposibles de predecir humanamente en una base de código compleja.
Sin embargo, existe una manera más fácil y de alto nivel de mitigar la propagación errónea de las ramas, y eso es para evitar la bifurcación por completo.
Omitir trabajo pequeño / raro
Uno de los errores que solía cometer al principio de mi carrera y ver a muchos compañeros tratando de hacer cuando están comenzando, antes de que hayan aprendido a perfilarse y sigan teniendo corazonadas, es tratar de omitir el trabajo pequeño o raro. .
Un ejemplo de esto es la memorización en una gran tabla de búsqueda para evitar repetidamente realizar cálculos relativamente baratos, como utilizar una tabla de búsqueda que abarca megabytes para evitar llamar repetidamente a cos
y sin
. Para un cerebro humano, parece que es trabajo de ahorro computarlo una vez y almacenarlo, excepto que a menudo cargar la memoria de esta LUT gigante a través de la jerarquía de la memoria y en un registro a menudo resulta ser incluso más costosa que los cálculos que se pretendían ahorrar.
Otro caso es agregar un grupo de pequeñas ramas para evitar pequeños cálculos que son inofensivos hacer innecesariamente (no afectarán la corrección) en todo el código como un ingenuo intento de optimización, solo para descubrir que la ramificación cuesta más que solo hacer cálculos innecesarios.
Este ingenuo intento de ramificación como optimización también puede aplicarse incluso para trabajos un poco caros pero poco comunes. Toma este ejemplo de C ++:
struct Foo
{
...
Foo& operator=(const Foo& other)
{
// Avoid unnecessary self-assignment.
if (this != &other)
{
...
}
return *this;
}
...
};
Tenga en cuenta que esto es algo así como un ejemplo simplista / ilustrativo ya que la mayoría de las personas implementan la asignación de copia utilizando copy-and-swap contra un parámetro pasado por valor y evitan la bifurcación de todos modos sin importar qué.
En este caso, estamos ramificando para evitar la autoasignación. Sin embargo, si la autoasignación solo está haciendo un trabajo redundante y no obstaculiza la corrección del resultado, a menudo puede darle un impulso en el rendimiento del mundo real para permitir simplemente la autocopia:
struct Foo
{
...
Foo& operator=(const Foo& other)
{
// Don''t check for self-assignment.
...
return *this;
}
...
};
... esto puede ayudar porque la autoasignación tiende a ser bastante rara. Estamos ralentizando el raro caso al autoasignarnos de forma redundante, pero estamos acelerando el caso común al evitar la necesidad de verificar en todos los demás casos. Por supuesto, es poco probable que reduzca las predicciones erróneas de las ramas de manera significativa, ya que existe un sesgo de caso común / raro en términos de ramificación, pero bueno, una rama que no existe no puede ser mal interpretada.
Un ingenuo intento en un pequeño vector
Como historia personal, anteriormente trabajaba en una base de código de C a gran escala que a menudo tenía muchos códigos como este:
char str[256];
// do stuff with ''str''
... y, naturalmente, dado que teníamos una base de usuarios bastante amplia, algún usuario raro podría escribir un nombre para un material en nuestro software de más de 255 caracteres de longitud y desbordar el búfer, lo que llevaría a segfaults. Nuestro equipo entraba en C ++ y comenzó a portar muchos de estos archivos fuente a C ++ y a reemplazar dicho código con esto:
std::string str = ...;
// do stuff with ''str''
... que eliminó esos desbordamientos de búfer sin mucho esfuerzo. Sin embargo, al menos en aquel entonces, los contenedores como std::string
y std::vector
eran estructuras asignadas de almacenamiento en librerías (heap), y nos encontramos comerciando la corrección / seguridad para la eficiencia. Algunas de estas áreas reemplazadas fueron de rendimiento crítico (llamadas en bucles estrechos), y aunque eliminamos muchos informes de errores con estos reemplazos masivos, los usuarios comenzaron a notar las ralentizaciones.
Entonces, queríamos algo que fuera como un híbrido entre estas dos técnicas. Queríamos poder introducir algo allí para lograr la seguridad sobre las variantes de búfer fijo de estilo C (que eran perfectamente correctas y muy eficientes para escenarios de caso común), pero aún así funcionan para los casos excepcionales donde el buffer no estaba No es lo suficientemente grande para las entradas del usuario. Fui uno de los geeks de rendimiento en el equipo y uno de los pocos que usaba un generador de perfiles (desafortunadamente trabajé con mucha gente que pensaba que era demasiado inteligente para usar uno), así que me llamaron a la tarea.
Mi primer intento ingenuo fue algo como esto (enormemente simplificado: el real usó la colocación nueva y así sucesivamente y era una secuencia totalmente compatible). Implica el uso de un búfer de tamaño fijo (tamaño especificado en tiempo de compilación) para el caso común y uno asignado dinámicamente si el tamaño excede esa capacidad.
template <class T, int N>
class SmallVector
{
public:
...
T& operator[](int n)
{
return num < N ? buf[n]: ptr[n];
}
...
private:
T buf[N];
T* ptr;
};
Este intento fue un fracaso total. Si bien no se pagó el precio de la tienda heap / free para construir, el operator[]
bifurcación operator[]
hizo aún peor que std::string
y std::vector<char>
y se mostraba como un punto de acceso de perfil en lugar de malloc
(nuestra implementación de proveedor de std::allocator
y operator new
utiliza malloc
bajo el capó). Entonces, rápidamente tuve la idea de simplemente asignar ptr
a buf
en el constructor. Ahora ptr
apunta a buf
incluso en el caso común, y ahora el operator[]
se puede implementar así:
T& operator[](int n)
{
return ptr[n];
}
... y con esa simple eliminación de rama, nuestros puntos de conexión desaparecieron. Ahora teníamos un contenedor de propósito general, compatible con el estándar que podíamos usar que era casi tan rápido como la antigua solución de buffer fijo de estilo C (la única diferencia es un puntero adicional y unas pocas instrucciones más en el constructor), pero podría manejar esos casos excepcionales en los que el tamaño debe ser mayor que N
Ahora usamos esto incluso más que std::vector
(pero solo porque nuestros casos de uso favorecen a un grupo de pequeños, temporales, contiguos, contenedores de acceso aleatorio). Y hacerlo rápido se redujo a simplemente eliminar una rama en el operator[]
.
Caso común / caso raro sesgamiento
Una de las cosas aprendidas después de la creación de perfiles y la optimización durante años es que no existe el código "absolutamente rápido en todas partes" . Mucho del acto de optimización es negociar una ineficiencia allí para una mayor eficiencia aquí. Los usuarios pueden percibir su código como absolutamente rápido en todas partes , pero eso proviene de compensaciones inteligentes donde las optimizaciones se alinean con el caso común (el caso común está alineado con escenarios de usuario realistas y proviene de puntos de acceso puntuales de un generador de perfiles que mide esos escenarios comunes).
Las cosas buenas tienden a suceder cuando sesga el rendimiento hacia el caso común y lejos del caso raro. Para que el caso común sea más rápido, a menudo el caso raro debe ser más lento, pero eso es algo bueno.
Costo por excepción de costos cero
Un ejemplo de sesgo común / caso raro es la técnica de manejo de excepciones utilizada en muchos compiladores modernos. Aplican EH de costo cero, que no es realmente de "costo cero" en general. En el caso de que se produzca una excepción, ahora son más lentos que nunca. Sin embargo, en el caso donde no se lanza una excepción, ahora son más rápidos que nunca antes y, a menudo, más rápidos en escenarios exitosos que un código como este:
if (!try_something())
return error;
if (!try_something_else())
return error;
...
Cuando usamos EH de costo cero aquí en su lugar y evitamos verificar y propagar errores manualmente, las cosas tienden a ir aún más rápido en los casos no excepcionales que este estilo de código anterior. En términos generales, se debe a la ramificación reducida. Sin embargo, a cambio, algo mucho más costoso tiene que suceder cuando se lanza una excepción. Sin embargo, esa inclinación entre el caso común y el caso raro tiende a ayudar a los escenarios del mundo real. No nos importa tanto la velocidad de no cargar un archivo (caso raro) como si lo cargamos con éxito (caso común), y es por eso que muchos modernos compiladores de C ++ implementan EH de "costo cero". Otra vez es por el interés de sesgar el caso común y el caso raro, alejándolos de cada uno en términos de rendimiento.
Despacho virtual y homogeneidad
Una gran cantidad de ramificaciones en código orientado a objetos donde las dependencias fluyen hacia abstracciones (principio de abstracciones estables, por ejemplo), puede tener una gran parte de su ramificación (además de bucles, por supuesto, que se adaptan bien al predictor de bifurcación) en forma de dinámica despacho (llamadas de función virtual o llamadas de puntero a función).
En estos casos, una tentación común es agregar todo tipo de subtipos en un contenedor polimórfico que almacene un puntero base, recorriendo y llamando a métodos virtuales en cada elemento de ese contenedor. Esto puede llevar a una gran cantidad de errores de predicción de ramas, especialmente si este contenedor se actualiza todo el tiempo. El pseudocódigo podría verse así:
for each entity in world:
entity.do_something() // virtual call
Una estrategia para evitar este escenario es comenzar a clasificar este contenedor polimórfico en función de sus subtipos. Esta es una optimización bastante antigua popular en la industria del juego. No sé qué tan útil es hoy, pero es un tipo de optimización de alto nivel.
Otra forma en la que definitivamente me resulta útil incluso en casos recientes que logran un efecto similar es separar el contenedor polimórfico en varios contenedores para cada subtipo, lo que lleva a un código como este:
for each human in world.humans():
human.do_something()
for each orc in world.orcs():
orc.do_something()
for each creature in world.creatures():
creature.do_something()
... naturalmente, esto dificulta el mantenimiento del código y reduce la extensibilidad. Sin embargo, no tiene que hacer esto para cada subtipo en este mundo. Solo tenemos que hacerlo por el más común. Por ejemplo, este videojuego imaginario podría consistir, por mucho, en humanos y orcos. También podría tener hadas, duendes, trolls, elfos, gnomos, etc., pero es posible que no sean tan comunes como los humanos y los orcos. Entonces solo necesitamos dividir a los humanos y los orcos del resto. Si puede pagarlo, también puede tener un contenedor polimórfico que almacene todos estos subtipos que podemos utilizar para obtener menos bucles de rendimiento crítico. Esto es algo parecido a la división caliente / fría para optimizar la localidad de referencia.
Optimización orientada a datos
La optimización para la predicción de bifurcación y la optimización de los diseños de memoria tiende a confundirse. Raramente he intentado optimizaciones específicamente para el pronosticador de bifurcaciones, y eso fue solo después de agotar todo lo demás. Sin embargo, he descubierto que concentrarme mucho en la memoria y la localidad de referencia hizo que mis mediciones resultaran en menos errores de predicción de ramas (a menudo sin saber exactamente por qué).
Aquí puede ayudar estudiar el diseño orientado a datos. He descubierto que algunos de los conocimientos más útiles relacionados con la optimización provienen del estudio de la optimización de la memoria en el contexto del diseño orientado a datos. El diseño orientado a los datos tiende a enfatizar menos abstracciones (si las hay) y las interfaces de alto nivel más voluminosas que procesan grandes cantidades de datos. Por naturaleza, tales diseños tienden a reducir la cantidad de ramificaciones dispares y saltar en el código con más código loopy procesando grandes trozos de datos homogéneos.
A menudo ayuda, incluso si su objetivo es reducir la predicción errónea de las ramas, centrarse más en consumir datos más rápidamente. He encontrado algunos grandes avances antes SIMDS sin sucursales, por ejemplo, pero la mentalidad todavía estaba en la línea de consumir datos más rápidamente (lo que hizo, y gracias a algo de ayuda de aquí en TAN como Harold).
TL; DR
Entonces, de todos modos, estas son algunas estrategias para reducir potencialmente las fallas de predicción de sucursales a lo largo de su código desde un punto de vista de alto nivel. Están desprovistos del más alto nivel de experiencia en arquitectura informática, pero espero que este sea un tipo apropiado de respuesta útil dado el nivel de la pregunta que se hace. Muchos de estos consejos son un poco borrosos con la optimización en general, pero he descubierto que la optimización para la predicción de ramas a menudo necesita ser borrosa con la optimización más allá (memoria, paralelización, vectorización, algorítmica). En cualquier caso, la apuesta más segura es asegurarse de tener un perfilador en la mano antes de aventurarse a fondo.
El kernel de Linux define macros likely
e unlikely
basadas en __builtin_expect
gcc builtins:
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
(Consulte here las definiciones de macros en include/linux/compiler.h
)
Puedes usarlos como:
if (likely(a > 42)) {
/* ... */
}
o
if (unlikely(ret_value < 0)) {
/* ... */
}
En general, es una buena idea mantener los bucles internos calientes bien proporcionados a los tamaños de caché más comunes. Es decir, si su programa maneja los datos en trozos de, por ejemplo, menos de 32kbytes a la vez y realiza una buena cantidad de trabajo, entonces está haciendo un buen uso de la memoria caché L1.
Por el contrario, si su bucle interno caliente mastica 100MByte de datos y realiza solo una operación en cada elemento de datos, entonces la CPU pasará la mayor parte del tiempo buscando datos de DRAM.
Esto es importante porque, en primer lugar, parte de la razón por la que las CPU tienen predicción de bifurcación es poder precargar los operandos para la próxima instrucción. Las consecuencias de rendimiento de una predicción equivocada de una sucursal pueden reducirse organizando su código de manera que haya una buena posibilidad de que los datos siguientes provengan de la memoria caché L1, sin importar qué sucursal se tome. Aunque no es una estrategia perfecta, los tamaños de caché L1 parecen estar universalmente trabajados en 32 o 64K; es casi una constante en toda la industria. Ciertamente, la codificación de esta manera no suele ser sencilla, y depender de la optimización impulsada por el perfil, etc., como recomiendan otros, es probablemente el camino más directo.
Independientemente de cualquier otra cosa, si un problema con la predicción errónea de bifurcación ocurrirá o no variará según los tamaños de caché de la CPU, qué más se está ejecutando en la máquina, cuál es el ancho de banda / latencia de la memoria principal, etc.
Para responder a sus preguntas, permítame explicar cómo funciona la predicción de ramas.
En primer lugar, hay una penalización de rama cuando el procesador predice correctamente las ramas tomadas . Si el procesador predice una rama como tomada, entonces debe conocer el objetivo de la bifurcación pronosticada, ya que el flujo de ejecución continuará desde esa dirección. Suponiendo que la dirección de destino de la sucursal ya está almacenada en el búfer de destino de sucursal (BTB), tiene que obtener nuevas instrucciones de la dirección que se encuentra en BTB. Por lo tanto, aún está perdiendo algunos ciclos de reloj, incluso si la derivación se predijo correctamente.
Como BTB tiene una estructura de caché asociativa, la dirección de destino podría no estar presente y, por lo tanto, podrían desperdiciarse más ciclos de reloj.
Por otro lado, si la CPU predice que una rama no se toma y si es correcta, entonces no hay penalización, ya que la CPU ya sabe dónde están las instrucciones consecutivas.
Como expliqué anteriormente, las ramas predichas que no se toman tienen un rendimiento mayor que las ramas tomadas predichas .
¿Es posible evitar la predicción errónea de las ramas utilizando alguna técnica de programación de alto nivel (es decir, sin ensamblaje)?
Sí, es posible. Puede evitar organizando su código de forma tal que todas las ramas tengan un patrón de ramas repetitivo tal que siempre se tome o no se tome.
Pero si desea obtener un mayor rendimiento, debe organizar las sucursales de forma tal que es más probable que no se tomen como expliqué anteriormente.
¿Qué debería tener en cuenta para producir código compatible con las sucursales en un lenguaje de programación de alto nivel (estoy interesado principalmente en C y C ++)?
Si es posible eliminar ramas como sea posible. Si este no es el caso al escribir declaraciones if-else o switch, primero verifique los casos más comunes para asegurarse de que las ramas no se tomen con mayor probabilidad. Intente utilizar la función _ _builtin_expect(condition, 1)
para forzar al compilador a producir una condición que se tratará como no tomada.
Tal vez las técnicas más comunes es usar métodos separados para retornos normales y de error. C no tiene otra opción, pero C ++ tiene excepciones. Los compiladores saben que las ramas de excepción son excepcionales y, por lo tanto, inesperadas.
Esto significa que las ramas de excepción son de hecho lentas, ya que son impredecibles, pero la rama sin errores se hace más rápido. En promedio, esto es una ganancia neta.