Cómo se aceleraron 10 veces los seguimientos de pila de las Herramientas para desarrolladores de Chrome

Benedikt Meurer
Benedikt Meurer

Los desarrolladores web esperan un impacto mínimo o nulo en el rendimiento a la hora de depurar su código. Sin embargo, esta expectativa no es universal. Un desarrollador de C++ nunca esperaría que una compilación de depuración de su aplicación alcance el rendimiento de producción y, en los primeros años de Chrome, solo abrir Herramientas para desarrolladores tuvo un impacto significativo en el rendimiento de la página.

El hecho de que ya no se perciba esta degradación del rendimiento es el resultado de años de inversión en capacidades de depuración de DevTools y V8. Sin embargo, nunca podremos reducir la sobrecarga de rendimiento de Herramientas para desarrolladores a cero. Configurar puntos de interrupción, recorrer el código, recopilar seguimientos de pila, capturar un registro de rendimiento, etc., afecta la velocidad de ejecución en un grado variable. Después de todo, observar algo lo cambia.

Sin embargo, la sobrecarga de Herramientas para desarrolladores (como cualquier depurador) debería ser razonable. Recientemente, vimos un aumento significativo en la cantidad de informes que, en ciertos casos, las Herramientas para desarrolladores ralentizaban la aplicación a un nivel que ya no se puede usar. A continuación, puedes ver una comparación en paralelo del informe chromium:1069425, que ilustra la sobrecarga de rendimiento que produce, literalmente, solo tener Herramientas para desarrolladores abiertas.

Como puedes ver en el video, la demora es del orden de 5 a 10x, lo que claramente no es aceptable. El primer paso fue comprender dónde va todo el tiempo y qué causa esta desaceleración masiva cuando Herramientas para desarrolladores estaba abierto. El uso de Linux perf en el proceso del procesador de Chrome reveló la siguiente distribución del tiempo de ejecución general del procesador:

Tiempo de ejecución del procesador de Chrome

Si bien esperábamos ver algo relacionado con la recopilación de seguimientos de pila, no esperábamos que alrededor del 90% del tiempo de ejecución total se destina a simbolizar los marcos de pila. La simbolización hace referencia al acto de resolver nombres de funciones y posiciones de origen concretas (números de línea y columna en secuencias de comandos) a partir de marcos de pila sin procesar.

Inferencia de nombres de métodos

Lo que fue aún más sorprendente es el hecho de que casi todo el tiempo se utiliza en la función JSStackFrame::GetMethodName() en V8, aunque sabíamos por investigaciones anteriores que JSStackFrame::GetMethodName() no es ajeno al mundo de los problemas de rendimiento. Esta función intenta calcular el nombre del método para los marcos que se consideran invocaciones del método (marcos que representan invocaciones de funciones con el formato obj.func() en lugar de func()). Un vistazo rápido al código reveló que funciona realizando un recorrido completo del objeto y su cadena de prototipos, y buscando

  1. Propiedades de datos cuyos value son el cierre func
  2. propiedades del descriptor de acceso en las que get o set son iguales al cierre func.

Si bien esto por sí solo no suena particularmente económico, tampoco parece explicar esta terrible demora. Por lo tanto, comenzamos a analizar el ejemplo informado en chromium:1069425 y descubrimos que los seguimientos de pila se recopilaron para tareas asíncronas y mensajes de registro que se originan en classes.js, un archivo JavaScript de 10 MiB. Un análisis más detallado reveló que se trataba básicamente de un entorno de ejecución de Java y del código de la aplicación compilado en JavaScript. Los seguimientos de pila contenían varios fotogramas con métodos que se invocaban en un objeto A, por lo que pensamos que valía la pena comprender con qué tipo de objeto estamos tratando.

seguimientos de pila de un objeto

Aparentemente, el compilador de Java a JavaScript generó un solo objeto con la friolera de 82,203 funciones. Claramente, esto estaba empezando a ser interesante. Luego, regresamos a JSStackFrame::GetMethodName() de V8 para comprender si hay alguna fruta más baja que podamos recoger allí.

  1. Primero, busca el "name" de la función como una propiedad en el objeto y, si lo encuentra, verifica que el valor de la propiedad coincida con la función.
  2. Si la función no tiene nombre o el objeto no tiene una propiedad coincidente, recurre a una búsqueda inversa mediante el recorrido de todas las propiedades del objeto y sus prototipos.

En nuestro ejemplo, todas las funciones son anónimas y tienen propiedades "name" vacías.

A.SDV = function() {
   // ...
};

El primer hallazgo fue que la búsqueda inversa se dividió en dos pasos (realizada para el objeto en sí y para cada objeto de su cadena de prototipos):

  1. Extrae los nombres de todas las propiedades que se pueden enumerar.
  2. Realiza una búsqueda de propiedad genérica para cada nombre y prueba si el valor de la propiedad resultante coincide con el cierre que buscábamos.

Parecía algo sencillo, ya que para extraer los nombres es necesario revisar todas las propiedades. En lugar de hacer los dos pases, O(N) para la extracción del nombre y O(N log(N)) para las pruebas, podríamos hacer todo en un solo pase y verificar directamente los valores de las propiedades. Eso hizo que toda la función fuera 2-10 veces más rápida.

El segundo hallazgo fue aún más interesante. Si bien las funciones eran técnicamente anónimas, el motor V8 registró lo que llamamos un nombre inferido para ellas. En el caso de los literales de función que aparecen en el lado derecho de las asignaciones con el formato obj.foo = function() {...}, el analizador de V8 memoriza "obj.foo" como nombre inferido para el literal de función. Por lo tanto, en nuestro caso, eso significa que, si bien no teníamos el nombre adecuado que podíamos buscar, teníamos algo lo suficientemente parecido: para el ejemplo de A.SDV = function() {...} anterior, teníamos el "A.SDV" como nombre inferido, podíamos derivar el nombre de la propiedad a partir del nombre inferido buscando el último punto y, luego, buscar la propiedad "SDV" en el objeto. Eso hizo el truco en la mayoría de los casos, ya que reemplazó un recorrido completo costoso con una sola búsqueda de propiedades. Estas dos mejoras se implementaron como parte de esta lista de cambios y redujeron significativamente la demora para el ejemplo informado en chromium:1069425.

Error.stack

Podríamos haber elegido un día aquí. Sin embargo, ocurría algo sospechoso, ya que Herramientas para desarrolladores nunca usa el nombre de método para los marcos de pila. De hecho, la clase v8::StackFrame en la API de C++ ni siquiera expone una forma de obtener el nombre del método. Por lo tanto, parecía incorrecto que llamaríamos a JSStackFrame::GetMethodName() en primer lugar. En cambio, el único lugar en el que usamos (y exponemos) el nombre del método es la API de seguimiento de pila de JavaScript. Para comprender este uso, considera el siguiente ejemplo simple de error-methodname.js:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

Aquí tenemos una función foo que se instala con el nombre "bar" en object. La ejecución de este fragmento en Chromium genera el siguiente resultado:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

Aquí vemos la búsqueda del nombre del método en acción: el marco de pila superior se muestra para llamar a la función foo en una instancia de Object a través del método llamado bar. Por lo tanto, la propiedad no estándar error.stack hace un uso intensivo de JSStackFrame::GetMethodName() y, de hecho, nuestras pruebas de rendimiento también indican que los cambios hicieron que todo fuese mucho más rápido.

Aceleración de las microcomparativas de StackTrace

Sin embargo, en el tema de las Herramientas para desarrolladores de Chrome, el hecho de que el nombre del método se calcule a pesar de que no se use error.stack parece incorrecto. Aquí hay un poco de historia que nos ayuda: tradicionalmente, V8 tenía dos mecanismos distintos para recopilar y representar un seguimiento de pila para las dos API diferentes descritas anteriormente (la API de v8::StackFrame de C++ y la API de seguimiento de pila de JavaScript). Tener dos formas diferentes de hacer (aproximadamente) lo mismo era propenso a errores y, a menudo, generaba inconsistencias y errores, por lo que a fines de 2018 iniciamos un proyecto para establecer un solo cuello de botella para la captura de seguimiento de pila.

Ese proyecto fue un gran éxito y redujo drásticamente la cantidad de problemas relacionados con la recopilación de seguimientos de pila. La mayor parte de la información que se proporcionaba a través de la propiedad no estándar error.stack también se había calculado de forma diferida y solo cuando era realmente necesaria, pero como parte de la refactorización, aplicamos el mismo truco a los objetos v8::StackFrame. Toda la información sobre el marco de pila se calcula la primera vez que se invocó un método en él.

Esto suele mejorar el rendimiento, pero lamentablemente resultó ser algo contrario a cómo se usan estos objetos de la API de C++ en Chromium y Herramientas para desarrolladores. En particular, como presentamos una nueva clase v8::internal::StackFrameInfo, que contenía toda la información sobre un marco de pila que se expuso a través de v8::StackFrame o error.stack, siempre se calcula el superconjunto de la información proporcionada por ambas APIs, lo que significa que, para los usos de v8::StackFrame (y, en particular, para Herramientas para desarrolladores), también se calcula el nombre del método, tan pronto como se solicita la información sobre un marco de pila. Resulta que Herramientas para desarrolladores siempre solicita de inmediato información sobre la fuente y la secuencia de comandos.

En base a esto, pudimos refactorizar y simplificar drásticamente la representación de marcos de pila y hacerla aún más diferida, de modo que los usos en V8 y Chromium ahora solo paguen el costo de procesar la información que solicitan. Esto generó un aumento masivo del rendimiento para las Herramientas para desarrolladores y otros casos de uso de Chromium, que solo necesitan una fracción de la información sobre los marcos de pila (básicamente solo el nombre de la secuencia de comandos y la ubicación de origen en forma de desplazamiento de línea y columna), y abrió las puertas a más mejoras de rendimiento.

Nombres de las funciones

Con las refactorizaciones mencionadas anteriormente, la sobrecarga de simbolización (el tiempo empleado en v8_inspector::V8Debugger::symbolize) se redujo a alrededor del 15% del tiempo de ejecución general, y pudimos ver con mayor claridad dónde pasaba el tiempo V8 cuando (recopilaba y simbolizaba fotogramas de pila para el consumo en Herramientas para desarrolladores).

Costo de simbolización

Lo primero que se destacó fue el costo acumulado de calcular el número de línea y columna. La parte costosa aquí es en realidad calcular el desplazamiento de caracteres dentro de la secuencia de comandos (según el desplazamiento del código de bytes que obtenemos de V8), y resultó que, debido a nuestra refactorización anterior, lo hicimos dos veces, una cuando calculamos el número de línea y otra cuando calculamos el número de columna. El almacenamiento en caché de la posición de origen en instancias de v8::internal::StackFrameInfo ayudó a resolver esto con rapidez y eliminó por completo v8::internal::StackFrameInfo::GetColumnNumber de cualquier perfil.

El resultado más interesante para nosotros fue que v8::StackFrame::GetFunctionName fue sorprendentemente alta en todos los perfiles que observamos. Al profundizar más en esto, nos dimos cuenta de que era innecesariamente costoso calcular el nombre que mostraríamos para la función en el marco de pila en Herramientas para desarrolladores.

  1. primero buscar la propiedad "displayName" no estándar y, si eso generara una propiedad de datos con un valor de string, la usaríamos
  2. De lo contrario, vuelve a buscar la propiedad "name" estándar y vuelve a verificar si eso produce una propiedad de datos cuyo valor sea una string.
  3. y, finalmente, recurre a un nombre de depuración interno que infiere el analizador V8 y que se almacena en el literal de la función.

Se agregó la propiedad "displayName" como solución alternativa para la propiedad "name" en instancias de Function que son de solo lectura y no se pueden configurar en JavaScript, pero nunca se estandarizó y no tuvo un uso generalizado, ya que las herramientas para desarrolladores del navegador agregaron inferencias de nombres de funciones que hacen el trabajo en el 99.9% de los casos. Además, ES2015 hizo que la propiedad "name" en instancias de Function se pueda configurar, lo que quitó por completo la necesidad de una propiedad "displayName" especial. Dado que la búsqueda negativa de "displayName" es bastante costosa y no es realmente necesaria (ES2015 se lanzó hace cinco años), decidimos quitar la compatibilidad con la propiedad no estándar fn.displayName de V8 (y Herramientas para desarrolladores).

Después de la búsqueda negativa de "displayName", se quitó la mitad del costo de v8::StackFrame::GetFunctionName. La otra mitad va a la búsqueda genérica de la propiedad "name". Afortunadamente, ya teníamos una lógica implementada para evitar búsquedas costosas de la propiedad "name" en instancias (sin tocar) de Function, que presentamos hace un tiempo en V8 para que Function.prototype.bind() sea más rápido. portamos las verificaciones necesarias que nos permiten omitir la costosa búsqueda genérica, con el resultado de que v8::StackFrame::GetFunctionName ya no aparece en ninguno de los perfiles que consideramos.

Conclusión

Con las mejoras anteriores, redujimos significativamente la sobrecarga de Herramientas para desarrolladores en términos de seguimientos de pila.

Sabemos que aún existen varias mejoras posibles. Por ejemplo, la sobrecarga cuando se usan objetos MutationObserver sigue siendo perceptible, como se informa en chromium:1077657. Sin embargo, por el momento, abordamos los problemas principales y es posible que regresemos en el futuro para optimizar aún más el rendimiento de la depuración.

Descarga los canales de vista previa

Considera usar Chrome Canary, Dev o Beta como tu navegador de desarrollo predeterminado. Estos canales de vista previa te brindan acceso a las funciones más recientes de Herramientas para desarrolladores, prueban las API de vanguardia de la plataforma web y te permiten encontrar problemas en tu sitio antes que los usuarios.

Cómo comunicarte con el equipo de Herramientas para desarrolladores de Chrome

Usa las siguientes opciones para hablar sobre las nuevas funciones y los cambios en la publicación, o cualquier otro aspecto relacionado con Herramientas para desarrolladores.

  • Envíanos una sugerencia o un comentario a través de crbug.com.
  • Informa un problema en Herramientas para desarrolladores con Más opciones   Más   > Ayuda > Informar problemas de Herramientas para desarrolladores en esta herramienta.
  • Envía un tweet a @ChromeDevTools.
  • Deje comentarios en las Novedades de los videos de YouTube de Herramientas para desarrolladores o en las sugerencias de Herramientas para desarrolladores los videos de YouTube.