지난 글에서 모델을 기기 안의 전용 칩에 올리는 이야기를 했다. 그 칩에 올리려면 모델이 먼저 작아져야 한다. 이번에는 만들어 둔 모델을 배포 전에 줄이는 쪽이다. 모델을 작게 만드는 방법은 여럿인데, 그중 가지치기(pruning)는 학습된 가중치 일부를 덜어 내는 방식이다.
가지치기의 출발점은 관찰 하나다. 학습이 끝난 신경망의 가중치 중 상당수는 절댓값이 0에 아주 가깝고, 그 값들을 0으로 만들어도 출력이 거의 안 변한다. 그러면 그것을 지우면 되지 않나 — 여기까지가 직관이고, 실무에서 어긋나는 지점은 그 다음이다.
0을 만들었는데 왜 안 빨라지는가
가중치의 절반을 0으로 만들었으니 계산도 절반이 되어야 할 것 같다. 실제로는 거의 그대로다.
이유는 하드웨어가 행렬 곱을 처리하는 방식에 있다. GPU는 정해진 크기의 타일 단위로 값을 읽어 곱셈-누산을 병렬로 수행한다. 그 타일 안에 0이 몇 개 섞여 있든 읽어 오는 데이터의 양도 같고 도는 연산 횟수도 같다. 0 곱하기 0.7도 곱셈 한 번이다.
왼쪽처럼 0이 격자에 흩어진 형태를 비구조적 가지치기(unstructured pruning)라 부른다. 어느 값을 지울지 완전히 자유롭게 고를 수 있어 같은 희소도에서 정확도가 가장 잘 버틴다. 대신 행렬의 모양이 8×8 그대로라서, 저장할 때도 추론할 때도 원래 크기 그대로 다뤄진다.
오른쪽이 구조적 가지치기(structured pruning)다. 값 하나가 아니라 행·열·채널·어텐션 헤드처럼 의미 있는 덩어리를 통째로 덜어 낸다. 빈 행을 들어내고 4×8 행렬을 새로 만들면, 그것은 희소 행렬이 아니라 그냥 더 작은 조밀 행렬이다. 어떤 하드웨어에서도 그만큼 덜 계산한다.
이 차이 때문에 판단 기준이 명확하다. 특별한 하드웨어 지원이나 희소 커널 없이 지금 쓰는 서빙 스택에서 이득을 보려면 구조적 가지치기여야 한다.
비구조적 쪽이 아무 쓸모가 없다는 뜻은 아니다. 압축해서 전송할 때는 0이 많으면 실제로 작아지고, 최신 GPU 일부는 특정 패턴의 희소성을 하드웨어로 지원한다. 네 개 중 두 개가 0인 규칙적인 패턴을 요구하는 식인데, 이건 완전히 자유로운 비구조적 가지치기가 아니라 반구조적(semi-structured)이라 부르는 절충안이다. 다만 그 패턴을 맞추려면 학습 쪽에서 제약을 걸어야 하고, 지원되는 하드웨어와 커널 조합이 한정된다.
무엇을 덩어리로 잡을 것인가
구조적 가지치기라고 해도 무엇을 단위로 삼느냐가 여러 층위로 갈린다. 위로 올라갈수록 이득이 크고 손실도 크다.
| 단위 | 무엇을 덜어 내는가 | 성격 |
|---|---|---|
| 채널·뉴런 | 합성곱 필터 하나, 완전연결 층의 출력 하나 | 가장 세밀한 구조적 단위. 다루기 쉽다 |
| 어텐션 헤드 | 트랜스포머 층의 헤드 하나 | 헤드마다 기여도가 크게 달라 덜어 낼 여지가 많다 |
| FFN 중간 차원 | 피드포워드 블록의 은닉 폭 | 파라미터의 큰 몫을 차지해 효과가 크다 |
| 층 전체 | 트랜스포머 블록 하나 | 지연이 확실히 줄지만 손실도 가장 크다 |
트랜스포머에서 실무적으로 가장 손이 잘 가는 곳이 FFN의 중간 차원이다. 파라미터 수에서 차지하는 비중이 크고, 폭을 줄이는 것은 앞뒤 층의 모양만 맞추면 되어 구조가 단순하다.
어텐션 헤드도 좋은 후보다. 학습된 모델의 헤드를 하나씩 꺼 보면 성능이 거의 안 변하는 헤드가 상당수 나온다는 것은 여러 차례 확인된 성질이다. 다만 헤드를 지울 때는 같은 층의 헤드를 전부 지워 버리지 않도록 층별 최소 개수를 정해 두는 편이 안전하다.
층 전체를 덜어 내는 것은 효과가 가장 확실한 만큼 위험도 크다. 그리고 층마다 중요도가 다르다 — 앞쪽 몇 층과 마지막 층은 대체로 못 건드리고, 중간의 비슷비슷한 층들 사이에 여지가 있다.
무엇을 지울지 어떻게 고르는가
덩어리마다 중요도 점수를 매기고 낮은 것부터 지운다. 점수를 매기는 방법이 여럿이다.
절댓값의 크기가 가장 단순하다. 채널에 속한 가중치들의 노름이 작으면 기여가 작다고 본다. 계산이 거의 공짜라서 기본 선택지가 되지만, 가중치가 작아도 뒤에서 크게 증폭되는 경로가 있으면 틀린다.
활성화 기반은 실제 데이터를 흘려 보고 그 채널의 출력이 얼마나 움직이는지를 본다. 늘 0에 가까운 출력을 내는 채널은 사실상 죽어 있다. 보정 데이터가 필요하지만 절댓값보다 대체로 정확하다.
손실 변화 근사는 "이 덩어리를 지우면 손실이 얼마나 오를까"를 기울기 정보로 추정한다. 가장 원리에 가깝고 대체로 가장 좋은 결과를 주지만, 역전파를 돌려야 해서 비용이 든다.
여기서 실무적으로 중요한 선택이 하나 더 있다. 점수를 층 안에서 비교할 것인가, 모델 전체에서 비교할 것인가.
- 층별로 같은 비율씩 지우면 층 사이 균형이 유지되고 구현이 단순하다. 대신 원래 여유가 없는 층에서도 억지로 같은 비율을 덜어 낸다
- 전체에서 하위 N%를 지우면 여유 있는 층에서 많이 가져오지만, 어떤 층이 통째로 비어 버릴 수 있다. 층별 하한을 함께 걸어야 한다
층마다 점수의 스케일이 다르다는 점도 함정이다. 정규화 없이 전체 비교를 하면 스케일이 큰 층만 살아남는다. 전체 비교를 할 때는 층 안에서 점수를 정규화한 뒤 비교한다.
한 번에 지우지 않는다
목표가 50%라고 해서 한 번에 절반을 덜어 내면 회복이 잘 안 된다. 실무에서 통하는 형태는 조금 지우고 회복시키기를 반복하는 것이다.
target = 0.5
steps = 5
for step in range(1, steps + 1):
# 목표 희소도까지 서서히 올린다 — 뒤로 갈수록 조금씩
ratio = target * (1 - (1 - step / steps) ** 3)
scores = importance_scores(model, calib_loader)
model = prune_channels(model, scores, ratio, min_keep_per_layer=0.2)
finetune(model, train_loader, epochs=1, lr=1e-5)
acc = evaluate(model, valid_loader)
print(f"step {step} sparsity={ratio:.2f} acc={acc:.4f}")
if acc < ACCEPT_FLOOR:
break # 여기서 멈추고 직전 체크포인트를 쓴다
몇 가지를 짚어 둔다.
ratio를 3제곱 형태로 올리는 것은 초반에 크게 덜어 내고 후반에 천천히 접근하기 위해서다. 처음 몇 %는 거의 손실 없이 사라지지만 마지막 몇 %가 어렵다.
min_keep_per_layer가 앞에서 말한 층별 하한이다. 이게 없으면 중요도가 낮게 나온 층이 통째로 사라져 모델이 끊긴다.
매 단계 뒤에 평가하고 바닥값을 넘으면 멈추는 부분이 가장 중요하다. 가지치기는 "얼마까지 줄일 수 있는가"를 미리 알 수 없는 작업이라, 목표를 정해 놓고 밀어붙이는 것보다 허용 가능한 손실을 정해 놓고 갈 수 있는 데까지 가는 편이 맞다.
그리고 지운 뒤의 재학습은 처음 학습보다 훨씬 작은 학습률로 한다. 큰 학습률로 돌리면 남은 가중치까지 흔들려 오히려 나빠진다.
다른 경량화 방법과 어떻게 섞는가
가지치기만 쓰는 경우는 드물다. 양자화·증류와 함께 쓰는데 순서가 있다.
| 순서 | 무엇을 | 왜 이 순서인가 |
|---|---|---|
| 1 | 가지치기 | 구조를 먼저 확정한다. 나중에 바꾸면 뒤 단계를 다시 해야 한다 |
| 2 | 재학습·증류 | 줄어든 구조에서 성능을 회복시킨다 |
| 3 | 양자화 | 마지막에 정밀도를 낮춘다. 보정이 최종 구조 위에서 이뤄져야 한다 |
양자화를 먼저 하고 가지치기를 하면 보정 통계를 다시 잡아야 하고, 두 손실이 겹쳐 원인 분리가 안 된다.
그리고 솔직하게 덧붙일 것이 있다. 원하는 크기의 모델이 이미 공개돼 있다면 큰 모델을 잘라 만드는 것보다 그것을 쓰는 편이 대개 낫다. 같은 크기에서, 처음부터 그 크기로 충분히 학습된 모델이 잘라 만든 모델보다 좋은 경우가 많다. 가지치기가 이기는 자리는 내 데이터로 파인튜닝한 모델을 배포 제약에 맞춰야 하는데 그 크기의 대체품이 없는 경우다.
정리
- 가중치를 0으로 만들어도 행렬 모양이 그대로면 읽는 데이터도 도는 연산도 그대로다. 희소도는 속도가 아니다
- 구조적 가지치기는 행·채널·헤드를 통째로 덜어 내 더 작은 조밀 행렬을 만든다. 특별한 커널 없이 어디서나 이득이 난다
- 반구조적 방식은 절충안이지만 지원되는 하드웨어와 커널 조합이 한정된다
- 트랜스포머에서는 FFN 중간 차원과 어텐션 헤드가 손대기 좋다. 층 전체 제거는 효과도 위험도 가장 크다
- 중요도는 절댓값·활성화·손실 변화 근사 순으로 정확해지고 비싸진다. 전체 비교를 할 때는 층 안에서 정규화한다
- 한 번에 목표까지 가지 않는다. 조금 지우고 재학습하기를 반복하되, 목표 희소도가 아니라 허용 손실을 기준으로 멈춘다
- 순서는 가지치기 → 회복 → 양자화다. 그리고 원하는 크기의 모델이 이미 있으면 잘라 만드는 것보다 그쪽이 대개 낫다
읽어주셔서 감사합니다. 😊

