¿Es una mala práctica escribir código que se basa en optimizaciones del compilador?

95

He estado aprendiendo algo de C ++ y, a menudo, tengo que devolver objetos grandes desde las funciones que se crean dentro de la función. Sé que existe el paso por referencia, devuelve un puntero y devuelve soluciones de tipo de referencia, pero también he leído que los compiladores de C ++ (y el estándar de C ++) permiten la optimización del valor de retorno, lo que evita copiar estos objetos grandes a través de la memoria. Guardando el tiempo y la memoria de todo eso.

Ahora, siento que la sintaxis es mucho más clara cuando el objeto se devuelve explícitamente por valor, y el compilador generalmente empleará el RVO y hará que el proceso sea más eficiente. ¿Es una mala práctica confiar en esta optimización? Hace que el código sea más claro y más legible para el usuario, lo cual es extremadamente importante, pero ¿debo tener cuidado de suponer que el compilador aprovechará la oportunidad RVO?

¿Es esto una microoptimización, o algo que debo tener en cuenta al diseñar mi código?

    
pregunta Matt 12.10.2017 - 14:39
fuente

14 respuestas

128

Emplee el principio de menos asombro .

¿Es usted y solo usted quien va a utilizar este código, y está seguro de que lo mismo que usted en 3 años no se sorprenderá de lo que haga?

Entonces adelante.

En todos los demás casos, utilice la forma estándar; de lo contrario, usted y sus colegas se encontrarán con errores difíciles de encontrar.

Por ejemplo, mi colega se quejaba de que mi código causaba errores. Resulta que había desactivado la evaluación booleana de cortocircuito en la configuración del compilador. Casi lo abofeteo.

    
respondido por el Pieter B 12.10.2017 - 14:58
fuente
78

Para este caso en particular, definitivamente solo se devuelve por valor.

  • RVO y NRVO son optimizaciones bien conocidas y robustas que realmente debería realizar cualquier compilador decente, incluso en el modo C ++ 03.

  • La semántica de movimiento garantiza que los objetos se muevan fuera de las funciones si no se realizó (N) RVO. Eso solo es útil si su objeto usa datos dinámicos internamente (como lo hace std::vector ), pero ese debería ser el caso si es lo que es grande: desbordar la pila es un riesgo con los grandes objetos automáticos.

  • C ++ 17 hace cumplir RVO. Así que no se preocupe, no desaparecerá y solo se terminará de establecer completamente una vez que los compiladores estén actualizados.

Y al final, forzar una asignación dinámica adicional para devolver un puntero, o forzar su tipo de resultado para que se pueda construir por defecto solo para que pueda pasarlo, ya que un parámetro de salida son soluciones feas y no idiomáticas para un problema que Probablemente nunca lo haya hecho.

Simplemente escriba el código que tenga sentido y agradezca a los escritores del compilador por optimizar correctamente el código que tiene sentido.

    
respondido por el Quentin 12.10.2017 - 17:31
fuente
60
  

Ahora, siento que la sintaxis es mucho más clara cuando el objeto se devuelve explícitamente por valor, y el compilador generalmente empleará el RVO y hará que el proceso sea más eficiente. ¿Es una mala práctica confiar en esta optimización? Hace que el código sea más claro y más legible para el usuario, lo cual es extremadamente importante, pero ¿debo tener cuidado de suponer que el compilador aprovechará la oportunidad RVO?

Esto no es una micro optimización optimizada, poco conocida, que se lee en un blog pequeño y con poco tráfico, y luego te sientes inteligente y superior acerca del uso.

Después de C ++ 11, RVO es la forma estándar para escribir este código de código. Es común, esperado, enseñado, mencionado en charlas, mencionado en blogs, mencionado en el estándar, se informará como un error del compilador si no se implementa. En C ++ 17, el lenguaje va un paso más allá y los mandatos copian la elección en ciertos escenarios.

Debes confiar absolutamente en esta optimización.

Además de eso, el retorno por valor solo conduce a un código mucho más fácil de leer y administrar que el código que se devuelve por referencia. La semántica del valor es una cosa poderosa, que a su vez podría generar más oportunidades de optimización.

    
respondido por el Barry 12.10.2017 - 19:05
fuente
16

La corrección del código que escribe no debería nunca depender de una optimización. Debe generar el resultado correcto cuando se ejecuta en la "máquina virtual" de C ++ que utilizan en la especificación.

Sin embargo, de lo que hablas es más una cuestión de eficiencia. Su código se ejecuta mejor si se optimiza con un compilador de optimización RVO. Eso está bien, por todas las razones señaladas en las otras respuestas.

Sin embargo, si requiere esta optimización (por ejemplo, si el constructor de copias realmente causara que su código falle), ahora está en los caprichos del compilador.

Creo que el mejor ejemplo de esto en mi propia práctica es la optimización de llamadas de cola:

   int sillyAdd(int a, int b)
   {
      if (b == 0)
          return a;
      return sillyAdd(a + 1, b - 1);
   }

Es un ejemplo tonto, pero muestra una llamada de cola, donde una función se llama recursivamente al final de una función. La máquina virtual de C ++ mostrará que este código funciona correctamente, aunque puedo causar una pequeña confusión en cuanto a por qué me molesté en escribir esa rutina de adición en primer lugar. Sin embargo, en implementaciones prácticas de C ++, tenemos una pila y tiene un espacio limitado. Si se hace de forma pediátrica, esta función tendría que empujar al menos b + 1 en la pila mientras hace su adición. Si quiero calcular sillyAdd(5, 7) , esto no es un gran problema. Si quiero calcular sillyAdd(0, 1000000000) , podría estar en problemas reales de causar un StackOverflow (y no el buen tipo ).

Sin embargo, podemos ver que una vez que alcanzamos la última línea de retorno, ya hemos terminado con todo en el marco de pila actual. Realmente no necesitamos mantenerlo cerca. La optimización de llamadas de cola le permite "reutilizar" el marco de pila existente para la siguiente función. De esta manera, solo necesitamos 1 marco de pila, en lugar de b+1 . (Todavía tenemos que hacer todas esas adiciones y restas tontas, pero no ocupan más espacio). En efecto, la optimización convierte el código en:

   int sillyAdd(int a, int b)
   {
      begin:
      if (b == 0)
          return a;
      // return sillyAdd(a + 1, b - 1);
      a = a + 1;
      b = b - 1;
      goto begin;  
   }

En algunos idiomas, la especificación requiere explícitamente la optimización de la llamada de cola. C ++ es no uno de esos. No puedo confiar en los compiladores de C ++ para reconocer esta oportunidad de optimización de llamadas de cola, a menos que vaya caso por caso. Con mi versión de Visual Studio, la versión de lanzamiento hace la optimización de la llamada de cola, pero la versión de depuración no lo hace (por diseño).

Por lo tanto, sería malo para mí depender de poder calcular sillyAdd(0, 1000000000) .

    
respondido por el Cort Ammon 12.10.2017 - 21:29
fuente
8

En la práctica Los programas de C ++ esperan algunas optimizaciones del compilador.

Mire notablemente en los encabezados estándar de sus implementaciones estándar de contenedores . Con GCC , puede solicitar el formulario preprocesado ( g++ -C -E ) y la representación interna de GIMPLE ( g++ -fdump-tree-gimple o Gimple SSA con -fdump-tree-ssa ) de la mayoría de los archivos de origen (unidades de traducción técnica) que utilizan contenedores. Te sorprenderá la cantidad de optimización que se realiza (con g++ -O2 ). Por lo tanto, los implementadores de contenedores confían en las optimizaciones (y la mayoría de las veces, el implementador de una biblioteca estándar de C ++ sabe qué optimización ocurriría y escribe la implementación del contenedor con esos en mente; a veces, también escribe el paso de optimización en el compilador para tratar con las características requeridas por la biblioteca estándar de C ++ entonces.

En la práctica, son las optimizaciones del compilador las que hacen que C ++ y sus contenedores estándar sean lo suficientemente eficientes. Así que puedes confiar en ellos.

Y lo mismo para el caso RVO mencionado en su pregunta.

El estándar de C ++ fue co-diseñado (notablemente experimentando optimizaciones lo suficientemente buenas al proponer nuevas características) para funcionar bien con las posibles optimizaciones.

Por ejemplo, considere el siguiente programa:

#include <algorithm>
#include <vector>

extern "C" bool all_positive(const std::vector<int>& v) {
  return std::all_of(v.begin(), v.end(), [](int x){return x >0;});
}

compílalo con g++ -O3 -fverbose-asm -S . Descubrirá que la función generada no ejecuta ninguna instrucción de máquina CALL . Así que la mayoría de los pasos de C ++ (construcción de un cierre lambda, su aplicación repetida, obtener los iteradores begin y end , etc.) se han optimizado. El código de máquina solo contiene un bucle (que no aparece explícitamente en el código fuente). Sin tales optimizaciones, C ++ 11 no tendrá éxito.

adiciones

(agregado el 31 de diciembre st 2017)

Consulte CppCon 2017: Matt Godbolt “¿Qué ha hecho mi compilador por última vez? Desatornillando la tapa del compilador ".

    
respondido por el Basile Starynkevitch 13.10.2017 - 12:12
fuente
3

Siempre que use un compilador, el entendimiento es que producirá un código de máquina o byte para usted. No garantiza nada acerca de cómo es ese código generado, excepto que implementará el código fuente de acuerdo con la especificación del idioma. Tenga en cuenta que esta garantía es la misma independientemente del nivel de optimización utilizado, por lo que, en general, no hay razón para considerar que una salida es más "correcta" que la otra.

Además, en aquellos casos, como RVO, donde se especifica en el idioma, parece inútil hacer todo lo posible para evitar su uso, especialmente si hace que el código fuente sea más simple.

Se pone mucho esfuerzo en hacer que los compiladores produzcan resultados eficientes, y claramente la intención es que esas capacidades se usen.

Puede haber razones para usar código no optimizado (por ejemplo, para la depuración), pero el caso mencionado en esta pregunta no parece ser uno (y si su código falla solo cuando está optimizado, y no es una consecuencia de algunos peculiaridad del dispositivo en el que lo está ejecutando, entonces hay un error en algún lugar y es poco probable que esté en el compilador.)

    
respondido por el sdenham 12.10.2017 - 22:58
fuente
2

Creo que otros cubrieron bien el ángulo específico sobre C ++ y RVO. Aquí hay una respuesta más general:

Cuando se trata de corrección, no debe confiar en las optimizaciones del compilador, o en el comportamiento específico del compilador en general. Afortunadamente, no parece que estés haciendo esto.

Cuando se trata de rendimiento, tienes que confiar en el comportamiento específico del compilador en general, y en las optimizaciones del compilador en particular. Un compilador compatible con las normas es libre de compilar su código de la forma que desee, siempre que el código compilado se comporte de acuerdo con la especificación del idioma. Y no tengo conocimiento de ninguna especificación para un lenguaje general que especifique qué tan rápido debe ser cada operación.

    
respondido por el svick 16.10.2017 - 02:38
fuente
1

Las optimizaciones del compilador solo deberían afectar el rendimiento, no los resultados. Confiar en las optimizaciones del compilador para cumplir con los requisitos no funcionales no solo es razonable, sino que con frecuencia es la razón por la que un compilador se selecciona sobre otro.

Los indicadores que determinan cómo se realizan las operaciones particulares (por ejemplo, las condiciones de índice o desbordamiento), se agrupan frecuentemente con optimizaciones del compilador, pero no deberían serlo. Afectan explícitamente los resultados de los cálculos.

Si una optimización del compilador produce resultados diferentes, es un error, un error en el compilador. Confiar en un error en el compilador es un error a largo plazo: ¿qué sucede cuando se soluciona?

El uso de indicadores de compilador que cambian la forma en que funcionan los cálculos debería estar bien documentado, pero se debe usar según sea necesario.

    
respondido por el jmoreno 16.10.2017 - 00:22
fuente
0

Todos los intentos de código eficiente escrito en cualquier cosa, excepto el ensamblaje, dependen en gran medida de las optimizaciones del compilador, comenzando con la asignación de registro más básica y eficiente para evitar derrames de pila superfluos en todo el lugar y al menos razonablemente bueno, si no excelente. selección de instrucciones. De lo contrario, volveríamos a la década de los 80, donde tendríamos que poner register consejos en todo el lugar y usar el número mínimo de variables en una función para ayudar a los compiladores arcaicos de C o incluso antes cuando goto era una optimización de ramificación útil.

Si no sintiéramos que pudiéramos confiar en la capacidad de nuestro optimizador para optimizar nuestro código, todavía estaríamos codificando las rutas de ejecución críticas para el rendimiento en el ensamblaje.

Es realmente una cuestión de qué tan confiable se siente la optimización que se puede hacer, lo que se resuelve mejor al analizar y analizar las capacidades de los compiladores que tiene y posiblemente incluso desarmar si hay un punto de acceso que no puede averiguar dónde está el compilador parece haber fallado en hacer una optimización obvia.

RVO es algo que ha existido durante años, y, al menos excluyendo casos muy complejos, es algo que los compiladores han aplicado de manera confiable durante años. Definitivamente no vale la pena resolver un problema que no existe.

Echa un vistazo al lado de confiar en el optimizador, sin temerlo

Por el contrario, yo diría que errar por el hecho de confiar demasiado en las optimizaciones del compilador que demasiado poco, y esta sugerencia proviene de un tipo que trabaja en campos muy críticos para el rendimiento, donde la eficiencia, la capacidad de mantenimiento y la calidad percibida entre los clientes es todo un borroso gigante. Prefiero que confíes demasiado en tu optimizador y encuentres algunos casos poco claros en los que confiaste demasiado que confiar poco y codificando los miedos supersticiosos todo el tiempo por el resto de tu vida. Al menos, eso le hará llegar a un perfilador e investigar adecuadamente si las cosas no se ejecutan tan rápido como deberían y obteniendo valiosos conocimientos, no supersticiones, en el camino.

Lo estás haciendo bien para apoyarte en el optimizador. Seguid así. No te conviertas en un tipo que comienza a solicitar explícitamente incluir en línea todas las funciones llamadas en un bucle, incluso antes de perfilar el miedo equivocado a las deficiencias del optimizador.

Profiling

El perfil es realmente la rotonda pero la respuesta definitiva a tu pregunta. El problema para los principiantes, ansiosos por escribir código eficiente con el que a menudo se lucha, no es con qué optimizar, sino con qué no optimizar porque desarrollan todo tipo de presentimientos erróneos acerca de ineficiencias que, aunque son humanamente intuitivas, son computacionalmente incorrectas. El desarrollo de la experiencia con un generador de perfiles comenzará realmente a brindarle una apreciación adecuada no solo de las capacidades de optimización de los compiladores en las que puede confiar con confianza, sino también de las capacidades (así como las limitaciones) de su hardware. Podría decirse que hay aún más valor en los perfiles para aprender lo que no valía la pena optimizar que aprender lo que era.

    
respondido por el user204677 30.11.2017 - 13:06
fuente
0

No.

Eso es lo que hago todo el tiempo. Si necesito acceder a un bloque de 16 bits arbitrario en la memoria, hago esto

void *ptr = get_pointer();
uint16_t u16;
memcpy(&u16, ptr, sizeof(u16)); // ntohs omitted for simplicity

... y confíe en que el compilador haga todo lo posible para optimizar ese fragmento de código. El código funciona en ARM, i386, AMD64 y prácticamente en todas las arquitecturas que existen. En teoría, un compilador que no optimiza podría llamar a memcpy , lo que resulta en un rendimiento totalmente malo, pero eso no es un problema para mí, ya que uso las optimizaciones del compilador.

Considera la alternativa:

void *ptr = get_pointer();
uint16_t *u16ptr = ptr;
uint16_t u16;
u16 = *u16ptr;  // ntohs omitted for simplicity

Este código alternativo no funciona en máquinas que requieren una alineación adecuada, si get_pointer() devuelve un puntero no alineado. Además, puede haber problemas de alias en la alternativa.

La diferencia entre -O2 y -O0 cuando se utiliza el truco memcpy es grande: 3.2 Gbps de rendimiento de suma de control de IP frente a 67 Gbps de rendimiento de suma de control de IP. ¡Sobre una diferencia de orden de magnitud!

A veces es posible que necesites ayudar al compilador. Entonces, por ejemplo, en lugar de confiar en el compilador para desenrollar los bucles, puede hacerlo usted mismo. Ya sea implementando el famoso dispositivo de Duff , o de una manera más limpia.

El inconveniente de confiar en las optimizaciones del compilador es que si ejecuta gdb para depurar su código, puede descubrir que se ha optimizado mucho. Por lo tanto, es posible que tenga que volver a compilar con -O0, lo que significa que el rendimiento será un fracaso total al depurar. Creo que este es un inconveniente que vale la pena tomar, considerando los beneficios de optimizar los compiladores.

Hagas lo que hagas, asegúrate de que tu camino no sea un comportamiento indefinido. Ciertamente, acceder a un bloque aleatorio de memoria como un entero de 16 bits es un comportamiento indefinido debido a problemas de alineación y alias.

    
respondido por el juhist 31.12.2017 - 20:05
fuente
-1

El software se puede escribir en C ++ en plataformas muy diferentes y para muchos propósitos diferentes.

Depende completamente del propósito del software. Debe ser fácil de mantener, expandir, parchar, refactorizar, etc. o son otras cosas más importantes, como el rendimiento, el costo o la compatibilidad con algún hardware específico o el tiempo que lleva desarrollarse.

    
respondido por el mathreadler 13.10.2017 - 18:48
fuente
-2

Creo que la respuesta aburrida a esto es: 'depende'.

¿Es una mala práctica escribir código que se basa en una optimización del compilador que probablemente esté desactivada y donde no se documente la vulnerabilidad y donde la El código en cuestión no se prueba en la unidad, de modo que si se rompiera, ¿lo sabría ? Probablemente.

¿Es una mala práctica escribir código que se basa en la optimización del compilador de que no es probable que se apague , que esté documentado y se haya probado en una unidad ? Tal vez no.

    
respondido por el Dave Cousineau 13.10.2017 - 22:54
fuente
-6

A menos que haya más que no nos diga, esta es una mala práctica, pero no por la razón que sugiere.

Posiblemente, a diferencia de otros lenguajes que ha usado antes, devolver el valor de un objeto en C ++ produce una copia del objeto. Si luego modifica el objeto, está modificando un objeto diferente . Es decir, si tengo Obj a; a.x=1; y Obj b = a; , entonces hago b.x += 2; b.f(); , entonces a.x todavía es 1, no 3

Entonces, no, usar un objeto como un valor en lugar de como una referencia o un puntero no proporciona la misma funcionalidad y podría terminar con errores en su software.

Quizás sepa esto y no afecte negativamente su caso de uso específico. Sin embargo, según la redacción de su pregunta, parece que es posible que no esté al tanto de la distinción; palabras como "crear un objeto en la función".

"crear un objeto en la función" suena como new Obj; donde "devolver el objeto por valor" suena como Obj a; return a;

Obj a; y Obj* a = new Obj; son cosas muy, muy diferentes; el primero puede provocar daños en la memoria si no se utiliza y entiende correctamente, y el segundo puede provocar pérdidas de memoria si no se utiliza y entiende correctamente.

    
respondido por el Aaron 12.10.2017 - 20:09
fuente
-7

Pieter B es absolutamente correcto al recomendar el menor asombro.

Para responder a su pregunta específica, lo que esto significa (probablemente) en C ++ es que debe devolver un std::unique_ptr al objeto construido.

La razón es que esto es más claro para un desarrollador de C ++ en cuanto a lo que está sucediendo.

Aunque su enfoque probablemente funcione, está indicando efectivamente que el objeto es un tipo de valor pequeño cuando, de hecho, no lo es. Además de eso, está desechando cualquier posibilidad de abstracción de la interfaz. Esto puede estar bien para sus propósitos actuales, pero a menudo es muy útil cuando se trata de matrices.

Aprecio que si has venido de otros idiomas, todos los sigilos pueden ser confusos inicialmente. Pero tenga cuidado de no asumir que, al no usarlos, hace que su código sea más claro. En la práctica, es probable que lo contrario sea cierto.

    
respondido por el Alex 12.10.2017 - 15:23
fuente

Lea otras preguntas en las etiquetas