Aller au contenu
Carnet d’ingénierie · RN 006

Roblox Luau côté serveur : raisonner en budgets, files et phases

Mon modèle mental de ce que le serveur dépense réellement entre deux Heartbeats : scheduler, VM Luau, GC, physique, Actors, sérialisation et réplication.

Position de cette note

J’écris ce texte comme le transcript raisonné de mes revues de performance : ce que je regarde, ce que je refuse de conclure trop vite et les modèles que je garde en tête lorsque plusieurs coûts se superposent. Je ne propose ni seuil magique d’« activité script » ni débit réseau universel. Les nombres présentés proviennent de la documentation Roblox ou Luau en vigueur au 25 juillet 2026 et restent séparés de mes budgets d’équipe. Aucune capture propriétaire de production n’accompagne cette publication ; je n’attribue donc aucun gain mesuré à une expérience qui ne pourrait pas être auditée.

Le jour où un script rapide a ralenti tout le serveur#

Je me méfie d’un serveur Roblox qui passe tous ses tests fonctionnels. Cette phrase paraît volontairement provocatrice, mais elle décrit exactement mon problème : la correction fonctionnelle ne dit rien sur la marge temporelle restante. Trente joueurs rejoignent une instance de staging, les combats répondent, les NPC trouvent leurs cibles et la mémoire paraît stable. Script Performance affiche des valeurs qui ne semblent pas alarmantes. Tout invite à publier, alors que le serveur peut déjà vivre à crédit.

Dans mes captures, je ne cherche donc pas d’abord une fonction manifestement catastrophique. Le problème arrive plus souvent sous forme de coïncidence : un groupe de joueurs entre dans une zone qui réveille des assemblages physiques ; le système de combat valide plusieurs salves ; un inventaire envoie un diff ; les NPC recalculent leur perception ; une vague instancie ses modèles. Chacun de ces systèmes est « assez rapide » mesuré seul. Leur réunion dépasse la fenêtre pendant laquelle le serveur devait produire l’état suivant. Je considère cette superposition comme une propriété du système, pas comme de la malchance.

Roblox plafonne le heartbeat serveur à 60 Hz. Le temps nominal entre deux pas est donc de 16,67 ms, et le tableau de bord Performance recommande de maintenir le temps CPU serveur sous 16 ms pour tenir cette cadence 12. Je tiens beaucoup à cette différence de formulation. 16,67 ms est la période mathématique ; 16 ms est une consigne d’exploitation qui laisse une marge minuscule. Ce n’est surtout pas « mon budget Luau ». C’est le budget du serveur entier : scripts, physique, animation, traitement réseau, réplication et travaux du moteur se présentent tous au même rendez-vous.

Lorsque je vois une frame serveur prendre 22 ms, je ne note pas seulement « +5,33 ms ». Cette dette ne sera pas remboursée tranquillement plus tard. Le heartbeat tombe sous sa cible, le traitement des paquets attend davantage, la réplication s’accumule, puis le data ping augmente alors que le chemin Internet n’a pas changé. Les clients voient une autorité qui répond tard. Selon le gameplay, ils l’interprètent comme du hit registration incohérent, un véhicule qui corrige sa trajectoire ou une capacité qui part après son animation.

Je représente cette dette par une récurrence volontairement simple :

\[D_{t+1} = \max(0, D_t + C_t - T)\]

où \(C_t\) est la longueur du chemin critique de la frame \(t\), \(T = 16{,}67\ \mathrm{ms}\), et \(D_t\) une dette conceptuelle, pas un compteur interne publié par Roblox. Ce modèle ne prétend pas reproduire le scheduler. Il me force à poser la bonne question : le système possède-t-il réellement une phase future sous-utilisée capable d’absorber ce travail, ou ai-je seulement déplacé le retard dans une file ?

Mon premier changement de modèle mental est là : la performance serveur n’est pas la vitesse d’une fonction. C’est la capacité de plusieurs producteurs de travail à respecter une échéance commune, frame après frame, sans faire croître leurs files. Quand je dis qu’un système est performant, je veux pouvoir nommer son échéance, sa file, sa politique de surcharge et le travail qu’il sacrifie.

Figure 1. La fenêtre de 16,67 ms est partagée. Un sous-système « dans son budget » isolément peut tout de même faire manquer l’échéance lorsque les pics se superposent.

La frame serveur est une échéance, pas une boucle Luau#

J’ai longtemps trouvé commode de représenter mentalement le serveur comme un grand while task.wait() do. J’ai fini par abandonner cette image parce qu’elle cache précisément les coûts que je veux expliquer. Le scheduler coordonne des tâches moteur et des reprises Luau à des points précis : animation, pré-simulation, physique, post-simulation, reprises de threads en attente, Heartbeat et réseau. Certaines tâches ne travaillent pas à chaque frame ; d’autres peuvent exécuter plusieurs pas. La documentation du Task Scheduler publie l’ordre général, mais pas un contrat de durée par catégorie ni une garantie que chaque machine serveur dispose du même nombre de cœurs 3.

Cette absence de quota public structure toute ma méthode. Je refuse une revue qui commence par « les scripts ont 8 ms » comme s’il s’agissait d’une propriété de la plateforme. Je peux réserver 4 ms de mon propre budget à une catégorie sur une population de serveurs observée. Ce nombre devient alors un SLO interne, lié à un scénario de charge, une version de place et une distribution de machines. Il ne devient jamais une constante Roblox.

Je lis ensuite le placement d’un calcul comme une dépendance, pas comme un choix esthétique d’API. PreSimulation convient au code qui modifie l’état destiné à la physique ; PostSimulation à ce qui consomme le résultat physique. Heartbeat n’est pas un espace gratuit situé « après la frame ». Tout travail qui y est repris participe encore à la cadence du serveur et peut retarder les traitements suivants. Déplacer une boucle de Heartbeat vers task.defer change son ordre de reprise, pas son coût total. Si mon scope se déplace dans la chronologie sans rétrécir, je n’ai pas optimisé : j’ai changé la causalité.

Les signaux différés rendent cette distinction visible. Avec Workspace.SignalBehavior = Deferred, les handlers sont mis en file et repris aux points de reprise du moteur. Roblox recommande ce mode pour la performance et la correction ; les templates récents le sélectionnent, alors que Default reste actuellement équivalent à Immediate et doit changer à l’avenir 4. En mode immédiat, une mutation de propriété peut réentrer en Luau au milieu du travail moteur. En mode différé, elle ajoute du travail à une file.

Le mode différé ne réduit pas par lui-même le nombre d’instructions métier. Il donne au moteur des frontières plus propres et évite certaines alternances moteur–Luau. Il change surtout la causalité observable. Un code qui déclenche un signal puis lit immédiatement une variable que le handler devait modifier n’a plus le même résultat. Je ne traite donc jamais SignalBehavior = Deferred comme un drapeau de performance isolé. Je commence par écrire les points où l’état devient visible, puis je teste les invariants qui dépendaient auparavant d’une réentrance immédiate.

Cette discipline améliore mon déterminisme applicatif. Les producteurs enregistrent des intentions ; une phase d’application les ordonne ; la simulation avance ; une phase de sortie construit les deltas. Je préfère payer une file explicite dont je connais la capacité plutôt que dépendre du hasard de callbacks imbriqués.

La moyenne est l’endroit où les pointes disparaissent#

Je n’accorde presque aucune valeur à une moyenne isolée. Un serveur à 60 pas par seconde peut sembler sain avec une moyenne de 10 ms et rester injouable si une frame atteint régulièrement 40 ms. Les joueurs ne consomment pas une moyenne arithmétique. Ils consomment une suite d’autorités discrètes : entrée acceptée, simulation avancée, résultat répliqué. Une seule longue frame retarde toute la chaîne.

Il faut donc conserver au moins quatre vues distinctes :

Vue Question opérationnelle Mauvaise conclusion fréquente
temps CPU serveur agrégé la population de serveurs a-t-elle régressé après une version ? « la moyenne est sous 16 ms, donc aucun serveur ne souffre »
fréquence Heartbeat le serveur tient-il réellement 60 pas/s ? « le client est à 60 FPS, donc le serveur aussi »
distribution de frames quels percentiles et quels maxima ratent l’échéance ? « le coût moyen d’une boucle représente son pire cas »
profondeur ou âge des files métier le travail produit est-il consommé assez vite ? « répartir une tâche sur plusieurs frames supprime son coût »

Le tableau de bord Roblox expose le temps CPU par frame, le heartbeat serveur, la mémoire, les cœurs utilisés et l’efficacité de calcul 1. Je l’utilise pour détecter une régression de flotte, jamais pour expliquer seul sa cause. Il agrège des serveurs d’âges, de populations et de scénarios différents. Une régression réservée aux instances pleines peut être diluée par les serveurs peu occupés. À l’inverse, un pic de join massif peut être interprété comme une dégradation du combat alors qu’il appartient au chemin d’initialisation.

Une mesure exploitable doit donc porter son contexte : placeVersion, âge du serveur, nombre de joueurs, nombre d’entités actives, carte, phase de round, taille des files d’entrée et de réplication métier. Dans mes propres dashboards, je veux au minimum pouvoir reconstruire trois cohortes : serveurs jeunes contre âgés, population faible contre pleine, régime stable contre transition. L’objectif n’est pas de créer cent dimensions dans chaque métrique. Il est de répondre à la question que je pose devant chaque graphe : « quels serveurs, dans quel état ? »

La règle la plus rentable que je connaisse consiste à construire des scénarios de charge reproductibles. Un replay d’entrées ou un harness de bots déclenche les mêmes vagues, les mêmes acquisitions de cibles et les mêmes mutations. Il ne reproduit pas parfaitement un serveur public, mais il permet de comparer deux versions sans confondre optimisation et différence de scénario. Pour moi, une capture sans scénario est une photographie. Une capture avec scénario, version et seed devient une expérience.

Le MicroProfiler raconte la frame que les agrégats cachent#

Quand j’ouvre le MicroProfiler, je ne cherche pas un Task Manager plus détaillé. Je cherche une chronologie causale. Il montre sur quels threads le moteur a travaillé, quelles tâches se sont chevauchées et quelle chaîne se trouvait sur le chemin critique. Sur serveur, cette relation compte davantage que la somme de tous les temps CPU : deux travaux de 4 ms exécutés réellement en parallèle n’allongent pas nécessairement la frame de 8 ms, tandis qu’un travail de 4 ms précédé par une barrière tardive peut définir à lui seul la fin de frame.

Ma lecture commence par une frame manquée, pas par le label le plus large sur une vue zoomée au hasard. Je remonte ensuite :

  1. la hauteur de la frame et son voisinage, pour distinguer coût permanent et pointe ;
  2. le thread critique, généralement RBX Main ou un worker qui retarde la barrière ;
  3. la catégorie moteur dominante : scripts, physique, animation, réseau ou autre ;
  4. le scope applicatif ajouté avec debug.profilebegin() et debug.profileend() ;
  5. les appels moteur enfants, dont le coût ne disparaîtra pas en réécrivant seulement la syntaxe Luau.

Je nomme les scopes d’après une unité de travail stable, jamais d’après une fonction générique comme Update. Combat/ValidateBatch, NPC/Perception, Replication/BuildDelta ou Inventory/Commit rendent deux captures comparables. J’ajoute la cardinalité dans une métrique séparée, pas dans le label, afin de ne pas fragmenter le profil. Je garantis aussi l’équilibre de profilebegin et profileend, y compris sur les retours anticipés. Dans les chemins à exceptions, une petite fonction d’enveloppe évite de corrompre la lisibilité du profil.

Lua
--!strict

local function profileScope(label: string, callback: () -> ()): ()
	debug.profilebegin(label)
	local ok: boolean, failure: any = xpcall(callback, debug.traceback)
	debug.profileend()
	if not ok then
		error(failure, 0)
	end
end

Le --!strict ne réduit ici aucune instruction. Je l’impose parce qu’un snippet de performance sans contrat de types laisse souvent passer exactement les ambiguïtés qui faussent une mesure : callback susceptible de yield, résultat polymorphe, buffer rendu à l’appelant, any qui traverse la frontière réseau. Le typechecker ne mesure pas la frame ; il rend la propriété et la durée de vie révisables 22.

Le Script Profiler répond à une autre question. C’est un profileur statistique de fonctions Luau. Il peut échantillonner à 1 kHz ou 10 kHz ; 10 kHz offre davantage de précision mais ajoute plus de coût et peut perturber la mesure 5. Une petite fonction très fréquente peut apparaître clairement ; une fonction brève et rare peut échapper à l’échantillonnage. Le MicroProfiler localise une frame et un chemin critique. Le Script Profiler attribue statistiquement le temps Luau aux fonctions. Dans ma pratique, je n’ouvre le second qu’après avoir trouvé le scope Luau responsable dans le premier.

Le panneau Script Performance reste utile pour trouver rapidement un script bavard. Je le traite comme un index de suspects, pas comme une preuve de temps de frame. Roblox ne publie actuellement ni définition normative suffisamment précise de son pourcentage Activity, ni seuil universel acceptable. Les règles communautaires du type « sous 3 % tout va bien » n’entrent pas dans mes revues. Le Rate aide à repérer une fréquence d’activation anormale ; l’activité aide au triage ; la décision vient des timings et des captures.

Enfin, une capture produit elle-même du coût. Les dumps réseau en niveau High contiennent le détail des éléments de paquets, de la désérialisation et des assets ; le niveau Low collecte moins de données et perturbe moins 6. Une comparaison avant/après doit utiliser le même niveau, le même scénario et une fenêtre suffisamment longue. Une unique frame héroïque ne valide pas une optimisation.

Le coût Luau le plus discret est souvent ce qui vient de mourir#

Les allocations temporaires me préoccupent précisément parce qu’elles simplifient localement le code. Une requête de zone retourne une table ; un pipeline filter en crée une seconde ; le tri en manipule une troisième ; la sérialisation construit des chaînes ; le résultat est abandonné avant la frame suivante. Le heap n’a pas nécessairement grossi à long terme, pourtant le serveur a payé l’allocation, l’écriture, la traversée et la collecte.

Luau utilise un allocateur spécialisé et un garbage collector incrémental. L’incrémental évite normalement de parcourir tout le heap pendant une unique pause, mais il ne rend pas l’allocation gratuite. L’implémentation répartit le travail de collecte avec l’exécution ; un taux d’allocation élevé force des GC assists pendant les scripts et réduit leur débit 7. Je préfère cette formulation à « le GC spike » : la documentation publique décrit surtout une taxe de throughput, et ne publie pas de pause maximale garantie pour le runtime serveur Roblox. Une moyenne de LuaHeap stable n’innocente donc jamais le churn.

Je ne regarde donc pas seulement la taille du heap. Je compare des snapshots Luau heap, les colonnes Self et Size, les comptes de table, function et thread, puis la pente de PlaceScriptMemory et de LuaHeap selon l’âge du serveur 8. La racine registry mérite une attention particulière : elle agrège notamment les fonctions connectées aux signaux, les tâches planifiées, les retours de ModuleScripts et les globals. Deux snapshots pris au même jalon d’un scénario permettent de distinguer :

  • la rétention, où des objets restent atteignables ;
  • le churn, où le volume revient mais les allocations sont continuellement recréées ;
  • la croissance légitime, où un cache borné se remplit puis se stabilise ;
  • la mémoire moteur, qui ne doit pas être attribuée à Luau parce que le graphe de heap ne la voit pas.

Scene Analysis complète cette vue. Son mode Script memory attribue le heap de VM aux Scripts et ModuleScripts ; son mode Unparented instances montre quelles références Luau retiennent des Instances sorties du DataModel. L’API SceneAnalysisService:GetScriptMemoryAsync() retourne même cette mémoire par script en octets 23. Je m’en sers pour passer d’un symptôme global à un propriétaire, puis je reviens au heap pour trouver le chemin de rétention.

Les connexions sont une cause classique de rétention. Une callback connectée conserve sa fermeture et ce qu’elle capture. Les tables indexées par Player survivent si l’entrée n’est pas supprimée. Un Actor multiplie aussi certains états : la documentation de l’Actor indique que chaque Actor exécute sa propre VM Luau ; un ModuleScript requis dans plusieurs Actors n’est ni partagé ni mis en cache entre ces VM, et son état de module est donc réinstancié 9. Si un module construit au chargement une LUT, un cache de configuration, des closures et des tables de dispatch, je multiplie cette empreinte par le nombre de VM qui le requièrent. Transformer 200 NPC en 200 Actors sans mesurer peut déplacer une saturation CPU vers un coût mémoire et d’initialisation.

Je sépare mentalement quatre allocations différentes, car elles n’ont pas le même remède :

  1. allocation Luau de requête : tables, strings, closures et threads visibles dans le heap ;
  2. allocation de VM : état isolé par Actor, modules réévalués et caches dupliqués ;
  3. allocation moteur : Instances, signaux, physique et navigation visibles dans les catégories PlaceMemory, pas comme objets Luau ordinaires ;
  4. allocation de transport : copies inter-VM et buffers de sérialisation dont le coût peut être bref mais fréquent.

Une baisse de LuaHeap n’est pas une victoire si Instances, Signals ou la taille des messages inter-VM monte. Je cherche une baisse du coût de bout en bout, pas une colonne plus verte.

Ma stratégie de tables dépend du chemin d’accès. Pour un objet à forme stable, je construis un littéral qui déclare tous les champs en une fois ; l’uniformité des clés permet à l’inline cache de rester monomorphe plus souvent. Pour un tableau dont la capacité maximale est connue, table.create préalloue l’espace. Pour un scratch buffer, table.clear conserve la capacité et évite de recréer le conteneur [R7, R10]. Je ne localise pas mécaniquement math.max ni les méthodes : Luau importe déjà de nombreux accès globaux et optimise les appels obj:Method(). Je préfère protéger les fast paths documentés plutôt que reproduire des recettes Lua 5.1 devenues contre-productives.

Lua
--!strict

type Entity = {
	id: number,
	position: Vector3,
	factionId: number,
}

-- Allocation par requête : lisible, mais le coût suit le nombre de requêtes.
local function collectVisible(
	entities: { Entity },
	origin: Vector3,
	radius: number
): { Entity }
	local visible: { Entity } = {}
	for _, entity: Entity in entities do
		if (entity.position - origin).Magnitude <= radius then
			table.insert(visible, entity)
		end
	end
	return visible
end

-- Scratch possédé par le système : capacité réutilisée, durée de vie explicite.
local visibleScratch: { Entity } = table.create(256)

local function collectVisibleInto(
	entities: { Entity },
	origin: Vector3,
	radius: number
): number
	table.clear(visibleScratch)
	local count: number = 0
	local radiusSquared: number = radius * radius

	for index = 1, #entities do
		local entity: Entity = entities[index]
		local delta: Vector3 = entity.position - origin
		if delta:Dot(delta) <= radiusSquared then
			count += 1
			visibleScratch[count] = entity
		end
	end

	return count
end

Je ne prétends pas que la seconde version est universellement supérieure. Son buffer ne peut pas être conservé par l’appelant ; elle est non réentrante dans ce périmètre ; un yield au milieu violerait son contrat. Le type { Entity } ne sait pas exprimer à lui seul un emprunt temporaire : cette propriété reste un invariant d’architecture que je documente et teste. Le gain potentiel vient de la suppression d’allocations et du test de distance sans racine carrée, pas d’une esthétique « low level ». Si le premier chemin s’exécute dix fois par minute avec douze entités, la seconde version ajoute surtout du risque.

La localité suit la même logique. Un tableau d’objets hétérogènes est excellent pour le code de domaine. Une boucle chaude qui ne lit que position, rayon et faction transporte inutilement le reste de la forme et multiplie les chargements. Une représentation structure of arrays — tableaux denses de positions, rayons et identifiants — peut mieux servir une passe numérique et le code natif. Elle rend en revanche les insertions, suppressions et correspondances plus complexes. Ma frontière préférée est souvent un état métier riche, transformé une fois en snapshot dense pour plusieurs lecteurs, puis appliqué par delta.

Roblox ne publie pas la taille en octets d’une entrée de table Luau dans son environnement, le coût d’une coroutine, ni le nombre d’allocations exact d’un appel de bibliothèque. Toute feuille de calcul qui prétend dériver la mémoire serveur à partir de constantes communautaires doit être validée sur le heap réel de la version moteur exécutée.

Je surveille également les closures. Luau évite une allocation d’upvalue pour de nombreuses captures immuables et peut mettre en cache certaines closures définies au niveau module. Une capture mutable nécessite en revanche un objet d’upvalue alloué ; une closure recréée dans une boucle peut encore générer du trafic d’allocation si elle ne satisfait pas les heuristiques de cache 7. Le code suivant n’est donc pas équivalent, du point de vue du heap, à une fonction stable qui reçoit son contexte en paramètre. Je ne réécris pas toutes les callbacks : je mesure les chemins où leur fréquence rend ce détail visible.

Les coroutines déplacent l’attente ; elles ne créent pas de capacité#

task.spawn, task.defer, task.delay et task.wait parlent au scheduler. Je ne les traite jamais comme des créateurs de capacité : ils ne produisent automatiquement ni thread OS ni parallélisme Luau. task.spawn reprend immédiatement via le scheduler ; task.defer place la reprise en fin du cycle de reprise courant, ou du suivant s’il n’y en a pas ; task.wait reprend sur une étape Heartbeat après la durée demandée 11. Ces différences sont des contrats d’ordre et de latence, pas des multiplicateurs de CPU.

Quand je vois chaque handler réseau entouré de task.spawn pour « ne pas bloquer le remote », je lis immédiatement « file non mesurée ». Le handler initial revient vite, mais le travail reste dans le même domaine sériel et devient une file de coroutines. Si l’arrivée moyenne dépasse le service moyen, le backlog croît. Le serveur paraît réactif pendant quelques frames, puis reprend une masse de threads, augmente le heap et détruit la prédictibilité.

Le modèle utile est celui d’une file bornée :

\[B_{t+1} = \min(B_{\max}, \max(0, B_t + A_t - S_t))\]

où \(A_t\) représente le travail admis pendant la frame et \(S_t\) le travail réellement servi. task.spawn change le moment où \(S_t\) commence ; il ne change pas durablement sa capacité. Si \(A_t > S_t\) en moyenne, il faut réduire l’admission, agréger les requêtes, augmenter le service par une meilleure algorithmique ou dégrader explicitement.

Pour moi, la dégradation est une fonctionnalité d’architecture. Une mise à jour cosmétique peut être écrasée par la plus récente. Une requête de sauvegarde ne le peut pas. Une perception NPC peut être répartie avec une ancienneté maximale. Un achat doit conserver son ordre et son idempotence. Mélanger ces travaux dans la même file de coroutines revient à laisser le scheduler décider d’une politique métier qu’il ne connaît pas.

J’écris plutôt une file bornée dont le refus est visible :

Lua
--!strict

type Job = {
	entityId: number,
	dueTick: number,
	costClass: "cheap" | "expensive",
}

local CAPACITY: number = 512
local jobs: { Job? } = table.create(CAPACITY)
local head: number = 1
local tail: number = 1
local size: number = 0

local function tryPush(job: Job): boolean
	if size == CAPACITY then
		return false -- Le producteur doit compter et appliquer sa politique.
	end
	jobs[tail] = job
	tail = (tail % CAPACITY) + 1
	size += 1
	return true
end

local function pop(): Job?
	if size == 0 then
		return nil
	end
	local job: Job = jobs[head] :: Job
	jobs[head] = nil
	head = (head % CAPACITY) + 1
	size -= 1
	return job
end

Cette file n’est pas présentée comme la structure la plus rapide pour tout workload. Elle rend trois propriétés impossibles à cacher en revue : la capacité, le rejet et l’ownership des éléments. Dans une vraie implémentation, j’ajoute l’âge du plus ancien job, les compteurs admitted, served, dropped et le temps de service par classe. Sans ces quatre séries, une queue n’est pour moi qu’une latence future.

Je conserve les coroutines persistantes lorsqu’elles portent une machine à états avec un véritable cycle de vie. Je les retire lorsqu’un objet possède sa propre boucle, son propre délai et ses propres captures sans raison sémantique. Un scheduler applicatif central peut parcourir un tableau dense d’entités dues, limiter le nombre traité par frame et exposer directement son backlog. Il remplace des milliers de petits réveils opaques par une politique visible.

Parallel Luau accélère les phases séparables, pas les graphes d’Instances#

Parallel Luau apporte un vrai parallélisme, mais je ne l’emploie que lorsque le travail possède une géométrie compatible. Plusieurs scripts dans le même Actor restent sériels les uns par rapport aux autres. Plusieurs Actors donnent au moteur des unités qu’il peut distribuer. Roblox recommande même, pour certaines validations par raycasts, davantage d’Actors que de cœurs afin d’améliorer l’équilibrage ; l’exemple officiel évoque 64 Actors ou plus, sans en faire une constante universelle 12.

Le nombre de cœurs serveur disponible, le nombre interne de workers et leur affectation ne sont pas publiés comme contrat stable. Je refuse donc un système qui exige exactement huit exécutions simultanées pour tenir son échéance. Je crée assez de partitions logiques pour que le moteur équilibre la charge, puis je mesure le point où messages, VM, synchronisations et duplication de modules dépassent le gain.

Ma formule de décision n’est pas « temps parallèle inférieur au temps sériel ». J’évalue plutôt :

\[C_{\mathrm{phase}} = C_{\mathrm{capture}} + \max_i(C_{\mathrm{worker},i}) + C_{\mathrm{barrière}} + C_{\mathrm{fusion}} + C_{\mathrm{copies}}\]

Le terme max est le bénéfice recherché ; tous les autres sont la facture. Un worker déséquilibré définit la phase. Une capture trop riche détruit la localité. Une fusion qui rematérialise des centaines d’Instances rend le calcul parallèle secondaire. Et une table copiée vers chaque VM peut déplacer le coût dans un scope que mon benchmark du noyau numérique ne voyait pas.

Quand je cherche un candidat, je demande quatre propriétés :

  • un input compact et immuable pour la durée de la phase ;
  • beaucoup de calcul ou d’appels marqués sûrs en parallèle ;
  • peu de sortie ;
  • une application sérielle courte.

La validation de hitboxes, la perception spatiale sur snapshot, le broad-phase applicatif et la génération procédurale par chunks entrent souvent dans cette forme. Un système qui lit puis écrit des centaines de propriétés d’Instances à chaque étape n’y entre pas. La sûreté de chaque API est un contrat explicite (Unsafe, Read Parallel, Local Safe, Safe) ; sans tag, l’API est considérée Unsafe 12. La plupart des mutations du DataModel nécessitent une phase synchronisée. Alterner task.desynchronize() et task.synchronize() dans une boucle fine transforme les barrières en coût dominant et me signale que j’ai choisi la mauvaise frontière.

Figure 2. Le schéma rentable est une phase sérielle de capture, plusieurs partitions parallèles indépendantes, puis une fusion ordonnée et courte.

Je budgète la communication comme un travail. Actor:SendMessage() est asynchrone pour l’émetteur, mais ses arguments ordinaires sont copiés entre frontières de VM ; une grande table impose une copie complète à chaque appel, et une fonction ne peut pas traverser cette frontière 9. Le mot asynchrone décrit le blocage de l’émetteur, pas la gratuité de la copie.

SharedTable évite cette copie lors du partage, autorise des mises à jour atomiques et peut cloner avec partage structurel 12. Je ne le prends pas pour une mémoire concurrente sans coût ni règle. Il ne transforme pas un état mutable en programme déterministe. Une itération de SharedTable crée un snapshot superficiel cohérent ; les clés numériques sont parcourues dans l’ordre croissant, l’ordre des clés chaîne n’est pas spécifié 13. Si l’ordre devient métier, je le matérialise séparément.

Pour une simulation autoritaire, le pattern robuste reste souvent le snapshot à époque :

  1. la phase sérielle publie un snapshot figé avec epoch = n ;
  2. chaque Actor calcule sur une partition et renvoie {epoch, partitionId, results} ;
  3. le coordinateur rejette les résultats d’une ancienne époque ;
  4. il fusionne par partitionId, jamais par ordre d’arrivée ;
  5. il applique les écritures au DataModel dans un ordre stable.

Voici le contrat minimal que j’attends avant même de discuter du nombre d’Actors :

Lua
--!strict

export type EntitySample = {
	id: number,
	position: Vector3,
	targetPosition: Vector3,
}

export type PartitionRequest = {
	epoch: number,
	partitionId: number,
	entities: { EntitySample },
}

export type SteeringDelta = {
	id: number,
	acceleration: Vector3,
}

export type PartitionResult = {
	epoch: number,
	partitionId: number,
	deltas: { SteeringDelta },
}

local currentEpoch: number = 0
local pendingByPartition: { [number]: PartitionResult } = {}

local function acceptResult(result: PartitionResult): boolean
	if result.epoch ~= currentEpoch then
		return false
	end
	pendingByPartition[result.partitionId] = result
	return true
end

local function mergeInPartitionOrder(partitionCount: number): { SteeringDelta }
	local merged: { SteeringDelta } = {}
	for partitionId = 1, partitionCount do
		local result: PartitionResult = pendingByPartition[partitionId]
		for _, delta: SteeringDelta in result.deltas do
			merged[#merged + 1] = delta
		end
	end
	return merged
end

Le typechecker protège ici la forme du protocole, pas sa sémantique temporelle. Il ne prouve ni que toutes les partitions répondent, ni que partitionId est unique, ni que les id sont triés. J’ajoute ces invariants dans le coordinateur, avec un timeout de phase et une politique explicite pour le résultat manquant. Je ne laisse jamais « le premier Actor terminé gagne » devenir une règle de simulation.

Cette barrière ajoute une frame ou une fraction de frame de latence selon le pipeline. Je l’accepte lorsqu’elle supprime une classe de courses et rend le résultat indépendant du worker qui termine le premier. Le parallélisme devient alors un détail d’exécution, ce qui est exactement la propriété que je recherche.

Le code natif est un autre levier distinct. La documentation Roblox le recommande pour certains scripts serveur dominés par du calcul numérique, et non pour du code qui passe son temps dans les API moteur 14. Aucun multiplicateur actuel garanti n’est publié. Une annonce de preview de 2023 évoquait une attente de 1,5× à 2,5× pour certaines fonctions compute-intensive ; je traite ce chiffre comme l’historique d’une preview, pas comme le contrat de production 2026 15. Les annotations de types peuvent aider le générateur natif à spécialiser des chemins, puisque le JIT Luau tient compte des types source et ne repose pas sur un profileur dynamique des valeurs 7. Malgré cela, j’exige un avant/après au Script Profiler et au MicroProfiler, ainsi que la vérification du surcoût mémoire du code natif.

La réplication est un pipeline avec une file, pas un appel de fonction#

RemoteEvent:FireClient() me donne syntaxiquement l’impression d’un envoi immédiat. Je combats cette intuition : le serveur encode un message, l’insère dans le système de réplication, le planifie avec d’autres données, le transporte, puis le client le traite à un point de sa propre frame. La fonction a fini avant que le coût et la latence de bout en bout aient disparu.

Roblox distingue le network ping du data ping. Le premier mesure principalement l’aller-retour réseau. Le second inclut les files de réplication et les retransmissions fiables. Il est donc toujours supérieur ou égal au network ping. Lorsque le serveur produit plus de données que le réseau ne peut en envoyer, ou que le client ne peut en traiter, la donnée attend et l’écart entre les deux mesures s’élargit 2. Cet écart est une latence de logiciel et de file, pas une propriété du câble.

Cette différence est l’un de mes meilleurs signaux de saturation de réplication. Elle m’évite d’accuser l’Internet lorsque le serveur est son propre embouteillage. Dans un dump MicroProfiler réseau, ProcessPackets couvre le traitement entrant et Allocate Bandwidth and Run Senders le travail sortant pertinent sur serveur [R6, R16]. Je corrèle ces scopes avec les barres réseau par frame : physique, données et assets ne suivent pas nécessairement la même remédiation. Je conserve aussi le niveau de capture, car un dump High instrumente davantage qu’un dump Low.

Les RemoteEvents fiables conviennent aux transitions qui doivent arriver dans l’ordre : confirmation d’achat, changement d’équipement, résultat autoritaire. UnreliableRemoteEvent convient à une valeur éphémère qui sera bientôt remplacée : visée, effet, état interpolable. Il abandonne ordre et fiabilité, et les messages peuvent être perdus pour préserver les performances 17.

Deux limites actuelles sont documentées pour les remotes client → serveur : environ 500 requêtes par seconde et par client, partagées entre les RemoteEvents d’un même type, et un payload maximal de 1 000 octets pour UnreliableRemoteEvent ; les payloads plus grands sont abandonnés 17. Je traite ces valeurs comme des garde-fous moteur, jamais comme un objectif de conception. Un client à 400 événements/s peut rester sous le throttle documenté tout en forçant validation, allocations, journalisation et fan-out à tous les autres joueurs. Soixante clients peuvent transformer une limite individuelle tolérable en charge serveur absurde.

Roblox ne publie pas de budget universel en kbit/s par joueur pour RemoteEvent, ni de nombre garanti de messages fiables par frame. La taille encodée dépend des types et de la compression ; les buffer peuvent être compressés. Je refuse donc les tableurs qui additionnent seulement les tailles Luau apparentes. La méthode défendable consiste à mesurer les octets dans le dump réseau, à séparer entrant et sortant, puis à tester sous latence, jitter et perte simulés.

Le fan-out mérite une équation simple. Un événement entrant de taille \(p\), relayé à \(N-1\) clients, n’a pas un coût réseau \(p\), mais un ordre de grandeur sortant proche de \((N-1)p\), avant en-têtes, batching et compression. Ce n’est pas un modèle exact du protocole Roblox ; c’est mon alarme architecturale. FireAllClients à la fréquence d’entrée transforme la population en multiplicateur.

Je place l’admission avant la construction d’une représentation riche :

Lua
--!strict

type Bucket = {
	tokens: number,
	lastRefill: number,
}

local ReplicatedStorage: ReplicatedStorage = game:GetService("ReplicatedStorage")
local remote: RemoteEvent = ReplicatedStorage:WaitForChild("Aim") :: RemoteEvent
local RATE_PER_SECOND: number = 12
local BURST: number = 6
local buckets: { [Player]: Bucket } = {}

local function enqueueValidatedPayload(_player: Player, _payload: buffer): ()
	-- La file métier bornée possède son propre contrat et ses métriques.
end

local function admit(player: Player, now: number): boolean
	local bucket: Bucket? = buckets[player]
	if bucket == nil then
		bucket = { tokens = BURST, lastRefill = now }
		buckets[player] = bucket
	end

	local elapsed: number = math.max(0, now - bucket.lastRefill)
	bucket.tokens = math.min(BURST, bucket.tokens + elapsed * RATE_PER_SECOND)
	bucket.lastRefill = now

	if bucket.tokens < 1 then
		return false
	end
	bucket.tokens -= 1
	return true
end

remote.OnServerEvent:Connect(function(player: Player, raw: unknown)
	if not admit(player, os.clock()) then
		return
	end
	if typeof(raw) ~= "buffer" or buffer.len(raw) > 256 then
		return
	end
	enqueueValidatedPayload(player, raw)
end)

Les constantes 12, 6 et 256 ne sont pas des recommandations Roblox ; elles représentent un budget de produit à calibrer par action. Ce qui m’intéresse dans le snippet est l’ordre : identité implicite fournie par l’événement, token bucket, type, taille, puis seulement décodage et logique métier. unknown m’oblige à raffiner le payload ; un any ferait disparaître la frontière de confiance du typechecker.

Figure 3. Un remote traverse admission, validation, simulation, sérialisation et file de réplication. Optimiser seulement le handler ignore la moitié du chemin.

Les optimisations les plus fortes que j’applique suppriment du travail :

  • envoyer un changement d’état plutôt qu’un snapshot complet ;
  • coalescer plusieurs changements d’une frame par destinataire ;
  • envoyer un identifiant compact plutôt qu’une arborescence ;
  • produire les VFX côté client à partir d’une intention autoritaire minimale ;
  • remplacer les états éphémères plutôt que les mettre en file ;
  • limiter l’admission avant la désérialisation profonde et avant tout fan-out ;
  • ne jamais tween côté serveur une propriété qui peut être animée localement, car sa mutation est répliquée régulièrement 16.

Le coût d’Instances suit le même pipeline. Créer puis détruire un modèle complexe sur le serveur déclenche travail DataModel, signaux, physique potentielle et réplication. Un pool d’Instances peut réduire l’allocation moteur, mais un pool parenté dans un conteneur répliqué peut maintenir une empreinte client inutile. Je place le pool du bon côté de la frontière : état autoritaire minimal sur serveur, représentation visuelle sur client lorsque la sécurité le permet. Je vérifie ensuite Instances, Signals, la taille de join et la réplication ; un scope de spawn plus court ne suffit pas à déclarer victoire.

La physique peut consommer le budget que Luau croit posséder#

Quand un profil est dominé par la physique, je ferme la PR qui micro-optimise une table et je remonte à la scène. Roblox simule par défaut la physique à 240 Hz, soit approximativement quatre worldsteps par frame de 60 Hz. Le mode adaptatif affecte des îlots à 60, 120 ou 240 Hz selon leur difficulté ; Roblox annonce jusqu’à 2,5× d’amélioration de performance dans des scénarios adaptés 18. Ce chiffre appartient au moteur physique et dépend de la scène. Je ne l’extrapole jamais à l’ensemble du serveur.

Une vague de NPC peut augmenter le coût sur plusieurs axes à la fois : davantage de Luau pour l’IA, davantage de Humanoids et d’animations, davantage d’assemblages actifs, davantage de contacts, davantage de propriétés à répliquer. C’est pourquoi je refuse « le système NPC prend 3 ms » si l’attribution ne compte que son scope Luau. Le système produit encore du travail moteur après la fin de son callback.

Le sommeil physique, les collision groups, les propriétés CanCollide, CanTouch et CanQuery, la complexité des assemblages et la fidélité de collision déterminent souvent plus de coût que la boucle qui déplace l’entité. Un Touched sur chaque projectile ajoute aussi un contrat de sécurité délicat lorsque le client possède la physique.

La network ownership distribue le calcul physique vers les clients et améliore la réactivité, mais elle transfère de l’autorité sur le mouvement. Roblox précise qu’il ne peut pas vérifier les calculs physiques d’un client propriétaire et qu’un client peut falsifier positions ou événements Touched 19. Mettre tous les projectiles côté client pour sauver le serveur n’est donc pas une optimisation neutre. Il faut décider ce qui peut être prédit, ce qui doit être revalidé et quelle quantité de correction est acceptable.

Mon compromis robuste est rarement « tout serveur » ou « tout client ». Le client simule la présentation immédiate ; le serveur valide une représentation plus compacte avec des bornes de vitesse, de temps, de portée et de visibilité ; la réplication transporte le résultat nécessaire. J’intègre le coût de validation au budget du remote. Décharger la physique pour recevoir un raycast arbitraire 120 fois par seconde n’a fait que changer la forme de la charge.

Le déterminisme se construit au-dessus d’un scheduler qui ne promet pas l’ordre total#

Je ne demande pas à deux serveurs Roblox d’exécuter chaque callback dans le même ordre nanoseconde par nanoseconde. Je demande en revanche à un jeu autoritaire que les mêmes entrées validées produisent un état cohérent selon ses propres règles. Cette propriété disparaît lorsque l’ordre d’arrivée, l’ordre d’itération de dictionnaires ou la fin des Actors devient une règle métier implicite.

J’utilise un pas fixe applicatif pour les systèmes qui exigent des vitesses et cooldowns stables. Je refuse qu’il devienne une boucle de rattrapage illimitée. Lorsque le serveur est déjà en retard, simuler dix pas d’un coup prolonge la frame et nourrit une spirale. Le système doit déclarer sa politique : nombre maximal de pas, saut contrôlé, réduction de fidélité ou mise en pause d’un sous-système.

Lua
--!strict

local RunService: RunService = game:GetService("RunService")

local STEP: number = 1 / 30
local MAX_STEPS_PER_HEARTBEAT: number = 2
local accumulator: number = 0
local tickId: number = 0

type Metrics = {
	droppedSimulationTime: number,
	droppedTicks: number,
}

local metrics: Metrics = {
	droppedSimulationTime = 0,
	droppedTicks = 0,
}

local function simulateTick(_tickId: number, _step: number): ()
	-- Simulation pure ou orchestration vers des phases bornées.
end

RunService.Heartbeat:Connect(function(deltaTime: number): ()
	accumulator += math.min(deltaTime, STEP * MAX_STEPS_PER_HEARTBEAT)

	local steps: number = 0
	while accumulator >= STEP and steps < MAX_STEPS_PER_HEARTBEAT do
		tickId += 1
		simulateTick(tickId, STEP)
		accumulator -= STEP
		steps += 1
	end

	if accumulator >= STEP then
		-- Politique explicite : compter et dégrader, jamais rattraper sans borne.
		metrics.droppedSimulationTime += accumulator
		metrics.droppedTicks += math.floor(accumulator / STEP)
		accumulator = 0
	end
end)

Ce code illustre ma politique, pas un réglage universel. À 30 Hz, une mécanique gagne du temps CPU et perd de la résolution temporelle. MAX_STEPS_PER_HEARTBEAT = 2 borne le travail mais abandonne du temps simulé en surcharge. Pour une économie, abandonner du temps peut être incorrect ; pour une perception NPC, c’est parfois acceptable. Je rends cette perte observable avec deux métriques plutôt que de cacher l’écart en tronquant silencieusement deltaTime.

Je produis l’ordre stable volontairement. Mes commandes portent un tickId et un numéro de séquence par joueur. Je fusionne les résultats d’Actors par identifiant de partition. Je n’utilise pas l’ordre d’un dictionnaire pour définir une résolution. Je sépare les générateurs aléatoires par système et les seed depuis un état enregistré. Les écritures d’économie sont idempotentes et ne dépendent pas de la répétition exacte d’un callback.

Roblox propose aussi RunService:BindToSimulation() dans le modèle d’autorité serveur avec simulation fixe. La callback est appelée à une fréquence fixe indépendante du frame rate ; les priorités basses passent d’abord et l’ordre de deux bindings de même priorité n’est pas spécifié 20. Cette API vise les contrôleurs physiques et la prédiction, avec des restrictions de propriétés synchronisées. Elle ne remplace pas une architecture déterministe pour tous les systèmes métier, mais elle confirme un principe : une simulation reproductible exige des entrées, propriétés et ordres explicitement synchronisés.

Les vrais incidents de production sont multiplicatifs#

Les régressions sévères que je redoute ne proviennent pas toujours d’un nouvel algorithme \(O(n^2)\). Elles proviennent souvent d’un multiplicateur ajouté à un travail ancien. Les scénarios suivants sont des composites : ils condensent les formes d’incident que je cherche en revue, sans prétendre reproduire une capture propriétaire.

Le join storm. Chaque PlayerAdded clone un arbre, charge un profil, construit un inventaire complet, connecte des signaux, calcule des attributs et envoie un snapshot. Un seul join paraît sain. Dix joins rapprochés alignent CPU, allocations et réplication. Mon premier réflexe n’est pas uniquement de rendre loadPlayer plus rapide. Je sépare admission, I/O, instanciation et réplication, je borne chaque phase et je déduplique ce qui peut être partagé.

Le fan-out quadratique. Chaque joueur envoie son aim à haute fréquence ; le serveur valide puis relaie à tous. Avec \(N\) joueurs, les entrées croissent en \(N\) et les sorties en \(N(N-1)\). Aucun remote individuel ne paraît énorme. La topologie l’est. Je corrige avec UnreliableRemoteEvent, fréquence réduite, quantification, intérêt spatial et interpolation client : je change la structure plutôt que la syntaxe.

Le cache qui devient registre. Une table playerData[player] est nettoyée, mais les callbacks capturent encore le profil. Le heap croît avec le nombre cumulé de visiteurs, pas avec les joueurs présents. Un serveur neuf est parfait ; un serveur âgé ralentit. Je le vois dans les snapshots de heap segmentés par âge et dans les chemins de rétention ; le MicroProfiler, lui, ne montre que le coût secondaire de cette mémoire.

Le parallélisme qui recopie le monde. Une passe NPC est découpée en Actors. Chaque frame, le coordinateur envoie à chacun une grande table d’entités ; chaque frontière de VM la copie, traite quelques éléments, puis renvoie une autre table. Les workers apparaissent occupés, les cœurs utilisés augmentent, mais le chemin critique ne baisse pas. Je publie plutôt un snapshot partagé ou partitionné, je conserve les données au plus près du worker et je renvoie un delta compact.

Le pool qui réplique. Pour supprimer les pointes d’instanciation, une équipe précrée des centaines de projectiles sous ReplicatedStorage. Le coût de spawn baisse ; la taille de join et la mémoire client augmentent. Le pool a optimisé une phase en taxant toutes les sessions. Je sépare le contrat avec un pool serveur non répliqué et un pool visuel client.

Leur point commun est une unité locale trompeuse. Une fonction, un remote, un NPC ou un joueur ne coûte presque rien. Le produit fréquence × population × fan-out × durée de vie devient le système. C’est ce produit que j’essaie d’écrire dans la marge de chaque capture.

Une optimisation n’existe qu’après la comparaison#

Dans une revue de performance, je me donne le droit de refuser deux types de changements : l’optimisation non mesurée et la mesure qui ne représente pas la production.

Mon protocole minimal tient en quelques règles :

  1. définir un scénario, une population, une durée, une seed et une version de place ;
  2. enregistrer serveur Heartbeat, temps CPU, mémoire, cœurs utilisés, data ping et files métier ;
  3. capturer plusieurs fenêtres MicroProfiler, dont les pointes et le régime stable ;
  4. utiliser le Script Profiler à fréquence identique avant et après ;
  5. prendre des snapshots Luau heap au même jalon de scénario ;
  6. conserver le dump réseau avec le même niveau de détail ;
  7. changer une hypothèse principale ;
  8. comparer non seulement la moyenne, mais les percentiles, maxima et pentes ;
  9. vérifier que le coût n’a pas été déplacé vers le client, le réseau, la mémoire ou une autre phase ;
  10. canarier sur une fraction de serveurs et segmenter par population et âge.

Un microbenchmark Luau isolé reste utile lorsque je choisis entre deux représentations dans une boucle chaude. Il ne prédit pas le gain de frame si la boucle appelle Workspace:Raycast, modifie des Instances ou attend une barrière. Je sépare calcul pur, appels moteur et allocations. J’échauffe le chemin, je consomme le résultat pour éviter un benchmark mort et je vérifie la sémantique avant de regarder le chronomètre.

Les chiffres publics donnent des bornes de départ, pas les résultats de votre jeu :

Métrique publiée Valeur ou comportement documenté Usage correct
heartbeat serveur plafonné à 60 Hz détecter une perte de cadence
période nominale 16,67 ms à 60 Hz raisonner sur l’échéance totale
consigne CPU du dashboard sous 16 ms/frame seuil de surveillance de flotte, pas quota Luau
mémoire serveur sous 50 % du total ; à défaut, guide sous 3 Go alerte grossière à compléter par âge et catégories
Script Profiler 1 kHz ou 10 kHz attribution statistique, avec overhead supérieur à 10 kHz
remote client → serveur environ 500 requêtes/s/client, partagé par type garde-fou moteur, jamais cible de trafic
UnreliableRemoteEvent payload maximal 1 000 octets borne d’encodage ; au-delà, abandon
physique 240 Hz par défaut ; îlots adaptatifs 60/120/240 Hz comprendre le multiplicateur de worldsteps
évènements différés profondeur de réentrance immédiate limitée à 10 détail de correction, pas budget de callbacks

Je laisse volontairement des cases sans nombres. Roblox ne garantit pas publiquement le coût d’une coroutine, la capacité réseau par joueur, le nombre de cœurs d’un serveur, la durée d’une synchronisation Actor, la taille d’une table ou une pause GC maximale. Ces valeurs dépendent du moteur, de la machine et du workload. Les inventer me donnerait une précision esthétique et une décision fausse.

Quand il vaut mieux supprimer une fréquence que gagner une instruction#

Ma hiérarchie des gains est rarement mystérieuse.

Je préfère supprimer un travail par frame plutôt que rendre sa boucle 20 % plus rapide. Passer une perception de 60 Hz à 10 Hz, avec stagger entre groupes, change la quantité de travail. Ne recalculer une route que lorsque la cible ou la topologie change supprime des appels moteur. Maintenir un index spatial évite de parcourir toute la population. Envoyer un delta évite sérialisation et réseau. Déplacer un effet visuel au client supprime à la fois Instances serveur, réplication et callbacks.

Ensuite, je regarde la forme algorithmique. Un GetDescendants() dans un Heartbeat reconstruit une vue du monde que le système pourrait maintenir par événements. Un tri complet par NPC peut devenir une sélection du minimum ou un bucket spatial. Une deep copy par remote peut devenir un buffer compact ou un identifiant vers un état déjà connu. Une table indexée par Instance peut devenir un tableau dense accompagné d’une map d’index uniquement pour les mutations.

Puis je traite les allocations et la localité : scratch buffers, table.create, formes stables, SoA sur les passes numériques, absence de closures temporaires. Elles comptent réellement dans les boucles chaudes et les serveurs âgés. Elles ne compensent pas une fréquence ou une cardinalité incorrecte.

Le code natif et Parallel Luau arrivent après cette clarification. Je les utilise pour augmenter la capacité d’un travail qui doit encore exister. Ils ne corrigent ni un fan-out, ni une file non bornée, ni une dépendance au DataModel qui force la sérialisation. Paralléliser un mauvais algorithme peut seulement lui permettre de saturer plus de cœurs.

Enfin viennent les micro-choix : préférer une boucle numérique sur une passe dense, stabiliser les formes, éviter une fonction __index sur un chemin brûlant, garder les appels builtins reconnaissables par le compilateur. Luau possède déjà inline caching, imports de globals, namecall, appels de builtins spécialisés et itération de tables optimisée 7. Je connais ces fast paths, mais je ne transforme pas une revue de serveur en concours de bytecode lorsque Allocate Bandwidth and Run Senders domine la frame.

Le serveur performant est celui qui sait quel travail il peut refuser#

Je ne définis pas une plateforme temps réel saine comme une plateforme qui exécute seulement vite. Je la définis par sa capacité à préserver le travail critique lorsque tout arrive ensemble.

Dans le système que je veux opérer, les entrées réseau possèdent des token buckets par joueur et par action, avant les allocations profondes. Les files ont une capacité, une métrique d’âge et une politique de rejet. Les états éphémères sont coalescés. Les sauvegardes et transactions sont idempotentes. Les simulations non critiques réduisent leur fréquence lorsque la frame approche de son budget. Les Actors renvoient des résultats versionnés. Les sorties réseau appliquent intérêt et priorité. Chaque système publie son coût avec des scopes stables.

Cette architecture améliore aussi la sécurité. Roblox recommande de valider types, valeurs, contexte et fréquence côté serveur ; un exploiteur peut envoyer des valeurs arbitraires, y compris NaN et les infinis, et déclencher les remotes à une fréquence hostile 21. Une validation qui parcourt récursivement une table non bornée après l’avoir acceptée reste une vulnérabilité de capacité. La forme et la taille doivent être rejetées avant la logique coûteuse.

La checklist que je garde à côté d’une capture est donc la suivante :

  • Traiter 16,67 ms comme l’échéance du serveur entier et 16 ms comme la consigne publique de surveillance, jamais comme un quota Luau.
  • Budgéter par scénario et par percentile ; conserver population, âge serveur et version de place avec les mesures.
  • Instrumenter les unités métier avec des labels MicroProfiler stables, puis lire le chemin critique avant d’ouvrir le Script Profiler.
  • Utiliser Script Performance pour le triage uniquement ; refuser tout seuil d’activité présenté sans définition ni timing.
  • Mesurer le taux d’allocation et la rétention séparément avec des snapshots Luau heap répétés.
  • Réutiliser les tables seulement lorsque leur ownership, leur non-réentrance et leur durée de vie sont explicites.
  • Centraliser les réveils de grandes populations et borner le travail servi par frame.
  • Employer les Actors pour des phases à input compact, calcul élevé et output faible ; fusionner les résultats dans un ordre stable.
  • Compter les copies inter-VM et la duplication des ModuleScripts dans le coût de Parallel Luau.
  • Comparer data ping et network ping ; un écart croissant est un signal de queue de réplication.
  • Coalescer et appliquer l’intérêt avant FireClient ou FireAllClients; ne jamais concevoir autour de la limite moteur d’environ 500 remotes/s.
  • Réserver UnreliableRemoteEvent aux états remplaçables et rester strictement sous son plafond de 1 000 octets mesuré après encodage réel.
  • Inclure le travail moteur causé par Luau — physique, signaux, Instances et réplication — dans le coût du système.
  • Définir une politique de surcharge : drop, coalescing, baisse de fréquence ou report borné. Une file infinie n’est pas une politique.
  • Canarier chaque optimisation importante et vérifier qu’elle n’a pas déplacé le coût vers la mémoire, le réseau ou le client.

Je ne considère pas le serveur Roblox comme lent parce que Luau serait un mauvais langage ou parce que Heartbeat serait une mauvaise API. Il devient lent lorsque mon architecture produit davantage de travail que ses phases ne peuvent en absorber, puis cache ce déséquilibre derrière des coroutines, des moyennes et des callbacks.

Après plusieurs années à penser des backends de jeu, ma tentation naturelle reste d’optimiser ce que je contrôle le plus facilement : la boucle visible. Je dois me rappeler que le coût réel vit souvent après elle. Une propriété modifiée réveille des signaux. Une Instance créée entre dans la physique et la réplication. Un remote envoyé rejoint une file. Une table abandonnée rejoint le travail du collecteur. Un Actor sollicité recopie son message et attend une barrière.

Pour moi, l’ingénierie de performance commence lorsque ces conséquences deviennent des objets de conception. Je ne demande plus seulement « combien de temps prend cette fonction ? » Je demande : quelle phase la paie, quelle file elle alimente, quelle cardinalité la multiplie, quelle mémoire elle retient, quelle échéance elle peut manquer et quel travail mon système sacrifiera lorsque la réponse n’entre plus dans 16,67 ms.

Le meilleur serveur n’est pas, à mes yeux, celui qui réussit son benchmark à vide. C’est celui dont les priorités restent vraies au moment précis où les joueurs, la physique, le réseau et le garbage collector réclament tous la même frame. C’est cette propriété que j’essaie de mesurer ; tout le reste n’est qu’un moyen.

Références techniques#

Roblox Creator Hub, Performance analytics, métriques serveur, consulté le 25 juillet 2026.

Roblox Creator Hub, Identify performance issues, heartbeat serveur, network ping et data ping, consulté le 25 juillet 2026.

Roblox Creator Hub, Task scheduler, ordre des phases et RunService, consulté le 25 juillet 2026.

Roblox Creator Hub, Deferred engine events, points de reprise, réentrance et SignalBehavior, consulté le 25 juillet 2026.

Roblox Creator Hub, Script Profiler, fréquences d’échantillonnage et attribution CPU, consulté le 25 juillet 2026.

Roblox Creator Hub, Network usage in MicroProfiler, niveaux de capture et lecture des trames réseau, consulté le 25 juillet 2026.

Luau, How we make Luau fast, tables, allocation, cache, code natif et garbage collector, consulté le 25 juillet 2026.

Roblox Creator Hub, Memory usage, catégories serveur et snapshots Luau heap, consulté le 25 juillet 2026.

Roblox Creator Hub, Actor, isolation des VM, cache des modules et copie des messages, consulté le 25 juillet 2026.

Luau RFC, table.clear, conservation de capacité, consulté le 25 juillet 2026.

Roblox Creator Hub, Task library, sémantique des reprises spawn, defer, delay et wait, consulté le 25 juillet 2026.

Roblox Creator Hub, Parallel Luau, Actors, sûreté des API, messaging et SharedTable, consulté le 25 juillet 2026.

Roblox Creator Hub, SharedTable, snapshot d’itération, ordre et opérations atomiques, consulté le 25 juillet 2026.

Roblox Creator Hub, Native code generation, périmètre serveur et profilage, consulté le 25 juillet 2026.

Roblox Developer Forum, Luau Native Code Generation Preview, annonce officielle, 31 août 2023.

Roblox Creator Hub, Improve performance, scopes réseau, scripts, mémoire et réplication, consulté le 25 juillet 2026.

Roblox Creator Hub, UnreliableRemoteEvent, fiabilité, throttling et payload, consulté le 25 juillet 2026.

Roblox Creator Hub, Adaptive timestepping, fréquences de simulation et gain annoncé, consulté le 25 juillet 2026.

Roblox Creator Hub, Network ownership, distribution physique et implications de sécurité, consulté le 25 juillet 2026.

Roblox Creator Hub, RunService:BindToSimulation, fréquence, priorité et restrictions de synchronisation, consulté le 25 juillet 2026.

Roblox Creator Hub, Securing the client-server boundary, validation et rate limiting, consulté le 25 juillet 2026.

Roblox Creator Hub, Type checking, modes --!strict, annotations, raffinements et types de tables, consulté le 25 juillet 2026.

Roblox Creator Hub, Scene Analysis, mémoire Luau par script, Instances non parentées et GetScriptMemoryAsync, consulté le 25 juillet 2026.

RN 006 · Carnet d’ingénierieUne frame saine est un budget respecté, pas une moyenne rassurante.

Toutes les publications
Index local, aucune donnée envoyée↑↓ parcourir · ↵ ouvrir