MSSQL natively compiled: когда они тормозят

В базах данных нет серебряной пули, универсального рецепта. Мне захотелось проверить экспериментально один граничный случай использования in memory tables и natively compiled — когда в тесте все было хорошо, а на реальных данных начались тормоза.

Начинаем за здравие…

Вначале покажем, как Natively compiled могут творить чудеса. Напишем наивную функцию проверки числа на простоту:

create function dbo.isPrime (@n bigint)
returns int
as
  begin
  if @n = 1 return 0
  if @n = 2 return 1
  if @n = 3 return 1
  if @n % 2 = 0 return 0
  declare @sq int
  set @sq = sqrt(@n)+1 -- check odds up to sqrt
  declare @dv int = 1
  while @dv < @sq 
    begin
	set @dv=@dv+2
	if @n % @dv = 0 return 0
	end
  return 1
  end
GO

Также создадим такую же natively compiled функцию. Далее часть кода я буду прятать, чтобы статья не вышла очень длинной. Код нужен для тех, кто захочет повторить эксперимент.

Скрытый текст

create function dbo.isPrimeN (@n bigint)
returns int
WITH NATIVE_COMPILATION, SCHEMABINDING
as
  BEGIN ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')
  if @n = 1 return 0
  if @n = 2 return 1
  if @n = 3 return 1
  if @n % 2 = 0 return 0
  declare @sq int
  set @sq = sqrt(@n)+1 -- check odds up to sqrt
  declare @dv int = 1
  while @dv < @sq 
    begin
	set @dv=@dv+2
	if @n % @dv = 0 return 0
	end
  return 1
  end
GO

Теперь сравним скорость выполнения:

declare @dt datetime set @dt=getdate()
select dbo.isPrime(1000000000000037)
select datediff(ms,@dt,getdate()) as ms --> 15390

declare @dt datetime set @dt=getdate()
select dbo.isPrimeN(1000000000000037)
select datediff(ms,@dt,getdate()) as ms --> 277

Получаем 277ms вместо 15390ms — укорение в 55 раз! Впечатляюще. Вам должно быть интересно, что именно тормозит в обычном коде? После каждого оператора SQL server вызывает scheduler, который определяет, а не пора ли уступить CPU другим тредам? Ведь мультизадачность у нас здесь кооперативная. Scheduler выполняет системный вызов GetTimePrecise, переключая контекст. Маленькие операторы, которые выполняются слишком часто, это кошмар для обычного кода MS SQL. Он для этого не предназначен. В некоторых случаях это может приводить к тому, что я описывал в своей давней статье.

Заканчиваем за упокой

А что, если данных будет слишком много? Давайте соберем тестовый макет. С помощью функции выше сохраним 5 миллионов простых чисел в таблицу primes:

Скрытый текст

create table Primes (p int)
-- gen 10_000_000 primes starting with 3
truncate table Primes
set nocount on
declare @n int = 3, @gen int = 0
while @gen<5000000 begin
  while dbo.IsPrimeN(@n) = 0 set @n=@n+2
  insert into Primes select @n
  set @gen=@gen+1
  set @n=@n+2
  end

Теперь создадим таблицы RelA и RelB, куда скопируем первые N простых чисел:

create table RelA (p int primary key, fl float, str1 nvarchar(100), str2 varchar(100))
create table RelB (p int primary key, fl float, str1 nvarchar(100), str2 varchar(100))
...
  insert into RelA select top (@n) p, p as p1, 
    'This a simple a filler field '+convert(nvarchar,p),
	'This a simple a filler field '+convert(varchar,p)
	from Primes order by p
  insert into RelB select top (@n) p+2, p as p1, 
    'This a simple a filler field '+convert(nvarchar,p),
	'This a simple a filler field '+convert(varchar,p)
	from Primes order by p

Поля fl, str1 и str2 нужны, чтобы таблица не была предельно узкой — обычно в реальных таблицах помимо id есть ряд нужных полей. Во вторую таблицу мы записываем простое число плюс 2. Таким образом, inner join по p между этими двумя таблицами найдет… ну конечно, простые числа близнецы.

Теперь создадим еще in memory таблицы, аналогичные RelA и RelB:

CREATE TABLE dbo.MemA (   
   p INT PRIMARY KEY NONCLUSTERED HASH WITH (BUCKET_COUNT=100000),
   fl float, str1 nvarchar(100), str2 varchar(100)   )   
  WITH (MEMORY_OPTIMIZED=ON, DURABILITY=SCHEMA_ONLY)
CREATE TABLE dbo.MemB (   
   p INT PRIMARY KEY NONCLUSTERED HASH WITH (BUCKET_COUNT=100000),
   fl float, str1 nvarchar(100), str2 varchar(100)   )   
  WITH (MEMORY_OPTIMIZED=ON, DURABILITY=SCHEMA_ONLY)
CREATE TABLE dbo.MemA2 (   
   p INT PRIMARY KEY NONCLUSTERED HASH WITH (BUCKET_COUNT=10000),
   fl float, str1 nvarchar(100), str2 varchar(100)   )   
  WITH (MEMORY_OPTIMIZED=ON, DURABILITY=SCHEMA_ONLY)
CREATE TABLE dbo.MemB2 (   
   p INT PRIMARY KEY NONCLUSTERED HASH WITH (BUCKET_COUNT=10000),
   fl float, str1 nvarchar(100), str2 varchar(100)   )   
  WITH (MEMORY_OPTIMIZED=ON, DURABILITY=SCHEMA_ONLY)

Они отличаются только bucket_count. Наконец, подошло время самого теста:

Скрытый текст

create function dbo.Relsum() returns float as 
  begin
  declare @s float
  select @s=sum(RelA.fl) from RelA inner join RelB on RelA.p=RelB.p
  return @s
  end
GO
create function dbo.Memsum() 
returns float
WITH NATIVE_COMPILATION, SCHEMABINDING
  as 
  begin ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')
  declare @s float
  select @s=sum(A.fl) from dbo.MemA A inner join dbo.MemB B on A.p=B.p
  return @s
  end
GO
create function dbo.Memsum2() 
returns float
WITH NATIVE_COMPILATION, SCHEMABINDING
  as 
  begin ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english')
  declare @s float
  select @s=sum(A.fl) from dbo.MemA2 A inner join dbo.MemB2 B on A.p=B.p
  return @s
  end
GO

create procedure test @n int as
  truncate table RelA
  truncate table RelB
  delete from MemA
  delete from MemB
  delete from MemA2
  delete from MemB2
  insert into RelA select top (@n) p, p as p1, 
    'This a simple a filler field '+convert(nvarchar,p),
	'This a simple a filler field '+convert(varchar,p)
	from Primes order by p
  insert into RelB select top (@n) p+2, p as p1, 
    'This a simple a filler field '+convert(nvarchar,p),
	'This a simple a filler field '+convert(varchar,p)
	from Primes order by p
  insert into dbo.MemA select * from RelA
  insert into dbo.MemB select * from RelB
  insert into dbo.MemA2 select * from RelA
  insert into dbo.MemB2 select * from RelB
  
  declare @res bigint
  declare @dt datetime set @dt=getdate()
  select @res=dbo.Relsum()
  select datediff(ms,@dt,getdate()) as ms
  set @dt=getdate()
  select @res=dbo.Memsum()
  select datediff(ms,@dt,getdate()) as ms
  set @dt=getdate()
  select @res=dbo.Memsum2()
  select datediff(ms,@dt,getdate()) as ms
GO

Построим время выполнения для N первых простых чисел:

70b1abd6f2673748adb3eaecc7aa9fa4.png

Как видно, для in memory tables время выполнения джойна — типично квадратичное. Я думаю, этот результат не вызывает удивления, он ожидаем. Меня интересовало, до какого числа записей in memory + natively compiled выигрывают? Увеличим первую часть графика:

ec35a58909e24807a8d8a1fa234cd06e.png

Как видно, natively compiled показывают себя лучше до 150–300 тысяч записей, но это сильно зависит от bucket_count. Для небольшого количества записей все вообще замечательно:

ba384df55139e7c68e9dab3eadcf11df.png

Обращаю внимание, насколько сильно производительность зависит от bucket_count. А есть ли негативный эффект от использования большого bucket_count? Я подозревал, что это увеличенное потребление памяти, но эксперименты не подтверждают этого.

© Habrahabr.ru