콘솔 라이브러리 3종(System.CommandLine, Spectre.Console.Cli, CliFx) 벤치마크 결과

제가 콘솔 앱을 하나 만드려고 해서 벤치마크를 한번 해봤습니다. 참고로 굳이 이 세개를 고른 이유는 클래스 형태로 명령을 작성할 수 있기 때문입니다. 물론 최소 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).

4개의 좋아요

User Interface(GUI/CLI)라는 것은 기본적으로 사용자의 의사결정과 입력에 의한 지연을 동반합니다.
UX 연구에 따르면 사용자는 100ms 이하의 지연은 그냥 즉시 반응한다 정도로 인식한다고 합니다.

따라서 콘솔 라이브러리의 선택에 있어서 중요한 것은 1~2ms 성능 차이가 아니라 API의 편의성이나 명령 구조를 얼마나 유연하게 구성할 수 있는지, 아규먼트 처리 방식이 직관적인지, 프로그레스 바나 시각적 요소 같은 기능이 쉽게 구현할 수 있는가의 문제라고 봅니다.
(사용자의 인터렉션이 없는 단순한 프로세스 실행의 경우라 할지라도요)

단순한 진입 조건에 대한 실행시간과 메모리 할당 차이가 저 정도라면 앞서 언급한 부분과 실제 실행 로직의 최적화가 더 중요한 요소가 되지 않을까 합니다.

5개의 좋아요

이제는 혹시 아실지 모르겠는데, Cocona는 최근에 써보진 않았으나

ConsoleAppFramework 역시 Class 형태로 Command를 만드는 것을 당연히 지원합니다.

공식 Readme.md 에도 당연히 명시되어있고, 저도 그렇게 하는 중입니다.

TerminalTools/src/TerminalTools.CAF at main · christian289/TerminalTools

커뮤니티라 공개 지식에 대한 오해를 줄이기 위해 댓글 남겼습니다.

아마 초경량 CLI 도구를 만드시는 분들은 CommandLineParser 를 많이 쓰시는 것으로 알고 있습니다. 코드 리펙토링량이 적어서 쉽게 적용하기도 쉬워서 그런 듯 합니다. 보니까 CliFx도 비슷한 맥락인 것 같네요.

밴치 마크 해볼 생각은 못했는데 재미있네요. 감사합니다.

4개의 좋아요

아, 제가 말한건 클래스에 메서드를 집어넣는게 아니라 클래스 자체를 하나의 커맨드로 만들 수 없는 것 같아서 뺀 것입니다.

1개의 좋아요

아하 이런 의미셨군요. 사실 저도 그것에 대해서 처음 쓸 때 개념이 혼잡하긴 했습니다. ㅎㅎ

클래스명을 커맨드라고 지었는데 메서드로 안으로 나뉘어서 들어가면서 SubCommand는 또 따로 있어가지고 저도 이게 뭐지? 했거든요 ㅋㅋ

1개의 좋아요

System.CommandLine 을 썻던 마지막기억에 최적화도 별로고 트리밍 관련 지원이 전혀안된채로 유기되있어서 ConsoleAppFramework 를 쓰는걸로 결정내렸었는데 이후 변화가 있었나보군요

2개의 좋아요