Cómo diseñamos Swarm, nuestro marco de prueba de carga interno, para hacer frente a los desafíos de las pruebas end-to-end, protocolos personalizados y simulaciones de botes a gran escala en gamedev.
Cómo diseñamos Swarm, nuestro marco de prueba de carga interno, para hacer frente a los desafíos de las pruebas end-to-end, protocolos personalizados y simulaciones de botes a gran escala en gamedev.
Hoy en día, es muy difícil imaginar un producto de software sinAlgunosniveles de prueba. las pruebas de unidades son una forma estándar de capturar los errores en pequeñas piezas de código, mientras que las pruebas de fin a fin cubren todo el flujo de trabajo de las aplicaciones. GameDev no es una excepción, pero viene con sus propios desafíos únicos que no siempre se alinean con la forma en que normalmente probamos las aplicaciones web. Este artículo trata del camino que tomamos para satisfacer esas necesidades únicas con nuestra propia herramienta de prueba interna.
Hola, soy Andrey Rakhubov, un ingeniero de software líder en MY.GAMES! En este post, voy a compartir detalles sobre el backend utilizado en nuestro estudio, y nuestro enfoque para la prueba de carga del meta-juego en War Robots: Frontiers.
Una nota sobre nuestra arquitectura
Sin entrar en ningún detalle innecesario, nuestro backend consiste en un conjunto de servicios clásicos y un grupo de nodos interconectados en los que viven los actores.
A pesar de algunas otras deficiencias que pueden existir, este enfoque ha demostrado ser bastante bueno en resolver uno de los problemas más molestos en el desarrollo de juegos: velocidad de iteración.esBueno, tenemos el mismo dicho sobre el GDD (Documento de Diseño de Juegos).
Los actores ayudan de una manera que son muy baratos para implementar y modificar en las primeras etapas del diseño de características y son bastante fáciles de refactor como un servicio separado si la necesidad surge más tarde.
Obviamente, esto complica las pruebas de carga e integración, ya que ya no tiene una separación clara de servicios con API pequeñas y bien definidas. En cambio, hay un grupo de nodos heterogéneos, donde hay conexiones mucho menos obvias o controlables entre los actores.
La prueba
Como puede adivinar, la prueba de unidades para actores y servicios (y la prueba de carga/integración para servicios) no es diferente de lo que encontrarías en cualquier otro tipo de software.
- Actores de las pruebas de carga e integración
- Pruebas de end-to-end y carga backend
No vamos a mirar a los actores de prueba en detalle aquí ya que no es específico para GameDev, sino que se refiere al modelo del actor en sí.El enfoque habitual es tener un clúster especial de nodo único que es adecuado para ser conectado en la memoria dentro de una sola prueba y que también es capaz de bloquear todas las solicitudes salientes y invocar la API de los actores.
Dicho esto, las cosas comienzan a ser más interesantes cuando llegamos a la carga o a las pruebas de fin a fin, y aquí es donde comienza nuestra historia.
Así, en nuestro caso, la aplicación cliente es el juego mismo.El juego utiliza Unreal Engine, por lo que el código está escrito en C++, y en el servidor utilizamos C#.Los jugadores interactúan con los elementos de interfaz de usuario en el juego, produciendo solicitudes al backend (o solicitudes se hacen indirectamente).
En este punto, la gran mayoría de los frameworks simplemente dejan de funcionar para nosotros, y cualquier tipo de kits similares al selenio que consideran aplicaciones cliente como un navegador están fuera de alcance.
El siguiente problema es que utilizamos un protocolo de comunicación personalizado entre el cliente y el backend.Esta parte realmente merece un artículo separado, pero destacaré los conceptos clave:
- La comunicación se realiza a través de una conexión WebSocket
- Es esquema-primero; usamos Protobuf para definir la estructura de mensajes y servicios
- Los mensajes de WebSocket son mensajes Protobuf envueltos en un recipiente con metadatos que imitan alguna información necesaria relacionada con gRPC, como URL y encabezados.
Por lo tanto, cualquier herramienta que no permita definir protocolos personalizados no es adecuada para la tarea.
La herramienta de carga para probarlos todos
Al mismo tiempo, queríamos tener una única herramienta que pudiera escribir tanto las pruebas de carga REST/gRPC como las pruebas end-to-end con nuestro protocolo personalizado.Después de considerar todos los requisitos que se discutieron en Testing y algunas discusiones preliminares, nos quedaron con estos candidatos:
Cada uno de ellos tenía sus pros y contras, pero había varias cosas que ninguno de ellos podía resolver sin grandes (a veces internos) cambios:
- En primer lugar, hubo una necesidad relacionada con la comunicación interbot para fines de sincronización, como se discutió anteriormente.
- En segundo lugar, los clientes son bastante reactivos y su estado podría cambiar sin una acción explícita del escenario de prueba; esto lleva a una sincronización adicional y la necesidad de saltar a través de un montón de saltos en su código.
- Por último, pero no menos importante, esas herramientas se centraron demasiado en las pruebas de rendimiento, ofreciendo numerosas características para aumentar gradualmente la carga o especificar intervalos de tiempo, sin embargo, carecían de la capacidad de crear escenarios de rama complejos de una manera sencilla.
Era hora de aceptar el hecho de que realmente necesitábamos una herramienta especializada.Swarmque había nacido.
Swarm
En un nivel alto, la tarea de Swarm es iniciar un montón de actores que producirán carga en el servidor. Estos actores se llaman bots, y pueden simular el comportamiento del cliente o el comportamiento del servidor dedicado.
Más formalmente, aquí está una lista de requisitos para la herramienta:
- La respuesta a las actualizaciones de estado debe ser fácil
- La competencia no debe ser un problema
- El código debe ser automáticamente instrumentable
- La comunicación bot-to-bot debe ser fácil
- Varias instancias deben ser unificables para crear suficiente carga
- La herramienta debe ser ligera y capaz de crear un estrés decente en el propio backend
Como bonus, también he añadido algunos puntos adicionales:
- Tanto el rendimiento como los escenarios de pruebas de fin a fin deben ser posibles
- La herramienta debe ser agnóstica al transporte; deberíamos poder conectarlo a cualquier otro transporte posible si es necesario.
- La herramienta cuenta con un estilo de código imperativo, ya que personalmente tengo la firme opinión de que un estilo declarativo no es adecuado para escenarios complejos con decisiones condicionales
- Los bots deben poder existir separados de la herramienta de prueba, lo que significa que no deben existir dependencias duras.
Nuestro objetivo
Imaginemos el código que nos gustaría escribir; consideraremos al bot como una muñeca, y no puede hacer las cosas por sí solo, sólo puede mantener invariantes, mientras que Scenario es el muñeco que tira las cuerdas.
public class ExampleScenario : ScenarioBase
{
/* ... */
public override async Task Run(ISwarmAgent swarm)
{
// spawn bots and connect to backend
var leader = SpawnClient();
var follower = SpawnClient();
await leader.Login();
await follower.Login();
// expect incoming InviteAddedEvent
var followerWaitingForInvite = follower.Group.Subscription
.ListenOnceUntil(GroupInviteAdded)
.ThrowIfTimeout();
// leader sends invite and followers waits for it
await leader.Group.SendGroupInvite(follower.PlayerId);
await followerWaitingForInvite;
Assert.That(follower.Group.State.IncomingInvites.Count, Is.EqualTo(1));
var invite = follower.Group.State.IncomingInvites[0];
// now vice versa, the leader waits for an event...
var leaderWaitingForAccept = leader.Group.Subscription
.ListenOnceUntil(InviteAcceptedBy(follower.PlayerId))
.ThrowIfTimeout();
// ... and follower accept invite, thus producing the event
await follower.Group.AcceptGroupInvite(invite.Id);
await leaderWaitingForAccept;
Assert.That(follower.Group.State.GroupId, Is.EqualTo(leader.Group.State.GroupId));
PlayerId[] expectedPlayers = [leader.PlayerId, follower.PlayerId];
Assert.That(leader.Group.State.Players, Is.EquivalentTo(expectedPlayers));
Assert.That(follower.Group.State.Players, Is.EquivalentTo(expectedPlayers));
}
}
Steaming
Backend impulsa muchas actualizaciones al cliente que pueden ocurrir esporádicamente; en el ejemplo anterior, uno de estos eventos esGroupInviteAddedEvent
El bot debe ser capaz de reaccionar a estos eventos internamente y darle la oportunidad de observarlos desde el código externo.
public Task SubscribeToGroupState()
{
Subscription.Listen(OnGroupStateUpdate);
Subscription.Start(api.GroupServiceClient.SubscribeToGroupStateUpdates, new SubscribeToGroupStateUpdatesRequest());
return Task.CompletedTask;
}
Aunque el código es bastante sencillo (y como puede haber adivinadoOnGroupStateUpdate
el manipulador es solo un caso de cambio largo), aquí sucede mucho.
StreamSubscription
Es la propia aIObservable<ObservedEvent<TStreamMessage>>
y proporciona extensiones útiles, tiene un ciclo de vida dependiente y está cubierto con métricas.
Otra ventaja: no requiere sincronización explícita para leer o modificar el estado, independientemente de dónde se implemente el manipulador.
case GroupUpdateEventType.GroupInviteAddedEvent:
State.IncomingInvites.Add(ev.GroupInviteAddedEvent.GroupId, ev.GroupInviteAddedEvent.Invite);
break;
La competencia no debe ser un problema
La idea es simple: nunca ejecutar código que pertenezca simultáneamente a un escenario o a un bot engendrado en ese escenario, y además, el código interno de un bot/modulo no debe ejecutarse simultáneamente con otras partes de un bot.
Esto es similar a un bloqueo de lectura/escritura, donde se lee el código de escenario (acceso compartido) y el código de bot se escribe (acceso exclusivo).
Planificadores de tareas y el contexto de sincronización
Los dos mecanismos mencionados en este encabezado son ahora partes muy poderosas del código asíncope en C#. Si alguna vez ha desarrollado una aplicación WPF, probablemente sepa que es unaDispatcherSynchronizationContext
que es responsable de devolver elegantemente sus llamadas async de vuelta al hilo de la interfaz de usuario.
En nuestro escenario, no nos importa la afinidad del hilo, y en cambio nos importa más el orden de ejecución de las tareas en un escenario.
Antes de presionar para escribir código de bajo nivel, echemos un vistazo a una clase no ampliamente conocida llamadaConcurrentExclusiveSchedulerPair
Desde la
Proporciona planificadores de tareas que se coordinan para ejecutar tareas, al tiempo que garantiza que las tareas simultáneas puedan ejecutarse simultáneamente y las tareas exclusivas nunca.
Proporciona planificadores de tareas que se coordinan para ejecutar tareas, al tiempo que garantiza que las tareas simultáneas puedan ejecutarse simultáneamente y las tareas exclusivas nunca.
Esto parece exactamente lo que queremos! Ahora tenemos que asegurarnos de que todo el código dentro del escenario se ejecuta enConcurrentScheduler
mientras el código del bot se ejecuta enExclusiveScheduler
.
Una opción es pasarlo explícitamente como un parámetro, lo que se hace cuando se inicia un escenario:
public Task LaunchScenarioAsyncThread(SwarmAgent swarm, Launch launch)
{
return Task.Factory.StartNew(
() =>
{
/* <some observability and context preparations> */
return RunScenarioInstance(Scenario, swarm, LaunchOptions, ScenarioActivity, launch);
},
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
Scenario.BotScenarioScheduler.ScenarioScheduler).Unwrap();
}
ElRunScenarioInstance
Los métodos, a su vez, invocan laSetUp
yRun
los métodos en el escenario, por lo que se ejecutan en un programador simultáneo (ScenarioScheduler
Es una parte competitiva deConcurrentExclusiveSchedulerPair
) de
Ahora, cuando estamos dentro del código de escenario y hacemos esto...
public Task LaunchScenarioAsyncThread(SwarmAgent swarm, Launch launch)
{
return Task.Factory.StartNew(
() =>
{
/* <some observability and context preparations> */
return RunScenarioInstance(Scenario, swarm, LaunchOptions, ScenarioActivity, launch);
},
CancellationToken.None,
TaskCreationOptions.DenyChildAttach,
Scenario.BotScenarioScheduler.ScenarioScheduler).Unwrap();
}
... la máquina de estado async hace su trabajo por nosotros manteniendo un cronista para nuestras tareas.
AhoraSendGroupInvite
también se ejecuta en el programador simultáneo, por lo que hacemos el mismo truco para él, también:
public Task<SendGroupInviteResponse> SendGroupInvite(PlayerId inviteePlayerId)
{
return Runtime.Do(async () =>
{
var result = await api.GroupServiceClient.SendGroupInviteAsync(/* ... */);
if (!result.HasError)
{
State.OutgoingInvites.Add(inviteePlayerId);
}
return result;
});
}
La abstracción de tiempo de ejecución envuelve la programación de la misma manera que antes, llamandoTask.Factory.StartNew
con el horario adecuado.
Generación de Código
Bueno, ahora necesitamos envolver manualmente todo dentro de laDo
llamada; y mientras esto resuelve el problema, es propenso al error, fácil de olvidar, y en general hablando, bueno, parece extraño.
Veamos otra vez esta parte del código:
await leader.Group.SendGroupInvite(follower.PlayerId);
En este caso, nuestro bot tiene unGroup
de propiedad.Group
es un módulo que existe sólo para dividir el código en clases separadas y evitar hinchar elBot
de clase .
public class BotClient : BotClientBase
{
public GroupBotModule Group { get; }
/* ... */
}
Supongamos que un módulo debe tener una interfaz:
public class BotClient : BotClientBase
{
public IGroupBotModule Group { get; }
/* ... */
}
public interface IGroupBotModule : IBotModule, IAsyncDisposable
{
GroupBotState State { get; }
Task<SendGroupInviteResponse> SendGroupInvite(PlayerId toPlayerId);
/* ... */
}
public class GroupBotModule :
BotClientModuleBase<GroupBotModule>,
IGroupBotModule
{
/* ... */
public async Task<SendGroupInviteResponse> SendGroupInvite(PlayerId inviteePlayerId)
{
// no wrapping with `Runtime.Do` here
var result = await api.GroupServiceClient.SendGroupInviteAsync(new() /* ... */);
if (!result.HasError)
{
State.OutgoingInvites.Add(new GroupBotState.Invite(inviteePlayerId, /* ... */));
}
return result;
}
|
Y ahora sólo funciona! No más envueltas y código feo, sólo un requisito simple (que es bastante común para los desarrolladores) para crear una interfaz para cada módulo.
La magia ocurre dentro del generador de fuente que crea una clase de proxy para cada interfaz de IBotModule y envuelve cada función enRuntime.Do
Llamar a:
public const string MethodProxy =
@"
public async $return_type$ $method_name$($method_params$)
{
$return$await runtime.Do(() => implementation.$method_name$($method_args$));
}
";
Instrumentos
El siguiente paso es instrumentar el código para recopilar métricas y rastros. En primer lugar, cada llamada de la API debe ser observada. Esta parte es muy sencilla, ya que nuestro transporte efectivo pretende ser un canal gRPC, por lo que solo usamos un interceptor correctamente escrito...
callInvoker = channel
.Intercept(new PerformanceInterceptor());
¿DóndeCallInvoker
es una abstracción gRPC de la invocación RPC del lado del cliente.
A continuación, sería bueno ampliar algunas partes de código para medir el rendimiento.Por esa razón, cada módulo inyectaIInstrumentationFactory
con la siguiente interfaz:
public interface IInstrumentationFactory
{
public IInstrumentationScope CreateScope(
string? name = null,
IInstrumentationContext? actor = null,
[CallerMemberName] string memberName = "",
[CallerFilePath] string sourceFilePath = "",
[CallerLineNumber] int sourceLineNumber = 0);
}
Ahora puedes seleccionar las partes que te interesen:
public Task AcceptGroupInvite(GroupInvideId inviteId)
{
using (instrumentationFactory.CreateScope())
{
var result = await api.GroupServiceClient.AcceptGroupInviteAsync(request);
}
}
Aunque todavía puede usar esta característica para crear sub-dominios, cada método de proxy del módulo define automáticamente un alcance:
public const string MethodProxy =
@"
public async $return_type$ $method_name$($method_params$)
{
using(instrumentationFactory.CreateScope(/* related args> */))
{
$return$await runtime.Do(() => implementation.$method_name$($method_args$));
}
}
";
El alcance de la instrumentación registra el tiempo de ejecución, las excepciones, crea rastros distribuidos, puede escribir registros de depuración y hace muchas otras cosas configurables.
Comunicación bot-to-bot
El ejemplo en la sección “Nuestro objetivo” no muestra realmente ninguna comunicación.PlayerId
Mientras que este estilo de escribir escenarios a menudo es fácil y sencillo, hay casos más complejos donde este enfoque simplemente no funciona.
Inicialmente planeé implementar algún tipo de patrón de tablero negro con un almacenamiento de valor clave compartido para la comunicación (como Redis), pero después de ejecutar algunas pruebas de pruebas de escenarios de concepto, resultó que la cantidad de trabajo se podría reducir enormemente a dos conceptos más simples: una cola y un selector.
Vuelos - Tickets
En el mundo real, los jugadores se comunican entre sí de alguna manera – escriben mensajes directos o utilizan el chat de voz. Con los bots, no necesitamos simular esa granularidad, y solo necesitamos comunicar intenciones.alguien, invítame al grupo por favor”. aquí es donde entran en juego las entradas:
public interface ISwarmTickets
{
Task PlaceTicket(SwarmTicketBase ticket);
Task<SwarmTicketBase?> TryGetTicket(Type ticketType, TimeSpan timeout);
}
El bot puede colocar un billete, y luego otro bot puede recuperar ese billete.ISwarmTickets
es nada más que un corredor de mensajes con una cola porticketType
(En realidad esligeramentemás que eso, ya que hay opciones adicionales para evitar que los bots recuperen sus propios boletos, así como otros ajustes menores).
Con esta interfaz, finalmente podemos dividir el escenario de ejemplo en dos escenarios independientes. (aquí, todo el código adicional se elimina para ilustrar la idea de base):
private async Task RunLeaderRole(ISwarmAgent swarm)
{
var ticket = await swarm.Blackboard.Tickets
.TryGetTicket<BotWantsGroupTicket>(TimeSpan.FromSeconds(5))
.ThrowIfTicketIsNull();
await bot.Group.SendGroupInvite(ticket.Owner);
await bot.Group.Subscription.ListenOnceUntil(
GotInviteAcceptedEvent,
TimeSpan.FromSeconds(5))
.ThrowIfTimeout();
}
private async Task RunFollowerRole(ISwarmAgent swarm)
{
var waitingForInvite = bot.Group.Subscription.ListenOnceUntil(
GotInviteAddedEvent,
TimeSpan.FromSeconds(5))
.ThrowIfTimeout();
await swarm.Blackboard.Tickets.PlaceTicket(new BotWantsGroupTicket(bot.PlayerId));
await waitingForInvite;
await bot.Group.AcceptGroupInvite(bot.Group.State.IncomingInvites[0].Id);
}
Selector
Tenemos dos comportamientos diferentes, uno para el líder, uno para el seguidor. Por supuesto, pueden ser divididos en dos escenarios diferentes y lanzados en paralelo. A veces, esta es la mejor manera de hacerlo, otras veces puede necesitar una configuración dinámica de tamaños de grupo (varios seguidores por líder único) o cualquier otra situación para distribuir / seleccionar diferentes datos / roles.
public override async Task Run(ISwarmAgent swarm)
{
var roleAction = await swarm.Blackboard
.RoundRobinRole(
"leader or follower",
Enumerable.Repeat(RunFollowerRole, config.GroupSize - 1).Union([RunLeaderRole]));
await roleAction(swarm);
}
Aquí es,RoundRobinRole
es sólo un envuelto fantástico alrededor de un contador compartido y una operación modulo para seleccionar el elemento correcto de la lista.
Clúster de Swarms
Ahora, con toda la comunicación oculta detrás de la cola y los contadores compartidos, se vuelve trivial introducir un nodo de orquestación o utilizar algunos almacenes MQ y KV existentes.
Hecho divertido: nunca terminamos la implementación de esta característica.Cuando QA obtuvo sus manos en una implementación de nodo único SwarmAgent
, inmediatamente comenzaron a desplegar simplemente múltiples instancias de agentes independientes con el fin de aumentar la carga.
Desempeño de instancia única
¿Qué pasa con el rendimiento? ¿Cuánto se pierde dentro de todos esos envases y sincronizaciones implícitas? no te sobrecargaré con toda la variedad de pruebas realizadas, solo las dos más significativas que demostraron que el sistema es capaz de crear una carga suficiente.
Instalación de Benchmark:
BenchmarkDotNet v0.14.0, Windows 10 (10.0.19045.4651/22H2/2022Actualización)
Intel Core i7-10875H CPU 2.30GHz, 1 CPU, 16 logical and 8 physical cores
Desarrollo de .NET SDK 9.0.203
[Host] : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX2
.NET 9.0 : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX2
private async Task SimpleWorker()
{
var tasks = new List<Task>();
for (var i = 0; i < Parallelism; ++i)
{
var index = i;
tasks.Add(Task.Run(async () =>
{
for (var j = 0; j < Iterations; ++j)
{
await LoadMethod(index);
}
}));
}
await Task.WhenAll(tasks);
}
private async Task SchedulerWorker()
{
// scenarios are prepared in GlobalSetup
await Task.WhenAll(scenarios.Select(LaunchScenarioAsyncThread));
}
public class TestScenario : ScenarioBase
{
public override async Task Run()
{
for (var iteration = 0; iteration < iterations; ++iteration)
{
await botClient.Do(BotLoadMethod);
}
}
}
En primer lugar, echemos un vistazo a la superposición casi pura de la programación frente a la generación de tareas sencilla.
En este caso, ambos métodos de carga son los siguientes:
private ValueTask CounterLoadMethod(int i)
{
Interlocked.Increment(ref StaticControl.Counter);
return ValueTask.CompletedTask;
}
Resultados para iteraciones = 10:
WorkerType |
Parallelism |
Mean |
Error |
StdDev |
Gen0 |
Gen1 |
Gen2 |
Allocated |
---|---|---|---|---|---|---|---|---|
Simple |
10 |
3.565us |
0.0450us |
0.0421us |
0.3433 |
- |
- |
2.83KB |
Simple |
100 |
30.281us |
0.2720us |
0.2544us |
3.1128 |
0.061 |
- |
25.67KB |
Simple |
1000 |
250.693us |
2.1626us |
2.4037us |
30.2734 |
5.8594 |
- |
250.67KB |
Scheduler |
10 |
40.629us |
0.7842us |
0.8054us |
8.1787 |
0.1221 |
- |
66.15KB |
Scheduler |
100 |
325.386us |
2.3414us |
2.1901us |
81.0547 |
14.6484 |
- |
662.09KB |
Scheduler |
1000 |
4,685.812us |
24.7917us |
21.9772us |
812.5 |
375 |
- |
6617.59KB |
sencillo
10
5656 de
0.0450 en España
0 0 421
0.3433
-
-
2.83 kB
sencillo
100
30281 años
0.2720 años
02544
3.1128
0.061
-
57.67 kB
sencillo
1000
250 693
2.1626 años
2.4037años
30.2734
5.8594
-
250.67 kB
Calendario
10
40629 años
07842
08054
8.1787
0.1221
-
66.15 kB
Calendario
100
325.386años
2.3414años
2 de 1901
81.0547
14.6484
-
62.09 kB
Calendario
1000
4 685 812
24 717
219772
812.5
375
-
6617.59 kB
Antes de realizar realmente la prueba, me esperaba un rendimiento mucho peor, pero los resultados realmente me sorprendieron.Sólo pienso en lo que sucedió bajo el capó y los beneficios que recibimos sólo por ~4us por instancia paralela. De todos modos, esto es solo una ilustración de la superioridad; estamos interesados en valores de referencia mucho más prácticos.
¿Qué podría ser un escenario de prueba de peores casos realista? Bueno, ¿qué pasa con una función que no hace nada más que una llamada de API? Por un lado, casi todos los métodos del bot hacen eso, pero por otro lado, si no hay nada más que llamadas de API, entonces todos los esfuerzos de sincronización se desperdician, ¿verdad?
El método de carga sólo llamaríaPingAsync
Por sencillez, los clientes de RPC se almacenan fuera de los bots.
private async ValueTask PingLoadMethod(int i)
{
await clients[i].PingAsync(new PingRequest());
}
Aquí están los resultados, de nuevo 10 iteraciones (el servidor grpc está en la red local):
WorkerType |
Parallelism |
Mean |
Error |
StdDev |
Gen0 |
Gen1 |
Allocated |
---|---|---|---|---|---|---|---|
Simple |
100 |
94.45 ms |
1.804 ms |
2.148 ms |
600 |
200 |
6.14 MB |
Simple |
1000 |
596.69 ms |
15.592 ms |
45.730 ms |
9000 |
7000 |
76.77 MB |
Scheduler |
100 |
95.48 ms |
1.547 ms |
1.292 ms |
833.3333 |
333.3333 |
6.85 MB |
Scheduler |
1000 |
625.52 ms |
14.697 ms |
42.405 ms |
8000 |
7000 |
68.57 MB |
sencillo
100
94.45 ms
804 ms
284 ms
600
200
6.4 MB
sencillo
1000
69.69 ms
15 592 ms
45 730 ms
9000
7000
67.77 MB
Calendario
100
95.48 ms
1547 ms
1 292 ms
833.3333
333.3333
685 MB
Calendario
1000
625,52 ms
14 697 ms
24405 ms
8000
7000
68,57 MB
Como se esperaba, el impacto del rendimiento en los escenarios de trabajo reales es insignificante.
Análisis
Por supuesto, hay registros de backend, métricas y rastros que proporcionan una buena visión de lo que sucede durante la carga. pero Swarm va un paso más allá, escribiendo sus propios datos -a veces conectados al backend- complementándolos.
Aquí está un ejemplo de varias métricas en una prueba de fracaso, con un número cada vez mayor de actores.La prueba fracasó debido a múltiples violaciones de los SLA (ver las temporadas en las llamadas de API), y tenemos toda la instrumentación necesaria para observar lo que estaba sucediendo desde la perspectiva del cliente.
Por lo general, paramos la prueba completamente después de un cierto número de errores, por lo que las líneas de llamada de la API terminan abruptamente.
Por supuesto, los rastros son necesarios para una buena observabilidad. Para las pruebas de Swarm, los rastros del cliente están vinculados con rastros de backend en un solo árbol.connectionIdD/botId
Lo que ayuda a la depilación.
Nota: DevServer es una compilación monolítica conveniente de servicios de backend todo en uno que se lanzarán en los PC de los desarrolladores, y este es un ejemplo especialmente diseñado para reducir la cantidad de detalles en la pantalla.
Swarm bots escriben otro tipo de rastro: imitan los rastros utilizados en el análisis de rendimiento. Con la ayuda de las herramientas existentes, esos rastros se pueden ver y analizar utilizando capacidades integradas como PerfettoSQL.
Lo que acabamos con el final
Ahora tenemos un SDK en el que están construidos tanto los bots de GameClient como los bots de servidor dedicado. Estos bots ahora soportan la mayoría de la lógica del juego: incluyen los subsistemas de amigos, los grupos de apoyo y el matchmaking. bots dedicados pueden simular encuentros donde los jugadores ganan recompensas, tareas de progreso y mucho más.
Ser capaz de escribir código simple hizo posible crear nuevos módulos y escenarios de prueba simples por el equipo de QA. Hay una idea sobre los escenarios de FlowGraph (programar visualmente), pero esto sigue siendo sólo una idea en este momento.
Las pruebas de rendimiento son casi continuas -lo digo casi porque uno todavía tiene que comenzarlas manualmente.
Swarm no solo ayuda con las pruebas, sino que se utiliza comúnmente para reproducir y corregir los errores en la lógica del juego, especialmente cuando esto es difícil de hacer manualmente, como cuando se necesita varios clientes de juego para realizar una secuencia especial de acciones.
Para resumir, estamos extremadamente satisfechos con nuestro resultado y definitivamente no lamentamos el esfuerzo gastado en desarrollar nuestro propio sistema de pruebas.
Espero que te haya gustado este artículo, ha sido un desafío contarle sobre todos los matices del desarrollo de Swarm sin hacer que este texto se hinche excesivamente.Estoy seguro de que podría haber excluido algunos detalles importantes en el proceso de equilibrar el tamaño del texto e información, pero me gustaría proporcionar más contexto si tiene alguna pregunta!