§ P20 — DAX-кейс · Оптимизация

Динамический ABC на DAX: 14× ускорение через chunking

Почему O(N²) умирает и что с этим делать

DaxPatterns-версия ABC на 10 тыс. товаров открывается 13.8 секунды. На 100 тыс. просто падает. Моя production-мера даёт 989 мс на той же модели и тянет сотни тысяч SKU без проблем — за счёт одного трюка с разбиением на блоки. Внутри: полный DAX, живые замеры из DAX Studio и почему захардкоженное число 1500 побеждает любую «умную» эвристику.

ABC нужен везде, где есть ассортимент: товары, клиенты, счета, ошибки в логах — что угодно, что даёт условную «выручку». Математика простая: сортируем по убыванию, считаем накопительную долю, режем на 80/15/5.

Все открытые DAX-реализации, которые я встречал, сводятся к одной и той же идее из шаблона Russo & Ferrari на daxpatterns.com. Читается идеально. Тормозит так же идеально: как только каталог переваливает за несколько тысяч строк, отчёт с ABC-матрицей становится неюзабельным. На 100 тыс. SKU я видел, как она падает с таймаутом.

Корень проблемы — один FILTER в середине формулы, делающий полный скан таблицы товаров для каждого товара. Алгоритмически O(N²), 10⁸ операций на 10 тыс. SKU. Дальше — как это починить без потери читаемости.

Тестовая модель. 9999 товаров (все с продажами), 1 000 000 строк транзакций, суммарная выручка 31.05 млн ₽. Таблицы: Продажи (date, product_id, amount, qty), Номенклатура (id, name), Сегментация ABC (классы с границами), 000 Календарь. База замеров: Power BI Desktop, DAX Studio-совместимые метрики через MCP-трейсинг.

Классическая мера (DaxPatterns) и её проблема

Референс-реализация из The Definitive Guide to DAX выглядит так (адаптированная под нашу модель):

ABC (by DaxPatterns.com) =
VAR SalesByProduct =
    CALCULATETABLE (
        ADDCOLUMNS (
            SUMMARIZE ('Продажи', 'Номенклатура'[ID]),
            "@ProdSales", [Продажи руб]
        ),
        ALLSELECTED ('Номенклатура')
    )
VAR AllSales =
    CALCULATE ([Продажи руб], ALLSELECTED ('Номенклатура'))
VAR CumulatedPctByProduct =
    ADDCOLUMNS (
        SalesByProduct,
        "@CumulatedPct",
        VAR CurrentSalesAmt = [@ProdSales]
        VAR CumulatedSales =
            FILTER (SalesByProduct, [@ProdSales] >= CurrentSalesAmt)
        VAR CumulatedSalesAmount = SUMX (CumulatedSales, [@ProdSales])
        RETURN
            MIN (DIVIDE (CumulatedSalesAmount, AllSales), 1)
    )
VAR ProductsInClass =
    FILTER (
        CROSSJOIN (CumulatedPctByProduct, 'Сегментация ABC'),
        AND (
            [@CumulatedPct] > 'Сегментация ABC'[Нижняя граница],
            [@CumulatedPct] <= 'Сегментация ABC'[Верхняя граница]
        )
    )
RETURN
    CALCULATE ([# SKU], KEEPFILTERS (ProductsInClass))

Для каждого товара считается накопительный процент (сумма продаж товаров с выручкой не меньше текущей, делённая на общий итог), затем CROSSJOIN с таблицей границ кладёт товар в нужный класс. Всё динамически, пороги меняются через фильтры отчёта — именно эта часть и нужна.

Плохая часть — строка FILTER (SalesByProduct, [@ProdSales] >= CurrentSalesAmt). Она выполняется для каждого из 9999 товаров и каждый раз делает SUMX по всем 9999 строкам. Получается ~10⁸ операций на одну ABC-ячейку. Математически корректно, на практике — катастрофа.

Цифры с нашей модели:

ПрогонTotalFESESE queriesPeak mem
DaxPatterns, холодный старт6 670 мс6 6046678.3 MB
DaxPatterns, тёплая модель13 797 мс13 66613078.3 MB

99% времени в Formula движок. Storage движок отработал за 130 мс — данные подняты мгновенно, дальше всё умирает в DAX-движке. Тёплая модель (когда Power BI Desktop поработал минут 15 и потерял часть ресурсов на фоновые процессы) удваивает время — именно это увидит пользователь у себя в проде.

На модели со 100 тыс. товаров эта же мера у меня не возвращается вообще. Таймаут, OOM, что-то между — неважно, результата нет.

Идея: chunking в два прохода

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

  1. Грубый проход по блокам. Делим отсортированный список товаров на чанки по ~1500 штук. Для каждого чанка считаем накопительную долю выручки до конца чанка — дешёвая операция, один раз на чанк.
  2. Точный проход только в граничных чанках. Находим чанки, где кумулятивная доля пересекает порог 80% (граница A) и 95% (граница B). Только внутри этих чанков (~1500 товаров) считаем точный O(N²) cumulative sum. Остальные 8500 товаров либо гарантированно в «своём» классе, либо гарантированно не в нём — детально разбирать их не надо.

Сложность схлопывается до O(N + K² × число классов), где K — размер чанка. При N=10 тыс. и K=1500: 10 000 + 1500²×2 = 4.5 млн операций вместо 100 млн. На N=100 тыс. разрыв уже на два порядка — поэтому naive-версия там ложится, а chunking работает.

Алгоритм пошагово

Шаг 1

Собираем продажи по товарам, отбрасываем нули

Стандартный SUMMARIZE + FILTER по Amount > 0. Нулевые/BLANK исключаются сразу — они не должны классифицироваться ни в A, ни в B, ни в C и ломать алгоритм тоже не должны.

Шаг 2

Ранжируем с tie-breaker по ID

Главный трюк: обычный RANKX по [@Amount] может дать одинаковый ранг двум товарам с равной выручкой. Это ломает последующее разбиение на чанки по рангу. Решение — микро-tie-breaker: ROUND([@Amount], 2) × 100 + [ID] / 1 000 000. ID младшего порядка гарантированно не переопределяет Amount, но делает ранг уникальным.

Дальше нормируем ранг в диапазон [0 .. RANK_SCALE]. RANK_SCALE = 1 000 000 выбран с запасом на любой разумный каталог (хоть 1 млн SKU).

Шаг 3

Разбиваем на чанки через GENERATESERIES

ChunkCount = ROUNDUP(ActiveCount / CHUNK_SIZE). Для 9999 товаров и CHUNK_SIZE = 1500 получаем 7 чанков. GENERATESERIES(1, 7) — даёт таблицу с номерами чанков.

Шаг 4

Считаем накопительные метрики по чанкам

Для каждого чанка считаем три столбца:

  • @CumShare — накопительная доля выручки «до конца чанка N»
  • @MaxRank — максимальный ранг (граница чанка по рангу)
  • @MinAmount — минимальная выручка (пригодится для фильтрации граничных товаров)

Операция линейная по числу товаров (N × число_колонок), не квадратичная.

Шаг 5

Определяем граничный чанк для класса A

Ищем первый чанк, где @CumShare уже превысил 80%, и последний, где ещё не превысил. Граничный чанк — между ними. Товары с рангом до «@MaxRank последнего чанка до границы» гарантированно в A. Остальным нужна точная проверка.

Шаг 6

Точный кумулятивный расчёт ТОЛЬКО в граничном чанке

Для 1500 товаров граничного чанка делаем классический O(K²) cum-sum с прибавлением накопленной доли предыдущих полностью-A чанков. Получаем точный порог по выручке (ThresholdAmount), который делит A и B внутри граничного чанка.

Шаг 7

Симметрично для класса B, формируем таблицу ID → Class

Повторяем шаги 5-6 с порогом 95%. Класс C — всё, что не в A и не в B (через EXCEPT). Финальный UNION с лейблами — в CALCULATE + KEEPFILTERS.

Production-мера с чистым кодом

Вот оптимизированная DAX-мера целиком. Имена переменных говорящие, логика разбита блок-комментариями, константы вынесены наверх. Под твою модель заменяешь 'Продажи руб', 'Номенклатура', '000 Календарь', 'Сегментация ABC' на свои имена.

ABC Продажи руб (refactored) =
// ══════════════════════════════════════════════════════════════════
// ABC-классификация по накопительной доле выручки (chunking).
// Сложность: O(N) + O(CHUNK_SIZE²) вместо O(N²).
// ══════════════════════════════════════════════════════════════════

VAR CHUNK_SIZE  = 1500                    // эмпирически оптимальный размер чанка
VAR RANK_SCALE  = 1000000                 // масштаб нормированного ранга

VAR BoundaryA = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "A")
VAR BoundaryB = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "B")
VAR BoundaryC = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "C")

// ─── ШАГ 1-2. Подготовка и ранжирование ───
VAR AllProducts =
    SUMMARIZE(
        ALLSELECTED('Номенклатура'),
        'Номенклатура'[ID],
        "@Amount", CALCULATE([Продажи руб], ALLSELECTED('000 Календарь'))
    )
VAR ActiveProducts = FILTER(AllProducts, [@Amount] > 0)
VAR ActiveCount    = COUNTROWS(ActiveProducts)
VAR TotalAmount    = SUMX(ActiveProducts, [@Amount])

VAR RankedProducts =
    ADDCOLUMNS(
        ActiveProducts,
        "@Rank",
            ROUND(
                DIVIDE(
                    RANKX(
                        ActiveProducts,
                        IF(NOT ISBLANK([@Amount]), ROUND([@Amount], 2) * 100, 0)
                            + [ID] / RANK_SCALE,   // tie-breaker
                        , DESC, DENSE
                    ),
                    ActiveCount
                ) * RANK_SCALE,
                0
            ),
        "@Share", DIVIDE([@Amount], TotalAmount)
    )

// ─── ШАГ 3-4. Чанкинг и накопительные доли ───
VAR ChunkCount = ROUNDUP(DIVIDE(ActiveCount, CHUNK_SIZE), 0)
VAR ChunkSeries = GENERATESERIES(1, IF(ISBLANK(ChunkCount), 1, ChunkCount))

VAR ChunkCumulatives =
    ADDCOLUMNS(
        ChunkSeries,
        "@CumShare",
            DIVIDE(
                SUMX(
                    FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
                    [@Amount]
                ),
                TotalAmount
            ),
        "@MaxRank",
            MAXX(
                FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
                [@Rank]
            ),
        "@MinAmount",
            MINX(
                FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
                [@Amount]
            )
    )

// ─── ШАГ 5-6. Граница класса A ───
VAR RankBeforeA      = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@MaxRank])
VAR MinAmountAfterA  = MAXX(FILTER(ChunkCumulatives, [@CumShare] >  BoundaryA), [@MinAmount])
VAR CumBeforeA       = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@CumShare])

VAR BoundaryChunkA =
    FILTER(RankedProducts, [@Rank] > RankBeforeA && [@Amount] >= MinAmountAfterA)

VAR BoundaryChunkA_WithCum =
    ADDCOLUMNS(
        BoundaryChunkA,
        "@CumShareExact",
            VAR CurrentRank = [@Rank]
            RETURN
                SUMX(FILTER(BoundaryChunkA, [@Rank] <= CurrentRank), [@Share])
                    + IF(ISBLANK(CumBeforeA), 0, CumBeforeA)
    )

VAR ThresholdAmountA =
    MINX(FILTER(BoundaryChunkA_WithCum, [@CumShareExact] <= BoundaryA), [@Amount])

// ─── ШАГ 5-6. Граница класса B (симметрично) ───
VAR RankBeforeB      = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@MaxRank])
VAR MinAmountAfterB  = MAXX(FILTER(ChunkCumulatives, [@CumShare] >  BoundaryB), [@MinAmount])
VAR CumBeforeB       = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@CumShare])

VAR BoundaryChunkB =
    FILTER(RankedProducts, [@Rank] > RankBeforeB && [@Amount] >= MinAmountAfterB)

VAR BoundaryChunkB_WithCum =
    ADDCOLUMNS(
        BoundaryChunkB,
        "@CumShareExact",
            VAR CurrentRank = [@Rank]
            RETURN
                SUMX(FILTER(BoundaryChunkB, [@Rank] <= CurrentRank), [@Share])
                    + IF(ISBLANK(CumBeforeB), 0, CumBeforeB)
    )

VAR ThresholdAmountB =
    MINX(FILTER(BoundaryChunkB_WithCum, [@CumShareExact] <= BoundaryB), [@Amount])

// ─── ШАГ 7. Классификация и финальный фильтр ───
VAR ProductsInA =
    UNION(
        SELECTCOLUMNS(FILTER(BoundaryChunkA_WithCum, [@Amount] >= ThresholdAmountA), "ID", [ID]),
        SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeA), "ID", [ID])
    )

VAR ProductsInAB =
    UNION(
        SELECTCOLUMNS(FILTER(BoundaryChunkB_WithCum, [@Amount] >= ThresholdAmountB), "ID", [ID]),
        SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeB), "ID", [ID])
    )

VAR ProductsInB = EXCEPT(ProductsInAB, ProductsInA)
VAR ProductsInC = EXCEPT(SELECTCOLUMNS(RankedProducts, "ID", [ID]), ProductsInAB)

VAR ProductsInCurrentClass =
    FILTER(
        UNION(
            ADDCOLUMNS(ProductsInA, "@Class", "A", "@Value", BoundaryA),
            ADDCOLUMNS(ProductsInB, "@Class", "B", "@Value", BoundaryB),
            ADDCOLUMNS(ProductsInC, "@Class", "C", "@Value", BoundaryC)
        ),
        [@Value] > [MinBoundary] && [@Value] <= [MaxBoundary]
    )

RETURN
    CALCULATE([Продажи руб], KEEPFILTERS(ProductsInCurrentClass))

Плюс две вспомогательные меры, которые читают динамические границы классов из таблицы-справочника:

MinBoundary = MIN('Сегментация ABC'[Нижняя граница])
MaxBoundary = MAX('Сегментация ABC'[Верхняя граница])

И справочник:

Сегментация ABC =
    DATATABLE(
        "Классификация", STRING, "Сегмент", STRING,
        "Нижняя граница", DOUBLE, "Верхняя граница", DOUBLE,
        {
            {"ABC", "A", 0.00, 0.80},
            {"ABC", "B", 0.80, 0.95},
            {"ABC", "C", 0.95, 1.00}
        }
    )

Пользователь двигает слайсер «Сегмент» — мера мгновенно пересчитывает, какие товары попадают в этот сегмент и какую выручку они дают. Классы лежат в отдельной таблице, пороги меняются без пересборки модели — поэтому «динамический» в названии.

Бенчмарк: chunking vs DaxPatterns

Сравниваем на той же модели, одинаковый SUMMARIZECOLUMNS-запрос с осью по сегменту ABC:

§ Время выполнения, мс (меньше = лучше)
DaxPatterns cold6 670
DaxPatterns warm13 797
Chunking cold388
Chunking warm989
МераCold startWarm modelFE %SpeedupРезультат A/B/C
ABC (by DaxPatterns.com)6 67013 79799%4845/2420/2734
ABC Продажи руб (chunking, original)4071 02696%16.4×4845/2420/2734 ✓
ABC Продажи руб (refactored)38898994%17.2× / 13.9×4845/2420/2734 ✓

Оба варианта chunking (оригинальный и рефакторенный) дают одинаковый speedup и идентичные значения A=4845, B=2420, C=2734. Рефакторинг — только про читаемость, без регрессии по скорости.

На модели 100 тыс. товаров DaxPatterns-версия не возвращает результат в разумное время (timeout/OOM), chunking-версия работает стабильно.

Grid-search: какой ChunkSize выбрать

Хочется сделать адаптивный ChunkSize: пусть формула сама подстраивается под размер каталога. Прогнали 10 фиксированных значений плюс адаптивную эвристику 15%. Минимум из 2-3 прогонов на каждый:

§ Grid-search ChunkSize на 9999 товарах, мс
CS = 1001 488
CS = 500952
CS = 1000938
CS = 1200752
CS = 1500388
CS = 17001 704
CS = 20002 421
CS = 30003 198
CS = 500012 488
Adaptive 15%917

Я ожидал плавную U-кривую — где-то оптимум, по краям хуже. Получилось не совсем так:

  • Зависимость НЕ монотонная. Оптимум резко выделен на CS=1500, соседние 1200 и 1700 хуже в 2-4 раза. Значения рядом лежат в кратерах, а не на склонах.
  • При CS=5000 мера медленнее, чем naive DaxPatterns. Всего два чанка, в граничном — 5000 товаров, точный cum-sum внутри O(25 млн) — сопоставимо с исходной квадратичной задачей.
  • Adaptive 15% даёт те же 7 чанков, что и CS=1500. Математически идентично. Но 917 мс vs 388 мс — в 2.4 раза медленнее. Разница в одной строке: INT(ActiveCount * 0.15) против константы 1500. DAX-оптимизатор не умеет folding-ать выражение, которое зависит от runtime-значения — весь план меры перестраивается хуже. Я проверил это специально — реально зависит только от INT().

Почему именно 1500? На нашей модели с Парето 80/15/5 класс A занимает ~1500 топовых товаров по выручке. Первый чанк почти целиком попадает в A, граничный чанк A остаётся почти пустым, точный cum-sum внутри делает минимум работы. Если у вас другое распределение (резкий long-tail, мягкое 70/30 или смешанные категории) — оптимум уедет. Прогоните grid-search у себя, это 10 минут.

Практическая рекомендация. Начните с CS = 1500. Если каталог меньше 5 тыс. SKU — попробуйте 500-800. Если больше 50 тыс. — прогоните свой grid-search для 5-6 значений вокруг N×0.15. На production-модели 10 минут на подбор оптимального ChunkSize — самая недооценённая оптимизация в DAX. Не пытайтесь сделать его «умным» через формулу — фиксированная константа чаще обыгрывает адаптивную эвристику.

Типичные ошибки при переносе в свою модель

  1. Забыли tie-breaker в RANKX. При одинаковых выручках два товара получат один ранг, разбиение на чанки поедет, на границе классов могут быть «пропавшие» или «удвоенные» товары.
  2. ID не целочисленный или > RANK_SCALE. Tie-breaker через [ID] / RANK_SCALE рассчитан на то, что ID ≤ 1 000 000. Для GUID/UUID нужен отдельный суррогатный integer key.
  3. FILTER по T2 вместо T3. Классика багов: ADDCOLUMNS возвращает новую таблицу, но в следующих VAR легко сослаться на старую и потерять @Rank/@Share. В рефакторинге одно имя RankedProducts везде — ошибка невозможна.
  4. ActiveCount считаем от всех продуктов, а не от продуктов с Amount > 0. Тогда ChunkCount включает нулевые товары, границы чанков сбиваются, класс C может переполниться.
  5. Пропущен ALLSELECTED('000 Календарь') в CALCULATE. Тогда ABC реагирует на фильтр по дате в визуале. Иногда это нужно, чаще — нет. Решите, должен ли ABC-класс меняться при выборе периода, и сознательно выбирайте ALLSELECTED / ALL / KEEPFILTERS.
  6. Класс D (нулевые продажи) не вынесен явно. В нашей мере товары с Amount = 0 игнорируются (отфильтрованы в ActiveProducts). Если в отчёте нужно различать «нет продаж» и «остатки есть, но нет продаж» — добавьте отдельный класс D и ветку в ProductsInCurrentClass.
  7. Слишком большой CHUNK_SIZE. Если чанков становится 2-3, мера может стать медленнее naive-версии. Проверьте через свой grid-search.

Когда chunking оправдан

Размер каталогаNaive DaxPatternsChunking CS=1500Рекомендация
< 1 000 SKU< 100 мс< 150 мсNaive хватает. Сложность chunking не окупается.
1 000 — 5 0000.5-3 с150-400 мсНачинаем задумываться: UX уже ощутимо страдает на матрицах.
5 000 — 50 0005-60 с0.5-3 сChunking обязателен. Naive даёт неюзабельный отчёт.
> 50 000timeout / OOM3-15 сТолько chunking, naive не выдерживает.

Готовый DAX для копирования

Чтобы не переписывать формулы вручную — обе меры (naive базовый уровень и chunking-рефактор) ниже в раскрывающихся блоках. Копируешь, вставляешь в свою модель, меняешь имена таблиц (Продажи, Номенклатура, 000 Календарь, Сегментация ABC) на свои — всё.

ABC (by DaxPatterns.com) — naive базовый уровень для сравнения
ABC (by DaxPatterns.com) =
VAR SalesByProduct =
    CALCULATETABLE (
        ADDCOLUMNS (
            SUMMARIZE ( 'Продажи', 'Номенклатура'[ID] ),
            "@ProdSales", [Продажи руб]
        ),
        ALLSELECTED ( 'Номенклатура' )
    )
VAR AllSales =
    CALCULATE ( [Продажи руб], ALLSELECTED ( 'Номенклатура' ) )
VAR CumulatedPctByProduct =
    ADDCOLUMNS (
        SalesByProduct,
        "@CumulatedPct",
        VAR CurrentSalesAmt = [@ProdSales]
        VAR CumulatedSales =
            FILTER ( SalesByProduct, [@ProdSales] >= CurrentSalesAmt )
        VAR CumulatedSalesAmount = SUMX ( CumulatedSales, [@ProdSales] )
        VAR Perc = DIVIDE ( CumulatedSalesAmount, AllSales )
        RETURN MIN ( Perc, 1 )
    )
VAR ProductsInClass =
    FILTER (
        CROSSJOIN ( CumulatedPctByProduct, 'Сегментация ABC' ),
        AND (
            [@CumulatedPct] > 'Сегментация ABC'[Нижняя граница],
            [@CumulatedPct] <= 'Сегментация ABC'[Верхняя граница]
        )
    )
RETURN
    CALCULATE ( [# SKU], KEEPFILTERS ( ProductsInClass ) )
ABC Продажи руб (refactored) — chunking-оптимизация, 14-17× быстрее
ABC Продажи руб (refactored) =
// ══════════════════════════════════════════════════════════════════
// ABC-классификация по накопительной доле выручки (chunking).
// Сложность: O(N) + O(CHUNK_SIZE²) вместо O(N²).
// ══════════════════════════════════════════════════════════════════

VAR CHUNK_SIZE  = 1500                    // эмпирически оптимальный размер чанка
VAR RANK_SCALE  = 1000000                 // масштаб нормированного ранга

VAR BoundaryA = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "A")
VAR BoundaryB = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "B")
VAR BoundaryC = CALCULATE([MaxBoundary], 'Сегментация ABC'[Сегмент] = "C")

// ─── ШАГ 1-2. Подготовка и ранжирование ───
VAR AllProducts =
    SUMMARIZE(
        ALLSELECTED('Номенклатура'),
        'Номенклатура'[ID],
        "@Amount", CALCULATE([Продажи руб], ALLSELECTED('000 Календарь'))
    )
VAR ActiveProducts = FILTER(AllProducts, [@Amount] > 0)
VAR ActiveCount    = COUNTROWS(ActiveProducts)
VAR TotalAmount    = SUMX(ActiveProducts, [@Amount])

VAR RankedProducts =
    ADDCOLUMNS(
        ActiveProducts,
        "@Rank",
            ROUND(
                DIVIDE(
                    RANKX(
                        ActiveProducts,
                        IF(NOT ISBLANK([@Amount]), ROUND([@Amount], 2) * 100, 0)
                            + [ID] / RANK_SCALE,   // tie-breaker
                        , DESC, DENSE
                    ),
                    ActiveCount
                ) * RANK_SCALE,
                0
            ),
        "@Share", DIVIDE([@Amount], TotalAmount)
    )

// ─── ШАГ 3-4. Чанкинг и накопительные доли ───
VAR ChunkCount = ROUNDUP(DIVIDE(ActiveCount, CHUNK_SIZE), 0)
VAR ChunkSeries = GENERATESERIES(1, IF(ISBLANK(ChunkCount), 1, ChunkCount))

VAR ChunkCumulatives =
    ADDCOLUMNS(
        ChunkSeries,
        "@CumShare",
            DIVIDE(
                SUMX(
                    FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
                    [@Amount]
                ),
                TotalAmount
            ),
        "@MaxRank",
            MAXX(
                FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
                [@Rank]
            ),
        "@MinAmount",
            MINX(
                FILTER(RankedProducts, [@Rank] <= DIVIDE([Value], ChunkCount) * RANK_SCALE),
                [@Amount]
            )
    )

// ─── ШАГ 5-6. Граница класса A ───
VAR RankBeforeA      = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@MaxRank])
VAR MinAmountAfterA  = MAXX(FILTER(ChunkCumulatives, [@CumShare] >  BoundaryA), [@MinAmount])
VAR CumBeforeA       = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryA), [@CumShare])

VAR BoundaryChunkA =
    FILTER(RankedProducts, [@Rank] > RankBeforeA && [@Amount] >= MinAmountAfterA)

VAR BoundaryChunkA_WithCum =
    ADDCOLUMNS(
        BoundaryChunkA,
        "@CumShareExact",
            VAR CurrentRank = [@Rank]
            RETURN
                SUMX(FILTER(BoundaryChunkA, [@Rank] <= CurrentRank), [@Share])
                    + IF(ISBLANK(CumBeforeA), 0, CumBeforeA)
    )

VAR ThresholdAmountA =
    MINX(FILTER(BoundaryChunkA_WithCum, [@CumShareExact] <= BoundaryA), [@Amount])

// ─── ШАГ 5-6. Граница класса B (симметрично) ───
VAR RankBeforeB      = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@MaxRank])
VAR MinAmountAfterB  = MAXX(FILTER(ChunkCumulatives, [@CumShare] >  BoundaryB), [@MinAmount])
VAR CumBeforeB       = MAXX(FILTER(ChunkCumulatives, [@CumShare] <= BoundaryB), [@CumShare])

VAR BoundaryChunkB =
    FILTER(RankedProducts, [@Rank] > RankBeforeB && [@Amount] >= MinAmountAfterB)

VAR BoundaryChunkB_WithCum =
    ADDCOLUMNS(
        BoundaryChunkB,
        "@CumShareExact",
            VAR CurrentRank = [@Rank]
            RETURN
                SUMX(FILTER(BoundaryChunkB, [@Rank] <= CurrentRank), [@Share])
                    + IF(ISBLANK(CumBeforeB), 0, CumBeforeB)
    )

VAR ThresholdAmountB =
    MINX(FILTER(BoundaryChunkB_WithCum, [@CumShareExact] <= BoundaryB), [@Amount])

// ─── ШАГ 7. Классификация и финальный фильтр ───
VAR ProductsInA =
    UNION(
        SELECTCOLUMNS(FILTER(BoundaryChunkA_WithCum, [@Amount] >= ThresholdAmountA), "ID", [ID]),
        SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeA), "ID", [ID])
    )

VAR ProductsInAB =
    UNION(
        SELECTCOLUMNS(FILTER(BoundaryChunkB_WithCum, [@Amount] >= ThresholdAmountB), "ID", [ID]),
        SELECTCOLUMNS(FILTER(RankedProducts, [@Rank] <= RankBeforeB), "ID", [ID])
    )

VAR ProductsInB = EXCEPT(ProductsInAB, ProductsInA)
VAR ProductsInC = EXCEPT(SELECTCOLUMNS(RankedProducts, "ID", [ID]), ProductsInAB)

VAR ProductsInCurrentClass =
    FILTER(
        UNION(
            ADDCOLUMNS(ProductsInA, "@Class", "A", "@Value", BoundaryA),
            ADDCOLUMNS(ProductsInB, "@Class", "B", "@Value", BoundaryB),
            ADDCOLUMNS(ProductsInC, "@Class", "C", "@Value", BoundaryC)
        ),
        [@Value] > [MinBoundary] && [@Value] <= [MaxBoundary]
    )

RETURN
    CALCULATE([Продажи руб], KEEPFILTERS(ProductsInCurrentClass))
Вспомогательные меры — нужны обеим
MaxBoundary = MAX('Сегментация ABC'[Верхняя граница])

MinBoundary = MIN('Сегментация ABC'[Нижняя граница])

Продажи руб = SUM('Продажи'[Сумма])

# SKU = DISTINCTCOUNT('Продажи'[Номенклатура ID])
Таблица-справочник «Сегментация ABC» (создать через Modeling → New table)
Сегментация ABC =
    DATATABLE(
        "Классификация", STRING,
        "Сегмент",       STRING,
        "Нижняя граница", DOUBLE,
        "Верхняя граница", DOUBLE,
        {
            { "ABC", "A", 0.00, 0.80 },
            { "ABC", "B", 0.80, 0.95 },
            { "ABC", "C", 0.95, 1.00 }
        }
    )

Порядок действий для внедрения:

  1. Создайте таблицу Сегментация ABC через Modeling → New table (вставьте DAX из четвёртого блока выше).
  2. Добавьте вспомогательные меры MaxBoundary, MinBoundary, Продажи руб, # SKU — если их ещё нет.
  3. Скопируйте chunking-меру в свою модель, замените имена таблиц (Продажи, Номенклатура, 000 Календарь) на свои.
  4. В DAX Studio (File → Connect → Power BI Desktop) включите Server Timings (Ctrl+F4), Clear Cache Then Run — получите свои замеры.
  5. Прогоните grid-search по ChunkSize (500 / 1000 / 1500 / 2000 / 3000), найдите оптимум под ваше распределение выручки.

Что дальше

На типовом каталоге chunking даёт 14-17× ускорения. Цифра выглядит скромно на фоне новостей «ускорили в 1000 раз», но на 100 тыс. SKU это разница между «открывается за секунду» и «отчёт не открывается вообще». А это уже принципиально.

И это не предел. У меня есть ещё один подход к той же задаче — на моих замерах он обгоняет chunking. Расскажу в следующей статье серии. Без спойлеров, просто держите в голове: 14× — ещё не финал.

А пока:

  • Если ABC-отчёт в вашей модели тормозит — перенесите refactored-версию к себе, подгоните имена таблиц/мер и прогоните 5-6 значений ChunkSize в DAX Studio с Server Timings. Почти гарантированный 5-20× speedup.
  • Если нужна помощь с переносом — 30-минутный звонок. Принесите свою модель и текущую формулу — за встречу соберём работающий рефакторинг.
  • Более тяжёлые DAX-кейсы — BI-аудит за 5 дней включает оптимизацию самых долгих мер в отчёте, с замерами до/после и планом переработки.

Связанные материалы:

§ Аудит · 5 дней

Ваш ABC-отчёт
открывается минуту?

Принесите модель — за 5 рабочих дней дадим полный план оптимизации с замерами до/после, refactoring готовых мер, grid-search по ключевым параметрам.

Телефон+7 918 042 34 43