우리는 Swarm, 내부 로드 테스트 프레임워크를 설계하여 gamedev에서 end-to-end 테스트, 사용자 지정 프로토콜 및 대규모 봇 시뮬레이션의 도전을 처리하는 방법.
우리는 Swarm, 내부 로드 테스트 프레임워크를 설계하여 gamedev에서 end-to-end 테스트, 사용자 지정 프로토콜 및 대규모 봇 시뮬레이션의 도전을 처리하는 방법.
요즘은 소프트웨어 제품이 없이는 상상하기가 정말 어렵습니다.일부단위 테스트는 코드의 작은 조각에서 버그를 잡는 표준 방법이며, 끝에서 끝까지 테스트는 전체 응용 프로그램 작업 흐름을 다루고 있습니다.GameDev는 예외는 아니지만 항상 우리가 일반적으로 웹 응용 프로그램을 테스트하는 방식과 일치하지 않는 독특한 도전을 제공합니다.이 기사는 우리가 내부 테스트 도구로 이러한 독특한 요구를 충족시키기위한 경로에 관한 것입니다.
안녕하세요, 나는 Andrey Rakhubov, MY.GAMES의 리드 소프트웨어 엔지니어입니다!이 포스트에서, 나는 우리의 스튜디오에서 사용 된 백엔드에 대한 세부 사항과 War Robots : Frontiers의 메타 게임을 테스트하는 우리의 접근 방식을 공유 할 것입니다.
우리의 아키텍처에 대한 한마디
불필요한 세부 사항에 들어가지 않고, 우리의 백엔드는 클래식 서비스의 집합과 배우들이 살고있는 상호 연결된 노드의 클러스터로 구성됩니다.
존재할 수있는 몇 가지 다른 결함에도 불구하고,이 접근 방식은 게임 개발에서 가장 불편한 문제 중 하나를 해결하는 데 꽤 좋은 것으로 입증되었습니다 : 반복 속도.당신은 Rust-lang 개발의 초기 일 동안 당신이 읽고있을 때 문서가 바뀔 것이라는 농담을 기억할 수 있습니다.이글쎄, 우리는 GDD (게임 디자인 문서)에 대해 똑같은 말을합니다.
배우들은 기능 설계의 초기 단계에서 구현하고 수정하는 것이 매우 저렴한 방식으로 도움을 주며 나중에 필요가있을 경우 별도의 서비스로 refactor를 사용하는 것이 매우 간단합니다.
분명히 이것은 부하 및 통합 테스트를 복잡하게 만듭니다.You no longer have a clear separation of services with small, well-defined APIs.Instead, there is a cluster of heterogeneous nodes, where there are much less obvious or controllable connections between the actors.
테스트
당신이 추측할 수 있듯이, 배우와 서비스에 대한 단위 테스트 (및 서비스에 대한 로드 / 통합 테스트)는 다른 유형의 소프트웨어에서 찾을 수있는 것과 다르지 않습니다.
- Load and Integration 테스트 배우
- End-to-end 및 load backend 테스트
우리는 GameDev에 특정하지 않기 때문에 여기에 자세히 연기자를 테스트하지 않을 것입니다, 그러나 연기자 모델 자체와 관련이 있습니다.일반적인 접근법은 하나의 테스트 내에서 메모리에 연결 될 수있는 특별한 단일 노드 클러스터를 갖는 것이며, 모든 출력 요청을 방해하고 연기자 API를 호출 할 수 있습니다.
즉, 우리가 로드 또는 끝에서 끝까지 테스트 할 때 모든 것이 더 흥미로워지기 시작합니다 - 이것이 우리의 이야기가 시작되는 곳입니다.
따라서 우리의 경우 클라이언트 응용 프로그램은 게임 자체입니다.이 게임은 Unreal Engine을 사용하므로 코드는 C++로 작성되며 서버에서는 C#를 사용합니다.플레이어는 게임의 UI 요소와 상호 작용하여 백엔드에 요청을 생성합니다 (또는 요청은 간접적으로 수행됩니다).
이 시점에서 프레임 워크의 대다수는 우리를 위해 작동하는 것을 멈추고, 브라우저로 클라이언트 응용 프로그램을 고려하는 모든 종류의 셀레늄 유사 키트는 범위를 벗어납니다.
다음 문제는 클라이언트와 백엔드 사이에 사용자 지정 통신 프로토콜을 사용하는 것입니다.This part really deserves a separate article altogether, but I will highlight the key concepts:
- 통신은 WebSocket 연결을 통해 이루어집니다.
- Schema-first; 우리는 Protobuf를 사용하여 메시지 및 서비스 구조를 정의합니다.
- WebSocket 메시지는 URL 및 헤더와 같은 필요한 gRPC 관련 정보를 모방하는 메타데이터가 포함된 컨테이너에 포장된 Protobuf 메시지입니다.
따라서 사용자 지정 프로토콜을 정의할 수 없는 도구는 해당 작업에 적합하지 않습니다.
로드 도구는 그들 모두를 테스트
동시에, 우리는 REST/gRPC 로드 테스트와 우리의 사용자 지정 프로토콜을 사용하여 끝까지 테스트를 모두 작성할 수있는 단일 도구를 원했습니다.Testing에서 논의 된 모든 요구 사항과 일부 예비 토론을 고려한 후, 우리는 다음 후보자로 남겨졌습니다.
그들 각각은 그들의 장점과 단점이 있었지만, 그들 중 하나는 거대한 (때로는 내부) 변화없이 해결할 수없는 몇 가지가있었습니다 :
- 첫째, 앞서 언급한 바와 같이 동기화 목적으로 봇 간 통신과 관련된 필요성이 있었다.
- 둘째, 클라이언트는 매우 반응적이며 그 상태는 테스트 시나리오에서 명시적인 조치없이 변경될 수 있습니다.This leads to extra synchronization and the need to jump through a lot of hoops in your code.
- 마지막으로, 그 도구들은 성능 테스트에 너무 좁게 초점을 맞추었으며, 점차적으로 부하를 증가시키거나 시간 간격을 지정할 수있는 수많은 기능을 제공했지만 복잡한 분업 시나리오를 간단하게 만들 수있는 능력이 부족했습니다.
우리가 정말로 전문 도구가 필요하다는 사실을 받아들이는 시간이었습니다.스웨덴태어났을 텐데
스웨덴
높은 수준에서, Swarm의 임무는 서버에 부하를 생성 할 수있는 많은 배우를 시작하는 것입니다.이 배우들은 봇이라고 불리며, 그들은 클라이언트 행동이나 전용 서버 행동을 시뮬레이션 할 수 있습니다.
더 공식적으로, 여기에 도구에 대한 요구 사항 목록이 있습니다 :
- 상태 업데이트에 대한 반응은 쉽습니다.Reaction to state updates should be easy
- 경쟁은 문제가 되지 않아야 한다.
- 코드는 자동으로 도구가 되어야 한다.
- Bot-to-bot 커뮤니케이션은 간단해야 합니다.
- 충분한 로드를 생성하기 위해 여러 인스턴스가 합쳐야 합니다.Multiple instances should be joined in order to create sufficient load.
- 도구는 가볍고 백엔드 자체에 적절한 스트레스를 창출 할 수 있어야합니다.
보너스로서, 나는 또한 몇 가지 추가 포인트를 추가했습니다 :
- 성능과 end-to-end 시험 시나리오 모두 가능해야 한다.
- 도구는 운송-아그네틱해야합니다; 우리는 필요하다면 다른 가능한 운송에 연결할 수 있어야합니다.
- 이 도구는 필수 코드 스타일을 특징으로 하는데, 개인적으로는 선언 스타일이 조건적 결정이 있는 복잡한 시나리오에 적합하지 않는다는 확고한 의견을 가지고 있기 때문이다.
- 봇은 테스트 도구와 별도로 존재할 수 있어야 하며, 이는 단단한 의존성이 없어야 한다는 것을 의미한다.
우리의 목표
우리가 쓰고 싶은 코드를 상상해보자; 우리는 봇을 인형으로 생각할 것이고, 그것은 스스로 일을 할 수 없으며, 불변을 유지할 수 있지만, 시나리오는 줄을 끌어내는 인형입니다.
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));
}
}
스테이밍
백엔드는 클라이언트에 수많은 업데이트를 밀어 넣으며, 이는 우연히 발생할 수 있다; 위의 예제에서, 그러한 이벤트 중 하나는GroupInviteAddedEvent
봇은 이들 이벤트에 내부적으로 반응할 수 있어야 하며, 외부 코드에서 그들을 관찰할 수 있는 기회를 제공해야 한다.
public Task SubscribeToGroupState()
{
Subscription.Listen(OnGroupStateUpdate);
Subscription.Start(api.GroupServiceClient.SubscribeToGroupStateUpdates, new SubscribeToGroupStateUpdatesRequest());
return Task.CompletedTask;
}
코드가 매우 간단하지만 (그리고 당신이 추측 한 것처럼)OnGroupStateUpdate
트레이너는 오랜 스위치 케이스에 불과합니다), 여기에 많은 일이 일어납니다.
StreamSubscription
그 자체가 AIObservable<ObservedEvent<TStreamMessage>>
그리고 그것은 유용한 확장을 제공하고, 의존적인 수명주기를 가지고 있으며, 메트릭스로 덮여 있습니다.
또 다른 장점은 컨트롤러가 구현되는 곳에 상관없이 상태를 읽거나 수정하기 위해 명시적인 동기화가 필요하지 않다는 것입니다.
case GroupUpdateEventType.GroupInviteAddedEvent:
State.IncomingInvites.Add(ev.GroupInviteAddedEvent.GroupId, ev.GroupInviteAddedEvent.Invite);
break;
경쟁은 문제가 되지 않아야 한다.
위의 코드는 선형, 단일 스레드 코드인 것처럼 쓰여져 있습니다.이 아이디어는 간단합니다 : 시나리오에 속하는 코드 또는 그 시나리오에서 태어난 봇에 동시에 속하는 코드를 실행하지 말고, 또한, 봇/모듈의 내부 코드는 봇의 다른 부분과 동시에 실행해서는 안됩니다.
이것은 읽기 / 쓰기 잠금과 유사하며, 시나리오 코드가 읽혀지며 (공유 액세스) 봇 코드가 쓰여지며 (독점 액세스) 우리의 목표는 이러한 종류의 잠금을 사용하여 달성 할 수 있지만 더 나은 방법이 있습니다.
Task Scheduler 및 Synchronization Context에 대한 리뷰 보기
이 제목에서 언급 한 두 가지 메커니즘은 이제 C#에서 비동기 코드의 매우 강력한 부분입니다.당신이 WPF 응용 프로그램을 개발 한 적이 있다면, 당신은 아마 그것을 알고 있습니다.DispatcherSynchronizationContext
그것은 우아하게 귀하의 async 호출을 UI 스레드로 반환하는 책임이 있습니다.
우리의 시나리오에서 우리는 스레드 친화성에 관심이 없으며, 대신 시나리오에서 작업의 실행 순서에 더 관심이 있습니다.
낮은 수준의 코드를 쓰기 위해 서두르기 전에, 널리 알려지지 않은 클래스를 살펴보자.ConcurrentExclusiveSchedulerPair
. from the
동시에 동시 작업이 동시에 실행될 수 있고 독점 작업이 결코 실행되지 않을 수 있도록 작업을 실행하기 위해 조정하는 작업 스케줄러를 제공합니다.Provides task schedulers that coordinate to execute tasks while ensuring that concurrent tasks can run simultaneously and exclusive tasks never do.
동시에 동시 작업이 동시에 실행될 수 있고 독점 작업이 결코 실행되지 않을 수 있도록 작업을 실행하기 위해 조정하는 작업 스케줄러를 제공합니다.Provides task schedulers that coordinate to execute tasks while ensuring that concurrent tasks can run simultaneously and exclusive tasks never do.
지금 우리는 시나리오 내부의 모든 코드가 실행되는지 확인해야합니다.ConcurrentScheduler
봇의 코드가 실행되는 동안ExclusiveScheduler
.
작업에 대한 스케줄러를 설정하는 방법은 무엇입니까? 하나의 옵션은 스케줄이 시작될 때 수행되는 매개 변수로 명시적으로 전달하는 것입니다.One option is to explicitly pass it as a parameter, which is done when a scenario launches:
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();
}
이RunScenarioInstance
이 방법은, 반대로, 그것을 호출한다.SetUp
그리고Run
시나리오에 대한 방법, 그래서 그들은 동시에 스케줄러에서 실행됩니다 (ScenarioScheduler
그것은 경쟁의 일부입니다ConcurrentExclusiveSchedulerPair
).
이제 우리가 시나리오 코드 안에 있고 우리가 이것을 할 때 ...
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();
}
... async state 기계는 우리의 작업을위한 스케줄을 유지함으로써 우리를 위해 일을합니다.
지금은SendGroupInvite
동시 스케줄러에서도 실행되므로 우리는 또한 동일한 트릭을 수행합니다 :
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;
});
}
Runtime abstraction는 이전과 같은 방식으로 스케줄을 포장하여Task.Factory.StartNew
적절한 스케줄을 가지고
코드 세대
OK, 이제 우리는 내부의 모든 것을 수동으로 포장해야합니다.Do
전화; 그리고 이것이 문제를 해결하지만, 그것은 오류의 경향이 있으며, 쉽게 잊을 수 있으며, 일반적으로 말하면, 이상하게 보입니다.
코드의 이 부분을 다시 살펴보자:
await leader.Group.SendGroupInvite(follower.PlayerId);
여기, 우리의 봇은 aGroup
부동산Group
단지 코드를 별도의 클래스로 나누고 팽창을 피하기 위해 존재하는 모듈입니다.Bot
클래스
public class BotClient : BotClientBase
{
public GroupBotModule Group { get; }
/* ... */
}
모듈이 인터페이스가 있어야 한다고 가정해 보자:
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;
}
|
더 이상 포장 및 못생긴 코드가 아니라 각 모듈에 대한 인터페이스를 만들기위한 간단한 요구 사항 (개발자들에게는 매우 일반적입니다).
마법은 각 IBotModule 인터페이스에 대한 프록시 클래스를 생성하고 각 기능을 포함하는 소스 생성기의 내부에서 발생합니다.Runtime.Do
전화 :
public const string MethodProxy =
@"
public async $return_type$ $method_name$($method_params$)
{
$return$await runtime.Do(() => implementation.$method_name$($method_args$));
}
";
도구화
다음 단계는 지표와 흔적을 수집하기 위해 코드를 기계화하는 것입니다. 첫째, 모든 API 호출이 관찰되어야합니다.이 부분은 우리의 운송이 효과적으로 gRPC 채널 인 척하기 때문에 매우 간단합니다.
callInvoker = channel
.Intercept(new PerformanceInterceptor());
어디서CallInvoker
gRPC는 클라이언트 측 RPC invocation의 abstraction이다.
다음으로, 성능을 측정하기 위해 코드의 일부 부분을 범위화하는 것이 좋을 것입니다.IInstrumentationFactory
다음 인터페이스로 :
public interface IInstrumentationFactory
{
public IInstrumentationScope CreateScope(
string? name = null,
IInstrumentationContext? actor = null,
[CallerMemberName] string memberName = "",
[CallerFilePath] string sourceFilePath = "",
[CallerLineNumber] int sourceLineNumber = 0);
}
이제 당신은 당신이 관심이있는 부분을 포장 할 수 있습니다 :
public Task AcceptGroupInvite(GroupInvideId inviteId)
{
using (instrumentationFactory.CreateScope())
{
var result = await api.GroupServiceClient.AcceptGroupInviteAsync(request);
}
}
이 기능을 사용하여 하위 범위를 만들 수 있지만 각 모듈 프록시 메서드는 범위를 자동으로 정의합니다.While you can still use this feature to create sub-scopes, each module proxy method defines a scope automatically:
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$));
}
}
";
도구 범위는 실행 시간, 예외를 기록하고, 분산된 트랙을 생성하고, 디버그 로그를 작성할 수 있으며, 다른 많은 구성 가능한 작업을 수행합니다.
Bot-to-bot 커뮤니케이션
"우리 목표" 섹션의 예제는 실제로 커뮤니케이션을 보여주지 않습니다.우리는 추종자의 봇을 알고 있습니다.PlayerId
이 스타일의 시나리오 작성은 종종 쉽고 간단하지만,이 접근법이 작동하지 않는 더 복잡한 경우가 있습니다.
처음에는 커뮤니케이션을위한 공유 키 값 저장소 (Redis와 같은)와 같은 블랙보드 패턴을 구현할 계획이었지만, 개념 시나리오 테스트의 증거를 실행한 후 작업량이 두 가지보다 간단한 개념으로 크게 줄어들 수 있다는 것이 밝혀졌습니다.
티켓 - Tickets
실제 세계에서 플레이어는 어떻게 든 서로 의사 소통 - 그들은 직접 메시지를 작성하거나 음성 채팅을 사용합니다.보트와 함께, 우리는 그 정밀성을 시뮬레이션 할 필요가 없습니다, 우리는 단지 의도를 통신 할 필요가 있습니다.누군가그룹에 나를 초대해 주십시오.”여기에 티켓이 나오는 곳입니다.
public interface ISwarmTickets
{
Task PlaceTicket(SwarmTicketBase ticket);
Task<SwarmTicketBase?> TryGetTicket(Type ticketType, TimeSpan timeout);
}
봇은 티켓을 넣을 수 있고, 다른 봇은 그 티켓을 얻을 수 있습니다.ISwarmTickets
메시지 브로커가 한 줄에 있는 것 이상이 아니다. is nothing more than a message broker with a queueticketType
(실제로 그것은약간로봇이 자신의 티켓을 복구하는 것을 방지하는 추가 옵션이 있기 때문에 (또는 다른 소규모 조정).
이 인터페이스를 통해 우리는 마침내 예제 시나리오를 두 개의 독립적 인 시나리오로 나눌 수 있습니다. (여기, 모든 추가 코드는 기본 아이디어를 설명하기 위해 제거됩니다):
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);
}
선택자
당연히, 그들은 두 개의 다른 시나리오로 나눌 수 있고 병렬로 시작할 수 있습니다. 때로는 이것이 가장 좋은 방법입니다, 다른 때는 그룹 크기의 역동적 구성 (일부 리더 당 여러 추종자) 또는 다른 데이터 / 역할을 배포 / 선택하는 다른 상황이 필요할 수 있습니다.
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);
}
여기서,RoundRobinRole
그것은 공유 카운터와 목록에서 올바른 요소를 선택하는 모듈 작업 주위에 단지 환상적인 포장입니다.
클러스터 of swarms
이제 모든 커뮤니케이션이 차단과 공유 카운터 뒤에 숨겨져 있기 때문에 오케스트레이터 노드를 도입하거나 기존의 MQ 및 KV 스토리지를 사용하는 것은 비정상적입니다.
재미있는 사실 : 우리는이 기능의 구현을 결코 끝내지 않았습니다.QA가 단일 노드 구현에 손을 넣었을 때 SwarmAgent
, 그들은 즉시 부하를 증가시키기 위해 독립적 인 에이전트의 여러 인스턴스를 단순히 배포하기 시작했습니다.
단일 사례 성능
성능에 대해 어떻게 생각하십니까?이 모든 포장 및 암시 동기화 내부에서 얼마나 손실되었습니까?나는 수행 된 모든 종류의 테스트로 당신을 과장하지 않을 것이며, 시스템이 충분히 좋은 부하를 만들 수 있음을 입증 한 가장 중요한 두 가지뿐입니다.
벤치마크 설정 :
BenchmarkDotNet v0.14.0, Windows 10 (10.0.19045.4651/22H2/2022 업데이트)
Intel Core i7-10875H CPU 2.30GHz, 1 CPU, 16 논리 코어 및 8 물리적 코어
.NET SDK 9.0.203 사용자 정의
[호스트] : .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);
}
}
}
첫째, 단순한 Task spawning에 대하여 거의 순수한 스케줄링을 살펴보자.
이 경우 양쪽 부하 방법은 다음과 같습니다.
private ValueTask CounterLoadMethod(int i)
{
Interlocked.Increment(ref StaticControl.Counter);
return ValueTask.CompletedTask;
}
Iterations에 대한 결과 = 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 |
간단한
10
5656개
0.0450개
0.0421개
0.3433
-
-
2.83 KB
간단한
100
30281회
2720개
0.2544개
3.1128
0.061
-
2567 KB
간단한
1000
250693
1626개
2.4037개
30.2734
5.8594
-
250.67 kB
스케줄
10
40629개
07842개
08054회
8.1787
0.1221
-
615 KB
스케줄
100
35386개
2.3414우리
1901년
81.0547
14.6484
-
6629 KB
스케줄
1000
4685 812개
247917사이즈
219772개
812.5
375
-
6617.59KB
실제로 테스트를 수행하기 전에, 나는 훨씬 더 나쁜 성능을 기대했지만, 결과는 정말로 나를 놀라게했습니다. 단지 모드 아래에서 얼마나 많은 일이 일어났는지 생각하고 혜택을 얻었을 때 ~4us / 연속 인스턴스.
현실적인 최악의 시험 시나리오가 될 수있는 것은 무엇입니까? API 호출 외에 아무것도하지 않는 기능에 대해 어떻게 생각하십니까? 한쪽으로는 봇의 거의 모든 방법이 그렇게하지만 다른 한편으로는 API 호출 외에 아무것도 없다면 모든 동기화 노력이 낭비됩니다.
Load Method 는 단지 전화 할 것입니다.PingAsync
단순성을 위해 RPC 클라이언트는 봇 외부에 저장됩니다.
private async ValueTask PingLoadMethod(int i)
{
await clients[i].PingAsync(new PingRequest());
}
다음은 결과, 다시 10 개의 반복 (grpc 서버는 로컬 네트워크에 있습니다):
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 |
간단한
100
94.45 미스터리
804 미세먼지
2,148 미스
600
200
6.14 MB
간단한
1000
596.69 미세먼지
15 592 미세먼지
45730 미세먼지
9000
7000
77.77 MB
스케줄
100
95.48 미스
1547 미세먼지
1,292 미세먼지
833.3333
333.3333
6.85 MB
스케줄
1000
625.52 미세먼지
14,697 미세먼지
42405 미세먼지
8000
7000
68,57 MB
예상대로 실제 작업 시나리오에 대한 성과 영향은 무시무시합니다.
분석
물론 백엔드 로그, 메트릭스 및 트랙은 로드 중에 일어나는 일에 대한 좋은 통찰력을 제공합니다.하지만 Swarm은 때때로 백엔드와 연결된 자신의 데이터를 작성하여 한 걸음 더 나아갑니다.
이 테스트는 여러 SLA 위반으로 인해 실패했습니다 (API 호출의 시간을 참조하십시오), 우리는 클라이언트 관점에서 무슨 일이 일어나고 있는지 관찰하는 데 필요한 모든 기계를 가지고 있습니다.
우리는 일반적으로 특정 수의 오류가 발생한 후에 테스트를 완전히 중지하며, 이는 API 호출 라인이 갑자기 종료되는 이유입니다.
물론, 흔적은 좋은 관찰성을 위해 필요합니다.Swarm 테스트 실행을 위해, 클라이언트 흔적은 한 나무에 백엔드 흔적과 연결됩니다.특별한 흔적은connectionIdD/botId
그것은 디버깅을 도와줍니다.
참고: DevServer는 개발자 PC에 출시 될 모든-in-one 백엔드 서비스의 편리한 단일 구축이며, 이는 화면에있는 세부 사항의 양을 줄이기 위해 특별히 만들어진 예입니다.
Swarm 봇은 성능 분석에 사용되는 흔적을 모방하며 기존 도구의 도움으로 PerfettoSQL과 같은 내장된 기능을 사용하여 이러한 흔적을 볼 수 있고 분석할 수 있습니다.
우리가 마침내 마무리 한 것은
우리는 이제 GameClient 및 Dedicated Server 봇 모두가 사용하는 SDK를 가지고 있습니다.이 봇은 이제 게임 논리의 대부분을 지원합니다.이 봇은 친구 서브시스템, 지원 그룹 및 matchmaking을 포함합니다.Dedicated bots는 플레이어가 보상, 진보 퀘스트 및 더 많은 것을 얻는 경기를 시뮬레이션 할 수 있습니다.
간단한 코드를 작성할 수 있기 때문에 QA 팀이 새로운 모듈과 간단한 테스트 시나리오를 만들 수 있었는데, FlowGraph 시나리오(시각 프로그래밍)에 대한 아이디어가 있다.
성능 테스트는 거의 연속적입니다 - 나는 아직도 수동으로 시작해야하기 때문에 거의 말합니다.
Swarm은 테스트뿐만 아니라 일반적으로 게임 논리에서 버그를 재현하고 수정하는 데 사용되며, 특히 수동으로 수행하기가 어려울 때, 예를 들어 특정 행동 순서를 수행하는 여러 게임 클라이언트가 필요합니다.
결론적으로, 우리는 우리의 결과에 매우 만족하고 확실히 우리 자신의 테스트 시스템을 개발하는 데 소비 된 노력에 대해 후회하지 않습니다.
나는 당신이이 기사를 즐겼기를 바랍니다, 나는이 텍스트를 과도하게 팽창시키지 않고 Swarm 개발의 모든 뉘앙스에 대해 당신에게 말하는 것이 어려웠다.나는 텍스트 크기와 정보를 균형화하는 과정에서 몇 가지 중요한 세부 사항을 배제 할 수있다 확신하지만, 당신이 질문이 있다면 나는 기꺼이 더 많은 맥락을 제공 할 것입니다!