A saída de SET STATISTICS IO ONpara ambos parece semelhante
SET STATISTICS IO ON;
PRINT 'V2'
EXEC dbo.V2 10
PRINT 'T2'
EXEC dbo.T2 10
Dá
V2
Table '#58B62A60'. Scan count 0, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3
Table '#58B62A60'. Scan count 10, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3
T2
Table '#T__ ... __00000000E2FE'. Scan count 0, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3
Table '#T__ ... __00000000E2FE'. Scan count 0, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3
E, como Aaron aponta nos comentários, o plano para a versão da variável de tabela é realmente menos eficiente, enquanto ambos têm um plano de loops aninhados acionado por uma pesquisa de índice na versão dbo.NUMda #temptabela realiza uma pesquisa no índice [#T].n = [dbo].[NUM].[n]com predicado residual, [#T].[n]<=[@total]enquanto a variável da tabela version executa uma busca de índice @V.n <= [@total]com predicado residual @V.[n]=[dbo].[NUM].[n]e, portanto, processa mais linhas (é por isso que esse plano tem um desempenho tão ruim para um número maior de linhas)
O uso de Eventos estendidos para examinar os tipos de espera para o spid específico fornece esses resultados para 10.000 execuções deEXEC dbo.T2 10
+---------------------+------------+----------------+----------------+----------------+
| | | Total | Total Resource | Total Signal |
| Wait Type | Wait Count | Wait Time (ms) | Wait Time (ms) | Wait Time (ms) |
+---------------------+------------+----------------+----------------+----------------+
| SOS_SCHEDULER_YIELD | 16 | 19 | 19 | 0 |
| PAGELATCH_SH | 39998 | 14 | 0 | 14 |
| PAGELATCH_EX | 1 | 0 | 0 | 0 |
+---------------------+------------+----------------+----------------+----------------+
e esses resultados para 10.000 execuções de EXEC dbo.V2 10
+---------------------+------------+----------------+----------------+----------------+
| | | Total | Total Resource | Total Signal |
| Wait Type | Wait Count | Wait Time (ms) | Wait Time (ms) | Wait Time (ms) |
+---------------------+------------+----------------+----------------+----------------+
| PAGELATCH_EX | 2 | 0 | 0 | 0 |
| PAGELATCH_SH | 1 | 0 | 0 | 0 |
| SOS_SCHEDULER_YIELD | 676 | 0 | 0 | 0 |
+---------------------+------------+----------------+----------------+----------------+
Portanto, fica claro que o número de PAGELATCH_SHesperas é muito maior no #tempcaso da tabela. Não tenho conhecimento de nenhuma maneira de adicionar o recurso de espera ao rastreamento de eventos estendidos, portanto, para investigar isso, executei
WHILE 1=1
EXEC dbo.T2 10
Enquanto em outra conexão de pesquisa sys.dm_os_waiting_tasks
CREATE TABLE #T(resource_description NVARCHAR(2048))
WHILE 1=1
INSERT INTO #T
SELECT resource_description
FROM sys.dm_os_waiting_tasks
WHERE session_id=<spid_of_other_session> and wait_type='PAGELATCH_SH'
Depois de deixar essa operação por cerca de 15 segundos, ela reuniu os seguintes resultados
+-------+----------------------+
| Count | resource_description |
+-------+----------------------+
| 1098 | 2:1:150 |
| 1689 | 2:1:146 |
+-------+----------------------+
Ambas as páginas bloqueadas pertencem a índices (diferentes) não agrupados na tempdb.sys.sysschobjstabela base denominada 'nc1'e'nc2' .
A consulta tempdb.sys.fn_dblogdurante as execuções indica que o número de registros de log adicionados pela primeira execução de cada procedimento armazenado era um tanto variável, mas para execuções subseqüentes o número adicionado por cada iteração era muito consistente e previsível. Depois que os planos de procedimento são armazenados em cache, o número de entradas de log é cerca da metade das necessárias para o#temp versão.
+-----------------+----------------+------------+
| | Table Variable | Temp Table |
+-----------------+----------------+------------+
| First Run | 126 | 72 or 136 |
| Subsequent Runs | 17 | 32 |
+-----------------+----------------+------------+
Examinando as entradas do log de transações com mais detalhes para a #tempversão da tabela do SP, cada chamada subsequente do procedimento armazenado cria três transações e a variável da tabela uma apenas duas.
+---------------------------------+----+---------------------------------+----+
| #Temp Table | @Table Variable |
+---------------------------------+----+---------------------------------+----+
| CREATE TABLE | 9 | | |
| INSERT | 12 | TVQuery | 12 |
| FCheckAndCleanupCachedTempTable | 11 | FCheckAndCleanupCachedTempTable | 5 |
+---------------------------------+----+---------------------------------+----+
O INSERT/TVQUERY transações são idênticas, exceto pelo nome. Ele contém os registros de log para cada uma das 10 linhas inseridas na tabela temporária ou na variável da tabela mais as entradas LOP_BEGIN_XACT/ LOP_COMMIT_XACT.
A CREATE TABLEtransação aparece apenas no#Temp versão e tem a seguinte aparência.
+-----------------+-------------------+---------------------+
| Operation | Context | AllocUnitName |
+-----------------+-------------------+---------------------+
| LOP_BEGIN_XACT | LCX_NULL | |
| LOP_SHRINK_NOOP | LCX_NULL | |
| LOP_MODIFY_ROW | LCX_CLUSTERED | sys.sysschobjs.clst |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc1 |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF | sys.sysschobjs.nc1 |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc2 |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF | sys.sysschobjs.nc2 |
| LOP_MODIFY_ROW | LCX_CLUSTERED | sys.sysschobjs.clst |
| LOP_COMMIT_XACT | LCX_NULL | |
+-----------------+-------------------+---------------------+
A FCheckAndCleanupCachedTempTabletransação aparece nos dois, mas possui 6 entradas adicionais na #tempversão. Estas são as 6 linhas referentes sys.sysschobjse têm exatamente o mesmo padrão que acima.
+-----------------+-------------------+----------------------------------------------+
| Operation | Context | AllocUnitName |
+-----------------+-------------------+----------------------------------------------+
| LOP_BEGIN_XACT | LCX_NULL | |
| LOP_DELETE_ROWS | LCX_NONSYS_SPLIT | dbo.#7240F239.PK__#T________3BD0199374293AAB |
| LOP_HOBT_DELTA | LCX_NULL | |
| LOP_HOBT_DELTA | LCX_NULL | |
| LOP_MODIFY_ROW | LCX_CLUSTERED | sys.sysschobjs.clst |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc1 |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF | sys.sysschobjs.nc1 |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc2 |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF | sys.sysschobjs.nc2 |
| LOP_MODIFY_ROW | LCX_CLUSTERED | sys.sysschobjs.clst |
| LOP_COMMIT_XACT | LCX_NULL | |
+-----------------+-------------------+----------------------------------------------+
Observando essas 6 linhas nas duas transações, elas correspondem às mesmas operações. A primeira LOP_MODIFY_ROW, LCX_CLUSTEREDé uma atualização para a modify_datecoluna em sys.objects. As cinco linhas restantes estão relacionadas à renomeação de objetos. Porque nameé uma coluna-chave dos dois NCIs afetados (nc1 enc2 ), isso é realizado como uma exclusão / inserção para aqueles, então ele volta ao índice em cluster e o atualiza também.
Parece que, para a #tempversão da tabela, quando o procedimento armazenado termina parte da limpeza realizada pela FCheckAndCleanupCachedTempTabletransação, renomeie a tabela temporária de algo como #T__________________________________________________________________________________________________________________00000000E316um nome interno diferente, como #2F4A0079quando é inserida, a CREATE TABLEtransação renomeia a mesma. Esse nome de flip-flop pode ser visto em uma conexão executando dbo.T2em um loop enquanto em outra
WHILE 1=1
SELECT name, object_id, create_date, modify_date
FROM tempdb.sys.objects
WHERE name LIKE '#%'
Resultados de exemplo

Portanto, uma explicação potencial para o diferencial de desempenho observado, como aludido por Alex, é que esse trabalho adicional é manter as tabelas do sistema tempdbresponsáveis.
Executando os dois procedimentos em um loop, o criador de perfil do Visual Studio Code revela o seguinte
+-------------------------------+--------------------+-------+-----------+
| Function | Explanation | Temp | Table Var |
+-------------------------------+--------------------+-------+-----------+
| CXStmtDML::XretExecute | Insert ... Select | 16.93 | 37.31 |
| CXStmtQuery::ErsqExecuteQuery | Select Max | 8.77 | 23.19 |
+-------------------------------+--------------------+-------+-----------+
| Total | | 25.7 | 60.5 |
+-------------------------------+--------------------+-------+-----------+
A versão da variável da tabela gasta cerca de 60% do tempo executando a instrução insert e a seleção subsequente, enquanto a tabela temporária é menos da metade disso. Isso está alinhado com os tempos mostrados no OP e com a conclusão acima de que a diferença no desempenho se deve ao tempo gasto na execução de trabalhos auxiliares, não devido ao tempo gasto na própria execução da consulta.
As funções mais importantes que contribuem para os 75% "ausentes" na versão temporária da tabela são
+------------------------------------+-------------------+
| Function | Inclusive Samples |
+------------------------------------+-------------------+
| CXStmtCreateTableDDL::XretExecute | 26.26% |
| CXStmtDDL::FinishNormalImp | 4.17% |
| TmpObject::Release | 27.77% |
+------------------------------------+-------------------+
| Total | 58.20% |
+------------------------------------+-------------------+
Nas funções de criação e liberação, a função CMEDProxyObject::SetNameé mostrada com um valor de amostra inclusivo de 19.6%. Pelo qual deduzo que 39,2% do tempo no caso de tabela temporária é ocupado com a renomeação descrita anteriormente.
E os maiores na versão variável de tabela que contribuem para os outros 40% são
+-----------------------------------+-------------------+
| Function | Inclusive Samples |
+-----------------------------------+-------------------+
| CTableCreate::LCreate | 7.41% |
| TmpObject::Release | 12.87% |
+-----------------------------------+-------------------+
| Total | 20.28% |
+-----------------------------------+-------------------+
Perfil da tabela temporária

Perfil da variável de tabela

#temptabela uma vez, apesar de serem limpas e preenchidas novamente 9.999 vezes depois disso.