sql >> Base de Datos >  >> RDS >> Database

Igualar la oferta con la demanda:soluciones, parte 3

[ Saltar a:Desafío original | Soluciones:Parte 1 | Parte 2 | Parte 3 ]

En este artículo, continúo explorando soluciones para el desafío de hacer coincidir la oferta con la demanda. Gracias a Luca, Kamil Kosno, Daniel Brown, Brian Walker, Joe Obbish, Rainer Hoffmann, Paul White, Charlie, Ian y Peter Larsson por enviar sus soluciones.

El mes pasado cubrí soluciones basadas en un enfoque de intersecciones de intervalo revisado en comparación con el clásico. La más rápida de esas soluciones combinó ideas de Kamil, Luca y Daniel. Unificó dos consultas con predicados sargables disjuntos. La solución tardó 1,34 segundos en completarse frente a una entrada de 400 000 filas. Eso no está nada mal si se tiene en cuenta que la solución basada en el enfoque clásico de intersecciones de intervalos tardó 931 segundos en completarse con la misma entrada. También recuerde que a Joe se le ocurrió una solución brillante que se basa en el enfoque clásico de intersección de intervalos, pero optimiza la lógica de coincidencia dividiendo los intervalos en cubos en función de la longitud de intervalo más grande. Con la misma entrada de 400 000 filas, la solución de Joe tardó 0,9 segundos en completarse. La parte complicada de esta solución es que su rendimiento se degrada a medida que aumenta la longitud del intervalo más grande.

Este mes exploro soluciones fascinantes que son más rápidas que la solución de intersecciones revisadas de Kamil/Luca/Daniel y son neutrales a la longitud del intervalo. Las soluciones de este artículo fueron creadas por Brian Walker, Ian, Peter Larsson, Paul White y yo.

Probé todas las soluciones de este artículo con la tabla de entrada Subastas con filas de 100 000, 200 000, 300 000 y 400 000, y con los siguientes índices:

-- Index to support solution
 
CREATE UNIQUE NONCLUSTERED INDEX idx_Code_ID_i_Quantity
  ON dbo.Auctions(Code, ID)
  INCLUDE(Quantity);
 
-- Enable batch-mode Window Aggregate
 
CREATE NONCLUSTERED COLUMNSTORE INDEX idx_cs
  ON dbo.Auctions(ID)
  WHERE ID = -1 AND ID = -2;

Al describir la lógica detrás de las soluciones, asumiré que la tabla Subastas se completa con el siguiente pequeño conjunto de datos de muestra:

ID          Code Quantity
----------- ---- ---------
1           D    5.000000
2           D    3.000000
3           D    8.000000
5           D    2.000000
6           D    8.000000
7           D    4.000000
8           D    2.000000
1000        S    8.000000
2000        S    6.000000
3000        S    2.000000
4000        S    2.000000
5000        S    4.000000
6000        S    3.000000
7000        S    2.000000

El siguiente es el resultado deseado para estos datos de muestra:

DemandID    SupplyID    TradeQuantity
----------- ----------- --------------
1           1000        5.000000
2           1000        3.000000
3           2000        6.000000
3           3000        2.000000
5           4000        2.000000
6           5000        4.000000
6           6000        3.000000
6           7000        1.000000
7           7000        1.000000

Solución de Brian Walker

Las uniones externas se usan con bastante frecuencia en las soluciones de consulta SQL, pero cuando las usa, usa las de un solo lado. Cuando enseño sobre uniones externas, a menudo recibo preguntas que piden ejemplos de casos de uso práctico de uniones externas completas, y no hay tantos. La solución de Brian es un hermoso ejemplo de un caso de uso práctico de uniones externas completas.

Aquí está el código completo de la solución de Brian:

DROP TABLE IF EXISTS #MyPairings;
 
CREATE TABLE #MyPairings
( 
  DemandID       INT            NOT NULL, 
  SupplyID       INT            NOT NULL, 
  TradeQuantity  DECIMAL(19,06) NOT NULL
);
 
WITH D AS
(
  SELECT A.ID,
    SUM(A.Quantity) OVER (PARTITION BY A.Code 
                          ORDER BY A.ID ROWS UNBOUNDED PRECEDING) AS Running
  FROM dbo.Auctions AS A
  WHERE A.Code = 'D'
),
S AS
(
  SELECT A.ID, 
    SUM(A.Quantity) OVER (PARTITION BY A.Code 
                          ORDER BY A.ID ROWS UNBOUNDED PRECEDING) AS Running
  FROM dbo.Auctions AS A
  WHERE A.Code = 'S'
),
W AS
(
  SELECT D.ID AS DemandID, S.ID AS SupplyID, ISNULL(D.Running, S.Running) AS Running
  FROM D
    FULL JOIN S
      ON D.Running = S.Running
),
Z AS
(
  SELECT 
    CASE 
      WHEN W.DemandID IS NOT NULL THEN W.DemandID 
      ELSE MIN(W.DemandID) OVER (ORDER BY W.Running 
                                 ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
    END AS DemandID,
    CASE
      WHEN W.SupplyID IS NOT NULL THEN W.SupplyID 
      ELSE MIN(W.SupplyID) OVER (ORDER BY W.Running 
                                 ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) 
    END AS SupplyID,
    W.Running
  FROM W
)
INSERT #MyPairings( DemandID, SupplyID, TradeQuantity )
  SELECT Z.DemandID, Z.SupplyID,
    Z.Running - ISNULL(LAG(Z.Running) OVER (ORDER BY Z.Running), 0.0) AS TradeQuantity
  FROM Z
  WHERE Z.DemandID IS NOT NULL
    AND Z.SupplyID IS NOT NULL;

Revisé el uso original de Brian de tablas derivadas con CTE para mayor claridad.

El CTE D calcula las cantidades acumuladas de demanda total en una columna de resultados denominada D.En ejecución, y la CTE S calcula las cantidades acumuladas de oferta total en una columna de resultados denominada S.En ejecución. El CTE W luego realiza una combinación externa completa entre D y S, haciendo coincidir D.Running con S.Running, y devuelve el primer no NULL entre D.Running y S.Running como W.Running. Este es el resultado que obtiene si consulta todas las filas de W ordenadas por En ejecución:

DemandID    SupplyID    Running
----------- ----------- ----------
1           NULL         5.000000
2           1000         8.000000
NULL        2000        14.000000
3           3000        16.000000
5           4000        18.000000
NULL        5000        22.000000
NULL        6000        25.000000
6           NULL        26.000000
NULL        7000        27.000000
7           NULL        30.000000
8           NULL        32.000000 

¡La idea de usar una combinación externa completa basada en un predicado que compara los totales acumulados de la oferta y la demanda es genial! La mayoría de las filas en este resultado, las primeras 9 en nuestro caso, representan emparejamientos de resultados a los que les faltan algunos cálculos adicionales. Las filas con ID NULL finales de uno de los tipos representan entradas que no pueden coincidir. En nuestro caso, las últimas dos filas representan entradas de demanda que no pueden coincidir con entradas de oferta. Entonces, lo que queda aquí es calcular DemandID, SupplyID y TradeQuantity de los emparejamientos de resultados, y filtrar las entradas que no se pueden emparejar.

La lógica que calcula el resultado DemandID y SupplyID se realiza en el CTE Z de la siguiente manera (suponiendo que se ordene en W por Ejecución):

  • DemandID:si DemandID no es NULL, entonces DemandID; de lo contrario, DemandID mínimo que comienza con la fila actual
  • SupplyID:si SupplyID no es NULL, entonces SupplyID; de lo contrario, el SupplyID mínimo comienza con la fila actual

Este es el resultado que obtiene si consulta Z y ordena las filas por Ejecución:

DemandID    SupplyID    Running
----------- ----------- ----------
1           1000         5.000000
2           1000         8.000000
3           2000        14.000000
3           3000        16.000000
5           4000        18.000000
6           5000        22.000000
6           6000        25.000000
6           7000        26.000000
7           7000        27.000000
7           NULL        30.000000
8           NULL        32.000000

La consulta externa filtra las filas de Z que representan entradas de un tipo que no pueden coincidir con entradas del otro tipo al garantizar que DemandID y SupplyID no sean NULL. La cantidad comercial de los emparejamientos de resultados se calcula como el valor corriente actual menos el valor corriente anterior usando una función LAG.

Esto es lo que se escribe en la tabla #MyPairings, que es el resultado deseado:

DemandID    SupplyID    TradeQuantity
----------- ----------- --------------
1           1000        5.000000
2           1000        3.000000
3           2000        6.000000
3           3000        2.000000
5           4000        2.000000
6           5000        4.000000
6           6000        3.000000
6           7000        1.000000
7           7000        1.000000

El plan para esta solución se muestra en la Figura 1.

Figura 1:Plan de consulta para la solución de Brian

Las dos ramas superiores del plan calculan los totales acumulados de la oferta y la demanda utilizando un operador Agregado de ventana en modo por lotes, cada uno después de recuperar las entradas respectivas del índice de soporte. Como expliqué en artículos anteriores de la serie, dado que el índice ya tiene las filas ordenadas como los operadores de ventana agregada necesitan que estén, pensaría que los operadores de clasificación explícitos no deberían ser necesarios. Pero SQL Server actualmente no admite una combinación eficiente de operaciones de índice paralelas que conserven el orden antes de un operador agregado de ventanas en modo por lotes paralelo, por lo que, como resultado, un operador Ordenar paralelo explícito precede a cada uno de los operadores agregados de ventanas paralelos.

El plan utiliza una combinación hash en modo por lotes para manejar la combinación externa completa. El plan también utiliza dos operadores agregados de ventana en modo por lotes precedidos por operadores de clasificación explícitos para calcular las funciones de ventana MIN y LAG.

¡Ese es un plan bastante eficiente!

Estos son los tiempos de ejecución que obtuve para esta solución con tamaños de entrada que van desde 100 000 a 400 000 filas, en segundos:

100K: 0.368
200K: 0.845
300K: 1.255
400K: 1.315

Solución de Itzik

La próxima solución para el desafío es uno de mis intentos de resolverlo. Aquí está el código completo de la solución:

DROP TABLE IF EXISTS #MyPairings;
 
WITH C1 AS
(
  SELECT *,
    SUM(Quantity)
      OVER(PARTITION BY Code 
           ORDER BY ID 
           ROWS UNBOUNDED PRECEDING) AS SumQty
  FROM dbo.Auctions
),
C2 AS
(
  SELECT *,
    SUM(Quantity * CASE Code WHEN 'D' THEN -1 WHEN 'S' THEN 1 END)
      OVER(ORDER BY SumQty, Code 
           ROWS UNBOUNDED PRECEDING) AS StockLevel,
    LAG(SumQty, 1, 0.0) OVER(ORDER BY SumQty, Code) AS PrevSumQty,
    MAX(CASE WHEN Code = 'D' THEN ID END)
      OVER(ORDER BY SumQty, Code 
           ROWS UNBOUNDED PRECEDING) AS PrevDemandID,
    MAX(CASE WHEN Code = 'S' THEN ID END)
      OVER(ORDER BY SumQty, Code 
           ROWS UNBOUNDED PRECEDING) AS PrevSupplyID,
    MIN(CASE WHEN Code = 'D' THEN ID END)
      OVER(ORDER BY SumQty, Code 
           ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS NextDemandID,
    MIN(CASE WHEN Code = 'S' THEN ID END)
      OVER(ORDER BY SumQty, Code 
           ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS NextSupplyID
  FROM C1
),
C3 AS
(
  SELECT *,
    CASE Code
      WHEN 'D' THEN ID
      WHEN 'S' THEN
        CASE WHEN StockLevel > 0 THEN NextDemandID ELSE PrevDemandID END
    END AS DemandID,
    CASE Code
      WHEN 'S' THEN ID
      WHEN 'D' THEN
        CASE WHEN StockLevel <= 0 THEN NextSupplyID ELSE PrevSupplyID END
    END AS SupplyID,
    SumQty - PrevSumQty AS TradeQuantity
  FROM C2
)
SELECT DemandID, SupplyID, TradeQuantity
INTO #MyPairings
FROM C3
WHERE TradeQuantity > 0.0
  AND DemandID IS NOT NULL
  AND SupplyID IS NOT NULL;

El CTE C1 consulta la tabla Subastas y utiliza una función de ventana para calcular las cantidades acumuladas de oferta y demanda totales, llamando a la columna de resultados SumQty.

El CTE C2 consulta a C1 y calcula una serie de atributos utilizando funciones de ventana basadas en la ordenación SumQty y Code:

  • StockLevel:el nivel de existencias actual después de procesar cada entrada. Esto se calcula asignando un signo negativo a las cantidades de demanda y un signo positivo a las cantidades de suministro y sumando esas cantidades hasta la entrada actual incluida.
  • PrevSumQty:valor SumQty de la fila anterior.
  • PrevDemandID:último ID de demanda no NULL.
  • PrevSupplyID:último ID de suministro no NULL.
  • NextDemandID:siguiente ID de demanda no NULL.
  • NextSupplyID:siguiente ID de suministro no NULL.

Aquí está el contenido de C2 ordenado por SumQty y Code:

ID    Code Quantity  SumQty     StockLevel  PrevSumQty  PrevDemandID PrevSupplyID NextDemandID NextSupplyID
----- ---- --------- ---------- ----------- ----------- ------------ ------------ ------------ ------------
1     D    5.000000   5.000000  -5.000000    0.000000   1            NULL         1            1000
2     D    3.000000   8.000000  -8.000000    5.000000   2            NULL         2            1000
1000  S    8.000000   8.000000   0.000000    8.000000   2            1000         3            1000
2000  S    6.000000  14.000000   6.000000    8.000000   2            2000         3            2000
3     D    8.000000  16.000000  -2.000000   14.000000   3            2000         3            3000
3000  S    2.000000  16.000000   0.000000   16.000000   3            3000         5            3000
5     D    2.000000  18.000000  -2.000000   16.000000   5            3000         5            4000
4000  S    2.000000  18.000000   0.000000   18.000000   5            4000         6            4000
5000  S    4.000000  22.000000   4.000000   18.000000   5            5000         6            5000
6000  S    3.000000  25.000000   7.000000   22.000000   5            6000         6            6000
6     D    8.000000  26.000000  -1.000000   25.000000   6            6000         6            7000
7000  S    2.000000  27.000000   1.000000   26.000000   6            7000         7            7000
7     D    4.000000  30.000000  -3.000000   27.000000   7            7000         7            NULL
8     D    2.000000  32.000000  -5.000000   30.000000   8            7000         8            NULL

El CTE C3 consulta a C2 y calcula los pares de resultados DemandID, SupplyID y TradeQuantity, antes de eliminar algunas filas superfluas.

El resultado de la columna C3.DemandID se calcula así:

  • Si la entrada actual es una entrada de demanda, devuelve DemandID.
  • Si la entrada actual es una entrada de suministro y el nivel de existencias actual es positivo, devuelve NextDemandID.
  • Si la entrada actual es una entrada de suministro y el nivel de existencias actual no es positivo, devuelva PrevDemandID.

El resultado de la columna C3.SupplyID se calcula así:

  • Si la entrada actual es una entrada de suministro, devuelve SupplyID.
  • Si la entrada actual es una entrada de demanda y el nivel de existencias actual no es positivo, devuelve NextSupplyID.
  • Si la entrada actual es una entrada de demanda y el nivel de existencias actual es positivo, devuelva PrevSupplyID.

El resultado TradeQuantity se calcula como SumQty de la fila actual menos PrevSumQty.

Estos son los contenidos de las columnas relevantes para el resultado de C3:

DemandID    SupplyID    TradeQuantity
----------- ----------- --------------
1           1000        5.000000
2           1000        3.000000
2           1000        0.000000
3           2000        6.000000
3           3000        2.000000
3           3000        0.000000
5           4000        2.000000
5           4000        0.000000
6           5000        4.000000
6           6000        3.000000
6           7000        1.000000
7           7000        1.000000
7           NULL        3.000000
8           NULL        2.000000

Lo que le queda por hacer a la consulta externa es filtrar las filas superfluas de C3. Estos incluyen dos casos:

  • Cuando los totales acumulados de ambos tipos son iguales, la entrada de suministro tiene una cantidad comercial de cero. Recuerde que el pedido se basa en SumQty y Code, por lo que cuando los totales acumulados son iguales, la entrada de demanda aparece antes que la entrada de suministro.
  • Entradas finales de un tipo que no pueden coincidir con entradas del otro tipo. Tales entradas están representadas por filas en C3 donde DemandID es NULL o SupplyID es NULL.

El plan para esta solución se muestra en la Figura 2.

Figura 2:Plan de consulta para la solución de Itzik

El plan aplica una pasada sobre los datos de entrada y utiliza cuatro operadores de ventana agregada en modo por lotes para manejar los diversos cálculos de ventana. Todos los operadores de ventana agregada están precedidos por operadores de clasificación explícitos, aunque solo dos de ellos son inevitables aquí. Los otros dos tienen que ver con la implementación actual del operador Agregado de ventanas en modo por lotes paralelo, que no puede depender de una entrada de conservación de orden paralela. Una forma sencilla de ver qué operadores de clasificación se deben a este motivo es forzar un plan en serie y ver qué operadores de clasificación desaparecen. Cuando fuerzo un plan en serie con esta solución, el primer y el tercer operador Ordenar desaparecen.

Estos son los tiempos de ejecución en segundos que obtuve para esta solución:

100K: 0.246
200K: 0.427
300K: 0.616
400K: 0.841>

Solución de Ian

La solución de Ian es corta y eficiente. Aquí está el código completo de la solución:

DROP TABLE IF EXISTS #MyPairings;
 
WITH A AS (
  SELECT *,
    SUM(Quantity) OVER (PARTITION BY Code 
                        ORDER BY ID 
                        ROWS UNBOUNDED PRECEDING) AS CumulativeQuantity
  FROM dbo.Auctions
), B AS (
  SELECT CumulativeQuantity,
    CumulativeQuantity 
      - LAG(CumulativeQuantity, 1, 0) 
          OVER (ORDER BY CumulativeQuantity) AS TradeQuantity,
    MAX(CASE WHEN Code = 'D' THEN ID END) AS DemandID,
    MAX(CASE WHEN Code = 'S' THEN ID END) AS SupplyID
  FROM A
  GROUP BY CumulativeQuantity, ID/ID -- bogus grouping to improve row estimate 
                                     -- (rows count of Auctions instead of 2 rows)
), C AS (
  -- fill in NULLs with next supply / demand
  -- FIRST_VALUE(ID) IGNORE NULLS OVER ... 
  -- would be better, but this will work because the IDs are in CumulativeQuantity order
  SELECT
    MIN(DemandID) OVER (ORDER BY CumulativeQuantity 
                        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS DemandID,
    MIN(SupplyID) OVER (ORDER BY CumulativeQuantity 
                        ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS SupplyID,
    TradeQuantity
  FROM B
)
SELECT DemandID, SupplyID, TradeQuantity
INTO #MyPairings
FROM C
WHERE SupplyID IS NOT NULL  -- trim unfulfilled demands
  AND DemandID IS NOT NULL; -- trim unused supplies

El código en el CTE A consulta la tabla Subastas y calcula las cantidades totales de oferta y demanda en ejecución usando una función de ventana, nombrando la columna de resultado CumulativeQuantity.

El código en CTE B consulta CTE A y agrupa las filas por CumulativeQuantity. Esta agrupación logra un efecto similar al de la unión externa de Brian en función de los totales acumulados de oferta y demanda. Ian también agregó la expresión ficticia ID/ID al conjunto de agrupación para mejorar la cardinalidad original de la agrupación bajo estimación. En mi máquina, esto también resultó en el uso de un plan paralelo en lugar de uno en serie.

En la lista SELECT, el código calcula DemandID y SupplyID recuperando el ID del tipo de entrada respectivo en el grupo usando el agregado MAX y una expresión CASE. Si el ID no está presente en el grupo, el resultado es NULL. El código también calcula una columna de resultados llamada TradeQuantity como la cantidad acumulada actual menos la anterior, recuperada mediante la función de ventana LAG.

Estos son los contenidos de B:

CumulativeQuantity  TradeQuantity  DemandId  SupplyId
------------------- -------------- --------- ---------
 5.000000           5.000000       1         NULL
 8.000000           3.000000       2         1000
14.000000           6.000000       NULL      2000
16.000000           2.000000       3         3000
18.000000           2.000000       5         4000
22.000000           4.000000       NULL      5000
25.000000           3.000000       NULL      6000
26.000000           1.000000       6         NULL
27.000000           1.000000       NULL      7000
30.000000           3.000000       7         NULL
32.000000           2.000000       8         NULL

El código en el CTE C luego consulta el CTE B y completa los ID de oferta y demanda NULOS con los siguientes ID de oferta y demanda no NULOS, utilizando la función de ventana MIN con el marco FILAS ENTRE FILA ACTUAL Y SIGUIENTE ILIMITADO.

Estos son los contenidos de C:

DemandID  SupplyID  TradeQuantity 
--------- --------- --------------
1         1000      5.000000      
2         1000      3.000000      
3         2000      6.000000      
3         3000      2.000000      
5         4000      2.000000      
6         5000      4.000000      
6         6000      3.000000      
6         7000      1.000000      
7         7000      1.000000      
7         NULL      3.000000      
8         NULL      2.000000

El último paso manejado por la consulta externa contra C es eliminar las entradas de un tipo que no pueden coincidir con las entradas del otro tipo. Eso se hace filtrando las filas donde SupplyID es NULL o DemandID es NULL.

El plan para esta solución se muestra en la Figura 3.

Figura 3:Plan de consulta para la solución de Ian

Este plan realiza un escaneo de los datos de entrada y utiliza tres operadores agregados de ventana en modo por lotes paralelos para calcular las diversas funciones de ventana, todos precedidos por operadores de clasificación paralelos. Dos de ellos son inevitables, como puede verificar forzando un plan en serie. El plan también usa un operador Hash Aggregate para manejar la agrupación y agregación en el CTE B.

Estos son los tiempos de ejecución en segundos que obtuve para esta solución:

100K: 0.214
200K: 0.363
300K: 0.546
400K: 0.701

Solución de Peter Larsson

La solución de Peter Larsson es sorprendentemente corta, dulce y altamente eficiente. Aquí está el código completo de la solución de Peter:

DROP TABLE IF EXISTS #MyPairings;
 
WITH cteSource(ID, Code, RunningQuantity)
AS
(
  SELECT ID, Code,
    SUM(Quantity) OVER (PARTITION BY Code ORDER BY ID) AS RunningQuantity
  FROM dbo.Auctions
)
SELECT DemandID, SupplyID, TradeQuantity
INTO #MyPairings
FROM (
       SELECT
         MIN(CASE WHEN Code = 'D' THEN ID ELSE 2147483648 END)
           OVER (ORDER BY RunningQuantity, Code 
                 ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS DemandID,
         MIN(CASE WHEN Code = 'S' THEN ID ELSE 2147483648 END) 
           OVER (ORDER BY RunningQuantity, Code 
                 ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS SupplyID,
         RunningQuantity
           - COALESCE(LAG(RunningQuantity) OVER (ORDER BY RunningQuantity, Code), 0.0)
             AS TradeQuantity
       FROM cteSource
     ) AS d
WHERE DemandID < 2147483648
  AND SupplyID < 2147483648
  AND TradeQuantity > 0.0;

CTE cteSource consulta la tabla Subastas y utiliza una función de ventana para calcular las cantidades acumuladas de oferta y demanda totales, llamando a la columna de resultados RunningQuantity.

El código que define la tabla derivada consulta cteSource y calcula los pares de resultados DemandID, SupplyID y TradeQuantity, antes de eliminar algunas filas superfluas. Todas las funciones de ventana utilizadas en esos cálculos se basan en el orden de Cantidad en ejecución y Código.

La columna de resultado d.DemandID se calcula como el ID de demanda mínimo que comienza con el actual o 2147483648 si no se encuentra ninguno.

La columna de resultado d.SupplyID se calcula como el ID de suministro mínimo que comienza con el actual o 2147483648 si no se encuentra ninguno.

El resultado TradeQuantity se calcula como el valor de RunningQuantity de la fila actual menos el valor de RunningQuantity de la fila anterior.

Aquí están los contenidos de d:

DemandID  SupplyID    TradeQuantity
--------- ----------- --------------
1         1000        5.000000
2         1000        3.000000
3         1000        0.000000
3         2000        6.000000
3         3000        2.000000
5         3000        0.000000
5         4000        2.000000
6         4000        0.000000
6         5000        4.000000
6         6000        3.000000
6         7000        1.000000
7         7000        1.000000
7         2147483648  3.000000
8         2147483648  2.000000

Lo que le queda por hacer a la consulta externa es filtrar las filas superfluas de d. Esos son casos en los que la cantidad de negociación es cero, o entradas de un tipo que no pueden coincidir con entradas del otro tipo (con ID 2147483648).

El plan para esta solución se muestra en la Figura 4.

Figura 4:Plan de consulta para la solución de Peter

Al igual que el plan de Ian, el plan de Peter se basa en un escaneo de los datos de entrada y utiliza tres operadores agregados de ventana en modo por lotes paralelos para calcular las diversas funciones de ventana, todas precedidas por operadores de clasificación paralelos. Dos de ellos son inevitables, como puede verificar forzando un plan en serie. En el plan de Peter, no hay necesidad de un operador agregado agrupado como en el plan de Ian.

La idea crítica de Peter que permitió una solución tan corta fue darse cuenta de que para una entrada relevante de cualquiera de los tipos, el ID coincidente del otro tipo siempre aparecerá más tarde (según la cantidad en ejecución y el orden del código). ¡Después de ver la solución de Peter, sentí que había complicado demasiado las cosas en la mía!

Estos son los tiempos de ejecución en segundos que obtuve para esta solución:

100K: 0.197
200K: 0.412
300K: 0.558
400K: 0.696

Solución de Paul White

Nuestra última solución fue creada por Paul White. Aquí está el código completo de la solución:

DROP TABLE IF EXISTS #MyPairings;
 
CREATE TABLE #MyPairings
(
  DemandID integer NOT NULL,
  SupplyID integer NOT NULL,
  TradeQuantity decimal(19, 6) NOT NULL
);
GO
 
INSERT #MyPairings 
    WITH (TABLOCK)
(
    DemandID,
    SupplyID,
    TradeQuantity
)
SELECT 
    Q3.DemandID,
    Q3.SupplyID,
    Q3.TradeQuantity
FROM 
(
    SELECT
        Q2.DemandID,
        Q2.SupplyID,
        TradeQuantity =
            -- Interval overlap
            CASE
                WHEN Q2.Code = 'S' THEN
                    CASE
                        WHEN Q2.CumDemand >= Q2.IntEnd THEN Q2.IntLength
                        WHEN Q2.CumDemand > Q2.IntStart THEN Q2.CumDemand - Q2.IntStart
                        ELSE 0.0
                    END
                WHEN Q2.Code = 'D' THEN
                    CASE
                        WHEN Q2.CumSupply >= Q2.IntEnd THEN Q2.IntLength
                        WHEN Q2.CumSupply > Q2.IntStart THEN Q2.CumSupply - Q2.IntStart
                        ELSE 0.0
                    END
            END
    FROM
    (
        SELECT 
            Q1.Code, 
            Q1.IntStart, 
            Q1.IntEnd, 
            Q1.IntLength, 
            DemandID = MAX(IIF(Q1.Code = 'D', Q1.ID, 0)) OVER (
                    ORDER BY Q1.IntStart, Q1.ID 
                    ROWS UNBOUNDED PRECEDING),
            SupplyID = MAX(IIF(Q1.Code = 'S', Q1.ID, 0)) OVER (
                    ORDER BY Q1.IntStart, Q1.ID 
                    ROWS UNBOUNDED PRECEDING),
            CumSupply = SUM(IIF(Q1.Code = 'S', Q1.IntLength, 0)) OVER (
                    ORDER BY Q1.IntStart, Q1.ID 
                    ROWS UNBOUNDED PRECEDING),
            CumDemand = SUM(IIF(Q1.Code = 'D', Q1.IntLength, 0)) OVER (
                    ORDER BY Q1.IntStart, Q1.ID 
                    ROWS UNBOUNDED PRECEDING)
        FROM 
        (
            -- Demand intervals
            SELECT 
                A.ID, 
                A.Code, 
                IntStart = SUM(A.Quantity) OVER (
                    ORDER BY A.ID 
                    ROWS UNBOUNDED PRECEDING) - A.Quantity,
                IntEnd = SUM(A.Quantity) OVER (
                    ORDER BY A.ID 
                    ROWS UNBOUNDED PRECEDING),
                IntLength = A.Quantity
            FROM dbo.Auctions AS A
            WHERE 
                A.Code = 'D'
 
            UNION ALL 
 
            -- Supply intervals
            SELECT 
                A.ID, 
                A.Code, 
                IntStart = SUM(A.Quantity) OVER (
                    ORDER BY A.ID 
                    ROWS UNBOUNDED PRECEDING) - A.Quantity,
                IntEnd = SUM(A.Quantity) OVER (
                    ORDER BY A.ID 
                    ROWS UNBOUNDED PRECEDING),
                IntLength = A.Quantity
            FROM dbo.Auctions AS A
            WHERE 
                A.Code = 'S'
        ) AS Q1
    ) AS Q2
) AS Q3
WHERE
    Q3.TradeQuantity > 0;

El código que define la tabla derivada Q1 usa dos consultas separadas para calcular los intervalos de oferta y demanda en función de los totales acumulados y los unifica. Para cada intervalo, el código calcula su inicio (IntStart), final (IntEnd) y longitud (IntLength). Estos son los contenidos de Q1 ordenados por IntStart e ID:

ID    Code IntStart   IntEnd     IntLength
----- ---- ---------- ---------- ----------
1     D     0.000000   5.000000  5.000000
1000  S     0.000000   8.000000  8.000000
2     D     5.000000   8.000000  3.000000
3     D     8.000000  16.000000  8.000000
2000  S     8.000000  14.000000  6.000000
3000  S    14.000000  16.000000  2.000000
5     D    16.000000  18.000000  2.000000
4000  S    16.000000  18.000000  2.000000
6     D    18.000000  26.000000  8.000000
5000  S    18.000000  22.000000  4.000000
6000  S    22.000000  25.000000  3.000000
7000  S    25.000000  27.000000  2.000000
7     D    26.000000  30.000000  4.000000
8     D    30.000000  32.000000  2.000000

El código que define la tabla derivada Q2 consulta Q1 y calcula las columnas de resultados denominadas DemandID, SupplyID, CumSupply y CumDemand. Todas las funciones de ventana utilizadas por este código se basan en el orden IntStart e ID y el marco FILAS SIN LÍMITES PRECEDENTES (todas las filas hasta la actual).

DemandID es el ID de demanda máxima hasta la fila actual, o 0 si no existe.

SupplyID es el ID de suministro máximo hasta la fila actual, o 0 si no existe ninguno.

CumSupply son las cantidades de suministro acumuladas (longitudes de intervalo de suministro) hasta la fila actual.

CumDemand son las cantidades de demanda acumulada (longitudes de intervalo de demanda) hasta la fila actual.

Estos son los contenidos de Q2:

Code IntStart   IntEnd     IntLength  DemandID  SupplyID  CumSupply  CumDemand
---- ---------- ---------- ---------- --------- --------- ---------- ----------
D     0.000000   5.000000  5.000000   1         0          0.000000   5.000000
S     0.000000   8.000000  8.000000   1         1000       8.000000   5.000000
D     5.000000   8.000000  3.000000   2         1000       8.000000   8.000000
D     8.000000  16.000000  8.000000   3         1000       8.000000  16.000000
S     8.000000  14.000000  6.000000   3         2000      14.000000  16.000000
S    14.000000  16.000000  2.000000   3         3000      16.000000  16.000000
D    16.000000  18.000000  2.000000   5         3000      16.000000  18.000000
S    16.000000  18.000000  2.000000   5         4000      18.000000  18.000000
D    18.000000  26.000000  8.000000   6         4000      18.000000  26.000000
S    18.000000  22.000000  4.000000   6         5000      22.000000  26.000000
S    22.000000  25.000000  3.000000   6         6000      25.000000  26.000000
S    25.000000  27.000000  2.000000   6         7000      27.000000  26.000000
D    26.000000  30.000000  4.000000   7         7000      27.000000  30.000000
D    30.000000  32.000000  2.000000   8         7000      27.000000  32.000000

Q2 already has the final result pairings’ correct DemandID and SupplyID values. The code defining the derived table Q3 queries Q2 and computes the result pairings’ TradeQuantity values as the overlapping segments of the demand and supply intervals. Finally, the outer query against Q3 filters only the relevant pairings where TradeQuantity is positive.

The plan for this solution is shown in Figure 5.

Figure 5:Query plan for Paul’s solution

The top two branches of the plan are responsible for computing the demand and supply intervals. Both rely on Index Seek operators to get the relevant rows based on index order, and then use parallel batch-mode Window Aggregate operators, preceded by Sort operators that theoretically could have been avoided. The plan concatenates the two inputs, sorts the rows by IntStart and ID to support the subsequent remaining Window Aggregate operator. Only this Sort operator is unavoidable in this plan. The rest of the plan handles the needed scalar computations and the final filter. That’s a very efficient plan!

Here are the run times in seconds I got for this solution:

100K: 0.187
200K: 0.331
300K: 0.369
400K: 0.425

These numbers are pretty impressive!

Performance Comparison

Figure 6 has a performance comparison between all solutions covered in this article.

Figure 6:Performance comparison

At this point, we can add the fastest solutions I covered in previous articles. Those are Joe’s and Kamil/Luca/Daniel’s solutions. The complete comparison is shown in Figure 7.

Figure 7:Performance comparison including earlier solutions

These are all impressively fast solutions, with the fastest being Paul’s and Peter’s.

Conclusión

When I originally introduced Peter’s puzzle, I showed a straightforward cursor-based solution that took 11.81 seconds to complete against a 400K-row input. The challenge was to come up with an efficient set-based solution. It’s so inspiring to see all the solutions people sent. I always learn so much from such puzzles both from my own attempts and by analyzing others’ solutions. It’s impressive to see several sub-second solutions, with Paul’s being less than half a second!

It's great to have multiple efficient techniques to handle such a classic need of matching supply with demand. Well done everyone!

[ Jump to:Original challenge | Solutions:Part 1 | Part 2 | Part 3 ]