Pourquoi la création d'un groupe de lignes CCI simple peut prendre jusqu'à 30 secondes?

20

Je travaillais sur une démonstration impliquant des CCI lorsque j'ai remarqué que certains de mes encarts prenaient plus de temps que prévu. Définitions des tableaux à reproduire:

DROP TABLE IF EXISTS dbo.STG_1048576;
CREATE TABLE dbo.STG_1048576 (ID BIGINT NOT NULL);
INSERT INTO dbo.STG_1048576
SELECT TOP (1048576) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) RN
FROM master..spt_values t1
CROSS JOIN master..spt_values t2;

DROP TABLE IF EXISTS dbo.CCI_BIGINT;
CREATE TABLE dbo.CCI_BIGINT (ID BIGINT NOT NULL, INDEX CCI CLUSTERED COLUMNSTORE);

Pour les tests, j'insère toutes les 1048576 lignes de la table de transfert. Cela suffit pour remplir exactement un groupe de lignes compressé tant qu'il n'est pas coupé pour une raison quelconque.

Si j'insère tous les entiers mod 17000, cela prend moins d'une seconde:

TRUNCATE TABLE dbo.CCI_BIGINT;

INSERT INTO dbo.CCI_BIGINT WITH (TABLOCK)
SELECT ID % 17000
FROM dbo.STG_1048576
OPTION (MAXDOP 1);

Temps d'exécution SQL Server: temps CPU = 359 ms, temps écoulé = 364 ms.

Cependant, si j'insère les mêmes nombres entiers mod 16000 cela prend parfois plus de 30 secondes:

TRUNCATE TABLE dbo.CCI_BIGINT;

INSERT INTO dbo.CCI_BIGINT WITH (TABLOCK)
SELECT ID % 16000
FROM dbo.STG_1048576
OPTION (MAXDOP 1);

Temps d'exécution SQL Server: temps CPU = 32062 ms, temps écoulé = 32511 ms.

Il s'agit d'un test reproductible qui a été effectué sur plusieurs machines. Il semble y avoir une tendance claire dans le temps écoulé lorsque la valeur du mod change:

MOD_NUM TIME_IN_MS
1000    2036
2000    3857
3000    5463
4000    6930
5000    8414
6000    10270
7000    12350
8000    13936
9000    17470
10000   19946
11000   21373
12000   24950
13000   28677
14000   31030
15000   34040
16000   37000
17000   563
18000   583
19000   576
20000   584

Si vous souhaitez exécuter des tests vous-même, n'hésitez pas à modifier le code de test que j'ai écrit ici .

Je n'ai rien trouvé d'intéressant dans sys.dm_os_wait_stats pour l'insertion du mod 16000:

╔════════════════════════════════════╦══════════════╗
             wait_type               diff_wait_ms 
╠════════════════════════════════════╬══════════════╣
 XE_DISPATCHER_WAIT                        164406 
 QDS_PERSIST_TASK_MAIN_LOOP_SLEEP          120002 
 LAZYWRITER_SLEEP                           97718 
 LOGMGR_QUEUE                               97298 
 DIRTY_PAGE_POLL                            97254 
 HADR_FILESTREAM_IOMGR_IOCOMPLETION         97111 
 SQLTRACE_INCREMENTAL_FLUSH_SLEEP           96008 
 REQUEST_FOR_DEADLOCK_SEARCH                95001 
 XE_TIMER_EVENT                             94689 
 SLEEP_TASK                                 48308 
 BROKER_TO_FLUSH                            48264 
 CHECKPOINT_QUEUE                           35589 
 SOS_SCHEDULER_YIELD                           13 
╚════════════════════════════════════╩══════════════╝

Pourquoi l'insert ID % 16000prend-il autant de temps que l'insert ID % 17000?

Joe Obbish
la source

Réponses:

12

À bien des égards, il s'agit d'un comportement attendu. Tout ensemble de routines de compression aura des performances très variées en fonction de la distribution des données d'entrée. Nous nous attendons à échanger la vitesse de chargement des données contre la taille du stockage et les performances de requête d'exécution.

Il y a une limite précise au niveau de détail d'une réponse que vous allez obtenir ici, car VertiPaq est une implémentation propriétaire, et les détails sont un secret bien gardé. Même ainsi, nous savons que VertiPaq contient des routines pour:

  • Encodage de valeurs (mise à l'échelle et / ou conversion de valeurs pour tenir dans un petit nombre de bits)
  • Encodage du dictionnaire (références entières à des valeurs uniques)
  • Run Length Encoding (stockage de séries de valeurs répétées sous forme de paires [valeur, nombre])
  • Compression de bits (stockage du flux dans le moins de bits possible)

En règle générale, les données seront codées par valeur ou par dictionnaire, puis RLE ou compression de bits sera appliqué (ou un hybride de RLE et de compression de bits utilisé dans différentes sous-sections des données de segment). Le processus de décision des techniques à appliquer peut impliquer la génération d'un histogramme pour aider à déterminer comment réaliser des économies de bits maximales.

En capturant le cas lent avec Windows Performance Recorder et en analysant le résultat avec Windows Performance Analyzer, nous pouvons voir que la grande majorité du temps d'exécution est consommée en examinant le clustering des données, en créant des histogrammes et en décidant comment les partitionner au mieux des économies:

Analyse WPA

Le traitement le plus coûteux se produit pour les valeurs qui apparaissent au moins 64 fois dans le segment. Il s'agit d'une heuristique pour déterminer quand le RLE pur est susceptible d'être bénéfique. Les cas les plus rapides entraînent un stockage impur , par exemple une représentation compacte, avec une plus grande taille de stockage final. Dans les cas hybrides, les valeurs avec 64 répétitions ou plus sont codées RLE et les autres sont compressées en bits.

La durée la plus longue se produit lorsque le nombre maximum de valeurs distinctes avec 64 répétitions apparaît dans le segment le plus grand possible, c'est-à-dire 1 048 576 lignes avec 16 384 ensembles de valeurs avec 64 entrées chacune. L'inspection du code révèle une limite de temps codée en dur pour le traitement coûteux. Cela peut être configuré dans d'autres implémentations VertiPaq, par exemple SSAS, mais pas dans SQL Server pour autant que je sache.

Des informations sur la configuration de stockage finale peuvent être obtenues à l'aide de la commande non documentéeDBCC CSINDEX . Cela montre les entrées d'en-tête et de tableau RLE, tous les signets dans les données RLE et un bref résumé des données du pack de bits (le cas échéant).

Pour plus d'informations, voir:

Paul White dit GoFundMonica
la source
9

Je ne peux pas dire exactement pourquoi ce problème se produit, mais je pense avoir développé un bon modèle de comportement via des tests de force brute. Les conclusions suivantes s'appliquent uniquement lors du chargement de données dans une seule colonne et avec des entiers très bien distribués.

J'ai d'abord essayé de faire varier le nombre de lignes insérées dans le CCI à l'aide de TOP. J'ai utilisé ID % 16000pour tous les tests. Voici un graphique comparant les lignes insérées à la taille du segment de groupe de lignes compressé:

graphique du haut par rapport à la taille

Voici un graphique des lignes insérées dans le temps CPU en ms. Notez que l'axe X a un point de départ différent:

top vs cpu

Nous pouvons voir que la taille du segment de groupe de lignes augmente à un rythme linéaire et utilise une petite quantité de CPU jusqu'à environ 1 M de lignes. À ce stade, la taille du groupe de lignes diminue considérablement et l'utilisation du processeur augmente considérablement. Il semblerait que nous payons un prix élevé en CPU pour cette compression.

Lors de l'insertion de moins de 1024000 lignes, je me suis retrouvé avec un groupe de lignes ouvert dans le CCI. Cependant, forcer la compression en utilisant REORGANIZEou REBUILDn'a pas eu d'effet sur la taille. En passant, j'ai trouvé intéressant que lorsque j'utilisais une variable pour TOPje me suis retrouvé avec un groupe de lignes ouvert mais avec RECOMPILEje me suis retrouvé avec un groupe de lignes fermé.

Ensuite, j'ai testé en faisant varier la valeur du module tout en gardant le même nombre de lignes. Voici un exemple des données lors de l'insertion de 102400 lignes:

╔═══════════╦═════════╦═══════════════╦═════════════╗
 TOP_VALUE  MOD_NUM  SIZE_IN_BYTES  CPU_TIME_MS 
╠═══════════╬═════════╬═══════════════╬═════════════╣
    102400     1580          13504          352 
    102400     1590          13584          316 
    102400     1600          13664          317 
    102400     1601          19624          270 
    102400     1602          25568          283 
    102400     1603          31520          286 
    102400     1604          37464          288 
    102400     1605          43408          273 
    102400     1606          49360          269 
    102400     1607          55304          265 
    102400     1608          61256          262 
    102400     1609          67200          255 
    102400     1610          73144          265 
    102400     1620         132616          132 
    102400     1621         138568          100 
    102400     1622         144512           91 
    102400     1623         150464           75 
    102400     1624         156408           60 
    102400     1625         162352           47 
    102400     1626         164712           41 
╚═══════════╩═════════╩═══════════════╩═════════════╝

Jusqu'à une valeur de mod de 1600, la taille du segment de groupe de lignes augmente linéairement de 80 octets pour 10 valeurs uniques supplémentaires. C'est une coïncidence intéressante qu'un a BIGINTtraditionnellement occupe 8 octets et la taille du segment augmente de 8 octets pour chaque valeur unique supplémentaire. Au-delà d'une valeur de mod de 1600, la taille du segment augmente rapidement jusqu'à ce qu'elle se stabilise.

Il est également utile de regarder les données lorsque vous laissez la même valeur de module et changez le nombre de lignes insérées:

╔═══════════╦═════════╦═══════════════╦═════════════╗
 TOP_VALUE  MOD_NUM  SIZE_IN_BYTES  CPU_TIME_MS 
╠═══════════╬═════════╬═══════════════╬═════════════╣
    300000     5000         600656          131 
    305000     5000         610664          124 
    310000     5000         620672          127 
    315000     5000         630680          132 
    320000     5000          40688         2344 
    325000     5000          40696         2577 
    330000     5000          40704         2589 
    335000     5000          40712         2673 
    340000     5000          40728         2715 
    345000     5000          40736         2744 
    350000     5000          40744         2157 
╚═══════════╩═════════╩═══════════════╩═════════════╝

Il semble que lorsque le nombre de lignes insérées <~ 64 * le nombre de valeurs uniques, nous voyons une compression relativement médiocre (2 octets par ligne pour le mod <= 65000) et une faible utilisation du processeur linéaire. Lorsque le nombre de lignes insérées> ~ 64 * le nombre de valeurs uniques, nous constatons une compression bien meilleure et une utilisation du processeur encore plus linéaire. Il y a une transition entre les deux états qui n'est pas facile pour moi de modéliser mais cela peut être vu dans le graphique. Il ne semble pas vrai que nous voyons l'utilisation maximale du processeur lorsque nous insérons exactement 64 lignes pour chaque valeur unique. Au contraire, nous ne pouvons insérer qu'un maximum de 1048576 lignes dans un groupe de lignes et nous constatons une utilisation et une compression du processeur beaucoup plus élevées une fois qu'il y a plus de 64 lignes par valeur unique.

Vous trouverez ci-dessous un tracé de contour de la façon dont le temps processeur change en fonction du nombre de lignes insérées et du nombre de lignes uniques. Nous pouvons voir les modèles décrits ci-dessus:

contour cpu

Vous trouverez ci-dessous un tracé de contour de l'espace utilisé par le segment. Après un certain point, nous commençons à voir une compression bien meilleure, comme décrit ci-dessus:

taille du contour

Il semble qu'il y ait au moins deux algorithmes de compression différents à l'œuvre ici. Compte tenu de ce qui précède, il est logique que nous voyions l'utilisation maximale du processeur lors de l'insertion de 1048576 lignes. Il est également logique que nous constations la plus grande utilisation du processeur à ce stade lors de l'insertion d'environ 16 000 lignes. 1048576/64 = 16384.

J'ai téléchargé toutes mes données brutes ici au cas où quelqu'un voudrait les analyser.

Il convient de mentionner ce qui se passe avec les plans parallèles. Je n'ai observé ce comportement qu'avec des valeurs uniformément réparties. Lors d'une insertion parallèle, il y a souvent un élément aléatoire et les threads sont généralement déséquilibrés.

Mettez 2097152 lignes dans la table intermédiaire:

DROP TABLE IF EXISTS STG_2097152;
CREATE TABLE dbo.STG_2097152 (ID BIGINT NOT NULL);
INSERT INTO dbo.STG_2097152 WITH (TABLOCK)
SELECT TOP (2097152) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) RN
FROM master..spt_values t1
CROSS JOIN master..spt_values t2;

Cet insert se termine en moins d'une seconde et a une mauvaise compression:

DROP TABLE IF EXISTS dbo.CCI_BIGINT;
CREATE TABLE dbo.CCI_BIGINT (ID BIGINT NOT NULL, INDEX CCI CLUSTERED COLUMNSTORE);

INSERT INTO dbo.CCI_BIGINT WITH (TABLOCK)
SELECT ID % 16000
FROM dbo.STG_2097152 
OPTION (MAXDOP 2);

Nous pouvons voir l'effet des threads déséquilibrés:

╔════════════╦════════════╦══════════════╦═══════════════╗
 state_desc  total_rows  deleted_rows  size_in_bytes 
╠════════════╬════════════╬══════════════╬═══════════════╣
 OPEN             13540             0         311296 
 COMPRESSED     1048576             0        2095872 
 COMPRESSED     1035036             0        2070784 
╚════════════╩════════════╩══════════════╩═══════════════╝

Il existe différentes astuces que nous pouvons faire pour forcer les threads à être équilibrés et à avoir la même distribution de lignes. Voici l'un d'entre eux:

DROP TABLE IF EXISTS dbo.CCI_BIGINT;
CREATE TABLE dbo.CCI_BIGINT (ID BIGINT NOT NULL, INDEX CCI CLUSTERED COLUMNSTORE);

INSERT INTO dbo.CCI_BIGINT WITH (TABLOCK)
SELECT FLOOR(0.5 * ROW_NUMBER() OVER (ORDER BY (SELECT NULL)))  % 15999
FROM dbo.STG_2097152
OPTION (MAXDOP 2)

Le choix d'un nombre impair pour le module est important ici. SQL Server analyse la table de transfert en série, calcule le numéro de ligne, puis utilise la distribution à tour de rôle pour placer les lignes sur des threads parallèles. Cela signifie que nous nous retrouverons avec des threads parfaitement équilibrés.

équilibre 1

L'insert prend environ 40 secondes, ce qui est similaire à l'insert série. Nous obtenons des groupes de lignes bien compressés:

╔════════════╦════════════╦══════════════╦═══════════════╗
 state_desc  total_rows  deleted_rows  size_in_bytes 
╠════════════╬════════════╬══════════════╬═══════════════╣
 COMPRESSED     1048576             0         128568 
 COMPRESSED     1048576             0         128568 
╚════════════╩════════════╩══════════════╩═══════════════╝

Nous pouvons obtenir les mêmes résultats en insérant des données de la table de transfert d'origine:

DROP TABLE IF EXISTS dbo.CCI_BIGINT;
CREATE TABLE dbo.CCI_BIGINT (ID BIGINT NOT NULL, INDEX CCI CLUSTERED COLUMNSTORE);

INSERT INTO dbo.CCI_BIGINT WITH (TABLOCK)
SELECT t.ID % 16000 ID
FROM  (
    SELECT TOP (2) ID 
    FROM (SELECT 1 ID UNION ALL SELECT 2 ) r
) s
CROSS JOIN dbo.STG_1048576 t
OPTION (MAXDOP 2, NO_PERFORMANCE_SPOOL);

Ici, la distribution à tour de rôle est utilisée pour la table dérivée, de ssorte qu'une analyse de la table est effectuée sur chaque thread parallèle:

équilibré 2

En conclusion, lors de l'insertion d'entiers uniformément répartis, vous pouvez voir une compression très élevée lorsque chaque entier unique apparaît plus de 64 fois. Cela peut être dû à un algorithme de compression différent utilisé. Il peut y avoir un coût élevé en CPU pour réaliser cette compression. De petits changements dans les données peuvent entraîner des différences dramatiques dans la taille du segment de groupe de lignes compressé. Je soupçonne que voir le pire des cas (du point de vue du processeur) sera rare dans la nature, au moins pour cet ensemble de données. C'est encore plus difficile à voir lors des insertions parallèles.

Joe Obbish
la source
8

Je crois que cela a à voir avec les optimisations internes de la compression pour les tables à colonne unique, et le nombre magique des 64 Ko occupés par le dictionnaire.

Exemple: si vous exécutez avec MOD 16600 , le résultat final de la taille du groupe de lignes sera 1,683 Mo , tandis que l'exécution de MOD 17000 vous donnera un groupe de lignes avec la taille de 2 001 Mo .

Maintenant, jetez un œil aux dictionnaires créés (vous pouvez utiliser ma bibliothèque CISL pour cela, vous aurez besoin de la fonction cstore_GetDictionaries, ou bien allez interroger sys.column_store_dictionaries DMV):

(MOD 16600) 61 Ko

entrez la description de l'image ici

(MOD 17000) 65 Ko

entrez la description de l'image ici

Chose amusante, si vous ajoutez une autre colonne à votre table, et appelons-la RÉELLE:

DROP TABLE IF EXISTS dbo.CCI_BIGINT;
CREATE TABLE dbo.CCI_BIGINT (ID BIGINT NOT NULL, REALID BIGINT NOT NULL, INDEX CCI CLUSTERED COLUMNSTORE);

Rechargez les données du MOD 16600:

TRUNCATE TABLE dbo.CCI_BIGINT;

INSERT INTO dbo.CCI_BIGINT WITH (TABLOCK)
SELECT ID % 16600, ID
FROM dbo.STG_1048576
OPTION (MAXDOP 1);

Cette fois, l'exécution sera rapide, car l'optimiseur décidera de ne pas surcharger et de le compresser trop loin:

select column_id, segment_id, cast(sum(seg.on_disk_size) / 1024. / 1024 as Decimal(8,3) ) as SizeInMB
    from sys.column_store_segments seg
        inner join sys.partitions part
            on seg.hobt_id = part.hobt_id 
    where object_id = object_id('dbo.CCI_BIGINT')
    group by column_id, segment_id;

Même s'il y aura une petite différence entre les tailles des groupes de lignes, elle sera négligeable (2.000 (MOD 16600) vs 2.001 (MOD 17000))

Pour ce scénario, le dictionnaire pour le MOD 16000 sera plus grand que pour le premier scénario avec 1 colonne (0,63 vs 0,61).

Niko Neugebuer
la source