Émuler la fonction scalaire définie par l'utilisateur d'une manière qui n'empêche pas le parallélisme

12

J'essaie de voir s'il existe un moyen de tromper SQL Server pour utiliser un certain plan pour la requête.

1. Environnement

Imaginez que vous ayez des données qui sont partagées entre différents processus. Supposons donc que nous ayons des résultats d'expérience qui prennent beaucoup de place. Ensuite, pour chaque processus, nous savons quelle année / mois de résultat d'expérience nous voulons utiliser.

if object_id('dbo.SharedData') is not null
    drop table SharedData

create table dbo.SharedData (
    experiment_year int,
    experiment_month int,
    rn int,
    calculated_number int,
    primary key (experiment_year, experiment_month, rn)
)
go

Maintenant, pour chaque processus, nous avons des paramètres enregistrés dans le tableau

if object_id('dbo.Params') is not null
    drop table dbo.Params

create table dbo.Params (
    session_id int,
    experiment_year int,
    experiment_month int,
    primary key (session_id)
)
go

2. Données d'essai

Ajoutons quelques données de test:

insert into dbo.Params (session_id, experiment_year, experiment_month)
select 1, 2014, 3 union all
select 2, 2014, 4 
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 3, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 4, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

3. Récupération des résultats

Maintenant, il est très facile d'obtenir les résultats de l'expérience en @experiment_year/@experiment_month:

create or alter function dbo.f_GetSharedData(@experiment_year int, @experiment_month int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.SharedData as d
    where
        d.experiment_year = @experiment_year and
        d.experiment_month = @experiment_month
)
go

Le plan est sympa et parallèle:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number

plan de requête 0

entrez la description de l'image ici

4. Problème

Mais, pour rendre l'utilisation des données un peu plus générique, je veux avoir une autre fonction - dbo.f_GetSharedDataBySession(@session_id int). Donc, la manière la plus simple serait de créer des fonctions scalaires, en traduisant @session_id-> @experiment_year/@experiment_month:

create or alter function dbo.fn_GetExperimentYear(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_year
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

create or alter function dbo.fn_GetExperimentMonth(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_month
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

Et maintenant, nous pouvons créer notre fonction:

create or alter function dbo.f_GetSharedDataBySession1(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
        dbo.fn_GetExperimentYear(@session_id),
        dbo.fn_GetExperimentMonth(@session_id)
    ) as d
)
go

plan de requête 1

entrez la description de l'image ici

Le plan est le même, sauf qu'il n'est bien sûr pas parallèle, car les fonctions scalaires assurant l'accès aux données rendent l'ensemble du plan série .

J'ai donc essayé plusieurs approches différentes, comme utiliser des sous-requêtes au lieu de fonctions scalaires:

create or alter function dbo.f_GetSharedDataBySession2(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
       (select p.experiment_year from dbo.Params as p where p.session_id = @session_id),
       (select p.experiment_month from dbo.Params as p where p.session_id = @session_id)
    ) as d
)
go

plan de requête 2

entrez la description de l'image ici

Ou en utilisant cross apply

create or alter function dbo.f_GetSharedDataBySession3(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.Params as p
        cross apply dbo.f_GetSharedData(
            p.experiment_year,
            p.experiment_month
        ) as d
    where
        p.session_id = @session_id
)
go

plan de requête 3

entrez la description de l'image ici

Mais je ne peux pas trouver un moyen d'écrire cette requête aussi bonne que celle qui utilise les fonctions scalaires.

Quelques pensées:

  1. Fondamentalement, ce que je voudrais, c'est pouvoir en quelque sorte dire à SQL Server de précalculer certaines valeurs, puis de les transmettre sous forme de constantes.
  2. Ce qui pourrait être utile, c'est si nous avions un indice de matérialisation intermédiaire . J'ai vérifié quelques variantes (TVF multi-déclarations ou cte avec top), mais aucun plan n'est aussi bon que celui avec des fonctions scalaires jusqu'à présent
  3. Je suis au courant de l'amélioration à venir de SQL Server 2017 - Froid: optimisation des programmes impératifs dans une base de données relationnelle. Je ne suis pas sûr que cela aidera, cependant. Cela aurait été bien de se tromper ici, cependant.

Information additionnelle

J'utilise une fonction (plutôt que de sélectionner des données directement dans les tables) car elle est beaucoup plus facile à utiliser dans de nombreuses requêtes différentes, qui ont généralement @session_idcomme paramètre.

On m'a demandé de comparer les temps d'exécution réels. Dans ce cas particulier

  • la requête 0 s'exécute pendant environ 500 ms
  • la requête 1 s'exécute pendant ~ 1500 ms
  • la requête 2 s'exécute pendant ~ 1500 ms
  • la requête 3 s'exécute pendant environ 2000 ms.

Le plan n ° 2 a un balayage d'index au lieu d'une recherche, qui est ensuite filtré par les prédicats sur les boucles imbriquées. Le plan n ° 3 n'est pas si mal, mais fait encore plus de travail et fonctionne plus lentement que le plan n ° 0.

Supposons que cela dbo.Paramssoit rarement modifié, et comportent généralement environ 1 à 200 lignes, pas plus que, disons, 2000 est attendu. C'est environ 10 colonnes maintenant et je ne m'attends pas à ajouter trop de colonnes.

Le nombre de lignes dans Params n'est pas fixe, donc pour chaque @session_idil y aura une ligne. Le nombre de colonnes n'est pas fixe, c'est l'une des raisons pour lesquelles je ne veux pas appeler dbo.f_GetSharedData(@experiment_year int, @experiment_month int)de partout, donc je peux ajouter une nouvelle colonne à cette requête en interne. Je serais heureux d'entendre des opinions / suggestions à ce sujet, même si cela comporte certaines restrictions.

Roman Pekar
la source
Le plan de requête avec Froid serait similaire à celui de la requête 2 ci-dessus, donc oui, il ne vous mènera pas à la solution que vous souhaitez atteindre dans ce cas.
Karthik

Réponses:

13

Vous ne pouvez pas vraiment atteindre en toute sécurité exactement ce que vous voulez dans SQL Server aujourd'hui, c'est-à-dire dans une seule instruction et avec une exécution parallèle, dans les limites définies dans la question (comme je les perçois).

Donc ma réponse simple est non . Le reste de cette réponse est principalement une discussion de la raison pour laquelle cela est, au cas où cela serait intéressant.

Il est possible d'obtenir un plan parallèle, comme indiqué dans la question, mais il existe deux variétés principales, dont aucune ne convient à vos besoins:

  1. Une jointure de boucles imbriquées corrélées, avec un round-robin répartit les flux au niveau supérieur. Étant donné qu'une seule ligne est garantie pour provenir d' Paramsune session_idvaleur spécifique , la face intérieure fonctionnera sur un seul thread, même si elle est marquée avec l'icône de parallélisme. C'est pourquoi le plan apparemment parallèle 3 ne fonctionne pas aussi bien; il s'agit en fait d'un feuilleton.

  2. L'autre alternative est pour le parallélisme indépendant du côté intérieur de la jointure des boucles imbriquées. Indépendant ici signifie que les threads sont démarrés du côté interne, et pas simplement les mêmes threads que ceux exécutant le côté externe de la jointure des boucles imbriquées. SQL Server prend uniquement en charge le parallélisme de boucles imbriquées côté intérieur indépendant lorsqu'il est garanti qu'il y a une ligne côté extérieur et qu'il n'y a pas de paramètres de jointure corrélés ( plan 2 ).

Ainsi, nous avons le choix d'un plan parallèle qui est en série (en raison d'un thread) avec les valeurs corrélées souhaitées; ou un plan parallèle intérieur qui doit balayer car il n'a pas de paramètres à rechercher. (À part: Il devrait vraiment être autorisé à conduire le parallélisme interne en utilisant exactement un ensemble de paramètres corrélés, mais il n'a jamais été implémenté, probablement pour une bonne raison).

Une question naturelle est alors: pourquoi avons-nous besoin de paramètres corrélés? Pourquoi SQL Server ne peut-il pas simplement rechercher directement les valeurs scalaires fournies par exemple par une sous-requête?

Eh bien, SQL Server ne peut «rechercher d'index» qu'en utilisant des références scalaires simples, par exemple une constante, une variable, une colonne ou une référence d'expression (de sorte qu'un résultat de fonction scalaire peut également être qualifié). Une sous-requête (ou une autre construction similaire) est tout simplement trop complexe (et potentiellement dangereuse) pour être poussée dans le moteur de stockage dans son ensemble. Par conséquent, des opérateurs de plan de requête distincts sont requis. C'est à son tour nécessite une corrélation, ce qui signifie pas de parallélisme du type que vous souhaitez.

Dans l'ensemble, il n'y a vraiment pas de meilleure solution actuellement que des méthodes telles que l'attribution des valeurs de recherche aux variables, puis l'utilisation de celles des paramètres de fonction dans une instruction distincte.

Maintenant, vous pouvez avoir des considérations locales spécifiques qui signifient que la mise en cache des valeurs actuelles de l'année et du mois en SESSION_CONTEXTvaut la peine, c'est-à-dire:

SELECT FGSD.calculated_number, COUNT_BIG(*)
FROM dbo.f_GetSharedData
(
    CONVERT(integer, SESSION_CONTEXT(N'experiment_year')), 
    CONVERT(integer, SESSION_CONTEXT(N'experiment_month'))
) AS FGSD
GROUP BY FGSD.calculated_number;

Mais cela tombe dans la catégorie des solutions de contournement.

D'un autre côté, si les performances d'agrégation sont primordiales, vous pouvez envisager de vous en tenir aux fonctions en ligne et de créer un index columnstore (principal ou secondaire) sur la table. Vous pouvez constater que les avantages du stockage columnstore, du traitement en mode batch et du pushdown agrégé offrent de plus grands avantages qu'une recherche parallèle en mode ligne de toute façon.

Mais méfiez-vous des fonctions scalaires T-SQL, en particulier avec le stockage columnstore, car il est facile de se retrouver avec la fonction évaluée par ligne dans un filtre en mode ligne séparé. Il est généralement assez difficile de garantir le nombre de fois que SQL Server choisira d'évaluer les scalaires, et mieux de ne pas essayer.

Paul White 9
la source
Merci, Paul, excellente réponse! J'ai pensé à utiliser session_contextmais je décide que c'est une idée un peu trop folle pour moi et je ne sais pas comment cela va s'adapter à mon architecture actuelle. Ce qui serait utile cependant, c'est peut-être un indice que je pourrais utiliser pour faire savoir à l'optimiseur qu'il devrait traiter le résultat de la sous-requête comme une simple référence scalaire.
Roman Pekar
8

Autant que je sache, la forme de plan que vous voulez n'est pas possible avec seulement T-SQL. Il semble que vous souhaitiez que la forme du plan d'origine (plan de requête 0) avec les sous-requêtes de vos fonctions soit appliquées directement en tant que filtres par rapport à l'analyse d'index clusterisé. Vous n'obtiendrez jamais un plan de requête comme celui-ci si vous n'utilisez pas de variables locales pour conserver les valeurs de retour des fonctions scalaires. Le filtrage sera à la place implémenté comme jointure de boucle imbriquée. Il existe trois façons différentes (du point de vue du parallélisme) que la jointure de boucle peut être implémentée:

  1. L'ensemble du plan est en série. Ce n'est pas acceptable pour vous. C'est le plan que vous obtenez pour la requête 1.
  2. La jointure de boucle s'exécute en série. Je crois que dans ce cas, le côté intérieur peut fonctionner en parallèle, mais il n'est pas possible de lui transmettre des prédicats. Donc, la plupart du travail se fera en parallèle, mais vous analysez la table entière et l'agrégat partiel est beaucoup plus cher qu'auparavant. C'est le plan que vous obtenez pour la requête 2.
  3. La jointure de boucle s'exécute en parallèle. Avec une boucle imbriquée parallèle, le côté intérieur de la boucle s'exécute en série, mais vous pouvez avoir jusqu'à un nombre de threads DOP s'exécutant du côté intérieur à la fois. Votre jeu de résultats externe n'aura qu'une seule ligne, de sorte que votre plan parallèle sera effectivement série. C'est le plan que vous obtenez pour la requête 3.

Ce sont les seules formes de plan possibles que je connaisse. Vous pouvez en obtenir d'autres si vous utilisez une table temporaire, mais aucune d'entre elles ne résout votre problème fondamental si vous souhaitez que les performances des requêtes soient aussi bonnes que pour la requête 0.

Vous pouvez obtenir des performances de requête équivalentes en utilisant les FDU scalaires pour affecter des valeurs de retour aux variables locales et en utilisant ces variables locales dans votre requête. Vous pouvez encapsuler ce code dans une procédure stockée ou une UDF à instructions multiples pour éviter les problèmes de maintenabilité. Par exemple:

DECLARE @experiment_year int = dbo.fn_GetExperimentYear(@session_id);
DECLARE @experiment_month int = dbo.fn_GetExperimentMonth(@session_id);

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(@experiment_year, @experiment_month)
group by
    calculated_number;

Les FDU scalaires ont été déplacés en dehors de la requête pour laquelle vous souhaitez être éligible au parallélisme. Le plan de requête que j'obtiens semble être celui que vous souhaitez:

plan de requête parallèle

Les deux approches présentent des inconvénients si vous devez utiliser cet ensemble de résultats dans d'autres requêtes. Vous ne pouvez pas rejoindre directement une procédure stockée. Vous devez enregistrer les résultats dans une table temporaire qui a son propre ensemble de problèmes. Vous pouvez rejoindre un MS-TVF, mais dans SQL Server 2016, vous pouvez voir des problèmes d'estimation de cardinalité. SQL Server 2017 offre une exécution entrelacée pour MS-TVF qui pourrait résoudre entièrement le problème.

Juste pour clarifier quelques points: les FDU scalaires T-SQL interdisent toujours le parallélisme et Microsoft n'a pas dit que FROID sera disponible dans SQL Server 2017.

Joe Obbish
la source
concernant Froid dans SQL 2017 - je ne sais pas pourquoi je pensais qu'il était là. Il est confirmé qu'il se trouve dans vNext - brentozar.com/archive/2018/01/…
Roman Pekar
4

Cela peut très probablement être fait en utilisant SQLCLR. L'un des avantages des FDU scalaires SQLCLR est qu'ils n'empêchent pas le parallélisme s'ils n'accèdent pas aux données (et doivent parfois également être marqués comme «déterministes»). Alors, comment utilisez-vous quelque chose qui ne nécessite aucun accès aux données lorsque l'opération elle-même nécessite un accès aux données?

Eh bien, parce que le dbo.Paramstableau devrait:

  1. ne contient généralement pas plus de 2000 lignes,
  2. changent rarement de structure,
  3. seulement (actuellement) doit avoir deux INTcolonnes

il est possible de mettre en cache les trois colonnes - session_id, experiment_year int, experiment_month- dans une collection statique (par exemple un dictionnaire, peut-être) qui est remplie hors processus et lue par les FDU Scalar qui obtiennent les valeurs experiment_year intet experiment_month. Ce que j'entends par «hors processus» est: vous pouvez avoir une UDF scalaire SQLCLR complètement distincte ou une procédure stockée qui peut accéder aux données et lire à partir de la dbo.Paramstable pour remplir la collection statique. Cette UDF ou procédure stockée serait exécutée avant d'utiliser les UDF qui obtiennent les valeurs "année" et "mois", de cette façon les UDF qui obtiennent les valeurs "année" et "mois" n'accèdent pas aux données de base de données.

L'UDF ou la procédure stockée qui lit les données peut d'abord vérifier si la collection a 0 entrées et si oui, remplir, sinon ignorer. Vous pouvez même garder une trace de l'heure à laquelle il a été rempli et s'il a dépassé X minutes (ou quelque chose comme ça), puis effacer et re-remplir même s'il y a des entrées dans la collection. Mais sauter la population sera utile car elle devra être exécutée fréquemment pour s'assurer qu'elle est toujours remplie pour que les deux principaux FDU obtiennent les valeurs.

La principale préoccupation est lorsque SQL Server décide de décharger le domaine d'application pour une raison quelconque (ou s'il est déclenché par quelque chose qui utilise DBCC FREESYSTEMCACHE('ALL');). Vous ne voulez pas risquer que la collecte soit effacée entre l'exécution de l'UDF "populate" ou de la procédure stockée et des UDF pour obtenir les valeurs "year" et "month". Dans ce cas, vous pouvez avoir une vérification au tout début de ces deux UDF pour lever une exception si la collection est vide, car il vaut mieux faire une erreur que de fournir avec succès de faux résultats.

Bien entendu, la préoccupation susmentionnée suppose que le souhait est de faire marquer l'Assemblée par SAFE. Si l'assembly peut être marqué comme EXTERNAL_ACCESS, il est possible qu'un constructeur statique exécute la méthode qui lit les données et remplit la collection, de sorte que vous n'ayez besoin de l'exécuter manuellement que pour actualiser les lignes, mais elles seront toujours renseignées (car le constructeur de classe statique s'exécute toujours lorsque la classe est chargée, ce qui se produit chaque fois qu'une méthode de cette classe est exécutée après un redémarrage ou que le domaine d'application est déchargé). Cela nécessite l'utilisation d'une connexion régulière et non la connexion de contexte en cours (qui n'est pas disponible pour les constructeurs statiques, d'où la nécessité EXTERNAL_ACCESS).

Remarque: pour ne pas être tenu de marquer l'assembly comme UNSAFE, vous devez marquer toutes les variables de classe statiques comme readonly. Cela signifie, à tout le moins, la collection. Ce n'est pas un problème car les collections en lecture seule peuvent avoir des éléments ajoutés ou supprimés, elles ne peuvent tout simplement pas être initialisées en dehors du constructeur ou de la charge initiale. Le suivi de l'heure à laquelle la collection a été chargée dans le but de l'expiration après X minutes est plus délicat car une static readonly DateTimevariable de classe ne peut pas être modifiée en dehors du constructeur ou de la charge initiale. Pour contourner cette restriction, vous devez utiliser une collection statique en lecture seule qui contient un seul élément qui est la DateTimevaleur afin qu'il puisse être supprimé et rajouté lors d'une actualisation.

Solomon Rutzky
la source
Je ne sais pas pourquoi quelqu'un a voté pour cela. Bien qu'il ne soit pas très générique, je pense qu'il pourrait être applicable dans mon cas actuel. Je préférerais avoir une solution SQL pure, mais je vais certainement y regarder de plus près et essayer de voir si cela fonctionne
Roman Pekar
@RomanPekar Pas sûr, mais il y a beaucoup de gens qui sont anti-SQLCLR. Et peut-être quelques-uns qui sont anti-moi ;-). Quoi qu'il en soit, je ne vois pas pourquoi cette solution ne fonctionnerait pas. Je comprends la préférence pour T-SQL pur, mais je ne sais pas comment y arriver, et s'il n'y a pas de réponse concurrente, alors peut-être que personne d'autre ne le fait non plus. Je ne sais pas si les tables optimisées en mémoire et les UDF compilés nativement feraient mieux ici. De plus, je viens d'ajouter un paragraphe avec quelques notes de mise en œuvre à garder à l'esprit.
Solomon Rutzky
1
Je n'ai jamais été entièrement convaincu que l'utilisation readonly staticsest sûre ou sage dans le SQLCLR. Je suis encore moins convaincu de continuer à tromper le système en en faisant readonlyun type de référence, que vous allez ensuite changer . Me donne les volontés absolues tbh.
Paul White 9
@PaulWhite Compris, et je me souviens de cela, il y a des années, lors d'une conversation privée. Étant donné la nature partagée des domaines d'application (et donc des staticobjets) dans SQL Server, oui, il existe un risque de conditions de concurrence. C'est pourquoi j'ai d'abord déterminé à partir de l'OP que ces données sont minimes et stables, et pourquoi j'ai qualifié cette approche comme nécessitant "rarement de changement", et donné un moyen de rafraîchir en cas de besoin. Dans ce cas d'utilisation, je ne vois pas grand-chose, voire aucun risque. Il y a quelques années, j'ai trouvé un article sur la possibilité de mettre à jour les collections en lecture seule comme étant de conception (en C #, pas de discussion sur SQLCLR). Va essayer de le trouver.
Solomon Rutzky
2
Pas besoin, il n'y a aucun moyen que vous me mettiez à l'aise avec cela en dehors de la documentation officielle de SQL Server disant que ça va, ce qui, j'en suis sûr, n'existe pas.
Paul White 9