Rendimiento DAX
Análisis Detallado de Rendimiento DAX en DirectQuery
🔍 Análisis Detallado de Rendimiento DAX en DirectQuery
Guía completa para identificar, diagnosticar y corregir los anti-patrones que destruyen el rendimiento de tus medidas en modelos DirectQuery — Agosto 2026
📑 Tabla de Contenidos
- El problema de DirectQuery: por qué el DAX importa el doble
- Herramientas de diagnóstico: DAX Studio y Performance Analyzer
- Los 8 anti-patrones más destructivos en DirectQuery
- Caso práctico: auditoría del modelo de ejemplo
- Patrones de reescritura avanzada
- Checklist de optimización y mantenimiento
- Conclusión y próximos pasos
1. El problema de DirectQuery: por qué el DAX importa el doble
En Import Mode, Power BI carga los datos en el motor VertiPaq (columnar, comprimido, en memoria). Una medida DAX mal escrita puede ser lenta, pero el Storage Engine (SE) resuelve la mayoría de las agregaciones en milisegundos gracias a la compresión y los índices columnares.
En DirectQuery, cada medida genera una o más consultas SQL que se envían a la fuente de datos remota. Si tu DAX fuerza iteraciones fila a fila, no es Power BI quien paga el coste: es tu base de datos relacional ejecutando miles de subconsultas anidadas. En DirectQuery, un anti-patrón DAX no es solo lento: puede colapsar el servidor de origen.
⚡ Diferencia clave: Import vs. DirectQuery
| Aspecto | Import Mode | DirectQuery |
|---|---|---|
| Motor de almacenamiento | VertiPaq (in-memory) | Fuente relacional (SQL) |
| Iterador sobre tabla de hechos | FE itera en memoria (~ms) | Genera N consultas SQL |
| Context transition | Lookup en caché comprimida | JOIN + subconsulta SQL |
| DISTINCTCOUNT alta cardinalidad | Scan columnar optimizado | GROUP BY + COUNT(DISTINCT) |
| Tiempo de respuesta objetivo | < 500 ms | < 3 segundos |
🚨 Advertencia crítica: En DirectQuery, cada medida en un visual genera una consulta SQL independiente. Si tienes 12 visuales en una página y cada uno usa 3 medidas, estás lanzando hasta 36 consultas simultáneas contra tu base de datos. Un solo anti-patrón multiplicado por 36 puede saturar la conexión.
2. Herramientas de diagnóstico: DAX Studio y Performance Analyzer
2.1 Performance Analyzer (Power BI Desktop)
Es tu primera línea de defensa. Ve a Vista → Performance Analyzer → Iniciar grabación. Interactúa con el reporte y obtendrás tres métricas por visual:
- Tiempo DAX (ms): Lo que tarda en ejecutarse la consulta DAX. Si esto es alto, el problema está en la medida.
- Tiempo Visual (ms): Renderizado del gráfico en el cliente. Normalmente < 50 ms.
- Tiempo Otros (ms): Latencia de red, esperas entre consultas.
💡 Regla de oro: Si Tiempo DAX representa más del 90% del tiempo total, el problema es DAX puro. Si Tiempo Otros es alto, revisa la latencia de red o el gateway.
2.2 DAX Studio: análisis de Query Plan y Server Timings
Conecta DAX Studio a tu modelo, activa Server Timings y Query Plan, y pega la consulta DAX que copiaste del Performance Analyzer. Verás:
| Métrica | Qué indica | Umbral crítico |
|---|---|---|
| FE Time (Formula Engine) | Tiempo de cálculo fila a fila | > 50% del total |
| SE Time (Storage Engine) | Tiempo de scan/agregación | > 3 segundos |
| CallbackDataID | Callbacks de FE a SE (iteración) | > 10 por consulta |
| VertiPaq Scan / SQL Query | Consultas SQL generadas | > 5 por visual |
| Records | Filas procesadas | > 1M sin filtro previo |
🔬 Cómo interpretar el split FE vs. SE en DirectQuery
En DirectQuery, el SE no es VertiPaq: es tu base de datos SQL. DAX Studio muestra las consultas SQL traducidas. Si ves múltiples SQL Queries para una sola medida, significa que el FE está fragmentando el trabajo y no consigue empujar todo al origen.
Objetivo: Maximizar el tiempo en SE (SQL) y minimizar CallbackDataID. Un plan ideal muestra 1-2 consultas SQL con agregaciones GROUP BY nativas y FE Time cercano a cero.
3. Los 8 anti-patrones más destructivos en DirectQuery
A continuación, los anti-patrones que más daño causan en entornos DirectQuery, ordenados por severidad. Cada uno incluye: por qué es lento en DirectQuery, cómo detectarlo, y la reescritura correcta.
CRÍTICO #1 SUMX / AVERAGEX / COUNTX sobre tabla de hechos
¿Por qué destruye el rendimiento en DirectQuery?
SUMX itera fila a fila sobre la tabla de hechos. En Import, el FE hace esto en memoria comprimida. En DirectQuery, cada iteración puede forzar al FE a solicitar datos al SE (SQL) fila a fila, generando un CallbackDataID por cada fila. Con 1 millón de filas, eso son 1 millón de callbacks.
— ANTI-PATRÓN: iterador sobre hechos
Total Sales SUMX =
SUMX(Sales, Sales[Qty] * Sales[Price])
Genera: 1 callback por fila de Sales. Si Sales tiene 5M filas → 5M callbacks SQL.
— SOLUCIÓN A: columna pre-calculada en Power Query
— Añadir [LineTotal] = [Qty] * [Price] en la fuente
Total Sales = SUM(Sales[LineTotal])
— SOLUCIÓN B: si no es posible, usar SUM directo
— (solo si la multiplicación ya existe como columna)
Genera: 1 consulta SQL con SUM agrupado. Cero callbacks.
— ANTI-PATRÓN: condición dentro del iterador
London Sales =
SUMX(Sales,
IF(Sales[Region] = «London»,
Sales[Amount], BLANK() ) )
— SOLUCIÓN: filtro fuera del iterador
London Sales =
CALCULATE(
SUM(Sales[Amount]),
Sales[Region] = «London»
)
💡 Detección automática: Usa la medida _Medidas con SUMX del cuadro de mando. Si devuelve >0 en un modelo DirectQuery, revisa inmediatamente.
CRÍTICO #2 CallbackDataID por filtros dinámicos en iteradores
¿Qué es CallbackDataID?
Es el evento que registra DAX Studio cuando el Formula Engine necesita pedir datos al Storage Engine fila a fila durante una iteración. En DirectQuery, cada CallbackDataID se traduce en una subconsulta SQL. Es el peor enemigo del rendimiento.
— ANTI-PATRÓN: FILTER anidado + CALCULATE
Sales con Descuento =
SUMX(Sales,
VAR _Check =
CALCULATE(
COUNTROWS(Sales),
FILTER(ALL(Sales),
Sales[Region] = «London» )
)
RETURN
IF(_Check > 0, Sales[Amount], BLANK())
)
Query plan: 104 CallbackDataID. FE: 44 ms. SE: 3 ms.
— SOLUCIÓN: predicado simple empujado a SQL
Sales con Descuento =
CALCULATE(
SUM(Sales[Amount]),
Sales[Region] = «London»
)
Query plan: 5 CallbackDataID. FE: 2 ms. SE: 3 ms.
🚨 En DirectQuery, el impacto es exponencial: Un CallbackDataID en Import puede costar 0.01 ms. En DirectQuery contra una base de datos remota, cada callback puede costar 50-200 ms por latencia de red. 100 callbacks = 5-20 segundos de espera.
ALTO #3 FILTER sobre tabla de hechos completa
¿Por qué es peligroso en DirectQuery?
FILTER(Sales, …) materializa la tabla de hechos completa en memoria antes de aplicar la condición. En DirectQuery, esto fuerza un SELECT * FROM Sales completo desde la fuente. Si Sales tiene 100M filas, estás transfiriendo 100M filas por red solo para filtrar 5 de ellas.
— ANTI-PATRÓN: FILTER sobre hechos
Filtered Sales =
CALCULATE(
SUM(Sales[Amount]),
FILTER(Sales, Sales[Region] = «East»)
)
— SOLUCIÓN A: predicado directo en CALCULATE
Filtered Sales =
CALCULATE(
SUM(Sales[Amount]),
Sales[Region] = «East»
)
— SOLUCIÓN B: filtro sobre dimensión
Filtered Sales =
CALCULATE(
SUM(Sales[Amount]),
DimRegion[RegionName] = «East»
)
💡 Regla mnemotécnica: Nunca pongas el nombre de una tabla de hechos dentro de FILTER(). Si ves FILTER(Sales o FILTER(Fact, es una señal de alarma. El filtro debe ir sobre una dimensión o como predicado de columna en CALCULATE.
ALTO #4 SUMMARIZECOLUMNS dentro de medidas con filtros dinámicos
El problema específico de DirectQuery
SUMMARIZECOLUMNS está optimizado para consultas de reporte (queries de visual), pero cuando se usa dentro de una medida con filtros dinámicos, rompe la fusión de consultas SQL. Cada medida con SUMMARIZECOLUMNS genera su propio SELECT ... GROUP BY en la fuente, impidiendo que Power BI agrupe múltiples medidas en una sola consulta SQL.
— ANTI-PATRÓN: SUMMARIZECOLUMNS en medida
Top Products =
SUMMARIZECOLUMNS(
Sales[Product],
«Sales», SUM(Sales[Amount])
)
— SOLUCIÓN: SUMX + VALUES o ADDCOLUMNS
Top Products =
SUMX(
VALUES(Sales[Product]),
CALCULATE(SUM(Sales[Amount]))
)
— O mejor aún, si Product es dimensión:
Top Products =
SUMX(
VALUES(DimProduct[ProductName]),
[Total Sales]
)
ALTO #5 DISTINCTCOUNT sobre columnas de alta cardinalidad en DirectQuery
El coste real en SQL
DISTINCTCOUNT genera COUNT(DISTINCT columna) en SQL. Sobre una columna de texto con millones de valores únicos (emails, GUIDs, descripciones), el motor de base de datos debe ordenar y deduplicar toda la columna. En DirectQuery, esto no se cachea: cada interacción del usuario vuelve a ejecutar el COUNT DISTINCT completo.
— ANTI-PATRÓN: DISTINCTCOUNT en texto
Unique Customers =
DISTINCTCOUNT(Sales[CustomerEmail])
SQL generado: SELECT COUNT(DISTINCT CustomerEmail) FROM Sales
— SOLUCIÓN A: usar ID numérico (relación)
Unique Customers =
DISTINCTCOUNT(Sales[CustomerID])
— SOLUCIÓN B: aproximación (si es aceptable)
Unique Customers Approx =
SUMX(VALUES(Sales[CustomerID]), 1)
— SOLUCIÓN C: pre-agregado en la fuente
— Crear vista SQL: SELECT COUNT(DISTINCT CustomerID) …
💡 Alternativa avanzada: Si CustomerID es clave primaria de la dimensión, usa COUNTROWS(DimCustomer) en lugar de DISTINCTCOUNT. Es una simple consulta SELECT COUNT(*) sobre la tabla de dimensiones, mucho más rápida.
MEDIO #6 ALL / REMOVEFILTERS indiscriminados sobre tablas grandes
Impacto en DirectQuery
ALL(Sales) elimina todos los filtros de la tabla de hechos. En DirectQuery, esto fuerza un SELECT SUM(Amount) FROM Sales sin WHERE clause, escaneando toda la tabla remota. Si Sales tiene 500M filas, esto es un scan completo de 500M filas por cada celda del visual.
— ANTI-PATRÓN: ALL sobre tabla grande
All Sales =
CALCULATE(
SUM(Sales[Amount]),
ALL(Sales)
)
— SOLUCIÓN A: ALLSELECTED (respeta selección)
All Sales Selected =
CALCULATE(
SUM(Sales[Amount]),
ALLSELECTED(Sales)
)
— SOLUCIÓN B: ALLEXCEPT (preserva dimensión)
All Sales by Region =
CALCULATE(
SUM(Sales[Amount]),
ALLEXCEPT(Sales, Sales[Region])
)
ALTO #7 DirectQuery sin agregaciones automáticas ni tablas de agregación
La solución arquitectónica
Este no es un anti-patrón DAX puro, sino una decisión de modelado. En DirectQuery, cada visual consulta la fuente en tiempo real. Si tienes un gráfico de barras mensual sobre 5 años de datos transaccionales, Power BI está ejecutando SELECT ... GROUP BY Month sobre miles de millones de filas cada vez que el usuario cambia un filtro.
— ESTRATEGIA: Tabla de agregación mensual en Import (Dual)
— 1. Crear tabla Agg_Sales_Month en la fuente o Power Query
— 2. Configurar modo de almacenamiento: Import
— 3. Power BI usará automáticamente Agg_Sales_Month cuando el usuario agrupe por mes
Total Sales = SUM(Sales[Amount])
— Esta medida se evaluará sobre Agg_Sales_Month (Import, rápido)
— en lugar de Sales (DirectQuery, lento) cuando sea posible
💡 Configuración: Modelado → Agregaciones automáticas → Configurar. Define la granularidad de la tabla de agregación (ej. mes + categoría). Power BI redirige las consultas automáticamente sin cambiar el DAX.
MEDIO #8 Funciones de inteligencia temporal nativas en DirectQuery
El problema documentado
Funciones como SAMEPERIODLASTYEAR, DATESYTD, DATEADD y DATESBETWEEN generan SQL complejo con múltiples subconsultas y uniones. En muchos casos, el motor no consigue optimizar la granularidad y devuelve datos a nivel de día cuando solo necesitas mes, forzando al FE a agregar.
— ANTI-PATRÓN: SAMEPERIODLASTYEAR nativo
Sales LY =
CALCULATE(
SUM(Sales[Amount]),
SAMEPERIODLASTYEAR(Date[Date])
)
SQL generado: subconsulta compleja con DATEADD. FE time: 343 ms.
— SOLUCIÓN: inteligencia temporal manual
Sales LY =
VAR _CurrentYear = SELECTEDVALUE(Date[Year])
VAR _CurrentMonth = SELECTEDVALUE(Date[Month])
RETURN
CALCULATE(
SUM(Sales[Amount]),
ALL(Date),
Date[Year] = _CurrentYear – 1,
Date[Month] = _CurrentMonth
)
SQL generado: predicados simples con índices. FE time: 20 ms.
⚠️ Nota importante: SAMEPERIODLASTYEAR y DATEADD sí reciben optimización de granularidad en versiones recientes (agrupan al nivel necesario). Pero DATESBETWEEN, DATESYTD y lógica temporal compleja no se benefician de esta optimización. Usa DAX Studio para verificar qué SQL genera tu medida.
4. Caso práctico: auditoría del modelo de ejemplo
Aplicamos el análisis al modelo del archivo Cuadro_Mando_Rendimiento_PowerBI.xlsx. Asumimos que la tabla Sales está en DirectQuery.
📊 Resumen de la auditoría
7
Medidas totales
3
Críticas
1
Alto riesgo
1
Medio
2
OK
57%
% Riesgo
4.1 Medida: Total Sales SUMX CRÍTICO
Total Sales SUMX = SUMX(Sales, Sales[Qty] * Sales[Price])
Diagnóstico DirectQuery: SUMX itera sobre Sales fila a fila. En DirectQuery, esto genera un CallbackDataID por cada fila. Con 10M filas → 10M callbacks. Tiempo estimado: 30-120 segundos por celda.
Corrección:
— Opción A: columna calculada en Power Query (foldable)
— Añadir [LineTotal] = [Qty] * [Price] en la fuente SQL o Power Query
Total Sales = SUM(Sales[LineTotal])
— Opción B: si no se puede modificar la fuente, crear columna calculada en el modelo
— (Nota: en DirectQuery, las columnas calculadas pueden no ser foldables)
— Opción C: vista materializada en SQL con LineTotal precalculado
4.2 Medida: London Sales CRÍTICO
London Sales = SUMX(Sales, IF(Sales[Region] = «London», Sales[Amount], BLANK()))
Diagnóstico DirectQuery: IF dentro de SUMX fuerza evaluación fila a fila en el FE. El SE no puede empujar la condición al SQL porque está dentro del iterador. Resultado: scan completo de Sales + evaluación condicional en FE.
Corrección:
London Sales = CALCULATE(SUM(Sales[Amount]), Sales[Region] = «London»)
Mejora esperada: De 30s a <500ms. SQL generado: SELECT SUM(Amount) FROM Sales WHERE Region = 'London'.
4.3 Medida: Filtered Sales ALTO
Filtered Sales = CALCULATE(SUM(Sales[Amount]), FILTER(Sales, Sales[Region] = «East»))
Diagnóstico DirectQuery: FILTER(Sales, …) materializa toda la tabla de hechos. En DirectQuery, esto traduce a SELECT * FROM Sales completo. El filtro se aplica después en memoria.
Corrección:
Filtered Sales = CALCULATE(SUM(Sales[Amount]), Sales[Region] = «East»)
4.4 Medida: Top Products ALTO
Top Products = SUMMARIZECOLUMNS(Sales[Product], «Sales», SUM(Sales[Amount]))
Diagnóstico DirectQuery: SUMMARIZECOLUMNS en medida rompe la fusión horizontal de consultas SQL. Si hay otras medidas en el mismo visual, cada una genera su propio SELECT GROUP BY.
Corrección:
Top Products =
SUMX(
VALUES(DimProduct[ProductName]),
CALCULATE(SUM(Sales[Amount]))
)
4.5 Medida: Unique Customers MEDIO
Unique Customers = DISTINCTCOUNT(Sales[CustomerID])
Diagnóstico DirectQuery: COUNT DISTINCT sobre una columna de hechos. Si CustomerID tiene alta cardinalidad (millones), el SQL debe ordenar y deduplicar toda la columna. No hay caché en DirectQuery.
Corrección:
— Si CustomerID es FK de DimCustomer:
Unique Customers = COUNTROWS(DimCustomer)
— Si no hay dimensión, usar aproximación:
Unique Customers Approx = SUMX(VALUES(Sales[CustomerID]), 1)
5. Patrones de reescritura avanzada
5.1 Fusión vertical: evitar múltiples consultas SQL por medida
Cuando un visual muestra varias medidas que aplican la misma función de inteligencia temporal (ej. Revenue YTD, Cost YTD, Margin YTD), cada una genera su propio SQL con DATESYTD. La solución es aplicar la función temporal una sola vez en una medida externa.
— ANTI-PATRÓN: cada medida aplica TI
Revenue YTD = CALCULATE([Revenue], DATESYTD(Date[Date]))
Cost YTD = CALCULATE([Cost], DATESYTD(Date[Date]))
Margin YTD = [Revenue YTD] – [Cost YTD]
2 consultas SQL con DATESYTD cada una.
— SOLUCIÓN: TI aplicado una vez
Margin YTD =
CALCULATE(
[Revenue] – [Cost],
DATESYTD(Date[Date])
)
1 consulta SQL. Las medidas base se fusionan.
5.2 Multiplicador booleano para desbloquear fusión
Cuando diferentes medidas filtran por diferentes valores de la misma columna (ej. Bikes YTD, Accessories YTD), el motor no fusiona las consultas porque los filtros son distintos. El multiplicador booleano convierte el filtro en una expresión que el SE evalúa de forma idéntica.
— ANTI-PATRÓN: filtros diferentes = no fusión
Bikes YTD = CALCULATE(SUM(Sales[Amount]), Product[Category] = «Bikes», DATESYTD(Date[Date]))
Accessories YTD = CALCULATE(SUM(Sales[Amount]), Product[Category] = «Accessories», DATESYTD(Date[Date]))
— SOLUCIÓN: multiplicador booleano
Bikes YTD =
SUMX(
KEEPFILTERS(ALL(Product[Category])),
CALCULATE(SUM(Sales[Amount])) * (Product[Category] = «Bikes»)
)
— El SE ve la misma estructura para ambas medidas y las fusiona
5.3 Pre-materializar con SUMMARIZECOLUMNS + NATURALINNERJOIN
Cuando una medida calcula un conjunto de claves filtradas y luego las usa para filtrar otra agregación (patrón TREATAS/IN), el SQL generado puede contener semijoins con cientos de tuplas compuestas. La solución es pre-calcular ambas agregaciones y unirlas en el FE.
Sales de Clientes Top =
VAR _FilteredAgg =
CALCULATETABLE(
ADDCOLUMNS(VALUES(Sales[CustomerID]), «@Agg1», [Total Sales]),
DimSegment[Segment] = «Premium»
)
VAR _Qualifying = FILTER(_FilteredAgg, [@Agg1] > 1000000)
VAR _UnfilteredAgg =
ADDCOLUMNS(VALUES(Sales[CustomerID]), «@Agg2», [Total Sales])
VAR _Joined = NATURALINNERJOIN(_Qualifying, _UnfilteredAgg)
RETURNSUMX(_Joined, [@Agg2])
6. Checklist de optimización y mantenimiento
✅ Checklist pre-despliegue (DirectQuery)
| ☐ | Acción | Herramienta |
|---|---|---|
| ☐ | Todas las medidas con SUMX/AVERAGEX han sido revisadas y reemplazadas por agregaciones simples o columnas precalculadas | DAX Studio + medida _Medidas con SUMX |
| ☐ | No hay FILTER(tabla_hechos, …) en ninguna medida | Medida _Medidas con FILTER |
| ☐ | CallbackDataID < 5 por consulta en los visuales críticos | DAX Studio Server Timings |
| ☐ | Tiempo DAX < 3 segundos para todos los visuales de la página de aterrizaje | Performance Analyzer |
| ☐ | Las funciones de inteligencia temporal generan SQL con GROUP BY al nivel correcto (no día si se necesita mes) | DAX Studio → copiar SQL generado |
| ☐ | Se han configurado tablas de agregación automática para las métricas más consultadas | Modelado → Agregaciones |
| ☐ | Las relaciones usan claves enteras (no GUID ni texto) con «Assume referential integrity» activado | Modelo → Propiedades de relación |
| ☐ | Los visuales por página no exceden 12 (límite recomendado para DirectQuery) | Conteo manual / Performance Analyzer |
| ☐ | Se han desactivado interacciones cruzadas innecesarias entre visuales | Formato → Editar interacciones |
| ☐ | Los slicers usan botón «Aplicar» para reducir query chatter | Formato del slicer → Reducción de consultas |
| ☐ | Las columnas de texto de alta cardinalidad tienen índices en la fuente SQL | SQL Server / DBA |
| ☐ | Se ha revisado «View Native Query» en Power Query para confirmar que los pasos son foldables | Power Query Editor |
🔄 Rutina de mantenimiento mensual
- Semana 1 — Análisis de query plans: Ejecutar DAX Studio sobre los 5 visuales más lentos del mes. Documentar FE Time, SE Time y CallbackDataID.
- Semana 2 — Refactorización: Aplicar las correcciones de anti-patrones identificados. Priorizar los que afectan a más de un visual.
- Semana 3 — Validación: Comparar tiempos antes/después con Performance Analyzer. Verificar que el SQL generado es más simple.
- Semana 4 — Documentación: Actualizar el cuadro de mando de auditoría. Registrar nuevas medidas añadidas y su nivel de riesgo.
7. Conclusión y próximos pasos
En DirectQuery, el DAX no es solo una cuestión de «escribir la fórmula correcta»: es una cuestión de arquitectura de consultas SQL. Cada función que fuerza al Formula Engine a iterar fila a fila se traduce en múltiples round-trips a la base de datos remota, y la latencia de red convierte un pequeño anti-patrón en un cuello de botella crítico.
Los tres principios que debes recordar:
- Empuja todo al Storage Engine (SQL): Usa agregaciones simples (SUM, COUNT, AVERAGE), predicados de columna en CALCULATE, y evita iteradores sobre tablas de hechos.
- Minimiza el número de consultas SQL: Usa variables, fusión vertical/horizontal, y evita SUMMARIZECOLUMNS en medidas cuando haya múltiples métricas en el mismo visual.
- Pre-calcula lo que no cambia: Columnas calculadas en Power Query (foldables), tablas de agregación, y vistas materializadas en la fuente SQL son tus mejores aliados.
🎯 Plan de acción inmediato para tu modelo
- Copia el script M del cuadro de mando y ejecútalo en Power Query para auditar todas las medidas existentes.
- Identifica las medidas con nivel CRÍTICO y ALTO.
- Para cada una, abre DAX Studio, pega la consulta del Performance Analyzer, y verifica el número de CallbackDataID.
- Aplica las reescrituras de este artículo, priorizando SUMX sobre hechos y FILTER sobre tablas de hechos.
- Configura agregaciones automáticas para las métricas de mayor uso.
- Programa la revisión mensual usando el checklist de mantenimiento.
— Artículo generado en agosto de 2026 · Basado en el Cuadro de Mando de Rendimiento DAX y mejores prácticas de DirectQuery —
