Hacker News

C vs. C#: racing VLC's Gaussian blur

C와 C#: VLC 가우시안 블러 성능 대결

VLC의 가우시안 블러를 C와 C#으로 구현해 같은 조건에서 비교했습니다. 알고리즘과 SIMD를 단계별로 최적화하자 C#도 C와 비슷한 속도를 냈지만, 그 과정에서 JIT 코드 생성에 맞춘 조정이 필요했습니다.

에디터 노트

이번 비교에서 C#의 성능 차이는 언어 자체보다 JIT가 만든 코드와 그 코드를 끌어내기 위한 작성 방식에서 크게 달라졌습니다. 경계값과 인덱스를 손본 뒤 차이가 줄었고 SIMD 단계에서는 sigma 4에서 0.06ms 차이까지 좁혔습니다. 다만 unsafe 포인터를 쓴 단일 CPU 필터의 결과이므로 안전한 C# 애플리케이션 전반의 성능을 보여주지는 않습니다. 댓글에서 나온 GPU 적용과 O(n) 알고리즘도 구현 목적에 따라 선택할 대안이며, 이 벤치마크는 그 대안들과 속도를 비교하지 않았습니다.

AI 요약

VLC의 가우시안 블러를 C와 C#으로 각각 구현해 성능을 비교했습니다. 두 구현은 같은 VLC 4 빌드와 1080p 영상에서 실행했고, 결과 픽셀도 바이트 단위로 일치했습니다. 테스트 장비는 11세대 Intel Core i7 노트북이며, 모든 측정은 단일 스레드에서 진행했습니다.

비교 방법과 결과

가우시안 블러는 가로 방향과 세로 방향으로 나눠 처리할 수 있습니다. 25×25 영역을 한 번에 계산하는 대신 25칸짜리 패스를 두 번 실행해 곱셈 수를 줄이는 방식입니다. 먼저 VLC의 C 코드를 C#으로 줄 단위로 옮겼습니다. 이때 C# 구현은 sigma 4에서 프레임당 155.2ms가 걸렸고 C는 125.9ms가 걸렸습니다. C#이 약 23% 더 느렸습니다.

작성자는 기계어를 비교해 C# 코드에서 반복문 안의 경계값을 다시 계산하고 합계를 메모리에 두는 점을 확인했습니다. 경계값을 반복문 밖에서 계산하고 인덱스에 nint를 쓰자 C#의 sigma 4 처리 시간은 126.4ms로 줄었습니다. C의 137.9ms보다 빨랐고 나머지 sigma 측정에서는 C보다 최대 8% 느렸습니다. 두 변경 뒤에도 계산 방식과 결과는 같았습니다.

알고리즘과 SIMD 최적화

다음 단계에서는 C와 C#에 같은 네 가지 최적화를 적용했습니다. 내부 영역의 나눗셈을 미리 계산한 역수 곱셈으로 바꾸고, 메모리에 저장된 순서대로 읽도록 세로 패스를 조정했습니다. 가우시안 가중치의 대칭성을 이용해 곱셈을 줄였으며, 테두리 처리를 분리해 이미지 내부 반복문을 단순하게 만들었습니다. SIMD를 쓰지 않은 이 단계에서 sigma 4 처리 시간은 최적화된 C가 40.7ms, C#이 50.6ms였습니다. 원래 VLC C 코드의 125.9ms와 비교하면 C#도 2.5배 빨라졌습니다.

마지막 단계에서는 AVX2 SIMD를 적용했습니다. 두 구현 모두 한 명령으로 16픽셀을 처리하고 색상 채널의 연산을 합쳤습니다. C#에서는 JIT가 C와 비슷한 코드를 만들도록 반복문 구조를 다시 조정했습니다. 그 결과 sigma 2와 4에서는 두 언어의 속도가 측정 오차 범위에서 비슷했습니다. sigma 4는 C가 4.17ms, C#이 4.23ms였고 sigma 8에서는 각각 6.62ms와 7.22ms였습니다. 원래 VLC 코드와 비교하면 두 구현 모두 약 30배 빨랐습니다.

작성자는 이 실험이 CPU에서 같은 알고리즘을 비교하려는 목적이었다고 설명합니다. 가우시안 블러는 GPU에서 처리하는 편이 맞으며, AVX-512나 멀티코어를 이용하면 더 빨라질 여지도 있다고 덧붙였습니다. 실험 과정에서 하드웨어 디코딩을 사용할 때 CPU 필터가 화면 절반을 녹색으로 만드는 문제와 블러가 각 줄의 끝에서 2바이트를 더 읽는 문제를 발견해 수정 요청을 제출했습니다. C# 구현과 벤치마크 코드는 VLCLR.Filters 라이브러리의 GaussianBlur로 공개했습니다.

Hacker News 반응

  • @maxlin — 흥미로운 정리입니다. AOT로 컴파일해 보고 싶었던 C# 프로젝트가 몇 개 있습니다. 복잡한 프로그램은 다르겠지만 C와 성능이 비슷하다는 점이 좋은 동기부여가 됩니다.
    • @mihular — 그런데 왜 AOT를 쓰려는 건가요? 시작 시간과 네이티브 상호 운용에는 좋지만, CPU별 최적화를 하는 JIT보다 전반적으로 느릴 수도 있습니다.
    • @mcraiha — Docker 때문입니다. 이미지를 가능한 한 작게 만들고 싶습니다.
    • @littlecranky67 — 저는 반대입니다. 컨테이너가 가능한 한 빠르길 바랍니다. JIT는 실행하는 CPU 아키텍처에 맞춰 컴파일하니 최적화도 잘되겠죠. Docker 이미지 크기는 누가 신경 쓰나요?
  • @Brian_K_White — 대부분의 업계에서는 8%가 큰 차이입니다. 회사 전체 이익률보다 큰 수치인 경우도 많습니다.
    • @Dylan16807 — 우유의 지방 함량이 8%면 맛이 나빠지고 요리도 망치겠죠. 서로 다른 종류의 수치를 왜 비교하는지 모르겠습니다. 사업에서 프로젝트를 몇 퍼센트 빨리 끝내거나 한 단계가 몇 퍼센트 빨라져도 매출에는 거의 영향이 없습니다. CPU 시간을 아끼는 효과는 더 작습니다.
  • @teo_zero — 좋은 글입니다. 기존 VLC 필터가 SIMD를 쓰지 않는다는 점이 놀랍습니다. 현대 CPU의 새 기능이 매일 쓰는 소프트웨어에서는 무시되는 경우가 많아 돈을 들여 최신 CPU를 사는 이유가 뭔지 가끔 생각합니다.
    • @fenwick67 — 저는 필터가 CPU에서 실행된다는 사실부터 놀랍습니다.
    • @ygra — 코드가 오래됐고 기능을 쓸모 있게 여기는 사람이 적다는 점이 반영됐을 수 있습니다. 실제로 영상을 흐리게 만들 사람이 없다면 필터 효율은 중요하지 않겠죠.
    • @ack_complete — 기본적인 구현이라는 점도 있습니다. 샘플마다 나눗셈으로 값을 다시 맞추고 커널 대칭성도 활용하지 않습니다. 실제 제품에서는 GPU에서도 이렇게 단순한 알고리즘을 쓰지 않습니다. 작은 반경에서는 bilinear filtering으로 샘플 수를 절반으로 줄이고 큰 반경에서는 계층형 피라미드 근사를 많이 씁니다.
  • @brookman64k — 필터가 GPU 셰이더로 실행돼 훨씬 효율적일 거라고 생각했습니다. 거의 20년 전에 GLSL로 2단계 가우시안 블러를 구현한 기억이 있습니다.
    • @bschwindHN — 네, 빠르게 처리하려면 GPU가 답입니다. 특히 4K 이상에서는 그렇습니다.
  • @anderskaseorg — 가우시안 블러에는 이미지 픽셀 수 n과 반경 sigma에 대해 O(n)으로 동작하는 훨씬 빠른 알고리즘도 있습니다.
    • @schobi — FFT 블러를 보면 O(n)이라 반경에 무관해 보여도 FFT와 역 FFT 비용이 있습니다. sigma에 따라 빠른 방법이 달라서 여러 알고리즘을 선택하고 자연스럽게 전환해야 합니다. 큰 sigma에서는 픽셀을 띄엄띄엄 처리하는 근사도 가능하지만 계단 현상이 생깁니다. SIMD와 CPU 캐시까지 고려하면 CPU별 구현을 여럿 만들고 유지해야 합니다. 얻으려는 성능 향상이 얼마인지 요구사항부터 따져야 합니다.
    • @anderskaseorg — FFT는 O(n log n)이지 O(n)이 아닙니다. O(n) 알고리즘으로는 Deriche, Vliet–Young–Verbeek, Alvarez–Mazorra 등이 있습니다.
  • @hn_submit — C# 버전은 unsafe 포인터를 쓰니 사실상 C로 작성한 것과 같습니다. 결과가 비슷한 것도 놀랍지 않습니다.
    • @pjmlp — C#으로 안전한 애플리케이션을 작성하고 아주 작은 부분에서만 unsafe 코드를 쓰는 것이 요점입니다. 애플리케이션 전체를 unsafe로 작성하거나 Python처럼 실제 구현은 C 라이브러리에 맡길 필요가 없습니다.
  • @theanonymousone — 경계값을 지역 변수에 저장하는 두 가지 수정으로 차이가 줄었다고 합니다. 괜찮은 런타임이나 컴파일러라면 이런 최적화를 해줄 거라고 생각했는데, Java에서도 보통 JDK에 맡깁니다. 정말 그렇지 않나요?
    • @RandomOnyx — 괜찮은 컴파일러라면 해줍니다. .NET JIT는 특히 RyuJIT 이전에는 성능이 좋지 않았습니다. 최근 따라잡고 있지만 아직 갈 길이 있어 보입니다.
    • @zerr — C와 달리 C#과 .NET의 주요 고객층은 업무용 CRUD 앱을 만드는 회사였기 때문에 성능이 우선순위가 아니었다고 생각합니다.
    • @pjmlp — .NET 사용자도 Delphi나 VB 6처럼 성능에 신경 써주길 바랐습니다. Microsoft 경영진은 오랫동안 성능이 중요하면 C++이나 COM을 쓰고 .NET에서 호출하라는 태도였습니다. 이후 .NET에 대한 접근이 바뀌었습니다. Go와 Rust 경쟁도 영향을 줬을 수 있습니다. Azure가 오픈소스 기여에 Go와 Rust를 많이 쓰고 .NET을 외면한 재작성 사례가 있다는 점도 관련 팟캐스트에서 언급됐습니다.
    • @pjmlp — 성능은 개선되고 있습니다. C#과 Java를 언어 기능만 놓고 비교하면 놓치는 부분이 있습니다. C# 언어가 Java보다 편리한 기능을 더 많이 제공하지만 Java 플랫폼에는 CLR보다 선택지가 많았습니다. Microsoft와 Mono 중심이었던 .NET과 달리 Java에는 여러 구현체와 JIT, GC, AOT 캐시가 있었습니다. Midori와 Secure Future Initiative를 거치며 Microsoft가 .NET 성능을 더 진지하게 다루게 된 점은 다행입니다.

원문: Martin Finkel / 번역·요약: Trawling