제가 콘솔 앱을 하나 만드려고 해서 벤치마크를 한번 해봤습니다. 참고로 굳이 이 세개를 고른 이유는 클래스 형태로 명령을 작성할 수 있기 때문입니다. 물론 최소 API 형태로 명령을 작성할 수 있는 라이브러리도 있긴 하지만 그렇게 하면 Program.cs가 너무 길어질까봐(물론 정적 클래스를 만들어서 거기다가 적을 수도 있겠지만) 개인적으로는 클래스 형태로 작성하는 것을 선호합니다. 그래서 ConsoleAppFramework, Cocona는 빠졌습니다.
먼저 코드부터 보고 가겠습니다.
System.CommandLine
internal sealed class TestCommandSystemCommandLine : RootCommand {
private readonly Argument<string> name = new("name");
private readonly Argument<int> number = new("number");
private readonly Option<bool> verbose = new("--verbose");
public TestCommandSystemCommandLine() {
Arguments.Add(name);
Arguments.Add(number);
Options.Add(verbose);
SetAction(Greet);
}
private void Greet(ParseResult obj) {
var v = obj.GetValue(verbose);
var n = obj.GetValue(name);
var n2 = obj.GetValue(number);
Console.WriteLine(v ? $"Hello, {n} at {DateTime.Now:yyyy-MM-dd HH:mm:ss}!" : $"Hello, {n}!");
Console.WriteLine($"The number is {n2}");
}
}
Spectre.Console.Cli
internal sealed class TestCommandSpectreConsoleCli : Command<TestCommandSpectreConsoleCli.Settings> {
public override int Execute(CommandContext context, Settings settings) {
AnsiConsole.WriteLine(settings.Verbose ? $"Hello, {settings.Name} at {DateTime.Now:yyyy-MM-dd HH:mm:ss}!" : $"Hello, {settings.Name}!");
AnsiConsole.WriteLine($"The number is {settings.Number}");
return 0;
}
internal sealed class Settings : CommandSettings {
[CommandArgument(0, "<name>")]
[Required]
public required string Name { get; init; }
[CommandArgument(1, "<number>")]
[Required]
[Range(1, 100)]
public required int Number { get; init; }
[CommandOption("--verbose")]
[DefaultValue(false)]
public bool Verbose { get; init; }
}
}
CliFx
[Command]
internal sealed class TestCommandCliFx : ICommand {
[CommandParameter(0)]
public required string Name { get; init; }
[CommandParameter(1)]
public required int Number { get; init; }
[CommandOption("verbose")]
public bool Verbose { get; init; }
public async ValueTask ExecuteAsync(IConsole console) {
await console.Output.WriteLineAsync(Verbose ? $"Hello, {Name} at {DateTime.Now:yyyy-MM-dd HH:mm:ss}!" : $"Hello, {Name}!");
await console.Output.WriteLineAsync($"The number is {Number}");
}
}
벤치마크 코드
[SimpleJob(RunStrategy.ColdStart, warmupCount: 0)]
[MemoryDiagnoser]
public class Benchmarks {
private static readonly int Number = Random.Shared.Next(1, 101);
private static readonly string[] Lee = ["Lee", Number.ToString()];
private static readonly string[] LeeVerbose = ["Lee", Number.ToString(), "--verbose"];
private static readonly string[] VerboseLee = ["--verbose", "Lee", Number.ToString()];
[Benchmark]
public void SystemCommandLine() {
TestCommandSystemCommandLine cmd = new();
cmd.Parse(Lee).Invoke();
cmd.Parse(LeeVerbose).Invoke();
cmd.Parse(VerboseLee).Invoke();
}
[Benchmark]
public void SpectreConsoleCli() {
CommandApp<TestCommandSpectreConsoleCli> cmdapp = new();
cmdapp.Run(Lee);
cmdapp.Run(LeeVerbose);
cmdapp.Run(VerboseLee);
}
[Benchmark]
public async Task CliFx() {
CliApplicationBuilder builder = new();
var app = builder.SetExecutableName("test").AddCommand<TestCommandCliFx>().Build();
await app.RunAsync(Lee);
await app.RunAsync(LeeVerbose);
////await app.RunAsync(VerboseLee); // Not working
}
private static void Main() => BenchmarkRunner.Run<Benchmarks>();
}
그 결과는 다음과 같이 나왔습니다.
BenchmarkDotNet v0.15.2, Linux Arch Linux
AMD Ryzen 5 5600G with Radeon Graphics 4.47GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 9.0.303
[Host] : .NET 9.0.7 (9.0.725.31616), X64 RyuJIT AVX2
Job-XNCIFD : .NET 9.0.7 (9.0.725.31616), X64 RyuJIT AVX2
RunStrategy=ColdStart WarmupCount=0
1회차
| Method | Mean | Error | StdDev | Median | Allocated |
|---|---|---|---|---|---|
| SystemCommandLine | 1.447 ms | 4.693 ms | 13.837 ms | 0.0604 ms | 12 KB |
| SpectreConsoleCli | 3.307 ms | 6.655 ms | 19.621 ms | 1.3159 ms | 191.15 KB |
| CliFx | 2.015 ms | 5.004 ms | 14.755 ms | 0.5330 ms | 76.42 KB |
2회차
| Method | Mean | Error | StdDev | Median | Allocated |
|---|---|---|---|---|---|
| SystemCommandLine | 1.416 ms | 4.589 ms | 13.529 ms | 0.0591 ms | 12 KB |
| SpectreConsoleCli | 3.236 ms | 6.405 ms | 18.884 ms | 1.3317 ms | 191.15 KB |
| CliFx | 1.984 ms | 4.945 ms | 14.581 ms | 0.5233 ms | 76.42 KB |
3회차
| Method | Mean | Error | StdDev | Median | Allocated |
|---|---|---|---|---|---|
| SystemCommandLine | 1.444 ms | 4.677 ms | 13.790 ms | 0.0629 ms | 12 KB |
| SpectreConsoleCli | 3.664 ms | 7.019 ms | 20.697 ms | 1.5341 ms | 191.15 KB |
| CliFx | 2.022 ms | 5.082 ms | 14.986 ms | 0.5221 ms | 76.42 KB |
보시다시피 System.CommandLine이 가장 빠르고 할당도 가장 적습니다. 어디서는 SCL이 가장 느리다고 들었는데 그건 구버전이었고 최신 버전에서는 성능이 향상되어서 그렇거나 다른 두개는 App 객체를 만들어서 실행시켰지만 SCL은 그냥 Parse하기만 하면 되기 때문에 그렇거나 아니면 제가 벤치를 잘못했거나 셋 중 하나겠죠.
제가 BenchmarkDotNet은 처음 써보기 때문에 이 벤치 설정이 맞는건지는 모르겠습니다.
사실 이 벤치는 공평하지 않습니다. 그 이유는 코드를 보시면 아시겠지만 CliFx는 옵션-인자 순의 입력을 지원하지 않기 때문에 그 부분을 제외시켰기 때문입니다.
나머지 두개는 동기 메서드를 사용했지만 CliFx는 비동기 메서드를 사용했다는 점도 지적하실 분이 계실 것 같지만 나머지 두개도 비동기 메서드를 지원하기는 하는데 편의상 그냥 동기 메서드를 썼고 무엇보다도 동기/비동기 성능 차이가 별로 안나더군요. 최신 닷넷에서는 async/await로 인한 오버헤드가 거의 없다는 의미도 되겠군요.
그리고 Native AOT 시 성능은 어떨까 궁금해서 시도해봤습니다. 하지만 Spectre와 CliFx는 AOT를 미지원하는지 제대로 실행이 안되더군요. 그 둘도 TrimmerRootAssembly를 설정해서 AOT로 썼던 기억이 나긴 하는데 BenchmarkDotNet에서는 어떻게 설정하는지 도저히 모르겠어서 그냥 포기했습니다.
결론(?): 웬만하면 System.CommandLine을 쓰자(특히 AOT).