Emule a função escalar definida pelo usuário de uma maneira que não impeça o paralelismo

12

Estou tentando ver se há uma maneira de enganar o SQL Server para usar um determinado plano para a consulta.

1. Ambiente

Imagine que você tem alguns dados que são compartilhados entre diferentes processos. Então, suponha que tenhamos alguns resultados de experimentos que ocupem muito espaço. Então, para cada processo, sabemos qual ano / mês de resultado do experimento queremos usar.

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

Agora, para cada processo, temos parâmetros salvos na tabela

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. Dados de teste

Vamos adicionar alguns dados de teste:

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. Buscando resultados

Agora, é muito fácil obter resultados da experiência @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

O plano é bom e paralelo:

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

consulta 0 plano

insira a descrição da imagem aqui

4. Problema

Mas, para tornar o uso dos dados um pouco mais genérico, quero ter outra função - dbo.f_GetSharedDataBySession(@session_id int). Portanto, a maneira direta seria criar funções escalares, traduzindo @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

E agora podemos criar nossa função:

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

plano de consulta 1

insira a descrição da imagem aqui

O plano é o mesmo, exceto que, é claro, não é paralelo, porque funções escalares que executam acesso a dados tornam todo o plano serial .

Então, eu tentei várias abordagens diferentes, como usar subconsultas em vez de funções escalares:

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

plano de consulta 2

insira a descrição da imagem aqui

Ou usando 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

plano de consulta 3

insira a descrição da imagem aqui

Mas não consigo encontrar uma maneira de escrever essa consulta tão boa quanto a que utiliza funções escalares.

Par de pensamentos:

  1. Basicamente, o que eu gostaria é poder, de alguma forma, dizer ao SQL Server para pré-calcular certos valores e depois passá-los como constantes.
  2. O que poderia ser útil é se tivéssemos alguma dica de materialização intermediária . Eu verifiquei algumas variantes (TVF de múltiplas instruções ou cte com top), mas nenhum plano é tão bom quanto o que tem funções escalares até agora
  3. Eu sei sobre as próximas melhorias do SQL Server 2017 - Froid: Otimização de programas imperativos em um banco de dados relacional . Não tenho certeza se isso ajudará. Seria bom se provar errado aqui, no entanto.

Informação adicional

Estou usando uma função (em vez de selecionar dados diretamente das tabelas) porque é muito mais fácil usar em muitas consultas diferentes, que geralmente têm @session_idcomo parâmetro.

Me pediram para comparar os tempos de execução reais. Nesse caso em particular

  • a consulta 0 é executada por ~ 500ms
  • a consulta 1 é executada por ~ 1500ms
  • a consulta 2 é executada por ~ 1500ms
  • a consulta 3 é executada por ~ 2000ms.

O plano 2 tem uma varredura de índice em vez de uma busca, que é filtrada por predicados em loops aninhados. O plano 3 não é tão ruim assim, mas ainda faz mais trabalho e funciona mais devagar que o plano 0.

Vamos supor que isso dbo.Paramsseja alterado raramente e geralmente tenha de 1 a 200 linhas, não mais do que, digamos, 2000. Agora são 10 colunas e não espero adicionar colunas com muita frequência.

O número de linhas no Params não é fixo, portanto, para cada @session_iduma delas, haverá uma linha. O número de colunas não é fixo, é um dos motivos pelos quais não desejo ligar dbo.f_GetSharedData(@experiment_year int, @experiment_month int)de todos os lugares, para que eu possa adicionar nova coluna a essa consulta internamente. Eu ficaria feliz em ouvir quaisquer opiniões / sugestões sobre isso, mesmo que haja algumas restrições.

Roman Pekar
fonte
O plano de consulta com o Froid seria semelhante ao da query2 acima, portanto, sim, não o levará à solução que você deseja obter neste caso.
Karthik #

Respostas:

13

Você não pode realmente conseguir exatamente o que deseja no SQL Server hoje, ou seja, em uma única declaração e com execução paralela, dentro das restrições estabelecidas na pergunta (como eu as percebo).

Então, minha resposta simples é não . O restante desta resposta é principalmente uma discussão sobre o motivo disso, caso seja de interesse.

É possível obter um plano paralelo, conforme observado na pergunta, mas existem duas variedades principais, nenhuma das quais é adequada às suas necessidades:

  1. Um loop aninhado correlacionado se junta, com um round-robin distribui fluxos no nível superior. Dado que é garantida a origem Paramsde uma única linha para um session_idvalor específico , o lado interno será executado em um único encadeamento, mesmo que esteja marcado com o ícone de paralelismo. É por isso que o plano aparentemente paralelo 3 não funciona tão bem; é de fato serial.

  2. A outra alternativa é para o paralelismo independente no lado interno da junção de loops aninhados. Independente aqui significa que os threads são iniciados no lado interno, e não apenas os mesmos threads que estão executando o lado externo da junção de loops aninhados. O SQL Server oferece suporte apenas ao paralelismo de loops aninhados do lado interno independente quando é garantido que há uma linha do lado externo e não há parâmetros de junção correlacionados ( plano 2 ).

Portanto, podemos escolher um plano paralelo que seja serial (devido a um encadeamento) com os valores correlatos desejados; ou um plano paralelo do lado interno que precisa ser varrido porque não possui parâmetros para buscar. (Além disso: realmente deve ser permitido conduzir o paralelismo interno usando exatamente um conjunto de parâmetros correlatos, mas nunca foi implementado, provavelmente por um bom motivo).

Uma pergunta natural é: por que precisamos de parâmetros correlatos? Por que o SQL Server não pode simplesmente buscar diretamente os valores escalares fornecidos por, por exemplo, uma subconsulta?

Bem, o SQL Server pode apenas 'indexar busca' usando referências escalares simples, por exemplo, uma referência constante, variável, coluna ou expressão (para que um resultado de função escalar também possa ser qualificado). Uma subconsulta (ou outra construção semelhante) é simplesmente muito complexa (e potencialmente insegura) para ser inserida no mecanismo de armazenamento inteiro. Portanto, são necessários operadores de plano de consulta separados. Por sua vez, isso requer correlação, o que significa que não há paralelismo do tipo que você deseja.

No geral, atualmente não há solução melhor do que métodos como atribuir os valores de pesquisa a variáveis ​​e depois usá-los nos parâmetros de função em uma instrução separada.

Agora você pode ter considerações locais específicas que significam que SESSION_CONTEXTvale a pena armazenar em cache os valores atuais do ano e do mês, ou seja:

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;

Mas isso se enquadra na categoria de solução alternativa.

Por outro lado, se o desempenho da agregação for de importância primordial, considere manter as funções em linha e criar um índice columnstore (primário ou secundário) na tabela. Você pode encontrar os benefícios do armazenamento columnstore, processamento no modo em lote e empilhamento agregado, oferecendo maiores benefícios do que uma busca paralela no modo de linha de qualquer maneira.

Mas tome cuidado com as funções escalares do T-SQL, especialmente com o armazenamento columnstore, pois é fácil terminar com a função sendo avaliada por linha em um filtro separado no modo de linha. Geralmente, é bastante complicado garantir o número de vezes que o SQL Server escolhe avaliar escalares e é melhor não tentar.

Paul White 9
fonte
Obrigado, Paul, ótima resposta! Pensei em usar, session_contextmas decido que é uma idéia um pouco louca demais para mim e não tenho certeza de como isso se encaixará na minha arquitetura atual. O que seria útil, porém, é, pode ser, alguma dica que eu possa usar para informar ao otimizador que ele deve tratar o resultado da subconsulta como uma simples referência escalar.
Roman Pekar 25/03
8

Tanto quanto sei, a forma do plano que você deseja não é possível apenas com o T-SQL. Parece que você deseja que a forma do plano original (consulta 0 plano) com as subconsultas de suas funções sejam aplicadas como filtros diretamente na verificação de índice em cluster. Você nunca obterá um plano de consulta como esse se não usar variáveis ​​locais para armazenar os valores de retorno das funções escalares. A filtragem será implementada como uma junção de loop aninhada. Existem três maneiras diferentes (do ponto de vista do paralelismo) em que a junção de loop pode ser implementada:

  1. Todo o plano é serial. Isso não é aceitável para você. Este é o plano que você obtém para a consulta 1.
  2. A junção do loop é executada em série. Acredito que, neste caso, o lado interno possa ser executado em paralelo, mas não é possível passar nenhum predicado para ele. Portanto, a maior parte do trabalho será realizada em paralelo, mas você está verificando a tabela inteira e o agregado parcial é muito mais caro do que antes. Este é o plano que você obtém para a consulta 2.
  3. A junção do loop é executada em paralelo. Com as junções aninhadas paralelas, o lado interno do loop é executado em série, mas você pode ter até DOP threads em execução no lado interno de uma só vez. Seu conjunto de resultados externos terá apenas uma única linha; portanto, seu plano paralelo será efetivamente serial. Este é o plano que você obtém para a consulta 3.

Essas são as únicas formas possíveis de plano que eu conheço. Você pode obter outros se usar uma tabela temporária, mas nenhum deles resolverá seu problema fundamental se desejar que o desempenho da consulta seja tão bom quanto o da consulta 0.

Você pode obter desempenho equivalente à consulta usando as UDFs escalares para atribuir valores de retorno às variáveis ​​locais e usando essas variáveis ​​locais na sua consulta. Você pode agrupar esse código em um procedimento armazenado ou em um UDF com várias instruções para evitar problemas de manutenção. Por exemplo:

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;

Os UDFs escalares foram movidos para fora da consulta que você deseja ser elegível para paralelismo. O plano de consulta que recebo parece ser o que você deseja:

plano de consulta paralela

Ambas as abordagens têm desvantagens se você precisar usar esse conjunto de resultados em outras consultas. Você não pode ingressar diretamente em um procedimento armazenado. Você precisaria salvar os resultados em uma tabela temporária com seu próprio conjunto de problemas. Você pode ingressar em um MS-TVF, mas no SQL Server 2016 você pode ver problemas de estimativa de cardinalidade. O SQL Server 2017 oferece execução intercalada para o MS-TVF, o que poderia resolver o problema completamente.

Apenas para esclarecer algumas coisas: UDFs escalares T-SQL sempre proíbem o paralelismo e a Microsoft não disse que o FROID estará disponível no SQL Server 2017.

Joe Obbish
fonte
sobre o Froid no SQL 2017 - não sei por que pensei que estava lá. Está confirmado para estar em vNext
Roman Pekar #
4

Provavelmente, isso pode ser feito usando o SQLCLR. Um dos benefícios de SQLCLR escalares UDFs é que eles não impedem o paralelismo se eles não fazem qualquer acesso de dados (e às vezes precisa também ser marcado como "determinista"). Então, como você usa algo que não requer acesso a dados quando a própria operação exige acesso a dados?

Bem, porque a dbo.Paramstabela deve:

  1. geralmente nunca tem mais de 2000 linhas,
  2. raramente muda a estrutura,
  3. somente (atualmente) precisa ter duas INTcolunas

é possível armazenar em cache as três colunas - session_id, experiment_year int, experiment_month- em uma coleção estática (por exemplo, um dicionário, talvez) que seja preenchida fora de processo e lida pelas UDFs escalares que obtêm os valores experiment_year inte experiment_month. O que quero dizer com "fora de processo" é: você pode ter um UDF escalar SQLCLR ou procedimento armazenado completamente separado que possa acessar e ler dados da dbo.Paramstabela para preencher a coleção estática. Esse UDF ou Stored Procedure seria executado antes do uso das UDFs que obtêm os valores "year" e "month", assim as UDFs que obtêm os valores "year" e "month" não estão acessando os dados do banco de dados.

O UDF ou Stored Procedure que lê os dados pode verificar primeiro se a coleção possui 0 entradas e, se houver, preencher, em seguida, ignorar. Você pode até controlar o tempo em que foi preenchido e se já passou mais de X minutos (ou algo parecido), limpe e preencha novamente mesmo que haja entradas na coleção. Mas pular a população ajudará, pois precisará ser executado com freqüência para garantir que seja sempre preenchido pelas duas UDFs principais para obter os valores.

A principal preocupação é quando o SQL Server decide descarregar o domínio do aplicativo por qualquer motivo (ou é acionado por algo usando DBCC FREESYSTEMCACHE('ALL');). Você não deseja arriscar que a coleta seja limpa entre a execução da UDF ou do procedimento armazenado "preencher" e as UDFs para obter os valores "ano" e "mês". Nesse caso, você pode ter uma verificação no início dessas duas UDFs para lançar uma exceção se a coleção estiver vazia, pois é melhor erro do que fornecer resultados falsos com êxito.

Obviamente, a preocupação mencionada acima pressupõe que o desejo é ter a Assembléia marcada como SAFE. Se o Assembly puder ser marcado como EXTERNAL_ACCESS, é possível que um construtor estático execute o método que lê os dados e preencha a coleção, para que você só precise executá-lo manualmente para atualizar as linhas, mas elas sempre serão preenchidas (porque o construtor de classe estática sempre é executado quando a classe é carregada, o que acontece sempre que um método nessa classe é executado após uma reinicialização ou o domínio do aplicativo é descarregado). Isso requer o uso de uma conexão regular e não a Conexão de Contexto em processo (que não está disponível para construtores estáticos, daí a necessidade EXTERNAL_ACCESS).

Observe: para não ser necessário marcar a montagem como UNSAFE, você precisa marcar as variáveis ​​de classe estática como readonly. Isso significa, no mínimo, a coleção. Isso não é um problema, pois as coleções somente leitura podem ter itens adicionados ou removidos, apenas não podem ser inicializadas fora do construtor ou do carregamento inicial. Controlar o tempo em que a coleção foi carregada com o objetivo de expirar após X minutos é mais complicado, pois uma static readonly DateTimevariável de classe não pode ser alterada fora do construtor ou do carregamento inicial. Para contornar essa restrição, você precisa usar uma coleção estática, somente leitura, que contenha um único item com o DateTimevalor para que possa ser removida e adicionada novamente após uma atualização.

Solomon Rutzky
fonte
Não sei por que alguém rebaixou isso. Embora não seja muito genérico, acho que poderia ser aplicável no meu caso atual. Eu preferiria ter uma solução SQL pura, mas definitivamente vou dar uma olhada mais de perto e tentar ver se funciona #
Roman Pekar
@RomanPekar Não tenho certeza, mas existem muitas pessoas por aí que são anti-SQLCLR. E talvez alguns que são anti-eu ;-). De qualquer maneira, não consigo pensar por que essa solução não funcionaria. Entendo a preferência pelo T-SQL puro, mas não sei como fazer isso acontecer e, se não houver resposta competitiva, talvez ninguém mais o faça. Não sei se as tabelas com otimização de memória e UDFs compilados nativamente se sairiam melhor aqui. Além disso, acabei de adicionar um parágrafo com algumas notas de implementação para ter em mente.
Solomon Rutzky
1
Nunca estive totalmente convencido de que o uso readonly staticsseja seguro ou sábio no SQLCLR. Muito menos estou convencido de que, depois disso, vou enganar o sistema, tornando esse readonlyum tipo de referência, que você muda e muda . Dá-me a vontade absoluta.
Paul White 9
@PaulWhite Entendido, e lembro-me disso em conversas privadas anos atrás. Dada a natureza compartilhada dos domínios de aplicativo (e, portanto, dos staticobjetos) no SQL Server, sim, há risco de condições de corrida. Foi por isso que primeiro determinei a partir do OP que esses dados são mínimos e estáveis, e porque qualifiquei essa abordagem como exigindo "raramente mudando" e forneci um meio de atualização quando necessário. Em este caso de uso não vejo muito se qualquer risco. Encontrei um post anos atrás sobre a capacidade de atualizar coleções somente leitura por design (em C #, nenhuma discussão sobre: ​​SQLCLR). Vai tentar encontrá-lo.
Solomon Rutzky 25/03
2
Não há necessidade, não há como você me sentir confortável com isso, além da documentação oficial do SQL Server dizendo que está tudo bem, o que tenho certeza que não existe.
Paul White 9