que - punteros c++
¿Por qué GCC no optimiza la eliminación de punteros nulos en C++? (6)
Creo que el compilador no tiene conocimiento sobre "eliminar", especialmente que "eliminar nulo" es un NOOP.
Puede escribirlo explícitamente, por lo que el compilador no necesita implicar conocimiento sobre la eliminación.
ADVERTENCIA: No lo recomiendo como implementación general. El siguiente ejemplo debería mostrar cómo podrías "convencer" a un compilador limitado de eliminar el código de todos modos en ese programa muy especial y limitado.
int main() {
int* ptr = nullptr;
if (ptr != nullptr) {
delete ptr;
}
}
Donde recuerdo bien, hay una manera de reemplazar "eliminar" con una función propia. Y en el caso de que una optimización por el compilador saliera mal.
@RichardHodges: ¿Por qué debería ser una des-optimización cuando uno da al compilador la pista para eliminar una llamada?
eliminar null es en general un NOOP (sin operación). Sin embargo, dado que es posible reemplazar o sobrescribir la eliminación, no hay garantías para todos los casos.
Depende del compilador saber y decidir si usar el conocimiento que eliminar null siempre podría eliminarse. hay buenos argumentos para ambos choises
Sin embargo, el compilador siempre puede eliminar el código muerto, esto "if (false) {...}" or "if (nullptr! = Nullptr) {...}"
Entonces, un compilador eliminará el código muerto y luego, al usar una verificación explícita, se verá como
int main() {
int* ptr = nullptr;
// dead code if (ptr != nullptr) {
// delete ptr;
// }
}
Por favor, dime, ¿dónde hay una desoptimización?
Yo llamo a mi propuesta un estilo defensivo de codificación, pero no una des-optimización
Si alguien puede argumentar, que ahora el non-nullptr causará dos veces la comprobación en nullptr, tengo que responder
- Lo siento, esta no era la pregunta original
- si el compilador sabe acerca de la eliminación, especialmente que eliminar null es un noop, entonces el compilador podría eliminar el externo si cualquiera de los dos. Sin embargo, no esperaría que los compiladores sean tan específicos
@ Peter Cordes: Estoy de acuerdo con una regla de optimización si no es general. Sin embargo, la optimización general NO era la cuestión del abridor. La pregunta era por qué algunos compiladores no eliman la eliminación en un programa muy breve y carente de sentido. Mostré una forma de hacer que el compilador elimine de todos modos.
Si ocurre una situación como en ese programa corto, probablemente algo diferente está mal. En general, trataría de evitar new / delete (malloc / free) ya que las llamadas son bastante caras. Si es posible, prefiero usar la pila (automático).
Cuando echo un vistazo al caso real documentado mientras tanto, diría que la clase X está mal diseñada, lo que provoca un rendimiento pobre y demasiada memoria. ( https://godbolt.org/g/7zGUvo )
En lugar de
class X {
int* i_;
public:
...
en el diseño sería
class X {
int i;
bool valid;
public:
...
o más temprano, le pediría la sensación de ordenar elementos vacíos / inválidos. Al final, me gustaría deshacerme de "válido" también.
Considere un programa simple:
int main() {
int* ptr = nullptr;
delete ptr;
}
Con GCC (7.2), hay una instrucción de call
relacionada con la operator delete
en el programa resultante. Con los compiladores Clang e Intel, no existen tales instrucciones, la eliminación del puntero nulo está completamente optimizada ( -O2
en todos los casos). Puede probar aquí: https://godbolt.org/g/JmdoJi .
Me pregunto si esa optimización puede activarse de alguna manera con GCC. (Mi motivación más amplia proviene de un problema de swap
personalizado vs std::swap
para tipos móviles, donde la eliminación de punteros nulos puede representar una penalización de rendimiento en el segundo caso; consulte https://stackoverflow.com/a/45689282/580083 para detalles)
ACTUALIZAR
Para aclarar mi motivación para la pregunta: si uso simplemente delete ptr;
sin if (ptr)
guard en un operador de asignación de movimiento y un destructor de alguna clase, std::swap
con objetos de esa clase produce 3 instrucciones de call
con GCC. Esto podría ser una penalización de rendimiento considerable, por ejemplo, al ordenar una matriz de tales objetos.
Además, puedo escribir if (ptr) delete ptr;
en todas partes, pero me pregunto si esto no puede ser una penalización de rendimiento también, ya que la expresión de delete
necesita comprobar ptr
. Pero, aquí, supongo, los compiladores generarán solo un cheque.
Además, me gusta mucho la posibilidad de llamar delete
sin el guardia y fue una sorpresa para mí, que podría arrojar resultados diferentes (rendimiento).
ACTUALIZAR
Acabo de hacer un punto de referencia simple, es decir, ordenar objetos, que invocan delete
en su operador de asignación de movimiento y destructor. La fuente está aquí: https://godbolt.org/g/7zGUvo
Tiempos de ejecución de std::sort
medidos con GCC 7.1 y -O2
bandera en Xeon E2680v3:
Hay un error en el código vinculado, compara punteros, no valores apuntados. Los resultados corregidos son los siguientes:
- sin
if
guardia:17.6 [s]40.8 [s] , - con
if
guardia:10.6 [s]31.5 [s] , - con
if
guard yswap
personalizado:10.4 [s]31.3 [s].
Estos resultados fueron absolutamente consistentes en muchas ejecuciones con una desviación mínima. La diferencia de rendimiento entre los primeros dos casos es significativa y no diría que este es un caso similar al "extremadamente curioso caso".
De acuerdo con C ++ 14 [expr.delete] / 7:
Si el valor del operando de la expresión de eliminación no es un valor de puntero nulo, entonces:
- [... omitido ...]
De lo contrario, no se especifica si se llamará a la función de desasignación.
Por lo tanto, ambos compiladores cumplen con el estándar, ya que no se especifica si se operator delete
para eliminar un puntero nulo.
Tenga en cuenta que el compilador en línea godbolt solo compila el archivo fuente sin vincularlo. Entonces el compilador en esa etapa debe permitir la posibilidad de que la operator delete
sea reemplazada por otro archivo fuente.
Como ya se especuló en otra respuesta, gcc puede estar buscando un comportamiento consistente en el caso de una operator delete
reemplazo; esta implementación significaría que alguien puede sobrecargar esa función con fines de depuración y romper todas las invocaciones de la expresión de delete
, incluso cuando se elimine un puntero nulo.
ACTUALIZADO: Se ha eliminado la especulación de que esto podría no ser un problema práctico, ya que OP proporcionó puntos de referencia que muestran que de hecho lo es.
El estándar realmente establece cuándo se deben llamar las funciones de asignación y desasignación y dónde no. Esta cláusula (@ n4296)
La biblioteca proporciona definiciones predeterminadas para las funciones globales de asignación y desasignación. Algunas funciones globales de asignación y desasignación son reemplazables (18.6.1). Un programa C ++ debe proporcionar como máximo una definición de una función reemplazable de atribución o desasignación. Cualquier definición de función reemplaza a la versión predeterminada proporcionada en la biblioteca (17.6.4.6). Las siguientes funciones de asignación y desasignación (18.6) se declaran implícitamente en alcance global en cada unidad de traducción de un programa.
probablemente sea la razón principal por la cual esas llamadas a funciones no se omiten arbitrariamente. Si lo fueran, el reemplazo de su implementación de la biblioteca causaría una función incoherente del programa compilado.
En la primera alternativa (eliminar objeto), el valor del operando de eliminar puede ser un valor de puntero nulo, un puntero a un objeto que no sea de matriz creado por una nueva expresión previa o un puntero a un subobjeto (1.8) que represente una clase base de tal objeto (Cláusula 10). Si no, el comportamiento no está definido.
Si el argumento dado a una función de desasignación en la biblioteca estándar es un puntero que no es el valor del puntero nulo (4.10), la función de desasignación desasignará el almacenamiento al que hace referencia el puntero, invalidando todos los punteros en referencia a cualquier parte del almacenamiento desasignado . La indicación a través de un valor de puntero no válido y la transferencia de un valor de puntero no válido a una función de desasignación tienen un comportamiento indefinido. Cualquier otro uso de un valor de puntero no válido tiene un comportamiento definido por la implementación.
...
Si el valor del operando de la expresión de eliminación no es un valor de puntero nulo, entonces
Si la llamada de asignación para la nueva expresión para el objeto que se va a eliminar no se omitió y la asignación no se extendió (5.3.4), la expresión de eliminación llamará a una función de desasignación (3.7.4.2). El valor devuelto por la llamada de asignación de la nueva expresión se pasará como el primer argumento de la función de desasignación.
De lo contrario, si la asignación se extendió o se proporcionó ampliando la asignación de otra newexpression, y la expresión de eliminación para cada otro valor de puntero producido por una nueva expresión que tenía almacenamiento proporcionado por la nueva expresión extendida se ha evaluado, la eliminación -expresión llamará una función de desasignación. El valor devuelto por la llamada de asignación de la nueva expresión extendida se pasará como el primer argumento de la función de desasignación.
- De lo contrario, la expresión de eliminación no llamará a una función de desasignación
De lo contrario, no se especifica si se llamará a la función de desasignación.
El estándar indica lo que se debe hacer si el puntero NO es nulo. Implicando que borrar en ese caso es noop, pero para qué fin, no se especifica.
En primer lugar, voy a estar de acuerdo con algunos contestadores previos en que no es un error, y GCC puede hacer lo que quiera aquí. Dicho esto, me preguntaba si esto significa que algún código RAII común y simple puede ser más lento en GCC que Clang porque no se realiza una optimización directa.
Así que escribí un pequeño caso de prueba para RAII:
struct A
{
explicit A() : ptr(nullptr) {}
A(A &&from)
: ptr(from.ptr)
{
from.ptr = nullptr;
}
A &operator =(A &&from)
{
if ( &from != this )
{
delete ptr;
ptr = from.ptr;
from.ptr = nullptr;
}
return *this;
}
int *ptr;
};
A a1;
A getA2();
void setA1()
{
a1 = getA2();
}
Como puede ver here , GCC omite la segunda llamada para delete
en setA1
(para el temporal getA2
que se creó en la llamada a getA2
). La primera llamada es necesaria para la corrección del programa porque a1
o a1.ptr
pueden haber sido previamente asignados a.
Obviamente, preferiría más "rima y razón", ¿por qué se realiza la optimización a veces, pero no siempre if ( ptr != nullptr )
Pero no estoy dispuesto a rociar redundante if ( ptr != nullptr )
comprueba todo mi código RAII por el momento.
Es un problema de QOI. clang efectivamente elide la prueba:
main: # @main
xor eax, eax
ret
Siempre es seguro (para ser correcto) permitir que el programa llame al operator delete
con un nullptr.
Para el rendimiento, es muy raro que tener el asm generado por el compilador realmente haga una prueba adicional y la rama condicional para omitir una llamada al operator delete
sea una ganancia. (No obstante, puede ayudar a optimizar la eliminación nullptr
tiempo de compilación de gcc sin agregar una comprobación de tiempo de ejecución; consulte más abajo).
En primer lugar, un tamaño de código mayor fuera de un punto crítico real aumenta la presión en el caché L1I, y el caché uop descodificado aún más pequeño en las CPU x86 que tienen uno (Intel SnB-family, AMD Ryzen).
En segundo lugar, las ramas condicional adicionales agotan las entradas en los cachés de predicción de bifurcación (BTB = Branch Target Buffer, etc.). Dependiendo de la CPU, incluso una sucursal que nunca se toma puede empeorar las predicciones para otras ramas si alias en el BTB. (En otros, tal rama nunca recibe una entrada en el BTB, para guardar las entradas de las sucursales donde la predicción estática por defecto del fall-through es precisa.) Consulte https://xania.org/201602/bpu-part-one .
Si nullptr
es raro en una ruta de código dada, entonces, en promedio, la verificación y la sucursal para evitar la call
terminan con su programa gastando más tiempo en el cheque que el que se guarda.
Si el perfil muestra que tiene un punto caliente que incluye una delete
, y la instrumentación / registro muestra que a menudo llama a delete
con un nullptr, entonces vale la pena intentarlo
if (ptr) delete ptr;
en lugar de simplemente delete ptr;
La predicción de la sucursal puede tener mejor suerte en ese único sitio de llamadas que para la sucursal dentro de la operator delete
, especialmente si hay alguna correlación con otras sucursales cercanas. (Las BPU aparentemente modernas no solo miran cada una de las sucursales de forma aislada). Esto se suma a guardar la call
incondicional en la función de la biblioteca (más otro jmp
del resguardo PLT, de la sobrecarga dinámica de enlace en Unix / Linux).
Si está buscando null por cualquier otra razón, entonces podría tener sentido colocar la delete
dentro de la rama no nula de su código.
Puede evitar delete
llamadas en casos en que gcc pueda probar (después de la alineación) que un puntero es nulo, pero sin hacer una comprobación de tiempo de ejecución si no es así :
static inline bool
is_compiletime_null(const void *ptr) {
#ifdef __GNUC__
// __builtin_constant_p(ptr) is false even for nullptr,
// but the checking the result of booleanizing works.
return __builtin_constant_p(!ptr) && !ptr;
#else
return false;
#endif
}
Siempre devolverá false con clang porque evalúa __builtin_constant_p
antes de __builtin_constant_p
. Pero dado que clang ya omite las llamadas de delete
cuando puede probar que un puntero es nulo, no lo necesita.
En realidad, esto podría ayudar en casos std::move
, y puede usarlo de manera segura en cualquier lugar con (en teoría) ningún inconveniente de rendimiento. Siempre compilo a if(true)
o if(false)
, por lo que es muy diferente de if(ptr)
, lo que probablemente resulte en una rama de tiempo de ejecución porque el compilador probablemente no puede probar que el puntero no es nulo en la mayoría de los casos ya sea. (Sin embargo, una desreferencia, porque un deref nulo sería UB, y los compiladores modernos optimizados en base a la suposición de que el código no contiene ningún UB).
Podría hacer esto como una macro para evitar la formación de estructuras no optimizadas (y así "funcionaría" sin tener que alinear primero). Puede usar una expresión de enunciado C de GNU para evitar la doble evaluación de la macroarg ( vea ejemplos para GNU C min()
y max()
). Para el respaldo de compiladores sin extensiones GNU, puede escribir ((ptr), false)
o algo para evaluar el arg una vez para los efectos secundarios mientras produce un resultado false
.
Demostración: asm de gcc6.3 -O3 en el explorador compilador Godbolt
void foo(int *ptr) {
if (!is_compiletime_null(ptr))
delete ptr;
}
# compiles to a tailcall of operator delete
jmp operator delete(void*)
void bar() {
foo(nullptr);
}
# optimizes out the delete
rep ret
Se compila correctamente con MSVC (también en el enlace del compilador), pero con la prueba que siempre devuelve falso, bar()
es:
# MSVC doesn''t support GNU C extensions, and doesn''t skip nullptr deletes itself
mov edx, 4
xor ecx, ecx
jmp ??3@YAXPEAX_K@Z ; operator delete
Es interesante notar que la operator delete
del operator delete
toma el tamaño del objeto como una función arg ( mov edx, 4
), pero el código gcc / Linux / libstdc ++ simplemente pasa el puntero.
Relacionado: Encontré esta publicación de blog , usando C11 (no C ++ 11) _Generic
para intentar hacer algo __builtin_constant_p
como __builtin_constant_p
null-pointer checks dentro de los inicializadores estáticos.