Primera transacción por encima del presupuesto
Requisito
Recorre el feed de tarjeta de esta semana en orden de llegada e informa de la primera transacción por encima del presupuesto de 100 — y deja de buscar. Imprime también cuántas transacciones se examinaron realmente, para demostrar que la búsqueda cortocircuitó. Los datos están en el código; las dos versiones deben imprimir las líneas que aparecen bajo Salida esperada.
Salida esperada
First over budget: T-04 (128) Examined 4 of 8 transactions
Lado a lado
RxDart
FxDart
Por qué difieren
Aquí ambos lados son genuinamente perezosos, cada uno en su propio
dialecto. El firstWhere de RxDart resuelve su future en
la primera coincidencia y cancela la suscripción —
las cuatro transacciones restantes nunca se entregan. El
find de FxDart simplemente deja de
tirar — las cuatro transacciones restantes nunca se
demandan. Cancelación y demanda son las palabras de cada modelo para
la misma economía, y la línea «Examined 4 of 8» sale idéntica en
ambos lados.
La diferencia instructiva es dónde vive el contador. Un
stream tiene un «entre»: doOnData pincha la tubería entre
operadores, así que el predicado rx se mantiene puro mientras el tap
observa el tráfico. Una cadena pull no tiene «entre» — el momento de
la demanda es la propia llamada al predicado, así que el lado FxDart
cuenta dentro de él. Ninguna de las dos formas es mejor; son los
idiomas de observación nativos de push y pull. Veredicto: empate — un
operador cada uno, y ambos se detienen exactamente en el momento
justo.
Por qué la diferencia del benchmark es tan grande
Las barras de abajo no miden la pereza. A la escala del benchmark, la primera transacción por encima del presupuesto está en el elemento 900 001 de un millón, y ambos lados examinan exactamente 900 001 — los checksums lo demuestran. Lo que miden las barras es el precio de que un elemento atraviese cada modelo: unos 1 ns del lado pull, unos 88 ns del lado push.
Eso no es un defecto de RxDart, ni una forma de escribirlo que se arregle eligiendo mejor los operadores. Medimos las alternativas sobre el mismo conjunto de datos — 900 001 elementos, resultados idénticos:
where().firsten lugar defirstWhere: 50 ns (la combinación de operadores más rápida que encontramos)- contar dentro del predicado en lugar de un tap
doOnData, lo que elimina una capa de transformación: 62 ns await forconbreak, sin operadores: 227 ns — el más lento, no el más rápido- un
StreamControllersíncrono movido a mano, que ya no es Rx idiomático pero es el suelo del modelo push: 20 ns
Incluso ese suelo es 20× la cadena pull. La razón es estructural. Un
Stream es un mecanismo de entrega: cada valor se
entrega a una suscripción, a través de todas las capas de
transformación que tenga la cadena, con la disciplina del bucle de
eventos que hace seguro compartir, pausar, cancelar y componer un
stream a través de fronteras asíncronas. El find de
FxDart sobre una List compila a un bucle indexado que
llama a un closure por elemento y retorna — no hay entrega, ni
suscripción, ni planificación, porque aquí nada es realmente
asíncrono.
¿No está amañado por dónde se sitúa el disparador?
Pregunta justa, y lo único de este caso que sí es una decisión de criterio. Como la búsqueda se cortocircuita, es el conjunto de datos el que decide cuánto trabajo se hace: el benchmark coloca la primera transacción por encima del presupuesto al 90% del recorrido, así que una ejecución de un millón examina 900.001. Muévela y ambos lados trabajan proporcionalmente menos. Si la diferencia fuese un artefacto de esa elección, adelantar el disparador la cerraría.
No la cierra. Medido seguido en una máquina ociosa, cinco rondas cada uno, con el conjunto del 90% ejecutado dos veces para mostrar el ruido:
| Escala | Disparador | Examinados | RxDart | FxDart | Factor |
|---|---|---|---|---|---|
| 100 | 90% | 91 | 10 µs | 286 ns | 35× |
| 100 | 90% (repetición) | 91 | 11 µs | 280 ns | 39× |
| 100 | 50% | 51 | 7,5 µs | 256 ns | 29× |
| 1.000.000 | 90% | 900.001 | 79,1 ms | 861 µs | 92× |
| 1.000.000 | 90% (repetición) | 900.001 | 78,9 ms | 901 µs | 88× |
| 1.000.000 | 50% | 500.001 | 44,2 ms | 374 µs | 118× |
Reducir el trabajo a la mitad reduce a la mitad ambos lados — y el factor se ensancha, de 88× a 118×. Divide las filas del millón entre los elementos examinados y la razón queda clara: RxDart cuesta 87,7, 87,9 y 88,4 ns por elemento en las tres ejecuciones, plano independientemente de dónde esté el disparador, mientras que FxDart cuesta 1,00, 0,96 y 0,75 ns — el recorrido más corto sale incluso algo más barato por elemento. Adelantar el disparador, si acaso, favorece a FxDart. Las filas de 100 elementos las domina el coste fijo de arranque y no el coste por elemento, por eso sus factores son menores y más ruidosos; y por eso mismo existe la escala grande.
Así que el 90% no está ahí para inflar nada — está ahí para que la escala grande mida el tirar de los datos y no el arranque, y es la posición menos favorable a FxDart de las que probamos. Lo que la posición no puede cambiar es el precio por elemento, y en eso consiste toda la diferencia.
Así que la lectura honesta de estas barras es estrecha: cuando la fuente ya está en memoria y la pregunta es síncrona, hacerla pasar por un stream es puro sobrecoste. Convierte la fuente en algo genuinamente asíncrono — un socket, un websocket, una API paginada — y ese coste por elemento desaparece bajo la E/S que el stream fue diseñado para gestionar, que es justo el terreno que cubre la Parte 4. El veredicto del código sigue siendo empate: ambos escriben el mismo cortocircuito con un operador, y ambos se detienen en el momento justo.
Benchmark
N = 100
Tiempo Empate
Memoria pico Empate
N = 1,000,000
Tiempo Gana FxDart
Memoria pico Gana FxDart
Las barras son medianas de iteraciones cronometradas repetidas en procesos nuevos por lado (los N pequeños se agrupan por resolución del temporizador). Dos lados a menos del 5% entre sí — o a menos de 0.6 ms, una diferencia que nadie puede percibir — cuentan como empate; las carreras relativas ajustadas se vuelven a medir hasta 5 veces. En una app, cualquier cosa por debajo de unos pocos milisegundos es invisible para el usuario, gane la barra que gane. La memoria es el RSS pico del proceso. La VM de Dart y el dataset son idénticos en ambos lados, así que la diferencia entre las dos barras es lo que retiene el pipeline en sí.