c# - ¿Por qué no se almacenan en caché los árboles de expresiones que no se capturan que se inicializan usando expresiones lambda?
compilation delegates (2)
Considere la siguiente clase:
class Program
{
static void Test()
{
TestDelegate<string, int>(s => s.Length);
TestExpressionTree<string, int>(s => s.Length);
}
static void TestDelegate<T1, T2>(Func<T1, T2> del) { /*...*/ }
static void TestExpressionTree<T1, T2>(Expression<Func<T1, T2>> exp) { /*...*/ }
}
Esto es lo que genera el compilador (de una manera un poco menos legible):
class Program
{
static void Test()
{
// The delegate call:
TestDelegate(Cache.Func ?? (Cache.Func = Cache.Instance.FuncImpl));
// The expression call:
var paramExp = Expression.Parameter(typeof(string), "s");
var propExp = Expression.Property(paramExp, "Length");
var lambdaExp = Expression.Lambda<Func<string, int>>(propExp, paramExp);
TestExpressionTree(lambdaExp);
}
static void TestDelegate<T1, T2>(Func<T1, T2> del) { /*...*/ }
static void TestExpressionTree<T1, T2>(Expression<Func<T1, T2>> exp) { /*...*/ }
sealed class Cache
{
public static readonly Cache Instance = new Cache();
public static Func<string, int> Func;
internal int FuncImpl(string s) => s.Length;
}
}
De esta manera, el delegado pasado con la primera llamada se inicializa una vez y se reutiliza en múltiples llamadas de Test
.
Sin embargo, el árbol de expresiones pasado con la segunda llamada no se reutiliza, una nueva expresión lambda se inicializa en cada llamada de Test
.
Siempre que no capture nada y que los árboles de expresión sean inmutables, ¿cuál sería el problema con el almacenamiento en caché del árbol de expresión también?
Editar
Creo que necesito aclarar por qué creo que los árboles de expresiones son aptos para ser almacenados en caché.
- El árbol de expresión resultante se conoce en el momento de la compilación (bueno, es creado por el compilador).
- Son inmutables. Por lo tanto, a diferencia del ejemplo de matriz proporcionado por X39 a continuación, un árbol de expresiones no se puede modificar después de que se haya inicializado y, por lo tanto, es seguro almacenarlo en caché.
- Solo puede haber tantos árboles de expresión en una base de código. Nuevamente, estoy hablando de los que se pueden almacenar en caché, es decir, los que se inicializan usando expresiones lambda (no las que se crean manualmente) sin capturar ningún exterior estado / variable Auto-interning de literales de cadena sería un ejemplo similar.
- Están destinados a ser recorridos: se pueden compilar para crear un delegado, pero esa no es su función principal. Si alguien quiere un delegado compilado, solo puede aceptar uno (un
Func<T>
, en lugar de unExpression<Func<T>>
). Aceptar un árbol de expresiones indica que se utilizará como una estructura de datos. Por lo tanto, "deberían compilarse primero" no es un argumento sensato contra el almacenamiento en caché de árboles de expresión.
Lo que estoy preguntando es los posibles inconvenientes de almacenar en caché estos árboles de expresión. Los requisitos de memoria mencionados por svick son un ejemplo más probable.
¿Por qué no se almacenan en caché los árboles de expresiones que no se capturan que se inicializan usando expresiones lambda?
Escribí ese código en el compilador, tanto en la implementación original de C # 3 como en la reescritura de Roslyn.
Como siempre digo cuando se hace una pregunta de "por qué no": a los escritores de compiladores no se les exige que proporcionen una razón por la que no hicieron algo . Hacer algo requiere trabajo, requiere esfuerzo y cuesta dinero. Por lo tanto, la posición predeterminada siempre es no hacer algo cuando el trabajo no es necesario.
Más bien, la persona que quiere que se haga el trabajo debe justificar por qué ese trabajo vale el costo. Y en realidad, el requisito es más fuerte que eso. La persona que desea realizar el trabajo debe justificar por qué el trabajo innecesario es una mejor manera de gastar tiempo, esfuerzo y dinero que cualquier otro uso posible del tiempo del desarrollador . Hay literalmente un número infinito de formas de mejorar el rendimiento del compilador, el conjunto de características, la solidez, la facilidad de uso, etc. ¿Qué hace que este sea tan grande?
Ahora, cada vez que doy esta explicación, recibo una respuesta negativa que dice "Microsoft es rico, bla bla bla". Tener muchos recursos no es lo mismo que tener recursos infinitos, y el compilador ya es extremadamente caro. También recibo una respuesta de rechazo que dice "el código abierto hace que el trabajo sea libre", lo que absolutamente no lo hace.
Noté que el tiempo era un factor. Puede ser útil ampliar eso más adelante.
Cuando se estaba desarrollando C # 3.0, Visual Studio tenía una fecha específica en la que sería "lanzado a la fabricación", un término extraño desde el momento en que el software se distribuía principalmente en CDROM que no podían cambiarse una vez que se imprimieron. Esta fecha no fue arbitraria; más bien, había toda una cadena de dependencias que lo siguieron. Si, digamos, SQL Server tenía una característica que dependía de LINQ, no tendría ningún sentido retrasar la versión de VS hasta después de la versión de SQL Server de ese año, por lo que la programación de VS afectó la programación de SQL Server, que a su vez afectó a la de otros equipos. horarios, y así sucesivamente.
Por lo tanto, todos los equipos de la organización VS enviaron un cronograma, y el equipo con el mayor número de días trabajando en ese cronograma fue el "polo largo". El equipo de C # fue el polo largo para VS, y yo el de polo largo para el equipo del compilador de C #, por lo que todos los días que llegué tarde a la entrega de las características de mi compilador fue un día en que Visual Studio, y cada producto posterior se deslizaría. y decepcionar a sus clientes .
Este es un poderoso desincentivo para hacer trabajo de desempeño innecesario, particularmente el trabajo de desempeño que podría empeorar las cosas, no mejorarlas . Un caché sin una política de caducidad tiene un nombre: es una pérdida de memoria .
Como se nota, las funciones anónimas se almacenan en caché. Cuando implementé lambdas, utilicé el mismo código de infraestructura que las funciones anónimas, de modo que el caché fue (1) "costo hundido": el trabajo ya estaba hecho y habría sido más trabajo desactivarlo que dejarlo activado, y (2) ya había sido probado y examinado por mis predecesores.
Consideré implementar un caché similar en los árboles de expresión, usando la misma lógica, pero me di cuenta de que esto sería (1) trabajo, lo que requiere tiempo, que ya me faltaba, y (2) no tenía idea de los impactos del rendimiento. ser de almacenamiento en caché de tal objeto. Los delegados son muy pequeños . Los delegados son un solo objeto; si el delegado es lógicamente estático, que son los que C # almacena en caché de manera agresiva, ni siquiera contiene una referencia al receptor. Los árboles de expresión, por el contrario, son potencialmente árboles enormes . Son una gráfica de objetos pequeños, pero esa gráfica es potencialmente grande. ¡Los gráficos de objetos hacen que el recolector de basura trabaje más cuanto más tiempo vivan!
Por lo tanto, las pruebas de rendimiento y las métricas se usaron para justificar la decisión de almacenar en memoria caché a los delegados no serían aplicables a los árboles de expresión, ya que las cargas de memoria eran completamente diferentes. No quería crear una nueva fuente de fugas de memoria en nuestra nueva característica de idioma más importante. El riesgo era demasiado alto.
Pero un riesgo podría valer la pena si el beneficio es grande. Entonces, ¿cuál es el beneficio? Comience preguntándose "¿dónde se usan los árboles de expresión?" En las consultas LINQ que van a ser remotadas a bases de datos. Esta es una operación muy costosa tanto en tiempo como en memoria . Agregar un caché no le da una gran ganancia porque el trabajo que está a punto de hacer es millones de veces más costoso que ganar; La victoria es el ruido.
Compare eso con la victoria de rendimiento para los delegados. La diferencia entre "asignar x => x + 1
, luego llamarlo" un millón de veces y "verificar el caché, si no está almacenado en caché asignarlo, llamarlo" es cambiar una asignación por un cheque, lo que podría ahorrarle nanosegundos completos . Parece que no es gran cosa, pero la llamada también llevará nanosegundos , por lo que en términos porcentuales, es significativo. Cachear a los delegados es una clara victoria. El almacenamiento en caché de árboles de expresión no está cerca de una clara victoria; Necesitaríamos datos de que es un beneficio que justifica el riesgo.
Por lo tanto, fue una decisión fácil de tomar para no gastar tiempo en esta optimización innecesaria, probablemente inadvertida, sin importancia en C # 3.
Durante C # 4, tuvimos muchas más cosas importantes que hacer que revisar esta decisión.
Después de C # 4, el equipo se dividió en dos sub-equipos, uno para volver a escribir el compilador, "Roslyn", y el otro para implementar async-await en el código base del compilador original. El equipo de async-await se consumió por completo al implementar esa característica compleja y difícil, y, por supuesto, el equipo era más pequeño de lo habitual. Y sabían que todo su trabajo eventualmente sería replicado en Roslyn y luego desechado; Ese compilador estaba al final de su vida. Así que no hubo incentivo para gastar tiempo o esfuerzo para agregar optimizaciones.
La optimización propuesta estaba en mi lista de cosas a considerar cuando reescribí el código en Roslyn, pero nuestra máxima prioridad era hacer que el compilador funcionara de principio a fin antes de optimizar partes pequeñas de él, y dejé Microsoft en 2012, antes de eso. el trabajo fue terminado
En cuanto a por qué ninguno de mis compañeros de trabajo volvió a examinar este problema después de que me fuera, tendría que preguntarles, pero estoy bastante seguro de que estaban muy ocupados haciendo un trabajo real en características reales solicitadas por clientes reales, o en optimizaciones de rendimiento que tenían Mayores ganancias por menor costo. Ese trabajo incluyó abrir el compilador, que no es barato.
Entonces, si quieres que se haga este trabajo, tienes algunas opciones.
- El compilador es de código abierto; Podrías hacerlo tú mismo. Si eso suena como mucho trabajo por muy poco beneficio para usted, entonces ahora tiene una comprensión más intuitiva de por qué nadie ha hecho este trabajo desde que se implementó la función en 2005.
Por supuesto, esto todavía no es "gratis" para el equipo del compilador. Alguien tendría que gastar tiempo y esfuerzo y dinero revisando su trabajo. Recuerde, la mayor parte del costo de una optimización del rendimiento no son los cinco minutos que lleva cambiar el código. ¡Son las semanas de pruebas bajo una muestra de todas las posibles condiciones del mundo real que demuestran que la optimización funciona y no empeora las cosas! El trabajo de rendimiento es el trabajo más caro que hago.
- El proceso de diseño está abierto. Ingrese un problema y en ese tema, indique una razón convincente por la que cree que esta mejora vale la pena. Con los datos.
Hasta ahora todo lo que has dicho es por qué es posible . Posible no lo corta! Muchas cosas son posibles. Danos números que justifiquen por qué los desarrolladores de compiladores deberían dedicar su tiempo a realizar esta mejora en lugar de implementar las nuevas funciones solicitadas por los clientes.
La ganancia real para evitar las asignaciones repetidas de árboles de expresión complejos es evitar la presión de recolección , y esto es una preocupación importante. Muchas características en C # están diseñadas para evitar la presión de recolección, y los árboles de expresión NO son una de ellas. Mi consejo para ti si quieres esta optimización es que te concentres en su impacto sobre la presión, porque ahí es donde encontrarás la mayor victoria y podrás hacer el argumento más convincente.
El compilador hace lo que siempre está haciendo, no guardando en caché lo que alimentes en él.
Para darse cuenta de que esto siempre está sucediendo, intente pasar una nueva matriz a su método.
this.DoSomethingWithArray(new string[] {"foo","bar" });
llegará a
IL_0001: ldarg.0
IL_0002: ldc.i4.2
IL_0003: newarr [mscorlib]System.String
IL_0008: dup
IL_0009: ldc.i4.0
IL_000A: ldstr "foo"
IL_000F: stelem.ref
IL_0010: dup
IL_0011: ldc.i4.1
IL_0012: ldstr "bar"
IL_0017: stelem.ref
IL_0018: call instance void Test::DoSomethingWithArray(string[])
En lugar de almacenar en caché la matriz una vez y reutilizarla cada vez.
Lo mismo se aplica más o menos a Expresiones, solo que aquí el compilador está realizando el útil trabajo de generar su árbol para usted, lo que significa que al final se espera que sepa cuándo se necesita el almacenamiento en caché y aplíquelo en consecuencia.
Para obtener una versión en caché, use algo como esto:
private static System.Linq.Expressions.Expression<Func<object, string>> Exp = (obj) => obj.ToString();