요약
C#으로 옮긴 VLC 가우시안 블러 필터가 최적화 끝에 C와 거의 같은 속도를 냈습니다.
개발자 마틴 핀켈이 2007년부터 VLC에 있던 블러 필터를 C#으로 한 줄씩 옮겨 원본 C와 속도를 비교했습니다. C# 코드는 Native AOT(실행 전에 미리 기계어로 컴파일하는 방식)로 빌드해 같은 VLC 4 안에서 돌렸고, 출력은 내내 바이트 단위로 같았습니다.
왜 중요한가
- CPU를 많이 쓰는 VLC 플러그인도 C#으로 짤 만하다는 근거를 실측으로 보여 줍니다.
- 남은 격차는 가비지 컬렉터(안 쓰는 메모리를 자동으로 치우는 장치)가 아니라 컴파일러가 만든 기계어에서 나왔습니다.
- 저자는 C#을 고르는 실제 비용이 컴파일러가 반복문을 어떻게 다루는지 알아야 하는 점이라고 봤습니다.
핵심 내용
- 그대로 옮긴 C#은 1080p 프레임당 약 23% 느렸고, 저자는 clang의 LLVM이 RyuJIT보다 나은 코드를 만든 탓으로 봤습니다.
- 반복문 경계를 미리 지역 변수에 담고 nint 인덱스를 쓰는 두 가지 수정만으로 격차가 8% 안쪽으로 줄었습니다.
- 나눗셈을 역수 곱셈으로 바꾸는 등 같은 최적화를 양쪽에 넣자 C#은 원래 VLC보다 2.5배 빨랐지만 C보다 24% 느렸습니다.
- AVX2 SIMD(한 명령으로 여러 데이터를 처리하는 기능)까지 쓰자 둘 다 약 30배 빨라졌고, 240fps 대 236fps로 비등했습니다.
- 저자는 이런 필터는 원래 GPU에서 돌려야 하고, 마지막 10%가 중요하면 여전히 C가 답이라고 덧붙였습니다.
HN 반응
- 원본 필터가 SIMD조차 쓰지 않는다는 데 놀란 이용자가 많았지만, 거의 쓰이지 않는 오래된 필터라 그렇다는 설명이 붙었습니다.
- C#이 unsafe 포인터를 쓰니 사실상 C와 같다는 지적에, 위험한 코드를 작은 구역에만 가둘 수 있는 게 장점이라는 반론이 나왔습니다.
좋은 글이네요!
놀라운 건 VLC의 원래 필터가 SIMD를 쓰지 않는다는 점입니다. 새 기능이 잔뜩 들어간 "최신" CPU를 큰돈 들여 샀는데, 매일 쓰는 소프트웨어 대부분이 그 기능을 완전히 무시하고 있다는 생각이 들 때가 있습니다.
솔직히 저는 이게 CPU에서 돌아간다는 것 자체가 놀라워요.
구현도 기본적인 수준입니다. 샘플마다 나눗셈을 한 번씩 해서 재정규화하고, 커널의 대칭성도 활용하지 않습니다.
GPU에서라도 이렇게 단순한 알고리즘을 실서비스에 쓰지는 않습니다. 반경이 작을 때는 바이리니어 필터링을 활용해 샘플 수를 절반으로 줄일 수 있고, 반경이 클 때는 계층적 피라미드 근사법이 많이 쓰입니다.
그 코드가 오래됐다는 점과 기능이 별로 쓸모없다는 점이 함께 드러난 결과일 겁니다. 영상을 흐리게 만들 일이 실제로 아무한테도 없다면 필터가 얼마나 효율적인지는 중요하지 않으니까요.
저는 이게 구현까지 됐다는 게 놀랍네요.
블러는 예외적인 경우입니다. 2007년에 만든 필터인데 쓰는 사람이 거의 없어서 최적화된 적이 없습니다.
VLC의 핵심 경로(dav1d/FFmpeg를 통한 디코딩, 크로마 변환, 서피스 복사, 디인터레이싱)는 런타임에 CPU를 감지해 분기하는 손으로 튜닝한 SIMD 코드이고, 재생 자체도 대부분 어차피 GPU에서 이루어집니다.
요즘 유리 효과(glass effect)에는 큰 가우시안 블러를 쓰지 않나요? 그렇게 하려면 사실상 FFT 합성곱이 필요하다는 점도 짚어 둘 만합니다. 이 벤치마크에는 그 최적화가 구현돼 있지 않고요.
물론 이 연구가 다루려는 질문과는 별로 상관없는 얘기입니다.
저는 웬만한 런타임이나 컴파일러라면 이런 최적화쯤은 알아서 해 준다고 생각해서, Java 코드를 쓸 때도 이런 건 보통 JDK에 맡겨 두거든요. 정말 그렇지 않은 건가요?
JIT이라면 최적화했을 수도 있습니다. 다만 그 최적화가 네이티브 AOT 빌드에는 반영되지 않는 거죠.
좀 더 C#다운 관용적인 코드였다면 더 잘 최적화됐을 수도 있습니다. RyuJIT은 실제로 BCE(배열 경계 검사 제거)와 다른 루프 최적화도 합니다. 해마다 많이 좋아지고 있기도 하고요.
그런데 Roslyn과 RyuJIT을 거치는 최적화 경로는 아마 C처럼 쓴 C#에서 출발하도록 되어 있지 않을 겁니다. Span이나 슬라이스로 순회했다면 중첩 for 루프를 날것으로 돌리는 것보다 명령어 수가 줄었을지도 모릅니다.
C++도 같은 문제를 겪습니다. 상수처럼 보여도 100% 상수라고 증명할 수 없으면 최적화할 수 없거든요.
https://news.ycombinator.com/item?id=20800076
계산이나 IO를 어느 정도 이상 하는 프로그램이라면, 프로그램이 얼마나 더 효율적일 수 있는지 알면 깜짝 놀랄 겁니다.
직접 측정해 보고, 인터넷 대역폭이나 IO 읽기/쓰기 같은 하드웨어 한계치로 대충 계산해 보면 어느 정도는 감을 잡을 수 있습니다.
C# 버전은 unsafe 원시 포인터를 쓰는데, 사실상 C로 쓴 것과 같습니다. 결과가 비슷하게 나온 게 당연하죠.
맞아요, 요점은 훨씬 더 안전한 애플리케이션을 만들면서 아주 작은 부분에서만 unsafe 코드에 손을 댈 수 있다는 겁니다. 애플리케이션 전체를 100% unsafe로 짜거나, 파이썬처럼 "파이썬 라이브러리"라 해 놓고 실은 C 라이브러리인 방식을 쓸 필요가 없다는 거죠.
관리되지 않는 메모리 위에 Memory<T>와 Span<T>를 얹어서 좀 더.. 안전하게 만들 수도 있습니다.
물론이죠. 그래도 100% 날것의 C를 쓰는 것과 비교하면 요점은 그대로입니다.
.NET 1.0 때부터 성능에 이런 관심이 있었으면 좋았을 텐데 싶습니다. 수석 아키텍트가 델파이 출신이었는데도 마이크로소프트는 우선순위가 달랐죠.
그리고 인라인 배열도 특수 목적 어트리뷰트 말고 제대로 된 문법으로 지원해 주면 안 될까요? 왜 그렇게 했는지 정말 이해가 안 됩니다.
저도 어느 정도 동의합니다. (제대로 된 문법이 생기면 나중에 다른 것으로 이어지는 디딤돌이 될지도 궁금하고요. 예를 들어 16바이트 필드의 정렬을 제대로 지원하면 cmpxchg16b를 정상적으로 쓸 수 있을 테니까요.) 다만 추측해 보자면, StructLayoutAttribute를 처리하는 코드가 이미 있어서, 필드를 새로 두는 것보다 어트리뷰트를 하나 더 추가하는 쪽이 JIT과 GC의 여러 부분에 걸쳐 구현하기 '가장 쉬웠을' 것 같습니다.
인라인 배열이라면 구조체 필드 안에 있는 걸 말씀하시는 건가요? 그건
fixed한정자로 할 수 있습니다.아니요, 제가 말한 건 이 "멋진" 꼼수입니다.
https://endjin.com/blog/csharp-12-inline-arrays
인라인 배열은 unsafe
fixed의 안전한 버전입니다.그래도 가우시안 블러에는 훨씬 빠른 알고리즘이 있습니다. n픽셀 이미지와 반경 σ에 대해 O(nσ)가 아니라 O(n)에 돌아갑니다. 개요는 https://www.ipol.im/pub/art/2013/87/article.pdf 를 보세요.
FFT 블러 필터를 예로 들면, O(n)이 블러 크기와 무관해서 더 쉬워 보이는 건 맞지만 FFT와 역FFT가 필요하다는 오버헤드가 있습니다. 어떤 시그마에서는 더 빠를 수 있어도 모든 경우에 그런 건 아닙니다. 그래서 시그마에 따라 다른 변형을 두고 그 사이를 매끄럽게 전환해야 합니다.
그다음엔 근사를 시작할 수 있습니다. 시그마가 크면 10번째 픽셀마다 하나씩만 써도 됩니다. MS Teams 배경 블러가 그렇게 하는 것 같아요. 에일리어싱이 생긴 흐릿한 배경이 나오죠. 좋네요!
SIMD 변형을 추가하고 CPU 캐시 동작까지 따지기 시작하면 CPU마다 다른 버전을 만들게 됩니다. 이미지 전체를 한 차원씩 필터링하는 대신 블록 단위로 국소적으로 끝내는 식으로요.
그러면 어느새 변형이 열두 개쯤 생기고, 아키텍처마다 테스트하고 유지보수해야 합니다.
요구사항 공학을 설파하던 제 동료가 생각나네요. 그래서 얻으려던 이득이 뭐였죠?
FFT는 O(n)이 아니라 O(n log n)입니다. 더 나은 O(n) 알고리즘은 Deriche, Vliet–Young–Verbeek, Alvarez–Mazorra 같은 것들입니다.
흥미롭네요. C# SIMD 인트린식을 썼다면 성능이 어떻게, 얼마나 좋아졌을지 봤다면 더 흥미로웠을 겁니다.
그건 했어요.
저는 필터가 GPU에서 셰이더로 돌아갈 줄 알았는데요. 그러면 훨씬 효율적일 테니까요. 20년 가까이 전에 GLSL로 2패스 가우시안 블러를 구현했던 기억이 납니다.
맞아요, 빨리 처리하고 싶으면 GPU가 답입니다. 특히 4K 해상도 이상에서는 더 그렇고요.
여기를 보세요.
https://aras-p.info/blog/2026/10/01/Fast-blur-with-animated-radius/
다 좋은 얘기인데, 네이티브 C 플러그인은 디버깅할 수 있잖아요. AOT로 컴파일한 C# 플러그인도 그게 가능한가요? 아니면 C# 플러그인을 일반 JIT 런타임을 올려서 실행할 수도 있나요?
네, AOT 컴파일러가 C# 소스 파일을 가리키는 디버그 심볼을 만들어 낼 수 있어서, 디버거에 따라 동작하게 할 수 있습니다. 저는 예전에 네이티브 C iOS/Android 앱에 C#/dotnet 라이브러리를 넣어 배포해야 했습니다. 정말 고생스러웠지만 결국 됐고, XCode에서 C# 코드를 디버깅하기도 했습니다(제한적이긴 했지만요).
아주 흥미롭게 읽었습니다! AOT로 컴파일해 보고 싶은 C# 프로젝트가 몇 개 있었거든요. 복잡한 프로그램은 물론 사정이 많이 다르겠지만, 성능이 C에 아주 가깝다는 건 큰 동기가 됩니다.
그런데 왜 AOT로 하려는 거죠? 시작 시간과 네이티브 상호운용에는 좋지만, 머신별 최적화 때문에 전반적으로는 더 느릴 수도 있습니다.