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 .
fuente
for(Integer index = 0, size = someList.size(); index < size; index++)
lugar de simplementefor(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.main()
, otros dividen todo en unas 50 pequeñas funciones y todos son completamente ilegibles. El truco es, como siempre, lograr un buen equilibrio .Respuestas:
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.
fuente
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.
fuente
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
(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.
fuente
final
clases y métodos cuando corresponda en Java, o novirtual
mé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 ...Desafiaré esta cita:
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.
fuente
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.
fuente
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:
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)):
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
s
cambio su contenido (incluso si estáconst
en su función, puede estar en otroconst
lugar), lo que hace imposible la optimización:fuente
Este viejo documento podría responder a su pregunta:
Resumen:
fuente
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.
fuente
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:
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:
Es decir, el código:
se reescribe a
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.
fuente
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:
Observe las palabras clave "función" y "compiladores". Su presupuesto es sutilmente diferente:
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:
Cuando el compilador ve la llamada
foo(y)
, sabe a qué funciónfoo
se refiere ese nombre, por lo que el programa de salida puede saltar directamente a lafoo
funció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!):
Aquí la
bar
función está llamando a su argumentof
, que podría ser cualquier cosa. Por lo tanto, el compilador no puede simplemente compilarbar
a una instrucción de salto rápido, porque no sabe a dónde saltar. En cambio, el código que generamosbar
desreferenciaráf
para 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:
La
y.foo()
llamada utiliza despacho dinámico, ya que busca el valor de lafoo
propiedad en ely
objeto y llama a lo que encuentre; no sabe quey
tendrá claseA
, o que laA
clase contiene unfoo
mé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
y
a la claseA
usando 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, yay
que en realidad puede tener una (sub) clase diferente, por lo que necesitaríamos información adicional como lasfinal
anotaciones de Java para saber exactamente qué función se llamará.Haskell no es un lenguaje OO, pero podemos inferir el valor de
f
mediante la inserciónbar
(que se envía estáticamente ) enmain
, sustituyendofoo
pory
. Dado que el objetivo defoo
inmain
es 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:
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.
fuente
Desafortunadamente, esto depende en gran medida de:
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 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:
Si
func
se colocarán en línea, a continuación, el optimizador se dará cuenta de que la rama no se toma, y optimizarcall
avoid 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.
fuente
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).
fuente