Оптимизация функций агрегирования и смещения в T-SQL

171 Исходник базы данных

Оптимизация этих функций значительно отличается от того, применимо ли упорядочение и кадрирование. Поэтому я расскажу о двух случаях отдельно, а начну с функций агрегирования без параметров упорядочения и кадрирования.

Без упорядочения и кадрирования

В функциях агрегирования, где отсутствуют упорядочение и кадрирование, кадром является вся секция. Например, посмотрите на следующий запрос:

SELECT actid, tranid, val,
   MAX(val) OVER(PARTITION BY actid) AS mx
FROM dbo.Transactions;

В каждой транзакции запрос требует вернуть элементы подробных данных (actid, tranid и vat), а также максимальное значение для текущего счета. Элементы как подробностей, так и агрегатов должны возвращаться в той же целевой строке. Как уже говорилось в разговоре о функции NTILE, в этом случае недостаточно одного просмотра данных. При чтении строк подробностей вы не знаете результат агрегирования, пока не завершите чтение секции. Оптимизатор решает эту задачу путем размещения строк каждой секции в рабочей таблице в tempdb, а затем двумя операциями чтения временных данных: первый раз для вычисления агрегата, а второй — для чтения строк подробностей.

План этого запроса показан на рисунке:

План агрегирования окна при наличии только агрегирования

План выполняет следующие операции:

При использовании временных данных они размещаются не в оптимизированной рабочей таблице в памяти, а в БД tempdb на диске. Запись и чтение во временные таблицы очень ресурсоемко. На моем компьютере выполнение этого кода заняло десять секунд.

Если нужно дополнительно фильтровать строки на основе полученных из оконной функции результатов, предложение WHERE в запросе использовать нельзя. Нужно определить табличное выражение на основе исходного запроса и только затем выполнять фильтрацию во внешнем запросе, например так:

WITH C AS
(
  SELECT actid, tranid, val,
     MAX(val) OVER(PARTITION BY actid) AS mx
  FROM dbo.Transactions
)
SELECT actid, tranid, val
FROM C
WHERE val = mx;

План этого запроса показан на рисунке:

 План агрегирования окна при наличии только агрегирования и дополнительной фильтрации

По сравнению с предыдущим планом в этом перед сбором потоков добавился итератор Filter. На моем компьютере выполнение этого кода заняло 12 секунд.

Так как создание временных таблиц на диске очень ресурсоемкая операция, добиться намного лучшей производительности можно, если задействовать групповые запросы для вычисления агрегата с дальнейшим соединением с базовой таблицей, например так:

WITH Aggs AS
(
  SELECT actid, MAX(val) AS mx
  FROM dbo.Transactions
  GROUP BY actid
)
SELECT T.actid, T.tranid, T.val, A.mx
FROM dbo.Transactions AS T
  JOIN Aggs AS A
    ON T.actid = A.actid;

План этого запроса показан на рисунке:

План при использовании группового агрегата

Заметьте, что индекс покрытия запроса читается дважды: один раз для вычисления агрегата, а второй - для получения подробных данных, а результаты соединяются итератором соединения Hash. Временных таблиц не создается, и запрос выполняется за две секунды.

Далее создаем на основе полученного агрегата фильтр:

WITH Aggs AS
(
  SELECT actid, MAX(val) AS mx
  FROM dbo.Transactions
  GROUP BY actid
)
SELECT T.actid, T.tranid, T.val
FROM dbo.Transactions AS T
  JOIN Aggs AS A
    ON T.actid = A.actid
   AND T.val = A.mx;

План этого запроса:

План при использовании группового агрегата и фильтра

Теперь запрос выполняется менее чем за секунду.

С упорядочением и кадрированием

Оконные функции агрегирования и смещения с упорядочением и кадрированием — новинка SQL Server 2012, поэтому для их оптимизации используются новые и усовершенствованные итераторы, в частности новый замечательный итератор Window Spool и усовершенствованный итератор Stream Aggregate.

Мы обсудим три случая оптимизации с упорядочением и кадрированием: с экстентом оконного кадра с нижней границей UNBOUNDED PRECEDING, с развертыванием всех строк кадра и с вычислением двух накопительных значений.

UNBOUNDED PRECEDING: быстрый путь

При использовании экстента оконного кадра с UNBOUNDED PRECEDING в качестве нижней границы, оптимизатор применяет сильно оптимизированную стратегию. Я называю это быстрым путем. Но я расскажу об этом чуть позже. Сначала я поведаю о роли итераторов Window Spool и Stream Aggregate. Вообще говоря, эти два итератора реализованы как один итератор, но в плане представлены как два разных итератора.

Задача итератора Window Spool — развернуть каждую исходную строку в соответствующие строки кадра — по крайней мере именно это происходит в худшем сценарии. Итератор генерирует атрибут, определяющий кадр окна и присваивает ему имя WindowCountN. Итератор Stream Aggregate группирует строки по WindowCountN и вычисляет агрегат. Теперь возникает проблема, где получить элементы подробностей строк после группировки данных. Для этого текущая строка всегда добавляется в Window Spool, а в итераторе Stream Aggregate есть логика возвращения элементов подробностей из этой строки.

Как я говорил, каждая исходная строка развертывается до соответствующих строк кадра только в случае худшего сценария, и расскажу об этом попозже. В этом разделе я хочу обсудить специальную оптимизацию в случаях, когда нижней границей оконного окна является UNBOUNDED PRECEDING. В этом случае вместо развертывания каждой строки до соответствующих строк кадра с последующей группировкой и агрегацией в двух указанных итераторах закодирована логика, предусматривающая только накопление значений. Поэтому каждой исходной строке в итераторе Window Spool отвечает две строки — одна с уже накопленной информацией, а другая с текущей строкой. (Как вы помните это нужно для элементов подробностей.) В качестве примера посмотрите на следующий запрос:

   
SELECT actid, tranid, val,
  SUM(val) OVER(PARTITION BY actid
                ORDER BY tranid
                ROWS BETWEEN UNBOUNDED PRECEDING
                         AND CURRENT ROW) AS balance
FROM dbo.Transactions;

План показан на рисунке ниже:

План с использованием параметра ROWS

Числа под стрелками означают число строк. Строки считываются из POC-индекса в соответствующем порядке. После этого итераторы Segment и Sequence Project вычисляют число строк (назовем это RowNumberN). Это число строк используется для фильтрации строк в правом кадре. Наш случай простой, но представьте себе более сложные ситуации (например, с ROWS BETWEEN 5 PRECEDING AND 2 FOLLOWING). После этого другой итератор Segment сегментирует данные по actid для вычисления оконной функции агрегирования. После этого итераторы Window Spool и Stream Aggregate продолжают накопление значений в каждом сегменте. Вспомните, что таблица Transactions содержит два миллиона строк. Именно это число строк поступает в итератор Window Spool, а также из итератора Stream Aggregate. Как объяснялось ранее, итератор Window Spool генерирует две строки для каждой исходной строки в нашем специально оптимизированном случае с UNBOUNDED PRECEDING — одну для уже накопленного значения и вторую для текущей строки для получения элементов подробностей. Поэтому вы видите, что из итератора Window Spool в итератор Stream Aggregate поступает поток из четырех миллионов строк.

Также, при соответствующих условиях (об этом я расскажу чуть позже) итератор Window Spool использует высоко оптимизированную рабочую таблицу в памяти без обычной дополнительной нагрузки, которая присутствует при использовании рабочих таблиц в tempdb и состоит из операций ввода/вывода, обычных и краткосрочных блокировок и т.п. В нашем запросе использовалась рабочая таблица в памяти, кроме того параметр UNBOUNDED PRECEDING не требовал разворачивания всех строк кадров. В совокупности эти два метода оптимизации позволили выполнить запрос на моей машине всего за девять секунд, при этом было выполнено 6208 операций логического чтения. Это совсем неплохо в сравнении с любыми другими надежными методами вычисления нарастающих итогов.

Ряд обстоятельств не позволят итератору Window Spool использовать рабочую таблицу в памяти, что заставит работать с намного более «дорогой» рабочей таблице на диске со сбалансированным деревом, проиндексированным по номерам строк. Подробно об этих обстоятельствах я расскажу в следующем разделе, а также о том, как проверять, какой тип рабочей таблицы используется. А пока я хотел бы напомнить, что одно из этих обстоятельств состоит в том, что SQL Server не может заранее определить число строк в кадре. В частности это происходит при использовании единиц оконного кадра RANGE вместо ROWS.

Как вы помните, при использовании RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW кадр определенного столбца может включать дополнительные строки, предшествующие текущей. Это происходит, когда значения, по которым выполняется упорядочение, не уникальны в рамках секции. В настоящее время оптимизатор не проверяет на предмет уникальности — в этом случае с технической точки зрения он мог бы преобразовать параметр RANGE в эквивалентный параметр ROWS. Поэтому по умолчанию выбирается рабочая таблица на диске. Это приводит к значительному снижению производительности по сравнению с использованием параметра ROWS.

Следующий запрос эквивалентен предыдущему, но я заменил параметр ROWS на RANGE:

SELECT actid, tranid, val,
  SUM(val) OVER(PARTITION BY actid
                ORDER BY tranid
                RANGE BETWEEN UNBOUNDED PRECEDING
                          AND CURRENT ROW) AS balance
FROM dbo.Transactions;

План этого запроса показан на рисунке ниже:

План с использованием параметра RANGE

По плану нельзя определить, что используется рабочая таблица на диске. В сущности он выглядит, как предыдущий план (если не считать итератор Sequence Project), причем между итераторами передается такое же число строк. Параметр STATISTICS IO - один из способов сказать, что используется рабочая таблица на диске. При использовании параметра ROWS он указывал нулевое число операций чтения по отношению к таблице 'Worktable', потому что она находится в памяти. При использовании параметра RANGE выполняются миллионы операций чтения. Трассировка показывает в общем зачете 18 063 511 операций логического чтения и 5800 операций записи. Это приводит к увеличению времени выполнения до 60 секунд с девяти секунд при использовании ROWS.

Неприятный момент заключается в том, что если указать предложение упорядочения окна без явного предложения оконного кадра, по умолчанию будет использоваться параметр RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, как в следующем запросе:

SELECT actid, tranid, val,
  SUM(val) OVER(PARTITION BY actid
                ORDER BY tranid) AS balance
FROM dbo.Transactions;

Очень большая вероятность того, что многие будут полагать, что по умолчанию используется ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, не осознавая, что это по сути означает RANGE. Это приведет к значительному падению производительности, не говоря уже о дубликатах. Я надеюсь, что в будущем, по крайней мере при наличии уникальности значений в каждой секции, в этом варианте с возможностью быстрого выполнения, оптимизатор будет преобразовывать параметр RANGE в ROWS.

Используя рассказанное ранее, можно улучшить параллельное выполнение запроса с RANGE с применением APPLY:

SELECT C.actid, A.*
FROM dbo.Accounts AS C
  CROSS APPLY (SELECT tranid, val,
                 SUM(val) OVER(ORDER BY tranid
                               RANGE BETWEEN UNBOUNDED PRECEDING
                                         AND CURRENT ROW) AS balance
               FROM dbo.Transactions AS T
               WHERE T.actid = C.actid) AS A;

Для этого запроса создается параллельный план, выполняющийся 21 секунду — треть времени выполнения запроса в отсутствие APPLY. Но это все равно намного медленнее, чем в версии с ROWS. Поэтому можно считать рекомендацией использовать параметр ROWS, где это только возможно — естественно при условии уникальности и принципиальной эквивалентности двух вариантов.

Развертывание всех строк кадров

В предыдущем разделе я описал быстрый способ, когда нижней границей кадра является UNBOUNDED PRECEDING. В этом случае SQL Server не разворачивает все строки кадра для каждой строки, а только накапливает значения. Как уже говорилось, итератор Window Spool дает только две строки на каждую исходную строку: одна с накопленными на данный момент значениями и вторая — с базовой строкой для работы с элементами подробностей.

Если нижняя граница не UNBOUNDED PRECEDING, быстрый вариант не работает. В таких случаях оптимизатор будет выбирать между двумя стратегиями. Первая стратегия, о которой я сейчас расскажу, заключается в развертывании всех строк кадров для каждой исходной строки. Другая стратегия, о которой мы поговорим чуть позже, заключается в вычислении двух совокупных значений - CumulativeBottom и CumulativeTop - и получении результата на их основе.

Для применения второй стратегии нужно, чтобы агрегат был накопительным, или кумулятивным, (SUM, COUNT, COUNT_BIG, AVG, STDEV, STDEVP, VAR или VARP), кроме того она оправдана, если в кадре больше четырех строк. Если агрегат не кумулятивный (MIN, MAX, FIRST_VALUE, LAST_VALUE или CHECKSUM_ AGG) или если в кадре четыре или меньше строк, применяется первая стратегия (в которой все строки кадров разворачиваются для каждой исходной строки).

В ядре СУБД LAG и LEAD преобразуются в функцию LAST_VALUE с одной строкой, поэтому я не буду раздельно обсуждать LAG и LEAD. Например, LAG(x, 6) OVER(ORDER BY y) преобразуется в LAST_VALUE(x) OVER(ORDER BY y ROWS BETWEEN 6 PRECEDING AND 6 PRECEDING).

Посмотрите на следующий пример:

SELECT actid, tranid, val,
  SUM(val) OVER(PARTITION BY actid
                ORDER BY tranid
                ROWS BETWEEN 5 PRECEDING
                         AND 2 PRECEDING) AS sumval
FROM dbo.Transactions;

План этого запроса показан на рисунке ниже. На его выполнение ушло 14 секунд:

План с развертыванием всех строк кадра

В запросе применяется кумулятивный агрегат (SUM), но в кадре только четыре строки, поэтому развертываются все строки кадров. Четыре строки в каждом кадре плюс текущая строка, нужная для элементов подробностей, дадут после итератора Window Spool пять строк для каждой исходной строки. Поэтому в плане видно, что итератор Window Spool генерирует почти 10 млн выходных строк из 2 млн исходных строк. Кадры в первых нескольких строках каждой секции содержат меньше четырех строк, поэтому план показывает, что итератор Window Spool генерирует чуть менее 10 млн строк.

Итератор Window Spool должен знать, какую целевую строку хранить в рабочей таблице для каждой исходной строки, а также должен сгенерировать идентификатор кадра в целевых строках, чтобы у итератора Stream Aggregate было по чему группировать строки.

Для определения, какие строки создавать в каждом кадре, план начинается с вычисления числа строк для каждой исходной строки (с применением сначала итератора Segment, а затем итератора Sequence Project). При определении числа строк используются те же параметры секционирования и упорядочения, что и в исходной оконной функции. В плане используется итератор Compute Scalar для вычисления двух значений для каждой исходной строки - BottomRowNumberN и TopRowNumberN. Предполагается, что эти номера строк ограничивают кадр.

Представьте, к примеру, что номер текущей строки — 10. Соответствующие номера границ кадра таковы: TopRowNumberN = 10 - 5 = 5 и BottomRowNumber = 10-2 = 8. Рабочая таблица, созданная итератором Window Spool, будет индексироваться по этим номерам строк. Поэтому если в рабочей таблице уже есть строки с номерами от 5 до 8, они будут запрошены и добавлены в рабочую таблицу, связанную с новым кадром. Если каких-то строк не хватает, план будет запрашивать больше строк и размещать их в рабочей таблице, пока не будет достигнута последняя строка. Для каждой исходной строки итератор Window Spool генерирует атрибут по имени WindowCountN, который идентифицирует кадр. Именно по этому атрибуту итератор Stream Aggregate группирует строки.

Помимо вычисления нужного агрегата, итератор Aggregate вычисляет число строк в кадре, а затем следующий итератор Compute Scalar возвращает NULL, если кадр пустой.

Если число строк в кадре равно четырем или меньше, все строки кадра будут развернуты независимо о используемой оконной функции. Аналогичным образом обрабатываются кадры 2 PRECEDING AND 1 FOLLOWING, 2 FOLLOWING AND 5 FOLLOWING и т.д.

Если текущая строка является границей кадра, в плане не нужно вычислять верхний и нижний номера строки. Будет вычислен только один номер границы в дополнение к существующему RowNumberN. Например, для кадра 3 PRECEDING AND CURRENT ROW будет вычисляться только TopRowNumberN * (RowNumberN - 3), а для кадра CURRENT ROW AND 3 FOLLOWING — BottomRowNumberN * (RowNumberN + 3). Другая граница будет просто RowNumberN.

Если оконная функция не является накопительной (MIN, MAX, FIRST_VALUE, LAST_VALUE или CHECKSUM_AGG), разворачиваются все строки кадра независимо от числа строк в кадре. Посмотрите на следующий пример:

SELECT actid, tranid, val,
  MAX(val) OVER(PARTITION BY actid
                ORDER BY tranid
                ROWS BETWEEN 100 PRECEDING
                          AND  2 PRECEDING) AS maxval
FROM dbo.Transactions;
План агрегата MAX

Для агрегата MAX развертываются все строки кадра. Это 99 строк на кадр, умножьте это число на число строк в таблице и вы получите приличное число строк, возвращаемых итератором Window Spool (примерно 200 млн строк). Этот запрос выполнялся 75 секунд.

Как видите, SQL Server решил применить параллельный план. Я уже объяснял, какие проблемы возможны при использовании стандартных механизмов параллелизма в оконных функциях, и предложил вместо этого использовать параллельную методику с применением APPLY. Вот параллельная версия с APPLY:

SELECT C.actid, A.*
FROM dbo.Accounts AS C
  CROSS APPLY (SELECT tranid, val,
                 MAX(val) OVER(ORDER BY tranid
                               ROWS BETWEEN 100 PRECEDING
                                        AND  2 PRECEDING) AS maxval
               FROM dbo.Transactions AS T
               WHERE T.actid = C.actid) AS A;

На моей машине выполнение этого запроса заняло 31 секунду. В итераторе Window Spool теперь используется новая оптимизированная рабочая таблица в памяти. Однако при возникновении следующих условий у итератора не будет выбора и придется вернуться к намного более медленной рабочей таблице на диске со всеми ее издержками (блокировками и дополнительными операциями ввода/вывода):

Есть ряд приемов, которые позволяют проверить, какой тип рабочих таблиц использует SQL Server — в памяти или на диске. Первый прием заключается в использовании параметра STATISTICS IO, а второй — в применении расширенного события, предназначенного именно для этой цели.

При использовании параметра STATISTICS IO можно быть уверенным, что если число операций чтения рабочей таблицы равно нулю, она размещается в памяти, в противном случае она размещается на диске. В качестве примера приведу код, который включает параметр STATISTICS IO ON и выполняет два запроса с применением оконной функции MAX:

SET STATISTICS IO ON;

SELECT actid, tranid, val,
  MAX(val) OVER(PARTITION BY actid
                ORDER BY tranid
                ROWS BETWEEN 9999 PRECEDING
                         AND 9999 PRECEDING) AS maxval
FROM dbo.Transactions;

SELECT actid, tranid, val,
  MAX(val) OVER(PARTITION BY actid
                ORDER BY tranid
                ROWS BETWEEN 10000 PRECEDING
                         AND 10000 PRECEDING) AS maxval
FROM dbo.Transactions;

В запросе используется следующий кадр:

ROWS BETWEEN 9999 PRECEDING AND 9999 PRECEDING

Расстояние в строках между конечными точками (как вы помните, текущая строка также считается) равно 10 000, поэтому может использоваться рабочая таблица в памяти. Этот запрос выполняется за шесть секунд. Во втором запросе используется следующий кадр:

ROWS BETWEEN 10000 PRECEDING AND 10000 PRECEDING

На этот раз расстояние между конечными точками равно 10 001, поэтому используется рабочая таблица на диске. Этот запрос выполнился за 33 секунды.

Вот результат вывода STATISTICS IO для двух запросов:

-- 9999 PRECEDING AND 9999 PRECEDING, 6 секунд
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, 
read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Transactions'. Scan count 1, logical reads 6208, physical reads 0, 
read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

-- 10000 PRECEDING AND 10000 PRECEDING, 33 секунды
Table 'Worktable'. Scan count 2000100, logical reads 12086700, physical reads 0, 
read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Transactions'. Scan count 1, logical reads 6208, physical reads 0, 
read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Заметьте, что в первом запросе сообщается о нуле операций чтения, а во втором запросе — 12 086 700 операций.

Прежде чем перейти к описанию второй методики выполните следующий код, чтобы выключить параметр STATISTICS IO:

SET STATISTICS IO OFF;

Второй способ выяснить, размещалась ли рабочая таблица на диске, заключается в использовании расширенного события window_spool_ondisk_warning. Выполните следующий код, чтобы создать сеанс события, используя асинхронный целевой файл, и запустить сеанс:

CREATE EVENT SESSION xe_window_spool ON SERVER
ADD EVENT sqlserver.window_spool_ondisk_warning
  ( ACTION (sqlserver.plan_handle, sqlserver.sql_text) )
ADD TARGET package0.asynchronous_file_target
  ( SET FILENAME  = N'c:\temp\xe_xe_window_spool.xel', 
    metadatafile  = N'c:\temp\xe_xe_window_spool.xem' );

ALTER EVENT SESSION xe_window_spool ON SERVER STATE = START;

Повторно выполните приведенные ранее запросы, после чего откройте файл c:\temp\xe_xe_window_spool.xel из консоли SQL Server Management Studio (SSMS). Вы найдете в нем информацию о запросах, в которых рабочие таблицы размещались на диске, а также описатель плана и текст запроса. По завершении надо выполнить следующий код очистки:

DROP EVENT SESSION xe_window_spool ON SERVER;

Если оконная функция является накопительной (SUM, COUNT, COUNT_BIG, AVG, STDEV, STDEVP, VAR или VARP) и в кадре более четырех строк, оптимизатор использует специализированную стратегию, которая не предусматривает развертывание всех строк кадра. В ней предусматривается вычисление двух накопительных значений, после чего из них выводится результат. В качестве примера посмотрите на такой запрос:

SELECT actid, tranid, val,
  SUM(val) OVER(PARTITION BY actid
                ORDER BY tranid
                ROWS BETWEEN 100 PRECEDING
                          AND  2 PRECEDING) AS sumval
FROM dbo.Transactions;

План этого запроса показан на рисунке ниже. Этот запрос выполнялся 14 секунд.

План вычисления двух накопительных значений

Оптимизатор решил применить параллельный план. В плане используется параллельный просмотр POC-индекса итератором обмена, который повторно выполняет секционирование по элементу, по которому выполняется секционирование окна (в нашем случае это actid). После этого в плане применяется последовательность итераторов (Segment, Sequence Project, Compute Scalar, Segment, Window Spool и Stream Aggregate) для вычисления накопительных нижних агрегированных значений SUM и COUNT (мы назовем их CumulativeBottomSum и CumulativeBottomCount). Для вычисления накопительных нижних агрегатов отбираются строки с начала секции и до строки с номером на два меньше номера текущей строки. Это именно тот способ вычисления накопительных агрегатов, о котором я рассказывал в разделе «UNBOUNDED PRECEDING: быстрый путь». Таким образом, видно, что итератор Window Spool генерирует только две строки для каждой исходной строки — строку с накопленными значениями и строку с элементами подробностей.

После этого в плане применяется другая последовательность итераторов (Segment, Sequence Project, Compute Scalar, Segment, Window Spool и Stream Aggregate) для вычисления накопительных верхних агрегированных значений SUM и COUNT (мы назовем их CumulativeTopSum и CumulativeTopCount). Для вычисления накопительных верхних агрегатов отбираются строки с начала секции и до строки с номером на 101 меньше номера текущей строки.

После этого итератор Compute Scalar вычисляет SUM для кадра как CumulativeBottomSum - CumulativeTopSum и COUNT как CumulativeBottomCount - CumulativeTopCount. Наконец последний итератор Compute Scalar определяет число строк в оконном кадре, и если оно равно нулю, возвращает NULL.

Как я уже говорил, в моей системе этот запрос выполнялся 14 секунд. При этом использовался встроенный механизм параллельной обработки оконных функций. Можно также воспользоваться методикой с APPLY как показано здесь:

SELECT C.actid, A.*
FROM dbo.Accounts AS C
  CROSS APPLY (SELECT tranid, val,
                 SUM(val) OVER(ORDER BY tranid
                               ROWS BETWEEN 100 PRECEDING
                                         AND  2 PRECEDING) AS sumval
               FROM dbo.Transactions AS T
               WHERE T.actid = C.actid) AS A;

На моей системе это позволило сократить время выполнения до восьми секунд.

Пройди тесты
Лучший чат для C# программистов