마크의 신기로운 곱셈

Mark's Magic Multiply

요약

임베디드 프로세서에서 단정밀도 부동소수점 곱셈을 최적화하는 다양한 구현 방식과 Mark Owen의 고급 최적화 기법을 상세히 분석합니다.

핵심 포인트

  • RISC-V 커스텀 확장(Xh3sfx)을 통한 부동소수점 연산 가속화로 16 사이클의 곱셈 성능 달성
  • 32×32→64비트 곱셈을 네 개의 16×16 곱셈으로 분해하여 파이프라인 최적화

왜 중요한가

임베디드 시스템과 낮은 사양 하드웨어에서 성능 최적화가 필요한 개발자들에게 실전적인 최적화 기법을 제공합니다.

📄 전문 번역

임베디드 프로세서에서 부동소수점 곱셈 구현하기

이번 글에서 다룰 주제는 제 관심사의 핵심입니다. 바로 임베디드 프로세서에서의 단정밀도 부동소수점 곱셈이거든요. 먼저 이 주제에 최근 집중하게 된 배경을 설명하고, 제가 직접 구현한 방법들을 소개한 뒤, Mark Owen이 32비트 임베디드 코어를 위해 제시한 정말 신기한 트릭을 분석해볼 겁니다. 이 트릭이 바로 이 글을 쓰게 한 영감의 원천이었어요.

⚠️ 이 글은 부동소수점을 다룹니다. 부동소수점은 인간의 뇌에 혼란과 공포를 야기하는 것으로 알려져 있습니다. 기본적으로 권장되는 학습 자료는 "What Every Computer Scientist Should Know About Floating-Point Arithmetic"입니다. IEEE 754-2008 표준 문서 자체도 생각보다 간결하고 읽기 좋으니 참고해보세요. 더 실습적으로 배우고 싶다면 IEEE 754 Calculator에서 직접 비트를 만지작거려보는 것도 좋은 방법입니다(binary16부터 시작하시길 추천합니다).

Xh3sfx: 소프트 부동소수점 가속화

최근 저는 Xh3sfx라는 커스텀 RISC‑V 확장 명령어 세트를 개발 중인데, 이것은 소프트 부동소수점 루틴을 가속화하기 위한 것입니다. 말하자면 FPU(부동소수점 유닛)를 갖춘 것도, 갖추지 않은 것도 아닌 중간 지점이라고 할 수 있죠. 이를 "펌 부동소수점(firm floating point)"이라고 부를 수 있을 겁니다.

C 언어로 float 변수를 사용하는 프로그램을 부동소수점 하드웨어가 없는 타겟으로 컴파일하면, 컴파일러는 libgcc나 compiler-rt 같은 런타임 라이브러리에 대한 호출을 자동으로 삽입합니다. 이를 흔히 "부동소수점 에뮬레이션"이라고 부르기도 하는데, 사실은 IEEE 754에 정의된 부동소수점 연산을 구현하는 여러 방식 중 하나일 뿐입니다.

Xh3sfx가 커스텀 확장이긴 하지만, 저는 컴파일러를 포크해서 유지보수하려는 생각은 없습니다. 그보다는 컴파일러 런타임 루틴을 가속화된 버전으로 교체하는 게 훨씬 간단합니다. 새로운 루틴들은 부동소수점 형식의 복잡한 부분을 처리하기 위해 몇 가지 특화된 ALU 연산을 사용하고, 실제 계산은 일반적인 정수 명령어로 수행합니다. 런타임 라이브러리들은 대체로 문서화되고 안정적인 API를 제공하니까요. 가속 라이브러리를 링크하거나 소스 파일을 빌드에 추가하기만 하면 되고, 이는 임베디드 펌웨어에서 매우 합리적인 접근입니다.

수백 개 게이트라는 적당한 비용으로 Xh3sfx는 단정밀도 덧셈을 14 사이클, 곱셈을 16 사이클에 처리할 수 있습니다(함수 호출 오버헤드 제외). 이렇게 되면 부동소수점이 "어? 왜 이렇게 느린 거야?"에서 "그냥 되네?"로 변합니다. 일반적인 애플리케이션 코드나 가벼운 오디오 DSP에서는 정말 자연스럽게 동작하거든요. 제가 이전에 Mastodon에 올린 포스트를 보면 더 자세한 내용을 확인할 수 있고, 명령어 세트와 라이브러리 루틴들도 공개되어 있습니다.

기본 구현: mul과 mulh 활용

Xh3sfx 라이브러리의 기본 단정밀도 곱셈 구현은 다음과 같은 단계를 거칩니다:

h3.*로 시작하는 명령어는 Hazard3 커스텀 확장에서 나온 것입니다. Xh3bextm의 h3.bextmi와 Xh3sfx의 나머지 명령어들이죠. ALU 연산과 불가피한 분기는 각각 1 사이클이므로, 전체적으로 16 사이클(함수 호출 오버헤드 제외)이 소요됩니다.

이 구현은 mul과 mulh가 동등한 속도를 가진다고 가정하고, 모든 입력에 대해 올바르게 반올림된 결과를 요구할 때 최적입니다. 약 0.5 ulp 정도의 오차를 허용할 수 있다면 2 사이클을 절약할 수 있지만, 이는 독자의 몫으로 남겨두겠습니다(제가 항상 해보고 싶던 표현이네요).

Hazard3의 곱셈 하드웨어 옵션

Hazard3는 곱셈에 대해 세 가지 하드웨어 구성을 제공합니다:

  • 순차적 곱셈/나눗셈만 지원
  • 32비트 × 32비트 곱셈 지원
  • 32 × 32 → 64비트 풀 곱셈 지원

이 옵션들은 성능과 면적 비용 모두 오름차순으로 정렬되어 있습니다. RP2350에 테이프아웃된 것은 풀 32 × 32 → 64비트 곱셈 옵션입니다. 순차 곱셈/나눗셈만 있는 최소 구성에서는 앞서 설명한 mul과 mulh 조합이 다시 최적입니다. 옵션 2는 여전히 흥미로운데, 이것이 균형 잡힌 설계이기 때문입니다. mul 명령어는 RISC‑V M 확장에서 압도적으로 가장 많이 실행되는 명령어거든요.

16비트 곱셈으로 분해하는 시도

옵션 2를 위해 최적화를 시도할 때, 제 첫 번째 접근법은 32 × 32 → 64비트 곱셈을 4개의 16 × 16 → 32비트 곱셈으로 분해하는 것이었습니다. 각 곱셈은 mul 명령어를 사용해 한 사이클에 실행됩니다.

이것은 잘 알려진 이항 곱셈 공식을 사용합니다:

(x1 + x0)(y1 + y0) = x1y1 + x1y0 + x0y1 + x0y0

A = a1 × 2^16 + a0로 설정합니다. 여기서 a1과 a0는 32비트 A의 두 16비트 절반입니다. 그러면 곱 AB는 4개의 16 × 16 → 32비트 곱셈 합으로 전개되며, 추가로 2^n(즉, 시프트)의 인수들이 붙습니다.

대부분의 시간은 곱셈 자체가 아니라 비트를 올바른 위치로 라우팅하는 데 소비됩니다. 캐리를 전파하는 것은 상당히 복잡한 작업인데, RISC‑V에서 가장 저평가된 명령어인 pack의 도움을 받아도 그렇습니다. 이 함수의 본체는 33 사이클에 실행됩니다. 이것도 나쁘지 않지만, mul과 mulh 버전의 실행 시간의 2배가 조금 넘습니다.

위의 코드를 개선하기는 정말 어렵습니다. 개선해보려다 마주칠 문제 중 하나는 학술 문헌들이 곱셈 횟수를 줄이는 데만 집착한다는 것입니다. 하지만 짧은 파이프라인인 mul은 다른 정수 ALU 명령어와 비용이 같거든요.