소스코드와 이미지는 아래 원본에서 참조했습니다.

 

https://efficientml.ai

https://github.com/erectbranch/MIT-Efficient-AI

efficientml.ai는 MIT HAN Lab의 TinyML and Efficient Deep Learning Computing 강의 사이트입니다.

강의자는 MIT의 Song Han 교수이며, Efficient AI Computing, 모델 경량화, TinyML, LLM 배포 최적화 등을 다룹니다.

강의에는 슬라이드, 영상, Colab Lab 자료가 연결되어 있어 독학하기 좋습니다. 2024년 과정 기준으로 총 5개의 Lab이 있으며, 주제는 Pruning, Quantization, NAS, LLM Compression, Laptop LLM Deployment입니다.

과정을 대부분 무료로 들을 수 있고, 유튜브, 슬라이드, 실즙자료까지 무료로 활용할 수 있읍니다.

신경망 프루닝(Network Pruning) 

On-Device AI(온디바이스 AI)를 공부할 때 가장 먼저 만나게 되는 모델 경량화 기법인 프루닝(Pruning, 가지치기)에 대해 알아보겠습니다.

학습시에는 매우 큰 모델이 필요합니다.

하지만 추론할때는 파라미터를 일부를 제거해도 성능이 크게 떨어지지 않습니다.

1. 프루닝(Pruning)이란 무엇인가요?

프루닝은 우리말로 "가지치기"라는 뜻입니다. 나무에서 불필요한 가지를 잘라내듯이, 신경망(Neural Network) 안에 있는 수많은 가중치(Weight) 중에서 모델 성능에 큰 영향을 주지 않는 '덜 중요한' 가중치를 찾아내어 제거(0으로 만듦)하는 기법을 의미합니다.

(1) 왜 프루닝이 필요할까요?

요즘 사용되는 딥러닝 모델은 수백만~수십억 개의 파라미터(가중치)를 가지고 있습니다.

이렇게 큰 모델은 다음과 같은 문제를 가지고 있습니요.

  • ① 메모리를 많이 차지 : 파라미터 하나하나가 저장 공간을 차지하기 때문에, 모델이 클수록 더 많은 메모리(RAM, 저장 공간)가 필요합니다.
  • ② 연산량이 많음 : 추론(inference) 시 곱셈/덧셈 연산이 그만큼 많이 필요하므로 속도가 느려지고 배터리 소모도 커집니다.
  • ③모바일/임베디드 기기에 올리기 어렵움 : 스마트폰, IoT 기기처럼 자원이 제한적인 환경에서는 큰 모델을 그대로 사용하기 힘듭니다.

프루닝의 목적은 바로 이러한 문제를 해결하는 것입니다.

프루닝의 목적 

모델 크기 감소: 가중치 중 일부를 0으로 만들면 압축 시 저장 공간을 크게 줄일 수 있습니다.

연산량 감소: 0인 가중치는 계산할 필요가 없으므로(혹은 더 작은 행렬로 대체할 수 있으므로) 연산 속도가 빨라집니다.

정확도는 최대한 유지: 무작정 가중치를 지우는 것이 아니라, "중요도가 낮은" 가중치를 골라서 제거함으로써 모델 성능 저하를 최소화합니다.

(2) 프루닝의 기본 동작 원리

프루닝의 핵심 아이디어는 "가중치의 절댓값이 작을수록 모델 출력에 미치는 영향이 적을 것이다" 라는 가정입니다.

(이를 Magnitude-based Pruning 이라고 부릅니다.)

동작 과정을 간단히 정리하면 다음과 같습니다.

  1. 중요도(Importance) 계산: 각 가중치의 절댓값을 구합니다. (값이 클수록 중요하다고 가정)
  2. 임계값(Threshold) 결정: 원하는 비율(Sparsity, 희소도)만큼 가중치를 제거할 수 있는 기준값을 정합니다.
  3. 마스크(Mask) 생성: 임계값보다 작은 가중치는 0(제거), 큰 가중치는 1(유지)로 표시하는 마스크를 만듭니다.
  4. 마스크 적용: 원래 가중치에 마스크를 곱해서 작은 가중치들을 0으로 만듭니다.

여기서 Sparsity(희소도)란 "전체 가중치 중에서 0이 된 가중치의 비율"을 의미합니다.

예를 들어 Sparsity = 0.5라면 전체 가중치의 50%를 0으로 만든다는 뜻입니다.

import torch

def prune_fine_grained(weight: torch.Tensor, sparsity: float):
    """가중치 텐서에서 절댓값이 작은 원소들을 0으로 만드는 함수
    Args:
        weight: 프루닝할 가중치 텐서
        sparsity: 0~1 사이의 값. 전체 가중치 중 몇 %를 0으로 만들지 결정
    Returns:
        mask: 유지(1)/제거(0) 여부를 담은 마스크 텐서
    """
    # 1. 중요도 = 가중치의 절댓값
    importance = weight.abs()

    # 2. 제거할 개수 계산 (예: 전체 12개 중 50% -> 6개)
    num_pruned = round(weight.numel() * sparsity)

    # 3. 임계값(threshold) 계산: 작은 값부터 num_pruned번째 값
    threshold = torch.kthvalue(importance.flatten(), num_pruned)[0]

    # 4. 임계값보다 큰 값만 살리는(1) 마스크 생성
    mask = importance > threshold

    # 5. 마스크를 적용해서 작은 값들을 0으로 만듦
    weight.mul_(mask)
    return mask

이 함수가 바로 프루닝의 가장 기본 형태입니다. 그런데 "어떤 단위로 가중치를 묶어서 제거할 것인가"에 따라 여러 방식이 존재하는데, 이를 프루닝 그래뉴얼러티(Granularity, 세분성)라고 부릅니다.

torch.kthvalue(tensor, k)는 "텐서 안에서 가장 작은 값부터 시작해서 k번째로 작은 값을 찾아라"라는 명령입니다.

이 함수는 특이하게 두 개(values, indices)를 묶어서 리턴합니다.

  • [0]번째 결과: 우리가 원하는 $k$번째로 작은 실제 값(value)
  • [1]번째 결과: 그 값이 있던 원래 위치의 인덱스(index)

코드에서 끝에 [0]을 붙인 이유가 바로 인덱스는 버리고 커트라인이 되는 실제 값만 가져오 위함입니다.


mask = importance > threshold

가중치 각각의 절댓값이 방금 구한 커트라인(0.5)보다 큰지 비교합니다. 크면 True(1), 작거나 같으면 False(0)가 담긴 똑같은 모양의 마스크 텐서가 만들어집니다.

 

weight.mul_(mask)
파이토치에서 함수 뒤에 언더바(_)가 붙는 연산(mul_)은 원본 데이터를 복사하지 않고 그 자리에서 바로 수정(In-place 연산)함을 뜻합니다. 원래 가중치에 마스크를 원소별로 곱해버립니다. 0이 곱해진 자리는 자연스럽게 0이 됩니다.

 

 

지금부터 5가지 방식을 하나씩 살펴보겠습니다.


2. 프루닝 그래뉴얼러티(Granularity)란?

CNN(합성곱 신경망)의 Convolution Layer 가중치는 보통 다음과 같은 4차원 텐서 모양을 가집니다.

weight.shape = (출력 채널 수 c_o, 입력 채널 수 c_i, 커널 높이 k_h, 커널 너비 k_w)

프루닝 그래뉴얼러티는 "이 4차원 텐서 안에서 어떤 단위(덩어리)로 가중치를 묶어서 제거할 것인가"를 정의합니다.

다음과 같이 5가지 방식을 "Irregular(불규칙적)"부터 "Regular(규칙적)"까지 스펙트럼으로 보여주고 있습니다.

방식 제거 단위 특징
① Fine-grained (Unstructured) 가중치 1개씩 가장 자유롭고 정확도 손실이 적음
② Pattern-based 정해진 패턴(N:M)으로 자유도와 가속화의 절충안
③ Vector-level 벡터 단위 구조적 정보를 일부 보존
④ Kernel-level 커널(k_h × k_w) 단위 더 큰 단위로 구조적
⑤ Channel-level 채널 전체 단위 가장 규칙적, 가속에 가장 유리

핵심 트레이드오프(Trade-off)는 다음과 같습니다.

  • Irregular(불규칙적, Fine-grained 쪽): 가중치를 자유롭게 골라 제거할 수 있어서 정확도를 높게 유지하기 좋습니다. 하지만 결과물이 듬성듬성한(Sparse) 행렬이 되기 때문에, 일반 하드웨어(GPU 등)에서 속도를 높이기가 어렵습니다.
  • Regular(규칙적, Channel-level 쪽): 통째로 잘라내기 때문에 프루닝 후에는 그냥 더 작은 행렬이 됩니다. 그래서 별도의 특수한 처리 없이도 연산 속도를 쉽게 높일 수 있습니다. 다만 통째로 잘라내야 하므로 선택의 자유도가 낮아 정확도 손실이 더 클 수 있습니다.

즉, "정확도(왼쪽, Fine-grained)"와 "하드웨어 가속 효율(오른쪽, Channel-level)" 사이에서 적절한 지점을 선택하는 것이 프루닝 그래뉴얼러티의 핵심입니다.

이제부터 실습 노트북의 코드를 참고하여, 작은 예제 텐서로 5가지 방식을 직접 구현해 보겠습니다.

예제용 작은 가중치 텐서 만들기

실제 모델을 학습시키지 않고도 동작 원리를 이해할 수 있도록, 아주 작은 Convolution 가중치 텐서를 하나 만들어 보겠습니다.

import torch

torch.manual_seed(0)

# (출력 채널 4개, 입력 채널 2개, 커널 3x3)인 작은 가중치 텐서
weight = torch.randn(4, 2, 3, 3)
print(weight.shape)  # torch.Size([4, 2, 3, 3])
print("전체 원소 개수:", weight.numel())  # 4 * 2 * 3 * 3 = 72

weight 텐서를 가지고 5가지 방식으로 각각 프루닝을 해보겠습니다. 모두 Sparsity = 0.5(전체의 약 50% 제거) 기준으로 진행합니다.


3. ① Fine-grained (Unstructured) Pruning

Fine-grained Pruning은 가장 작은 단위, 즉 개별 가중치 하나하나를 기준으로 프루닝하는 방식입니다.

위에서 본 기본 프루닝 함수가 바로 Fine-grained 방식이에요.

  • 장점: 어떤 가중치를 제거할지 가장 자유롭게 고를 수 있어서, 같은 Sparsity 대비 정확도 손실이 가장 적습니다.
  • 단점: 0이 흩어져 있는 형태(Sparse Matrix)가 되기 때문에, 일반적인 GPU/CPU 연산에서는 속도 향상으로 이어지기 어렵습니다. (전용 하드웨어나 라이브러리가 필요)

예제 코드

def fine_grained_prune(tensor: torch.Tensor, sparsity : float) -> torch.Tensor:
    """
    magnitude-based pruning for single tensor
    :param tensor: torch.(cuda.)Tensor, weight of conv/fc layer
    :param sparsity: float, pruning sparsity
        sparsity = #zeros / #elements = 1 - #nonzeros / #elements
    :return:
        torch.(cuda.)Tensor, mask for zeros
    """
    sparsity = min(max(0.0, sparsity), 1.0)
    if sparsity == 1.0:
        tensor.zero_()
        return torch.zeros_like(tensor)
    elif sparsity == 0.0:
        return torch.ones_like(tensor)

    num_elements = tensor.numel()

    ##################### YOUR CODE STARTS HERE #####################
    # Step 1: 제거할 원소 개수 계산 (please use round())
    num_zeros = round(tensor.numel()*sparsity)
    # Step 2: 가중치 weight 의 중요도를 절대값으로 계산
    importance = torch.abs(tensor)
    # Step 3: pruning threshold 계산
    threshold = torch.kthvalue(importance.flatten(), num_zeros)[0]
    # Step 4: get binary mask (1 for nonzeros, 0 for zeros)
    mask = importance > threshold
    ##################### YOUR CODE ENDS HERE #######################

    # Step 5: apply mask to prune the tensor
    tensor.mul_(mask)
    return mask

sparsity = 0.5
fine_grained_mask = fine_grained_prune(weight.clone(), sparsity)
draw_weight_distribution(fine_grained_mask, "fine_grained_prune")

마스크를 시각화해보면, 0과 1이 텐서 전체에 체스판처럼 불규칙하게 흩어져 있는 것을 확인할 수 있습니다.

(가장 왼쪽 "Irregular" 패턴과 동일합니다.)

검정부분이 마스크, 제거되는 부분입니다. 불규칙하게 흩어져 있습니다.


4. ② Pattern-based Pruning

Pattern-based Pruning은 Fine-grained처럼 개별 가중치를 제거하되, 완전히 자유롭게 고르는 것이 아니라 정해진 '패턴'을 따르도록 강제하는 방식입니다.

가장 대표적인 예시가 N:M Sparsity입니다.

예를 들어 "2:4 패턴"이란, 연속된 4개의 가중치 묶음마다 반드시 2개를 제거(0)하고 2개를 남긴다는 규칙입니다.

  • 장점: Fine-grained보다는 규칙적이라서, NVIDIA의 최신 GPU(Sparse Tensor Core)처럼 이 패턴을 인식해서 가속해주는 전용 하드웨어의 도움을 받을 수 있습니다.
  • 단점: 완전히 자유로운 Fine-grained보다는 선택의 폭이 좁아서, 정확도 면에서 약간의 손해를 볼 수 있습니다.

한 마디로 "자유도(정확도)"와 "규칙성(가속화)"의 절충안이라고 볼 수 있습니다.

예제 코드 (2:4 패턴)

def pattern_nm_prune(weight: torch.Tensor, n: int, m: int) -> torch.Tensor:
    """
    N:M pattern-based pruning을 수행합니다.

    연속된 m개의 weight 그룹마다 절댓값 기준으로
    가장 큰 n개는 유지하고, 나머지 m-n개는 0으로 만듭니다.

    Args:
        weight: pruning할 weight tensor
        n: 그룹마다 유지할 weight 개수
        m: 그룹 크기

    Returns:
        weight와 같은 shape의 pruning mask
    """
    original_shape = weight.shape

    # flatten 후 m개씩 그룹화
    flat = weight.reshape(-1, m)

    # 중요도 계산: 절댓값
    importance = flat.abs()

    # mask 생성
    mask = torch.zeros_like(flat)

    if n > 0:
        # 각 그룹에서 중요도가 큰 n개 인덱스 선택
        keep_idx = torch.topk(importance, k=n, dim=1, largest=True).indices

        # 선택된 위치만 1로 설정
        mask.scatter_(dim=1, index=keep_idx, value=1)

    # pruning 적용
    with torch.no_grad():
        flat.mul_(mask)

    return mask.reshape(original_shape)

pattern_mask = pattern_nm_prune(weight.clone(), n=2, m=4)
draw_weight_distribution(pattern_mask, "Pattern NM prune")

이 방식은 Sparsity 자체는 Fine-grained와 동일하게 50%이지만, "4개 중 2개"라는 규칙을 항상 지킨다는 점이 다릅니다.

이 규칙성 덕분에 하드웨어가 "각 그룹에서 어떤 2개가 살아남았는지"만 기록하면 되므로 압축과 가속이 더 쉬워집니다.

5. ③ Vector-level Pruning

Vector-level Pruning은 가중치를 개별적으로 보지 않고, 벡터(Vector) 단위로 묶어서 한꺼번에 제거하는 방식입니다.

가중치 텐서 (c_o, c_i, k_h, k_w)에서 마지막 차원인 k_w(커널 너비) 방향의 벡터를 하나의 단위로 묶습니다.

즉, (c_o, c_i, k_h) 조합 하나당 길이 k_w짜리 벡터 하나가 만들어지고, 이 벡터 전체를 통째로 제거할지 말지를 결정합니다.

  • 중요도 계산 방법: 벡터 안에 있는 값들의 절댓값을 모두 더한 값(L1 norm)을 그 벡터의 중요도로 사용합니다.
  • 장점: 개별 가중치보다 더 큰 단위로 제거되므로, Fine-grained보다 구조적인 정보를 더 잘 보존합니다.
  • 단점: Fine-grained보다는 선택의 자유도가 줄어들어 정확도 손실이 조금 더 클 수 있습니다.

예제 코드

def vector_level_prune(weight: torch.Tensor, sparsity: float) -> torch.Tensor:
    """
    벡터 단위로 가중치를 프루닝하는 함수입니다.
    Args:
        weight: 프루닝할 가중치 텐서
        sparsity: 프루닝할 비율 (0~1 사이 값)

    Returns:
        프루닝 마스크 텐서
    """
    # sparsity 값을 0~1 사이로 제한
    sparsity = min(1.0, max(0.0, sparsity))

    # 특수한 경우 처리
    if sparsity == 1.0:  # 모든 가중치를 제거
        weight.zero_()
        return torch.zeros_like(weight)
    elif sparsity == 0.0:  # 모든 가중치를 유지
        return torch.ones_like(weight)

    # Prunning할 벡터 개수를 구합니다.
    # (out, in, height, width) 로 마지막 차원으로 prune합니다.
    num_vectors = weight.shape[0] * weight.shape[1] * weight.shape[2]
    num_prune = round(num_vectors * sparsity)

    # 각 벡터의 중요도를 절댓값 합으로 계산
    importance = weight.abs().sum(dim=-1, keepdim=True)

    # pruning trheshold를 계산
    threshold = torch.kthvalue(importance.flatten(), num_prune)[0]

    # threshold보다 큰 벡터는 유지(1), 작은 벡터는 제거(0)
    mask = importance > threshold

    # 마스크를 가중치와 동일한 크기로 확장
    mask = mask.expand_as(weight)

    # 마스크를 적용하여 프루닝 수행
    weight.mul_(mask)

    return mask

sparsity = 0.5
vector_mask = vector_level_prune(weight.clone(), sparsity)
draw_weight_distribution(vector_mask, "Vector level prune")

마스크를 그려보면, kw 방향(가로줄)으로 한 줄(3칸)씩 통째로 0이 되거나 1이 되는 것을 확인할 수 있습니다. Fine-grained와 달리 "한 칸씩"이 아니라 "한 줄씩" 단위로 패턴이 나타납니다.

6. ④ Kernel-level Pruning

Kernel-level Pruning은 Vector-level보다 한 단계 더 큰 단위인 커널(Kernel) 전체를 기준으로 프루닝합니다.

Convolution 가중치 (c_o, c_i, k_h, k_w)에서, 하나의 (c_o, c_i) 조합에 해당하는 k_h × k_w 크기의 2D 커널 전체를 하나의 단위로 보고, 이 커널 전체를 통째로 제거할지 결정합니다.

  • 중요도 계산 방법: 커널(k_h × k_w) 안의 모든 값의 절댓값 합을 그 커널의 중요도로 사용합니다.
  • 장점: Vector-level보다 더 큰 단위로 정리되기 때문에, 연산을 건너뛰기가 더 쉬워서 하드웨어 가속에 더 유리합니다.
  • 단점: 더 큰 단위로 묶어서 제거하므로, 정확도 손실 위험이 더 커질 수 있습니다.

예제 코드

def kernel_level_prune(weight: torch.Tensor, sparsity: float) -> torch.Tensor:
    """
    Conv2d weight에 대해 kernel-level pruning을 수행합니다.
    Conv2d weight shape:
        (out_channels, in_channels, kernel_h, kernel_w)
    Pruning 단위:
        weight[out_channel, in_channel, :, :]
    Args:
        weight: pruning할 Conv2d weight tensor
        sparsity: 제거할 kernel 비율
    Returns:
        weight와 동일한 shape의 pruning mask
    """
    sparsity = min(1.0, max(0.0, sparsity))
    if sparsity == 1.0:
        with torch.no_grad():
            weight.zero_()
        return torch.zeros_like(weight)

    if sparsity == 0.0:
        return torch.ones_like(weight)

    out_channels, in_channels, kernel_h, kernel_w = weight.shape

    # 전체 2D kernel 개수
    num_kernels = out_channels * in_channels
    num_pruned_kernels = round(num_kernels * sparsity)

    if num_pruned_kernels == 0:
        return torch.ones_like(weight)

    # 각 2D kernel의 중요도 계산: L1 norm
    # shape: (out_channels, in_channels, 1, 1)
    importance = weight.abs().sum(dim=(2, 3), keepdim=True)

    # 중요도가 작은 kernel을 제거하기 위한 threshold
    threshold = torch.kthvalue(importance.flatten(), num_pruned_kernels)[0]

    # threshold보다 큰 kernel만 유지
    mask = importance > threshold

    # 3x3 kernel 전체에 같은 mask 적용
    mask = mask.expand_as(weight)
    weight.mul_(mask)
    return mask

sparsity = 0.5
kernel_mask = kernel_level_prune(weight.clone(), sparsity)
draw_weight_distribution(kernel_mask, title="Kernel-level Pruning Mask")

이 경우 3x3 커널 전체가 통째로 0이 되거나 그대로 유지됩니다. 즉, 4×2 = 8개의 커널 중 4개의 커널 전체(각 9개 원소)가 한꺼번에 사라집니다.

7. ⑤ Channel-level Pruning

Channel-level Pruning은 5가지 방식 중 가장 큰 단위, 즉 채널(Channel) 전체를 기준으로 프루닝합니다.

이 코드에서는 입력 채널(c_i) 단위로 프루닝을 수행합니다. 즉, 특정 입력 채널 번호 하나를 골라서, 그 채널에 해당하는 모든 (c_o, k_h, k_w) 가중치를 한꺼번에 제거합니다.

  • 중요도 계산 방법: 해당 입력 채널에 속한 모든 가중치(c_o, k_h, k_w 방향)의 절댓값 합을 그 채널의 중요도로 사용합니다.
  • 장점: 채널을 통째로 제거하면, 프루닝 후 모델은 단순히 더 작은 채널 수를 가진 모델이 됩니다. 별도의 마스크나 특수 연산 없이도 일반적인 행렬 연산으로 바로 속도 향상을 얻을 수 있어 가장 하드웨어 친화적입니다.
  • 단점: 채널 전체를 한 번에 결정해야 하므로 선택의 폭이 가장 좁고, 5가지 방식 중 정확도 손실이 가장 클 수 있습니다.

예제 코드

ef input_channel_level_prune(weight: torch.Tensor, sparsity: float) -> torch.Tensor:
    """
    Conv2d weight에 대해 input-channel-level pruning을 수행합니다.
    Conv2d weight shape:
        (out_channels, in_channels, kernel_h, kernel_w)
    Pruning 단위:
        weight[:, input_channel, :, :]
    Args:
        weight: pruning할 Conv2d weight tensor
        sparsity: 제거할 input channel 비율
    Returns:
        weight와 동일한 shape의 pruning mask
    """
    sparsity = min(1.0, max(0.0, sparsity))

    if sparsity == 1.0:
        with torch.no_grad():
            weight.zero_()
        return torch.zeros_like(weight)

    if sparsity == 0.0:
        return torch.ones_like(weight)

    out_channels, in_channels, kernel_h, kernel_w = weight.shape

    # 제거할 input channel 수
    num_pruned_channels = round(in_channels * sparsity)

    if num_pruned_channels == 0:
        return torch.ones_like(weight)

    # 각 input channel의 중요도 계산
    # shape: (1, in_channels, 1, 1)
    importance = weight.abs().sum(dim=(0, 2, 3), keepdim=True)

    # 중요도가 낮은 channel 기준 threshold
    threshold = torch.kthvalue(importance.flatten(), num_pruned_channels)[0]

    # threshold보다 큰 input channel만 유지
    mask = importance > threshold

    # 전체 weight shape으로 확장
    mask = mask.expand_as(weight)
    weight.mul_(mask)
    return mask

sparsity = 0.5
channel_mask = input_channel_level_prune(weight.clone(), sparsity)
draw_weight_distribution(channel_mask, title="Channel-level Pruning Mask")

이 예제에서는 입력 채널이 2개뿐이므로, Sparsity 0.5를 적용하면 입력 채널 1개 전체(36개 원소)가 한꺼번에 사라지게 됩니다.

8. 5가지 방식 한눈에 비교하기

지금까지 살펴본 5가지 방식을 정리하면 다음과 같습니다.

방식 제거 단위 중요도 계산 정확도 하드웨어 가속
① Fine-grained 가중치 1개 |w| 가장 높음 가장 어려움
② Pattern-based (N:M) 정해진 패턴 (예: 2:4) 그룹 내 |w| 높음 전용 HW 필요
③ Vector-level 벡터 (k_w 방향) 벡터의 L1 norm 중간 중간
④ Kernel-level 커널 (k_h × k_w) 커널의 L1 norm 중간~낮음 중간~쉬움
⑤ Channel-level 채널 전체 채널의 L1 norm 가장 낮음 가장 쉬움

표를 보시면 알 수 있듯이, 왼쪽(①)으로 갈수록 정확도는 좋아지지만 가속은 어려워지고, 오른쪽(⑤)으로 갈수록 가속은 쉬워지지만 정확도 손실 위험이 커집니다. 실제 프로젝트에서는 "이 모델을 어떤 하드웨어에 배포할 것인가", "정확도를 얼마나 양보할 수 있는가"를 고려해서 적절한 방식을 선택하게 됩니다.

9. Sparsity : Network Pruning 후 메모리를 절약하는 방법

- CSR과 CSC를 활용한 Sparse Matrix 저장 방식 -

딥러닝 모델은 수많은 파라미터로 구성되어 있습니다.
모델이 커질수록 성능은 좋아질 수 있지만, 그만큼 메모리 사용량연산 비용도 증가합니다.

이 문제를 줄이기 위한 대표적인 방법 중 하나가 Network Pruning입니다.

 

Pruning만 하면 메모리가 줄어들까요?

주의할 점은, 단순히 weight 값을 0으로 만든다고 해서 메모리가 자동으로 줄어드는 것은 아니라는 점입니다.

일반적인 dense matrix는 값이 0이어도 그대로 저장합니다.

예를 들어 float32 기준으로 weight 하나는 4바이트를 차지합니다.

[0.1, 0.0, 0.0, 0.4]

위와 같이 0이 많아도 dense format에서는 모든 값을 저장합니다.

따라서 메모리를 실제로 줄이려면, 0을 저장하지 않는 방식으로 바꿔야 합니다.
이때 사용하는 대표적인 저장 방식이 CSRCSC입니다.

CSR과 CSC는 희소 행렬을 효율적으로 저장하기 위한 포맷입니다.

CSR: Compressed Sparse Row

CSR은 행 기준으로 0이 아닌 값만 저장하는 방식입니다.

CSR은 보통 다음 3가지 배열로 구성됩니다.

Values 0이 아닌 실제 weight 값
Column Indices 각 값이 위치한 열 번호
Row Pointer 각 행의 데이터가 시작되는 위치

예를 들어 한 행에 있는 non-zero 값들을 순서대로 모아 저장하고, 각 행이 어디서 시작하고 끝나는지를 Row Pointer로 관리합니다. 따라서 CSR은 행 단위 접근이 빠른 방식입니다.

CSR

CSC: Compressed Sparse Column

CSC는 CSR과 반대로 열 기준으로 0이 아닌 값만 저장하는 방식입니다.

CSC는 다음 3가지 배열로 구성됩니다.

구성 요소 의미
Values 0이 아닌 실제 weight 값
Row Indices 각 값이 위치한 행 번호
Column Pointer 각 열의 데이터가 시작되는 위치

즉, 열 단위로 non-zero 값을 모아서 저장하고, 각 열이 어디서 시작하고 끝나는지를 Column Pointer로 관리합니다.

따라서 CSC는 열 단위 접근이 빠른 방식입니다.


CSR과 CSC 비교

구분 CSR CSC
저장 기준 행 기준 열 기준
Pointer Row Pointer Column Pointer
Index Column Indices Row Indices
유리한 접근 행 접근 열 접근
사용 예 행렬-벡터 곱에서 행 단위 계산 열 단위 연산, column slicing


Pruning 후 CSR/CSC를 쓰면 메모리가 줄어드는 이유

Pruning 후 많은 weight가 0이 되면, 전체 행렬을 그대로 저장할 필요가 없습니다.

Dense format에서는 0도 모두 저장하지만, CSR/CSC는 다음 정보만 저장합니다.

  • 0이 아닌 값
  • 그 값의 위치
  • 행 또는 열의 시작 위치

즉, 0을 저장하지 않기 때문에 메모리를 줄일 수 있습니다.

Dense 저장:
모든 weight 저장

Sparse 저장:
0이 아닌 weight + 위치 정보만 저장

CSR/CSC는 0을 저장하지 않는 대신, 위치 정보를 저장해야 합니다.

예를 들어 sparse format에서는 보통 다음 정보가 추가로 필요합니다.

value  +  index  +  pointer

따라서 0이 충분히 많지 않으면, 오히려 인덱스 저장 비용 때문에 메모리 절감 효과가 작을 수 있습니다.

Sparsity 의미 메모리 절감 가능성
30% 30%가 0 효과 작음
50% 절반이 0 애매함
70% 이상 대부분 0 효과 가능
90% 이상 매우 희소 효과 큼

 

중요한 점은, CSR/CSC로 저장한다고 해서 추론 속도까지 항상 빨라지는 것은 아니라는 점입니다.

GPU는 일반적으로 dense matrix multiplication에 매우 최적화되어 있습니다.
반면 sparse matrix 연산은 index를 따라가야 하므로 메모리 접근이 불규칙해질 수 있습니다.

따라서 결과는 다음처럼 정리할 수 있습니다.

항목 기대 효과
모델 파일 크기 줄어들 수 있음
RAM/VRAM 사용량 줄어들 수 있음
추론 속도 항상 빨라지지는 않음
GPU 효율 하드웨어/라이브러리 지원에 따라 달라짐

Network Pruning은 weight를 0으로 만들어 희소 행렬을 만들고, CSR/CSC는 그 희소 행렬을 효율적으로 저장하여 메모리를 절약하는 방식입니다.

 

+ Recent posts