블록 경계 버전

1D LayoutTensor a와 1D LayoutTensor b의 1D 합성곱을 계산하여 1D LayoutTensor output에 저장하는 커널을 구현하세요.

참고: 일반적인 경우를 처리해야 합니다. 스레드당 전역 읽기 2회, 전역 쓰기 1회만 필요합니다.

구성

  • 입력 배열 크기: SIZE_2 = 15
  • 커널 크기: CONV_2 = 4
  • 블록당 스레드 수: TPB = 8
  • 블록 수: 2
  • 공유 메모리: 입력용 TPB + CONV_2 - 1

참고:

  • 확장 로딩: 경계 겹침 영역을 고려
  • 블록 가장자리: 블록 경계를 넘는 데이터 처리
  • 메모리 레이아웃: 공유 메모리의 효율적 활용
  • 동기화: 적절한 스레드 간 조율

완성할 코드

comptime SIZE_2 = 15
comptime CONV_2 = 4
comptime BLOCKS_PER_GRID_2 = (2, 1)
comptime THREADS_PER_BLOCK_2 = (TPB, 1)
comptime in_2_layout = Layout.row_major(SIZE_2)
comptime out_2_layout = Layout.row_major(SIZE_2)
comptime conv_2_layout = Layout.row_major(CONV_2)


fn conv_1d_block_boundary[
    in_layout: Layout, out_layout: Layout, conv_layout: Layout, dtype: DType
](
    output: LayoutTensor[dtype, out_layout, MutAnyOrigin],
    a: LayoutTensor[dtype, in_layout, ImmutAnyOrigin],
    b: LayoutTensor[dtype, conv_layout, ImmutAnyOrigin],
):
    global_i = Int(block_dim.x * block_idx.x + thread_idx.x)
    local_i = Int(thread_idx.x)
    # FILL ME IN (roughly 18 lines)


전체 파일 보기: problems/p13/p13.mojo

  1. LayoutTensor[dtype, Layout.row_major(TPB + CONV_2 - 1), MutAnyOrigin, address_space = AddressSpace.SHARED].stack_allocation()으로 공유 메모리 할당
  2. 메인 데이터 로드: shared_a[local_i] = a[global_i]
  3. 경계 데이터 로드: if local_i < CONV_2 - 1일 때 다음 블록의 데이터 처리
  4. 커널 로드: shared_b[local_i] = b[local_i]
  5. 입력 범위 안에서 합산: if global_i + j < SIZE_2

코드 실행

솔루션을 테스트하려면 터미널에서 다음 명령어를 실행하세요:

pixi run p13 --block-boundary
pixi run -e amd p13 --block-boundary
pixi run -e apple p13 --block-boundary
uv run poe p13 --block-boundary

퍼즐을 아직 풀지 않았다면 출력은 다음과 같습니다:

out: HostBuffer([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
expected: HostBuffer([14.0, 20.0, 26.0, 32.0, 38.0, 44.0, 50.0, 56.0, 62.0, 68.0, 74.0, 80.0, 41.0, 14.0, 0.0])

솔루션

fn conv_1d_block_boundary[
    in_layout: Layout, out_layout: Layout, conv_layout: Layout, dtype: DType
](
    output: LayoutTensor[dtype, out_layout, MutAnyOrigin],
    a: LayoutTensor[dtype, in_layout, ImmutAnyOrigin],
    b: LayoutTensor[dtype, conv_layout, ImmutAnyOrigin],
):
    global_i = Int(block_dim.x * block_idx.x + thread_idx.x)
    local_i = Int(thread_idx.x)
    # first: need to account for padding
    shared_a = LayoutTensor[
        dtype,
        Layout.row_major(TPB + CONV_2 - 1),
        MutAnyOrigin,
        address_space = AddressSpace.SHARED,
    ].stack_allocation()
    shared_b = LayoutTensor[
        dtype,
        Layout.row_major(CONV_2),
        MutAnyOrigin,
        address_space = AddressSpace.SHARED,
    ].stack_allocation()
    if global_i < SIZE_2:
        shared_a[local_i] = a[global_i]
    else:
        shared_a[local_i] = 0

    # second: load elements needed for convolution at block boundary
    if local_i < CONV_2 - 1:
        # indices from next block
        next_idx = global_i + TPB
        if next_idx < SIZE_2:
            shared_a[TPB + local_i] = a[next_idx]
        else:
            # Initialize out-of-bounds elements to 0 to avoid reading from uninitialized memory
            # which is an undefined behavior
            shared_a[TPB + local_i] = 0

    if local_i < CONV_2:
        shared_b[local_i] = b[local_i]

    barrier()

    if global_i < SIZE_2:
        var local_sum: output.element_type = 0

        @parameter
        for j in range(CONV_2):
            if global_i + j < SIZE_2:
                local_sum += shared_a[local_i + j] * shared_b[j]

        output[global_i] = local_sum


확장된 공유 메모리를 사용해 블록 경계를 넘는 1D 합성곱을 처리하는 솔루션입니다. 자세히 분석해 보겠습니다:

메모리 레이아웃과 크기 계산

테스트 구성:
- 전체 배열 크기: SIZE_2 = 15
- 그리드: 2 블록 × 8 스레드
- 합성곱 커널: CONV_2 = 4

Block 0 공유 메모리:  [0 1 2 3 4 5 6 7|8 9 10]  // TPB(8) + (CONV_2-1)(3) 패딩
Block 1 공유 메모리:  [8 9 10 11 12 13 14 0|0 0 0]  // 두 번째 블록. 데이터(7) + 그리드 채움용 패딩(1) + (CONV_2-1)(3) 패딩

크기 계산:
- 메인 데이터: TPB개 (8)
- 겹침 영역: CONV_2 - 1개 (4 - 1 = 3)
- 합계: TPB + CONV_2 - 1 = 8 + 4 - 1 = 11개

구현 상세

  1. 공유 메모리 할당:

    # 합성곱 윈도우에 필요한 패딩을 먼저 고려
    shared_a = LayoutTensor[dtype, Layout.row_major(TPB + CONV_2 - 1), MutAnyOrigin, address_space = AddressSpace.SHARED].stack_allocation()
    shared_b = LayoutTensor[dtype, Layout.row_major(CONV_2), MutAnyOrigin, address_space = AddressSpace.SHARED].stack_allocation()
    

    이렇게 하면 블록 데이터와 겹침 영역을 모두 담기에 충분한 공간이 확보됩니다.

  2. 데이터 로딩 전략:

    # 메인 블록 데이터
    if global_i < SIZE_2:
        shared_a[local_i] = a[global_i]
    else:
        shared_a[local_i] = 0
    
    # 다음 블록의 경계 데이터
    if local_i < CONV_2 - 1:
        next_idx = global_i + TPB
        if next_idx < SIZE_2:
            shared_a[TPB + local_i] = a[next_idx]
        else:
            # 범위 밖 원소를 0으로 초기화하여
            # 미정의 동작을 유발하는 초기화되지 않은 메모리 읽기를 방지
            shared_a[TPB + local_i] = 0
    
    • local_i < CONV_2 - 1인 스레드만 경계 데이터를 로드
    • 불필요한 스레드 분기 방지
    • 메인 데이터 로드의 메모리 병합 유지
    • 범위 밖 원소를 명시적으로 0으로 초기화하여 미정의 동작 방지
  3. 커널 로딩:

    if local_i < b_size:
        shared_b[local_i] = b[local_i]
    
    • 스레드당 1회 로드
    • 커널 크기로 범위 제한
  4. 합성곱 연산:

    if global_i < SIZE_2:
        var local_sum: output.element_type = 0
        @parameter
        for j in range(CONV_2):
            if global_i + j < SIZE_2:
                local_sum += shared_a[local_i + j] * shared_b[j]
    
    • @parameter로 컴파일 타임 루프 전개
    • output.element_type으로 적절한 타입 추론
    • 의미적으로 올바른 경계 검사: 유효한 입력 위치에서만 합성곱 계산

메모리 접근 패턴 분석

  1. Block 0 접근 패턴:

    Thread 0: [0 1 2 3] × [0 1 2 3]
    Thread 1: [1 2 3 4] × [0 1 2 3]
    Thread 2: [2 3 4 5] × [0 1 2 3]
    ...
    Thread 7: [7 8 9 10] × [0 1 2 3]  // 겹침 영역 데이터 사용
    
  2. Block 1 접근 패턴: Thread 4부터는 global_i + j < SIZE_2False가 되어 해당 반복을 건너뛰는 점에 주목하세요.

    Thread 0: [8  9 10 11] × [0 1 2 3]
    Thread 1: [9 10 11 12] × [0 1 2 3]
    ...
    Thread 4: [12 13 14] × [0 1 2]       // 끝부분 제로 패딩
    Thread 5: [13 14]    × [0 1]
    Thread 6: [14]       × [0]
    Thread 7: 건너뜀                      // 모든 j에 대해 global_i + j < SIZE_2가 false, 연산 없음
    

성능 최적화

  1. 메모리 병합:

    • 메인 데이터 로드: 인접 스레드가 연속된 메모리에 접근
    • 경계 데이터: 필요한 스레드만 참여
    • 단일 배리어 동기화 지점
  2. 스레드 분기 최소화:

    • 메인 로딩과 경계 로딩의 깔끔한 분리
    • 워프 내 균일한 연산 패턴
    • 효율적인 경계 검사
  3. 공유 메모리 활용:

    • 블록 경계 처리에 최적화된 크기 설정
    • 접근 패턴에서 뱅크 충돌 없음
    • 로드한 데이터의 효율적 재사용
  4. 경계 처리:

    • 범위 밖 원소를 명시적으로 0으로 설정하여 초기화되지 않은 공유 메모리 읽기 방지
    • global_i + j < SIZE_2로 공유 메모리가 아닌 실제 입력 범위 기준의 경계 검사
    • 불필요한 연산 없이 적절한 엣지 케이스 처리

경계 조건 개선

이 솔루션은 공유 메모리 범위를 확인하는 대신 if global_i + j < SIZE_2:를 사용합니다. 이 패턴은:

  • 수학적으로 정확: 입력 데이터가 실제로 존재하는 위치에서만 합성곱 계산
  • 더 효율적: 입력 배열을 넘어선 위치에 대한 불필요한 연산 회피
  • 더 안전: 공유 메모리의 제로 패딩 동작에 의존하지 않음

이 구현은 블록 간 합성곱을 효율적으로 수행하면서 다음을 유지합니다:

  • 적절한 경계 검사를 통한 메모리 안전성
  • 최적화된 메모리 접근을 통한 높은 성능
  • LayoutTensor 추상화를 활용한 깔끔한 코드 구조
  • 최소한의 동기화 오버헤드
  • 수학적으로 건전한 경계 처리