본문 바로가기
생각

LoRA는 가중치를 어떻게 바꾸나: 작은 행렬의 학습부터 병합까지

LoRA의 두 작은 행렬이 출력을 바꾸는 과정을 숫자 예제로 따라간다. 0 초기화의 기울기, 학습 변수 절감, 가중치 병합과 어댑터 분리의 대가를 연결해 설명한다.

LoRA는 가중치를 어떻게 바꾸나: 작은 행렬의 학습부터 병합까지 관련 이미지

LoRA는 큰 모델의 가중치를 작은 모델로 교체하는 방법이 아니다. 원래 가중치는 그대로 두고, 그 출력에 더할 변화량을 두 개의 작은 행렬로 학습한다. 이 구조를 따라가 보면 학습할 값이 줄어드는 이유뿐 아니라, 왜 한 행렬을 0으로 시작하는지, 그리고 추론할 때 어떻게 추가 경로를 없애는지까지 자연스럽게 이어진다.

이 글에서는 양자화하지 않은 선형층 하나와 고정된 편향, 기본 LoRA의 alpha/r 배율을 다룬다. 행렬 계산에서 입력은 열벡터로 표기한다. 아래에 나오는 숫자는 원리를 설명하기 위해 만든 가상 예시이며, 작은 계산은 Python으로 검증했다. 실제 언어 모델을 파인튜닝하거나 GPU 성능을 측정한 결과는 아니다.

원래 경로 옆에 변화량을 계산하는 경로를 붙인다

LoRA 원논문 (새 창)의 기본식은 h = W₀x + sBAx다. 여기서 W₀는 동결한 원래 가중치이고, 실제로 학습하는 대상은 A와 B뿐이다. 기본 방식에서 배율 s는 alpha/r로 둔다. 입력 차원이 n, 출력 차원이 m일 때 W₀는 m×n, A는 r×n, B는 m×r 크기를 갖는다. 입력 x가 먼저 A를 거치며 r개의 값으로 바뀌고, 이어서 B가 이를 m개 출력에 대한 변화량으로 바꾼다. 마지막으로 이 변화량을 원래 출력인 W₀x에 더한다.

Hugging Face의 LoRA 그림 (새 창)에서 왼쪽 파란 상자는 원래 경로를, 오른쪽 주황색 A와 B는 추가 경로를 나타낸다. 두 경로 위에 있는 더하기 기호가 양쪽 출력이 합쳐지는 지점이다. 그림에 적힌 r은 입력 문장의 길이가 아니라 두 행렬 사이의 차원을 뜻한다. 그림에서는 정사각형 W를 썼지만, 본문의 수식은 입력과 출력 차원이 서로 다른 선형층에도 그대로 적용된다.

Microsoft 참조 구현의 Linear.forward (새 창)는 입력을 행벡터로 다루기 때문에 코드에서는 x @ A.T @ B.T 순서로 계산한다. 수식의 BAx와는 표기 방향만 다를 뿐이다. 학습 중에는 추가 경로의 입력에 dropout을 걸 수 있지만, 아래 계산에서는 dropout을 끈 상태를 가정한다.

동결한 원래 가중치 W 경로와 저랭크 A·B 경로의 출력을 더하는 LoRA 구조. A는 난수, B는 0으로 초기화하며 r은 두 행렬 사이 차원이다.
출처: Hugging Face PEFT LoRA 문서. 원본 GIF의 36번 프레임(0부터 계산)을 정지 인용해 두 출력의 합산을 보여준다. 입력·출력 이동 애니메이션은 생략했다. 그림의 A 정규분포 표기는 본문에서 읽은 loralib의 Kaiming 초기화와 구분한다.

입력 하나를 두 경로로 흘려보면

입력 x를 [1, 2, −1], 원래 가중치 W₀의 두 행을 [1, 0, 1]과 [0, 1, −1]이라고 해 보자. 이때 원래 경로의 출력은 [0, 3]이다. 여기에 r=1, alpha=1로 두고 A=[1, −1, 0], B의 두 원소를 [2, −1]로 설정한다. 이 A와 B는 학습을 마친 뒤에 얻었다고 가정한 설명용 값이다.

입력 x가 A를 먼저 통과하면 1−2+0=−1이 나온다. 여기에 B를 곱하면 변화량은 [−2, 1]이 되고, 이를 원래 출력에 더한 최종 출력은 [−2, 4]가 된다. 이처럼 LoRA가 만들어 내는 것은 원래 출력에 더해질 보정값이다. 따라서 A가 반드시 의미 단위의 ‘중요한 특징’을 추출한다고 해석할 필요는 없다.

같은 예시에서 행렬 곱 BA를 직접 구해 보면 두 행은 [2, −2, 0]과 [−1, 1, 0]이 된다. 두 번째 행이 첫 번째 행의 −1/2배이므로 독립적인 방향은 하나뿐이다. r=1이라는 제약이 실제 변화량 행렬에 어떻게 남는지 여기서 확인할 수 있다. 반대로 두 행이 [1, 0, 0], [0, 1, 0]인 변화량은 rank가 2이기 때문에 이 r=1 구조로는 정확히 표현할 수 없다. 물론 이것이 원래 가중치 W₀ 자체의 rank를 1로 줄인다는 뜻은 아니다.

본문의 가상 수치 예제. s=1, 편향=0, dropout을 끈 조건에서 Python으로 산술 검증했다.
단계계산 결과
입력 x[1, 2, −1]
원래 경로 W₀x[0, 3]
A를 통과한 Ax[−1]
추가 경로 BAx[−2, 1]
두 경로 합산[−2, 4]

B가 0이어도 학습이 시작되는 이유

참조 구현의 Linear.reset_parameters (새 창)를 보면 A는 Kaiming-uniform 난수로, B는 0으로 초기화한다. 덕분에 학습을 시작할 때는 BA=0이 되어 전체 출력이 원래 선형층과 완전히 같아진다. 앞서 살펴본 원자료 그림에서는 A를 정규분포로 표기해 두어 코드의 구체적인 분포와는 차이가 있지만, A는 난수로 두고 B는 0으로 시작한다는 핵심 설계는 같다.

그렇다면 0으로 막혀 있는 경로가 어떻게 학습을 시작할 수 있을까. 출력에 대한 손실 L의 기울기를 g라고 할 때, 편향과 dropout을 제외한 이 예시에서 B의 기울기는 s·g·(Ax)ᵀ이고 A의 기울기는 s·Bᵀ·g·xᵀ다. B가 0인 첫 단계에서는 A 쪽 기울기가 0이 되더라도, Ax와 g가 마침 0이 아니라면 B 쪽에는 기울기가 남는다. 일단 B의 값이 한 번 바뀌고 나면 그다음부터는 A에도 기울기가 흐를 수 있다.

앞선 예시의 A와 x를 그대로 쓰고 초기 상태를 B=[0, 0], g=[1, −1], s=1로 두면, 첫 단계에서 B의 기울기는 [−1, 1], A의 기울기는 [0, 0, 0]이 된다. 여기에 학습률 0.1인 SGD를 한 번 적용하면 B는 [0.1, −0.1]로 갱신되고, 같은 입력에 대한 출력도 [−0.1, 3.1]로 바뀐다. 반면 A와 B를 둘 다 0으로 초기화하면 양쪽 기울기가 모두 0이 된다. 이 경우 순수한 BA 경로는 손실의 기울기만으로는 학습을 출발할 수 없다.

줄어드는 것은 학습 변수이지 원래 행렬이 아니다

선형층의 전체 가중치를 직접 학습한다면 총 mn개의 값을 갱신해야 한다. 반면 LoRA는 A와 B에 속한 r(n+m)개의 값만 갱신한다. 예를 들어 n=m=4,096이고 r=8일 때, 원래 행렬의 원소는 16,777,216개지만 두 작은 행렬의 원소 합은 65,536개에 불과해 학습 변수 수가 256분의 1로 줄어든다. 이는 편향과 다른 층을 제외하고 계산한 수치다. 앞서 든 2×3 크기의 예시는 계산 과정을 쉽게 보여 주기 위한 것이라, 원래 6개 대신 5개를 학습하는 셈이어서 변수 절감 폭이 작다.

하지만 이 학습 변수 감소 비율을 전체 GPU 메모리 절감률로 그대로 치환할 수는 없다. 동결된 W₀도 여전히 메모리에 올려 두고 순전파 계산에 써야 하기 때문이다. 또한 앞선 층의 학습 변수까지 기울기를 전달해야 한다면, 비록 W₀가 동결되어 있더라도 입력에 대한 역전파 계산은 수행해야 한다. 순전파 과정에서 생기는 활성값과 임시 버퍼도 메모리에 남는다. 따라서 학습 변수가 줄어들어 아낄 수 있는 기울기 및 옵티마이저 상태 메모리와, 구조상 계속 유지해야 하는 메모리를 구분해서 봐야 한다.

한편 rank를 높이면 표현할 수 있는 변화량의 범위가 넓어지는 동시에 학습 변수 수도 함께 늘어난다. 게다가 기본 배율인 s=alpha/r을 쓸 때는 alpha를 고정한 채 r만 키우면 스케일 배율까지 함께 변한다. 즉 rank를 바꾸는 실험은 단순히 행렬의 표현력만 조절하는 실험이 아닐 수 있다. 여기에 LoRA를 적용할 대상 층의 범위까지 달라지면, 같은 rank를 쓰더라도 전체 학습 변수 수는 달라진다.

두 경로를 한 행렬로 접는 병합

dropout을 끈 추론 환경에서는 W₀x+sBAx를 (W₀+sBA)x로 묶어 계산할 수 있다. 앞선 숫자 예시를 기준으로 병합된 가중치 W를 구해 보면 두 행은 [3, −2, 1]과 [−1, 2, −1]이 된다. 이 단일 행렬에 입력 x를 곱해도 분리해 계산했을 때와 똑같이 [−2, 4]가 나온다. 이처럼 학습할 때 따로 두었던 변화량을 원래 가중치에 미리 더해 저장해 두면, 추론할 때는 추가 경로 없이 선형층 하나만으로 결과를 낼 수 있다.

참조 구현의 Linear.train (새 창) 코드를 보면, merge_weights 옵션이 켜져 있을 때 평가 모드로 전환하면 가중치에 sBA를 더하고, 다시 학습 모드로 돌아가면 이를 뺀다. 내부적으로 merged 상태를 기록해 두기 때문에 평가 모드를 연속해서 두 번 호출하더라도 같은 변화량을 중복해서 더하지는 않는다. 다만 이러한 동작은 여기서 살펴본 loralib 구현의 약속일 뿐, 모든 라이브러리의 eval() 호출이 자동으로 가중치 병합을 수행한다는 뜻은 아니다.

수식으로는 완전히 같더라도 실제 컴퓨터에서 부동소수점 연산 순서가 달라지면 미세한 오차가 발생할 수 있다. 따라서 병합 전후의 출력을 비교할 때는 동일한 입력과 편향, 평가 모드 조건에서 적절한 오차 허용 범위를 두고 검증한다. 양자화된 가중치를 쓰거나 변형된 LoRA 기법을 적용할 때는 추가 조건이 붙을 수 있으므로, 여기서 확인한 동등성을 그대로 일반화해서는 안 된다. 본문에서 다룬 계산 확인은 작은 정수와 소수 예제를 이용한 산술 검증이다.

하나로 병합할지, 작은 어댑터로 남길지

하나의 기본 모델 위에 여러 업무용 변경분을 함께 보관해야 한다면, 거대한 W₀ 하나만 공유하고 업무별 A·B만 따로 두는 구성이 저장 공간 면에서 유리할 수 있다. 다만 추론할 때 어댑터를 병합하지 않고 별도로 실행하면 추가 연산 경로가 그대로 남는다. 반대로 단일 업무만 고정해서 배포한다면 가중치를 미리 병합해 추가 경로를 없앨 수 있지만, 여러 업무마다 병합된 전체 모델을 각각 저장하면 기본 가중치가 중복으로 쌓이게 된다. 이 비교는 저장 구조와 연산 경로를 놓고 본 분석이며, 실제 추론 지연 시간을 측정한 결과는 아니다.

PEFT의 병합 안내 (새 창) 문서에서는 merge_and_unload()가 반환하는 모델을 사용하도록 설명한다. 일단 가중치를 병합하고 나면 어댑터를 별도로 유지하던 기존의 운용 방식과는 형태가 달라진다. 배포하기 전에 학습 때와 같은 기본 가중치 및 대상 층에 어댑터를 정확히 연결했는지, 그리고 배율 설정과 활성 어댑터 선택이 맞는지부터 확인할 이유가 여기에 있다.

결국 LoRA가 만들어 내는 작은 파일은 완성된 기본 모델을 대체하는 것이 아니다. 그 파일은 원본의 어느 가중치에 더해질 변화량인지를 알아야만 비로소 쓸 수 있는 변경분이다. 학습 변수 수를 줄이는 선택, 추론 경로를 하나로 합치는 선택, 그리고 여러 업무의 변경분을 나누어 관리하는 선택은 모두 이 구조 위에서 목적에 맞게 각각 결정할 수 있다.

채용 제안 보내기

생각

함께 해결할 LLM 과제와 기대하는 역할을 알려 주세요.

연락하기