227 lecturas

Ninguna herramienta podría manejar nuestro marco de prueba de carga para juegos, así que construimos Swarm

por Andrew Rakhubov19m2025/05/20
Read on Terminal Reader

Demasiado Largo; Para Leer

Necesitábamos una herramienta de prueba de carga especializada que pudiera manejar protocolos WebSocket+Protobuf personalizados, sincronización inter-bot y simulaciones a gran escala. Las herramientas existentes cayeron en escasez, por lo que crearon Swarm: un marco C# interno en el que los “bots” (actores) ejecutan escenarios bajo planificadores coordinados, soportan la comunicación bot-to-bot a través de boletos y selectores, y automatizan todo el código para métricas y rastros. El diseño de Swarm prioriza la facilidad de reaccionar a las actualizaciones de estado, el control de concurrencia a través de ConcurrentExclusiveSchedulerPair, la generación de código a través de generadores de fuentes y la plena observabilidad, proporcionando pruebas de rendimiento
featured image - Ninguna herramienta podría manejar nuestro marco de prueba de carga para juegos, así que construimos Swarm
Andrew Rakhubov HackerNoon profile picture
0-item
1-item

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.

backend architecture overview

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:


k6locaciónBomba

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:


  1. La respuesta a las actualizaciones de estado debe ser fácil
  2. La competencia no debe ser un problema
  3. El código debe ser automáticamente instrumentable
  4. La comunicación bot-to-bot debe ser fácil
  5. Varias instancias deben ser unificables para crear suficiente carga
  6. 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:

  1. Tanto el rendimiento como los escenarios de pruebas de fin a fin deben ser posibles
  2. La herramienta debe ser agnóstica al transporte; deberíamos poder conectarlo a cualquier otro transporte posible si es necesario.
  3. 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
  4. 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 esGroupInviteAddedEventEl 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 adivinadoOnGroupStateUpdateel manipulador es solo un caso de cambio largo), aquí sucede mucho.


StreamSubscriptionEs 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 unaDispatcherSynchronizationContextque 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 llamadaConcurrentExclusiveSchedulerPairDesde ladocs:

Docs


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 enConcurrentSchedulermientras 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();
}


ElRunScenarioInstanceLos métodos, a su vez, invocan laSetUpyRunlos métodos en el escenario, por lo que se ejecutan en un programador simultáneo (ScenarioSchedulerEs 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.

AhoraSendGroupInvitetambié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.StartNewcon el horario adecuado.

Generación de Código

Bueno, ahora necesitamos envolver manualmente todo dentro de laDollamada; 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 unGroupde propiedad.Groupes un módulo que existe sólo para dividir el código en clases separadas y evitar hinchar elBotde 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.DoLlamar 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óndeCallInvokeres 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 inyectaIInstrumentationFactorycon 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.PlayerIdMientras 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.ISwarmTicketses 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,RoundRobinRolees 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íaPingAsyncPor 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.


Grafana dashboard panels showing failed test


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/botIdLo que ayuda a la depilación.


Distributed tracing connecting Swarm scenario with backend


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.


Adapting perf logs to show bot execution flow

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!

Trending Topics

blockchaincryptocurrencyhackernoon-top-storyprogrammingsoftware-developmenttechnologystartuphackernoon-booksBitcoinbooks