SET NOCOUNT ON et les performances
Categories:
10 minutes à lire
Article coécrit avec Claude, qui a mené les tests et les recherches.
Sans SET NOCOUNT ON, une procédure qui fait 100 000 UPDATE dans une boucle envoie 3,9 Mo au client ; avec, 75 octets. Ces messages partent dans des paquets pleins, à la suite, sans attendre de réponse : il n’y a donc pas d’impact sur les performances de l’exécution des requêtes. Mais ils coûtent du débit.
La question
Une bonne pratique pour les procédures stockées est de toujours les commencer par l’instruction SET NOCOUNT ON, qui empêche SQL Server d’envoyer au client, après chaque instruction, le message qui donne le nombre de lignes touchées (le « (1 row affected) » de SSMS).
Ce message est le jeton DONE_IN_PROC du protocole TDS. La documentation de SET NOCOUNT dit qu’il « peut améliorer sensiblement les performances » des procédures qui contiennent des boucles, parce que le trafic réseau est fortement réduit. Celle de CREATE PROCEDURE dit au contraire qu’il n’y a pas de bénéfice mesurable sur le matériel actuel.
Qu’est-ce que SET NOCOUNT ON fait gagner, et dans quels cas ? Et peut-on s’en servir pour accélérer un traitement ligne à ligne (RBAR, row by agonizing row) ?
Le laboratoire
SQL Server 2025 CU10 (17.0.5035.5), édition Developer, conteneur Linux jetable limité à 3 Go. Une base q_nocount en récupération simple, une table de 1 000 lignes, et deux procédures identiques à une ligne près : chacune fait @n UPDATE d’une ligne dans une boucle WHILE, à l’intérieur d’une transaction, et seule la seconde commence par SET NOCOUNT ON.
Le client est un programme Python (pymssql 2.4.6) qui tourne sur l’hôte. Il passe par un relais TCP écrit pour l’occasion, qui compte les octets envoyés par le serveur et peut retarder chaque segment de 10 ms dans chaque sens (20 ms d’aller-retour), puis limiter le débit du serveur vers le client à 10 Mbit/s. Le nombre de paquets écrits par le serveur vient de la colonne num_writes de sys.dm_exec_connections, relevée avant et après la mesure. Chaque mesure a été faite trois fois. Le script complet est en fin de page.
Le trafic envoyé au client
| Appel | Octets reçus par le client | Paquets écrits par le serveur |
|---|---|---|
1 appel, 100 000 UPDATE, sans SET NOCOUNT ON | 3 907 759 | 956 |
1 appel, 100 000 UPDATE, avec | 75 | 2 |
1 000 appels de 10 UPDATE, sans | 496 021 | 1 001 |
1 000 appels de 10 UPDATE, avec | 54 021 | 1 001 |
Sans SET NOCOUNT ON, chaque itération de la boucle coûte 39 octets au client. La spécification de TDS donne 13 octets à un jeton DONE_IN_PROC, et dit qu’il en part un pour chaque instruction exécutée dans une procédure. Trois jetons par tour, c’est le compte des instructions de la boucle : l’UPDATE, l’incrément de @i et le test du WHILE. Je n’ai pas décodé le flux pour le vérifier. SET NOCOUNT ON les supprime tous, y compris ceux des instructions qui ne touchent aucune ligne.
Le nombre de paquets montre comment ils partent. Les 3,9 Mo font 956 paquets, soit des paquets pleins de 4 096 octets, la taille par défaut : le serveur ne vide pas son tampon à chaque instruction, il remplit un paquet et l’envoie. Quand la procédure est appelée 1 000 fois avec 10 instructions, chaque réponse tient dans un paquet, avec ou sans les messages : 1 001 paquets dans les deux cas.
La latence et le débit
Temps mesuré par le client, trois exécutions :
| Appel | Réseau | Sans SET NOCOUNT ON | Avec |
|---|---|---|---|
1 appel, 100 000 UPDATE | relais sans délai | 0,41 à 0,76 s | 0,38 à 0,73 s |
1 appel, 100 000 UPDATE | 20 ms d’aller-retour | 0,45 à 0,48 s | 0,42 à 0,44 s |
1 appel, 100 000 UPDATE | 20 ms et 10 Mbit/s | 3,92 à 3,96 s | 0,42 à 0,71 s |
1 000 appels de 10 UPDATE | relais sans délai | 2,02 à 2,16 s | 2,00 à 2,18 s |
1 000 appels de 10 UPDATE | 20 ms d’aller-retour | 28,5 à 31,0 s | 30,2 à 41,1 s |
1 000 appels de 10 UPDATE | 20 ms et 10 Mbit/s | 31,0 à 32,1 s | 29,9 à 32,8 s |
La latence ne change rien : les messages suivent les résultats dans le même flux, et le client n’a pas à répondre à chacun. Les 1 000 appels coûtent 1 000 allers-retours dans les deux variantes, et c’est ce qui fait les 30 secondes.
Le débit change tout dès que le volume est grand. 3,9 Mo à 10 Mbit/s demandent un peu plus de 3 secondes de transmission, et c’est ce que l’appel a pris en plus. Pour une procédure appelée souvent avec peu d’instructions, l’écart est de 442 octets par appel, invisible même à 10 Mbit/s.
Le temps serveur et le traitement ligne à ligne
Même table, cette fois de 100 000 lignes, chacune mise à jour une fois. Le client est sqlcmd, dans le conteneur, et les temps sont ceux de sys.dm_exec_procedure_stats, sur cinq exécutions :
| Procédure | Temps moyen | Minimum et maximum | Temps processeur moyen |
|---|---|---|---|
boucle de 100 000 UPDATE, sans SET NOCOUNT ON | 399 ms | 389 à 406 ms | 398 ms |
boucle de 100 000 UPDATE, avec | 402 ms | 388 à 412 ms | 401 ms |
un seul UPDATE des 100 000 lignes | 97 ms | 85 à 136 ms | 97 ms |
Une seconde série de cinq exécutions, lancée plus tard avec le script de cette page tel qu’il est publié, a donné des temps plus longs sur une machine plus chargée : 684 ms sans SET NOCOUNT ON, 714 ms avec, et 126 ms pour l’UPDATE ensembliste. L’écart entre les deux boucles reste du bruit, dans un sens puis dans l’autre.
Pour le serveur, produire les messages ne coûte rien de mesurable. Avec SET NOCOUNT ON, la boucle reste aussi lente ; l’option retire les 39 octets par ligne traitée envoyés au client, qui ne comptent que sur un lien lent. Le même travail écrit en une instruction ensembliste est quatre à cinq fois plus rapide, avec ou sans l’option.
Il y a deux bonnes raisons pour mettre SET NOCOUNT ON en tête de toutes les procédures :
- diminuer le trafic réseau inutile ;
- sans lui, le nombre de lignes que reçoit l’application additionne celles de toutes les instructions, déclencheurs compris. Dans un déclencheur qui fait deux instructions,
ExecuteNonQuerya rendu 3 pour unINSERTd’une ligne, au lieu de 1.
Limites des mesures
- Mesuré sur une seule version, SQL Server 2025 CU10, sur Linux, et un seul pilote,
pymssql. Un client qui traite chaque message plus lentement, comme la fenêtre de messages de SSMS, n’a pas été mesuré. - Le réseau est simulé par un relais en Python sur la même machine. Sa limite de débit retarde les données après les avoir lues : le serveur n’a jamais attendu le client (pas d’attente
ASYNC_NETWORK_IO), alors que sur un vrai lien lent il attendrait. - La taille de paquet est celle par défaut, 4 096 octets.
- Je n’ai pas décodé le flux TDS : les trois jetons par itération de la boucle sont déduits de la taille et de la spécification, pas observés.
Le script
Le script crée et supprime sa base jetable. Il contient un DBCC FREEPROCCACHE, qui vide le cache des plans de toute l’instance : à ne lancer que sur une instance de test.
-- SET NOCOUNT ON : temps serveur d'une boucle, avec et sans, face à un UPDATE ensembliste
SET NOCOUNT ON;
USE master;
IF DB_ID(N'q_nocount') IS NOT NULL
BEGIN
ALTER DATABASE q_nocount SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE q_nocount;
END;
CREATE DATABASE q_nocount;
ALTER DATABASE q_nocount SET RECOVERY SIMPLE;
GO
USE q_nocount;
CREATE TABLE dbo.Compteur (id int PRIMARY KEY, valeur int NOT NULL);
INSERT dbo.Compteur (id, valeur)
SELECT TOP (1000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), 0 FROM sys.all_columns;
GO
-- Une procédure qui exécute @n UPDATE d'une ligne, dans une transaction
CREATE OR ALTER PROCEDURE dbo.BoucleSansNocount @n int AS
BEGIN
DECLARE @i int = 0;
BEGIN TRAN;
WHILE @i < @n BEGIN UPDATE dbo.Compteur SET valeur += 1 WHERE id = 1 + @i % 1000; SET @i += 1; END;
COMMIT;
END;
GO
CREATE OR ALTER PROCEDURE dbo.BoucleAvecNocount @n int AS
BEGIN
SET NOCOUNT ON;
DECLARE @i int = 0;
BEGIN TRAN;
WHILE @i < @n BEGIN UPDATE dbo.Compteur SET valeur += 1 WHERE id = 1 + @i % 1000; SET @i += 1; END;
COMMIT;
END;
GO
DROP TABLE IF EXISTS dbo.Ligne;
CREATE TABLE dbo.Ligne (id int PRIMARY KEY, valeur int NOT NULL);
INSERT dbo.Ligne SELECT TOP (100000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), 0 FROM sys.all_columns a CROSS JOIN sys.all_columns b;
GO
CREATE OR ALTER PROCEDURE dbo.LigneParLigneSansNocount AS
BEGIN
DECLARE @i int = 1;
BEGIN TRAN;
WHILE @i <= 100000 BEGIN UPDATE dbo.Ligne SET valeur += 1 WHERE id = @i; SET @i += 1; END;
COMMIT;
END;
GO
CREATE OR ALTER PROCEDURE dbo.LigneParLigneAvecNocount AS
BEGIN
SET NOCOUNT ON;
DECLARE @i int = 1;
BEGIN TRAN;
WHILE @i <= 100000 BEGIN UPDATE dbo.Ligne SET valeur += 1 WHERE id = @i; SET @i += 1; END;
COMMIT;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Ensembliste AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.Ligne SET valeur += 1;
END;
GO
DBCC FREEPROCCACHE; -- conteneur jetable : remet les statistiques des procédures à zéro
GO
EXEC dbo.LigneParLigneSansNocount;
EXEC dbo.LigneParLigneAvecNocount;
EXEC dbo.Ensembliste;
GO 5
SELECT OBJECT_NAME(object_id) AS proc_, execution_count,
total_elapsed_time / execution_count / 1000 AS ms_moyen,
min_elapsed_time / 1000 AS ms_min, max_elapsed_time / 1000 AS ms_max,
total_worker_time / execution_count / 1000 AS cpu_ms_moyen
FROM sys.dm_exec_procedure_stats WHERE database_id = DB_ID();
GO
Le programme Python qui mesure les octets, les paquets et les temps à travers le relais, à lancer après le script précédent. Il se connecte au port 11990 de la machine locale et lit le mot de passe de sa dans la variable SQLCMDPASSWORD.
"""Mesure de SET NOCOUNT ON : temps client, octets reçus, paquets écrits par le serveur.
Un relais TCP local ajoute un délai fixe dans chaque sens (latence simulée) et compte les octets."""
import asyncio, os, sys, time, threading, statistics
import pymssql
CIBLE = ("127.0.0.1", 11990)
recu = 0 # octets serveur -> client
async def relais(lecteur, ecrivain, delai, compter, debit=None):
global recu
file = asyncio.Queue()
async def ecrire():
while True:
t, data = await file.get()
if data is None:
ecrivain.close(); return
attente = t - time.monotonic()
if attente > 0:
await asyncio.sleep(attente)
ecrivain.write(data); await ecrivain.drain()
if debit: # octets par seconde : on attend le temps de transmission du segment
await asyncio.sleep(len(data) / debit)
tache = asyncio.create_task(ecrire())
while True:
data = await lecteur.read(65536)
if not data:
await file.put((0, None)); break
if compter:
recu += len(data)
await file.put((time.monotonic() + delai, data))
await tache
def lancer_relais(port, delai, debit=None):
async def client(r, w):
sr, sw = await asyncio.open_connection(*CIBLE)
await asyncio.gather(relais(r, sw, delai, False), relais(sr, w, delai, True, debit), return_exceptions=True)
async def main():
srv = await asyncio.start_server(client, "127.0.0.1", port)
async with srv: await srv.serve_forever()
threading.Thread(target=lambda: asyncio.run(main()), daemon=True).start()
time.sleep(0.3)
def mesure(port, sql, appels):
global recu
cn = pymssql.connect(server="127.0.0.1", port=port, user="sa", password=os.environ["SQLCMDPASSWORD"], database="q_nocount", autocommit=True)
cn.cursor().execute("EXEC dbo.BoucleAvecNocount @n = 1") # chauffe
cur = cn.cursor()
cur.execute("SELECT num_writes FROM sys.dm_exec_connections WHERE session_id = @@SPID"); w0 = cur.fetchone()[0]
recu = 0
t0 = time.perf_counter()
for _ in range(appels):
cur.execute(sql)
while cur.nextset():
pass
dt = time.perf_counter() - t0
octets = recu
cur.execute("SELECT num_writes FROM sys.dm_exec_connections WHERE session_id = @@SPID"); w1 = cur.fetchone()[0]
cn.close()
return dt, octets, w1 - w0 - 1 # -1 : la réponse du premier relevé
if __name__ == "__main__":
lancer_relais(12000, 0.0) # relais sans délai
lancer_relais(12010, 0.010) # 10 ms par sens, 20 ms aller-retour
lancer_relais(12020, 0.010, 10e6 / 8) # idem, et 10 Mbit/s du serveur vers le client
scenarios = [
("1 appel, 100 000 UPDATE", 100000, 1),
("1 000 appels, 10 UPDATE", 10, 1000),
]
passes = int(sys.argv[1]) if len(sys.argv) > 1 else 3
for nom, n, appels in scenarios:
for port, reseau in ((12000, "relais 0 ms"), (12010, "relais 10 ms"), (12020, "10 ms + 10 Mbit/s")):
for proc in ("BoucleSansNocount", "BoucleAvecNocount"):
res = [mesure(port, f"EXEC dbo.{proc} @n = {n}", appels) for _ in range(passes)]
temps = ", ".join(f"{r[0]:.2f}" for r in res)
print(f"{nom} | {reseau} | {proc} | {temps} s | {res[-1][1]} octets | {res[-1][2]} paquets", flush=True)
Pour finir, la suppression de la base :
USE master;
ALTER DATABASE q_nocount SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE q_nocount;