¿Cuándo importan los costos de las llamadas de función en los compiladores modernos?


95

Soy una persona religiosa y hago esfuerzos para no cometer pecados. Es por eso que tiendo a escribir funciones pequeñas ( más pequeñas que eso , para reformular a Robert C. Martin) para cumplir con los diversos mandamientos ordenados por la Biblia Clean Code . Pero mientras revisaba algunas cosas, aterricé en esta publicación , debajo de la cual leí este comentario:

Recuerde que el costo de una llamada al método puede ser significativo, dependiendo del idioma. Casi siempre hay una compensación entre escribir código legible y escribir código de rendimiento.

¿En qué condiciones esta declaración citada sigue siendo válida hoy en día dada la rica industria de los compiladores modernos de alto rendimiento?

Esa es mi única pregunta. Y no se trata de si debo escribir funciones largas o pequeñas. Solo resalto que sus comentarios pueden, o no, contribuir a alterar mi actitud y dejarme incapaz de resistir la tentación de los blasfemos .


11
Escribir código legible y mantenible. Solo cuando te enfrentas a un problema con el desbordamiento de la pila puedes repensar tu enfoque
Fabio

33
Una respuesta general aquí es imposible. Hay demasiados compiladores diferentes, implementando demasiadas especificaciones de lenguaje diferentes. Y luego están los lenguajes compilados JIT, los idiomas interpretados dinámicamente, etc. Sin embargo, basta con decir que si está compilando código nativo C o C ++ con un compilador moderno, no tiene que preocuparse por los costos de una llamada de función. El optimizador los incluirá siempre que sea apropiado. Como entusiasta de la microoptimización, rara vez veo compiladores que toman decisiones en línea con las que yo o mis puntos de referencia no estamos de acuerdo.
Cody Gray

66
Hablando por experiencia personal, escribo código en un lenguaje propietario que es bastante moderno en términos de capacidad, pero las llamadas a funciones son ridículamente caras, hasta el punto en que incluso lo típico para los bucles tiene que optimizarse para la velocidad: en for(Integer index = 0, size = someList.size(); index < size; index++)lugar de simplemente for(Integer index = 0; index < someList.size(); index++). El hecho de que su compilador se haya creado en los últimos años no significa necesariamente que pueda renunciar a la creación de perfiles.
phyrfox

55
@phyrfox que tiene sentido, obteniendo el valor de someList.size () fuera del ciclo en lugar de llamarlo cada vez a través del ciclo. Eso es especialmente cierto si hay alguna posibilidad de un problema de sincronización en el que los lectores y escritores puedan intentar chocar durante la iteración, en cuyo caso también querrá proteger la lista contra cualquier cambio durante la iteración.
Craig

8
Tenga cuidado de llevar las funciones pequeñas demasiado lejos, puede ofuscar el código tan eficientemente como lo hace una megafunción monolítica. Si no me cree, vea algunos de los ganadores de ioccc.org : algunos codifican todo en uno solo main(), otros dividen todo en unas 50 pequeñas funciones y todos son completamente ilegibles. El truco es, como siempre, lograr un buen equilibrio .
cmaster

Respuestas:


148

Depende de tu dominio.

Si está escribiendo código para un microcontrolador de baja potencia, el costo de la llamada al método puede ser significativo. Pero si está creando un sitio web o aplicación normal, el costo de la llamada al método será insignificante en comparación con el resto del código. En ese caso, siempre valdrá la pena centrarse en algoritmos y estructuras de datos correctos en lugar de micro optimizaciones como las llamadas a métodos.

Y también hay una cuestión de que el compilador incluya los métodos para usted. La mayoría de los compiladores son lo suficientemente inteligentes como para incluir funciones en línea donde es posible.

Y por último, hay una regla de oro de rendimiento: SIEMPRE PERFIL PRIMERO. No escriba código "optimizado" basado en suposiciones. Si no está seguro, escriba ambos casos y vea cuál es mejor.


13
Y, por ejemplo, el compilador HotSpot performes Inlining especulativa , que es, en cierto sentido procesos en línea, incluso cuando es no es posible.
Jörg W Mittag

49
De hecho, en una aplicación web, todo el código es probablemente insignificante en relación con el acceso a la base de datos y el tráfico de la red ...
AnoE

72
De hecho, estoy en potencia incrustada y de muy bajo consumo con un compilador muy antiguo que apenas sabe lo que significa la optimización, y créanme a pesar de que la función es importante, nunca es el primer lugar para buscar la optimización. Incluso en este dominio de nicho, la calidad del código es lo primero en este caso.
Tim

2
@Mehrdad Incluso en este caso, me sorprendería si no hubiera nada más relevante para optimizar en el código. Al perfilar el código, veo cosas mucho más pesadas que las llamadas a funciones, y ahí es donde es importante buscar la optimización. Algunos desarrolladores se vuelven locos por uno o dos LOC no optimizados, pero cuando perfilas el SW te das cuenta de que el diseño es más importante que esto, al menos para la mayor parte del código. Cuando encuentre el cuello de botella, puede intentar optimizarlo, y tendrá mucho más impacto que la optimización arbitraria de bajo nivel, como escribir grandes funciones para evitar la sobrecarga de las llamadas.
Tim

8
¡Buena respuesta! Su último punto debe ser el primero: siempre haga un perfil antes de decidir dónde optimizar .
CJ Dennis

56

La sobrecarga de las llamadas de función depende completamente del idioma y en qué nivel está optimizando.

En un nivel ultra bajo, las llamadas a funciones y aún más, las llamadas a métodos virtuales pueden ser costosas si conducen a predicciones erróneas de rama o fallas en el caché de la CPU. Si ha escrito ensamblador , también sabrá que necesita algunas instrucciones adicionales para guardar y restaurar registros alrededor de una llamada. No es cierto que un compilador "suficientemente inteligente" pueda incorporar las funciones correctas para evitar esta sobrecarga, porque los compiladores están limitados por la semántica del lenguaje (especialmente en torno a características como el envío de métodos de interfaz o bibliotecas cargadas dinámicamente).

En un alto nivel, los lenguajes como Perl, Python, Ruby realizan una gran cantidad de contabilidad por llamada de función, lo que los hace relativamente costosos. Esto empeora con la metaprogramación. Una vez aceleré un software Python 3x simplemente levantando llamadas de funciones de un circuito muy activo. En el código de rendimiento crítico, las funciones auxiliares en línea pueden tener un efecto notable.

Pero la gran mayoría del software no es tan extremadamente crítico para el rendimiento como para que pueda notar la sobrecarga de las llamadas de función. En cualquier caso, escribir código limpio y simple vale la pena:

  • Si su código no es crítico para el rendimiento, esto facilita el mantenimiento. Incluso en el software de rendimiento crítico, la mayoría del código no será un "punto caliente".

  • Si su código es crítico para el rendimiento, un código simple facilita la comprensión del código y detecta oportunidades para la optimización. Las mayores ganancias generalmente no provienen de micro optimizaciones como las funciones de alineación, sino de mejoras algorítmicas. O redactado de manera diferente: no hagas lo mismo más rápido. Encuentra una manera de hacer menos.

Tenga en cuenta que "código simple" no significa "factorizado en mil funciones pequeñas". Cada función también introduce un poco de sobrecarga cognitiva: es más difícil razonar sobre un código más abstracto. En algún momento, estas pequeñas funciones podrían hacer tan poco que no usarlas simplificaría su código.


16
Un DBA realmente inteligente me dijo una vez "Normalizar hasta que duela, luego desnormalizar hasta que no duela". Me parece que podría reformularse a "Extraer métodos hasta que duela, luego en línea hasta que no duela".
RubberDuck

1
Además de la sobrecarga cognitiva, existe una sobrecarga simbólica en la información del depurador, y generalmente la sobrecarga en los binarios finales es inevitable.
Frank Hileman

Con respecto a los compiladores inteligentes, PUEDEN hacer eso, pero no siempre. Por ejemplo, jvm puede alinear cosas basadas en el perfil de tiempo de ejecución con una trampa muy barata / libre para una ruta poco común o una función polimórfica en línea para la que solo hay una implementación del método / interfaz dados y luego desoptimizar esa llamada a polimórficos correctamente cuando se carga dinámicamente una nueva subclase tiempo de ejecución Pero sí, hay muchos idiomas donde tales cosas no son posibles y muchos casos incluso en jvm, cuando no es rentable o posible en el caso general.
Artur Biesiadowski

19

Casi todos los adagios sobre el código de ajuste para el rendimiento son casos especiales de la ley de Amdahl . La breve y humorística declaración de la ley de Amdahl es

Si una parte de su programa toma el 5% del tiempo de ejecución, y optimiza esa parte para que ahora tome el cero por ciento del tiempo de ejecución, el programa en su conjunto solo será un 5% más rápido.

(La optimización de las cosas hasta el cero por ciento del tiempo de ejecución es totalmente posible: cuando se sienta para optimizar un programa grande y complicado, es muy probable que descubra que está gastando al menos parte de su tiempo de ejecución en cosas que no necesita hacer en absoluto .)

Esta es la razón por la cual la gente normalmente dice que no se preocupe por los costos de las llamadas de función: no importa cuán costosos sean, normalmente el programa en su conjunto solo gasta una pequeña fracción de su tiempo de ejecución en la carga de llamadas, por lo que acelerarlos no ayuda mucho .

Pero, si hay un truco que puede hacer que haga que todas las llamadas a funciones sean más rápidas, ese truco probablemente valga la pena. Los desarrolladores de compiladores pasan mucho tiempo optimizando las funciones "prólogos" y "epílogos", porque eso beneficia a todos los programas compilados con ese compilador, incluso si es solo un poquito para cada uno.

Y, si tiene razones para creer que un programa está gastando gran parte de su tiempo de ejecución solo haciendo llamadas a funciones, entonces debe comenzar a pensar si algunas de esas llamadas a funciones son innecesarias. Aquí hay algunas reglas generales para saber cuándo debe hacer esto:

  • Si el tiempo de ejecución por invocación de una función es inferior a un milisegundo, pero esa función se llama cientos de miles de veces, probablemente debería estar en línea.

  • Si un perfil del programa muestra miles de funciones, y ninguna de ellas requiere más del 0.1% de tiempo de ejecución, entonces la sobrecarga de llamadas a funciones probablemente sea significativa en conjunto.

  • Si tiene un " código de lasaña " , en el que hay muchas capas de abstracción que apenas funcionan más allá del envío a la siguiente capa, y todas estas capas se implementan con llamadas a métodos virtuales, entonces hay una buena probabilidad de que la CPU esté desperdiciando un mucho tiempo en puestos de ductos indirectos. Desafortunadamente, la única cura para esto es deshacerse de algunas capas, que a menudo es muy difícil.


77
Solo ten cuidado con las cosas costosas que se hacen en bucles anidados. Optimicé una función y obtuve un código que funciona 10 veces más rápido. Eso fue después de que el perfilador señaló al culpable. (Se llamó una y otra vez, en bucles desde O (n ^ 3) a un pequeño n O (n ^ 6).)
Loren Pechtel

"Desafortunadamente, la única cura para esto es deshacerse de algunas capas, que a menudo es muy difícil". - Esto depende mucho de su compilador de idioma y / o tecnología de máquina virtual. Si puede modificar el código para que sea más fácil para el compilador en línea (por ejemplo, utilizando finalclases y métodos cuando corresponda en Java, o no virtualmétodos en C # o C ++), entonces el compilador / tiempo de ejecución puede eliminar la indirección y usted ' Veré una ganancia sin una reestructuración masiva. Como @JorgWMittag señala anteriormente, la JVM puede incluso alinearse en casos en los que no es comprobable que la optimización sea ...
Jules

... válido, por lo que bien puede ser que lo esté haciendo en su código a pesar de las capas de todos modos.
Jules

@Jules Si bien es cierto que los compiladores JIT pueden realizar una optimización especulativa, no significa que tales optimizaciones se apliquen de manera uniforme. Específicamente con respecto a Java, mi experiencia es que la cultura del desarrollador favorece las capas apiladas sobre capas que conducen a pilas de llamadas extremadamente profundas. Como anécdota, eso contribuye a la sensación lenta e hinchada de muchas aplicaciones Java. Dicha arquitectura altamente estratificada funciona contra el tiempo de ejecución JIT, independientemente de si las capas son técnicamente en línea. JIT no es una bala mágica que puede solucionar automáticamente problemas estructurales.
amon

@amon Mi experiencia con el "código de lasaña" proviene de aplicaciones C ++ muy grandes con una gran cantidad de código que data de la década de 1990, cuando las jerarquías de objetos profundamente anidados y COM eran la moda. Los compiladores de C ++ hacen esfuerzos heroicos para eliminar las penalizaciones de abstracción en programas como este, y aún así puede verlos gastando una fracción significativa del tiempo de ejecución del reloj de pared en puestos de canalización indirectos (y otra porción significativa de errores de caché I) .
zwol

17

Desafiaré esta cita:

Casi siempre hay una compensación entre escribir código legible y escribir código de rendimiento.

Esta es una declaración realmente engañosa y una actitud potencialmente peligrosa. Hay algunos casos específicos en los que tiene que hacer una compensación, pero en general los dos factores son independientes.

Un ejemplo de una compensación necesaria es cuando tienes un algoritmo simple versus uno más complejo pero más eficiente. Una implementación de tabla hash es claramente más compleja que una implementación de lista vinculada, pero la búsqueda será más lenta, por lo que es posible que tenga que cambiar la simplicidad (que es un factor de legibilidad) por el rendimiento.

Con respecto a la sobrecarga de llamadas de función, convertir un algoritmo recursivo en un iterativo podría tener un beneficio significativo dependiendo del algoritmo y el idioma. Pero este es nuevamente un escenario muy específico y, en general, la sobrecarga de las llamadas a funciones será insignificante u optimizada.

(Algunos lenguajes dinámicos como Python tienen una sobrecarga significativa de llamadas a métodos. Pero si el rendimiento se convierte en un problema, probablemente no debería usar Python en primer lugar).

La mayoría de los principios para el código legible: el formato consistente, los nombres de identificadores significativos, los comentarios apropiados y útiles, etc., no tienen ningún efecto en el rendimiento. Y algunos, como el uso de enumeraciones en lugar de cadenas, también tienen beneficios de rendimiento.


5

La sobrecarga de la llamada de función no es importante en la mayoría de los casos.

Sin embargo, la mayor ganancia del código de línea es optimizar el nuevo código después de la línea .

Por ejemplo, si llama a una función con un argumento constante, el optimizador ahora puede doblar ese argumento donde no podía antes de incluir la llamada. Si el argumento es un puntero de función (o lambda), el optimizador ahora también puede alinear las llamadas a ese lambda.

Esta es una gran razón por la cual las funciones virtuales y los punteros de función no son atractivos, ya que no puede alinearlos en absoluto a menos que el puntero de función real se haya plegado constantemente hasta el sitio de la llamada.


5

Asumiendo que el rendimiento es importante para su programa, y ​​de hecho tiene muchas llamadas, el costo aún puede o no importar dependiendo del tipo de llamada que sea.

Si la función llamada es pequeña y el compilador puede incorporarla, el costo será esencialmente cero. Las implementaciones modernas de compiladores / lenguaje tienen JIT, optimizaciones de tiempo de enlace y / o sistemas de módulos diseñados para maximizar la capacidad de las funciones en línea cuando es beneficioso.

OTOH, hay un costo no obvio para las llamadas de función: su mera existencia puede inhibir las optimizaciones del compilador antes y después de la llamada.

Si el compilador no puede razonar sobre lo que hace la función llamada (p. Ej., Es un despacho virtual / dinámico o una función en una biblioteca dinámica), entonces puede tener que asumir pesimistamente que la función podría tener algún efecto secundario: lanzar una excepción, modificar estado global, o cambiar cualquier memoria vista a través de punteros. Es posible que el compilador tenga que guardar valores temporales en la memoria de respaldo y volver a leerlos después de la llamada. No podrá reordenar las instrucciones alrededor de la llamada, por lo que es posible que no pueda vectorizar bucles ni levantar cálculos redundantes de los bucles.

Por ejemplo, si invoca innecesariamente una función en cada iteración de bucle:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

El compilador puede saber que es una función pura, y sacarla del ciclo (en un caso terrible como este ejemplo, incluso corrige el algoritmo accidental O (n ^ 2) para que sea O (n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

Y luego tal vez incluso reescriba el bucle para procesar elementos 4/8/16 a la vez utilizando instrucciones de ancho / SIMD.

Pero si agrega una llamada a algún código opaco en el bucle, incluso si la llamada no hace nada y es súper barata, el compilador debe asumir lo peor: que la llamada accederá a una variable global que apunta a la misma memoria que el scambio su contenido (incluso si está consten su función, puede estar en otro constlugar), lo que hace imposible la optimización:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}

3

Este viejo documento podría responder a su pregunta:

Guy Lewis Steele, Jr .. "Desacreditando el mito de la 'Llamada de procedimiento costosa', o, Implementaciones de llamadas de procedimiento consideradas dañinas, o, Lambda: The Ultimate GOTO". MIT AI Lab. Nota de laboratorio AI AIM-443. Octubre de 1977.

Resumen:

El folklore afirma que las declaraciones GOTO son "baratas", mientras que las llamadas a procedimientos son "caras". Este mito es en gran parte el resultado de implementaciones de lenguaje mal diseñadas. Se considera el crecimiento histórico de este mito. Se discuten ideas teóricas y una implementación existente que desacredita este mito. Está demostrado que el uso irrestricto de llamadas a procedimientos permite una gran libertad con estilo. En particular, cualquier diagrama de flujo puede escribirse como un programa "estructurado" sin introducir variables adicionales. La dificultad con la declaración GOTO y la llamada al procedimiento se caracteriza por un conflicto entre conceptos de programación abstractos y construcciones de lenguaje concretas.


12
Dudo mucho que un artículo tan antiguo responda a la pregunta de si "los costos de llamadas a funciones siguen siendo importantes en los compiladores modernos ".
Cody Gray

66
@CodyGray Creo que la tecnología del compilador debería haber avanzado desde 1977. Por lo tanto, si las llamadas a funciones pueden hacerse baratas en 1977, deberíamos poder hacerlo ahora. Por tanto, la respuesta es no. Por supuesto, esto supone que está utilizando una implementación de lenguaje decente que puede hacer cosas como la integración de funciones.
Alex Vong

44
@AlexVong Confiar en las optimizaciones del compilador de 1977 es como confiar en las tendencias de los precios de los productos básicos en la edad de piedra. Todo ha cambiado demasiado. Por ejemplo, la multiplicación solía ser reemplazada por el acceso a la memoria como una operación más barata. Actualmente, es más caro por un factor enorme. Las llamadas a métodos virtuales son relativamente mucho más caras de lo que solían ser (acceso a la memoria y predicciones erróneas de las ramas), pero a menudo pueden optimizarse y la llamada al método virtual puede incluso en línea (Java lo hace todo el tiempo), por lo que el costo es exactamente cero No había nada como esto en 1977.
maaartinus

3
Como otros han señalado, no son solo los cambios en la tecnología del compilador los que han invalidado las investigaciones antiguas. Si los compiladores hubieran continuado mejorando mientras las microarquitecturas hubieran permanecido en gran medida sin cambios, entonces las conclusiones del documento seguirían siendo válidas. Pero eso no sucedió. En todo caso, las microarquitecturas han cambiado más que los compiladores. Las cosas que solían ser rápidas ahora son lentas, relativamente hablando.
Cody Gray

2
@AlexVong Para ser más precisos sobre los cambios de CPU que hacen que el papel quede obsoleto: en 1977, un acceso a la memoria principal era un solo ciclo de CPU. Hoy, incluso un simple acceso al caché L1 (!) Tiene una latencia de 3 a 4 ciclos. Ahora, las llamadas a funciones son bastante pesadas en los accesos a la memoria (creación de marco de pila, guardado de la dirección de retorno, guardado de registros para variables locales), lo que fácilmente hace que los costos de una sola función funcionen a 20 y más ciclos. Si su función solo reorganiza sus argumentos, y quizás agrega otro argumento constante para pasar a una llamada, entonces eso es casi un 100% de gastos generales.
cmaster

3
  • En C ++, tenga cuidado con el diseño de llamadas a funciones que copian argumentos, el valor predeterminado es "pasar por valor". La sobrecarga de llamadas de función debido a guardar registros y otras cosas relacionadas con el marco de la pila puede verse abrumada por una copia no intencionada (y potencialmente muy costosa) de un objeto.

  • Hay optimizaciones relacionadas con el marco de pila que debe investigar antes de renunciar al código altamente factorizado.

  • La mayoría de las veces, cuando tuve que lidiar con un programa lento, descubrí que hacer cambios algorítmicos producía velocidades mucho mayores que las llamadas a funciones en línea. Por ejemplo: otro ingeniero rehizo un analizador que llenó una estructura de mapa de mapas. Como parte de eso, eliminó un índice almacenado en caché de un mapa a uno lógicamente asociado. Ese fue un buen movimiento de robustez del código, sin embargo, hizo que el programa fuera inutilizable debido a un factor de desaceleración de 100 debido a realizar una búsqueda hash para todos los accesos futuros en lugar de usar el índice almacenado. La creación de perfiles mostró que la mayor parte del tiempo se dedicaba a la función de hashing.


44
El primer consejo es un poco viejo. Desde C ++ 11, el movimiento ha sido posible. En particular, para las funciones que necesitan modificar sus argumentos internamente, tomar un argumento por valor y modificarlo en el lugar puede ser la opción más eficiente.
MSalters

@MSalters: Creo que confundiste "en particular" con "además" o algo así. La decisión de pasar copias o referencias estaba allí antes de C ++ 11 (aunque sé que lo sabes).
phresnel

@phresnel: Creo que lo hice bien. El caso particular al que me refiero es el caso en el que crea un temporal en la persona que llama, lo mueve a un argumento y luego lo modifica en la persona que llama. Esto no era posible antes de C ++ 11, ya que C ++ 03 no puede / no enlazará una referencia no constante a una temporal ...
MSalters

@MSalters: Entonces he entendido mal su comentario en la primera lectura. Me parecía que estabas insinuando que antes de C ++ 11, pasar por el valor no era algo que uno haría si quisiera modificar el valor pasado.
phresnel

El advenimiento del 'movimiento' ayuda más significativamente en el retorno de objetos que se construyen más convenientemente en la función que afuera y que se pasan por referencia. Antes de que devolver un objeto de una función invocara una copia, a menudo un movimiento costoso. Eso no trata con argumentos de función. Pongo cuidadosamente la palabra "diseño" en el comentario ya que uno debe dar explícitamente al compilador permiso para 'moverse' a argumentos de función (sintaxis &&). Tengo la costumbre de 'borrar' los constructores de copias para identificar los lugares donde hacerlo es valioso.
user2543191

2

Sí, una predicción de rama perdida es más costosa en el hardware moderno que hace décadas, pero los compiladores se han vuelto mucho más inteligentes para optimizar esto.

Como ejemplo, considere Java. A primera vista, la sobrecarga de llamadas a funciones debería ser particularmente dominante en este lenguaje:

  • las funciones pequeñas están muy extendidas debido a la convención JavaBean
  • funciones predeterminadas a virtuales, y generalmente son
  • la unidad de compilación es la clase; el tiempo de ejecución admite la carga de nuevas clases en cualquier momento, incluidas las subclases que anulan los métodos previamente monomórficos

Horrorizado por estas prácticas, el programador promedio de C predeciría que Java debe ser al menos un orden de magnitud más lento que C. Y hace 20 años hubiera tenido razón. Sin embargo, los puntos de referencia modernos colocan el código idiomático de Java dentro de un pequeño porcentaje del código C equivalente. ¿Cómo es eso posible?

Una razón es que las llamadas de función en línea de JVM modernas son algo natural. Lo hace usando la línea especulativa:

  1. El código recién cargado se ejecuta sin optimización. Durante esta etapa, para cada sitio de llamada, la JVM realiza un seguimiento de los métodos que se invocaron realmente.
  2. Una vez que el código ha sido identificado como punto de acceso de rendimiento, el tiempo de ejecución utiliza estas estadísticas para identificar la ruta de ejecución más probable y la alinea, prefijando con una rama condicional en caso de que la optimización especulativa no se aplique.

Es decir, el código:

int x = point.getX();

se reescribe a

if (point.class != Point) GOTO interpreter;
x = point.x;

Y, por supuesto, el tiempo de ejecución es lo suficientemente inteligente como para subir este tipo de verificación siempre que el punto no esté asignado, o elíjalo si el tipo es conocido por el código de llamada.

En resumen, si incluso Java gestiona la integración automática de métodos, no hay una razón inherente por la que un compilador no pueda admitir la integración automática, y todas las razones para hacerlo, porque la integración es muy beneficiosa para los procesadores modernos. Por lo tanto, casi no puedo imaginar un compilador principal moderno que ignore estas estrategias básicas de optimización, y supondría un compilador capaz de esto a menos que se demuestre lo contrario.


44
"No hay una razón inherente por la que un compilador no pueda admitir la inserción automática". Usted ha hablado sobre la compilación JIT, que equivale a código auto modificable (que un sistema operativo puede evitar por seguridad) y la capacidad de hacer una optimización automática de programa completo guiada por perfil. Un compilador AOT para un lenguaje que permite la vinculación dinámica no sabe lo suficiente como para desvirtualizar e incorporar cualquier llamada. OTOH: un compilador AOT tiene tiempo para optimizar todo lo que puede, un compilador JIT solo tiene tiempo para centrarse en optimizaciones baratas en puntos calientes. En la mayoría de los casos, eso deja a JIT en una ligera desventaja.
amon

2
Dime un sistema operativo que impide ejecutar Google Chrome "por seguridad" (V8 compila JavaScript en código nativo en tiempo de ejecución). Además, querer incluir AOT en línea no es una razón inherente (no está determinada por el lenguaje, sino por la arquitectura que elija para su compilador), y si bien el enlace dinámico inhibe la inserción de AOT en las unidades de compilación, no inhibe la inclusión dentro de la compilación unidades, donde se realizan la mayoría de las llamadas. De hecho, la alineación útil es posiblemente más fácil en un lenguaje que utiliza enlaces dinámicos de manera menos excesiva que Java.
meriton - en huelga el

44
En particular, iOS impide JIT para aplicaciones no privilegiadas. Chrome o Firefox tienen que usar la vista web proporcionada por Apple en lugar de sus propios motores. Buen punto, sin embargo, que AOT vs. JIT es un nivel de implementación, no una opción de nivel de idioma.
amon

@meriton Windows 10 S y los sistemas operativos de la consola de videojuegos también tienden a bloquear motores JIT de terceros.
Damian Yerrick

2

Como dicen otros, primero debe medir el rendimiento de su programa, y ​​probablemente no encontrará ninguna diferencia en la práctica.

Aún así, desde un nivel conceptual, pensé que aclararía algunas cosas que se combinan en su pregunta. En primer lugar, preguntas:

¿Los costos de las llamadas a funciones siguen siendo importantes en los compiladores modernos?

Observe las palabras clave "función" y "compiladores". Su presupuesto es sutilmente diferente:

Recuerde que el costo de una llamada al método puede ser significativo, dependiendo del idioma.

Se trata de métodos , en el sentido orientado a objetos.

Si bien la "función" y el "método" a menudo se usan de manera intercambiable, existen diferencias en lo que respecta a su costo (que está preguntando) y cuando se trata de la compilación (que es el contexto que dio).

En particular, necesitamos saber acerca del despacho estático frente al despacho dinámico . Ignoraré las optimizaciones por el momento.

En un lenguaje como C, generalmente llamamos a funciones con despacho estático . Por ejemplo:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

Cuando el compilador ve la llamada foo(y), sabe a qué función foose refiere ese nombre, por lo que el programa de salida puede saltar directamente a la foofunción, lo cual es bastante barato. Eso es lo que significa el envío estático .

La alternativa es el despacho dinámico , donde el compilador no sabe a qué función se llama. Como ejemplo, aquí hay un código Haskell (¡ya que el equivalente en C sería desordenado!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

Aquí la barfunción está llamando a su argumento f, que podría ser cualquier cosa. Por lo tanto, el compilador no puede simplemente compilar bara una instrucción de salto rápido, porque no sabe a dónde saltar. En cambio, el código que generamos bardesreferenciará fpara averiguar a qué función está apuntando, y luego saltará a él. Eso es lo que significa el despacho dinámico .

Ambos ejemplos son para funciones . Usted mencionó métodos , que pueden considerarse como un estilo particular de función distribuida dinámicamente. Por ejemplo, aquí hay algo de Python:

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

La y.foo()llamada utiliza despacho dinámico, ya que busca el valor de la foopropiedad en el yobjeto y llama a lo que encuentre; no sabe que ytendrá clase A, o que la Aclase contiene un foométodo, por lo que no podemos saltar directamente a ella.

OK, esa es la idea básica. Tenga en cuenta que el despacho estático es más rápido que el despacho dinámico, independientemente de si compilamos o interpretamos; en igualdad de condiciones. La desreferenciación conlleva un costo adicional en ambos sentidos.

Entonces, ¿cómo afecta esto a los compiladores modernos y optimizadores?

Lo primero a tener en cuenta es que el despacho estático se puede optimizar más: cuando sabemos a qué función estamos saltando, podemos hacer cosas como la alineación. Con el despacho dinámico, no sabemos que estamos saltando hasta el tiempo de ejecución, por lo que no hay mucha optimización que podamos hacer.

En segundo lugar, es posible en algunos idiomas inferir dónde terminarán algunos despachos dinámicos y, por lo tanto, optimizarlos en despacho estático. Esto nos permite realizar otras optimizaciones como la alineación, etc.

En el ejemplo anterior de Python, tal inferencia es bastante inútil, ya que Python permite que otro código anule las clases y propiedades, por lo que es difícil inferir mucho de lo que se mantendrá en todos los casos.

Si nuestro lenguaje nos permite imponer más restricciones, por ejemplo limitando ya la clase Ausando una anotación, entonces podríamos usar esa información para inferir la función objetivo. En los idiomas con subclases (¡que es casi todos los idiomas con clases!) Eso en realidad no es suficiente, ya yque en realidad puede tener una (sub) clase diferente, por lo que necesitaríamos información adicional como las finalanotaciones de Java para saber exactamente qué función se llamará.

Haskell no es un lenguaje OO, pero podemos inferir el valor de fmediante la inserción bar(que se envía estáticamente ) en main, sustituyendo foopor y. Dado que el objetivo de fooin maines estáticamente conocido, la llamada se despacha estáticamente, y probablemente se alineará y optimizará por completo (dado que estas funciones son pequeñas, es más probable que el compilador las incorpore; aunque no podemos contar con eso en general )

Por lo tanto, el costo se reduce a:

  • ¿El idioma despacha su llamada de forma estática o dinámica?
  • Si es lo último, ¿permite el lenguaje que la implementación infiera el objetivo utilizando otra información (por ejemplo, tipos, clases, anotaciones, líneas, etc.)?
  • ¿Qué tan agresivamente se puede optimizar el despacho estático (inferido o no)?

Si está utilizando un lenguaje "muy dinámico", con mucha distribución dinámica y pocas garantías disponibles para el compilador, entonces cada llamada tendrá un costo. Si está utilizando un lenguaje "muy estático", entonces un compilador maduro producirá un código muy rápido. Si está en el medio, puede depender de su estilo de codificación y de lo inteligente que sea la implementación.


1
No estoy de acuerdo en que llamar a un cierre (o algún puntero de función ), como su ejemplo de Haskell, es un despacho dinámico. el despacho dinámico implica algunos cálculos (p. ej., usar algún vtable ) para obtener ese cierre, por lo que es más costoso que las llamadas indirectas. De lo contrario, buena respuesta.
Basile Starynkevitch

2

Recuerde que el costo de una llamada al método puede ser significativo, dependiendo del idioma. Casi siempre hay una compensación entre escribir código legible y escribir código de rendimiento.

Desafortunadamente, esto depende en gran medida de:

  • la cadena de herramientas del compilador, incluido el JIT si lo hay,
  • el dominio.

En primer lugar, la primera ley de optimización del rendimiento es primero el perfil . Hay muchos dominios en los que el rendimiento de la parte del software es irrelevante para el rendimiento de toda la pila: llamadas a la base de datos, operaciones de red, operaciones del sistema operativo, ...

Esto significa que el rendimiento del software es completamente irrelevante, incluso si no mejora la latencia, la optimización del software puede generar ahorros de energía y hardware (o ahorro de batería para aplicaciones móviles), lo que puede ser importante.

Sin embargo, por lo general, NO se pueden dejar de lado y, a menudo, las mejoras algorítmicas triunfan sobre las micro optimizaciones por un amplio margen.

Entonces, antes de optimizar, debe comprender para qué está optimizando ... y si vale la pena.


Ahora, con respecto al rendimiento puro del software, varía mucho entre las cadenas de herramientas.

Hay dos costos para una llamada de función:

  • el costo del tiempo de ejecución,
  • El costo del tiempo de compilación.

El costo del tiempo de ejecución es bastante obvio; Para realizar una llamada de función es necesaria una cierta cantidad de trabajo. Usando C en x86, por ejemplo, una llamada a la función requerirá (1) derramar registros a la pila, (2) enviar argumentos a los registros, realizar la llamada y luego (3) restaurar los registros de la pila. Vea este resumen de convenciones de llamadas para ver el trabajo involucrado .

Este registro de derrame / restauración lleva una cantidad de veces no trivial (docenas de ciclos de CPU).

En general, se espera que este costo sea trivial en comparación con el costo real de ejecutar la función, sin embargo, algunos patrones son contraproducentes aquí: captadores, funciones protegidas por una condición simple, etc.

Además de los intérpretes , un programador esperará que su compilador o JIT optimice las llamadas de función que son innecesarias; aunque esta esperanza a veces puede no dar fruto. Porque los optimizadores no son mágicos.

Un optimizador puede detectar que una llamada a la función es trivial y alinear la llamada: esencialmente, copie / pegue el cuerpo de la función en el sitio de la llamada. Esto no siempre es una buena optimización (puede inducir la hinchazón), pero en general vale la pena porque la inlineización expone el contexto , y el contexto permite más optimizaciones.

Un ejemplo típico es:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

Si funcse colocarán en línea, a continuación, el optimizador se dará cuenta de que la rama no se toma, y optimizar calla void call() {}.

En ese sentido, las llamadas a funciones, al ocultar información del optimizador (si aún no está en línea), pueden inhibir ciertas optimizaciones. Las llamadas a funciones virtuales son especialmente culpables de esto, porque la desvirtualización (que prueba qué función finalmente se llama en tiempo de ejecución) no siempre es fácil.


En conclusión, mi consejo es escribir claramente primero, evitando la pesimización algorítmica prematura (complejidad cúbica o peores mordidas rápidamente), y luego solo optimizar lo que necesita optimización.


1

"Recuerde que el costo de una llamada a un método puede ser significativo, dependiendo del idioma. Casi siempre existe una compensación entre escribir código legible y escribir código de rendimiento".

¿En qué condiciones esta declaración citada sigue siendo válida hoy en día dada la rica industria de los compiladores modernos de alto rendimiento?

Voy a decir que nunca. Creo que la cita es imprudente simplemente tirar por ahí.

Por supuesto, no estoy diciendo la verdad completa, pero no me importa ser sincero tanto. Es como en esa película de Matrix, olvidé si era 1 o 2 o 3: creo que fue la de la sexy actriz italiana con los grandes melones (no me gustó nada excepto la primera), cuando el oracle lady le dijo a Keanu Reeves: "Solo te dije lo que necesitabas escuchar", o algo así, eso es lo que quiero hacer ahora.

Los programadores no necesitan escuchar esto. Si tienen experiencia con los perfiladores en sus manos y la cita es algo aplicable a sus compiladores, ya lo sabrán y aprenderán de la manera adecuada siempre que comprendan su producción de perfiles y por qué ciertas llamadas de hoja son puntos críticos, a través de la medición. Si no tienen experiencia y nunca han perfilado su código, esto es lo último que deben escuchar, que deberían comenzar a comprometer supersticiosamente cómo escriben el código hasta el punto de incluir todo antes de identificar puntos de acceso con la esperanza de que sea ser más performante

De todos modos, para una respuesta más precisa, depende. Algunas de las condiciones del barco ya figuran entre las buenas respuestas. Las posibles condiciones de solo elegir un idioma ya son enormes, como C ++, que tendría que entrar en un despacho dinámico en llamadas virtuales y cuándo se puede optimizar y bajo qué compiladores e incluso enlazadores, y eso ya garantiza una respuesta detallada y mucho menos intentarlo para abordar las condiciones en todos los idiomas y compiladores posibles. Pero agregaré en la parte superior, "¿a quién le importa?" porque incluso trabajando en áreas críticas para el rendimiento como el trazado de rayos, lo último que comenzaré a hacer por adelantado son los métodos de alineación manual antes de realizar cualquier medición.

Creo que algunas personas se vuelven demasiado celosas al sugerirle que nunca debe hacer micro optimizaciones antes de medir. Si la optimización para la localidad de referencia cuenta como una microoptimización, a menudo empiezo a aplicar tales optimizaciones desde el principio con una mentalidad de diseño orientada a los datos en áreas que sé con certeza que serán críticas para el rendimiento (código de trazado de rayos, por ejemplo), porque de lo contrario sé que tendré que reescribir grandes secciones poco después de haber trabajado en estos dominios durante años. La optimización de la representación de datos para los éxitos de caché a menudo puede tener el mismo tipo de mejoras de rendimiento que las mejoras algorítmicas, a menos que estemos hablando de tiempo cuadrático a lineal.

Pero nunca, nunca veo una buena razón para comenzar a alinear antes de las mediciones, especialmente porque los perfiladores son decentes para revelar lo que podría beneficiarse de la alineación, pero no para revelar lo que podría beneficiarse de no estar en línea (y la no alineación puede hacer que el código sea más rápido si el la llamada de función sin forro es un caso raro, que mejora la localidad de referencia para el icache para el código dinámico y, a veces, incluso permite que los optimizadores hagan un mejor trabajo para la ruta de ejecución de caso común).

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.