Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

소개

FuriosaAI의 Tensor Contraction Processor(TCP)는 추론 워크로드를 위한 대규모 병렬 AI 가속기입니다. PyTorch나 XLA 같은 고수준 프레임워크는 메모리 레이아웃과 하드웨어 스케줄링을 추상화해 감춥니다. 이와 달리 TCP는 저수준 커널 API처럼 바이트 단위까지 따지지 않아도 프로그래머가 직접 제어할 수 있게 합니다.

TCP의 가상 명령어 집합 아키텍처(Virtual ISA, 또는 vISA)는 이러한 제어 기능을 제공하는 프로그래밍 인터페이스입니다. vISA를 사용하면 프로그래머는 텐서 단위로 사고하면서 메모리 할당과 Tensor Unit 스케줄링을 직접 관리할 수 있습니다. 이 매뉴얼은 주로 작성자가 AI 에이전트를 이끌어 vISA 커널을 작성, 검증, 최적화하도록 돕습니다. 또한 컴파일러 개발자가 vISA를 생성할 때 필요한 기술 자료도 제공합니다. 공개된 릴리스의 API는 furiosa-opt-std rustdoc을 공식 기준으로 삼습니다. 이 책은 커널 설계 결정을 내리고 검토하는 방법을 설명합니다. 두 독자층 모두 Rust의 기초를 알고 있다고 가정합니다. 필요하다면 언어 매뉴얼을 참고하십시오.

작업별로 이 책 읽기

빠른 시작에서는 기본 템플릿 커널 패턴과 함께 매핑, 이동, 계산, 엔드투엔드 사례, 스케줄링, 도구로 이어지는 학습 경로를 소개합니다.

Warning

알파 테스트 빌드: 실험적 소프트웨어

이 소프트웨어는 기술 평가와 내부 테스트만을 위한 초기 단계의 실험적이고 불완전한 빌드입니다.

이 소프트웨어를 프로덕션 작업이나 중요한 작업, 또는 중요한 데이터에 사용하기 전에 반드시 Furiosa 엔지니어와 상의하십시오.

여러분의 피드백은 저희 개발에 매우 중요합니다. 피드백을 보내 주십시오.

라이선스

이 문서와 furiosa-opt 저장소 전체는 Apache License Version 2.0에 따라 라이선스가 부여됩니다.

빠른 시작

이 색인은 실행할 수 있는 기본 템플릿 벡터 커널을 처음부터 구성합니다. 커널 설계의 실행 가능한 다섯 가지 패턴은 하드웨어 결정을 한 번에 하나씩 추가합니다. 상수 덧셈, 요소별 곱셈, 내적, GEMV, GEMM에 대해 소스 코드를 기반으로 한 디바이스 및 호스트 오라클 워크스루는 이 한 장에서만 다룹니다.

검증의 의미와 주의 사항은 커널 검증에서만 정의합니다.

HBM은 호스트 전송에 사용되는 고대역폭 메모리이고, DM은 Tensor Unit이 사용하는 Chip 내장 Data Memory입니다.

이 기본 패턴들을 익힌 후에는 매핑, 이동, 계산, 엔드투엔드 사례, 스케줄링, 도구 순서로 계속 진행하십시오. 이제 고급 예제는 각 하위 시스템 장이 맡고, 여러 요소를 조합한 모델 튜토리얼은 엔드투엔드 사례 장이 Collect합니다.

하위 페이지를 순서대로 읽으십시오

설치 방법과 첫 번째 프로젝트는 설치와 도구를 읽으십시오. 텐서 형태와 축약 수학은 텐서와 축약을 읽으십시오. 실행 가능한 다섯 가지 패턴 섹션은 커널 설계를 읽으십시오. 컴파일, CPU, NPU, 스케줄 검증 근거는 커널 검증을 읽으십시오.

참조 장으로 계속 진행하십시오

실행 가능한 패턴은 한 번의 하드웨어 패스에 들어가고 Slice당 512 KB인 DM 용량 안에 들어가는 텐서를 처리합니다. 타일이 시간에 따라 순차적으로 실행될 때는 시간 분할을 사용하고, 타일이 병렬 하드웨어 유닛에 분산될 때는 공간 분할을 사용하십시오. 텐서 매핑, 텐서 이동, 텐서 계산 순서로 읽으십시오. 이 참조 장들을 읽은 후에는 관련된 매핑, 이동, 계산 장에서 알맞은 고급 패턴을 선택하십시오.

다음 패턴을 선택하십시오

예제를 선택하기 전에 세 참조 장에서 매핑, 이동, 계산 계약을 확립합니다. 커널에 다음으로 적용되는 제약 조건에 맞는 가장 단순한 예제 경로를 사용하십시오.

  • 한 번의 패스에 들어가는 경우: 다음 단계로 넘어갈 필요가 없으며, 앞의 기본 패턴이 그대로 적용됩니다.
  • 레이아웃 순열 또는 패킷 처리: 텐서 이동 아래의 사례 연구: Tensor Unit I/O를 읽으십시오.
  • 리덕션 축이 여러 Chip이나 Cluster에 걸치는 경우: 텐서 계산 아래의 사례 연구: Chip/Cluster 리덕션을 읽으십시오.
  • 조합된 모델 패턴: 엔드투엔드 사례 아래의 사례 연구: Transformer를 읽으십시오.

설치와 도구

설치와 첫 프로그램

이 장에서는 설치와 첫 프로젝트 생성에 필요한 명령 절차를 다룹니다. 커널 패턴을 읽기 전에 이 절차를 먼저 실행하십시오.

호스트 요구 사항

배포된 바이너리에는 GLIBC_2.34가 필요하지만 20.04에는 2.31이 포함되어 있으므로, 호스트는 Ubuntu 22.04 이상의 x86_64-unknown-linux-gnu여야 합니다. macOS나 arm64에서는 모든 작업을 linux/amd64 컨테이너 안에서 실행하십시오.

sudo apt install build-essential libclang-dev   # every build
sudo apt install gcc-aarch64-linux-gnu          # only for the NPU build (cargo furiosa-opt)

아래 툴체인은 다음 항목을 포함하지 않으므로 툴체인보다 먼저 설치하십시오. rustc는 시스템의 cc로 호스트 바이너리를 링크하고, furiosa-opt-std의 빌드 스크립트는 libclang.so를 로드합니다.

스타터 설치와 실행

Furiosa 옵티마이저는 rustc 드라이버이며, 고정된 nightly 버전에 ABI가 묶여 있습니다. 툴체인과 호스트 도구를 설치하고 표준 스타터를 생성한 다음, CPU에서 실행하고 커널을 컴파일하십시오.

rustup toolchain install nightly-2026-05-01
cargo +nightly-2026-05-01 install cargo-binstall
cargo +nightly-2026-05-01 binstall cargo-furiosa-opt
cargo install cargo-generate
cargo generate furiosa-ai/furiosa-opt base-template
cd base-template
cargo run --release --bin gemm
cargo furiosa-opt compile

생성된 프로젝트에서는 rust-toolchain.toml이 같은 채널을 고정합니다. Cargo는 해당 디렉터리에서 이 채널을 자동으로 활성화합니다.

일반 cargo 호출은 CPU에서 빌드하고 실행합니다. cargo furiosa-opt는 NPU용으로 빌드하고 커널을 미리 컴파일합니다.

NPU 요구 사항

NPU 빌드에는 Furiosa SDK와 물리 NPU도 필요합니다. NPU 빌드는 SDK 커널 드라이버와 PE 런타임(furiosa-driver-rngd, furiosa-smi 및 관련 구성 요소)을 통해 디스패치됩니다. SDK 문서를 참조하십시오.

CPU 빌드에는 SDK가 필요하지 않습니다. CPU 빌드는 NPU 의존성 없이 호스트 측에서 실행됩니다.

생성된 프로젝트

이 명령 절차는 base-template 스타터의 뼈대를 생성합니다. 이 템플릿은 실행 가능한 표준 예제이며, 지속적 통합(CI)은 현재 소스를 임의의 이름으로 생성한 뒤 CPU에서 테스트합니다. 생성된 템플릿을 수정하기 전에 텐서와 축약과 커널 검증을 읽으십시오. 에이전트가 변경 사항을 구현할 때는 커널 작성 스킬(Kernel Authoring Skill)을 사용하십시오.

템플릿 레이아웃

생성된 Cargo.toml은 [package.metadata.furiosa-opt]를 선언하며, 이 선언은 해당 패키지를 커널 컴파일 대상으로 지정합니다. src/kernel/에는 각 #[device] 함수가 들어 있고, src/kernel/mod.rs는 이 커널 모듈들을 다시 내보냅니다. src/*.rs에 바로 위치한 파일은 launch(kernel, ...)를 호출하는 호스트 바이너리이며, 각자의 호스트 오라클 테스트를 포함합니다. 각 호스트 바이너리에는 Cargo.toml에 path = "src/<name>.rs"를 지정한 명시적인 [[bin]] 항목이 있습니다. 컴파일러 플러그인은 src/를 루트로 하는 cargo 타깃을 스캔하므로, 호스트 바이너리를 src/bin/, examples/, tests/로 옮기지 마십시오.

base-template/
├── Cargo.toml
├── rust-toolchain.toml
└── src/
    ├── lib.rs
    ├── kernel/
    │   ├── mod.rs
    │   └── <name>_kernel.rs
    └── <name>.rs

커널 추가

  1. #[device(...)] pub fn <name>_kernel(...) 함수를 담은 src/kernel/<name>_kernel.rs를 추가하십시오.
  2. src/kernel/mod.rs에서 이를 다시 내보내십시오.
  3. 호스트 프로그램, 호스트 오라클, launch(<name>_kernel, ...) 호출을 담은 src/<name>.rs를 추가하십시오.
  4. 호스트 프로그램을 Cargo.toml에 [[bin]] 타깃으로 등록하십시오.
  5. Kernel Optimizer 명령 레퍼런스를 참고하여 커널을 실행하십시오.

개발 도구

Furiosa IR Optimizer는 Furiosa SDK 컴파일러를 보완합니다. 프로그래머가 vISA를 직접 작성하거나 다른 컴파일러로 생성할 때 세밀하게 제어할 수 있게 해 줍니다.

Kernel Optimizer

CLI 문법과 컴파일 동작은 Kernel Optimizer 도구 레퍼런스에서 다룹니다.

언어 서버

furiosa-rust-analyzer-proxy는 표준 Rust IDE 기능을 제공하며, Furiosa 매핑 표현식을 읽기 쉬운 형태로 보여 줍니다. Stride<Symbol<A>, 8>처럼 장황한 매핑 타입을 m![A / 8]로 표시합니다. 설치와 설정 방법은 언어 서버 도구 레퍼런스를 참고하십시오.

Schedule Viewer

Schedule Viewer는 실행 타임라인을 시각화하여 성능 병목 지점을 찾는 데 도움을 줍니다. furiosa-opt로 스케줄 JSON 파일을 내보낸 다음 furiosa-schedule-viewer로 여십시오.

설치와 사용 방법은 Schedule Viewer 도구 레퍼런스를 참고하십시오.

텐서와 축약

텐서는 형태에 속한 각 이름 있는 인덱스를 하나의 값에 대응시킵니다. 형태는 이름 있는 축들의 순서 없는 집합이므로 { N = 4, C = 3 }과 { C = 3, N = 4 }는 같은 텐서를 나타냅니다. 그 형태에 순서를 부여한 표현은 익숙한 다차원 배열처럼 동작합니다. 텐서 인덱스는 모든 축에 값을 하나씩 지정하므로 { N = 4, C = 3 }에는 { N: 0, C: 0 }, { N: 0, C: 1 }과 같은 인덱스가 포함됩니다.

텐서차원예시이름 있는 형태
스칼라0D5.2{}
벡터1D[1, 2, 3]{ I = 3 }
행렬2D2 × 4 격자{ I = 2, J = 4 }
이미지 배치4DRGB 이미지 4장{ N = 4, C = 3, H = 256, W = 512 }

텐서 축약은 두 입력을 요소별로 곱하고, 공유 축 중 출력에 없는 축을 모두 리덕션합니다. 모든 축약은 브로드캐스트, 곱셈, 리덕션 단계로 구성됩니다.

연산Einsum브로드캐스트곱셈리덕션
내적(I, I \rightarrow 1)없음.(x_i y_i)(\sum_i x_i y_i)
GEMV(IJ, J \rightarrow I)(I)에 걸친 (x).(A_{ij} x_j)(y_i = \sum_j A_{ij} x_j)
GEMM(IK, KJ \rightarrow IJ)(J)에 걸친 (A)와 (I)에 걸친 (B).(A_{ik} B_{kj})(C_{ij} = \sum_k A_{ik} B_{kj})

이 페이지는 입문 수준의 수학만 다룹니다. 매핑, 이동, 엔진 계약은 각각의 참조 장에서 다룹니다.

커널 설계

첫 번째 벡터 커널

상수 덧셈

목표

벡터의 모든 요소에 대해 \(\text{out}[i] = \text{in}[i] + 1\)을 계산합니다. 이 패턴은 잔차 덧셈과 바이어스 덧셈을 다룹니다.

데이터 이동

벡터를 HBM에서 DM으로 옮기고, 각 DM Slice를 Fetch, Collect, Vector Engine, Commit을 거쳐 스트리밍한 뒤, 결과를 DM에서 HBM으로 다시 옮깁니다.

flowchart TB
    HOST[Host] <-->|PCIe DMA| HBM[(HBM)]
    HBM <-->|Tensor DMA| DM[(DM)]

    subgraph TU[Tensor Unit]
        direction TB
        FE[Fetch] --> CO[Collect] --> VE["Vector AddFxp +1"] --> CM[Commit]
    end

    DM -->|stream| FE
    CM -->|stream| DM

디바이스 소스

이 커널은 Chip 하나와 Cluster 두 개 중 하나를 사용하며, 해당 Cluster의 Slice 256개를 모두 사용합니다. 각 Slice는 i32 값 여덟 개를 받습니다. TagMode::Zero는 매 사이클마다 Vector Engine을 실행합니다. to_dm은 벡터를 여러 Slice에 분배하고, begin → fetch → collect → vector_init → vector_intra_slice_tag → vector_fxp → vector_final → commit 체인은 각 Slice를 한 번의 패스로 처리합니다.

디바이스 소스: src/kernel/constant_add_kernel.rs.

use furiosa_opt_std::prelude::*;

axes![A = 2048];

pub type Chip = m![1];
pub type Cluster = m![1 # 2];
pub type Slice = m![A / 8 # 256];

#[device(chip = 1)]
pub fn constant_add_kernel(device: &mut Device, input: &HbmTensor<i32, Chip, m![A]>) -> HbmTensor<i32, Chip, m![A]> {
    // HBM → DM: split 2048 elements across 256 slices (8 elements per slice)
    let dm = input.to_dm::<Cluster, Slice, m![A % 8]>(&mut device.tdma);

    let result = device
        .main
        .begin(dm.view())
        // Fetch: stream 8-element packets from DM into the pipeline
        .fetch::<m![1], m![A % 8]>()
        // Collect: normalize the stream into 32-byte flits (8 × i32)
        .collect::<m![1], m![A % 8]>()
        // Vector Engine: enter pipeline and arm unconditionally
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        // Add the scalar constant 1 to every element
        .vector_fxp(FxpBinaryOp::AddFxp, 1)
        // Exit VE and commit: trim the packet to the commit width, then write
        // results back to DM
        .vector_final()
        .commit_trim::<m![A % 8]>()
        .commit::<m![A % 8]>();

    // DM → HBM
    result.to_hbm(&mut device.tdma)
}

호스트 프로그램과 오라클

호스트 프로그램은 입력을 생성하여 HBM에 업로드하고, 디바이스 함수를 실행한 뒤, 테스트에서 결과를 호스트 수식과 비교합니다.

기본 템플릿 호스트 프로그램과 오라클: src/constant_add.rs.

use furiosa_opt_std::prelude::*;
use {{ crate_name }}::kernel::constant_add_kernel::{A, constant_add_kernel};
use rand::SeedableRng;
use rand::rngs::SmallRng;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let mut device = Device::new(constant_add_kernel.topology())?;
    let mut rng = SmallRng::seed_from_u64(42);
    let input = HostTensor::<i32, m![A]>::rand(&mut rng);
    let in_hbm = input.to_hbm(&mut device.pdma).await?;
    let _out_hbm = launch(constant_add_kernel, (&mut device, &in_hbm)).await?;
    println!("Constant Add: kernel ran");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn matches_reference() {
        let mut device = Device::new(constant_add_kernel.topology()).unwrap();

        let mut rng = SmallRng::seed_from_u64(42);
        let input = HostTensor::<i32, m![A]>::rand(&mut rng);
        let in_hbm = input.to_hbm(&mut device.pdma).await.unwrap();

        // Reference: out[i] = in[i] + 1.
        let in_buf: Vec<i32> = input.into_vec();
        let expected: Vec<i32> = in_buf.iter().map(|&x| x.wrapping_add(1)).collect();

        let out_hbm = launch(constant_add_kernel, (&mut device, &in_hbm)).await.unwrap();

        let actual: Vec<i32> = out_hbm.to_host::<m![A]>(&mut device.pdma).await.unwrap().into_vec();
        for (i, (&e, &a)) in expected.iter().zip(&actual).enumerate() {
            assert_eq!(e, a, "constant_add mismatch at i={i}: expected {e}, actual {a}");
        }
    }
}

패턴별 매핑과 메모리

TCP에는 네 단계로 중첩된 하드웨어 계층이 있습니다.

계층개수 (RNGD)역할
Chip시스템에 따라 다름HBM을 보유하는 최상위 단위입니다.
ClusterChip당 두 개Slice 256개로 이루어진 그룹입니다.
SliceCluster당 256개Tensor Unit 하나입니다.
LaneSlice당 여덟 개Contraction Engine MAC 배열의 한 행입니다.

텐서 타입은 요소 타입과 각 논리 축이 이 계층 전반에 어떻게 분포되는지를 인코딩합니다. 예를 들어 DmTensor<bf16, m![1], m![1 # 2], m![A / 8 # 256], m![A % 8]>는 축 A가 256개의 Slice에 나뉘고 Slice마다 요소가 8개씩 있는 bf16 벡터를 나타냅니다. A의 각 요소는 하나의 Slice 안에서 명확하게 정의된 위치를 하나씩 가집니다.

매핑 연산자는 축이 어디로 배치되는지를 기술합니다.

  • /는 축을 스트라이드 단위로 분할합니다.
  • %는 단위 내 개수를 나타냅니다.
  • #는 매핑을 하드웨어 단위 개수에 맞게 패딩합니다.
  • =는 뷰가 기존 항을 포괄하는 범위를 줄이며, 타일은 이 방식으로 텐서의 한 조각을 선택합니다.

%와 =는 모두 축을 좁히므로 둘의 차이를 짚어 둘 필요가 있습니다. %는 새로운 내부 항을 도입합니다. m![B / 2048, B % 2048]에서 첫 번째 항은 B의 2048개 요소 블록을 세고, 두 번째 항은 한 블록 안의 위치를 셉니다. =는 항을 그대로 두고 뷰가 읽는 범위를 줄입니다. 따라서 m![B / 2048 = 1 # 2]는 두 블록 중 하나를 선택하며, 이때 # 2는 선택된 범위가 아닌 전체 축의 스트라이드를 유지합니다. 타일링된 커널은 m![B / 2048 = 1 # 2, B % 2048]처럼 둘을 함께 사용하여 한 블록을 고르고 그 안의 모든 위치를 포괄합니다. 일반 규칙은 타일링을 참조하십시오.

Tensor Unit 텐서는 파이프라인 반복에 Time을, 한 반복 내의 요소에 Packet을 추가로 사용합니다. Tensor Unit 파이프라인은 Fetch → Switch → Collect → Contraction → Vector → Cast → Transpose → Commit 순서입니다. Switch Engine은 Slice 사이를 연결하며, 대부분의 단계는 Slice 안에서 독립적으로 동작합니다.

타입위치용량(RNGD)역할
HbmTensor패키지 내장48 GB, 1.5 TB/s가중치와 활성화 값의 장기 저장소입니다.
DmTensorChip 내장 SRAM총 256 MB, Slice당 512 KB주 작업 메모리입니다.
TrfTensorChip 내장 SRAM레인당 8 KB, Slice당 레인 8개Contraction Engine의 Register File입니다.
VrfTensorChip 내장 SRAMSlice당 8 KBVector Engine의 피연산자 Register File입니다.

이러한 선택은 상수 덧셈 패턴을 특수화합니다. 전체 매핑 모델은 텐서 매핑을, 메모리 이동은 텐서 이동을, 파이프라인 API는 텐서 계산을 참조하십시오.

고정 벡터 피연산자

요소별 곱셈

목표

\(\text{out}[i] = \text{lhs}[i] \times \text{rhs}[i]\)를 계산합니다. 이 패턴은 게이트 스케일링, GLU, 어텐션 스케일링에 적용됩니다.

매핑과 데이터 이동

sub 컨텍스트는 rhs를 VRF에 미리 적재하고, main 컨텍스트는 lhs를 스트리밍하면서 Vector Engine 사이클마다 VRF를 읽습니다. 두 DM 영역은 겹치면 안 됩니다.

flowchart TB
    LHS_HBM[(lhs HBM)] -->|Tensor DMA| LHS_DM[(lhs DM)]
    RHS_HBM[(rhs HBM)] -->|Tensor DMA| RHS_DM[(rhs DM)]

    subgraph sub[sub context]
        direction LR
        sFE[Fetch] --> sCO[Collect] --> VRF[(VRF)]
    end

    subgraph main[main context]
        direction LR
        mFE[Fetch] --> mCO[Collect] --> VE["Vector MulInt"] --> CM[Commit]
    end

    RHS_DM --> sFE
    LHS_DM --> mFE
    VRF --> VE
    CM --> OUT_DM[(result DM)]
    OUT_DM -->|Tensor DMA| OUT_HBM[(HBM)]

모든 디바이스 커널은 별도의 하드웨어 자원 위에서 동작하는 device.main 실행 컨텍스트와 device.sub 실행 컨텍스트를 가집니다. main은 주요 계산을 수행하고, sub는 보통 피연산자를 프리페치합니다. main은 sub가 아직 생성하지 않은 데이터가 필요하면 대기하며, 두 컨텍스트는 평면 구조의 Chip 내장 SRAM을 공유합니다.

자동 배치를 사용하려면 DM 주소를 생략하십시오. 알고리즘에 서로 겹치지 않는 명시적 주소가 필요한 경우에만 _at API를 사용하십시오. Tu const 제네릭은 텐서가 { Tu::Main }과 { Tu::Sub } 중 어느 쪽을 통해 흐르는지 식별합니다. sub 컨텍스트는 Fetch, Collect, .to_vrf()를 거쳐 rhs_dm을 적재하고, main은 그 고정 VRF 피연산자와 함께 lhs_dm을 MulInt로 스트리밍합니다. 자원 의존성이 허용하면 두 컨텍스트는 동시에 실행됩니다.

디바이스 소스

디바이스 소스: src/kernel/elementwise_mul_kernel.rs.

use furiosa_opt_std::prelude::*;

axes![A = 2048];

pub type Chip = m![1];
pub type Cluster = m![1 # 2];
pub type Slice = m![A / 8 # 256];

#[device(chip = 1)]
pub fn elementwise_mul_kernel(
    device: &mut Device,
    lhs: &HbmTensor<i32, Chip, m![A]>,
    rhs: &HbmTensor<i32, Chip, m![A]>,
) -> HbmTensor<i32, Chip, m![A]> {
    // Move both operands from HBM to DM (DM placement is assigned automatically).
    let lhs_dm = lhs.to_dm::<Cluster, Slice, m![A % 8]>(&mut device.tdma);
    let rhs_dm = rhs.to_dm::<Cluster, Slice, m![A % 8]>(&mut device.tdma);

    // Sub context: load rhs into VRF (runs concurrently with the main context below).
    // VRF holds a per-slice operand that the Vector Engine reads every cycle.
    let rhs_vrf: VrfTensor<i32, Chip, Cluster, Slice, m![A % 8]> = device
        .sub
        .begin(rhs_dm.view())
        .fetch::<m![1], m![A % 8]>()
        .collect::<m![A % 8 / 8], m![A % 8 % 8]>()
        .to_vrf();

    // Main context: multiply every lhs element by its rhs counterpart from VRF
    let result = device
        .main
        .begin(lhs_dm.view())
        .fetch::<m![1], m![A % 8]>()
        .collect::<m![1], m![A % 8]>()
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        // Each slice multiplies its 8 lhs elements by the matching 8 rhs elements in VRF
        .vector_fxp(FxpBinaryOp::MulInt, &rhs_vrf)
        .vector_final()
        .commit_trim::<m![A % 8]>()
        .commit::<m![A % 8]>();

    result.to_hbm(&mut device.tdma)
}

호스트 프로그램과 오라클

기본 템플릿 호스트 프로그램과 오라클: src/elementwise_mul.rs.

use furiosa_opt_std::prelude::*;
use {{ crate_name }}::kernel::elementwise_mul_kernel::{A, elementwise_mul_kernel};
use rand::SeedableRng;
use rand::rngs::SmallRng;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let mut device = Device::new(elementwise_mul_kernel.topology())?;
    let mut rng = SmallRng::seed_from_u64(42);
    let lhs = HostTensor::<i32, m![A]>::rand(&mut rng);
    let rhs = HostTensor::<i32, m![A]>::rand(&mut rng);
    let lhs_hbm = lhs.to_hbm(&mut device.pdma).await?;
    let rhs_hbm = rhs.to_hbm(&mut device.pdma).await?;
    let _out_hbm = launch(elementwise_mul_kernel, (&mut device, &lhs_hbm, &rhs_hbm)).await?;
    println!("Elementwise Mul: kernel ran");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn matches_reference() {
        let mut device = Device::new(elementwise_mul_kernel.topology()).unwrap();

        let mut rng = SmallRng::seed_from_u64(42);
        let lhs = HostTensor::<i32, m![A]>::rand(&mut rng);
        let rhs = HostTensor::<i32, m![A]>::rand(&mut rng);

        let lhs_hbm = lhs.to_hbm(&mut device.pdma).await.unwrap();
        let rhs_hbm = rhs.to_hbm(&mut device.pdma).await.unwrap();

        // Reference: out[i] = lhs[i] * rhs[i].
        let lhs_buf: Vec<i32> = lhs.into_vec();
        let rhs_buf: Vec<i32> = rhs.into_vec();
        let expected: Vec<i32> = lhs_buf.iter().zip(&rhs_buf).map(|(&a, &b)| a.wrapping_mul(b)).collect();

        let out_hbm = launch(elementwise_mul_kernel, (&mut device, &lhs_hbm, &rhs_hbm)).await.unwrap();

        let actual: Vec<i32> = out_hbm.to_host::<m![A]>(&mut device.pdma).await.unwrap().into_vec();
        for (i, (&e, &a)) in expected.iter().zip(&actual).enumerate() {
            assert_eq!(e, a, "elementwise_mul mismatch at i={i}: expected {e}, actual {a}");
        }
    }
}

축약 패턴

내적

목표

\(\sum_i x_i y_i\)를 계산합니다. 이 패턴은 어텐션 점수와 유사도 계산에 적용됩니다.

매핑과 데이터 이동

한 피연산자는 파이프라인을 통해 흐르고, sub 컨텍스트는 다른 피연산자를 TRF에 저장합니다. sub 컨텍스트는 Fetch, Collect, .to_trf()를 거쳐 해당 피연산자를 적재합니다.

.contract_outer()는 32바이트 플릿을 짝지어 64바이트 패킷으로 만들고, 고정된 TRF 피연산자를 읽습니다. 짝짓기는 Stream Adapter가 수행하고, 고정 피연산자는 TRF Sequencer가 읽습니다. 두 피연산자는 모두 레인별 곱셈기로 들어갑니다. .contract_packet()은 하드웨어 리덕션 트리를 통해 곱을 공간적으로 리덕션합니다. .contract_time::<m![1]>()은 시간에 걸쳐 누적하여 Slice마다 스칼라 하나를 생성합니다. .contract_lane()은 레인을 접으며, Lane = m![1]일 때는 자명한 연산입니다. .cast()는 f32 누산기를 bf16으로 변환합니다.

축약은 많은 항에 대해 곱셈-누산을 수행하므로 확장된 타입이 필요합니다. 축약은 i4와 i8을 i32로 확장하고, fp8과 bf16을 f32로 확장합니다. .align()과 .contract()에 붙은 어노테이션이 이 확장된 타입을 선택합니다.

디바이스 소스

디바이스 소스: src/kernel/dot_product_kernel.rs.

use furiosa_opt_std::prelude::*;

axes![A = 2048];

pub type Chip = m![1];
pub type Cluster = m![1 # 2];
pub type Slice = m![1 # 256]; // 1 active slice; m![A / 8 # 256] would distribute across all 256
pub type Time = m![1]; // No temporal iteration
pub type Lane = m![1]; // No lane parallelism

#[device(chip = 1)]
pub fn dot_product_kernel(
    device: &mut Device,
    lhs: &HbmTensor<bf16, Chip, m![A]>,
    rhs: &HbmTensor<bf16, Chip, m![A]>,
) -> HbmTensor<bf16, Chip, m![1]> {
    // HBM → DM
    let lhs: DmTensor<bf16, Chip, Cluster, Slice, m![A]> = lhs.to_dm(&mut device.tdma);
    let rhs: DmTensor<bf16, Chip, Cluster, Slice, m![A]> = rhs.to_dm(&mut device.tdma);

    // Sub context: load rhs into TRF
    let rhs: TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![A]> = device
        .sub
        .begin(rhs.view())
        .fetch::<Time, m![A]>()
        .collect::<m![{ Time }, A / 16], m![A % 16]>()
        .to_trf();

    // Main context: stream lhs through the Contraction Engine, reduce along A
    let result: DmTensor<bf16, Chip, Cluster, Slice, m![1 # 8]> = device
        .main
        .begin(lhs.view())
        .fetch::<Time, m![A]>()
        .collect::<m![A / 16], m![A % 16]>()
        // Pair consecutive 32-byte flits into 64-byte packets, halving time steps (A/16 → A/32)
        .contract_outer::<m![A / 32], m![A % 32], _, _, _>(&rhs)
        .contract_packet::<m![1]>()
        .contract_time::<m![1]>()
        .contract_lane::<m![1], m![1 # 8]>(LaneMode::Interleaved)
        .cast::<bf16, m![1 # 16]>() // cast f32 accumulator output back to bf16
        .commit_trim::<m![1 # 8]>()
        .commit();

    // DM → HBM
    result.to_hbm(&mut device.tdma)
}

호스트 프로그램과 오라클

기본 템플릿 호스트 프로그램과 오라클: src/dot_product.rs.

use furiosa_opt_std::prelude::*;
use {{ crate_name }}::kernel::dot_product_kernel::{A, dot_product_kernel};
use rand::SeedableRng;
use rand::rngs::SmallRng;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let mut device = Device::new(dot_product_kernel.topology())?;
    let mut rng = SmallRng::seed_from_u64(42);
    let lhs = HostTensor::<bf16, m![A]>::rand(&mut rng);
    let rhs = HostTensor::<bf16, m![A]>::rand(&mut rng);
    let lhs_hbm = lhs.to_hbm(&mut device.pdma).await?;
    let rhs_hbm = rhs.to_hbm(&mut device.pdma).await?;
    let _out_hbm = device.launch(dot_product_kernel, (&lhs_hbm, &rhs_hbm)).await?;
    println!("Dot Product: kernel ran");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn matches_reference() {
        let mut device = Device::new(dot_product_kernel.topology()).unwrap();

        let mut rng = SmallRng::seed_from_u64(42);
        let lhs = HostTensor::<bf16, m![A]>::rand(&mut rng);
        let rhs = HostTensor::<bf16, m![A]>::rand(&mut rng);

        let lhs_hbm = lhs.to_hbm(&mut device.pdma).await.unwrap();
        let rhs_hbm = rhs.to_hbm(&mut device.pdma).await.unwrap();

        // Reference: sum_i lhs[i] * rhs[i] in f32, then round to bf16.
        let lhs_buf: Vec<bf16> = lhs.into_vec();
        let rhs_buf: Vec<bf16> = rhs.into_vec();
        let expected_f32: f32 = lhs_buf
            .iter()
            .zip(&rhs_buf)
            .map(|(&a, &b)| f32::from(a) * f32::from(b))
            .sum();
        let expected = bf16::from_f32(expected_f32);

        let out_hbm = device.launch(dot_product_kernel, (&lhs_hbm, &rhs_hbm)).await.unwrap();

        let actual_buf: Vec<bf16> = out_hbm.to_host::<m![1]>(&mut device.pdma).await.unwrap().into_vec();
        if let Some(&actual) = actual_buf.first() {
            let diff = (f32::from(actual) - f32::from(expected)).abs();
            let tol = (0.02 * f32::from(expected).abs()).max(0.5);
            assert!(
                diff <= tol,
                "dot_product mismatch: expected {expected:?}, actual {actual:?}, diff {diff} > tol {tol}"
            );
        }
    }
}

GEMV

목표

\(y_i = \sum_j A_{ij} x_j\)를 계산합니다. 이 패턴은 LLM 디코드에 해당합니다.

매핑과 데이터 이동

출력 차원 I를 여러 Slice에 걸쳐 매핑하여 각 Slice가 한 행을 계산하도록 하십시오. 전체 벡터 x를 브로드캐스트하여 모든 Slice가 이 벡터를 자신의 행과 축약할 수 있도록 하십시오. 내적과 달리 각 행은 서로 독립적이지만, 모든 행에는 동일한 완전한 벡터 피연산자가 필요합니다.

디바이스 소스

디바이스 소스: src/kernel/gemv_kernel.rs.

use furiosa_opt_std::prelude::*;

axes![I = 256, J = 2048];

pub type Chip = m![1];
pub type Cluster = m![1 # 2];
pub type Slice = m![I]; // Distribute output dimension across slices
pub type Time = m![J / 32]; // Temporal iterations for reduction dimension
pub type Packet = m![J % 32]; // Packet size for reduction dimension
pub type Lane = m![1];

#[device(chip = 1)]
pub fn gemv_kernel(
    device: &mut Device,
    matrix: &HbmTensor<bf16, Chip, m![I, J]>,
    vector: &HbmTensor<bf16, Chip, m![J]>,
) -> HbmTensor<bf16, Chip, m![I]> {
    // Move data from HBM to DM
    let matrix: DmTensor<bf16, Chip, Cluster, Slice, m![J]> = matrix.to_dm(&mut device.tdma);
    let vector: DmTensor<bf16, Chip, Cluster, Slice, m![J]> = vector.to_dm(&mut device.tdma);

    // Load vector into TRF
    let vector_trf: TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![J]> = device
        .sub
        .begin(vector.view())
        .fetch::<m![1], m![J]>()
        // Collect Engine: split into 32-byte flits.
        .collect::<m![J / 16], m![J % 16]>()
        .to_trf();

    // Compute GEMV: matrix × vector
    // Key difference: `I` maps to slice (preserved), `J` gets reduced
    let result: DmTensor<bf16, Chip, Cluster, Slice, m![1 # 4]> = device
        .main
        .begin(matrix.view())
        .fetch::<m![J / 16], m![J % 16]>()
        .collect::<m![J / 16], m![J % 16]>()
        .contract_outer::<Time, Packet, _, _, _>(&vector_trf)
        .contract_packet::<m![1]>()
        .contract_time::<m![1]>()
        .contract_lane::<m![1], m![1 # 8]>(LaneMode::Interleaved)
        .cast::<bf16, m![1 # 16]>()
        .commit_trim::<m![1 # 4]>()
        .commit();

    // Transfer result to HBM
    result.to_hbm(&mut device.tdma)
}

호스트 프로그램과 오라클

기본 템플릿 호스트 프로그램과 오라클: src/gemv.rs.

use furiosa_opt_std::prelude::*;
use {{ crate_name }}::kernel::gemv_kernel::{I, J, gemv_kernel};
use rand::SeedableRng;
use rand::rngs::SmallRng;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let mut device = Device::new(gemv_kernel.topology())?;
    let mut rng = SmallRng::seed_from_u64(42);
    let matrix = HostTensor::<bf16, m![I, J]>::rand(&mut rng);
    let vector = HostTensor::<bf16, m![J]>::rand(&mut rng);
    let matrix_hbm = matrix.to_hbm(&mut device.pdma).await?;
    let vector_hbm = vector.to_hbm(&mut device.pdma).await?;
    let _out_hbm = launch(gemv_kernel, (&mut device, &matrix_hbm, &vector_hbm)).await?;
    println!("GEMV: kernel ran");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn matches_reference() {
        let mut device = Device::new(gemv_kernel.topology()).unwrap();

        let mut rng = SmallRng::seed_from_u64(42);
        let matrix = HostTensor::<bf16, m![I, J]>::rand(&mut rng);
        let vector = HostTensor::<bf16, m![J]>::rand(&mut rng);

        let matrix_hbm = matrix.to_hbm(&mut device.pdma).await.unwrap();
        let vector_hbm = vector.to_hbm(&mut device.pdma).await.unwrap();

        // Reference: y[i] = sum_j matrix[i, j] * vector[j] in f32, rounded to bf16.
        let mat_buf: Vec<bf16> = matrix.into_vec();
        let vec_buf: Vec<bf16> = vector.into_vec();
        let expected: Vec<bf16> = mat_buf
            .chunks(J::SIZE)
            .map(|row| {
                let acc: f32 = row
                    .iter()
                    .zip(&vec_buf)
                    .map(|(&a, &b)| f32::from(a) * f32::from(b))
                    .sum();
                bf16::from_f32(acc)
            })
            .collect();

        let out_hbm = launch(gemv_kernel, (&mut device, &matrix_hbm, &vector_hbm)).await.unwrap();

        let actual: Vec<bf16> = out_hbm.to_host::<m![I]>(&mut device.pdma).await.unwrap().into_vec();
        for (i, (&e, &a)) in expected.iter().zip(&actual).enumerate() {
            let diff = (f32::from(a) - f32::from(e)).abs();
            let tol = (0.02 * f32::from(e).abs()).max(0.5);
            assert!(
                diff <= tol,
                "gemv mismatch at i={i}: expected {e:?}, actual {a:?}, diff {diff} > tol {tol}"
            );
        }
    }
}

GEMM

목표

\(C_{ij} = \sum_k A_{ik} B_{kj}\)를 계산합니다. A는 J에 걸쳐 브로드캐스트되고, B는 I에 걸쳐 브로드캐스트됩니다.

매핑과 데이터 이동

type Slice = m![I / 32, J / 32]로 두 출력 차원을 모두 매핑하여 각 Slice가 16 × 16 출력 타일을 계산하도록 하십시오. Switch Engine이 각 B 타일을 대응하는 Slice로 라우팅하므로, 각 Slice는 자신의 출력 타일에 속하는 J 부분만 받습니다. .contract_packet::<m![1]>()은 K를 따라 공간적으로 리덕션합니다. .contract_time::<m![I]>()은 I를 유지하면서 시간에 걸쳐 누적합니다. .contract_lane::<m![I], m![J # 8]>(LaneMode::Interleaved)는 출력 패킷에서 I와 J를 유지합니다.

디바이스 소스

디바이스 소스: src/kernel/gemm_kernel.rs.

use furiosa_opt_std::prelude::*;

axes![I = 512, J = 512, K = 64];

pub type Chip = m![1];
pub type Cluster = m![1 # 2];
// Distribute output dimensions `I` and `J` across slices
pub type Slice = m![I / 32, J / 32]; // Each slice handles a 16 × 16 output tile
pub type Lane = m![J % 8];

#[device(chip = 1)]
pub fn gemm_kernel(
    device: &mut Device,
    a: &HbmTensor<bf16, Chip, m![I, K]>,
    b: &HbmTensor<bf16, Chip, m![J, K]>,
) -> HbmTensor<bf16, Chip, m![I, J]> {
    // Move data from HBM to DM
    let a: DmTensor<bf16, Chip, Cluster, Slice, m![I % 32, K]> = a.to_dm(&mut device.tdma);
    let b: DmTensor<bf16, Chip, Cluster, Slice, m![J % 32, K]> = b.to_dm(&mut device.tdma);

    // Load matrix B into TRF
    // Switch Engine distributes B across 256 slices
    // Each slice gets the full `K` dimension but only its (16 × 16) output tile
    // See: Switch Engine topologies for details on distribution
    let b_trf: TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![J / 8 % 4, K]> = device
        .sub
        .begin(b.view())
        .fetch::<m![J % 8, J / 8 % 4], m![K]>()
        .collect::<m![J % 8, J / 8 % 4, K / 16], m![K % 16]>()
        .to_trf();

    // Compute GEMM: A × B
    // Switch Engine ensures matching (`I / 32`, `J / 32`) slice distribution
    // Contraction reduces along `K`, preserves `I` and `J`
    let result: DmTensor<bf16, Chip, Cluster, Slice, m![I % 32, J % 32]> = device
        .main
        .begin(a.view())
        .fetch::<m![I % 32, J / 8 % 4], m![K]>()
        .collect::<m![I % 32, J / 8 % 4, K / 16], m![K % 16]>()
        .contract_outer::<m![I % 32, J / 8 % 4, K / 32], m![K % 32], _, _, _>(&b_trf)
        .contract_packet::<m![1]>()
        .contract_time::<m![I % 32, J / 8 % 4]>()
        .contract_lane::<m![I % 32, J / 8 % 4], m![J % 8]>(LaneMode::Interleaved)
        .cast::<bf16, m![J % 8 # 16]>()
        .commit_trim::<m![J % 8]>()
        .commit();

    // Transfer result to HBM
    result.to_hbm(&mut device.tdma)
}

호스트 프로그램과 오라클

기본 템플릿 호스트 프로그램과 오라클: src/gemm.rs.

use furiosa_opt_std::prelude::*;
use {{ crate_name }}::kernel::gemm_kernel::{I, J, K, gemm_kernel};
use rand::SeedableRng;
use rand::rngs::SmallRng;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let mut device = Device::new(gemm_kernel.topology())?;
    let mut rng = SmallRng::seed_from_u64(42);
    let a = HostTensor::<bf16, m![I, K]>::rand(&mut rng);
    let b = HostTensor::<bf16, m![J, K]>::rand(&mut rng);
    let a_hbm = a.to_hbm(&mut device.pdma).await?;
    let b_hbm = b.to_hbm(&mut device.pdma).await?;
    let _out_hbm = launch(gemm_kernel, (&mut device, &a_hbm, &b_hbm)).await?;
    println!("GEMM: kernel ran");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn matches_reference() {
        let mut device = Device::new(gemm_kernel.topology()).unwrap();

        let mut rng = SmallRng::seed_from_u64(42);
        let a = HostTensor::<bf16, m![I, K]>::rand(&mut rng);
        let b = HostTensor::<bf16, m![J, K]>::rand(&mut rng);

        let a_hbm = a.to_hbm(&mut device.pdma).await.unwrap();
        let b_hbm = b.to_hbm(&mut device.pdma).await.unwrap();

        // Reference: C[i, j] = sum_k A[i, k] * B[j, k] in f32, rounded to bf16.
        let a_buf: Vec<bf16> = a.into_vec();
        let b_buf: Vec<bf16> = b.into_vec();
        let expected: Vec<bf16> = a_buf
            .chunks(K::SIZE)
            .flat_map(|a_row| {
                b_buf.chunks(K::SIZE).map(move |b_row| {
                    let acc: f32 = a_row
                        .iter()
                        .zip(b_row)
                        .map(|(&a, &b)| f32::from(a) * f32::from(b))
                        .sum();
                    bf16::from_f32(acc)
                })
            })
            .collect();

        let out_hbm = launch(gemm_kernel, (&mut device, &a_hbm, &b_hbm)).await.unwrap();

        let actual: Vec<bf16> = out_hbm.to_host::<m![I, J]>(&mut device.pdma).await.unwrap().into_vec();
        for (idx, (&e, &av)) in expected.iter().zip(&actual).enumerate() {
            let diff = (f32::from(av) - f32::from(e)).abs();
            let tol = (0.05 * f32::from(e).abs()).max(1.0);
            assert!(diff <= tol, "gemm mismatch at idx={idx}: expected {e:?}, actual {av:?}");
        }
    }
}

Blocked GEMM은 온Chip DM 용량을 초과하는 행렬을 처리할 수 있도록 GEMM에 타일링을 더해 확장합니다. 이 사례는 K에 대한 시간 분할과 여러 Chip에 걸친 I 및 J의 공간 분할을 다룹니다. Flash Attention은 하나의 Transformer 어텐션 헤드에 걸쳐 GEMM, Vector Engine softmax, main/sub 프리페치를 결합합니다.

커널 검증

검증 절차는 구조적 유효성, 수치적 정확성, 하드웨어 동작, 정적 스케줄 데이터를 구분합니다.

  1. 커널 컴파일(cargo furiosa-opt compile)은 모든 #[device] 본문을 변환하고 검증하며, 아무것도 실행하지 않고 매핑 및 형태 오류를 잡아냅니다.
  2. CPU 실행(일반 cargo)은 호스트 측 텐서 결과를 호스트 오라클과 비교합니다.
  3. NPU 실행은 컴파일된 EDF(executable device format)를 Furiosa SDK를 통해 실제 하드웨어에서 실행합니다.
  4. 스케줄 데이터는 생성된 스케줄 JSON을 검사하고, 릴리스, 형태, 타입, 매핑을 고정한 상태에서 makespan을 비교합니다.

커널 컴파일은 정적입니다. 선택한 모든 커널이 변환되고 검증된다는 것을 증명하지만, 수치적 정확성은 증명하지 않습니다. 스케줄 makespan은 릴리스, 형태, 데이터 타입, 매핑이 고정된 경우에만 비교하십시오. 스케줄 자체는 처리량 측정값이 아닙니다. 고정된 스케줄 후보를 비교하려면 스케줄링과 튜닝을 참고하십시오. 각 비교마다 소스 리비전, 백엔드, 형태, 매핑, 오라클 결과, 스케줄 산출물을 기록하십시오.

텐서 매핑

매핑은 논리적 순서와 물리적 배치를 결정하는 기준입니다. 이동 연산이나 계산 연산을 선택하기 전에 매핑의 셀과 차원을 검증하십시오. 모든 매핑 표현식은 논리 인덱스를 물리적 위치에 매핑합니다. 이동 연산이나 계산 연산을 선택하기 전에 위치 셀 요약에 정의된 결과 범주를 읽으십시오.

차원 배치와 리덕션

이 표는 이동 연산이나 계산 연산을 선택하기 전에 축을 배치합니다.

차원타입정의 위치리덕션 위치
Chip공간HBM, DM, StreamDMA + Vector
Cluster공간DM, StreamDMA + Vector
Slice공간DM, StreamVector
Lane공간TRFContraction
Time시간StreamContraction
Packet공간스트림축약

Chip과 Cluster 리덕션은 DMA 재분배 후 Vector Engine 리덕션을 수행합니다.

매핑 순서와 성능

텐서에는 값이 있지만 고유한 저장 순서는 없습니다. 매핑은 그 순서를 정하며, 하드웨어 접근은 인접한 값이 연속된 버퍼 위치를 차지할 때 가장 효율적입니다. 가장 바깥쪽 매핑 차원은 major 차원이며 가장 느리게 변합니다. 가장 안쪽 차원은 minor 차원이며 가장 빠르게 변합니다. 높이×너비 텐서에서 m![H, W]는 행을 연속되게 하고, m![W, H]는 열을 연속되게 합니다. 각 순서는 서로 다른 축을 따른 지역성에 유리합니다. H-major 매핑은 W를 따른 스캔을 연속되게 하고, W-major 매핑은 H를 따른 스캔을 연속되게 합니다. m![H / 2, W / 2, H % 2, W % 2]와 같은 타일링된 매핑은 작은 2차원 이웃 영역을 연속되게 배치하므로, H와 W 중 어느 쪽도 전역적으로 유일한 연속 방향이 되지 않습니다. 이는 어떤 접근 패턴이 가까운 버퍼 위치에 매핑되는지를 바꾸지만, 그 자체로 더 빠른 하드웨어 접근을 보장하지는 않습니다. 분할된 차원을 표현하는 데 필요한 추가 매핑 상태가 이 방식의 주된 추가 비용입니다.

따라서 축 H(6개 행)와 W(8개 열)에 대해 같은 값이라도 물리적 접근 패턴이 서로 다를 수 있습니다.

H\W01234567
0abcdefgh
1ijklmnop
2········
3········
4········
5········
  • H-major, W-minor: m![H, W] — W 방향 스캔은 연속적입니다. H 방향 스캔은 캐시 라인마다 값 하나에만 접근합니다.

    H=0 H=1 ...
    abcdefgh ijklmnop ...
  • W-major, H-minor: m![W, H] — H 축을 따라 스캔하면 연속적으로 접근합니다. W 축을 따라 스캔하면 캐시 라인마다 값 하나에만 접근합니다.

    W=0 W=1 W=2 ...
    ai···· bj···· ck···· ...
  • 2×2 타일: m![H / 2, W / 2, H % 2, W % 2] — H-major와 W-major는 각각 한 축을 따라서는 지역성을 희생합니다. 타일링은 작은 이웃 영역을 두 축 모두를 따라 연속적으로 배치하므로 지역성 분포가 달라지지만, 그 자체로 하드웨어 접근이 더 빨라진다고 보장하지는 않습니다. 분할된 차원에는 단순하지 않은 주소 공식과 추가 매핑 상태가 필요합니다.

    t(0,0) t(0,1) t(0,2) ...
    abij cdkl efmn ...

선택한 매핑은 이후 실행도 제약합니다. 할당 후에 표현의 순서를 바꾸려면 일반적으로 데이터를 복사하거나 전치해야 하므로, 할당 시점의 선택이 이후 연산에 영향을 줍니다. 하드웨어 기하 구조, 정렬, 스케줄링은 여전히 컴파일러 로워링이 고려해야 하는 아키텍처 제약 조건입니다. 매핑은 원시 주소 계산이 아니라 논리적 순서를 표현합니다. 장치별 정렬 검사가 적용되며, 정렬되지 않은 접근에는 read-modify-write 사이클이 필요할 수 있어 성능 비용이 상당합니다(영향을 받는 DM 접근에서 약 50배의 비용이 관측된 바 있습니다).

매핑은 이 선택을 기술적으로 완전하게 기술합니다. 물리적 표현은 해당 매핑으로부터 만들어지는 구체적인 버퍼, 장치 배치, 스트림 분해입니다. 같은 값은 논리적 텐서를 바꾸지 않고도 서로 다른 물리적 표현 사이를 이동할 수 있습니다.

위치 Cell 요약

모든 매핑 표현식은 물리적 위치를 Cell에 매핑합니다. 좌표를 해석하기 전에 먼저 결과 범주를 확인하십시오.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;
axes![A = 4];
type E = m![A];

assert_eq!(E::map(0), i![A: 0]);
assert_eq!(E::map(4), Cell::OutOfBounds);
}

Cell::Index는 살아 있는 Index 항을 담습니다. Cell::OutOfBounds는 매핑 범위 밖의 위치를 나타냅니다. Cell::Padding은 매핑 표현식에서 패딩과 함께 소개합니다.

이 섹션에서는 결과 범주를 요약합니다. Cell을 확인한 후, 생성자와 API 문법은 매핑 표현식을 참고하십시오.

표현과 분산

물리적 표현

저장소 텐서와 스트림 텐서는 하나의 매핑을 여러 하드웨어 차원에 걸쳐 분할합니다. HBM 표현은 채널을 여러 Chip에 분산할 수 있습니다. DM 표현은 그 채널을 여러 Slice에 걸쳐 다시 분할할 수 있습니다. 스트림 표현은 나머지 차원을 Time과 Packet에 둘 수 있습니다. 공간 차원과 시간 차원에서는 하나의 NCHW 텐서가 이러한 표현을 거치는 과정을 구체적인 좌표 추적으로 따라갑니다.

이 표현 추적으로 이 장을 마무리합니다. 매핑을 선택하고, 각 물리적 표현을 도출한 다음, 추적한 Index의 값이 예상한 스트림 셀에 도달하는지 확인합니다. 텐서 값의 형식적 정의는 텐서 의미론에 있습니다. 이 장에서는 동작 중심으로 설명합니다.

표현에 대한 최소한의 계산은 Vector Engine을 참고하십시오. 요소별 연산은 해당 실행 API에서 다루며, 이 장에서는 매핑과 표현에 집중합니다.

공간과 시간에 걸친 분산

매핑 차원은 공간 하드웨어 차원(Chip, Cluster, Slice, Packet) 또는 시간 차원인 Time 루프에 명시적으로 할당됩니다. 이 이름들은 매핑이 어디에서 소비되는지를 나타낼 뿐이며, 별도의 표현 어휘가 아닙니다. 텐서 선언과 제약 조건은 공간 차원과 시간 차원을 참고하십시오.

할당은 파이프라인 전체가 아니라 한 단계에만 적용되며, Time과 공간 차원 사이에서 축을 옮기는 경로는 네 가지입니다.

경로이동 대상사용 가능 조건비용
Switch EngineSlice와 Time 사이에서 어느 방향으로든 옮기는 축 하나구성 중 하나가 Slice 배치를 표현하는 경우링 순회 ring_size × Time::SIZE × flits_per_packet 사이클
DMA원하는 배치로 기록하여 Chip, Cluster 또는 Slice로 옮기는 축 하나항상텐서 전체의 전송
Inter-Slice ReducerTime에서 리덕션된 축이 비운 Slice 슬롯으로 옮기는 축 하나스트림이 어차피 Slice 축에 대해 리덕션되는 경우해당 리덕션 외에는 없음
축 리프팅, 페치 시점Time에서 Chip, Cluster 또는 Slice로 옮기는 축선택한 차원이 브로드캐스트를 담고 있음원래의 읽기에 더해, Slice의 경우 DMA, 컨텍스트 로컬 덧셈, SFR 저장

Switch Engine은 아직 값을 갖고 있지 않은 Slice에도 값을 전달할 수 있으므로 Slice / Time 경계를 넘는 일반적인 경로입니다. Switch Engine은 Slice 사이에서만 동작하므로, 축을 Chip 또는 Cluster로 옮기려면 원하는 배치대로 쓰는 DMA가 필요합니다.

축 리프팅을 하려면 선택한 차원에 브로드캐스트가 있어야 합니다. 즉, 각 Chip, Cluster 또는 Slice가 이미 소스 영역을 갖고 있어야 합니다. 그러면 읽기 오프셋을 서로 다르게 하는 것만으로 데이터 전송 없이 축을 Time 밖으로 옮길 수 있습니다.

선언적 매핑 컨텍스트

선언적 매핑은 원시 스트라이드나 오프셋 대신 논리 축을 기준으로 순서를 나타냅니다. 위의 H×W 예제는 H-major 순서에 m![H, W], W-major 순서에 m![W, H], 2×2 타일에 m![H / 2, W / 2, H % 2, W % 2]를 사용합니다. 앞의 두 타일 차원은 타일을 식별하고, 뒤의 두 차원은 그 타일 안에서의 위치를 식별합니다.

매핑 표현식은 표준 표현으로 정규화할 수 있으므로, 동등한 생성자 형태를 더 쉽게 살펴볼 수 있습니다. 테스트된 정규화 속성과 그 한계는 동등한 매핑에 문서화되어 있습니다. 이 장은 보편적인 증명이나 유일한 표현을 주장하지 않습니다.

매핑 표현식

매핑 표현식은 매핑을 인코딩하는 Rust 타입입니다. 먼저 위치의 Cell을 읽으십시오. 그런 다음 Index 항, 패딩 종류 또는 범위 밖 결과를 사용하여 접근을 디버깅하십시오. 이 절에서는 이러한 결과를 만들어 내는 생성자와 지원되는 동작을 정의합니다.

축 크기

axes! 매크로는 축 식별자와 그 크기를 선언합니다. 다음 선언은 이 절 전체에 적용됩니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 512];
}

위치 결과

m![H, W]와 같은 매핑 표현식은 각 물리적 위치를 Cell에 매핑합니다. 항을 읽기 전에 먼저 결과를 확인하십시오:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;
axes![A = 8];
type E = m![A];
assert_eq!(E::map(0), i![A: 0]);
}

Cell::Index는 구조적 라이브 결과입니다. 이 결과는 매핑 항을 보존하며, Index::finalize()는 해당 항의 스칼라 좌표를 검증하고 모듈로 초과에 대해 오류를 반환할 수 있습니다. 패딩 위치와 범위 밖 위치에는 라이브 항이 없습니다. Cell::Index(Index::new())는 축 항이 없는 유효한 라이브 결과입니다. 이는 Cell::Padding 및 Cell::OutOfBounds와 구별되므로, 빈 Index가 라이브가 아닌 위치를 나타내는 경우는 없습니다. i! 매크로는 할당을 작성된 순서대로 결합하며, 이 순서는 합성된 매핑의 상위에서 하위로의 순서입니다. Cell::combine은 패딩에 대해 교환 법칙이 성립하지 않으므로, 해당 순서를 의도한 경우에만 할당 순서를 바꾸십시오.

M 생성자 규칙

모든 매핑 표현식은 버퍼 크기와 위치 매핑을 제공하는 M 트레이트를 구현합니다:

#![allow(unused)]
fn main() {
// Inside `furiosa_opt_std::prelude`...
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use std::fmt::Debug;
pub trait M: Debug + Clone {
    /// The computed size for the given shape.
    const SIZE: usize;

    /// Converts the mapping expression type into a value.
    fn to_value() -> Mapping;

    /// Converts a buffer index to a physical cell result.
    fn map(i: usize) -> Cell;
}

/// A live tensor index: a map from axis identifiers to coordinate values.
pub struct Index { /* ... */ }

/// Results for live, padded, and out-of-range positions.
pub enum Cell { Index(Index), Padding(PaddingKind), OutOfBounds }

/// Constructs classified cells.
/// `i![A: 2, B: 3]` creates a live `Cell::Index` with A = 2 and B = 3;
/// non-live contributions remain `Cell::Padding` or `Cell::OutOfBounds`.
macro_rules! i {
    () => {};
    /* ... */
}
}

호스트 텐서 사용법

M 트레이트를 기반으로 만든 가장 단순한 구체 타입은 HostTensor<D, E>입니다. 이는 요소 타입이 D인 호스트 메모리 버퍼로, 그 표현은 매핑 E에 의해 완전히 결정됩니다. E는 버퍼 크기(E::SIZE)와 버퍼 위치에서 셀로의 대응(E::map)을 모두 결정합니다. HostTensor<bf16, m![A, B]>는 bf16 데이터 4,096개 요소를 담습니다. 호스트 텐서의 경우, E::map이 반환하는 각 Cell::Index(index)는 해당 물리적 위치에 저장된 구조적 Index를 식별합니다. 이를 텐서 인덱스로 취급하기 전에 Index::finalize()를 호출하여 스칼라 좌표를 검증하십시오. 초과가 있으면 이 단계에서 실패할 수 있습니다. Cell::OutOfBounds는 읽기의 경우 CellExt::finalize(PaddingKind::Top)으로, 쓰기의 경우 CellExt::finalize(PaddingKind::Bottom)으로 변환하십시오. holds의 형식적 정의는 텐서 의미론에 나와 있습니다.

HbmTensor와 DmTensor 같은 디바이스 텐서는 여러 매핑 표현식에 걸친 더 복잡한 표현을 가집니다. 자세한 내용은 공간 차원과 시간 차원을 참조하십시오.

생성자

HostTensor<D, E>의 매핑 E를 포함한 매핑 표현식은 작은 생성자를 합성하여 만들며, 각 생성자는 더 단순한 매핑을 변환하거나 결합합니다. 이러한 표현식은 산술 연산자와 유사한 연산자(/, %, 그리고 패딩을 위한 #)를 사용하여 텐서 인덱스와 선형 버퍼 인덱스 간의 매핑을 간결하게 정의합니다.

심볼

심볼은 대문자 한 글자이며, 그 크기는 형태 선언에서 정해집니다. 매핑 m![A]는 8개의 버퍼 인덱스를 축을 따라 텐서 인덱스에 선형으로 매핑합니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;

axes![A = 8];

type E = m![A]; // Symbol<Ident::A, 8>

fn test_symbol() {
    assert_eq!(E::map(0), i![A: 0]);
    assert_eq!(E::map(1), i![A: 1]);
    assert_eq!(E::map(2), i![A: 2]);
    for i in 0..E::SIZE {
        assert_eq!(E::map(i), i![A: i]);
    }
    assert_eq!(E::map(E::SIZE), Cell::OutOfBounds);
}

test_symbol();
}
impl<S: AxisName> M for Symbol<S> {
    const SIZE: usize = S::SIZE;

    fn to_value() -> Mapping {
        Mapping::Symbol {
            symbol: S::NAME,
            size: S::SIZE,
        }
    }

    fn map(i: usize) -> Cell {
        if i >= S::SIZE {
            return Cell::OutOfBounds;
        }
        Cell::Index({
            let mut index = Index::new();
            index.add_term(
                Term {
                    inner: Atom::Symbol {
                        symbol: S::NAME,
                        size: S::SIZE,
                    },
                    stride: 1,
                    modulo: S::SIZE,
                },
                i,
            );
            index
        })
    }
}

Note

모든 심볼 A에 대해 0번째 인덱스 i![A: 0]은 빈 텐서 인덱스 i![]와 동등합니다.

쌍

쌍 매핑 m![A, B]는 형태가 \(\{A=8, B=512\}\)인 2D 텐서를 4,096개 요소로 이루어진 버퍼로 저장합니다. 매핑 Pair<L, R>는 두 공간의 데카르트 곱을 선형 버퍼로 매핑하며, 여기서 L은 주 차원이고 R은 부 차원입니다. 크기는 L::SIZE * R::SIZE이며, 이 매핑은 내림 나눗셈과 나머지 연산으로 인덱스를 분해합니다. 주 차원(L)의 결과가 패딩이면 부 차원(R)의 결과를 고려하기 전에 그 패딩의 종류가 우선합니다. 유효한 조합이 불가능하면 범위 밖 결과가 전파됩니다. m![A, B, C, D]는 Pair<A, Pair<B, Pair<C, D>>>로 전개되며 오른쪽 결합입니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;

axes![A = 8, B = 512];

type E = m![A, B]; // Pair<m![A], m![B]>

fn test_pair() {
    // First 512 elements hold A=0, next 512 hold A=1
    assert_eq!(E::map(0),   i![A: 0, B: 0]);
    assert_eq!(E::map(511), i![A: 0, B: 511]);
    assert_eq!(E::map(512), i![A: 1, B: 0]);
    assert_eq!(E::map(519), i![A: 1, B: 7]); // 519 == 512 * 1 + 7
    for i in 0..E::SIZE {
        assert_eq!(E::map(i), i![A: i / <m![B]>::SIZE, B: i % <m![B]>::SIZE]);
    }
    assert_eq!(E::map(E::SIZE), Cell::OutOfBounds);
}

test_pair();
}
impl<L, R> M for Pair<L, R>
where
    L: M,
    R: M,
{
    const SIZE: usize = L::SIZE * R::SIZE;

    fn to_value() -> Mapping {
        Mapping::Pair {
            left: RBox::new(L::to_value()),
            right: RBox::new(R::to_value()),
        }
    }

    fn map(i: usize) -> Cell {
        if i >= Self::SIZE {
            return Cell::OutOfBounds;
        }
        let l = L::map(i / R::SIZE);
        let r = R::map(i % R::SIZE);
        match l {
            Cell::OutOfBounds => Cell::OutOfBounds,
            Cell::Padding(kind) => Cell::Padding(kind),
            Cell::Index(mut left) => match r {
                Cell::Index(right) => {
                    left.add(right);
                    Cell::Index(left)
                }
                Cell::Padding(kind) => Cell::Padding(kind),
                Cell::OutOfBounds => Cell::OutOfBounds,
            },
        }
    }
}

항등

항등 매핑 m![1]은 버퍼 인덱스 0을 빈 텐서 인덱스 i![]로 매핑하는 단일 요소 버퍼를 생성합니다. 이는 Pair에 대한 항등 요소 역할을 합니다. m![1, A]와 m![A, 1]은 모두 m![A]와 동등합니다. 더 일반적으로, 브로드캐스트 매핑은 크기 미만의 모든 위치에 대해 동일한 유효한 빈 Cell::Index(Index::new())를 반환하고, 그 크기를 넘어서는 위치에 대해서는 Cell::OutOfBounds를 반환합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;

type E = m![1]; // Identity

fn test_identity() {
    assert_eq!(E::map(0), i![]);
    assert_eq!(E::map(1), Cell::OutOfBounds);
}

test_identity();
}
/// The identity mapping (size-1 broadcast), the unit written `m![1]`.
pub type Identity = Broadcast<1>;

패딩

패딩은 사용하지 않는 버퍼 공간을 추가하여 데이터를 하드웨어 요구 사항에 맞게 정렬합니다. 예를 들어, DMA Engine은 행이 64바이트 경계에서 시작할 것을 요구합니다. axes![C = 13, D = 61]에서는 61이 64로 나누어떨어지지 않으므로 m![C, D]가 정렬되지 않은 행을 만듭니다. m![C, D # 64]는 행마다 추가 요소 3개를 사용하여 각 행을 64바이트 경계에 정렬함으로써 이 문제를 해결합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;

axes![C = 13, D = 61];

type E = m![C, D # 64]; // Pair<m![C], Padding<m![D], 64>>

fn test_padding() {
    assert_eq!(E::map(0),  i![C: 0, D: 0]);
    assert_eq!(E::map(60), i![C: 0, D: 60]);
    assert_eq!(E::map(61), Cell::Padding(PaddingKind::Top));
    assert_eq!(E::map(62), Cell::Padding(PaddingKind::Top));
    assert_eq!(E::map(63), Cell::Padding(PaddingKind::Top));
    assert_eq!(E::map(64), i![C: 1, D: 0]);
}

test_padding();
}
impl<L, F, const KIND: PaddingKind> M for Padding<L, F, KIND>
where
    L: M,
    F: Count,
{
    const SIZE: usize = F::SIZE;

    fn to_value() -> Mapping {
        Mapping::Padding {
            inner: RBox::new(L::to_value()),
            padding: F::SIZE,
            kind: KIND,
        }
    }

    fn map(i: usize) -> Cell {
        if i >= F::SIZE {
            Cell::OutOfBounds
        } else if i >= L::SIZE {
            Cell::Padding(KIND)
        } else {
            L::map(i)
        }
    }
}

패딩된 위치는 그 개수뿐 아니라 내용도 타입의 일부입니다. 세 가지 종류를 추적합니다.

  • m![A # m](또는 m![A #{*} m])은 크기 m까지의 top 패딩입니다. 이 위치들은 접근할 수 있지만 임의의 값을 가집니다. 원시 DM 텐서가 이 패딩을 가집니다. #은 축약형이고, #{*}는 종류를 명시적으로 표기합니다.
  • m![A #{0} m]은 크기 m까지 0으로 채워진 패딩입니다. 이 위치들은 접근할 수 있으며 0을 가진다는 것이 알려져 있습니다. 매핑은 이 값 속성을 기록할 뿐이며, 표현식 자체가 값을 채우지는 않습니다.
  • m![A #{!} m]은 크기 m까지의 bottom 패딩입니다. 이 위치들은 접근할 수 없으며, 이 위치를 읽거나 쓰는 것은 정의되지 않은 동작입니다. 이는 컴파일러가 피해야 하는 주소를 모델링합니다.

#의 기본 종류는 top입니다. Rust 타입 수준에서는 Padding<L, SIZE, KIND>에 있는 PaddingKind const 제네릭을 통해 이를 그대로 반영합니다. Padding<L, N>은 KIND = PaddingKind::Top이고, Padding<L, N, { PaddingKind::Zero }>는 0으로 채워진 변형이며, Padding<L, N, { PaddingKind::Bottom }>은 접근할 수 없습니다.

Cell::Padding(kind)는 패딩된 범위 안에 있지만 내부 매핑 밖에 있는 위치를 나타냅니다. kind는 Top, Zero, Bottom 중 하나이며, 패딩된 범위를 넘어서는 위치는 여전히 Cell::OutOfBounds입니다. Cell 결과의 전체 정의는 위치 결과에 있습니다.

Resize

Resize는 새 크기를 넘어서는 인덱스를 잘라내어 매핑을 더 작은 논리적 크기로 제한하며, 그 범위 밖의 요소는 버립니다. 버퍼를 확장하는 패딩과 달리, Resize는 논리적 뷰를 축소합니다. 매핑 m![D = 2]는 축 D의 처음 2개 요소만 취하여 인덱스 D = 0과 D = 1을 생성합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;

axes![C = 2, D = 3];
type E = m![C, D = 2]; // Pair<m![C], Resize<m![D], 2>>

fn test_resize() {
    assert_eq!(E::map(0), i![C: 0, D: 0]);
    assert_eq!(E::map(1), i![C: 0, D: 1]);
    assert_eq!(E::map(2), i![C: 1, D: 0]);
    assert_eq!(E::map(3), i![C: 1, D: 1]);
    assert_eq!(E::map(4), Cell::OutOfBounds);
}

test_resize();
}
impl<L, F> M for Resize<L, F>
where
    L: M,
    F: Count,
{
    const SIZE: usize = F::SIZE;

    fn to_value() -> Mapping {
        Mapping::Resize {
            inner: RBox::new(L::to_value()),
            resize: F::SIZE,
        }
    }

    fn map(i: usize) -> Cell {
        if i < F::SIZE { L::map(i) } else { Cell::OutOfBounds }
    }
}

타일링

타일링은 인덱스 뷰를 통해 구현됩니다. tile은 매핑 분할을 검증한 다음, 데이터를 복사하지 않고 메타데이터 뷰를 생성합니다. 범용 뷰 API는 TensorView::tile<I, E2, LEN>(start)입니다. I는 타일의 위치를 찾는 데 쓰이는 매핑이고, E2는 요청하는 뷰 매핑이며, LEN은 타일에 포함되는 I 셀의 개수이고, start는 I를 따라가는 논리적 시작 좌표입니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;

axes![A = 8, B = 512];

let tensor = HbmTensor::<bf16, m![1], m![A, B]>::new();
let view = tensor.view(); // HbmTensorView::<'_, bf16, m![1], m![A, B]>
let tile01 = view.tile::<m![B], 2, m![A, B = 2 # 512]>(0); // HbmTensorView::<'_, bf16, m![1], m![A, B = 2 # 512]>
let tile23 = view.tile::<m![B], 2, m![A, B = 2 # 512]>(2); // HbmTensorView::<'_, bf16, m![1], m![A, B = 2 # 512]>
}

HBM 예제는 I = m![B], LEN = 2, E2 = m![A, B = 2 # 512]를 사용합니다. B = 2 # 512 매핑은 512셀 크기의 물리적 범위 안에서 뷰에 유효한 B 위치 두 개를 부여합니다. 이 풋프린트가 없으면 분할이 원본 매핑에 대해 검증되지 않습니다. start = 0과 start = 2는 논리적 B 좌표이므로, 이 뷰들은 각각 0..2와 2..4 범위를 다룹니다. 이 값들은 시작 위치이며, 별도의 타일 번호 API가 아닙니다.

읽기 뷰는 타일 바깥의 셀을 PaddingKind::Top으로 채웁니다. 가변 뷰는 쓰기가 타일 밖으로 벗어나지 못하도록 해당 위치에 PaddingKind::Bottom을 요구합니다. HBM과 DM은 계층별 전용 타일 래퍼를 제공하는 반면, TRF, VRF, DPE는 범용 뷰를 사용합니다. HostTensor에는 뷰/타일 API가 없습니다.

스트라이드와 모듈로

스트라이드(/)와 모듈로(%)는 하나의 차원을 바깥쪽(블록 인덱스)과 안쪽(블록 내 위치)의 두 차원으로 분해합니다. 요소 512개로 이루어진 축 B를 요소 64개씩으로 된 블록 8개로 나눈다고 생각해 보십시오. 매핑 m![B / 64, B % 64]는 8 × 64 격자를 생성하며, 첫 번째 차원은 어느 블록인지를 선택하고 두 번째 차원은 해당 블록 내의 위치를 선택합니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 512];
type D1 = m![B / 64]; // stride with size 8
type D2 = m![B % 64]; // modulo with size 64

type E = m![B / 64, B % 64]; // equivalent to `m![B]`

fn test_stride_modulo() {
    assert_eq!(E::map(130), i![B / 64: 2, B % 64: 2]); // block 2, position 2: B = 64*2 + 2 = 130
    assert_eq!(E::map(130), <m![B]>::map(130));               // same result as flat m![B]

    for i in 0..8 {
        assert_eq!(D1::map(i), i![B / 64: i]);
    }
    assert_eq!(D1::map(8), Cell::OutOfBounds);

    for j in 0..64 {
        assert_eq!(D2::map(j), i![B % 64: j]);
    }
    assert_eq!(D2::map(64), Cell::OutOfBounds);

    for i in 0..8 {
        for j in 0..64 {
            assert_eq!(
                E::map(64 * i + j),
                <m![B]>::map(64 * i + j),
            );
        }
    }
    assert_eq!(E::map(512), Cell::OutOfBounds);
}

test_stride_modulo();
}
impl<L, F> M for Stride<L, F>
where
    L: M,
    F: Count,
{
    const SIZE: usize = {
        assert!(L::SIZE % F::SIZE == 0, "Stride size must divide the original size");
        L::SIZE / F::SIZE
    };

    fn to_value() -> Mapping {
        Mapping::Stride {
            inner: RBox::new(L::to_value()),
            stride: F::SIZE,
        }
    }

    fn map(i: usize) -> Cell {
        if i < Self::SIZE {
            L::map(i * F::SIZE)
        } else {
            Cell::OutOfBounds
        }
    }
}

impl<L, F> M for Modulo<L, F>
where
    L: M,
    F: Count,
{
    const SIZE: usize = {
        assert!(L::SIZE % F::SIZE == 0, "Modulo size must divide the original size");
        F::SIZE
    };

    fn to_value() -> Mapping {
        Mapping::Modulo {
            inner: RBox::new(L::to_value()),
            modulo: F::SIZE,
        }
    }

    fn map(i: usize) -> Cell {
        if i < Self::SIZE {
            L::map(i % L::SIZE)
        } else {
            Cell::OutOfBounds
        }
    }
}

스트라이드와 모듈로 매핑은 표 형태로 시각화할 수 있습니다. B::SIZE = 16인 매핑 m![B / 4, B % 4]를 생각해 보십시오. 다음 표는 버퍼 인덱스가 배열되는 방식을 보여 줍니다. 각 행은 B / 4(스트라이드 축)의 특정 인덱스에 대응하고, 각 열은 B % 4(모듈로 축)의 인덱스에 대응합니다:

i![B % 4: 0]i![B % 4: 1]i![B % 4: 2]i![B % 4: 3]
i![B / 4: 0]i![B: 0]i![B: 1]i![B: 2]i![B: 3]
i![B / 4: 1]i![B: 4]i![B: 5]i![B: 6]i![B: 7]
i![B / 4: 2]i![B: 8]i![B: 9]i![B: 10]i![B: 11]
i![B / 4: 3]i![B: 12]i![B: 13]i![B: 14]i![B: 15]

모듈로는 버퍼 크기를 다루는 방식에서 리사이즈와 다음과 같이 다릅니다.

  • 리사이즈는 새 크기를 넘어서는 인덱스를 잘라내어 버퍼를 축소합니다.
  • 모듈로는 원래 버퍼 크기를 유지하면서 버퍼를 같은 크기의 블록으로 분할합니다.

복잡한 분해를 위해 이러한 연산을 중첩할 수 있습니다. 다음 예제는 B를 세 개의 차원으로 분할하며, 이때 버퍼의 비트 배치는 텐서 인덱스의 비트 배치와 다릅니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
extern crate furiosa_mapping;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 512];
// B's bits: 6 - 8,  0 - 4,          5
// Values:   0 - 7, 0 - 31,      0 - 1
type E = m![B / 64, B % 32, B / 32 % 2];

fn test_nested_stride() {
    assert_eq!(E::map(67), i![B: 97]); // 67 = 64*1 + 2*1 + 1 (i=1,j=1,k=1) → B = 64*1 + 1 + 32*1 = 97
    // Verify B=97 round-trips: 97/64=1, 97%32=1, (97/32)%2=1
    assert_eq!(97 / 64, 1);
    assert_eq!(97 % 32, 1);
    assert_eq!((97 / 32) % 2, 1);

    // buffer index: 64 * i + 2 * j + k (i = block, j = position within block, k = sub-block)
    // tensor index B: 64 * i + j + 32 * k (rearranges bit positions)
    for i in 0..8 {
        for j in 0..32 {
            for k in 0..2 {
                assert_eq!(
                    E::map(64 * i + 2 * j + k),
                    i![B: 64 * i + j + 32 * k],
                );
            }
        }
    }
    assert_eq!(E::map(512), Cell::OutOfBounds);
}

test_nested_stride();
}

이러한 비트 재배치는 뱅크 인터리빙이나 캐시 효율을 위해 주소 비트의 순서를 바꾸는 하드웨어 표현에 자연스럽게 대응됩니다. 이진수로 보면 비트 위치가 재배치되어 버퍼 001_00001_1은 B = 001_1_00001이 됩니다. 버퍼는 비트를 [8:6]_[5:1]_[0]으로 묶는 반면, B는 [8:6]_[5]_[4:0]으로 묶습니다.

타일링은 개별 요소가 아닌 블록 단위로 수행할 수 있습니다. 다음 예제는 m![B / 32]를 사용해 블록 단위로 타일링하여 서로 겹치는 타일을 만듭니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 512];
let tensor = HbmTensor::<bf16, m![1], m![A, B]>::new();
for i in 0..15 {
    let tile = tensor.view().tile::<m![B / 32], 2, m![A, B / 32 = 2 # 16, B % 32]>(i);
}
}

B = 512일 때 차원 B / 32에는 0부터 15까지 번호가 매겨진 16개의 블록이 있습니다. 각 타일은 인덱스 i에서 시작하는 연속된 블록 2개를 가집니다. 타일 0은 블록 {0, 1}을, 타일 1은 블록 {1, 2}를 포함하며, 이런 식으로 이어져 타일 14는 블록 {14, 15}를 포함합니다. 연속된 타일끼리 블록 하나를 공유하므로 이 타일들은 서로 겹칩니다.

각 타일은 정확히 2개의 블록을 포함하므로 타일 매핑 B / 32 = 2는 블록 차원의 크기를 2로 조정합니다. 단일 블록으로 타일링하는 경우 차원의 값이 하나뿐이므로 B / 32 = 1은 항등 매핑 m![1]로 단순화됩니다.

이스케이프

복잡한 매핑은 타입 별칭을 정의하고 { ... }를 사용해 참조하십시오. 별도의 매핑 L = m![A]와 R = m![B]가 있을 때, 이를 m![{ L }, { R }]처럼 결합하면 m![A, B]와 같은 결과가 나옵니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 512];
type L = m![A];
type R = m![B];
type E = m![{ L }, { R }]; // equivalent to `m![A, B]`

fn test_escape() {
    for i in 0..E::SIZE {
        assert_eq!(E::map(i), <m![A, B]>::map(i));
    }
}

test_escape();
}

이 이스케이프 문법은 복잡한 매핑을 이름이 붙은 재사용 가능한 구성 요소로 나눕니다.

동등한 매핑

서로 다른 생성자 조합이 같은 위치 동작을 만들어 낼 수 있습니다. 이러한 동작상의 관계는 Rust 매핑 값의 구조적 동등성과는 다릅니다. 다음 조건을 만족할 때 매핑 E1과 E2는 동등합니다.

  • E1::SIZE == E2::SIZE이고,
  • 모든 i에 대해 E1::map(i) == E2::map(i)입니다.

이 동치 관계는 반사적이고, 대칭적이며, 추이적입니다. 아래 항등식은 유용한 동작을 설명하지만, 정규화가 동치인 모든 입력에 대해 구조적으로 유일한 대표를 제공한다고 주장하지는 않습니다.

정규화에 대해 테스트된 동작은 이보다 범위가 좁습니다. 정규화를 두 번 적용해도 결과가 같은 멱등성을 가지고, 생성된 매핑 코퍼스는 테스트된 위치 동작과 패딩 동작을 유지하며, 정규화된 매핑은 매핑 표현을 거쳐 왕복 변환됩니다. 이러한 테스트는 동치성을 증명하거나, 표현의 유일성을 보장하거나, 보편적인 의미 보존을 입증하지 않습니다. 다음 항등식은 흔히 쓰이는 동치 관계를 나타냅니다.

  • 쌍의 항등원: 모든 E에 대해 E는 m![{ E }, 1]과 m![1, { E }] 모두와 동치입니다.
  • 스트라이드-모듈로 분해: 크기 E::SIZE가 n으로 나누어떨어지는 모든 E에 대해 E와 m![{ E } / n, { E } % n]은 동치입니다.
  • 쌍 사영: 모든 A와 B에 대해 m![[{ A }, { B }] / B::SIZE]는 m![A]와 동치이고, m![[{ A }, { B }] % B::SIZE]는 m![B]와 동치입니다.
  • 쌍의 결합 법칙: 모든 E1, E2, E3에 대해 m![{ E1 }, { E2 }, { E3 }], m![[{ E1 }, { E2 }], { E3 }], m![{ E1 }, [{ E2 }, { E3 }]]는 서로 동치입니다.
  • 멱등 연산: 모든 E에 대해 E는 m![{ E } / 1], m![{ E } # E::SIZE], m![{ E } = E::SIZE]와 각각 동치입니다.
  • 1에 대한 모듈로: 모든 E에 대해 m![E % 1]은 항등 매핑 m![1]과 동치입니다.

공간 차원과 시간 차원

HostTensor<D, E>는 단일 매핑으로 물리적 표현을 완전히 나타냅니다. 디바이스 텐서는 매핑을 여러 전용 차원에 나누어 담습니다:

  • 공간 차원: Chip, Cluster, Slice는 하드웨어 계층 전반에 데이터를 분산합니다. 스트림 텐서에서는 Packet이 각 시간 반복 내 병렬 전달의 크기를 추가로 정합니다.
  • 시간 차원: Time은 스트림 텐서에서 전달 반복의 순서를 정합니다.

공간 차원

하드웨어 계층의 각 공간 수준은 텐서 타입에서 고유한 타입 매개변수를 가지며, 이를 통해 공간 병렬성이 가능해집니다. 각 수준의 모든 유닛은 동일한 매핑을 공유한다고 가정합니다.

아래 표기법은 물리적 차원을 가리키지만 공개 타입이나 생성자는 아닙니다. 공개 텐서 API와 뷰는 스토리지 모듈에 문서화되어 있습니다.

extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use std::marker::PhantomData;
// Assumed throughout this page.
axes![A = 8, B = 512];

// HBM tensors
struct HbmTensor<D: Scalar, Chip: M, Element: M> {
    /* ... */
    _marker: PhantomData<(D, Chip, Element)>,
}

// SRAM tensors
// DM (Data Memory), TRF (Tensor Register File), and VRF (Vector Register File)
struct DmTensor<D: Scalar, Chip: M, Cluster: M, Slice: M, Element: M> {
    /* ... */
    _marker: PhantomData<(D, Chip, Cluster, Slice, Element)>,
}
struct TrfTensor<D: Scalar, Chip: M, Cluster: M, Slice: M, Lane: M, Element: M> {
    /* ... */
    _marker: PhantomData<(D, Chip, Cluster, Slice, Lane, Element)>,
}
struct VrfTensor<D: Scalar, Chip: M, Cluster: M, Slice: M, Element: M> {
    /* ... */
    _marker: PhantomData<(D, Chip, Cluster, Slice, Element)>,
}

HBM 텐서는 공간 병렬성을 위해 데이터를 여러 Chip에 분산합니다. 각 Chip은 자신이 맡은 데이터 부분을 동시에 처리합니다. 예를 들어, HbmTensor<bf16, m![A], m![B]>는 8 × 512 = 4096개의 요소를 8개의 Chip에 Chip당 512개씩 분산합니다. i번째 Chip의 j번째 요소는 텐서 인덱스 i![A: i, B: j]를 저장합니다.

SRAM 텐서 타입은 더 세분화된 병렬성을 위해 Cluster 및 Slice 차원을 추가합니다. TrfTensor에는 추가로 TRF 데이터를 Slice당 8개 레인에 분산하는 Lane 차원이 있습니다. 자세한 내용은 Contraction Engine을 참조하십시오.

HBM 텐서는 구체적인 주소를 가집니다. DM, TRF, VRF 텐서는 백엔드로부터 주소를 할당받을 수 있습니다. 호스트 텐서는 호스트 측 값으로 남습니다. 매핑 매개변수는 물리적 표현을 결정하며, 주소는 스토리지가 별도로 소유합니다.

제약 조건

  • Chip, Cluster, Slice 크기: 하드웨어 개수와 정확히 일치해야 합니다:

    단위개수제약 조건패딩 예시
    Chip시스템에 따라 다름Chip::SIZE == NUM_CHIPSm![1 # NUM_CHIPS]
    ClusterChip당 2개Cluster::SIZE == 2m![1 # 2]
    Slice256 / ClusterSlice::SIZE == 256m![X / N # 256]

    커널이 하드웨어가 제공하는 것보다 적은 수의 유닛을 사용할 때는 어떤 차원이든 #로 패딩할 수 있습니다. 예를 들어 type Cluster = m![1 # 2]는 활성 Cluster 1개와 패딩 전용 Cluster 1개를 사용하여 Chip당 Cluster 2개라는 하드웨어 요구 사항을 충족합니다.

    Note

    런타임은 Chip 단위(#[device(chip = N)])로 동작하므로 Chip이나 Cluster의 일부만 사용하는 것은 아직 지원되지 않습니다. 이 제한은 향후 릴리스에서 완화될 수 있습니다.

  • Element 크기: Element::SIZE * size_of::<D>()는 유닛당 SRAM 용량을 초과해서는 안 되며, 이 용량은 텐서 타입에 따라 다릅니다:

    타입단위제약 조건
    DmTensor512KB / SliceElement::SIZE * size_of::<D>() <= 512KB
    TrfTensor8KB / LaneLane::SIZE <= 8, Element::SIZE * size_of::<D>() <= 8KB
    VrfTensor8KB / SliceElement::SIZE * size_of::<D>() <= 8KB
  • Element 정렬: 디바이스 저장소 API는 주소에 정렬 제약 조건을 부과합니다. 모든 텐서에 하나의 주소 규칙이 적용된다고 가정하지 말고, 계층별 생성자와 백엔드 검사를 확인하십시오.

시간 차원

TuTensor는 Tensor Unit을 통해 스트림으로 흐르는 텐서 데이터를 나타냅니다. SRAM 타입과 동일한 Chip, Cluster, Slice 차원을 유지하며, 스트리밍을 위해 Time과 Packet을 추가합니다. Time은 시간 차원으로, 전달 반복의 순서를 정합니다. 공간 차원과 달리 Time에는 하드웨어가 부과하는 크기 제한이 없으며, 처리할 데이터의 양에 따라 커집니다. Packet은 각 Slice가 시간 반복마다 받는 요소 수를 결정하는 추가 공간 차원입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use std::marker::ConstParamTy;
use std::marker::PhantomData;
axes![N = 4, C = 64, H = 32, W = 32];

/// Pipeline stage.
/// `Vector` is intentionally absent: the Vector Engine uses a separate typestate
/// (`VectorBranchTensor` and friends) that tracks branch, ALU, and other Vector-specific state.
/// `Commit` is intentionally absent: once the Commit Engine writes results back to DM,
/// the data is at rest and the type becomes `DmTensor`, not `TuTensor`.
#[derive(PartialEq, Eq, ConstParamTy)]
enum Position {
    Begin,       // After the start of the pipeline
    Fetch,       // After the Fetch Engine
    Switch,      // After the Switch Engine
    Collect,     // After the Collect Engine
    Contraction, // After the Contraction Engine
    Reduce,      // After the Reduce Engine
    Cast,        // After the Cast Engine
    Transpose,   // After the Transpose Engine
}

struct TuTensor<
    'l,                // Lifetime tied to the Tensor Unit context
    const P: Position,
    D: Scalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
> {
    /* ... */
     _marker: PhantomData<&'l (D, Chip, Cluster, Slice, Time, Packet)>,
}

type T<'l> = TuTensor<
    'l,
    { Position::Fetch }, // Fetch Engine's output
    bf16,
    m![1],           // Chip: single chip
    m![1],           // Cluster: single cluster
    m![C / 2],       // Slice: distribute 64 channels across 32 slices
    m![N, H, W],     // Time: iterate over batch (N) and spatial (H, W) dimensions
    m![C % 2],       // Packet: 2 channels per cycle
>;
}

타입 T는 전체 형태가 \(\{N=4, C=64, H=32, W=32\}\)인 텐서를 32개 Slice에 걸쳐 스트리밍합니다(Slice::SIZE = m![C / 2]::SIZE = 32). Time 차원(m![N, H, W])의 크기는 4 * 32 * 32 = 4096이며, 이는 시간 반복이 4,096회 있다는 뜻입니다. 각 시간 반복마다 Packet 차원 m![C % 2]는 각 Slice에 채널 2개를 전달합니다. 32개 Slice가 병렬로 동작하므로, 각 시간 반복은 총 32 * 2 = 64개 채널을 처리합니다.

NCHW 표현 추적

NCHW 예제에서는 배치를 네 개의 Chip에 분산하는 HBM 표현부터 시작해 표현이 어떻게 바뀌는지 추적할 수 있습니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![N = 4, C = 64, H = 32, W = 32];
type Hbm = HbmTensor<bf16, m![N], m![C, H, W]>;
}

이를 활성 Chip 하나와 채널 두 개당 요소 Slice 하나로 구성된 DM으로 옮깁니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![N = 4, C = 64, H = 32, W = 32];
type Dm = DmTensor<bf16, m![1], m![1], m![C / 2], m![N, H, W, C % 2]>;
}

위의 스트림 타입 T는 같은 채널 분할을 사용하지만, N, H, W는 Time에 두고 각 Slice의 두 채널은 Packet에 둡니다.

Index (N=1, C=17, H=3, W=5)를 추적해 봅시다:

  • HBM은 Chip 1을 선택하며, 요소 오프셋은 17 * 32 * 32 + 3 * 32 + 5 = 17,509입니다.
  • DM은 Slice 17 / 2 = 8을 선택하고, 요소 매핑은 N=1, H=3, W=5와 패킷 위치 17 % 2 = 1을 선택합니다.
  • 스트림은 Slice 8에서 Time = 1 * 32 * 32 + 3 * 32 + 5 = 1,125와 Packet = 1을 선택합니다.

값의 물리적 좌표는 단계마다 바뀌지만, 그 Index는 (1, 17, 3, 5)로 유지됩니다. 이렇게 매핑에서 표현으로 이어지는 경로를 추적할 수 있습니다. 텐서가 값을 가진다는 것이 무엇을 뜻하는지는 텐서 의미론에서 정의합니다.

텐서 의미론

텐서는 HBM, Chip 내부 DM 또는 파이프라인 스트림에 존재하며, 연산은 이러한 텐서를 변환합니다. 이 장에서는 텐서의 수학적 의미를 정의합니다. 즉, 텐서 변수가 수학적 텐서를 보유한다는 것이 무엇을 의미하는지, 그리고 연산이 수학적 함수를 명세한다는 것이 무엇을 의미하는지를 정의합니다. 이러한 정의를 통해 vISA 프로그램을 텐서 수준에서 추론할 수 있습니다. 어떤 매핑이나 메모리 계층 단계를 사용하든 관계없이, 함수의 출력이 올바른 수학적 텐서를 보유하면 그 함수는 올바릅니다.

텐서 보유 의미론

각 요소가 각 차원의 매핑이 생성한 부분 인덱스를 합하여 만든 텐서 인덱스에서의 \(T\) 값을 저장할 때, 텐서 변수는 수학적 텐서 \(T\)를 보유합니다.

HostTensor<D, E>는 가장 단순한 경우로, 단일 매핑 E가 버퍼 위치와 텐서 인덱스 사이의 대응 관계를 완전히 결정합니다. 예를 들어 A = 8, B = 512인 HostTensor<bf16, m![A, B]>는 4,096개의 bf16 요소를 A-major, B-minor 순서로 저장합니다. 이 텐서는 다음 조건을 만족할 때 텐서 \(T\)를 보유합니다.

  • E::map(i) = Cell::Index(ti)인 모든 버퍼 인덱스 i에 대해,
  • i번째 요소가 ti에서의 \(T\) 값을 저장합니다.

HbmTensor<D, Chip, Element>는 단일 매핑을 둘로 나누어 이를 확장합니다. Chip은 Chip 인덱스를 부분 텐서 인덱스로 매핑하고, Element는 Chip별 요소 인덱스를 나머지 부분 인덱스로 매핑하며, 각각이 서로 겹치지 않는 축 부분집합을 담당하므로 둘의 합이 전체 텐서 인덱스를 복원합니다. 이 텐서는 다음 조건을 만족할 때 \(T\)를 보유합니다.

  • Chip::map(i) = Cell::Index(ti)이고 Element::map(j) = Cell::Index(tj)인 모든 Chip 인덱스 i와 요소 인덱스 j에 대해,
  • i번째 Chip의 j번째 요소는 인덱스 ti + tj에서의 \(T\) 값을 저장합니다.

다른 모든 텐서 타입은 같은 규칙을 더 많은 차원에 적용합니다. 즉, 각 요소는 모든 매핑 매개변수가 반환한 부분 인덱스의 합에서의 \(T\) 값을 저장합니다.

선형 결합 의미론

선형 결합 표현식 $(e1:n1, ..., ed:nd)는 지정된 스트라이드로 여러 차원을 결합합니다. 그 크기는 size_S($(e1:n1, ..., ed:nd)) = 1 + sum_k((size_S(ek) - 1) * nk)입니다. 매핑 S, $(e1:n1, ..., ed:nd) |- si ~ ti는 모든 k에 대해 S, ek |- sik ~ tik, si = sum_k(sik * nk), ti = sum_k(tik * nk)를 만족하는 si1...sid, ti1...tid가 존재할 때 유효합니다.

선형 결합은 외합(outer sum)을 표현할 수 있습니다. e1 * e2는 $(e1 : size_S(e2), e2 : 1)와 동등합니다. 축 순서 변경이 중요한 경우에는 외합이 선호됩니다. e1 * e2를 e2 * e1로 바꿀 때 스트라이드를 수동으로 갱신할 필요가 없기 때문입니다.

슬라이딩 연산은 서로 겹치는 데이터 블록에 접근합니다. \(\{N=5, F=3\}\) 형태의 텐서를 나타내는 9개 요소의 버퍼를 생각해 보십시오. 여기서 각 행은 한 번에 한 요소씩 슬라이딩하는 3개 요소 Slice입니다. \(N, F\) 위치의 텐서 요소는 버퍼 인덱스 \(N + 2F\)에 매핑됩니다.

$$ \begin{array}{c|ccc} & F=0 & F=1 & F=2 \\ \hline N=0 & 0 & 2 & 4 \\ N=1 & 1 & 3 & 5 \\ N=2 & 2 & 4 & 6 \\ N=3 & 3 & 5 & 7 \\ N=4 & 4 & 6 & 8 \\ \end{array} $$

이 슬라이딩 패턴에서는 하나의 공간 인덱스가 여러 텐서 인덱스에 매핑될 수 있습니다. 예를 들어 공간 인덱스 4는 {4_N}, {2_N, 1_F}, {2_F}에 동시에 매핑되며, 이는 (S, e).maps(si, ti)가 일대일 관계가 아님을 보여 줍니다. 이 선형 결합은 N에 스트라이드 1을, F에 스트라이드 2를 사용하므로 1 + (5-1)*1 + (3-1)*2 = 9가 됩니다.

함수 명세

함수를 명세한다는 것은 그 출력이 입력에 대해 무엇을 보유하는지 선언하는 것을 의미합니다. 예를 들어 함수 elementwise_add는 다음과 같은 의미에서 수학적 연산 \(f(T_1, T_2) = T_1 + T_2\)를 명세합니다.

  • 모든 텐서 \(T_1\)와 \(T_2\)에 대해,
  • lhs가 \(T_1\)를 담고 rhs가 \(T_2\)를 담으면,
  • 반환값은 \(T_1 + T_2\)를 담습니다.
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 512];

// This signature specifies the mathematical contract; the engine pipeline
// implementation is shown in the Computing Tensors chapter.
fn elementwise_add(
    lhs: &HbmTensor<bf16, m![A], m![B]>,
    rhs: &HbmTensor<bf16, m![A], m![B]>,
) -> HbmTensor<bf16, m![A], m![B]> {
    // The implementation is intentionally omitted; this example states only
    // the function's tensor-level contract.
}

수학적 텐서 이동은 \(f(T) = T\)를 명세합니다. 즉, 출력은 표현 방식과 관계없이 입력과 같은 수학적 텐서를 담습니다. .to_dm()은 수학적 텐서 이동입니다. 예를 들어 .to_dm() 메서드는 다음과 같은 의미에서 \(f(T) = T\)를 명세합니다.

  • hbm이 \(T\)를 담으면,
  • 반환값은 \(T\)를 담습니다.
#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 512];

fn hbm_to_dm(
    device: &mut Device,
    hbm: &HbmTensor<bf16, m![A], m![B]>,
) -> DmTensor<bf16, m![A], m![1], m![B / 2], m![B % 2]> {
    hbm.to_dm(&mut device.tdma)
}
}

텐서 이동

Furiosa-opt 커널은 Furiosa NPU용으로 컴파일되는 Rust 함수입니다. 이 장에서는 커널이 호스트 메모리, HBM, DM, Tensor Unit 사이에서 텐서 데이터를 전송하는 방법을 설명합니다. 빠른 시작 커널 설계에서는 완전한 커널을 통해 이러한 계층을 소개합니다. 전송 예제를 읽기 전에 이동 표에서 출발지와 목적지를 고르십시오. Sequencer에서는 공통 루프 모델을 설명합니다. 각 엔진 페이지에서는 구체적인 경계를 하나씩 문서화합니다.

이동 경로 선택

이동 표는 현재 값에서 목적지로 가는 연산을 선택합니다. 이 표는 각 공개 경계에 대한 연산과 컨텍스트를 제공합니다.

현재 값목적지연산과 컨텍스트
HostTensorHbmTensorDevice::pdma와 함께 사용하는 HostTensor::to_hbm(비동기). DMA Engine을 참조하십시오.
HbmTensorHostTensorDevice::pdma와 함께 사용하는 HbmTensor::to_host(비동기). DMA Engine을 참조하십시오.
HbmTensorDmTensorDevice::tdma와 함께 사용하는 HbmTensor::to_dm. DMA Engine을 참조하십시오.
DmTensorHbmTensorDevice::tdma와 함께 사용하는 DmTensor::to_hbm. DMA Engine을 참조하십시오.
HbmTensorHbmTensor호출에 필요한 DMA 컨텍스트와 함께 사용하는 HbmTensor::to_hbm. DMA Engine을 참조하십시오.
DmTensorDmTensorDevice::tdma와 함께 사용하는 DmTensor::to_dm 또는 Tensor Unit을 거치는 fetch/commit 왕복. DMA Engine과 Commit Engine을 참조하십시오.
DmTensorViewTensor Unit 스트림TuContext::begin 후 fetch
Tensor Unit 스트림DmTensorcommit, 또는 기존 가변 뷰에는 commit_view

복사 가능한 엔드투엔드 전송

이 페이지는 이동 경계, 컨텍스트 소유권, 수명 규칙을 정의합니다. 예제 전송은 HBM 텐서를 확장하고 재정렬한 뒤 HBM으로 되돌려 보냅니다. 디바이스 함수는 HBM의 데이터를 DM에 단계적으로 적재하고, 스트림을 fetch하고, 이를 캐스팅하여 collect한 뒤 DM에 commit하고, 결과를 다시 HBM으로 전송합니다. 호스트 테스트는 호스트에서 HBM으로, HBM에서 호스트로 가는 I/O만 대기하며 재정렬된 결과를 검사합니다.

예제 전송은 다음 순서로 읽고 변경하십시오.

단계선언된 선택
입력A = 4096, B = 8, 입력 요소 타입은 i8, 입력 HBM 매핑은 m![1], m![A, B]입니다.
HBM→DMDM의 Cluster는 m![1 # 2], Slice는 m![A / 16], Element는 m![A / 8 % 2, A % 8, B]입니다.
FetchOutTime은 m![A / 8 % 2]이고 OutPacket은 m![A % 8, B]입니다.
계산과 Collect스트림은 i8을 i32로 캐스팅하고, 이어서 Collect는 Time에 m![A / 8 % 2, A % 8]을, Packet에 m![B]를 사용합니다.
Commit→DM→HBMcommit_trim::<m![B]>()는 DM 텐서를 m![A, B]로 기록하고, 이어서 to_hbm은 DMA 재배치를 통해 HbmTensor<i32, m![1], m![B, A]>를 반환합니다.

소스의 정렬 단언은 유지하면서 여기에 선언된 축, 매핑 또는 요소 타입만 변경하십시오. 호스트 테스트는 pdma를 통해 to_hbm과 to_host를 대기한 다음, 예상한 B-major 결과를 단언합니다.

#![expect(clippy::type_complexity)]

use furiosa_opt_std::prelude::*;

axes![A = 4096, B = 8];

type Chip = m![1];
type Cluster = m![1 # 2];

#[device(chip = 1)]
pub fn fetch_commit_simple(
    device: &mut Device,
    input: &HbmTensor<i8, m![1], m![A, B]>,
) -> HbmTensor<i32, m![1], m![B, A]> {
    // Element's innermost axis must match the source's innermost (B, stride 1) so that the
    // DMA tail contains the full B axis (8 × i8 = 8 bytes), satisfying min_align = 8.
    let input_dm = input.to_dm::<Cluster, m![A / 16], m![A / 8 % 2, A % 8, B]>(&mut device.tdma);

    let fetch_and_commit_tensor: DmTensor<i32, Chip, Cluster, m![A / 16], m![A / 8 % 2, A % 8, B]> = device
        .main
        .begin(input_dm.view())
        .fetch::<m![A / 8 % 2], m![A % 8, B]>()
        .fetch_cast::<i32>()
        .collect::<m![A / 8 % 2, A % 8], m![B]>()
        .commit_trim::<m![B]>()
        .commit();

    fetch_and_commit_tensor.to_hbm(&mut device.tdma)
}
use furiosa_opt_examples::fetch_commit::fetch_commit_simple;
use furiosa_opt_std::prelude::*;

#[tokio::test]
async fn test_fetch_commit_simple_host() {
    use furiosa_opt_examples::fetch_commit::{A, B};

    let mut device = Device::new(fetch_commit_simple.topology()).unwrap();

    // Create input tensor with shape (A=4096)(B=8).
    let input = HostTensor::<i8, m![A, B]>::from_vec((0..32768).map(|x| x as i8).collect::<Vec<_>>())
        .to_hbm::<m![1], m![A, B]>(&mut device.pdma)
        .await
        .unwrap();

    // Call the device function.
    let output = launch(fetch_commit_simple, (&mut device, &input)).await.unwrap();

    let mut expected = vec![0i32; 4096 * 8];
    let mut idx = 0;
    for b in 0..8 {
        for a in 0..4096 {
            expected[idx] = ((a * 8 + b) as i8) as i32;
            idx += 1;
        }
    }

    assert_eq!(
        output.to_host::<m![B, A]>(&mut device.pdma).await.unwrap().into_inner(),
        Tensor::<_, m![B, A], CurrentBackend>::from_vec(expected)
    );
}

포함된 소스는 아래에서 설명하는 순서 규칙과 수명 규칙을 모두 보여 주는 완전한 예제입니다. 이 장에서는 이 전송을 읽기 쉬운 HBM↔DM 및 수명 튜토리얼로 남겨 두며, 상세한 패킷 및 성능 비교는 링크된 Tensor Unit I/O 패턴에서 다룹니다.

타입 수준의 Chip, Cluster, Slice, Time, Element 매핑은 각 값의 저장 방식과 스트림 동작을 정의합니다. DMA 호출은 타입 매개변수로 지정된 매핑을 갖는 대상을 생성합니다. 이 호출은 소스를 변경하지 않습니다. Fetch와 commit은 Tensor Unit 파이프라인으로 들어가고 나오는 인계 지점입니다.

첫 파이프라인에서는 HBM 입력을 DM에 스테이징하고 begin(...).fetch(...)를 호출한 다음, 계산 단계를 실행하고 스트림을 DM에 커밋한 뒤 결과를 다시 HBM으로 전송하십시오. 사례 연구: Tensor Unit I/O에서 구체적인 매핑과 소스 코드로 이 순서를 보여 줍니다.

전송 순서 유지

Device::new(Topology { chips, pes })는 Chip을 열며 launch가 실행되는 대상입니다. 이 디바이스는 서로 독립적인 main 및 sub Tensor Unit 컨텍스트와 tdma 및 pdma DMA 컨텍스트를 가집니다. 각 연산에는 그에 맞는 가변 컨텍스트를 전달하십시오. Rust의 차용(borrow)은 Tensor Unit 스트림이 소스 뷰를 살아 있게 유지하도록 강제합니다. begin은 DmTensorView를 차용하며, 스트림의 수명은 그 뷰보다 길 수 없습니다. Fetch는 BeginTensor를 소비하여 스트림을 생성합니다. fetch_cast나 collect 같은 어댑터는 스트림을 소비하고 다음 단계를 반환합니다. Commit은 최종 스트림을 소비하거나, 주어진 DmTensorViewMut에 쓰면서 소비합니다.

호스트 전송인 to_hbm과 to_host는 비동기 I/O이므로 반드시 await해야 합니다. 커널 측의 to_dm, to_hbm, fetch, commit 연산은 각자 선택된 컨텍스트에 디바이스 명령을 큐에 넣습니다. 이를 감싸는 커널 또는 백엔드 제출이 완료될 때까지 소스 핸들과 대상 핸들을 살아 있게 유지하십시오. 가변 뷰는 대상 영역을 의도적으로 덮어쓸 때만 사용하십시오. 대상을 생성하는 전송이나 커밋이 완료되기 전에는 그 대상을 읽지 마십시오. 또한 순서 간선(ordering edge) 없이 별칭 뷰에 대한 쓰기를 겹치게 하지 마십시오.

엔진 경계

  • Fetch는 Slice당 하나의 Sequencer로 DM을 읽고 Tensor Unit용 패킷 스트림을 내보냅니다. 출력 매핑은 스트림의 Time 축과 Packet 축을 선택합니다. 선택된 차원에 브로드캐스트가 포함된 경우 축 리프팅으로 축을 Time에서 Chip, Cluster 또는 Slice로 옮길 수도 있습니다.
  • Commit은 Tensor Unit 스트림을 DM에 씁니다. Element 매핑은 대상 레이아웃을 선택하며, 쓰는 동안 스트림 축을 전치할 수 있습니다.
  • DMA는 메모리 간 이동을 위해 읽기 Sequencer와 쓰기 Sequencer를 짝지어 사용합니다. 지원되는 공개 경로는 HBM에서 HBM, HBM에서 DM, DM에서 HBM, DM에서 DM입니다. 간접 gather/scatter API는 인덱스 단위에 대해 별도의 계약을 가집니다. 스케일되지 않은 gather는 원시 행 위치를 담은 DmTensor 인덱스를 사용하며, 스케일되지 않은 scatter는 현재 구현되어 있지 않습니다. SPM에는 공개 텐서 타입이 없습니다.

따라서 Tensor Unit 파이프라인은 다음과 같습니다.

flowchart LR
    H[Host] <-->|PCIe DMA| B[HBM]
    B <-->|Tensor DMA| D[DM]
    D -->|begin + fetch| F[Tensor Unit stream]
    F -->|commit| D
    F --> A[Adapters / switch / collect]
    A --> C[Compute]
    C -->|commit| D

Collect는 Contraction 작업이나 Vector 작업을 위해 스트림 피연산자를 TRF 또는 VRF 레지스터 파일에 배치할 수 있습니다. 이러한 계산 경계는 텐서 계산에서 다룹니다.

이동 튜닝

모든 이동은 수학적 텐서 이동입니다. 논리적 값은 보존되지만 대상 매핑은 바뀔 수 있습니다. 컴파일러는 두 매핑으로부터 Sequencer 스트라이드를 도출합니다. 성능을 튜닝하기 전에 엔진 제약 조건을 지키십시오. DM Cluster는 1 또는 2여야 하고, DM Slice는 64, 128 또는 256이어야 하며, DMA 패킷 꼬리는 API가 검사하는 요소 크기 정렬을 만족해야 합니다. Fetch와 Commit도 각자의 매핑별 Sequencer 규칙을 검증합니다. 잘못된 매핑은 컴파일이나 명령 검증 중에 실패합니다.

트래픽을 Cluster, Slice, DM 뱅크, HBM 채널 전반에 분산하는 연속적인 패킷 레이아웃과 매핑을 우선적으로 사용하십시오. 패킷이 작거나 스트라이드가 있으면 Sequencer 접근 횟수가 늘어납니다. DM 뱅크 패턴이 충돌하면 DMA가 기아 상태에 빠질 수 있습니다. 뱅크 기아 규칙, HBM 인터리빙, 패킷 크기 트레이드오프는 메모리 성능을 참조하십시오.

완전한 엔드투엔드 이동 패턴은 사례 연구: Tensor Unit I/O를 참조하십시오. 간접 HBM 행 이동은 DMA gather와 scatter를 참조하십시오.

함께 보기

furiosa-opt-std rustdoc에 릴리스 API가 문서화되어 있습니다. 연산별 계약은 위의 엔진 페이지를 참조하고, 유효한 이동 계획에 오버랩 측정이나 해저드 진단이 필요할 때는 스케줄링과 튜닝을 참조하십시오.

Sequencer

Sequencer는 메모리 버퍼를 패킷 스트림으로 읽고, 패킷 스트림을 다시 메모리에 씁니다. 작성자가 제어하는 요소는 매핑과 패킷 크기입니다.

Sequencer 동작 방식에서는 Fetch, Commit, DMA가 공유하는 루프, 스트라이드, 접근 크기, 수명을 설명합니다. 의사 타입 예제에서 모델을 소개하고, 접근 크기 절과 아키텍처 절에서 구체적인 엔진 동작을 설명합니다. Fetch Engine과 Commit Engine은 각각 Sequencer 하나를 사용하여 DM의 주소를 지정합니다. DMA Engine은 읽기 Sequencer와 쓰기 Sequencer를 연결하여 중간 버퍼 없이 DM과 HBM 사이에서 데이터를 이동합니다.

인터페이스

MemTensor와 StreamTensor는 버퍼-스트림 패턴만 따로 떼어 보여 주는 설명용 의사 타입이므로, 이 절에서는 모든 엔진 타입을 소개하지 않고도 Sequencer 동작 방식을 설명할 수 있습니다. 실제 엔진 API는 다른 타입(DmTensor, HbmTensor, TuTensor, …)을 사용하지만, 구체적인 타입 쌍은 모두 아래에 나타낸 것과 같은 MemTensor → StreamTensor 형태에 대응합니다.

MemTensor는 어떤 메모리 매핑 Buf에 데이터를 보관합니다. 공개 API에서는 DM 텐서와 HBM 텐서가 이 역할을 합니다.

/// A generic buffer-backed tensor.
/// Anything that holds data in a memory `Buf` and can be streamed in or out.
#[derive(Debug)]
pub struct MemTensor<D: Scalar, Buf: M> {
    inner: Tensor<D, Buf>,
}

StreamTensor는 전송 중인 텐서입니다. 수명 'l은 스트림을 원본 버퍼에 묶어 스트림이 데이터보다 오래 존재할 수 없게 합니다. Time은 시간 매핑(시간에 따른 반복)이고 Packet은 공간 매핑(단일 패킷의 내용)입니다.

/// A streaming view of a tensor in flight.
/// `Packet` is the per-cycle shape and `Time` is the multi-cycle shape.
#[derive(Debug)]
pub struct StreamTensor<'l, D: Scalar, Time: M, Packet: M> {
    inner: Tensor<D, Pair<Time, Packet>>,
    _marker: PhantomData<&'l ()>,
}

read는 MemTensor를 StreamTensor로 변환하고 write는 그 반대로 변환하며, 두 연산 모두 값을 보존합니다. 각 엔진의 전체 API는 여기에 공간 차원(Chip, Cluster, Slice)을 더하며, 이는 엔진별 페이지에서 다룹니다.

impl<D: Scalar, Buf: M> MemTensor<D, Buf> {
    /// Reads a stream from this buffer with the supplied `Time` and `Packet` mapping.
    /// `(Time, Packet)` may be a broadcast of `Buf` (matches Fetch / Switch / DMA-read behavior).
    pub fn read<'l, Time: M, Packet: M>(&'l self) -> StreamTensor<'l, D, Time, Packet> {
        StreamTensor {
            inner: self.inner.transpose(true),
            _marker: PhantomData,
        }
    }

    /// Writes a stream back into this buffer.
    /// Broadcast is rejected: each `Buf` slot must have exactly one source position in `(Time, Packet)` (matches Commit behavior).
    pub fn write<'l, Time: M, Packet: M>(&mut self, stream: StreamTensor<'l, D, Time, Packet>) {
        self.inner = stream.inner.transpose(false);
    }
}

어떤 MemTensor에 대해서든 유효한 Time과 Packet 조합이 여러 가지 존재하며, 각 조합은 서로 다른 StreamTensor를 만들어 냅니다. 유효한 선택지 중에서는 Packet 크기가 클수록 대역폭 활용도가 높아지며, 이에 따른 트레이드오프는 메모리 성능에서 자세히 다룹니다.

예제

다음 예제는 위의 핵심 API를 사용하는 일반적인 읽기 및 쓰기 패턴을 보여 줍니다. 컴파일러가 각 패턴의 하드웨어 구성을 도출하는 방법은 아래의 아키텍처에서 설명합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![A = 8, B = 512, N = 4, C = 3, H = 8, W = 8, T = 4, P = 4];

/// Strided access: read 8×512 tensor as 128 packets of 32 elements.
/// Time = m![A, B / 32] produces 8 * 16 = 128 time steps.
/// Packet = m![B % 32] delivers 32 consecutive elements per packet.
fn strided_read<'l>(
    buf: &'l MemTensor<bf16, m![A, B]>,
) -> StreamTensor<'l, bf16, m![A, B / 32], m![B % 32]> {
    buf.read()  // Automatic type inference
}

/// Strided write: write 128 packets of 32 elements back to 8×512 tensor.
fn strided_write(
    buf: &mut MemTensor<bf16, m![A, B]>,
    stream: StreamTensor<bf16, m![A, B / 32], m![B % 32]>,
) {
    buf.write(stream)
}

/// Axis reordering read: change traversal from [N, C, H, W] to [W, H, C, N].
/// Time = m![W, H, C, N] iterates in reversed axis order.
/// Packet = m![1] delivers single-element packets.
fn axis_reordering_read<'l>(
    buf: &'l MemTensor<bf16, m![N, C, H, W]>,
) -> StreamTensor<'l, bf16, m![W, H, C, N], m![1]> {
    buf.read()
}

/// Axis reordering write: write [W, H, C, N] stream back to [N, C, H, W] buffer.
fn axis_reordering_write(
    buf: &mut MemTensor<bf16, m![N, C, H, W]>,
    stream: StreamTensor<bf16, m![W, H, C, N], m![1]>,
) {
    buf.write(stream)
}

/// Tiling read: break axes into sub-blocks for cache efficiency.
/// Time = m![A % 2, B % 4, A / 2, B / 4] tiles A into 2 × 4, B into 4 × 128 blocks.
/// Packet = m![C # 32] pads C to 32 elements per packet.
fn tiling_read<'l>(
    buf: &'l MemTensor<i8, m![A, B, C # 8]>,
) -> StreamTensor<'l, i8, m![A % 2, B % 4, A / 2, B / 4], m![C # 32]> {
    buf.read()
}

/// Tiling write: write tiled stream back to buffer.
fn tiling_write(
    buf: &mut MemTensor<i8, m![A, B, C # 8]>,
    stream: StreamTensor<i8, m![A % 2, B % 4, A / 2, B / 4], m![C # 32]>,
) {
    buf.write(stream)
}

/// Broadcasting read: replicate elements absent from `Buf`.
/// Time = m![T, A] broadcasts T temporally (same data repeated T times).
/// Packet = m![P] broadcasts P spatially (same element fills packet).
fn broadcasting_read<'l>(
    buf: &'l MemTensor<i8, m![A]>,
) -> StreamTensor<'l, i8, m![T, A], m![P]> {
    buf.read()
}

/// Broadcasting write: write broadcast stream back to buffer.
/// This is rejected as each `Buf` slot must have exactly one source position in `(Time, Packet)`
/// This code will panic when run
fn broadcasting_write(
    buf: &mut MemTensor<i8, m![A]>,
    stream: StreamTensor<i8, m![T, A], m![P]>,
) {
    buf.write(stream)
}

let buf_read = MemTensor::<bf16, m![A, B]>::from_vec(vec![bf16::from_f32(1f32); 8 * 512]);
let mut buf_write = MemTensor::<bf16, m![A, B]>::from_vec(vec![bf16::from_f32(1f32); 8 * 512]);

let stream = strided_read(&buf_read);
strided_write(&mut buf_write, stream);

// -----------------------------------------------------------------------------------

let buf_read = MemTensor::<bf16, m![N, C, H, W]>::from_vec(vec![bf16::from_f32(1f32); 4 * 3 * 8 * 8]);
let mut buf_write = MemTensor::<bf16, m![N, C, H, W]>::from_vec(vec![bf16::from_f32(0f32); 4 * 3 * 8 * 8]);

let stream = axis_reordering_read(&buf_read);
axis_reordering_write(&mut buf_write, stream);

// -----------------------------------------------------------------------------------

let buf_read = MemTensor::<i8, m![A, B, C # 8]>::from_vec(vec![1i8; 8 * 512 * 8]);
let mut buf_write = MemTensor::<i8, m![A, B, C # 8]>::from_vec(vec![0i8; 8 * 512 * 8]);

let stream = tiling_read(&buf_read);
tiling_write(&mut buf_write, stream);

// -----------------------------------------------------------------------------------

let buf_read = MemTensor::<i8, m![A]>::from_vec(vec![1i8; 8 ]);
let mut buf_write = MemTensor::<i8, m![A]>::from_vec(vec![0i8; 8 ]);

let stream = broadcasting_read(&buf_read);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
    broadcasting_write(&mut buf_write, stream);
}));
assert!(result.is_err()); 

}

아키텍처

각 Sequencer 호출은 입력 및 출력 텐서 매핑으로부터 Sequencer 하드웨어가 실행하는 중첩 루프 구성으로 컴파일됩니다. 각 구성은 [size_0 : stride_0, size_1 : stride_1, ...] : packet_size 형태를 가지며, 여기서 첨자 0은 가장 바깥쪽 루프입니다.

#![allow(unused)]
fn main() {
struct Config {
    /// Each entry defines a nested loop level.
    entries: Vec<Entry>,
    /// Number of elements per packet.
    packet_size: usize,
}

struct Entry {
    /// Number of iterations for this loop level.
    size: usize,
    /// Memory address distance (in elements) to skip after each iteration.
    stride: isize,
}
}

각 항목은 텐서 순회의 한 차원을 나타냅니다. size 필드는 이 루프의 반복 횟수를 결정하고, stride 필드는 연속된 반복 사이의 메모리 오프셋을 결정합니다.

접근 크기

max_access_size = gcd(Packet::SIZE, contiguous_run)는 하드웨어 접근 1회당 요소 수입니다.

여기서 contiguous_run은 Config 항목 중 물리적으로 연속된 가장 안쪽 연속 구간의 요소 수입니다. max_access_size가 클수록 패킷당 접근 횟수가 줄어듭니다. 다음은 Config로부터 max_access_size를 계산하는 방법을 보여 줍니다.

#![allow(unused)]
fn main() {
struct Config { entries: Vec<Entry>, packet_size: usize }
struct Entry { size: usize, stride: isize }
fn gcd(mut a: usize, mut b: usize) -> usize { while b != 0 { (a, b) = (b, a % b); } a }
impl Config {
    fn contiguous_run(&self) -> usize {
        // Walk pairs from innermost outward; stop at the first non-contiguous pair.
        // Two adjacent entries (n_outer : s_outer) and (n_inner : s_inner)
        // are contiguous when s_outer == n_inner * s_inner.
        let mut contiguous_run = self.entries.last().map_or(1, |e| e.size);
        for w in self.entries.windows(2).rev() {
            if w[0].stride == w[1].size as isize * w[1].stride {
                contiguous_run *= w[0].size;
            } else {
                break;
            }
        }
        contiguous_run
    }

    fn max_access_size(&self) -> usize {
        gcd(self.packet_size, self.contiguous_run())
    }
}

let config = Config {
    entries: vec![
        Entry { size: 4, stride: 192 },
        Entry { size: 3, stride: 64 },
        Entry { size: 8, stride: 8 },
        Entry { size: 8, stride: 1 },
    ],
    packet_size: 8,
};

assert_eq!(config.contiguous_run(), 768);
assert_eq!(config.max_access_size(), 8);
}

대부분의 경우 패킷 레이아웃은 DM에서 완전히 연속적이며 max_access_size == Packet::SIZE입니다. max_access_size < Packet::SIZE인 경우는 비연속 패킷을 참조하십시오.

작동 방식

m![N, C, H, W] → m![W, H, C, N]에 대한 Config는 스트림의 축마다 항목을 하나씩 가지며, 각 항목의 스트라이드는 원본 버퍼에서 해당 축이 차지하는 범위와 같습니다. Packet = m![1]이므로 Packet::SIZE = max_access_size = 1이며, Sequencer는 루프 반복마다 DM 접근을 한 번 수행합니다.

extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
struct Config {
    entries: Vec<Entry>,
    packet_size: usize,
}
struct Entry {
    size: usize,
    stride: isize,
}
axes![N = 4, C = 3, H = 8, W = 8];

fn read_nchw_whcn(buf: &MemTensor<bf16, m![N, C, H, W]>) ->
                     StreamTensor<bf16, m![W, H, C, N], m![1]> {
    // Compiler-generated configuration: [8 : 1, 8 : 8, 3 : 64, 4 : 192] : 1
    let config = Config {
        entries: vec![
            Entry { size: 8, stride: 1 },   // W
            Entry { size: 8, stride: 8 },   // H
            Entry { size: 3, stride: 64 },  // C
            Entry { size: 4, stride: 192 }, // N
        ],
        packet_size: 1,
    };

    // The hardware executes the configuration as nested loops:
    for w in 0..8 {
        for h in 0..8 {
            for c in 0..3 {
                for n in 0..4 {
                    // Read each address
                    let addr = 1 * w + 8 * h + 64 * c + 192 * n;
                    // yield buf[addr];
                }
            }
        }
    }

    buf.read()
}

fn write_whcn_nchw(buf: &mut MemTensor<bf16, m![N, C, H, W]>,
                  stream: StreamTensor<bf16, m![W, H, C, N], m![1]>) {
    // The compiler generates an identical config for writing
    // The hardware executes the configuration as nested loops:
    for w in 0..8 {
        for h in 0..8 {
            for c in 0..3 {
                for n in 0..4 {
                    // Write to each address
                    let addr = 1 * w + 8 * h + 64 * c + 192 * n;
                    // buf[addr] = stream.next();
                }
            }
        }
    }
}

구성

다음 패턴은 커널 작성자가 마주칠 만한 구성 대부분을 다룹니다.

축 전치

축을 전치하여 스트림이 버퍼와 다른 순서로 축을 방문하게 할 수 있으며, 컴파일러는 그 순서로 메모리를 순회하는 데 필요한 스트라이드를 계산합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![A = 8, B = 8, C = 8];

fn read_rearranging<'l>(
    buf: &'l MemTensor<i8, m![A, B, C # 32]>,  // Buf
) -> StreamTensor<'l, i8, m![B, A], m![C # 16]> {  // Time, Packet
    buf.read()
}

let buf_read = MemTensor::<i8, m![A, B, C # 32]>::from_vec(vec![1i8; 8 * 8 * 32]);
let _stream = read_rearranging(&buf_read);
}

컴파일러는 결합된 매핑 m![B, A, C # 16]을 항 단위로 처리하고 그 과정에서 Buf를 변환하여 구성 항목을 생성합니다. 각 항에서 항목 크기는 항의 크기와 같고, 스트라이드는 현재 Buf 안에서 그 항이 차지하는 부피와 같습니다. 항을 처리한 후에는 해당 축이 소비되었음을 반영하도록 Buf가 갱신됩니다:

항항목스트라이드 출처처리 후 Buf
B8 : 32m![C # 32]::SIZEm![A, 1 # 8, C # 32]
A8 : 256m![1 # 8, C # 32]::SIZEm![1 # 64, C # 32]
C # 1616 : 1연속(Packet 차원)1 # 2048

Packet::SIZE = max_access_size = 16입니다. 가장 안쪽 항목 16 : 1이 연속이므로 하드웨어는 한 번의 접근으로 패킷 전체를 전송합니다.

축 분할

타일링은 캐시 효율을 높이거나 Tensor Unit 버퍼 크기에 맞추기 위해 논리 축을 하위 블록으로 나누며, 컴파일러는 축을 여러 항목으로 분할하여 이를 구현합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![A = 8, B = 8, C = 4];

fn read_splitting<'l>(
    buf: &'l MemTensor<i8, m![A, B, C # 8]>,  // Buf
) -> StreamTensor<'l, i8, m![A % 2, B % 4, A / 2, B / 4], m![C # 32]> {  // Time, Packet
    buf.read()
}

let buf_read = MemTensor::<i8, m![A, B, C # 8]>::from_vec(vec![1i8; 8 * 8 * 8]);
let _stream = read_splitting(&buf_read);
}

A % 2와 A / 2 같은 표현식은 축 A를 별도의 항목으로 분할합니다. 컴파일러는 m![A % 2, B % 4, A / 2, B / 4, C # 32]를 항 단위로 처리합니다:

항항목스트라이드 출처처리 후 Buf
A % 22 : 64m![B, C # 8]::SIZEm![A / 2, 1 # 2, B, C # 8]
B % 44 : 8m![C # 8]::SIZEm![A / 2, 1 # 2, B / 4, 1 # 4, C # 8]
A / 24 : 128m![1 # 2, B / 4, 1 # 4, C # 8]::SIZEm![1 # 8, B / 4, 1 # 4, C # 8]
B / 42 : 32m![1 # 4, C # 8]::SIZEm![1 # 64, C # 8]
C # 3232 : 1연속(Packet 차원)1 # 512

Packet::SIZE = max_access_size = 32입니다.

축 슬라이싱

슬라이싱은 메모리 레이아웃에서 인덱스의 일부 범위만 읽는 것으로, 인덱싱된 뷰가 원본 텐서의 부분 집합을 선택할 때 발생합니다.

extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![A = 16, B = 8, C = 8];

fn read_slicing<'l>(
    buf: &'l MemTensor<i8, m![A, B, C]>,  // Buf
) -> StreamTensor<'l, i8, m![A / 4, A % 4 = 3, B / 4, B % 4 = 2], m![C]> {  // Time, Packet
    buf.read()
}

let buf_read = MemTensor::<i8, m![A, B, C]>::from_vec(vec![1i8; 16 * 8 * 8]);
let _stream = read_slicing(&buf_read);

= 3 표기는 A % 4의 반복 횟수를 4회가 아닌 3회로 제한하여, 하드웨어가 텐서의 하위 영역만 다루도록 합니다. 컴파일러는 m![A / 4, A % 4 = 3, B / 4, B % 4 = 2, C]를 항 단위로 처리합니다:

항항목스트라이드 출처처리 후 Buf
A / 44 : 256m![A % 4, B, C]::SIZEm![1 # 4, A % 4, B, C]
A % 4 = 33 : 64m![B, C]::SIZE(3으로 슬라이스됨)m![1 # 16, B, C]
B / 42 : 32m![B % 4, C]::SIZEm![1 # 32, B % 4, C]
B % 4 = 22 : 8m![C]::SIZE(2로 슬라이스됨)m![1 # 128, C]
C8 : 1연속(Packet 차원)1 # 1024

Packet::SIZE = max_access_size = 8입니다.

축 브로드캐스트

브로드캐스트는 스트림이 Buf에 없는 축을 순회할 때 하나의 요소를 여러 패킷이나 시간 단계에 걸쳐 복제합니다. N / 512와 같은 축 또는 부분 축 조각은 Time이나 Packet에는 나타날 수 있지만 Buf에는 나타날 수 없습니다. 그러면 Sequencer는 브로드캐스트 항목 : 0을 내보내고 같은 주소를 다시 방문합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![A = 16, T = 4, P = 4];

fn read_broadcasting<'l>(
    buf: &'l MemTensor<i8, m![A]>,  // Buf
) -> StreamTensor<'l, i8, m![T, A], m![P]> {  // Time, Packet
    buf.read()
}

let buf_read = MemTensor::<i8, m![A]>::from_vec(vec![1i8; 16]);
let _stream = read_broadcasting(&buf_read);
}

컴파일러는 m![T, A, P]를 항 단위로 처리합니다.

항항목스트라이드 출처처리 후 Buf
T4 : 0Buf에 없음(브로드캐스트)m![A]
A16 : 1m![A]의 A1 # 16
P4 : 0Buf에 없음(브로드캐스트)1 # 16

Packet::SIZE = max_access_size = 4입니다. P가 브로드캐스트이므로 동일한 요소가 패킷 전체에 복제됩니다(공간 브로드캐스트). T가 브로드캐스트이므로 동일한 데이터가 시간 단계에 걸쳐 반복됩니다(시간 브로드캐스트).

Time 또는 Packet이 Buf에 없는 축의 일부를 참조하는 경우에도 같은 규칙이 적용됩니다. 예를 들어 m![N % 512] 버퍼를 StreamTensor<m![N / 512], m![N % 512]>로 읽으면 N / 512 시간 엔트리에서 브로드캐스트가 일어납니다. 즉, 버퍼의 512개 요소가 N / 512개의 바깥 반복마다 재사용됩니다.

엔트리 병합

하드웨어는 구성당 최대 8개의 엔트리만 지원하므로, 변환 결과 엔트리가 이보다 많아지면 컴파일러가 인접한 엔트리를 병합하여 이 한도를 맞춥니다. 인접한 엔트리 (n1 : s1)과 (n2 : s2)는 물리적으로 연속일 때, 즉 s1 == n2 * s2일 때 (n1 * n2 : s2)로 병합됩니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![N = 8, C = 8, H = 8, W = 32];

fn read_merging<'l>(
    buf: &'l MemTensor<i8, m![N, C, H, W]>,  // Buf
) -> StreamTensor<'l, i8, m![W / 16, H % 2, H / 2, C / 2, C % 2, N / 2, N % 2, W / 8 % 2], m![W % 8]> {  // Time, Packet
    buf.read()
}

let buf_read = MemTensor::<i8, m![N, C, H, W]>::from_vec(vec![1i8; 8 * 8 * 8 * 32]);
let _stream = read_merging(&buf_read);
}

컴파일러는 m![W / 16, H % 2, H / 2, C / 2, C % 2, N / 2, N % 2, W / 8 % 2, W % 8]를 항별로 처리하여 다음과 같이 초기 엔트리 9개를 만듭니다.

항항목스트라이드 출처
W / 162 : 16m![W % 16]::SIZE
H % 22 : 32m![W]::SIZE
H / 24 : 64m![H % 2, W]::SIZE
C / 24 : 512m![C % 2, H, W]::SIZE
C % 22 : 256m![H, W]::SIZE
N / 24 : 4096m![N % 2, C, H, W]::SIZE
N % 22 : 2048m![C, H, W]::SIZE
W / 8 % 22 : 8m![W % 8]::SIZE
W % 88 : 1연속(패킷 차원)

항목 9개는 하드웨어 한도인 8개를 초과하므로, 컴파일러는 s1 == n2 * s2를 만족하는 연속된 쌍을 병합합니다. H % 2 -> (2 : 32)와 H / 2 -> (4 : 64) 항목은 물리적으로 연속되어 있지 않으므로(\(s_1 \neq n_2 \times s_2 \iff 32 \neq 4 \times 64\)) 병합되지 않습니다. 최종 구성은 6개 항목으로 이루어집니다. 마지막 병합은 시간/패킷 경계를 넘습니다. W/8%2 (2:8)와 W%8 (8:1)이 W%16 (16:1)로 병합됩니다.

항항목병합된 항목
W / 162 : 16
H % 22 : 32
H / 24 : 64
C8 : 256C / 2 (4 : 512),
C % 2 (2 : 256)
N8 : 2048N / 2 (4 : 4096),
N % 2 (2 : 2048)
W % 1616 : 1W / 8 % 2 (2 : 8),
W % 8 (8 : 1)

Packet::SIZE = max_access_size = 8입니다.

비연속 패킷

DM 레이아웃이 패킷 범위 안에서 스트라이드 불연속을 가지면 max_access_size < Packet::SIZE가 되며, 하드웨어는 패킷당 한 번이 아니라 연속된 하위 블록마다 한 번씩 접근을 발행합니다. 아래 예시는 32개 요소로 이루어진 패킷(m![A, B])을 각 B 행이 DM에서 16개 슬롯으로 패딩된 버퍼에 씁니다. A의 스트라이드가 8이 아니라 16이므로 패킷 범위가 연속되지 않으며, 하드웨어는 1회 대신 4회의 접근을 발행합니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![A = 4, B = 8];

fn write_padded(
    buf: &mut MemTensor<i8, m![A, B # 16]>,
    stream: StreamTensor<i8, m![1], m![A, B]>,
) {
    // Compiler-generated configuration: [
    //   A -> 4 : 16,   (16 != 8 * 1, NOT contiguous — padding gap after each B row)
    //   B -> 8 : 1,    (packet sub-block, contiguous)
    // ] : 32
    buf.write(stream)
}

let buf_read = MemTensor::<i8, m![A, B]>::from_vec(vec![1i8; 4 * 8]);
let mut buf_write = MemTensor::<i8, m![A, B # 16]>::from_vec(vec![0i8; 4 * 16]);
let stream = buf_read.read();
write_padded(&mut buf_write, stream);
}

Packet::SIZE = 32, contiguous_run = 8, max_access_size = 8입니다.

불연속 스트라이드는 Packet이 소스 레이아웃에서 인접하지 않은 축을 포함할 때도 발생합니다. 아래 예시는 동일한 m![N, C, H, W] 버퍼를 서로 다른 두 가지 Packet 선택으로 읽습니다. 가장 안쪽 축 W만 Packet에 두면 max_access_size = Packet::SIZE = 8이 되어 패킷당 한 번 접근합니다. m![N, H, W]를 Packet에 배치하면 C를 건너뛰므로 소스에서 N의 스트라이드(96)가 H×W(32)와 같지 않습니다. 따라서 contiguous_run = 32, max_access_size = 32가 되며, 하드웨어는 패킷당 1회 대신 4회의 접근을 발행합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![N = 4, C = 3, H = 4, W = 8];

// Compiler-generated configuration: [
//   N -> 4 : 96,   (96 == 3 × 32, contiguous)
//   C -> 3 : 32,   (32 == 4 × 8,  contiguous)
//   H -> 4 : 8,    (8  == 8 × 1,  contiguous)
//   W -> 8 : 1,    (packet dimension)
// ] : 8
// contiguous_run = 8 (W); ×4 (H): 8==8×1 ✓; ×3 (C): 32==4×8 ✓; ×4 (N): 96==3×32 ✓; all axes contiguous
// max_access_size = gcd(packet_size, contiguous_run) = packet_size = 8
fn read_contiguous<'l>(
    buf: &'l MemTensor<i8, m![N, C, H, W]>,
) -> StreamTensor<'l, i8, m![N, C, H], m![W]> {
    buf.read()
}

let buf_read = MemTensor::<i8, m![N, C, H, W]>::from_vec(vec![1i8; 4 * 3 * 4 * 8]);
let _stream = read_contiguous(&buf_read);

// Compiler-generated configuration: [
//   C -> 3 : 32,   (time dimension)
//   N -> 4 : 96,   (96 != 4 × 8 = 32, NOT contiguous — C axis interspersed)
//   H -> 4 : 8,    (8  == 8 × 1, contiguous)
//   W -> 8 : 1,    (packet dimension)
// ] : 128
// contiguous_run = 8 (W); ×4 (H): 8==8×1 ✓ → 32; ×4 (N): 96!=4×8 ✗ stop → 32
// max_access_size = gcd(128, 32) = 32; hardware issues 128/32 = 4 accesses per packet
fn read_non_contiguous<'l>(
    buf: &'l MemTensor<i8, m![N, C, H, W]>,
) -> StreamTensor<'l, i8, m![C], m![N, H, W]> {
    buf.read()
}

let buf_read = MemTensor::<i8, m![N, C, H, W]>::from_vec(vec![1i8; 4 * 3 * 4 * 8]);
let _stream = read_non_contiguous(&buf_read);
}

제약 조건

RNGD에서는 다음 하드웨어 한도 중 하나라도 초과하면 컴파일 오류가 발생합니다:

  • 엔트리 한도: 엔트리는 최대 8개이므로 컴파일러는 가능한 경우 인접한 엔트리를 병합합니다(Configurations의 엔트리 병합 참조).
  • 반복 한도: 엔트리당 size <= 65,536입니다.
  • 패킷 크기: 1, 2, 4, 8, 16, 32바이트 중 하나여야 합니다.
  • 패킷 페치: 가장 안쪽 엔트리 n : s는 다음 중 하나를 만족해야 합니다:
    • 연속 접근(인접 요소): (s == 0 || s == 1) && n % packet_size == 0
    • 이산 접근(단일 요소 패킷): packet_size == 1

병합에 실패하거나 한도를 초과하면 텐서 매핑을 다시 설계하거나 연산을 여러 Sequencer 호출로 나누십시오.

호환되는 축 분해

Buf와 스트림 양쪽에 등장하는 각 축은 동일한 분해를 사용해야 합니다. 컴파일러는 스트림을 항 단위로 순회하면서 Buf에서 축을 소비합니다(아키텍처 참조). Buf가 축을 한 방식으로 분할하고 스트림이 공통 세분화 없이 다른 방식으로 분할하면, 양쪽의 전체 요소 수가 같더라도 어떤 순회 순서도 성립하지 않으므로 해당 구성은 거부됩니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use furiosa_opt_std::pseudo::{MemTensor, StreamTensor};
axes![A = 15];

fn read_incompatible<'l>(
    buf: &'l MemTensor<i8, m![A % 5, A / 5]>,  // Buf
) -> StreamTensor<'l, i8, m![1], m![A % 3, A / 3]> {  // Time, Packet
    buf.read() // Compilation error: incompatible decomposition
}

let buf_read = MemTensor::<i8, m![A % 5, A / 5]>::from_vec(vec![1i8; 15]);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { read_incompatible(&buf_read) }));
assert!(result.is_err());
}

Buf는 A를 5 × 3으로 분해하는 반면 스트림은 이를 3 × 5로 분해합니다. gcd(5, 3) = 1이므로 어느 분해도 다른 분해를 세분화하지 않습니다. 즉, 컴파일러는 이미 5블록 분할로 확정된 Buf에서 A % 3을 소비할 수 없습니다.

Fetch Engine

Fetch Engine은 DM 텐서를 읽어 Tensor Unit용 패킷 스트림을 생성하며, OutTime과 OutPacket이 이후 단계에서 소비할 스트림을 결정합니다.

DM의 데이터가 특정한 시간 순서와 패킷 순서로 Tensor Unit 스트림에 들어가야 할 때 Fetch를 선택하십시오.

인터페이스

BeginTensor는 Tensor Unit 파이프라인의 입구에서 DM에 상주하는 텐서를 나타냅니다. 이 텐서의 Time은 m![1]이고(파이프라인이 시작되기 전에는 시간 반복이 없음), Packet은 DM 내의 요소 레이아웃입니다.

BeginTensor::fetch()는 Sequencer를 실행하여 FetchTensor 패킷 스트림을 생성합니다. 이 스트림은 Fetch Adapter, Switch Engine, Collect Engine에 공급될 수 있습니다. assert_eq! 호출은 Cluster::SIZE, Slice::SIZE, 패킷 정렬에 대한 하드웨어 제약 조건을 강제합니다(제약 조건 참조).

impl<'l, const T: Tu, P: CanApplyFetch, D: Scalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Runs the Fetch Sequencer.
    #[primitive(TuTensor::fetch)]
    pub fn fetch<OutTime: M, OutPacket: M>(self) -> FetchTensor<'l, T, D, Chip, Cluster, Slice, OutTime, OutPacket, B> {
        verify_fetch::<Cluster, Slice, Time, Packet, OutTime, OutPacket>();
        FetchTensor::new(self.device, self.inner.transpose(true))
    }
}

텐서 매핑에서 소개했듯이, Chip, Cluster, Slice, Time, Packet 매핑은 데이터를 공간과 시간에 걸쳐 분배합니다. 각 Slice가 자신의 DM 파티션을 독립적으로 읽기 때문에 .fetch()는 Chip, Cluster, Slice 차원을 입력에서 변경하지 않고 그대로 유지합니다. 이후 Switch Engine이 Slice 간에 데이터를 이동시켜 Slice 매핑을 변경합니다. 축 리프팅은 선택한 차원에 걸쳐 서로 다른 DM 읽기 오프셋을 사용하여, 데이터를 전송하지 않고 Chip, Cluster 또는 Slice를 변경합니다.

fetch()는 Fetch Sequencer를 구성하는 OutTime 및 OutPacket 타입 매개변수를 받습니다. OutTime은 출력 스트림의 시간 단계 수를 설정하고, OutPacket은 각 패킷 내의 요소 레이아웃을 설정합니다. OutPacket 선택이 성능에 미치는 영향은 최적화를 참조하십시오.

다음 예제는 DM에서 i8 행렬을 i8 패킷 스트림으로 가져옵니다. 출력 FetchTensor는 512개의 시간 단계를 스트리밍하며, 각 시간 단계는 32개 요소로 이루어진 i8 패킷(32바이트)입니다. 여기서 OutTime = m![A]이고 OutPacket = m![B]입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![CH = 4, CL = 2, S = 256, A = 512, B = 32];

fn fetch_matrix_example<'l, const T: Tu>(
    input: BeginTensor<'l, T, i8, m![CH], m![CL], m![S], m![1], m![A, B]>,
) -> FetchTensor<'l, T, i8, m![CH], m![CL], m![S], m![A], m![B]> {
    input.fetch::<m![A], m![B]>()
}
}

Chip, Cluster, Slice는 하드웨어 공간 병렬성 차원입니다. Fetch Sequencer는 모든 Slice에서 독립적으로 실행되며, 각각 자신의 로컬 DM 파티션에서 동작합니다. 이 예제에서 CH = 4, CL = 2, S = 256은 Chip당 Cluster가 2개이고 Cluster당 Slice가 256개인 4-Chip 시스템을 나타냅니다. 각 Slice는 자신의 A×B 하위 텐서에 대해 동일한 Sequencer를 실행합니다.

축 리프팅

fetch_chip_lift, fetch_cluster_lift, fetch_slice_lift는 축을 Time에서 Chip, Cluster 또는 Slice로 옮깁니다. 일반 fetch는 모든 Chip, Cluster, Slice를 동일한 DM 오프셋에서 시작하고 Time의 축을 순회합니다. 리프트는 각 Chip, Cluster 또는 Slice에 서로 다른 읽기 베이스를 부여하므로, 리프트된 축은 병렬로 읽히고 Time에서 제거됩니다.

읽기 베이스는 시작 오프셋만 변경하므로, 리프트를 하려면 소스 영역이 선택한 차원에 걸쳐 복제되어 있어야 합니다. DM 배치는 이 복제를 브로드캐스트로 표현하며, 리프트는 이 브로드캐스트를 리프트된 축으로 대체합니다.

Slice의 경우, 데이터가 Slice 간에 이동하거나 Slice에서 Time으로 이동해야 할 때는 InterTranspose를 사용하십시오. Chip과 Cluster의 경우, 소스 영역이 복제되어 있지 않을 때는 DMA가 대안입니다.

단계차원베이스의 출처
fetch_chip_liftChip읽기 명령이 정적 base를 인코딩하므로 리프트는 설정 연산을 추가하지 않습니다
fetch_cluster_liftCluster읽기 명령이 정적 base를 인코딩하므로 리프트는 설정 연산을 추가하지 않습니다
fetch_slice_liftSlice컴파일러는 DMA, fetch 실행 컨텍스트에서의 덧셈, SFR 저장을 통해 Slice마다 런타임 base를 하나씩 구체화합니다

Slice base는 소스 텐서의 런타임 주소에 의존하므로 정적 명령 필드로 인코딩할 수 없습니다. 컴파일러는 상수 상대 오프셋을 DRAM에서 SRAM으로 복사하고, fetch 실행 컨텍스트에서 소스 주소를 더한 뒤, 그 결과를 Slice별 base SFR에 저장합니다. fetch는 이 설정이 끝날 때까지 대기하며, 이 설정은 Switch Engine의 사용자 정의 비트맵과 같은 SFR 저장 메커니즘을 사용합니다.

서브 컨텍스트 fetch의 경우 설정 저장이 fetch 자체에 필요한 SFR을 덮어씁니다. 컴파일러는 base를 기록한 뒤 해당 파라미터를 복원하며, 두 컨텍스트 모두 모든 리프트 변형을 지원합니다.

impl<
    'l,
    const T: Tu,
    P: CanApplyFetchChipLift,
    D: Scalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Gives each chip its own fetch base, lifting a DM axis onto `Chip`.
    #[primitive(TuTensor::fetch_chip_lift)]
    pub fn fetch_chip_lift<OutChip: M, OutTime: M>(
        self,
    ) -> FetchChipLiftTensor<'l, T, D, OutChip, Cluster, Slice, OutTime, Packet, B> {

impl<
    'l,
    const T: Tu,
    P: CanApplyFetchClusterLift,
    D: Scalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Gives each cluster its own fetch base, lifting a DM axis onto `Cluster`.
    #[primitive(TuTensor::fetch_cluster_lift)]
    pub fn fetch_cluster_lift<OutCluster: M, OutTime: M>(
        self,
    ) -> FetchClusterLiftTensor<'l, T, D, Chip, OutCluster, Slice, OutTime, Packet, B> {

impl<
    'l,
    const T: Tu,
    P: CanApplyFetchSliceLift,
    D: Scalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Gives each slice its own fetch base, lifting a DM axis onto `Slice`.
    #[primitive(TuTensor::fetch_slice_lift)]
    pub fn fetch_slice_lift<OutSlice: M, OutTime: M>(
        self,
    ) -> FetchSliceLiftTensor<'l, T, D, Chip, Cluster, OutSlice, OutTime, Packet, B> {

예시: Q를 Slice로 리프팅하기

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 128, Q = 4, V = 16];

fn fetch_halves_per_slice<'l, const T: Tu>(
    input: BeginTensor<'l, T, bf16, m![1], m![1 # 2], m![A, 2], m![1], m![Q, V]>,
) -> FetchSliceLiftTensor<'l, T, bf16, m![1], m![1 # 2], m![A, Q / 2], m![Q % 2], m![V]> {
    input
        .fetch::<m![Q], m![V]>()
        .fetch_slice_lift::<m![A, Q / 2], m![Q % 2]>()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let b: BeginTensor<'_, _, bf16, m![1], m![1 # 2], m![A, 2], m![1], m![Q, V]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_halves_per_slice(b);
}

A = 128일 때 Slice 배치 m![A, 2]는 128개의 Slice 쌍을 나타내며, 각 쌍의 두 Slice는 동일한 m![Q, V] 데이터를 보유합니다. 일반 fetch는 두 Slice 모두에서 네 개의 Q 값을 전부 읽습니다. 리프트는 브로드캐스트 2를 Q / 2로 대체하고 Q % 2를 Time에 남깁니다. 첫 번째 Slice는 base 0에서 Q = 0, 1을 읽고, 두 번째 Slice는 base 32 요소(bf16 기준 64바이트)에서 Q = 2, 3을 읽습니다.

배치base읽기시간 단계
fetch, 두 Slice 공통m![A, 2]0Q = 0, 1, 2, 34
리프트, 첫 번째 Slicem![A, Q / 2]0Q = 0, 12
리프트, 두 번째 Slicem![A, Q / 2]32개 요소Q = 2, 32

임의의 Chip, Cluster 또는 Slice의 기준 주소는 리프트된 각 축의 값에 해당 축의 원래 DM 스트라이드를 곱한 값의 합입니다. 여기서 두 번째 Slice는 Q / 2 = 1이므로 base는 1 × (Q % 2) × V = 32개 요소입니다.

요구 사항

리프트는 다음 조건을 모두 만족해야 합니다.

  • 선택한 차원의 입력 크기와 출력 크기가 일치해야 합니다.
  • 브로드캐스트만 변경할 수 있습니다. 검증기는 매핑을 크기 2 그룹 단위로 비교합니다. 변경되는 브로드캐스트 2는 크기 2의 축 구성 요소가 되어야 합니다. 브로드캐스트를 패딩으로 대체하려면 DM 배치를 재구성(reshape)하고 해당 패딩을 읽지 않은 채로 두십시오. 한 번의 리프트에서 여러 그룹을 변경할 수 있지만, 기존 축이나 크기 3의 브로드캐스트는 변경할 수 없습니다.
  • 리프트된 축은 OutTime에서 제거해야 합니다. OutPacket은 변경되지 않습니다.
  • 리프트 메서드는 Chip, Cluster, Slice 순서로 호출해야 하며, 차원마다 최대 한 번만 호출할 수 있습니다.
  • 계산된 모든 base는 8바이트의 배수여야 합니다.
  • unsafe reshape로 브로드캐스트를 도입하는 경우, 데이터가 이미 복제되어 있어야 합니다. reshape는 요소 순서만 검사하고 복제 여부는 검사하지 않으므로, 이 요구 사항을 위반하면 하드웨어와 CPU의 결과가 달라질 수 있습니다.
#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 128, H = 2, V = 16, Rep = 2, G = 2];

// Valid: DMA replicates `[H, V]` across `Rep`; reshape exposes `Rep` as a broadcast.
fn named_broadcast(
    device: &mut Device,
    input: &HbmTensor<bf16, m![1], m![A, H, V]>,
) -> DmTensor<bf16, m![1], m![1 # 2], m![A, H], m![V]> {
    let written: DmTensor<bf16, m![1], m![1 # 2], m![A, Rep], m![H, V]> =
        input.to_dm::<m![1 # 2], m![A, Rep], m![H, V]>(&mut device.tdma);
    let dm: DmTensor<bf16, m![1], m![1 # 2], m![A, 2], m![H, V]> = unsafe { written.reshape() };

    device.main
        .begin(dm.view())
        .fetch::<m![H], m![V]>()
        .fetch_slice_lift::<m![A, H], m![1]>()
        .collect::<m![1], m![V]>()
        .commit_trim::<m![V]>()
        .commit()
}

// Invalid for lifting: `G` stores different values, but reshape names it as a broadcast.
fn without_broadcast(
    device: &mut Device,
    input: &HbmTensor<bf16, m![1], m![A, G, H, V]>,
) -> DmTensor<bf16, m![1], m![1 # 2], m![A, H], m![V]> {
    let written: DmTensor<bf16, m![1], m![1 # 2], m![A, G], m![H, V]> =
        input.to_dm::<m![1 # 2], m![A, G], m![H, V]>(&mut device.tdma);
    let dm: DmTensor<bf16, m![1], m![1 # 2], m![A, 2], m![H, V]> = unsafe { written.reshape() };

    device.main
        .begin(dm.view())
        .fetch::<m![H], m![V]>()
        .fetch_slice_lift::<m![A, H], m![1]>()
        .collect::<m![1], m![V]>()
        .commit_trim::<m![V]>()
        .commit()
}
}

제약 조건

  • 하드웨어 차원: Chip::SIZE, Cluster::SIZE, Slice::SIZE는 하드웨어 구성과 일치해야 합니다(Sequencer 참조).

다중 읽기 패킷

패킷 축이 DM에서 연속적이지 않을 수 있고 하드웨어는 한 번에 최대 32바이트까지만 읽으므로, 패킷 하나를 준비하는 데 하드웨어 읽기가 여러 번 필요할 수 있습니다. 메인 컨텍스트에서 read_size는 max_access_size의 약수 중 D[read_size]가 1, 2, 4, 8, 16 또는 32바이트가 되는 가장 큰 값입니다. max_access_size에 대해서는 Sequencer 아키텍처를 참조하십시오. 서브 컨텍스트에서 read_size는 8바이트로 고정됩니다. 컴파일러는 입력 요소 타입과 Fetch Adapter 캐스트 여부로부터 read_size를 도출합니다. 사용자는 이 값을 직접 설정하지 않습니다. Packet::SIZE > read_size이면 항상 다중 읽기가 발생합니다. 예를 들어 메인 컨텍스트에서 24바이트 패킷은 read_size = 8이 되며 패킷당 3번 읽게 됩니다. 총 사이클 수는 Time::SIZE * (Packet::SIZE / read_size)입니다.

다음 예제는 형태가 m![N, C, H, W](N=4, C=3, H=4, W=16)인 동일한 i4 텐서를 서로 다른 네 가지 OutPacket 선택으로 가져옵니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![N = 4, C = 3, H = 4, W = 16];

/// Sequencer config: [N = 4 : 192, C = 3 : 64, H = 4 : 16, W = 16 : 1].
/// max_access_size = 16; read_size = 16 (8 bytes); reads per packet = 1; cycles = 48
fn fetch_batch_1<'l, const T: Tu>(
    input: BeginTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]>,
) -> FetchTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![N, C, H], m![W]> {
    input.fetch()
}

/// Sequencer config: [N = 4 : 192, C = 3 : 64, H / 2 = 2 : 32, H % 2 = 2 : 16, W = 16 : 1].
/// max_access_size = 32; read_size = 32 (16 bytes); reads per packet = 1; cycles = 24
fn fetch_batch_2<'l, const T: Tu>(
    input: BeginTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]>,
) -> FetchTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![N, C, H / 2], m![H % 2, W]> {
    input.fetch()
}

/// Sequencer config: [N = 4 : 192, C = 3 : 64, H = 4 : 16, W = 16 : 1].
/// max_access_size = 64; read_size = 64 (32 bytes); reads per packet = 1; cycles = 12
fn fetch_batch_3<'l, const T: Tu>(
    input: BeginTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]>,
) -> FetchTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![N, C], m![H, W]> {
    input.fetch()
}

/// Sequencer config: [N = 4 : 192, C = 3 : 64, H = 4 : 16, W = 16 : 1].
/// max_access_size = 192; read_size = 64 (32 bytes); reads per packet = 3; cycles = 12
fn fetch_batch_4<'l, const T: Tu>(
    input: BeginTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]>,
) -> FetchTensor<'l, T, i4, m![1], m![1 # 2], m![1 # 256], m![N], m![C, H, W]> {
    input.fetch()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let b: BeginTensor<'_, _, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_batch_1(b);

let b: BeginTensor<'_, _, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_batch_2(b);

let b: BeginTensor<'_, _, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_batch_3(b);

let b: BeginTensor<'_, _, i4, m![1], m![1 # 2], m![1 # 256], m![1], m![N, C, H, W]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_batch_4(b);
}

인터리빙

인터리빙은 매핑이 동일한 두 텐서를 하나의 Sequencer 연산으로 결합하므로, 같은 계산에 두 텐서가 모두 필요할 때 오버헤드를 줄입니다. 명시적인 Time 축이 두 텐서 사이의 교대를 인코딩합니다.

다음 예제에서 메인 컨텍스트는 begin_interleaved()를 사용하여 인터리빙된 텐서를 생성합니다. 첫 번째 시간 반복은 lhs에서, 두 번째는 rhs에서, 세 번째는 다시 lhs에서 fetch하는 식으로 진행됩니다. 하나의 fetch 연산에서 인터리브할 수 있는 텐서는 최대 두 개입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 16, B = 32, I = 2];

/// Interleaves two input tensors into a single packet stream.
/// Useful for operations like 'input1 + input2' in the Vector Engine.
/// The interleaved BeginTensor is created via Tu.begin_interleaved().
/// The `I = 2` axis in Time encodes alternation between the two tensors.
fn fetch_interleaved<'l>(
    device: &'l mut Device,
    lhs: &'l DmTensor<i8, m![1], m![1 # 2], m![1 # 256], m![A, B]>,
    rhs: &'l DmTensor<i8, m![1], m![1 # 2], m![1 # 256], m![A, B]>,
) -> FetchTensor<'l, { Tu::Main }, i8, m![1], m![1 # 2], m![1 # 256], m![A, I], m![B]> {
    device.main.begin_interleaved::<I, _, _, _, _, _>(lhs.view(), rhs.view()).fetch()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let lhs = DmTensor::new();
let rhs = DmTensor::new();
let _o = fetch_interleaved(&mut device, &lhs, &rhs);
}

최적화

Fetch Sequencer의 처리량은 세 가지 요인으로 결정됩니다.

  • 입력 대역폭: read_size는 DM에서의 축 연속성과 패킷 크기에 의해 제한됩니다. 인접하지 않은 축은 max_access_size를 줄이고, 그 결과 read_size도 줄어듭니다(비연속 패킷 참조). 패킷이 연속 구간보다 작아도 read_size가 제한됩니다. 더 큰 2의 거듭제곱으로 패딩하면 이 값이 커집니다(패킷 패딩 참조).

    같은 뱅크에 반복해서 접근하면 우선순위가 낮은 Commit Engine 및 DMA Engine 연산이 기아 상태에 빠질 수 있습니다. 연속 접근 한도는 64회이며, 이를 초과하면 NoC 타임아웃이 발생할 수 있습니다.

    자세한 내용은 메모리 성능을 참조하십시오.

  • 출력 대역폭: 하류의 Collect Engine은 Fetch의 패킷을 32바이트 플릿으로 변환하므로, 32바이트에 정렬되지 않는 패킷 크기는 대역폭을 낭비합니다. 20바이트 패킷은 플릿 하나를 채우고 12바이트를 0으로 패딩하므로 12 / 32 = 37.5%가 낭비됩니다. 40바이트 패킷은 플릿 두 개(총 64바이트)에 걸치며 두 번째 플릿의 마지막 24바이트를 0으로 패딩하므로 24 / 64 = 37.5%가 낭비됩니다.

  • 공간 병렬성: fetch를 여러 Slice에 분산하면 처리량이 최대화됩니다.

예제: 패킷 패딩

OutPacket을 더 큰 2의 거듭제곱 요소 수로 패딩하면 read_size가 커질 수 있습니다. 아래 세 예제는 패킷을 2바이트에서 16바이트, 32바이트로 늘려 동일한 30바이트 텐서를 각각 15, 3, 1사이클에 fetch합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 3, B = 5, C = 2];

/// Smallest packet: only C dimension padded to 8bytes. Takes 15 cycles.
fn fetch_packet_C<'l, const T: Tu>(
    input: BeginTensor<'l, T, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![1], m![A, B, C]>,
) -> FetchTensor<'l, T, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![A, B], m![C # 8]> {
    input.fetch()
}

/// Medium packet: B and C dimensions padded to 16 bytes. Takes 3 cycles.
fn fetch_packet_BC<'l, const T: Tu>(
    input: BeginTensor<'l, T, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![1], m![A, B, C]>,
) -> FetchTensor<'l, T, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![A], m![[B, C] # 16]> {
    input.fetch()
}

/// Largest packet: all dimensions padded to 32 bytes. Takes 1 cycle.
fn fetch_packet_ABC<'l, const T: Tu>(
    input: BeginTensor<'l, T, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![1], m![A, B, C]>,
) -> FetchTensor<'l, T, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![1], m![[A, B, C] # 32]> {
    input.fetch()
}


let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let x: BeginTensor<'_, _, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![1], m![A, B, C]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_packet_C(x);
let y: BeginTensor<'_, _, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![1], m![A, B, C]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_packet_BC(y);
let z: BeginTensor<'_, _, f8e4m3, m![1], m![1 # 2], m![1 # 256], m![1], m![A, B, C]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_packet_ABC(z);
}

이 예제들에서 패딩은 실제 데이터 범위를 넘어 읽지만, 패딩 값은 계산에 영향을 주지 않으므로 안전합니다. 패딩 전략이 다르면 FetchTensor 매핑도 달라지며, 이는 하류 컴포넌트에 영향을 줄 수 있습니다.

동일한 A = 3, B = 5, C = 2 f8e4m3 텐서는 다음과 같은 패킷 매핑을 사용할 수 있습니다.

함수Fetch TimeFetch PacketFetch 작업량Collect 패딩
fetch_packet_cA, BC # 88바이트 패킷 15개패킷당 24바이트입니다.
fetch_packet_bcA[B, C] # 1616바이트 패킷 3개패킷당 16바이트입니다.
fetch_packet_abc1[A, B, C] # 3232바이트 패킷 1개없음.

8바이트 후보와 16바이트 후보는 Collect가 각 패킷을 32바이트 플릿으로 패딩해야 합니다. 32바이트 후보는 하나의 완전한 플릿으로 Collect에 도달하지만, 추가된 물리적 용량 때문에 DM 사용량이 늘어날 수 있습니다. 고정된 워크로드에서 fetch 횟수가 줄어드는 이점이 패딩 비용과 다운스트림 레이아웃 비용보다 큰지는 스케줄 비교를 통해 판단합니다.

Commit Engine

Commit Engine은 Adapter가 최종 요소 타입과 레이아웃을 선택한 뒤 Tensor Unit 스트림 패킷을 DM에 씁니다. 이는 Fetch Engine의 역방향 동작입니다. 이는 Commit Sequencer이며, 모든 Slice에서 독립적으로 실행되어 각 Slice가 자신의 로컬 DM 파티션에 기록하는 수학적 텐서 이동입니다.

계산된 Tensor Unit 스트림을 의도한 Element 레이아웃을 갖는 DM 텐서로 만들어야 할 때 Commit을 선택하십시오.

인터페이스

TuTensor는 Tensor Unit 파이프라인의 끝에서 Chip, Cluster, Slice, Time, Packet 차원을 가집니다. 이때 Time은 계산의 시간적 언롤링을 반영하며, Packet은 출력 스트림의 요소 레이아웃입니다.

.commit()은 스트림을 DM의 DmTensor에 기록합니다.

impl<'l, const T: Tu, P: CanApplyCommit, D: Scalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Commits to data memory.
    #[primitive(TuTensor::commit)]
    pub fn commit<Element: M>(self) -> DmTensor<D, Chip, Cluster, Slice, Element, B> {
        verify_commit::<D, Time, Packet, Element>();
        DmTensor::from_parts(self.inner.transpose(false), None)
    }

    /// Commits to a mutable tensor view in data memory.
    #[primitive(TuTensor::commit_view)]
    pub fn commit_view<DstSlice: M, Element: M>(
        self,
        mut dst: DmTensorViewMut<'l, D, Chip, Cluster, DstSlice, Element, B>,
    ) {
        constraints::assert_slice_preserved::<Slice, DstSlice>();
        verify_commit::<D, Time, Packet, Element>();
        dst.inner.transpose(self.inner.view(), false);
    }
}

각 Slice가 자신의 DM 파티션에 독립적으로 기록하므로 .commit()은 Chip, Cluster, Slice 차원을 변경 없이 그대로 유지합니다. 출력 Element 매핑은 Time과 Packet을 대체하여 스트림이 DM에 배치되는 방식을 정의합니다. Element는 Commit Sequencer와 Commit Adapter를 모두 구성하며, 입력 스트림을 기준으로 Time 축의 순서를 바꿔 커밋 중에 전치를 수행할 수 있습니다. Element 매핑이 성능에 미치는 영향은 최적화를 참고하십시오.

다음 예제는 캐스트된 누적 결과를 bf16으로 DM에 커밋합니다. 출력 DmTensor는 256개 Slice에 걸쳐 16개 시간 단계 × 8개의 bf16 요소를 저장합니다. 여기서 D = bf16이고 Element = m![M, N # 16]입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![P = 256, M = 16, N = 8];

fn cast_commit<'l, const T: Tu>(
    input: ContractTensor<'l, T, f32, m![1], m![1 # 2], m![P], m![M], m![N]>,
) -> DmTensor<bf16, m![1], m![1 # 2], m![P], m![M, N # 16]> {
    // Cast f32 to bf16 (Cast Engine), then commit to DM (Commit Engine).
    // Input: M = 16 time steps, N = 8 f32 elements per packet (32 bytes).
    // After cast: N = 8 bf16 elements padded to 16 (32 bytes).
    // After trim: N # 16 trimmed into N = 8.
    // The sequencer writes across P = 256 slices.
    input.cast::<bf16, m![N # 16]>().commit_trim::<m![N]>().commit()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: ContractTensor<'_, _, f32, m![1], m![1 # 2], m![P], m![M], m![N]> = ContractTensor::new(&mut device.main, Tensor::zero());
let _o = cast_commit(c);
}

제약 조건

  • 하드웨어 차원: Chip::SIZE, Cluster::SIZE, Slice::SIZE는 하드웨어 구성과 일치해야 합니다(Sequencer 참고).
  • 주소 정렬: 모든 Sequencer 스트라이드는 8바이트의 배수여야 합니다.
  • 쓰기 단위 정렬: D[valid_size]는 8, 16, 24, 32바이트 중 하나여야 합니다(Commit Adapter의 Trimming 단계 참고).

다중 쓰기 패킷

패킷 축이 DM에서 연속적이지 않을 수 있으므로 패킷 하나를 기록하는 데 여러 번의 하드웨어 쓰기가 필요할 수 있습니다. 컴파일러는 write_size = gcd(valid_size, access_size)를 도출합니다. Commit Adapter는 valid_size를 제공하고, Sequencer는 access_size를 제공합니다. 서브 컨텍스트에서는 D[write_size]가 8바이트로 고정됩니다. 전체 사이클 수는 Time::SIZE * (valid_size / write_size)입니다. 이 나눗셈은 항상 나누어떨어집니다. 메인 컨텍스트에서는 valid_size == write_size이므로 각 패킷이 한 사이클에 커밋됩니다. 서브 컨텍스트에서는 write_size가 8바이트로 고정되고 valid_size는 (트리밍 제약 조건에 따라) 8, 16, 24, 32바이트 중 하나이므로 valid_size / write_size는 항상 1, 2, 3, 4 중 하나입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![M = 4, K = 2, W = 8, N = 16, L = 32];

// Compiler-generated configuration: [
//   M -> 4 : 64,  (64 == 2 * 32,  contiguous)
//   K -> 2 : 32,   (32  == 32 * 1,  contiguous)
//   M -> 32 : 1    (packet dimension, contiguous)
// ] : 8
// access_size = 64; valid_size = 8; write_size = gcd(64, 8) = 8; writes per packet = 1
fn no_transpose<'l, const T: Tu>(
    input: CastTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![M, K], m![L]>,
) -> DmTensor<i8, m![1], m![1 # 2], m![1 # 256], m![M, K, L]> {
    input.commit_trim::<m![L]>().commit()
}

// Compiler-generated configuration: [
//   M -> 4 : 8,   (8  != 2 * 32, NOT contiguous)
//   K -> 2 : 32,  (32 != 8 * 1,  NOT contiguous)
//   W -> 8 : 1    (packet dimension, contiguous)
// ] : 32
// access_size = 8; valid_size = 8; write_size = gcd(8, 8) = 8; writes per packet = 1
fn transpose<'l, const T: Tu>(
    input: ContractTensor<'l, T, f32, m![1], m![1 # 2], m![1 # 256], m![M, K], m![W]>,
) -> DmTensor<f32, m![1], m![1 # 2], m![1 # 256], m![K, M, W]> {
    input.commit_trim::<m![W]>().commit()
}

// Compiler-generated configuration: [
//   M -> 4 : 8,   (8  != 2 * 32, NOT contiguous)
//   K -> 2 : 32,  (32 != 8 * 1,  NOT contiguous)
//   N -> 8 : 1    (trimmed packet dimension, contiguous)
// ] : 16
// access_size = 8; valid_size = 8 (trimmed from 16); write_size = gcd(8, 8) = 8; writes per packet = 1
fn transpose_with_trimming<'l, const T: Tu>(
    input: CastTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![M, K], m![N # 32]>,
) -> DmTensor<i8, m![1], m![1 # 2], m![1 # 256], m![K, M, N]> {
    input.commit_trim::<m![N]>().commit()
}


let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let a: CastTensor<'_, _, i8, m![1], m![1 # 2], m![1 # 256], m![M, K], m![L]> = CastTensor::new(&mut device.main, Tensor::zero());
let _o = no_transpose(a);
let b: ContractTensor<'_, _, f32, m![1], m![1 # 2], m![1 # 256], m![M, K], m![W]> = ContractTensor::new(&mut device.main, Tensor::zero());
let _o = transpose(b);
let c: CastTensor<'_, _, i8, m![1], m![1 # 2], m![1 # 256], m![M, K], m![N # 32]> = CastTensor::new(&mut device.main, Tensor::zero());
let _o = transpose_with_trimming(c);
}

최적화

Commit Sequencer의 처리량은 세 가지 요인으로 결정됩니다.

  • 순차 주소: 각 Slice 안에서 순차적인 DM 주소에 쓰면 병렬 뱅크 접근이 가능합니다(DMN당 128 B/사이클, DMN 인터리빙 사용 시 256 B/사이클). 같은 뱅크에 연속으로 64회 이상 접근하는 패턴은 DM 뱅크 기아를 유발합니다.
  • 공간 병렬성: 활성화된 모든 Slice에 쓰기를 분산하면 처리량이 최대화됩니다.
  • 정렬된 쓰기(불변 조건): 쓰기 주소와 쓰기 단위가 모두 항상 8바이트로 정렬되어 있으므로 부분 뱅크 쓰기는 절대 발생하지 않습니다. Sequencer의 스트라이드는 8바이트의 배수이며(제약 조건 참조), Commit Adapter의 Trimming 단계는 D[valid_size]를 8바이트의 배수로 유지합니다.

사례 연구: Tensor Unit I/O

이 엔드투엔드 Tensor Unit I/O 패턴은 Fetch와 Commit을 사용해 모든 논리 인덱스를 보존하면서 텐서를 DM을 거쳐 단계적으로 옮깁니다. 이는 여러 동작을 조합한 이동 패턴이며, 개별 Fetch Engine 및 Commit Engine 레퍼런스 페이지와는 구별됩니다.

대표적인 공개 사례는 fetch_commit_simple입니다. 이 사례는 매핑이 [A, B]인 i8 HBM 텐서를 받아 Tensor Unit을 통해 값을 i32로 확장하고, 매핑이 [B, A]인 HBM 텐서를 반환합니다. 마지막 to_hbm DMA 호출이 출력 HBM 매핑을 선택하므로, 이 테스트는 HBM-DM 전송, Fetch, Collect, Commit, DM-HBM 전송으로 이어지는 전체 파이프라인을 검증합니다. 이 사례는 의도적으로 스트리밍 순열을 주장하지 않습니다. 공개 예제에서 [A, B]를 [B, A]로 바꾸는 재배치는 마지막 DMA 경계에서 일어납니다. 디바이스 소스는 아래에 포함되어 있습니다.

#![expect(clippy::type_complexity)]

use furiosa_opt_std::prelude::*;

axes![A = 4096, B = 8];

type Chip = m![1];
type Cluster = m![1 # 2];

#[device(chip = 1)]
pub fn fetch_commit_simple(
    device: &mut Device,
    input: &HbmTensor<i8, m![1], m![A, B]>,
) -> HbmTensor<i32, m![1], m![B, A]> {
    // Element's innermost axis must match the source's innermost (B, stride 1) so that the
    // DMA tail contains the full B axis (8 × i8 = 8 bytes), satisfying min_align = 8.
    let input_dm = input.to_dm::<Cluster, m![A / 16], m![A / 8 % 2, A % 8, B]>(&mut device.tdma);

    let fetch_and_commit_tensor: DmTensor<i32, Chip, Cluster, m![A / 16], m![A / 8 % 2, A % 8, B]> = device
        .main
        .begin(input_dm.view())
        .fetch::<m![A / 8 % 2], m![A % 8, B]>()
        .fetch_cast::<i32>()
        .collect::<m![A / 8 % 2, A % 8], m![B]>()
        .commit_trim::<m![B]>()
        .commit();

    fetch_and_commit_tensor.to_hbm(&mut device.tdma)
}

호스트 오라클과 테스트는 examples 테스트 타깃에 포함되어 있습니다.

use furiosa_opt_examples::fetch_commit::fetch_commit_simple;
use furiosa_opt_std::prelude::*;

#[tokio::test]
async fn test_fetch_commit_simple_host() {
    use furiosa_opt_examples::fetch_commit::{A, B};

    let mut device = Device::new(fetch_commit_simple.topology()).unwrap();

    // Create input tensor with shape (A=4096)(B=8).
    let input = HostTensor::<i8, m![A, B]>::from_vec((0..32768).map(|x| x as i8).collect::<Vec<_>>())
        .to_hbm::<m![1], m![A, B]>(&mut device.pdma)
        .await
        .unwrap();

    // Call the device function.
    let output = launch(fetch_commit_simple, (&mut device, &input)).await.unwrap();

    let mut expected = vec![0i32; 4096 * 8];
    let mut idx = 0;
    for b in 0..8 {
        for a in 0..4096 {
            expected[idx] = ((a * 8 + b) as i8) as i32;
            idx += 1;
        }
    }

    assert_eq!(
        output.to_host::<m![B, A]>(&mut device.pdma).await.unwrap().into_inner(),
        Tensor::<_, m![B, A], CurrentBackend>::from_vec(expected)
    );
}

대표 사례를 실행하려면 cargo furiosa-opt test --test fetch_commit_tests를 실행하십시오. 호스트 오라클은 모든 output[b, a] 값을 확장된 input[a, b] 값과 비교하여 검사합니다. 스트리밍 경로는 입력 DM, Fetch, Cast, Collect, Commit, 그리고 원래의 [A, B] DM 레이아웃을 유지하는 출력 DM으로 이어집니다. 여기서 사용한 API는 Fetch Engine, Collect Engine, Commit Engine 문서에 설명되어 있습니다.

DMA Engine

DMA Engine은 Tensor Unit 파이프라인을 거치지 않고 메모리 계층 간에 텐서를 직접 이동하므로, 계산 단계가 필요 없는 상주 위치 변경이나 레이아웃 변경에 사용하십시오.

Fetch, 계산, Commit 단계가 필요 없는 HBM↔HBM, HBM↔DM, DM↔DM 이동에는 DMA를 선택하십시오. 인터페이스 예제는 소스 텐서, 목적지 매핑, tdma 또는 pdma 컨텍스트가 어떻게 결합되는지 보여 줍니다. 각 전송은 서로 조율되는 두 단계를 짝지어 수행합니다.

DMA 전송은 수학적 텐서 이동입니다. 즉, 레이아웃이 달라도 출력은 입력과 동일한 수학적 텐서를 담습니다. Tensor DMA는 DMN 간, Cluster 간, Chip 간 전송을 모두 포괄하며, Chip ID는 시스템 전체에서 전역적으로 합의됩니다.

전송 처리량과 관련된 고려 사항은 최적화를 참조하십시오.

인터페이스

DMA 전송은 한 메모리 계층의 텐서를 받아 다른 (또는 같은) 계층의 텐서를 생성합니다. 커널 작성자는 소스 텐서에 대해 .to_dm(), .to_hbm() 또는 관련 메서드를 호출하면서 DmaContext를 전달합니다.

  • Device::tdma: Chip 내부 전송(HBM ↔ HBM, HBM ↔ DM, DM ↔ DM)을 위한 Tensor DMA 컨텍스트입니다.
  • Device::pdma: 호스트 ↔ HBM 전송을 위한 PCIe DMA 컨텍스트입니다(PCIe DMA 참조).
impl<D: Scalar, Chip: M, Element: M, B: Backend> HbmTensor<D, Chip, Element, B> {
    /// Converts to data memory tensor.
    #[primitive(HbmTensor::to_dm)]
    pub fn to_dm<Cluster: M, Slice: M, Element2: M>(
        &self,
        _dma: &mut DmaContext<{ Dma::Tensor }, B>,
    ) -> DmTensor<D, Chip, Cluster, Slice, Element2, B> {
        assert_dma_layout::<
            D,
            m![{ Chip }, { Element }],
            Element,
            m![{ Chip }, { Cluster }, { Slice }, { Element2 }],
            Element2,
        >(DM_WRITE_ALIGN_BYTES);
        DmTensor::from_parts(self.inner.transpose(true), None)
    }

    /// Reshapes the tensor to a different mapping at the same HBM address, consuming `self`.
    /// The HBM analogue of [`DmTensor::reshape`]; both delegate to [`Tensor::reshape`].
    ///
    /// # Safety
    ///
    /// The per-level sizes (`Chip::SIZE == Chip2::SIZE`, `Element`) are asserted at compile time below
    /// (see [`constraints::assert_hbm_reshape_dimension_preserved`]); the genuine precondition is
    /// [`Tensor::reshape`]'s: the old and new mappings must lay the elements out in the SAME physical
    /// (wire) order, so the relabel moves no data. Axis regrouping (merge/split) preserves wire order
    /// and is valid; a permutation is not (use a transpose). Equal sizes do not guarantee this.
    /// Consuming `self` is the safety contract made explicit: the old-shaped handle cannot survive to
    /// alias the same HBM bytes under a conflicting mapping.
    #[primitive(HbmTensor::reshape)]
    pub unsafe fn reshape<Chip2: M, Element2: M>(self) -> HbmTensor<D, Chip2, Element2, B> {
        constraints::assert_hbm_reshape_dimension_preserved::<Chip, Chip2, Element, Element2>();
        let reshaped = unsafe { self.inner.reshape::<m![{ Chip2 }, { Element2 }]>() };
        HbmTensor {
            inner: reshaped,
            buffer: self.buffer,
        }
    }
}

컴파일러는 소스 텐서 타입과 목적지 텐서 타입으로부터 읽기 Sequencer와 쓰기 Sequencer의 구성을 도출합니다. HBM에서 DM으로의 전송은 HBM의 Chip 차원을 유지하고 목적지의 Cluster, Slice, Element 차원을 지정합니다. DM에서 DM으로의 전송은 새로운 Chip, Cluster, Slice, Element 차원을 지정할 수 있으며, HBM 목적지는 Chip과 Element를 지정합니다.

아래 예제는 HBM에서 HBM으로의 전송 두 번을 사용하여 텐서를 [A, B, C]에서 [C, A, B]로 전치합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 16, C = 32];

fn transpose_simple(
    device: &mut Device,
    input: &HbmTensor<f32, m![1], m![A, B, C]>,
) -> HbmTensor<f32, m![1], m![C, A, B]> {
    // Step 1: [A, B, C] → [A, C, B]
    let intermediate: HbmTensor<f32, m![1], m![A, C, B]> = input.to_hbm(&mut device.tdma);

    // Step 2: [A, C, B] → [C, A, B]
    intermediate.to_hbm(&mut device.tdma)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let in_hbm = HbmTensor::<f32, m![1], m![A, B, C]>::new();
let _out_hbm = transpose_simple(&mut device, &in_hbm);
}

계층을 넘나드는 전송은 목적지 타입의 매핑을 통해 레이아웃 변환도 함께 수행합니다. HBM에서 DM으로의 전송에서는 목적지 DM 텐서에 Cluster 축과 Slice 축이 추가되며, 이 축들이 하드웨어 파티션 전체에 텐서를 분산하고 브로드캐스트합니다.

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048];

fn hbm_to_dm(
    device: &mut Device,
    input: &HbmTensor<i8, m![1], m![A]>,
) -> DmTensor<i8, m![1], m![1 # 2], m![A / 8], m![A % 8]> {
    input.to_dm::<m![1 # 2], m![A / 8], m![A % 8]>(&mut device.tdma)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let in_hbm = HbmTensor::<i8, m![1], m![A]>::new();
let _out_dm = hbm_to_dm(&mut device, &in_hbm);
}

여기서 2,048개 요소로 이루어진 벡터는 Slice당 256개 요소(Slice = m![A / 8]), Slice당 8개 요소(Element = m![A % 8])로 분산되어 2개의 Cluster에 걸쳐 배치됩니다.

아키텍처

각 RNGD Chip에는 DMN 쌍마다 하나씩, 총 8개의 DMA Engine이 있으며, 최대 8개의 독립적인 전송을 병렬로 실행합니다. 단일 DMA Engine은 짝을 이룬 읽기 Sequencer와 쓰기 Sequencer를 실행하며, 텐서 이동은 aggregate를 통해 여러 엔진에 분산됩니다. 아래 하위 절에서는 정적 구조, Sequencer 표현, 동적 동작, 컴파일러 도출, 집계 연산을 설명합니다.

Note

DMA는 SRAM Slice 대역폭을 충분히 활용하지 못할 수 있으므로, SRAM 간 전송에서는 Tensor Unit(Fetch Engine과 Commit Engine 경유)이 DMA보다 효율적인 경우가 많습니다. 그러나 실제로는 HBM 대역폭이 병목이 되는 경우가 일반적이므로, HBM ↔ DM 전송에서는 이 차이가 덜 중요합니다.

정적 구조

Chip, Cluster, Slice는 하드웨어의 공간 병렬성 차원입니다. Chip당 8개의 DMA Engine은 서로 다른 메모리 구성 요소 간 전송을 병렬로 수행합니다(예: 엔진 #0이 HBM ↔ DM을 처리하는 동안 엔진 #1은 DM ↔ DM을 처리합니다).

각 DMA Engine은 짝을 이루는 읽기 Sequencer와 쓰기 Sequencer를 보조를 맞춰 동시에 실행합니다. 읽기 Sequencer는 소스 주소를 순회하고, 쓰기 Sequencer는 목적지 주소를 순회합니다. 두 Sequencer는 같은 루프 횟수를 공유하지만 서로 다른 스트라이드와 기준 주소를 사용합니다. 레이아웃 변환으로 같은 논리적 요소가 소스 메모리와 목적지 메모리에 놓이는 순서가 달라지기 때문입니다. 컴파일러는 읽기 횟수와 쓰기 횟수가 같다는 점을 활용하여, 이 쌍을 루프 항목마다 짝지어진 스트라이드를 갖는 단일 Sequencer로 간결하게 표현합니다.

컴파일러는 하나의 텐서 이동을 사용 가능한 DMA Engine들에 분산합니다. 작업을 chip, cluster, slice 차원을 따라 분할하고 각 분할을 하나의 DMA Engine에 할당합니다. 어떤 DMA Engine이든 어떤 전송이든 처리할 수 있습니다. 로컬 DMN 접근이 DMN 간 접근보다 빠르므로, 컴파일러는 기본적으로 소스 DM의 로컬 DMA Engine을 선택합니다. 커널 작성자가 엔진을 명시적으로 지정할 수도 있습니다.

동종(homogeneous) 집합은 참여하는 모든 DMA Engine에 걸쳐 매개변수화된 하나의 디스크립터 템플릿을 사용합니다. 이종(heterogeneous) 집합은 각 DMN을 해당 DMN 전용 디스크립터와 짝짓는 HashMap<DmnIndex, DmaDescriptor>를 사용합니다. DM 텐서 명세는 정확한 메모리 위치를 식별하기 위해 매핑 표현식에 chip, cluster, slice를 포함해야 합니다.

동적 동작

DmaSequencer의 각 루프는 행 우선(row-major) 순서로 카운터를 증가시키며, 짝지어진 스트라이드로부터 읽기 주소와 쓰기 주소를 도출합니다. base = (0, 256³)인 아래 Sequencer의 경우는 다음과 같습니다.

[
  A -> 256 : (65,536, 256),
  B -> 256 : (256, 65,536),
  C -> 256 : (1, 1),
] : 256
반복 i카운터읽기 주소쓰기 주소
0(0, 0, 0)0write_base
1(0, 0, 1)11 + write_base
…………
255(0, 0, 255)255255 + write_base
256(0, 1, 0)25665,536 + write_base
i = a·256² + b·256 + c(a, b, c)i256·a + 256²·b + c + write_base

stride0 = 256이면 하드웨어가 반복마다 256바이트를 읽고 쓰므로, 반복 0은 (A, B, C) = (0, 0, 0..255)의 모든 값을 하나의 패킷으로 처리합니다. 전송은 약 500사이클의 시작 지연 시간에 256 × 256사이클의 데이터 전송 시간을 더한 만큼 걸려 완료됩니다.

컴파일러 유도

소스와 대상의 텐서 매핑(In, Out)과 스트림 형태(Stream)가 주어지면, 컴파일러는 읽기 Sequencer와 쓰기 Sequencer를 다음과 같이 도출합니다:

  • 읽기 Sequencer: In을 스트림 형태에 사영하여 소스 계층에 대한 루프별 스트라이드를 생성합니다.
  • 쓰기 Sequencer: Out을 스트림 형태에 사영하여 대상 계층에 대한 루프별 스트라이드를 생성합니다.
  • 통합 Sequencer: 두 Sequencer를 병합하여 각 항목이 읽기 스트라이드와 쓰기 스트라이드를 쌍으로 갖도록 합니다.
  • 패킷 크기: 연속된 읽기/쓰기 양으로부터 stride0을 추론합니다. 읽기와 쓰기가 모두 연속된 256바이트에 접근하면 최적의 stride0은 256입니다.

A=B=C=256인 m![A, B, C] → m![B, A, C]의 경우 스트림은 m![A, B, C]입니다. 컴파일러는 아래 관계식을 사용하여 각 선형 인덱스를 A, B, C에 매핑합니다. 그런 다음 쌍을 이루는 스트라이드를 유도합니다.

관계식은 m![A, B, C]::map(i) = i![A: i / 65,536, B: (i % 65,536) / 256, C: i % 256]입니다(표기법은 매핑 표현식을 참조하십시오).

read_sequencer  = [
  A -> 256 : 65,536,
  B -> 256 : 256,
  C -> 256 : 1,
] : 256, HBM @ 0
write_sequencer = [
  A -> 256 : 256,
  B -> 256 : 65,536,
  C -> 256 : 1,
] : 256, HBM @ 256³

이들은 항목마다 쌍을 이루는 스트라이드를 갖는 하나의 DmaSequencer로 결합됩니다. 통합 DmaSequencer의 각 측(읽기 또는 쓰기)은 명확성을 위해 쌍 스트라이드 괄호를 생략하고 해당 방향의 단일 스트라이드 Sequencer로 표시할 수 있습니다.

집합 연산

집합 연산은 텐서 형태가 DMN 간에 균등하게 나누어지는지에 따라 두 가지 형식 중 하나를 취합니다.

형태가 균등하게 나누어지면, 참여하는 모든 엔진이 동종 집합 연산을 실행합니다. 모든 DMA Engine은 동일한 매개변수화된 스트림 환경(Stream = { chip, cluster, slice, time, packet })을 사용하며, 기준 주소만 서로 다릅니다.

형태가 균등하게 나누어지지 않으면, 컴파일러는 이종 집합 연산으로 대체합니다. 각 DMN은 StreamFn(chip, cluster, slice)를 통해 자체 스트림 환경을 받으며, 경계에 있는 DMN은 유효 영역을 넘어 쓰지 않도록 작업을 여러 DMA 명령으로 나눕니다. 입력 및 출력 매핑 환경(In과 Out)은 동종의 경우와 구조적으로 동일하게 유지되므로, 전체 논리적 텐서 이동은 잘 정의됩니다.

두 형식 모두에 다음 두 가지 정확성 불변 조건이 적용됩니다:

  • 동일한 미디어 타입: 참여하는 모든 DMA Engine은 동일한 소스 미디어와 대상 미디어를 사용해야 합니다.
  • 단일 통합 매핑: 하나의 입력 텐서 매핑과 하나의 출력 텐서 매핑이 전체 전송을 결정합니다.

명령마다 각자의 시작 지연 시간이 발생하므로, 전체 전송을 균일하게 유지하려면 DMN 간에 고르게 나누어지는 텐서 형태를 사용하는 것이 좋습니다.

제약 조건

DMA Engine은 하드웨어 수준의 정렬 규칙과 패킷 크기 규칙을 강제합니다. 이를 위반하면 단순한 성능 저하에 그치지 않고 정확성 오류나 하드웨어 예외가 발생합니다.

  • 주소 정렬:

    계층읽기쓰기
    HBM1바이트1바이트
    DM (SRAM)1바이트8바이트

    HBM ↔ DM 전송에서는 위 표와 관계없이 읽기 주소, 쓰기 주소, 패킷 크기에 추가로 8바이트 정렬이 필요합니다. DM 규칙이 비대칭인 것은 SRAM 하드웨어가 비대칭이기 때문입니다. 읽기 포트는 바이트 선택 로직으로 임의의 바이트 범위를 추출하지만, 쓰기 포트는 8바이트 뱅크 폭 전체를 단위로 동작합니다. 따라서 정렬되지 않은 DM 쓰기는 Read-Modify-Write 연산을 유발하며, 이 연산은 쓰기 시간을 세 배로 늘리고 해당 뱅크의 다른 연산을 차단합니다.

    컴파일러는 이러한 제약 조건을 하드웨어 불변 조건으로 강제합니다.

  • 패킷 크기: 최대 패킷 크기는 4,096바이트이며, 이는 트랜잭션이 256 beat × 16바이트 데이터 폭을 초과할 수 없다는 AXI 프로토콜 제약 조건에 따라 정해집니다.

최적화

DMA 처리량을 결정하는 요인은 메모리 대역폭, 채널 및 DMN 인터리빙, 시작 지연 시간과 패킷 분할의 세 가지입니다.

메모리 대역폭

각 계층에는 달성 가능한 처리량의 상한이 되는 최대 대역폭이 있으며, 실제 전송 속도는 스트리밍 경로에서 가장 느린 구성 요소에 의해 제한됩니다.

계층최대 대역폭
HBMChip당 1.5 TB/s (32개 채널 × 0.75 GHz에서 채널당 48 GB/s)
DMCluster당 256 B/사이클 (DMN 인터리빙 시 DMN당 128 B/사이클)
PCIe읽기와 쓰기 모두 30 B/사이클 (PCIe DMA 참조)

각 DMA Engine은 단독으로 사이클당 최대 256 B를 이동합니다. HBM 대역폭은 HBM 데이터를 전송하는 모든 엔진이 공유하므로, HBM을 포화시키는 총 전송량은 엔진별 합계가 아니라 1.5 TB/s의 HBM 최대 대역폭에 의해 제한됩니다.

같은 Cluster 내 DM 간 전송은 두 단계가 동일한 DM 뱅크 접근을 두고 경합하므로 읽기와 쓰기가 직렬화됩니다. HBM ↔ DM과 같은 계층 간 전송은 읽기 단계와 쓰기 단계를 파이프라인으로 처리합니다.

Note

DMA는 SRAM Slice 대역폭을 충분히 활용하지 못할 수 있으므로, SRAM 간 전송에서는 Tensor Unit(Fetch 및 Commit Engine 경유)이 DMA보다 효율적인 경우가 많습니다. 그러나 실제로는 대개 HBM 대역폭이 병목이 되므로, HBM ↔ DM 전송에서는 이 차이가 그다지 중요하지 않습니다.

채널 및 DMN 인터리빙

최대 대역폭을 유지하려면 하위 메모리 파티션 전반에 걸쳐 접근 패턴을 인터리빙해야 합니다.

HBM 채널 선택에는 주소 비트 9번부터 28번까지가 사용되며, 주소 비트 8은 스택 비트입니다. 요청을 32개 채널 전체에 분산하려면 접근 패턴이 이 비트들을 모두 토글해야 합니다. 스택 비트(주소 비트 8) 하나만 누락되어도 모든 요청이 32개 채널 중 16개로만 라우팅되어 유효 대역폭이 절반으로 줄어듭니다. 같은 HBM 뱅크에 반복적으로 접근하면 21번 이상의 행 주소 비트가 토글될 수 있습니다. 충돌 하나당 약 40사이클이 소요되며, 대역폭이 10분의 1 수준으로 떨어질 수 있습니다. FR-FCFS 메모리 스케줄링으로 처리량을 일부 회복할 수 있지만, 근본적인 비용은 여전히 큽니다.

DM 대역폭을 확보하려면 두 DMN(각각 128 B/사이클)을 번갈아 사용해야 하므로, 단일 DMN 접근 패턴은 DM 대역폭을 절반으로 줄입니다.

시작 지연 시간과 패킷 분할

각 DMA 명령은 데이터 전송이 시작되기 전에 약 500사이클의 고정 시작 지연 시간을 발생시킵니다. 여러 전송을 더 적은 수의 명령으로 결합하면 이 비용이 분산되지만, 이질적인 묶음은 DMN별 명령으로 분할되어 명령마다 지연 시간을 치르게 됩니다.

단일 명령 내에서 하드웨어는 각 패킷을 256바이트 단위로 분할하므로, n바이트 패킷은 ceil(n / 256)개의 AXI 요청이 됩니다. 따라서 4,095바이트 패킷에는 16개의 요청이 필요하며, 4,099바이트 패킷(4,096바이트 한도를 애매하게 넘어서는 소수 길이)은 여러 명령으로 분할해야 합니다. 최내곽 루프의 스트라이드(stride0)가 패킷 정렬을 결정합니다. stride0가 256바이트로 정렬되어 있으면 사이클 수는 ceil(stride0 / 256)입니다. stride0가 256바이트로 정렬되어 있지 않으면, HBM 쓰기는 256바이트 미만의 부분 블록에 대해 Read-Modify-Write 페널티를 추가로 치릅니다. HBM 읽기에는 ceil 오버헤드만 발생하며, DM 연산은 이러한 정렬 불일치의 영향을 거의 받지 않습니다.

DMA는 DM 뱅크 접근 우선순위가 가장 낮으므로, Fetch Engine이나 Commit Engine이 같은 뱅크에 64회 이상 연속으로 접근하면 DMA가 기아 상태에 빠져 NoC 타임아웃이 발생할 수 있습니다. 자세한 내용은 DM 뱅크 기아를 참조하십시오.

상세 예제

아래 예제는 대표적인 전송 패턴에 대한 구체적인 Sequencer 구성과 사이클 추정치를 제시합니다. 예제 1부터 3까지는 각 계층 쌍에 대해 잘 튜닝된 단일 엔진 사례를 다룹니다. 예제 4와 5는 10배 이상 손실이 발생하는 비정상적인 접근 패턴을 대조합니다. 예제 6은 형태가 DMN 간에 균등하게 나누어지지 않을 때의 이종 분할을 보여 줍니다.

Sequencer 구성에는 두 가지 주소 스트라이드 기호를 사용합니다:

  • slice_stride: Slice 내 DM 파티션 하나의 가상 주소 범위(4 MB)입니다.
  • DMN_stride: 같은 Cluster 안에 있는 두 DMN 사이의 주소 범위입니다.

예제 1: HBM ↔ HBM 레이아웃 변환

인자:

  • axes![A = 8, B = 8, C = 256]
  • dtype = i8
  • 소스: 오프셋 0의 HBM, 매핑 m![A, B, C]
  • 대상: 오프셋 16,384의 HBM, 매핑 m![B, A, C]
  • 스트림: 시간 m![A, B], 패킷 m![C]

생성된 Sequencer:

read = [
  A -> 8   : 2,048,
  B -> 8   : 256,
  C -> 256 : 1,
] : 256, HBM @ 0

write = [
  A -> 8   : 256,
  B -> 8   : 2,048,
  C -> 256 : 1,
] : 256, HBM @ 16,384

최내곽이 아닌 스트라이드(256과 2,048)는 HBM 주소 비트 8(스택)과 11(채널)을 토글하여 모든 요청을 서로 다른 HBM 채널로 분산시키고 병렬로 실행되게 합니다. 256바이트 단일 전송은 0.75 GHz에서 Channel당 4사이클이 걸리지만, Channel 간 병렬 분산 덕분에 최대 대역폭에 가까운 대역폭이 유지됩니다. 총 시간: 1 GHz에서 약 64개의 읽기 요청 + 64개의 쓰기 요청에 시작 500사이클을 더해 ≈ 628사이클입니다.

4개의 DMA Engine이 HBM ↔ HBM 트래픽을 공유하면, 각 엔진은 0.75 TB/s의 읽기 대역폭 중 약 0.1875 TB/s를 할당받습니다. stride0 = 256이더라도 이 할당량으로는 어떤 엔진도 사이클당 요청 하나를 완료하지 못합니다.

예제 2: 전체 대역폭을 활용하는 HBM → DM

이 계층 간 전송은 HBM 채널과 두 DMN 모두에 걸쳐 인터리빙하여 읽기와 쓰기를 파이프라인으로 처리합니다.

인자:

  • axes![A = 256, B = 256, C = 256]
  • dtype = i8
  • 소스: Chip 0의 HBM, 매핑 m![B, A, C]
  • 대상: Chip 0, Cluster 0, Slice 0의 DM입니다. Slice 매핑 m![A / 4], 요소 매핑 m![A % 4, B, C]
  • 스트림: 시간 m![B, A % 4, A / 4 % 32, A / 128], 패킷 m![C]

생성된 Sequencer:

read = [
  B      -> 256 : 65,536,
  A%4    -> 4   : 256,
  A/4%32 -> 32  : 1,024,
  A/128  -> 2   : 32,768,
  C      -> 256 : 1,
] : 256, HBM @ 0

write = [
  B      -> 256 : 256,
  A%4    -> 4   : 65,536,
  A/4%32 -> 32  : slice_stride,
  A/128  -> 2   : DMN_stride,
  C      -> 256 : 1,
] : 256, DM @ 0

HBM 쪽에서는 A/128=2에 적용된 32,768 스트라이드가 채널 간 접근을 인터리빙하며, 하드웨어 명령 큐가 65,536개 요청(256 × 4 × 32 × 2) 전체가 끊김 없이 흐르도록 유지합니다. DM 쪽에서는 slice_stride와 DMN_stride가 연속된 256바이트 쓰기를 두 DMN에 걸쳐 인터리빙하여 두 DMN 모두 사이클당 하나의 요청을 처리하도록 유지합니다. 읽기와 쓰기는 계층 간에 파이프라인으로 처리되므로, 총 시간은 대략 max(65,536 읽기 사이클, 65,536 쓰기 사이클) + 시작 500 사이클 ≈ 66,036 사이클입니다.

예제 3: 단일 Cluster 내 DM → DM

동일 Cluster 내 DM 간 전송에서는 읽기와 쓰기가 동일한 DM 뱅크 접근을 두고 경합하므로 서로 직렬화됩니다.

인자:

  • axes![A = 256, B = 256, C = 256]
  • dtype = i8
  • 소스: Chip 0, Cluster 0, Slice 0의 DM, 요소 오프셋 0. Slice 매핑 m![A / 4], 요소 매핑 m![A % 4, B, C]
  • 목적지: Chip 0, Cluster 0, Slice 0의 DM, 요소 오프셋 4·256·256. Slice 매핑 m![A / 4], 요소 매핑 m![B, A % 4, C]
  • 스트림: 시간 m![B, A % 4, A / 4 % 32, A / 128], 패킷 m![C]

생성된 Sequencer:

read = [
  B      -> 256 : 1,
  A%4    -> 4   : 65,536,
  A/4%32 -> 32  : slice_stride,
  A/128  -> 2   : DMN_stride,
  C      -> 256 : 1,
] : 256, DM @ 0

write = [
  B      -> 256 : 1,024,
  A%4    -> 4   : 256,
  A/4%32 -> 32  : slice_stride,
  A/128  -> 2   : DMN_stride,
  C      -> 256 : 1,
] : 256, DM @ (4·256·256)

DMN 및 Slice 인터리빙으로 각 단계는 256 B/사이클 전체를 사용하지만, 두 단계는 직렬화됩니다. 총 시간: 대략 131,072 사이클(읽기 65,536 + 쓰기 65,536) + 시작 500 사이클입니다.

Note

가능하면 C를 256의 배수로 선택하십시오. 0 < r < 256인 C = 256n + r의 경우, 총 데이터량은 약간만 변하더라도 각 접근이 더 많은 요청으로 분할되므로 사이클 수가 n+1배로 증가합니다.

예제 4: HBM 뱅크 충돌 병리 사례

여기서 DM 인터리빙은 정상적이지만, 비정상적인 HBM 접근 패턴 때문에 잘 튜닝된 경우의 사이클 수보다 약 10배의 비용이 듭니다.

인자:

  • Chip 1개(DMN 8개)
  • axes![A = 64, B = 2,048, C = 1,024]
  • dtype = i8
  • 소스: HBM, Cluster 매핑 m![B / 1024], Slice 매핑 m![B / 256 % 4, A], 요소 매핑 m![B % 256, C]
  • 목적지: DM, Slice 매핑 m![A / 4], 요소 매핑 m![B, A % 4, C]
  • 스트림: Cluster m![B / 1024], Slice m![B / 256 % 4], 시간 m![B % 256, C / 256, A % 32, A / 32], 패킷 m![C % 256]

(cluster_i, dmn_j)별로 생성된 Sequencer:

read = [
  B%256 -> 256 : 1,024,
  C/256 -> 4   : 256,
  A%32  -> 32  : 2²¹,
  A/32  -> 2   : 2²⁶,
  C%256 -> 256 : 1,
] : 256, HBM @ (i·2²⁰ + j·2¹⁸)

write = [
  B%256 -> 256 : 1,024,
  C/256 -> 4   : 256,
  A%32  -> 32  : slice_stride,
  A/32  -> 2   : DMN_stride,
  C%256 -> 256 : 1,
] : 256, DM @ (cluster_i, dmn_j, 0)

A%32와 A/32의 스트라이드는 HBM 뱅크 내 행을 선택하는 HBM 주소 비트 21과 26을 토글합니다. 따라서 각 채널 내의 연속 접근은 거의 모든 요청마다 한 행을 닫고 다음 행을 열게 되어, 접근당 약 40 사이클을 소모합니다. C / 256 = 4(스트라이드 256)를 통한 Channel 인터리빙은 요청을 32개 Channel 전체에 분산시키기는 하지만, 각 Channel 내의 행 충돌 비용을 숨기지는 못합니다.

성능 분석:

  • HBM 읽기: 32개 채널에 걸쳐 총 524,288개 요청 = 채널당 16,384개 × 약 40 사이클 ≈ 655,360 사이클입니다.
  • DM 쓰기: DMN당 65,536개 요청을 사이클당 하나씩 처리하며, 이 시간은 읽기 지연 시간에 가려집니다.

총 소요 시간: 약 655,360 사이클 + 시작 500 사이클 ≈ 655,860 사이클입니다. FR-FCFS 스케줄링이 처리량을 일부 회복하지만, 자릿수 단위의 성능 저하는 여전히 남습니다.

예제 5: 스택 비트 누락 병리 현상

이 접근 패턴은 HBM의 스택 차원(주소 비트 8)에 걸쳐 인터리빙하지 못하므로, 모든 트래픽이 절반의 채널로만 라우팅되어 유효 대역폭이 절반으로 줄어듭니다.

인자:

  • Chip 1개(DMN 8개)
  • axes![A = 8, B = 64, C = 8, D = 512]
  • dtype = i8
  • 소스: HBM, 매핑 m![A, B, C, D]
  • 대상: DM, Cluster 매핑 m![A / 4], Slice 매핑 m![A % 4, B], 요소 매핑 m![C, D % 256]
  • 스트림: Cluster m![A / 4], Slice m![A % 4], 시간 m![C, B % 32, B / 32], 패킷 m![D % 256]

(cluster_i, dmn_j)별로 생성된 Sequencer:

read = [
  C     -> 8   : 512,
  B%32  -> 32  : 4,096,
  B/32  -> 2   : 131,072,
  D%256 -> 256 : 1,
] : 256, HBM @ (i·2²⁰ + j·2¹⁸)

write = [
  C     -> 8   : 256,
  B%32  -> 32  : slice_stride,
  B/32  -> 2   : DMN_stride,
  D%256 -> 256 : 1,
] : 256, DM @ (cluster_i, dmn_j, 0)

C의 스트라이드 512는 HBM 주소 비트 8(스택 비트)을 전혀 토글하지 않으므로, 8개의 DMN이 32개 HBM 채널 중 16개에만 집중됩니다.

성능 분석:

  • HBM 읽기(병목): 16개 채널에 걸쳐 총 4,096개 요청 = 채널당 256개 × 요청당 약 5.3 사이클(1 GHz 기준) ≈ 1,357 사이클입니다.
  • DM 쓰기: DMN당 512개 요청이며, 읽기 뒤에서 파이프라인으로 처리됩니다.

총 소요 시간: 약 1,357 사이클 + 시작 500 사이클 ≈ 1,857 사이클입니다. 32개 채널 전체에 걸쳐 스택 비트 인터리빙을 복원하면 HBM 사이클 수가 절반으로 줄어듭니다.

예제 6: 이질적인 DMN 분할

텐서 형태가 DMN들에 균등하게 나누어떨어지지 않으면, 컴파일러는 경계 DMN의 작업을 여러 명령으로 분할하며, 각 명령은 저마다 시작 지연 시간을 치릅니다.

인자:

  • Chip 4개
  • axes![A = 15, B = 32, C = 256, D = 8]
  • dtype = i8
  • 소스: DM, (A' = A + 1#로 표기할 때) Chip 매핑 m![D / 2], Cluster 매핑 m![D % 2], Slice 매핑 m![A' / 4, A' / 2 % 2, B], 요소 매핑 m![A' % 2, C]
  • 대상: HBM, Chip 매핑 m![D / 2], 요소 매핑 m![D % 2, B, A, C]
  • 스트림(DMN별, StreamFn(chip_i, cluster_j, slice_k)로 표현):
StreamFn(chip_i, cluster_j, slice_k) = let A' = A + 1# in
  { chip: m![(D / 2) @ i = 1], cluster: m![(D % 2) @ j = 1],
    slice: m![(A' / 4) @ k = 1],
    time: (k == 0,1,2): m![A' % 2, B, A' / 2 % 2, C]
          (k == 3, exec #0): m![A' % 2, B, A' / 2 = 1, C]
          (k == 3, exec #1): m![A' = 1, B, A' / 2 % 2 @ 1, C],
    packet: m![C] }

차원 A = 15는 4개의 DMN에 나누어떨어지지 않으므로(15 = 3·4 + 3), DMN 0부터 2까지는 각각 4개 요소를 처리하고 DMN 3은 3개만 처리합니다. DMN 3에서 단일 디스크립터를 사용하면 유효 영역을 넘어 네 번째 요소를 쓰게 되므로, 컴파일러는 DMN 3의 작업을 합쳐서 정확히 3개 요소를 다루는 두 개의 명령으로 분할합니다.

성능 분석:

  • DMN 0~2(각각 명령 1개): 약 256사이클 + 시작 500사이클 ≈ 756사이클입니다.
  • DMN 3(명령 2개): 데이터 약 192사이클 + 시작 1,000사이클(각 500) ≈ 1,192사이클입니다.

총 소요 시간은 약 1,192사이클이며, DMN 3이 이를 결정합니다. 이러한 분할 비용을 피하려면 DMN 전체에 고르게 나누어지는 텐서 형태를 선택하십시오.

재분배 연산

SRAM 재분배는 DmTensor 또는 DmTensorView로부터 재분배 계획을 구성하고, 이를 to_dm 또는 to_dm_view로 실행합니다. 종결 메서드 이전의 모든 단계는 하나의 DMA 연산으로 융합됩니다. 이 연산들은 Chip 간 또는 Cluster 간에 부분 값을 라우팅하고, 각 대상에 필요한 샤드를 선택합니다. 이 연산들을 사용해 부분 값을 라우팅하고 선택하는 전체 리덕션 예제는 Chip 및 Cluster 리덕션을 참조하십시오.

인터페이스 요약

소스를 제공하는 차원 또는 선택된 Element 좌표를 제공하는 차원에 따라 연산을 선택하십시오.

API의미형태에 미치는 영향실행
source.chip_shuffle(sources)각 대상 Chip마다 소스 Chip 하나를 선택합니다없음지연된 텐서 DMA
source.cluster_swap()각 Chip 안에서 소스 Cluster 0과 1을 서로 교환합니다없음지연된 텐서 DMA
source.chip_slice::<Axis, Output>(indices)각 대상 Chip마다 Axis 좌표 하나를 선택합니다Element를 Output으로 변경합니다지연된 텐서 DMA
source.cluster_slice::<Axis, Output>(indices)각 대상 Cluster마다 Axis 좌표 하나를 선택합니다Element를 Output으로 변경합니다지연된 텐서 DMA
tensor.asymmetric_chip_slice::<Axis, Output>(...)각 Chip마다 로컬 Axis 좌표 하나를 선택합니다Element에서 Axis를 제거합니다즉시 서브 컨텍스트 병렬 복사
tensor.asymmetric_cluster_slice::<Axis, Output>(...)각 Cluster마다 로컬 Axis 좌표 하나를 선택합니다Element에서 Axis를 제거합니다즉시 서브 컨텍스트 병렬 복사
source.hbm_chip_shuffle(dma, sources)각 대상 HBM Chip마다 소스 Chip 하나를 선택합니다없음즉시 HBM DMA

이 표에서 source는 텐서 또는 뷰일 수 있습니다. SRAM 뷰는 Element 타일을 포함할 수 있지만, Chip 타일과 Cluster 타일은 재분배 입력으로 지원되지 않습니다. 네 가지 SRAM 메서드는 지연된 재분배 계획을 반환하므로, 하나의 종단 호출 전에 체이닝할 수 있습니다. to_dm은 그 계획을 새 대상 텐서로 구체화하고, to_dm_view는 같은 논리적 결과를 타일과 같은 기존 대상 뷰에 기록합니다. 축 순열은 대상 매핑에서 결정되며, 어떤 종단 호출을 사용하는지와는 무관합니다. 결과 매핑은 대상 레이아웃을 기술하지만, 각 값을 어떤 소스 배치나 Slice 좌표가 제공했는지는 기록하지 않습니다. 이러한 값 의미론을 파악하려면 재분배 단계와 그 배열을 읽으십시오. 컴파일러는 완성된 계획으로부터 동기화를 도출합니다. 소스 Chip이 바뀌면 ChipSync를 삽입하고, 소스 Cluster가 바뀌면 ClusterSync를 삽입하며, 둘 다 포함된 계획에는 둘 다 삽입합니다. 로컬 데이터를 슬라이스하기만 하는 계획에는 어떤 동기화도 삽입하지 않습니다.

Tensor DMA 의미론

아래 예시는 Chip 4개, Cluster 2개, Slice 256개, 그리고 로컬 [B, C, D] 요소 형태를 사용합니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![ChipAxis = 4, ClusterAxis = 2, B = 4, C = 8, D = 8];
}

표에서 in[p, k, b, c, d]는 소스 Chip p, 소스 Cluster k의 로컬 Element 좌표 [b, c, d]에 있는 입력값을 나타냅니다. 결과의 이름은 out이며, 모든 행은 동일한 활성 Slice를 가리킵니다.

Chip 셔플

chip_shuffle(sources)는 각 대상 Chip에 sources의 같은 위치에 있는 소스 Chip의 값을 전달합니다. 아래 패턴은 대상 Chip 0, 1, 2, 3이 각각 소스 Chip 1, 2, 3, 0을 읽는다는 의미입니다. 이 연산은 텐서 형태를 바꾸지 않고, 각 값을 어느 Chip이 공급하는지만 바꿉니다.

두 대상 Cluster k 중 어느 쪽이든, 그리고 모든 로컬 좌표 [b, c, d]에 대해 다음과 같습니다:

대상 Chip대상 Cluster출력 요소소스 Chip소스 Cluster소스 요소출력 값
0k[b, c, d]1k[b, c, d]out[0, k, b, c, d] = in[1, k, b, c, d]
1k[b, c, d]2k[b, c, d]out[1, k, b, c, d] = in[2, k, b, c, d]
2k[b, c, d]3k[b, c, d]out[2, k, b, c, d] = in[3, k, b, c, d]
3k[b, c, d]0k[b, c, d]out[3, k, b, c, d] = in[0, k, b, c, d]
#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![ChipAxis = 4, ClusterAxis = 2, B = 4, C = 8, D = 8];
fn chip_shuffle(
    device: &mut Device,
    dm: &DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]>,
) -> DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]> {
    dm.chip_shuffle([1, 2, 3, 0]).to_dm(&mut device.tdma)
}
}

HBM Chip 셔플

HbmTensor::hbm_chip_shuffle와 HbmTensorView::hbm_chip_shuffle는 각 대상 Chip 슬롯을 sources가 선택한 소스 Chip에서 복사하고, 동일한 Chip 및 Element 매핑을 갖는 새 HbmTensor를 반환합니다. 소스 목록은 sources[target_chip] = source_chip 규칙을 따르며, Chip의 모든 위치에 대한 순열이어야 합니다. Chip 간 Chip 셔플은 시스템 전체에서 통용되는 전역 Chip ID를 사용합니다.

모든 요소 좌표 e에 대해 패턴 [1, 2, 3, 0]의 값 의미는 다음과 같습니다. 여기서 in[p, e]는 소스 Chip p의 입력이고, out[t, e]는 대상 Chip t의 결과입니다.

대상 Chip소스 Chip출력 값
01out[0, e] = in[1, e]
12out[1, e] = in[2, e]
23out[2, e] = in[3, e]
30out[3, e] = in[0, e]
#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![HbmChip = 4, HbmElement = 256];

fn hbm_chip_shuffle(
    device: &mut Device,
    hbm: &HbmTensor<i32, m![HbmChip], m![HbmElement]>,
) -> HbmTensor<i32, m![HbmChip], m![HbmElement]> {
    hbm.hbm_chip_shuffle(&mut device.tdma, &[1, 2, 3, 0])
}
}

두 엔진 모두 HBM 간 전송을 지원하므로 DMA 인수로는 tdma와 pdma 중 어느 것이든 사용할 수 있습니다.

Cluster 교환

cluster_swap()은 모든 Chip 안에서 소스 Cluster 0과 1을 서로 교환합니다. 텐서 형태는 바꾸지 않고, 각 값을 어느 Cluster에서 가져올지만 바꿉니다.

모든 대상 Chip p와 로컬 좌표 [b, c, d]에 대해 다음과 같습니다.

대상 Chip대상 Cluster출력 요소소스 Chip소스 Cluster소스 요소출력 값
p0[b, c, d]p1[b, c, d]out[p, 0, b, c, d] = in[p, 1, b, c, d]
p1[b, c, d]p0[b, c, d]out[p, 1, b, c, d] = in[p, 0, b, c, d]
#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![ChipAxis = 4, ClusterAxis = 2, B = 4, C = 8, D = 8];
fn cluster_swap(
    device: &mut Device,
    dm: &DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]>,
) -> DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]> {
    dm.cluster_swap().to_dm(&mut device.tdma)
}
}

Chip Slice

chip_slice::<Axis, Output>(indices)는 각 대상 Chip마다 살아 있는 Axis 위치를 하나씩 선택하고, 나머지 Element 매핑을 Output으로 선언합니다. [0, 1, 2, 3]을 사용하면 대상 Chip t는 B의 위치 t를 유지하며, 결과에서는 Element에서 B가 제거됩니다.

두 대상 Cluster k 각각과 모든 결과 좌표 [c, d]에 대해 다음이 성립합니다.

대상 Chip대상 Cluster출력 요소소스 Chip소스 Cluster소스 요소출력 값
0k[c, d]0k[0, c, d]out[0, k, c, d] = in[0, k, 0, c, d]
1k[c, d]1k[1, c, d]out[1, k, c, d] = in[1, k, 1, c, d]
2k[c, d]2k[2, c, d]out[2, k, c, d] = in[2, k, 2, c, d]
3k[c, d]3k[3, c, d]out[3, k, c, d] = in[3, k, 3, c, d]
#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![ChipAxis = 4, ClusterAxis = 2, B = 4, C = 8, D = 8];
fn chip_slice(
    device: &mut Device,
    dm: &DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]>,
) -> DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![C, D]> {
    dm.chip_slice::<m![B], m![C, D]>([0, 1, 2, 3])
        .to_dm(&mut device.tdma)
}
}

Cluster Slice

cluster_slice::<Axis, Output>(indices)는 각 대상 Cluster마다 활성 Axis 위치 하나를 선택하고, 나머지 Element 매핑을 Output으로 선언합니다. [1, 0]을 사용하면 대상 Cluster 0과 1은 각각 B의 위치 1과 0을 유지하며, 결과에서는 Element에서 B가 제거됩니다.

모든 대상 Chip p와 결과 좌표 [c, d]에 대해 다음이 성립합니다:

대상 Chip대상 Cluster출력 요소소스 Chip소스 Cluster소스 요소출력 값
p0[c, d]p0[1, c, d]out[p, 0, c, d] = in[p, 0, 1, c, d]
p1[c, d]p1[0, c, d]out[p, 1, c, d] = in[p, 1, 0, c, d]
#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![ChipAxis = 4, ClusterAxis = 2, B = 4, C = 8, D = 8];
fn cluster_slice(
    device: &mut Device,
    dm: &DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]>,
) -> DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![C, D]> {
    dm.cluster_slice::<m![B], m![C, D]>([1, 0])
        .to_dm(&mut device.tdma)
}
}

단계 결합

재분배 단계는 물리적 순서를 따릅니다. 선택적인 Chip 셔플, 선택적인 Cluster 스왑, 0개 이상의 Chip 슬라이스, 0개 이상의 Cluster 슬라이스 순입니다. 두 종결 메서드 모두 완성된 계획을 하나의 DMA 연산으로 결합합니다. to_dm은 새 대상 텐서를 생성하고, to_dm_view는 주어진 대상 뷰에 기록합니다. Slice 메서드를 두 번 이상 호출하면 같은 DMA 안에서 여러 축이 제거됩니다.

아래의 융합 예제는 남은 모든 d에 대해 다음과 같은 배치와 요소 좌표 매핑을 만들어 냅니다.

대상 Chip대상 Cluster출력 요소소스 Chip소스 Cluster소스 요소출력 값
00[d]11[0, 3, d]out[0, 0, d] = in[1, 1, 0, 3, d]
01[d]10[0, 7, d]out[0, 1, d] = in[1, 0, 0, 7, d]
10[d]21[1, 3, d]out[1, 0, d] = in[2, 1, 1, 3, d]
11[d]20[1, 7, d]out[1, 1, d] = in[2, 0, 1, 7, d]
20[d]31[2, 3, d]out[2, 0, d] = in[3, 1, 2, 3, d]
21[d]30[2, 7, d]out[2, 1, d] = in[3, 0, 2, 7, d]
30[d]01[3, 3, d]out[3, 0, d] = in[0, 1, 3, 3, d]
31[d]00[3, 7, d]out[3, 1, d] = in[0, 0, 3, 7, d]
#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![ChipAxis = 4, ClusterAxis = 2, B = 4, C = 8, D = 8];
fn fused_redistribution(
    device: &mut Device,
    dm: &DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]>,
) -> DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![D]> {
    dm.view()
        .chip_shuffle([1, 2, 3, 0])
        .cluster_swap()
        .chip_slice::<m![B], m![C, D]>([0, 1, 2, 3])
        .cluster_slice::<m![C], m![D]>([3, 7])
        .to_dm(&mut device.tdma)
}
}

서브 컨텍스트 Chip 및 Cluster Slice

asymmetric_chip_slice와 asymmetric_cluster_slice는 서브 컨텍스트를 통해 DmTensor에서 직접 동작합니다:

#![allow(unused)]
fn main() {
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![ChipAxis = 4, ClusterAxis = 2, B = 4, C = 8, D = 8];

fn sub_context_chip_slice(
    device: &mut Device,
    dm: &DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]>,
) -> DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![C, D]> {
    dm.asymmetric_chip_slice::<m![B], m![C, D]>(&mut device.sub, &[3, 0, 1, 2])
}

fn sub_context_cluster_slice(
    device: &mut Device,
    dm: &DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![B, C, D]>,
) -> DmTensor<i32, m![ChipAxis], m![ClusterAxis], m![1 # 256], m![C, D]> {
    dm.asymmetric_cluster_slice::<m![B], m![C, D]>(&mut device.sub, &[1, 0])
}
}

각 연산은 Chip 또는 Cluster 차원을 따라 데이터를 이동하지 않고 병렬 복사를 통해 하나의 축을 선택합니다.

재분배 제약 조건

cluster_swap()에는 정확히 두 개의 Cluster가 필요합니다. 소스 Chip 목록은 순열이어야 하며, 모든 활성 대상은 활성 소스 Chip 또는 Cluster 인덱스를 읽어야 합니다. Slice 배열에는 대상 Chip 또는 Cluster마다 활성 인덱스가 하나씩 있어야 합니다. 융합된 계획은 사용 가능한 동기화 프로토콜에 맞게 목적지 Chip마다 소스 Chip 하나를, 목적지 Cluster마다 소스 Cluster 하나를 선택해야 합니다. chip_slice::<m![B, C]>는 m![B, C]를 선형 인덱스가 하나인 연속된 매핑 하나로 취급합니다. 서로 떨어져 위치한 축을 Slice하려면 축마다 메서드 호출을 하나씩 체이닝하십시오.

성능

SRAM 재분배는 DMA 정렬 및 패킷 제약 조건을 따릅니다. 경험적으로, 컴파일러가 효율적인 패킷을 구성할 수 있도록 DM 쓰기가 정렬되고 연속 구간이 긴 Element 매핑을 선택하십시오. 로컬 축 하나에는 서브 컨텍스트 Slice를 사용하고, Slice를 셔플과 융합해야 하거나 여러 축을 한 번의 DMA로 Slice해야 할 때는 DmTensorView 체인을 사용하십시오.

Scatter와 Gather

Scatter와 gather는 고정된 스트라이드가 아니라 인덱스 텐서로부터 계산된 주소에서 텐서 요소를 이동합니다.

DmTensor::dma_scatter는 인덱스 텐서가 선택한 HBM 행에 DM 값을 씁니다. HbmTensor::dma_gather_scaled와 HbmTensor::dma_gather_unscaled는 HBM 행을 읽어 인덱스 텐서가 선택한 DM 행에 넣습니다. 두 gather 변형은 인덱스가 있는 위치와 그 값을 읽는 방식만 다르며, 이는 아래에서 설명합니다.

extern crate furiosa_opt_std;
extern crate tokio;
use furiosa_opt_std::prelude::*;
axes![K = 512, D = 128, C = 612, G = 512, CL = 2];

fn scatter_minimal(
    device: &mut Device,
    data: &HbmTensor<bf16, m![1], m![K, D]>,
    index: &HbmTensor<i32, m![1], m![K]>,
    output: &mut HbmTensor<bf16, m![1], m![C, D]>,
) {
    let data_dm: DmTensor<bf16, m![1], m![1 # 2], m![K / 2], m![K % 2, D]> =
        data.to_dm(&mut device.tdma);

    data_dm.dma_scatter::<m![K], _, _>(index, output);
}

fn gather_minimal(
    table: &HbmTensor<bf16, m![1], m![K, D]>,
    index: &HbmTensor<i32, m![1], m![G]>,
    // The gather axis itself partitions into Slice x Element (`G / 2 = 256`, a valid slice count)
    // with `D` folded into the Element side alongside the `G % 2` remainder; `C = 612` (used above
    // for the scatter cache) has no divisor landing on a valid 64 | 128 | 256 slice count, so the
    // gather count here is the separate, slice-friendly `G` instead.
) -> DmTensor<bf16, m![1], m![1 # 2], m![G / 2], m![G % 2, D]> {
    table.dma_gather_scaled(index)
}

fn gather_unscaled(
    device: &mut Device,
    table: &HbmTensor<bf16, m![1], m![K, D]>,
    // Raw row positions per cluster. The kernel stages the index on-chip with `to_dm`;
    // a real per-cluster (`CL`) partition avoids broadcast padding.
    index: &HbmTensor<i32, m![1], m![CL, G]>,
) -> DmTensor<bf16, m![1], m![CL], m![G / 2], m![G % 2, D]> {
    let index_dm: DmTensor<i32, m![1], m![CL], m![G / 2], m![G % 2]> =
        index.to_dm(&mut device.tdma);
    table.dma_gather_unscaled(&index_dm)
}

#[tokio::main]
async fn main() {
    let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

    let index = &(HostTensor::<i32, m![K]>::zero().to_hbm(&mut device.pdma).await.unwrap());
    let data = HbmTensor::<bf16, m![1], m![K, D]>::new();
    let mut output_hbm = HbmTensor::<bf16, m![1], m![C, D]>::new();

    scatter_minimal(&mut device, &data, &index, &mut output_hbm);
    gather_minimal(&data, &(HostTensor::<i32, m![G]>::zero().to_hbm(&mut device.pdma).await.unwrap()));
    let placed_index = &(HostTensor::<i32, m![CL, G]>::zero().to_hbm(&mut device.pdma).await.unwrap());
    gather_unscaled(&mut device, &data, placed_index);
}

scaled 변형(dma_gather_scaled, dma_scatter)은 DRAM에 있는 HbmTensor에서 인덱스를 가져오고, 그 값을 gather/scatter 축을 따른 바이트 오프셋으로 읽습니다. r번째 행에 접근하려면 r에 한 행의 바이트 크기(해당 행의 요소 수와 요소의 바이트 크기를 곱한 값으로, 예를 들어 128 너비의 bf16 행이라면 128 * 2 = 256입니다)를 곱한 값을 전달하십시오. 반면 dma_gather_unscaled는 to_dm으로 DRAM에서 가져온 Chip 내장 DmTensor 인덱스를 받아 그 값을 원시 행 위치로 읽습니다. 이 방식은 paged-어텐션 블록 테이블처럼 Chip 내에서 계산된 인덱스에 사용합니다.

PCIe DMA

PCIe DMA(Device::pdma)는 호스트 시스템 메모리와 디바이스 HBM 사이에서 텐서를 이동합니다. 이는 Chip 내부의 Tensor DMA와는 물리적으로 별개인 엔진입니다. PCIe DMA는 호스트 ↔ HBM 전송만 담당하고, Tensor DMA는 Chip 내부의 모든 전송을 담당합니다.

커널 작성자는 HostTensor에 .to_hbm()을 호출하거나(호스트 → 디바이스) HbmTensor에 .to_host()를 호출합니다(디바이스 → 호스트). 두 연산 모두 비동기 연산입니다.

extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
use rand::{rngs::SmallRng, SeedableRng};
axes![A = 8, B = 512];

async fn upload_and_download(device: &mut Device) -> Result<(), Error> {
    let mut rng = SmallRng::seed_from_u64(0);
    let host: HostTensor<i8, m![A, B]> = HostTensor::rand(&mut rng);

    // Host → HBM (allocator-assigned address)
    let hbm: HbmTensor<i8, m![A], m![B]> = host.to_hbm(&mut device.pdma).await?;

    // HBM → host (back to system memory)
    let _round_tripped: HostTensor<i8, m![A, B]> = hbm.to_host(&mut device.pdma).await?;
    Ok(())
}

HostTensor는 Element 매핑만 가지는 반면(호스트 메모리에는 chip/cluster/slice 분할이 없습니다), 대상 HbmTensor는 여러 Chip에 분산하기 위해 Chip 축을 추가합니다. to_hbm은 새로운 Chip 및 Element 타입 매개변수를 받으므로, HBM의 대상 요소 레이아웃은 호스트 레이아웃과 다를 수 있습니다.

PCIe DMA 대역폭은 30 B/사이클로, Chip 내장 Tensor DMA(256 B/사이클)보다 한 자릿수 느립니다. 알고리즘은 호스트 ↔ 디바이스 간 트래픽을 최소화해야 합니다. 데이터는 한 번만 업로드하고, 이를 Chip 내부의 여러 연산에서 재사용하십시오.

메모리 성능

메모리 성능은 커널 처리량을 제한하는 경우가 많습니다. 연산 매핑을 변경하기 전에 먼저 고정된 스케줄끼리 비교하십시오. Fetch Engine, Commit Engine, DMA Engine은 각각 성능 결과에 직접 연결되는 API 선택지(Packet 크기, 접근 순서 등)를 제공합니다. 이 절에서는 이러한 선택의 바탕이 되는 하드웨어 제약 조건과 이 제약 조건이 측정 처리량에 미치는 영향을 설명합니다.

메모리 유형별 Chip당 최대 대역폭은 다음과 같습니다:

메모리최대 대역폭
DMChip당 2 TB/s
HBMChip당 1.5 TB/s

이 최대치에 도달하려면 특정한 접근 패턴이 필요합니다. 다음 표는 위반하면 처리량이 저하되는 규칙을 정리한 것이며, 아래 절에서 각 메모리 유형과 요인을 자세히 설명합니다:

메모리문제규칙불이익
DM뱅크 기아동일 뱅크 연속 접근 < 64회NoC 타임아웃 → 하드웨어 리셋
DMDMN 인터리빙Cluster당 2개의 DMN을 번갈아 사용대역폭 50% 손실
DMSlice 인터리빙DMN당 32개의 Slice에 분산명령 큐 경합
HBM정렬256바이트 정렬 접근비정렬 읽기: 2배 페널티. 비정렬 쓰기: 약 50배 페널티(RMW)
HBM뱅크 충돌같은 뱅크 안에서 행 전환을 피하십시오30–40배 성능 저하
HBMChannel 인터리빙32개 채널에 분산병렬성 감소

먼저 표를 보고 정렬, 인터리빙, 패킷 크기를 정한 다음, 상세 섹션을 참고하여 뱅크 기아(starvation)나 HBM 충돌을 진단하십시오. 규칙 표는 후보 매핑에서 병목이 되는 자원을 알려 주며, DM, SPM, HBM 섹션에서 그 원인을 설명합니다.

Data Memory (DM)

Data Memory(DM)는 Chip당 256MB 용량이며, Cluster, Data Memory Network(DMN), Slice, 뱅크로 계층적으로 구성됩니다. 다음 표는 이 구조를 요약한 것입니다:

단위개수
Cluster2 / Chip
Data Memory Network(DMN)8 / Cluster
Slice32 / DMN
뱅크16 / Slice
행4096 / 뱅크
바이트8 / 행

Cluster는 Switch Engine을 통해 데이터를 교환할 수 있습니다. 자세한 내용은 해당 섹션을 참조하십시오. 아래 하위 섹션에서는 이 구조가 대역폭과 뱅크 접근 제약 조건을 어떻게 결정하는지 설명합니다.

Slice 내 뱅크 구조

각 Slice는 전용 주소 공간을 갖춘 512KB의 SRAM을 제공합니다. 메모리는 각각 8바이트 데이터 폭을 갖는 16개의 병렬 뱅크로 구성되어 있으며, 이를 통해 총 128 B/사이클의 데이터 접근 속도를 제공합니다. 개별 뱅크에 대한 접근은 직렬화되지만, 주소 공간은 연속된 128바이트를 16개 뱅크 전체에 분산하여(뱅크당 8바이트) 병렬 접근을 가능하게 합니다. 다음 비트 매핑이 이 분산 방식을 정의합니다:

비트 번호구성 요소
0–2바이트
3–6뱅크
7–18Row

연속된 주소는 서로 다른 뱅크에 매핑되므로 순차 스캔 중에 병렬 접근이 가능합니다.

DMN과 Slice 인터리빙

각 DMN은 128 B/사이클의 대역폭만 제공합니다(32개의 Slice가 데이터 경로를 공유하기 때문입니다). 표준 전송 단위인 256바이트는 DMN당 두 사이클이 필요하므로, 연속적인 처리량을 유지하려면 두 DMN에 걸쳐 접근을 파이프라인으로 처리하십시오:

사이클DMN #0DMN #1
0읽기 #0 (1/2)(유휴)
1읽기 #0 (2/2)읽기 #1 (1/2)
2읽기 #2 (1/2)읽기 #1 (2/2)
3읽기 #2 (2/2)읽기 #3 (1/2)
………
2n-1읽기 #2n-2 (2/2)읽기 #2n-1 (1/2)
2n(유휴)읽기 #2n-1 (2/2)

Note

명령 큐가 이론적으로는 인터리빙 없이도 어느 정도의 버스트 접근을 허용하지만, DMA 스트림을 생성할 때는 항상 DMN 간에 인터리빙하십시오. 이것이 가장 자연스러운 방법입니다.

Slice는 DMA, Fetch, Commit Engine이 공유하므로, 각 DMN 내의 32개 Slice에 요청을 분산하면 경합이 줄어듭니다. Data Memory Router는 각 DMN 내에서 이 Slice들을 링 토폴로지로 연결합니다: slice0_in → slice31_out, slice32_in → slice63_out. 각 Data Memory Slice에는 대기 중인 DMA 요청을 위한 2-엔트리 명령 큐가 있습니다. 우선순위 때문에 개별 Slice가 지연되더라도, 요청을 M개의 Slice에 분산하면 Slice당 필요한 처리량이 1/M로 줄어듭니다. n 사이클마다 DMN 인터리빙을 하면 포화 처리량인 256 B/cycle을 달성합니다.

뱅크 기아

뱅크 기아는 DMA Engine이 더 높은 우선순위의 엔진이 점유한 DM 뱅크를 기다리며 무기한 차단될 때 발생합니다. 64회 접근 규칙이 이를 방지합니다. 이 규칙을 위반하면 Network-on-Chip(NoC) 타임아웃과 전체 Cluster 리셋이 발생하여 모든 연산 상태를 잃게 됩니다.

각 DM 뱅크는 공유 자원입니다. 우선순위가 높은 엔진이 뱅크에 계속 접근하면 우선순위가 낮은 요청자는 무기한 차단되며, 이는 일종의 우선순위 역전입니다. DM 컨트롤러는 다음 순서로 요청의 우선순위를 정합니다:

  • 메인 컨텍스트 Fetch Engine
  • 메인 컨텍스트 Commit Engine
  • 서브 컨텍스트 Fetch Engine
  • 서브 컨텍스트 Commit Engine
  • DMA Engine

정상 동작 중에는 연산 엔진이 데이터에 먼저 접근해야 하므로, DMA는 모든 메모리 엔진 중에서 우선순위가 가장 낮습니다. 우선순위가 높은 엔진은 같은 뱅크를 계속해서 사용할 수 있습니다. 이때 대기열에 있는 DMA 요청은 해당 엔진들이 뱅크를 점유하는 동안 기다려야 합니다. Tensor DMA는 NoC 허브를 통해 DRAM 및 DMN과 통신하며, 이 허브에서 각 포트(DMA, DRAM, DMN)는 4,096 사이클 이내에 요청에 응답해야 합니다. 응답 없이 4,096 사이클이 지나면 NoC 프로토콜은 해당 트랜잭션을 죽은 것으로 판정하고 예외 상태에 진입합니다. 이는 교착 상태와 무기한 멈춘 트랜잭션을 감지하기 위한 안전 장치입니다. 타임아웃이 발생하면 하드웨어에는 정상적으로 복구할 수 있는 메커니즘이 없습니다. 유일한 복구 방법은 Cluster 도메인 전체를 리셋하는 것이며, 이 경우 모든 연산 상태가 사라지고 완전한 재초기화가 필요합니다.

한도는 연속 접근 64회입니다. DMA가 활성화된 동안 Fetch와 Commit은 같은 뱅크를 64회 이상의 연산 동안 점유해서는 안 됩니다. 왜 64회입니까? 제약 조건은 (TDMA_IO_BYTE / DMN_IO_BYTE) * Max_Consecutive_Access * DMN_SIZE < 4096이며(TDMA_IO_BYTE = 256, DMN_IO_BYTE = 128, DMN_SIZE = 32), 이로부터 Max_Consecutive_Access < 64가 도출됩니다. 이 제약 조건은 최악의 경우에도 DMA 요청이 NoC 타임아웃 전에 완료되도록 보장합니다.

예를 들어, DMA Engine이 (다른 15개 뱅크와 함께) 뱅크 0에 요청을 보냈는데, 메인 컨텍스트의 Fetch Engine이 뱅크 0에 계속해서 요청을 보낸다고 가정해 봅시다. DMA 요청은 지연되고, 이 지연이 4,096 사이클을 넘으면 NoC 타임아웃으로 인해 하드웨어 리셋이 강제로 발생합니다.

컨텍스트 점유, 64회 접근 규칙, 메인/서브 뱅크 경합에 대해서는 스케줄링: 리소스와 뱅크 경합을 참조하십시오. 어떤 연산이 병렬로 실행되는지 보여 주고 실제 컨텍스트 할당을 검증하는 스케줄링 시각화 유틸리티에 대해서는 Schedule Viewer를 참조하십시오.

고대역폭 메모리(HBM)

HBM은 Chip당 48GB를 저장하고 총 1.5 TB/s의 대역폭을 제공하지만, 이 최대치에 도달하려면 256바이트 정렬 접근과 32개 Channel 전체에 걸친 Channel 인터리빙이 필요합니다. 정렬되지 않은 쓰기와 뱅크 충돌은 처리량을 30–50배까지 떨어뜨릴 수 있습니다. 다음 표는 HBM 구조를 요약한 것입니다:

단위개수
스택2 / Chip
채널16 / 스택
Slice3 / 채널
뱅크 그룹4 / Slice
뱅크4 / 뱅크 그룹
행16K / 뱅크
바이트2K / 행

최대 대역폭

단일 DMA Engine(용량 256GB/s)을 포화시키려면 여러 채널에 걸쳐 액세스를 인터리빙해야 합니다. 최대 HBM 대역폭은 스택과 Channel의 병렬 동작을 통해 Chip당 1.5TB/s에 이릅니다. 채널 컨트롤러는 0.75GHz에서 사이클당 64B를 전송하며,1 이는 채널당 48GB/s(0.75GHz x 64B/cycle), Chip당 1.5TB/s(48GB/s x 32채널)에 해당합니다. 기본 전송 단위는 256바이트이며, 채널당 4클록 사이클이 필요합니다.

최대 대역폭은 접근 패턴에 민감합니다. 정렬 불일치, 뱅크 충돌, 리소스 공유는 각각 처리량을 심각하게 저하시킬 수 있습니다. 각 채널 컨트롤러에는 페널티를 최소화하도록 액세스를 인터리빙하는 64개 항목의 명령 큐가 있지만, 병적인 경우에는 여전히 심각한 성능 저하가 발생할 수 있습니다. 다음 절에서는 성능 저하의 원인과 이를 피하는 방법을 설명합니다.

Chip 내 주소 공간

HBM 주소 공간은 병렬 순차 액세스에 최적화된 비선형 비트 매핑을 사용합니다. 이 설계는 병렬성을 극대화하고 오버헤드를 최소화합니다:

비트 번호주요 구성 요소추가 구성 요소
0–7바이트
8스택
9–12Channel
13뱅크 그룹Channel
14–16바이트Channel
17–18뱅크Channel
19뱅크 그룹Channel
20SliceChannel
21–33RowChannel (21–28)
34SliceRow
35Row

각 구성 요소의 비트 할당은 물리적 메모리 구조에 대응합니다. 예를 들어 byte 구성 요소는 행당 2K(2^11)바이트를 표현하기 위해 11비트(비트 0-7, 14-16)를 차지합니다. 다음과 같은 세 가지 예외가 있습니다:

  • Slice 표현: Slice는 세 개뿐이지만 Slice를 표현하는 데 두 비트(20과 34)를 사용합니다.
  • 연속 주소 공간: 비트 34는 row 구성 요소의 영향을 받아 비트 34와 35가 동시에 1이 되지 않으며, 이를 통해 연속된 48GB 주소 공간이 보장됩니다.
  • Channel XOR 매핑: channel 구성 요소는 비트 9-12와 13-28의 XOR과 같습니다(예: channel의 첫 번째 비트는 비트 9, 13, 21, 25의 XOR과 같습니다).

이러한 비트 순서 덕분에 순차 접근이 스택, 채널, 뱅크 그룹, 뱅크에 동시에 분산되므로 여러 메모리 자원이 병렬로 계속 동작합니다.

정렬되지 않은 접근

정렬되지 않은 접근은 HBM 성능을 크게 저하시킵니다. 256바이트 경계를 넘는 읽기는 두 번의 전송이 필요하고(2배 페널티), 정렬되지 않은 쓰기는 Read-Modify-Write(RMW) 연산이 필요합니다(약 50배 페널티). 256바이트 최소 접근 단위는 비트 0-7(하위 8비트)로 정의되므로, 이 경계를 넘는 데이터에는 이러한 페널티가 발생합니다.

  • 정렬되지 않은 읽기: 256바이트 경계를 넘는 읽기 요청은 두 번의 NoC 전송이 필요하므로 대역폭이 사실상 절반으로 줄어듭니다.
  • 비정렬 쓰기 또는 부분 쓰기: 비정렬 쓰기는 DMA 패킷이 내부적으로 256바이트 트랜잭션 단위로 분할되기 때문에 발생합니다. 패킷 크기가 256바이트 단위로 정렬되지 않은 경우(예: 2,800바이트 패킷은 256바이트 요청 10개와 240바이트 요청 1개로 분할됨), 마지막 “나머지” 트랜잭션에는 RMW 연산이 필요합니다. RMW는 256바이트 단위 전체를 읽고, 요청된 바이트를 갱신한 다음, 단위 전체를 다시 씁니다. RMW는 정렬된 쓰기에 비해 쓰기 속도를 약 50배까지 늦출 수 있습니다.

뱅크 충돌

HBM 뱅크는 한 번에 하나의 행만 열어 둡니다. 같은 뱅크 안에서 다른 행으로 전환하려면 현재 행을 닫고 새 행을 열어야 합니다. 이로 인해 40-50 ns(1.5 GHz 기준 60-75 사이클)의 지연 시간이 추가되며, 이는 이미 열린 행에 접근하는 것보다 30-40배 느립니다. 이 페널티는 연속된 접근이 같은 뱅크 안의 서로 다른 행을 대상으로 할 때마다 발생합니다. 모든 행은 닫힌 상태로 시작하므로, 어떤 행이든 첫 접근에서는 항상 행을 여는 비용을 치릅니다.

Channel 인터리빙은 뱅크 충돌을 완화합니다. 32개 채널 전체에 걸쳐 접근을 인터리빙하면 부하가 분산되고 충돌이 줄어듭니다. 비트 8-12(그다음 하위 5비트)는 독립적인 스택과 채널을 나타냅니다. 이 비트들을 하위 주소에 배치하면 인접한 접근 간의 간섭을 막을 수 있으며, 이는 연속 연산을 병렬화하는 데 매우 중요합니다. Channel 구성 요소가 비트 9-28에 걸쳐 있으므로, 비연속 연산은 자연스러운 Channel 인터리빙의 이점을 얻는 경우가 많습니다. 그러나 스택 성분은 비트 8에만 대응하므로, 완전한 스택 인터리빙을 달성하려면 프로그래머가 접근이 두 스택 사이를 번갈아 가도록 명시적으로 보장해야 합니다.

컨트롤러는 명령 인터리빙을 통해 행 전환 지연 시간을 숨깁니다. 각 채널 안에서 컨트롤러는 뱅크 간에 명령을 자동으로 인터리빙하여, 다른 뱅크가 행 전환을 수행하는 동안에도 유효한 전송이 이루어지도록 합니다. 컨트롤러는 명령 큐를 사용하여 뱅크 상태를 관리합니다. 컨트롤러는 FR-FCFS(First Ready-First Come First Served) 스케줄링을 사용하여, 이미 열린 행을 대상으로 하는 명령을 우선 처리합니다.

이러한 정교한 스케줄링에도 불구하고, 같은 뱅크 안에서 계속해서 행을 전환하는 접근 패턴은 여전히 성능을 크게 떨어뜨립니다. 컴파일러와 프로그래머는 코드를 생성할 때 행 전환 비용을 추정해야 합니다.

열 간 지연(Column-to-Column Delay)

tCCD(Column-to-Column Delay)는 같은 채널에서 연속된 읽기 또는 쓰기 명령 사이의 최소 시간이며, 이 값이 최대 명령 발행 속도를 결정합니다. 대부분의 접근 패턴에서는 tCCD가 병목이 되기 전에 뱅크 충돌이나 Channel 인터리빙이 지배적인 요인이 됩니다. 벤더 사양은 DRAM 스택 레이어와 공유 자원에 접근할 때의 아날로그 제약 조건을 바탕으로 tCCD 값을 정합니다.

tCCD 값은 연속된 명령이 어떤 메모리 자원을 대상으로 하는지에 따라 달라집니다:

명령 관계tCCD(1.5GHz 기준 사이클)상대 성능페널티 원인
같은 Slice, 다른 뱅크 그룹21뱅크 그룹의 이상적인 인터리빙
다른 Slice32/3데이터 경로 전환
같은 Slice, 같은 뱅크 그룹41/2네 뱅크가 공유하는 I/O 버퍼

한 Slice 안에서 뱅크 그룹을 인터리빙하면 1.5GHz에서 tCCD = 2 사이클이 됩니다. 이렇게 하면 0.75GHz 사이클마다 새로운 64B 명령을 보낼 수 있어 채널 최대 속도에 도달합니다. tCCD가 2보다 크면 명령 속도와 채널 활용률이 떨어집니다.

뱅크 충돌에 비해 tCCD로 인한 성능 저하는 덜 심각합니다. 최악의 패턴은 뱅크 충돌과 동시에 발생하거나(이 경우 tCCD는 부차적인 영향이 됨) Channel 인터리빙에 의해 가려지기 때문입니다:

  • 다른 Slice(tCCD = 3): Slice ID는 비트 20에 대응하고, 비트 21은 로우(row)에 대응합니다. 따라서 Slice 간 인터리빙은 뱅크 충돌을 동시에 일으킬 가능성이 높습니다.
  • 같은 Slice, 같은 뱅크 그룹(tCCD = 4): 이 패턴은 비트 13, 19, 20, 34를 제외한 비트 8-35를 인터리빙합니다. 비트 29-35는 뱅크 충돌과 관련이 있습니다. 비트 8-28은 Channel 인터리빙과 관련됩니다.

  1. 채널 컨트롤러는 0.75GHz의 주파수로 동작하지만 사이클당 8회의 버스트를 수행하므로 유효 주파수는 0.75×8=6GHz가 됩니다. ↩

텐서 계산

텐서 계산에서는 유효한 Tensor Unit 스트림이 Vector Engine 또는 Contraction Engine에 도달하는 방식을 설명합니다. 한 피연산자는 Register File에 고정되어 머무르고, 다른 피연산자는 엔진을 통해 스트리밍됩니다. Tensor Register File(TRF)은 축약 피연산자를 저장하고, Vector Register File(VRF)은 벡터 피연산자를 저장합니다. 이 장에서는 피연산자를 배치하고 스트림 차원을 보존하는 엔진 동작을 설명합니다.

계산 경로 선택

커널 요구 사항선택다음에 읽을 동작
요소별 연산 또는 리덕션Vector Engine벡터 스트림 및 리덕션 계약
matmul 또는 합성곱과 같은 축약Tensor Register File(TRF)과 Contraction EngineTRF 레이아웃 및 축약 매핑
Slice 간 재분배 또는 리덕션Switch Engine 또는 Inter-Slice ReducerSlice 이동 및 리듀서 계약
레이아웃 또는 정밀도 변경Fetch/Commit Adapter, Cast Engine 또는 Transpose EngineAdapter 단계와 출력 매핑 계약

Tensor Unit

Tensor Unit은 Chip 내부의 연산 파이프라인입니다. Tensor Unit은 DM에서 텐서 데이터를 읽고, 10개의 엔진을 거쳐 변환한 뒤, 결과를 다시 DM에 씁니다.

각 텐서는 사이클당 하나의 패킷씩, 패킷 스트림 형태로 파이프라인을 통과합니다. 엔진들은 이러한 스트림을 소비하고 생성하며, 그 과정에서 사이클별 레이아웃과 반복 순서를 재구성합니다. Collect Engine은 들어오는 패킷을 32바이트 플릿(flit)으로 정규화합니다. 모든 다운스트림 엔진은 이 플릿을 대상으로 동작합니다. 파이프라인에는 Contraction, Vector, Cast, Transpose, Commit Adapter, Commit이 포함됩니다. 링크된 엔진 페이지 Contraction, Vector, Cast, Transpose, Commit Adapter, Commit을 참조하십시오.

flowchart TB
    subgraph SRAM
        DM[(DM)] & TRF[(TRF)] & VRF[(VRF)]
    end

    subgraph TU[Tensor Unit]
        direction LR
        FE[Fetch] --> FA[Fetch Adapter] --> SW[Switching] --> CO[Collect] --> CE[Contraction] --> VE[Vector] --> CA[Cast] --> TR[Transpose] --> CMA[Commit Adapter] --> CM[Commit]
    end

    DM --> FE
    CM --> DM
    CO --> TRF --> CE
    CO --> VRF --> VE

    click FE "../moving-tensors/fetch-engine.html" "Fetch Engine"
    click FA "./fetch-adapter.html" "Fetch Adapter"
    click SW "./switch-engine.html" "Switch Engine"
    click CO "./collect-engine.html" "Collect Engine"
    click CE "./contraction-engine/index.html" "Contraction Engine"
    click VE "./vector-engine/index.html" "Vector Engine"
    click CA "./cast-engine.html" "Cast Engine"
    click TR "./transpose-engine.html" "Transpose Engine"
    click CMA "./commit-adapter.html" "Commit Adapter"
    click CM "../moving-tensors/commit-engine.html" "Commit Engine"
엔진기능핵심 제약 조건
FetchDM에서 파이프라인으로 데이터를 로드합니다패킷은 8바이트 단위로 정렬되어야 합니다. 축 리프팅이 브로드캐스트를 Time의 축으로 대체하지 않는 한 Slice는 유지됩니다
Fetch Adapter페치 후 요소별 변환(테이블 룩업, 캐스팅)선택 사항입니다. 생략하면 항등 변환입니다
SwitchingSlice 간에 데이터를 이동합니다링 네트워크이며, Slice가 바뀔 수 있습니다
Collect패킷을 32바이트 플릿으로 정규화출력 = 정확히 하나의 플릿
ContractionEinsum: matmul, 합성곱, 어텐션한 피연산자는 TRF에 상주합니다. 다른 피연산자는 스트리밍됩니다
Vector요소별 연산, 이항 연산, 리덕션 연산i32/f32 입력만 가능합니다
Cast배칭을 통한 정밀도 낮춤출력 = 정확히 하나의 플릿
Transpose플릿 내 요소 재정렬플릿 내부 한정
Commit Adaptercommit 이전 요소별 변환(캐스트, ReLU, trim)선택 사항입니다. .commit() 앞에 체이닝됩니다
Commit결과를 DM에 다시 씁니다플릿 정렬 쓰기

Tensor Unit 내부의 각 텐서 스트림은 [Chip, Cluster, Slice, Time, Packet]의 다섯 차원을 가지며, 이 차원은 두 그룹으로 나뉩니다. Chip, Cluster, Slice는 공간 차원입니다. 각 Slice는 자체 파이프라인 인스턴스를 실행하며, Slice는 Cluster 단위로, Cluster는 Chip 단위로 묶입니다. Time과 Packet은 Slice별 스트림을 나타냅니다(정의는 공간 차원과 시간 차원을 참조하십시오). 위의 엔진들은 파이프라인을 따라 Time / Packet의 형태를 바꿉니다. 대부분의 엔진은 공간 차원을 유지합니다. Switch는 Slice 간에 데이터를 이동합니다. Fetch의 축 리프팅은 Chip, Cluster 또는 Slice를 따라 서로 다른 DM 읽기 오프셋을 할당하여 축을 Time 밖으로 옮깁니다. Vector Inter-Slice Reducer는 Cluster 내의 256개 Slice를 결합합니다.

Contraction Engine과 Vector Engine은 각각 한 피연산자를 파이프라인 스트림에서, 다른 피연산자를 Slice별 전용 Register File에서 가져옵니다. TRF(Tensor Register File)는 Contraction Engine에 데이터를 공급하고, VRF(Vector Register File)는 Vector Engine에 데이터를 공급합니다. Collect Engine은 .to_trf()를 통해 TRF에, .to_vrf()를 통해 VRF에 씁니다. 두 Register File을 모두 사용하는 엔드투엔드 예제는 빠른 시작을 참조하십시오.

Fetch는 DM에서 읽고 Commit은 DM에 다시 씁니다. 이들의 상세한 Sequencer 동작은 여기가 아닌 텐서 이동에서 설명합니다.

실행 컨텍스트

스케줄러는 각 실행 컨텍스트를 독립적인 연산 스트림으로 취급합니다. 하드웨어는 다음 세 가지 실행 컨텍스트를 제공합니다:

  • Main은 커널의 주요 연산을 위해 Tensor Unit 파이프라인을 구동합니다.
  • Sub는 같은 파이프라인의 일부를 구동하며, 일반적으로 Main이 연산하는 동안 피연산자를 TRF / VRF로 미리 가져옵니다.
  • DMA는 Tensor Unit 외부에서 DMA Engine만 단독으로 구동합니다.

Main 컨텍스트는 모든 Tensor Unit 엔진을 구동할 수 있습니다. Sub 컨텍스트에서는 Contraction Engine과 몇 가지 다른 기능을 사용할 수 없습니다. 그 외의 모든 것은 Main과 동일하게 적용됩니다.

컨텍스트 순서, 중첩, 리소스 충돌, 메모리 규칙 스케줄링은 스케줄에서 정의합니다.

Fetch Adapter

Fetch Adapter는 Fetch Engine이 내보내는 패킷 스트림에 요소별 변환(타입 캐스팅, 마스킹, 테이블 룩업, 영점 차감)을 적용하며, 이 변환은 Switch Engine이 스트림을 여러 Slice로 라우팅하기 전에 이루어집니다. Fetch Engine 자체는 이러한 변환을 전혀 수행하지 않습니다. 이 변환들은 텐서 계산 아래의 이 항목에 속하며, Fetch Engine과 Switch Engine 사이에서 별도의 단계로 적용됩니다. 커널 작성자는 FetchTensor에서 단계별 메서드를 직접 조합하며, 각 호출은 다음 단계로 진행합니다.

Adapter에는 사용할 수 있는 단계가 세 개 있으며, 각 단계는 선택 사항이고 하드웨어 파이프라인 순서에 따라 스트림에서 해당 메서드를 호출하여 실행합니다. FetchTensor는 Adapter를 전혀 호출하지 않고 Switch Engine이나 Collect Engine으로 바로 흘러갈 수도 있습니다.

테이블 룩업

테이블 룩업은 fetch 단계에서 하드웨어로 가속되는 룩업 테이블을 제공합니다. 각 값은 미리 구성된 테이블의 인덱스로 취급되며, 원래 값 대신 해당 테이블 항목이 출력됩니다. 이는 Sigmoid나 GeLU 같은 비선형 활성화 함수, 또는 사용자 정의 인코딩 테이블을 사용하는 양자화 방식처럼 표준 산술 연산으로는 효율적으로 구현할 수 없는 연산에 유용합니다. 이를 통해 다음이 가능합니다.

  • 비선형 활성화: 미리 계산된 룩업 테이블을 통해 Sigmoid, GeLU 및 기타 함수를 구현합니다.
  • 사용자 정의 타입 캐스팅: 변환 테이블을 사용하여 MXFP4 / NVFP4 같은 특수 인코딩을 표준 형식으로 변환합니다.

Sigmoid와 GeLU는 Vector Engine에서 직접 표현할 수도 있으므로, 테이블 룩업은 이러한 활성화 함수를 위한 유일한 경로가 아니라 여러 선택지 중 하나입니다.

// Table lookup is a **main-context-only** hardware feature: only the main Fetch Unit's
// `mode_indirect_table` can point at a resident lookup table, so this impl is fixed to
// `{ Tu::Main }`. The sub-context Fetch Unit (used by the StoTrf weight-staging path) has no
// table-lookup register, so decoding a packed weight must happen in a main-context fetch that
// commits the decoded stream to DM, after which a plain convert-only StoTrf stages it into the TRF.
impl<
    'l,
    P: CanApplyFetchTableLookup,
    D: MaterializableScalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, { Tu::Main }, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Runs the Fetch Adapter's table-lookup stage (main context only).
    ///
    /// Each input value indexes a decode table selected by the `TableLookupCast` impl,
    /// not by a runtime argument: `f4e2m1` decodes to either 8-bit float through the
    /// paired 4b->8b table (the NVFP4 / MXFP4 weight path), and either 8-bit float
    /// decodes to `bf16` through the non-paired table. Chain a
    /// [`fetch_cast`](Self::fetch_cast) to widen a paired decode to `f32`; the
    /// per-block scale is applied downstream in the Vector or Contraction Engine.
    /// See the book chapter `computing-tensors/fetch-adapter.md` ("Table Lookup").
    ///
    /// Only available on the main context: the lookup table lives in a main Fetch Unit register the
    /// sub context lacks. To feed a decoded weight into a contraction, decode here and commit the
    /// `f8e4m3` stream to DM, then stage that DM tensor into the TRF with a plain convert-only StoTrf.
    #[primitive(TuTensor::fetch_table_lookup)]
    pub fn fetch_table_lookup<OutD: Scalar>(
        self,
    ) -> FetchTableLookupTensor<'l, { Tu::Main }, OutD, Chip, Cluster, Slice, Time, Packet, B>
    where
        D: TableLookupCast<OutD>,
    {
        FetchTableLookupTensor::new(self.device, self.inner.map(|v| v.lookup()))
    }
}

디코드 테이블은 런타임 인자가 아니라 입력 스칼라 타입에 따라(TableLookupCast 트레이트를 통해) 선택되며, 이는 fetch_cast가 입력 타입에 따라 변환 방식을 선택하는 것과 정확히 같습니다. 키 폭이 테이블을 결정하고, 요청된 출력 타입이 항목 인코딩을 결정합니다.

입력출력
f4e2m1f8e4m3, f8e5m2
f8e4m3bf16
f8e5m2bf16

디코딩된 8비트 부동소수점 값은 뒤이은 fetch_cast로 f32까지 확장하고, NVFP4 / MXFP4의 블록별 스케일은 다운스트림의 Vector Engine이나 Contraction Engine에서 적용하십시오(스케일은 의도적으로 테이블에 합쳐 넣지 않으며, 테이블은 블록과 무관한 16개 항목의 정적 디코드로 유지됩니다).

f4e2m1 디코드용 테이블은 fetch-sequencer 구성에 내장된 컴파일 타임 상수이므로, 이 단계의 유일한 데이터 입력은 f4e2m1 가중치 스트림입니다. 호출마다 테이블을 스테이징하는 과정은 없습니다. 16개 항목의 e2m1 디코드는 블록과 무관하며, 블록별 스케일을 여기에 포함하지 않는 이유가 바로 이것입니다. 스케일을 합쳐 넣으면 블록마다 별도의 테이블이 필요해져 공유 정적 테이블의 이점이 사라집니다.

하드웨어 Sequencer는 원시 4비트 니블이 아니라 항상 바이트 정렬된 키(1바이트 또는 2바이트)를 순회하므로, 4비트 키는 반드시 짝을 이루는 테이블을 사용해야 합니다. 따라서 f4e2m1 디코딩은 256개 항목의 바이트 인덱스 테이블로 실행됩니다. 각 바이트에는 니블 두 개가 들어 있고, 한 번의 조회로 디코딩된 f8e4m3 값 한 쌍을 얻으므로 키당 두 요소씩 디코딩합니다. 이에 따라 f4e2m1 스칼라는 니블 두 개를 담은 바이트로 모델링되며, fetch 매핑이 바이트가 아닌 요소 단위로 계산하도록 BITS = 4로 설정됩니다.

#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8];

/// Decodes an e2m1 (NVFP4 / MXFP4) weight stream to f8e4m3 via the hardware table,
/// then widens to f32 with a following fetch_cast.
fn fetch_decode_e2m1<'l, const T: Tu>(
    input: BeginTensor<'l, T, f4e2m1, m![1], m![1], m![1], m![1], m![A]>,
) -> FetchCastTensor<'l, T, f32, m![1], m![1], m![1], m![1], m![A]> {
    input
        .fetch::<m![1], m![A]>()
        .fetch_table_lookup::<f8e4m3>()
        .fetch_cast::<f32>()
}

타입 캐스팅

fetch_cast::<OutD>()는 Time 및 Packet 매핑을 유지하면서 요소 타입을 D에서 OutD로 변환합니다. 타입 캐스팅은 지연 시간을 1~2 사이클 늘립니다. fetch_cast는 타입 변환만 수행합니다. 영점 오프셋을 담는 정수 확장(i4에서 i5로, i8에서 i9로)은 영점 차감이라는 별도의 단계이므로 fetch_cast가 i5/i9를 만드는 일은 없습니다.

impl<
    'l,
    const T: Tu,
    P: CanApplyFetchCast,
    D: MaterializableScalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Runs the Fetch Adapter's type-casting stage.
    ///
    /// Converts the stream's element type from `D` to `OutD`. The mapping
    /// shape is preserved.
    #[primitive(TuTensor::fetch_cast)]
    pub fn fetch_cast<OutD: Scalar>(self) -> FetchCastTensor<'l, T, OutD, Chip, Cluster, Slice, Time, Packet, B>
    where
        D: FetchCast<OutD>,
    {
        FetchCastTensor::new(self.device, self.inner.map(|v| v.cast()))
    }
}

RNGD는 아래의 변환만 지원하며 그 밖의 변환은 지원하지 않습니다. 모든 확장 변환은 i32 / f32 연산 폭으로 이어지며, 유일한 축소 변환은 f32에서 bf16으로의 변환입니다.

입력출력
i4i32
i8i32
i16i32
f8e4m3f32
f8e5m2f32
bf16f32
f32bf16

특히 Fetch Adapter에는 8비트 부동소수점에서 bf16으로의 캐스트가 없으며, f32에서 8비트 부동소수점으로의 캐스트도 없습니다. f8e4m3 스트림을 bf16로 받으려면 대신 비페어 테이블 룩업 테이블로 디코딩하십시오. 8비트 부동소수점으로 만들려면 파이프라인 뒤쪽의 Cast Engine에서 좁히십시오.

아래 예제는 8개 요소로 이루어진 i8 스트림을 fetch하여 i32로 캐스팅합니다. 호출 전후로 Time 및 Packet 매핑은 변하지 않습니다.

#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8];

/// Fetches with type casting: converts i8 storage to i32 for computation.
/// Input:   i8 [0, 1, 2, 3, 4, 5, 6, 7]
/// Output: i32 [0, 1, 2, 3, 4, 5, 6, 7]
fn fetch_with_type_cast<'l, const T: Tu>(
    input: BeginTensor<'l, T, i8, m![1], m![1], m![1], m![1], m![A]>,
) -> FetchCastTensor<'l, T, i32, m![1], m![1], m![1], m![1], m![A]> {
    input.fetch::<m![1], m![A]>().fetch_cast::<i32>()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let x: BeginTensor<'_, _, i8, m![1], m![1], m![1], m![1], m![A]> = BeginTensor::new(&mut device.main, Tensor::zero());
let _o = fetch_with_type_cast(x);

타입 캐스팅은 read_size에 추가 제한을 둡니다. fetch 한 번당 캐스트 출력은 32바이트 플릿 하나에 들어가야 합니다(Collect Engine 참조).

  • 유효한 경우:
    • i4 -> i32, read_size = 8 (4 bytes): 8 × 4 = 32 B를 생성합니다
    • i8 -> i32, read_size = 8 (8 bytes): 8 × 4 = 32 B를 생성합니다
  • 잘못된 예:
    • i4 -> i32, read_size = 16 (8 bytes): 16 × 4 = 64 B를 생성합니다
    • i8 -> i32, read_size = 16 (16 bytes): 16 × 4 = 64 B를 생성합니다

영점 차감

fetch_zero_point_sub::<OutD>(zero_point)는 각 요소에서 양자화 zero_point를 빼고, 스트림을 Contraction Engine의 스테이징 타입으로 넓힙니다. i4는 i5로, i8은 i9로 넓어집니다. i5/i9를 생성하는 단계는 이 단계뿐입니다.

impl<
    'l,
    const T: Tu,
    P: CanApplyFetchZeroPointSub,
    D: MaterializableScalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Runs the Fetch Adapter's zero-point-subtraction stage.
    ///
    /// Subtracts `zero_point` and widens the stream from `D` to its contraction-engine staging
    /// type `OutD` (`i4 -> i5`, `i8 -> i9`), the only way to produce an i5/i9
    /// stream. The result may only feed `contract_outer`; it is not
    /// [`MaterializableScalar`], so committing or re-routing it is a compile
    /// error. The mapping shape is preserved.
    ///
    /// Panics if `zero_point` is outside the source type's range
    /// ([`FetchZeroPointSub::ZERO_POINT_RANGE`]); a zero point in range keeps
    /// every widened residual within `OutD`, so this one check (independent of
    /// the stream data) is enough.
    #[primitive(TuTensor::fetch_zero_point_sub)]
    pub fn fetch_zero_point_sub<OutD: Scalar>(
        self,
        zero_point: i32,
    ) -> FetchZeroPointSubTensor<'l, T, OutD, Chip, Cluster, Slice, Time, Packet, B>
    where
        D: FetchZeroPointSub<OutD>,
    {
        let zero_point_range = <D as FetchZeroPointSub<OutD>>::ZERO_POINT_RANGE;
        assert!(
            zero_point_range.contains(&zero_point),
            "zero_point {zero_point} is outside the source type's quantized range {zero_point_range:?}",
        );
        FetchZeroPointSubTensor::new(self.device, self.inner.map(|v| v.zero_point_sub(zero_point)))
    }
}

추가 비트가 필요한 이유

영점을 빼면 zero_point를 중심으로 한 부호 없는 양자화 값이 부호 있는 잔차로 바뀌며, 이 잔차의 범위는 더 이상 입력 폭에 들어맞지 않습니다. 대칭 부호 있는 입력의 경우 잔차는 같은 폭을 가진 두 값의 차이입니다.

  • i4 잔차: [-8, 7] - [-8, 7] = [-15, 15]이며, 이를 담으려면 i5의 [-16, 15]가 필요합니다.
  • i8 잔차: [-128, 127] - [-128, 127] = [-255, 255]이며, 이를 담으려면 i9의 [-256, 255]가 필요합니다.

따라서 뺄셈의 출력은 입력보다 1비트 더 넓습니다. 변환은 이를 런타임에 검사합니다. i5/i9 범위를 벗어나는 잔차(범위를 벗어난 zero_point 또는 입력)는 조용히 래핑되지 않고 거부됩니다.

축약 전용 스테이징

i5/i9 스트림은 Switch Engine과 Collect Engine을 거쳐 흐를 수 있지만, 그 이후에 이 스트림을 합법적으로 소비할 수 있는 유일한 소비자는 contract_outer이며, 이는 피연산자 타입에서 설명하는 대로 스트림을 가중치와 짝짓습니다. 이 스트림은 메모리에 커밋하거나, Register File에 저장하거나(to_trf/to_vrf), 전치하거나, 다른 엔진에 공급할 수 없습니다.

이 제약은 런타임 검사가 아니라 컴파일 시점에 강제됩니다. i5/i9 스트림을 contract_outer가 아닌 다른 소비자에 전달하면 컴파일 오류가 발생합니다.

Switch Engine

다른 모든 Tensor Unit 엔진은 각자의 DM 파티션에서 Slice 단위로 실행되지만, Switch Engine은 256개 Slice로 이루어진 링 네트워크를 통해 Slice 간에 데이터를 이동합니다. 즉, 한 Slice의 값을 그룹에 브로드캐스트하거나, Slice 간에 값을 교환하거나, 어느 Slice가 어떤 값을 가질지 치환합니다.

인터페이스

FetchTensor::switch()는 Chip, Cluster, Packet 및 기저 데이터 값을 보존하면서 SwitchTensor를 생성합니다. 선택한 구성을 반영하도록 Slice와 Time 매핑만 변경됩니다.

impl<'l, const T: Tu, P: CanApplySwitch, D: Scalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Applies switching network routing only. The packet passes through
    /// unchanged, no padding, no reshaping. Use `collect` afterwards to
    /// normalize the packet to flit-sized chunks.
    #[primitive(TuTensor::switch)]
    pub fn switch<OutSlice: M, OutTime: M>(
        self,
        config: SwitchConfig,
    ) -> SwitchTensor<'l, T, D, Chip, Cluster, OutSlice, OutTime, Packet, B> {
        verify_switch::<Slice, Time, OutSlice, OutTime>(&config);
        SwitchTensor::new(self.device, self.inner.transpose(true))
    }
}

커널 작성자는 OutSlice, OutTime, 그리고 구성과 그 매개변수를 선택하는 SwitchConfig 인자를 지정합니다. SwitchConfig는 일반적인 패턴을 위한 사전 정의된 변형(Broadcast01, Broadcast1, Transpose, InterTranspose, TransposedBroadcast1) 중 하나이거나, 보다 일반적인 패턴을 위한 CustomBroadcast입니다. 각 변형 이름의 숫자 접미사는 Slice에서 빠져나가는 slice 하위 차원을 나타냅니다. 예를 들어 Broadcast01은 slice0과 slice1을 모두 브로드캐스트하고, Broadcast1은 slice1만 브로드캐스트합니다.

컴파일러는 OutSlice와 OutTime이 구성에서 요구하는 차원 구조와 일치하는지 검증하며(아래의 각 구성별 섹션에서 입력/출력 다이어그램과 함께 요구되는 구조를 보여 줍니다), 일치하지 않으면 컴파일이 실패합니다. 또한 모든 구성은 InSlice::SIZE == OutSlice::SIZE를 요구합니다. 즉, switch는 전체 slice 수를 보존합니다.

정규 구성

각 정규 구성은 Chip의 256개 Slice를 각각 ring_size개의 Slice로 이루어진 병렬 서브링으로 분할합니다. ring_size는 구성의 매개변수(일반적으로 slice1 × slice0)에서 도출되며, 분할 단위와 사이클 비용을 함께 결정합니다. 링 토폴로지와 라우터별 결정 로직은 아래의 아키텍처를, 사이클 계수 분석은 성능을 참조하십시오.

정규 구성은 여섯 가지 일반적인 패턴을 다룹니다. 이 패턴들로 표현할 수 없는 임의의 Slice 하위 차원 순열에는 대신 CustomBroadcast가 필요합니다.

브로드캐스트 축(Broadcast01, Broadcast1, TransposedBroadcast1의 X 또는 Y)을 도입하는 구성에서는 해당 축이 새로운 축이어야 합니다. 즉, 입력 Slice나 입력 Time에 이미 나타나서는 안 됩니다.

구성사용 사례ring_size
Forwarding각 Slice의 데이터를 변경 없이 그대로 전달합니다(Slice 간 교환 없음)1
Broadcast01내부 Slice 하위 차원 두 개(slice1과 slice0)를 서브링의 모든 Slice에 브로드캐스트합니다slice1 × slice0
Broadcast1slice0을 Slice에 유지하면서 slice1을 브로드캐스트합니다slice1 × slice0
TransposeSlice 내에서 slice1과 slice0을 교환합니다slice1 × slice0
InterTransposeSlice 하위 차원(slice1)과 Time 하위 차원(time1)을 교환합니다slice1 × slice0
TransposedBroadcast1slice1을 가장 안쪽 Slice로 옮기면서 slice0을 Time으로 브로드캐스트합니다(Transpose 후 Broadcast1을 적용한 것과 동일합니다)slice1 × slice0

Forwarding

Forwarding은 Slice 매핑과 Time 매핑을 변경하지 않습니다. 모든 라우터는 Slice 간 이동 없이 자기 Slice의 입력을 그대로 출력합니다.

SwitchConfig에는 Forwarding 변형이 없습니다. Slice 간 교환이 필요하지 않으면 .switch()를 건너뛰고 FetchTensor에서 .collect()를 직접 호출하십시오.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 256, B = 64, C = 32];

fn forwarding<'l, const T: Tu>(
    input: FetchTensor<'l, T, f32, m![1], m![1 # 2], m![A], m![B], m![C]>,
) -> CollectTensor<'l, T, f32, m![1], m![1 # 2], m![A], m![B, C / 8], m![C % 8]> {
    input.collect()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, f32, m![1], m![1 # 2], m![A], m![B], m![C]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = forwarding(f);
}

Broadcast01

Broadcast01은 각 Slice의 데이터를 서브링 내 모든 Slice로 브로드캐스트하며, 안쪽 Slice 하위 차원 두 개(slice0과 slice1)를 모두 Time으로 옮깁니다.

입력 차원 구조(왼쪽에서 오른쪽으로, 가장 바깥쪽부터 가장 안쪽 순서)는 다음과 같습니다.

┌──────────────────────────┬───────────────┐
│          Slice           │      Time     │
├────────┬────────┬────────┼───────┬───────┤
│ slice2 │ slice1 │ slice0 │ time1 │ time0 │
└────────┴────────┴────────┴───────┴───────┘

스위칭 후 slice1과 slice0은 서브링 전체에 브로드캐스트하기 위해 Slice에서 Time으로 이동합니다. X와 Y로 표시된 새로운 브로드캐스트 차원 두 개가 비워진 Slice 위치를 채웁니다.

┌──────────────────────┬─────────────────────────────────┐
│        Slice         │              Time               │
├────────┬──────┬──────┼───────┬────────┬───────┬────────┤
│ slice2 │  X   │  Y   │ time1 │ slice1 │ time0 │ slice0 │
└────────┴──────┴──────┴───────┴────────┴───────┴────────┘

서브링은 ring_size = slice1 × slice0개의 Slice에 걸쳐 있으며, 고정된 slice2에서 (slice1, slice0) 조합마다 Slice가 하나씩 대응합니다. 모든 Slice는 자신의 패킷을 서브링을 따라 전송하고 ring_size개의 패킷을 모두 수신합니다. 따라서 각 출력 Slice는 브로드캐스트 그룹 전체의 데이터를 갖게 되며, 이 데이터는 출력 Slice의 새 X 축과 Y 축을 따라 인덱싱됩니다.

예시

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 256, B = 64, C = 63, D = 2, X = 2, Y = 2];

fn broadcast01<'l, const T: Tu>(
    input: FetchTensor<'l, T, f32, m![1], m![D], m![A], m![B], m![C # 64]>,
) -> SwitchTensor<'l, T, f32, m![1], m![D], m![A / 4, X, Y], m![B / 4, A / 2 % 2, B % 4, A % 2], m![C # 64]> {
    input.switch::<m![A / 4, X, Y], m![B / 4, A / 2 % 2, B % 4, A % 2]>(
        SwitchConfig::Broadcast01 {
            slice1: 2,
            slice0: 2,
            time0: 4
        }
    )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, f32, m![1], m![D], m![A], m![B], m![C # 64]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o= broadcast01(f);
}

slice1 = 2(브로드캐스트 X의 크기), slice0 = 2(브로드캐스트 Y의 크기), time0 = 4이면 컴파일러는 slice2 = 64, time1 = 16, ring_size = 4를 도출합니다(64개의 서브링이 256개의 Slice에 걸쳐 있습니다).

하위 차원은 slice2 = A / 4, slice1 = A / 2 % 2, slice0 = A % 2, time1 = B / 4, time0 = B % 4로 결정되며, 그 결과 OutSlice = m![A / 4, X, Y]와 OutTime = m![B / 4, A / 2 % 2, B % 4, A % 2]가 됩니다.

사이클 추정치: ring_size × Time::SIZE × flits_per_packet = 4 × 64 × 8 = 2048이며, 여기서 Time::SIZE = 64이고 flits_per_packet = sizeof(f32) × Packet::SIZE / 32 = 4 × 64 / 32 = 8입니다.

Broadcast1

입력 차원 구조(왼쪽에서 오른쪽으로, 가장 바깥쪽부터 가장 안쪽 순서)는 다음과 같습니다.

┌──────────────────────────┬────────┐
│           Slice          │  Time  │
├────────┬────────┬────────┼────────┤
│ slice2 │ slice1 │ slice0 │ time0  │
└────────┴────────┴────────┴────────┘

스위칭 후 slice1은 서브링 전체에 브로드캐스트하기 위해 Slice에서 Time으로 이동하고, slice0은 Slice에 그대로 남습니다. X로 표시된 새로운 브로드캐스트 차원이 slice1이 비운 Slice 위치를 채웁니다.

┌────────────────────────┬────────────────┐
│          Slice         │      Time      │
├────────┬──────┬────────┼───────┬────────┤
│ slice2 │  X   │ slice0 │ time0 │ slice1 │
└────────┴──────┴────────┴───────┴────────┘

서브링은 ring_size = slice1 × slice0개의 Slice에 걸쳐 있으며, 이는 Broadcast01의 서브링과 물리적 범위가 같습니다. 그러나 브로드캐스트는 slice1을 따라서만 일어납니다. 각 출력 Slice는 같은 slice0 위치에 있는 소스로부터 slice1개의 패킷을 수신하며, 이 패킷들은 가장 안쪽 Time을 따라 순서대로 배치됩니다. 출력 Slice의 새로운 X 축(크기 slice1)은 이렇게 수집된 데이터를 복제하며, slice0 자체는 원래 위치에 그대로 유지됩니다.

예시

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 256, B = 64, C = 63, X = 4];

fn broadcast1<'l, const T: Tu>(
    input: FetchTensor<'l, T, i8, m![1], m![1 # 2], m![A], m![B], m![C # 64]>,
) -> SwitchTensor<'l, T, i8, m![1], m![1 # 2], m![A / 32, X, A % 8], m![B, A / 8 % 4], m![C # 64]> {
    input.switch::<m![A / 32, X, A % 8], m![B, A / 8 % 4]>(
        SwitchConfig::Broadcast1 {
            slice1: 4,
            slice0: 8,
        }
    )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, i8, m![1], m![1 # 2], m![A], m![B], m![C # 64]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = broadcast1(f);
}

slice1 = 4(브로드캐스트 X의 크기), slice0 = 8이면 컴파일러는 slice2 = 8과 ring_size = 32를 도출합니다(8개의 서브링이 256개의 Slice에 걸쳐 있습니다).

하위 차원은 slice2 = A / 32, slice1 = A / 8 % 4, slice0 = A % 8, time0 = B로 결정되며, 그 결과 OutSlice = m![A / 32, X, A % 8]와 OutTime = m![B, A / 8 % 4]가 됩니다.

사이클 추정치: ring_size × Time::SIZE × flits_per_packet = 32 × 64 × 2 = 4096이며, 여기서 Time::SIZE = 64이고 flits_per_packet = sizeof(i8) × Packet::SIZE / 32 = 1 × 64 / 32 = 2입니다.

Transpose

Transpose는 Slice의 가장 안쪽 부분에서 slice1과 slice0을 맞바꿉니다.

입력과 출력의 Slice 순서는 다음과 같습니다.

┌──────────────────────────┐         ┌──────────────────────────┐
│           Slice          │         │           Slice          │
├────────┬────────┬────────┤   ──►   ├────────┬────────┬────────┤
│ slice2 │ slice1 │ slice0 │         │ slice2 │ slice0 │ slice1 │
└────────┴────────┴────────┘         └────────┴────────┴────────┘

각 서브링은 slice0 × slice1개의 Slice에 걸쳐 있으며 데이터를 순환시킵니다. 그 결과 서브링의 모든 Slice는 교환 상대가 이전에 가지고 있던 값을 갖게 됩니다.

Transpose를 사용하려면 (정규화 후) 입력 Time과 출력 Time이 일치해야 합니다.

예시

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 256, B = 64, C = 63];

fn transpose<'l, const T: Tu>(
    input: FetchTensor<'l, T, i8, m![1], m![1 # 2], m![A], m![B], m![C # 64]>,
) -> SwitchTensor<'l, T, i8, m![1], m![1 # 2], m![A / 64, A % 2, A / 2 % 32], m![B], m![C # 64]> {
    input.switch::<m![A / 64, A % 2, A / 2 % 32], m![B]>(SwitchConfig::Transpose {
        slice1: 32,
        slice0: 2,
    })
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, i8, m![1], m![1 # 2], m![A], m![B], m![C # 64]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = transpose(f);
}

slice1 = 32이고 slice0 = 2이면 컴파일러는 slice2 = 4와 ring_size = 64를 도출합니다(서브링 4개가 256개 Slice에 걸쳐 있습니다).

하위 차원은 slice2 = A / 64, slice1 = A / 2 % 32, slice0 = A % 2, time0 = B로 결정되며, 그 결과 OutSlice = m![A / 64, A % 2, A / 2 % 32]와 OutTime = m![B]가 됩니다(slice0과 slice1이 맞바뀌고 Time은 변하지 않습니다).

사이클 추정치: ring_size × Time::SIZE × flits_per_packet = 64 × 64 × 2 = 8192이며, 여기서 Time::SIZE = 64이고 flits_per_packet = sizeof(i8) × Packet::SIZE / 32 = 1 × 64 / 32 = 2입니다.

InterTranspose

InterTranspose는 Slice와 Time 사이에서 차원을 맞바꿉니다. slice1은 Time으로 이동하고 time1은 Slice로 이동합니다(일반 Transpose는 Slice 안에서만 이루어집니다).

Note

모든 대상 Slice가 이미 원본 영역의 사본을 가지고 있으면 축 리프팅으로 InterTranspose를 대체할 수 있습니다. 이 방식은 각 Slice에 서로 다른 읽기 오프셋을 할당하며 서브링 순회를 수행하지 않습니다. 데이터가 Slice 사이에서, 또는 Slice에서 Time으로 이동해야 할 때는 InterTranspose를 사용하십시오.

입력 차원 구조(왼쪽에서 오른쪽으로, 가장 바깥쪽에서 가장 안쪽 순)는 다음과 같습니다.

┌──────────────────────────┬───────────────────────┐
│           Slice          │         Time          │
├────────┬────────┬────────┼───────┬───────┬───────┤
│ slice2 │ slice1 │ slice0 │ time2 │ time1 │ time0 │
└────────┴────────┴────────┴───────┴───────┴───────┘

스위칭 후 slice1과 time1은 Slice/Time 경계를 넘어 위치를 맞바꿉니다.

┌─────────────────────────┬────────────────────────┐
│          Slice          │          Time          │
├────────┬───────┬────────┼───────┬───────┬────────┤
│ slice2 │ time1 │ slice0 │ time2 │ time0 │ slice1 │
└────────┴───────┴────────┴───────┴───────┴────────┘

각 서브링은 slice1 × slice0개의 Slice에 걸쳐 있으며 time1개의 시간 단계 동안 데이터를 순환시킵니다. 그 결과 각 Slice에서 이전에 slice1로 인덱싱되던 값은 time1로 인덱싱되고, 반대의 경우도 마찬가지입니다.

InterTranspose는 세 가지 크기 제약 조건을 적용합니다.

  • InSlice는 256개의 Slice 전체에 걸쳐 있습니다: slice2 × slice1 × slice0 == 256.
  • 맞바뀌는 차원들의 크기가 같습니다: time1.SIZE == slice1.
  • time2 분해가 정수로 떨어지도록 InTime::SIZE는 slice1 × time0으로 나누어떨어져야 합니다.

예시

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 256, B = 8, C = 32];

fn inter_transpose<'l, const T: Tu>(
    input: FetchTensor<'l, T, i8, m![1], m![1 # 2], m![A], m![B], m![C # 32]>,
) -> SwitchTensor<'l, T, i8, m![1], m![1 # 2], m![A / 32, B / 2 % 2, A % 16], m![B / 4, B % 2, A / 16 % 2], m![C # 32]> {
    input.switch::<m![A / 32, B / 2 % 2, A % 16], m![B / 4, B % 2, A / 16 % 2]>(
        SwitchConfig::InterTranspose {
            slice1: 2,
            slice0: 16,
            time0: 2,
        })
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, i8, m![1], m![1 # 2], m![A], m![B], m![C # 32]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = inter_transpose(f);
}

slice1 = 2, slice0 = 16, time0 = 2이면 컴파일러는 slice2 = 8, time2 = 2, ring_size = 32를 도출합니다(서브링 8개가 256개 Slice에 걸쳐 있습니다).

하위 차원은 slice2 = A / 32, slice1 = A / 16 % 2, slice0 = A % 16, time2 = B / 4, time1 = B / 2 % 2, time0 = B % 2로 결정되며, 그 결과 OutSlice = m![A / 32, B / 2 % 2, A % 16]와 OutTime = m![B / 4, B % 2, A / 16 % 2]가 됩니다(slice1과 time1이 Slice와 Time 사이에서 맞바뀝니다).

사이클 추정치: ring_size × Time::SIZE × flits_per_packet = 32 × 8 × 1 = 256이며, 여기서 Time::SIZE = 8이고 flits_per_packet = sizeof(i8) × Packet::SIZE / 32 = 1 × 32 / 32 = 1입니다.

TransposedBroadcast1

TransposedBroadcast1은 slice0을 Time의 가장 안쪽 위치로 브로드캐스트하고 slice1을 Slice의 가장 안쪽 위치로 옮깁니다. 이는 Transpose를 적용한 뒤 Broadcast1을 적용하는 것과 같습니다. 다음과 같은 구조의 입력 텐서는

┌──────────────────────────┬────────┐
│           Slice          │  Time  │
├────────┬────────┬────────┼────────┤
│ slice2 │ slice1 │ slice0 │ time0  │
└────────┴────────┴────────┴────────┘

아래 출력이 됩니다. 여기서 slice0은 서브링 전체에 브로드캐스트되도록 가장 안쪽 Time 위치로 이동하고, slice1은 가장 안쪽 Slice 위치로 옮겨지며, slice1이 비운 가운데 슬롯은 브로드캐스트 차원이 채웁니다:

┌────────────────────────┬────────────────┐
│          Slice         │      Time      │
├────────┬──────┬────────┼───────┬────────┤
│ slice2 │  Y   │ slice1 │ time0 │ slice0 │
└────────┴──────┴────────┴───────┴────────┘

각 서브링은 slice0 × slice1개의 Slice에 걸쳐 있으며 데이터를 순환시킵니다. 그 결과 모든 Slice는 교환 상대의 값을 갖게 되며, 이 값은 가장 안쪽 Time 하위 차원에서 slice0 위치 전체에 브로드캐스트됩니다.

예시

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 256, B = 16, C = 32, Y = 8];

fn transposed_broadcast1<'l, const T: Tu>(
    input: FetchTensor<'l, T, i8, m![1], m![1 # 2], m![A], m![B], m![C # 32]>,
) -> SwitchTensor<'l, T, i8, m![1], m![1 # 2], m![A / 64, Y, A / 8 % 8], m![B, A % 8], m![C # 32]> {
    input.switch::<m![A / 64, Y, A / 8 % 8], m![B, A % 8]>(
        SwitchConfig::TransposedBroadcast1 {
            slice1: 8,
            slice0: 8,
        }
    )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, i8, m![1], m![1 # 2], m![A], m![B], m![C # 32]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = transposed_broadcast1(f);
}

slice1 = 8이고 slice0 = 8(브로드캐스트 Y의 크기)이면 컴파일러는 slice2 = 4와 ring_size = 64를 도출합니다(서브링 4개가 256개 Slice에 걸쳐 있습니다).

하위 차원은 slice2 = A / 64, slice1 = A / 8 % 8, slice0 = A % 8, time0 = B로 결정되며, 그 결과 OutSlice = m![A / 64, Y, A / 8 % 8]와 OutTime = m![B, A % 8]가 됩니다.

사이클 추정치: ring_size × Time::SIZE × flits_per_packet = 64 × 16 × 1 = 1024이며, 여기서 Time::SIZE = 16이고 flits_per_packet = sizeof(i8) × Packet::SIZE / 32 = 1 × 32 / 32 = 1입니다.

아키텍처

앞서 소개한 구성을 실행하기 위해 Switch Engine은 Chip의 256개 Slice 전체를 하나의 물리적 링(Slice당 라우터 하나)으로 배치하며, 이 링은 각각 ring_size개의 Slice로 이루어진 256 / ring_size개의 병렬 서브링으로 분할됩니다(아래에 그러한 서브링 하나를 나타냈습니다). 정규 구성에서는 컴파일러가 구성의 매개변수(slice1, slice0, time0)로부터 ring_size를 도출하고, CustomBroadcast에서는 커널 작성자가 ring_size를 직접 설정합니다.

   ┌────────────────────┐    ┌────────────────────┐    ┌────────────────────┐    ┌────────────────────┐
   │      Router 0      │◀──▶│      Router 1      │◀──▶│        ...         │◀──▶│ Router ring_size-1 │
   └────────────────────┘    └────────────────────┘    └────────────────────┘    └────────────────────┘
             ▲                                                                                          ▲
             └──────────────────────────────────────────────────────────────────────────────────────────┘
                                            wrap-around (links are bidirectional)

성능

스위치 연산 하나에는 대략 ring_size × Time::SIZE × flits_per_packet 사이클이 소요됩니다. 모든 서브링은 병렬로 진행되므로, 이 링당 사이클 수가 곧 Chip 전체의 지연 시간이기도 합니다. 세 가지 요인은 다음과 같습니다:

  • ring_size: 플릿 하나가 서브링 하나를 순회하는 데 걸리는 사이클 수입니다. ring_size가 클수록 링당 비용은 커지지만 링 하나가 더 많은 Slice에 도달하며, ring_size가 작을수록 Cluster가 더 많은 병렬 링으로 분할되고 링당 비용은 작아집니다.
  • Time::SIZE: 입력 텐서의 시간 단계 수입니다. 서브링 순회는 시간 단계마다 한 번씩 반복됩니다.
  • flits_per_packet: 패킷당 플릿 수로, D[Packet::SIZE]의 크기를 32바이트로 나눈 값과 같습니다. 순회는 또한 패킷의 플릿마다 한 번씩 반복됩니다.

사용자 정의 구성

사용자 정의 구성은 임의의 차원 순열이나 부분 차원 추출처럼 정규 구성으로 표현할 수 없는 이동 패턴을 처리합니다. 이러한 유연성에는 구성 오버헤드와 이 절의 끝에 나열된 제약 조건이 따릅니다.

SwitchConfig::CustomBroadcast 변형은 필드 하나를 가집니다:

/// Routes data across slices using a custom snoop bitmap.
/// The bitmap is computed by the compiler from the input shape and
/// topology parameters.
CustomBroadcast {
    /// Ring group size for the custom routing.
    ring_size: usize,
},

정규 구성은 snoop 비트맵을 위한 내장 생성기를 제공하는 반면, CustomBroadcast에서는 컴파일러가 커널 작성자의 입력/출력 매핑과 ring_size로부터 비트맵을 직접 합성합니다.

지원되는 변환 패턴

사용자 정의 비트맵은 정규 구성으로 처리할 수 없는 두 가지 패턴을 다룹니다:

  • 브로드캐스트를 동반한 자유 전치: Transpose나 TransposedBroadcast1의 고정된 형태를 넘어서는, 분할 차원의 임의 순열과 브로드캐스트입니다.
  • 부분 차원 추출: 브로드캐스트 중에 한 차원의 값 중 일부만 Time으로 이동합니다. 반면 Broadcast01 같은 정규 구성은 항상 차원 전체를 이동시킵니다.

아래 예제에서 이러한 패턴을 보여 줍니다.

구성 오버헤드

사용자 정의 스누프 비트맵을 기록하면 구성 데이터가 Switch Engine의 특수 기능 레지스터(Special Function Register, SFR)로 스트리밍되며, 이 SFR 기록이 진행되는 동안 DMA Engine과 서브 컨텍스트가 모두 점유됩니다. 비트맵이 로드되는 동안 DMA 컨텍스트와 sub 컨텍스트는 다른 연산을 실행할 수 없습니다. 따라서 이 비용은 고정 사이클 정지가 아니라 스케줄링 병렬성의 감소로 나타납니다.

예제 1: 임의 순열

이 예시는 가장 안쪽의 Slice 하위 차원 네 개(A / 4, A % 4, B / 4, B % 4)를 [3, 2, 1, 0] 순서로 뒤집습니다. 이는 어떤 정규 구성으로도 표현할 수 없는 패턴입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 16, B = 16, C = 8, D = 8, E = 8];

fn arbitrary_permutation<'l, const T: Tu>(
    input: FetchTensor<'l, T, f32, m![1], m![1 # 2], m![A, B], m![C], m![D, E]>,
) -> SwitchTensor<'l, T, f32, m![1], m![1 # 2], m![B % 4, B / 4, A % 4, A / 4], m![C], m![D, E]> {
    input.switch::<m![B % 4, B / 4, A % 4, A / 4], m![C]>(
        SwitchConfig::CustomBroadcast { ring_size: 256 }
    )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, f32, m![1], m![1 # 2], m![A, B], m![C], m![D, E]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = arbitrary_permutation(f);
}

출력 Slice = m![B % 4, B / 4, A % 4, A / 4]는 입력 Slice 형태 [0, 1, 2, 3]을 [3, 2, 1, 0]으로 치환합니다. 이 순열은 어떤 정규 구성으로도 다룰 수 없지만 사용자 정의 비트맵으로는 다룰 수 있습니다.

비트맵 인덱스(B % 4, B / 4, A % 4, A / 4)(A, B)링 그룹
0(0, 0, 0, 0)(0, 0)0
1(0, 0, 0, 1)(4, 0)64
2(0, 0, 0, 2)(8, 0)128
3(0, 0, 0, 3)(12, 0)192
4(0, 0, 1, 0)(0, 1)1
5(0, 0, 1, 1)(4, 1)65
…………
255(3, 3, 3, 3)(15, 15)255

사이클 공식에 대입하면 cycles ≈ ring_size × Time::SIZE × flits_per_packet = 256 × 8 × 8 = 16384이며, 각 값은 다음과 같습니다:

  • ring_size = 256
  • Time::SIZE = C::SIZE = 8
  • flits_per_packet = sizeof(f32) × Packet::SIZE / 32 = 4 × 64 / 32 = 8 (Packet = m![D, E], D::SIZE × E::SIZE = 64)

최대값인 ring_size = 256이 필요한 이유는 이 순열이 반복 구조 없이 모든 Slice에 걸친 의존성을 만들기 때문입니다. 따라서 입력 Slice와 출력 Slice는 링 인덱스상에서 임의로 멀리 떨어져 있을 수 있으며, 더 작은 서브링으로는 그러한 쌍 중 적어도 하나를 포괄하지 못합니다.

예제 2: 다차원 브로드캐스트

예제 1의 순수 순열과 달리, 이 예제는 연속하지 않는 두 차원(A % 2와 B % 2)을 Slice에서 Time으로 옮기고 원래 위치에서는 브로드캐스트합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 16, B = 16, C = 8, D = 8, E = 8, X = 2, Y = 2];

fn multi_axis_broadcast<'l, const T: Tu>(
    input: FetchTensor<'l, T, f32, m![1], m![1 # 2], m![A, B], m![C], m![D, E]>,
) -> SwitchTensor<'l, T, f32, m![1], m![1 # 2], m![A / 2, X, B / 2, Y], m![C, A % 2, B % 2], m![D, E]> {
    input.switch::<m![A / 2, X, B / 2, Y], m![C, A % 2, B % 2]>(
        SwitchConfig::CustomBroadcast { ring_size: 32 }
    )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, f32, m![1], m![1 # 2], m![A, B], m![C], m![D, E]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = multi_axis_broadcast(f);
}

출력은 A % 2와 B % 2를 Slice에서 Time으로 옮기고, 브로드캐스트 차원 X와 Y를 통해 원래 위치에서 브로드캐스트합니다. Broadcast01도 비슷한 형태를 지원하지만 브로드캐스트 차원(slice0, slice1)이 입력 Slice에서 연속해야 하므로, 연속하지 않는 차원이 Slice에서 Time으로 이동하는 경우는 표현할 수 없습니다. 대신 사용자 정의 비트맵으로 이를 표현합니다.

비트맵 인덱스(A / 2, A % 2, B / 2, B % 2)(A, B)링 그룹
0(0, 0, 0, 0), (0, 0, 0, 1), (0, 1, 0, 0), (0, 1, 0, 1)(0, 0), (0, 1), (1, 0), (1, 1)0, 1, 16, 17
1(0, 0, 0, 0), (0, 0, 0, 1), (0, 1, 0, 0), (0, 1, 0, 1)(0, 0), (0, 1), (1, 0), (1, 1)0, 1, 16, 17
2(0, 0, 1, 0), (0, 0, 1, 1), (0, 1, 1, 0), (0, 1, 1, 1)(0, 2), (0, 3), (1, 2), (1, 3)2, 3, 18, 19
3(0, 0, 1, 0), (0, 0, 1, 1), (0, 1, 1, 0), (0, 1, 1, 1)(0, 2), (0, 3), (1, 2), (1, 3)2, 3, 18, 19
…………
255(7, 0, 7, 0), (7, 0, 7, 1), (7, 1, 7, 0), (7, 1, 7, 1)(14, 14), (14, 15), (15, 14), (15, 15)238, 239, 254, 255

사이클 공식 cycles ≈ ring_size × Time::SIZE × flits_per_packet = 32 × 8 × 8 = 2048에 대입하면 다음과 같습니다.

  • ring_size = 32(가장 바깥쪽 A / 2 분할은 서브링 간 교환이 필요 없으므로 각 서브링 안에서 가장 안쪽 32개 Slice끼리만 통신합니다)
  • Time::SIZE = 8
  • flits_per_packet = sizeof(f32) × Packet::SIZE / 32 = 4 × 64 / 32 = 8(Packet = m![D, E], D::SIZE × E::SIZE = 64)

예제 3: 부분 축 추출(슬라이싱)

이동하는 차원의 모든 값을 포함하는 예제 1, 2와 달리, 여기서는 C # 4의 유효한 값 3개만 Slice에서 Time으로 이동하고 패딩 셀은 그대로 남습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 16, B = 4, C = 3, D = 8, E = 8, X = 4];

fn partial_axis_extraction<'l, const T: Tu>(
    input: FetchTensor<'l, T, f32, m![1], m![1 # 2], m![A, B, C # 4], m![D], m![E]>,
) -> SwitchTensor<'l, T, f32, m![1], m![1 # 2], m![A, B, X], m![D, C], m![E]> {
    input.switch::<m![A, B, X], m![D, C]>(
        SwitchConfig::CustomBroadcast { ring_size: 4 }
    )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let f: FetchTensor<'_, _, f32, m![1], m![1 # 2], m![A, B, C # 4], m![D], m![E]> = FetchTensor::new(&mut device.main, Tensor::zero());
let _o = partial_axis_extraction(f);
}

출력은 C # 4를 Slice에서 Time으로 옮기고 비워진 Slice 위치에 브로드캐스트 축 X를 배치합니다. 순수한 패딩 셀인 네 번째 값(C # 4 = 3)은 버려지므로 유효한 값 3개인 C만 추출됩니다. Broadcast1도 비슷한 형태를 지원하지만 항상 차원 전체를 옮기므로 부분 집합을 표현할 수 없습니다.

이 부분 추출이 허용되는 이유는 버려지는 값이 패딩이기 때문입니다. C # 4는 4로 패딩된 슬롯에 C = 3개의 유효한 요소를 담고 있으므로, 이를 C로 슬라이싱하여 줄여도(C # 4 → C) 실제 값은 전혀 버려지지 않습니다. 반면 유효한 범위를 슬라이싱하면 살아 있는 입력이 버려지며, 이는 버려지는 값은 패딩이어야 함 제약 조건에 의해 금지됩니다. 대신 이처럼 패딩만 제거하는 추출은 사용자 정의 비트맵으로 표현합니다.

비트맵 인덱스(A, B, C # 4) 소스링 그룹
0(0, 0, 0), (0, 0, 1), (0, 0, 2)0, 1, 2
1(0, 0, 0), (0, 0, 1), (0, 0, 2)0, 1, 2
2(0, 0, 0), (0, 0, 1), (0, 0, 2)0, 1, 2
3(0, 0, 0), (0, 0, 1), (0, 0, 2)0, 1, 2
4(0, 1, 0), (0, 1, 1), (0, 1, 2)4, 5, 6
………
255(15, 3, 0), (15, 3, 1), (15, 3, 2)252, 253, 254

사이클 공식에 대입하면 cycles ≈ ring_size × Time::SIZE × flits_per_packet = 4 × 24 × 1 = 96입니다.

  • ring_size = 4(바깥쪽 A, B 분할은 서브링 간 교환이 필요 없으므로, 각 서브링 안에서 가장 안쪽의 Slice 4개(C # 4 그룹 하나)만 통신합니다)
  • Time::SIZE = D::SIZE × C::SIZE = 8 × 3 = 24
  • flits_per_packet = sizeof(f32) × Packet::SIZE / 32 = 4 × 8 / 32 = 1(Packet = m![E], E::SIZE = 8)

비트맵을 보면 패딩만 걸러 내는 추출 방식이 그대로 드러납니다. bitmap[0] = {0, 1, 2}는 출력 Slice 0이 자신의 C # 4 그룹에 있는 유효한 입력 Slice 3개에서 데이터를 받고, 패딩 셀인 인덱스 3은 건너뛴다는 뜻입니다. {0, 1, 2, 3}을 읽으면 그 패딩을 실제 데이터처럼 가져오게 됩니다.

제약 조건

커스텀 구성에는 이러한 유연성을 제한하는 제약 조건 일곱 가지가 있습니다.

브로드캐스트 축은 새 축이어야 합니다

정규 구성 소개 부분과 같은 규칙입니다. 출력 Slice에 새로 추가하는 각 브로드캐스트 축은 입력 Slice나 입력 Time에 나타나면 안 됩니다.

예를 들어 axes![A = 256, B = 64, C = 32]이고 입력이 Slice = m![A], Time = m![B], Packet = m![C # 32]일 때, 출력 Slice = m![A / 4, B / 32, A % 4]는 B가 이미 입력 Time에 나타나므로 이 제약 조건을 위반합니다.

각 브로드캐스트 축은 정확히 한 번만 사용해야 합니다

각 브로드캐스트 축은 출력 Slice에 정확히 한 번만 나타나야 합니다. 같은 축을 출력의 두 위치에 반복하면 라우팅 비트맵에서 그 의미가 정의되지 않습니다.

예를 들어 출력 Slice = m![A / 4, X, X](X는 두 번 사용된 새 축)는 이 제약 조건을 위반합니다.

브로드캐스트 축은 패딩될 수 없습니다

출력 Slice의 브로드캐스트 축에는 패딩이 있어서는 안 됩니다(Axis # N 형태 불가). 브로드캐스트 축에 패딩이 있으면 패딩된 위치의 라우팅 목적지가 정의되지 않은 상태로 남습니다.

예를 들어 출력 Slice = m![A / 4, X # 4, Y]는 X가 패딩이 있는 브로드캐스트 축이므로 이 제약 조건을 위반합니다.

순서 보존

Slice에서 Time으로 이동하는 축은 입력 Slice 차원에서의 상대 순서를 유지해야 하며, 그렇지 않으면 SwitchConfig::CustomBroadcast의 검증기가 커널 컴파일 시점에 패닉을 일으킵니다. 각 라우터의 버퍼는 최소한(패킷 하나)이므로 로컬로 출력할지 전달할지를 즉시 결정해야 하며, 여러 패킷을 버퍼에 담아 순서를 바꿀 여지가 없습니다.

예를 들어 axes![A = 16, B = 16, C = 8, D = 8, E = 8]이고 dtype = i8일 때 입력 Slice = m![A, B], Time = m![C], Packet = m![D, E]를 출력 Slice = m![A, B / 4, 4], Time = m![C, B % 2, B / 2], Packet = m![D, E]로 매핑하면 이 제약 조건을 위반합니다. 여기서 B % 2와 B / 2는 입력 Slice에서의 배치와 비교해 순서가 뒤바뀌어 있습니다. 출력 Time = m![C, B / 2, B % 2]는 B / 2, B % 2가 입력 순서와 일치하므로 유효합니다.

가장 안쪽 시간 위치

Slice에서 Time으로 이동하는 축은 출력 Time의 가장 안쪽 위치를 차지해야 합니다. 파이프라인에서 다른 Slice의 데이터는 각 패킷 안에서 마지막에 도착하므로, Slice에서 Time으로 이동하는 하위 차원은 자연스럽게 가장 안쪽 시간 차원에 놓입니다. 이들을 다른 위치에 두려면 전체 시간 시퀀스를 버퍼링하고 재정렬해야 하는데, 하드웨어는 이를 수행할 수 없습니다.

예를 들어 위와 같은 axes!와 dtype에서 입력 Slice = m![A, B], Time = m![C], Packet = m![D, E]를 출력 Slice = m![A / 2, 2, B / 2, 2], Time = m![A % 2, C, B % 2], Packet = m![D, E]로 매핑하면 이 제약 조건을 위반합니다. 여기서 A % 2와 B % 2는 상대적인 순서를 올바르게 유지하지만, 그 사이에 C가 끼어 있습니다.

Note

Broadcast01은 time0 파라미터를 통해 이 제약 조건을 우회하지만, 사용자 정의 구성에는 그러한 메커니즘이 없으므로 이 제약 조건을 엄격히 따라야 합니다.

버려지는 값은 패딩이어야 함

어떤 차원에서 Slice에서 Time으로 이동하는 값의 개수가 그 차원의 전체 크기보다 적을 때(예시 3과 같은 부분 추출), 남겨지는 값은 모두 패딩이어야 합니다. 유효한 값을 버리면 실제 입력이 아무런 경고 없이 폐기되므로, 검증기는 커널 컴파일 시점에 이를 거부합니다.

예를 들어 axes![A = 16, B = 4, C = 3, D = 8, E = 8, X = 4]이고 입력이 Slice = m![A, B, C # 4]일 때, C # 4 → C로 추출하는 것은 버려지는 네 번째 값이 패딩 셀이므로 허용됩니다. 반면 완전히 유효한 축을 슬라이싱하는 것, 예를 들어 B = 4 → B = 3은 버려지는 값이 실제 데이터를 담고 있으므로 이 제약 조건을 위반합니다.

링 크기

ring_size 파라미터는 2의 거듭제곱이어야 합니다. 또한 컴파일러는 입력/출력 매핑(가장 바깥쪽의 non-direct-cast 경계)에서 예상 ring_size를 도출하며, 이와 일치하지 않는 사용자 지정 값은 거부합니다.

Collect Engine

모든 다운스트림 엔진(Contraction Engine, Vector Engine, Cast Engine, Transpose Engine, Commit Engine)은 정확히 32바이트 크기의 플릿(flit)을 소비합니다. Collect Engine은 임의 크기의 패킷을 두 단계를 거쳐 하나의 플릿으로 정규화합니다.

  1. 입력 패킷을 다음 32바이트 경계까지 패딩합니다. 패킷이 이미 32바이트로 정렬되어 있으면 이 단계를 건너뜁니다.
  2. 플릿 경계에서 분할합니다. 안쪽 32바이트는 Packet2가 되고, 바깥쪽 플릿 수는 Time2에 흡수됩니다. 패킷이 이미 32바이트이면 이 단계를 건너뜁니다.

그 결과로 생성된 CollectTensor는 파이프라인을 따라 다운스트림 엔진으로 전달되거나 Register File에 저장됩니다.

인터페이스

SwitchTensor와 FetchTensor는 모두 동일한 의미의 .collect()를 제공합니다. Slice 분배가 필요하지 않을 때 FetchTensor 진입점은 Switch Engine을 우회합니다.

impl<'l, const T: Tu, P: CanApplyCollect, D: Scalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Normalizes packet to exactly 32 bytes (one flit).
    ///
    /// Pads to flit-aligned boundary, then splits: inner 32 bytes become
    /// `Packet2`, outer flit portion is absorbed into `Time2`. For packets
    /// already ≤ 32 bytes, only padding is added.
    #[primitive(TuTensor::collect)]
    pub fn collect<Time2: M, Packet2: M>(self) -> CollectTensor<'l, T, D, Chip, Cluster, Slice, Time2, Packet2, B> {
        verify_collect::<D, Time, Packet, Time2, Packet2>();
        CollectTensor::new(self.device, self.inner.transpose(false))
    }
}

예제

단일 플릿 패킷

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 32];

fn collect_identity<'l, const T: Tu>(
    input: SwitchTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B]>,
) -> CollectTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B # 32]> {
    // B=32 elements × 1 byte (i8) = 32 bytes = one flit.
    // Time and Packet pass through unchanged.
    input.collect()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: SwitchTensor<'_, _, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B]> = SwitchTensor::new(&mut device.main, Tensor::zero());
let _o = collect_identity(c);
}

입력 패킷이 이미 정확히 32바이트이면 collect는 패킷을 변경 없이 그대로 통과시킵니다(i8의 경우 B = 32개 요소 × 1바이트 = 32바이트).

Before:   Time = m![A]
          Packet = m![B]
          ┌──────────────────────────┐
          │            B             │  32 bytes
          └──────────────────────────┘

After:    Time = m![A]
          Packet = m![B # 32]
          ┌──────────────────────────┐
          │          B # 32          │  32 bytes
          └──────────────────────────┘

플릿 미만 패킷

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 16];

fn collect_padding<'l, const T: Tu>(
    input: SwitchTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B]>,
) -> CollectTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B # 32]> {
    // B=16 elements × 1 byte = 16 bytes < 32 bytes.
    // Padded to 32 bytes: Packet2 = m![B # 32].
    // Time unchanged since it fits in one flit.
    input.collect()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: SwitchTensor<'_, _, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B]> = SwitchTensor::new(&mut device.main, Tensor::zero());
let _o = collect_padding(c);
}

입력 패킷이 32바이트보다 작으면 collect는 32바이트까지 패딩합니다(i8의 경우 B = 16개 요소 × 1바이트 = 16바이트).

Before:   Time = m![A]
          Packet = m![B]
          ┌────────────┐
          │     B      │  16 bytes
          └────────────┘

After:    Time = m![A]
          Packet = m![B # 32]
          ┌────────────┬─────────────┐
          │     B      │     pad     │  32 bytes
          └────────────┴─────────────┘

다중 플릿 패킷

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 32];

fn collect_multi_flit<'l, const T: Tu>(
    input: SwitchTensor<'l, T, bf16, m![1], m![1 # 2], m![1 # 256], m![A], m![B]>,
) -> CollectTensor<'l, T, bf16, m![1], m![1 # 2], m![1 # 256], m![A, B / 16], m![B % 16]> {
    // B=32 elements × 2 bytes (bf16) = 64 bytes = 2 flits.
    // Inner 16 elements = 32 bytes → Packet2 = m![B % 16].
    // Outer 2 flits → absorbed into Time2 = m![A, B / 16].
    input.collect()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: SwitchTensor<'_, _, bf16, m![1], m![1 # 2], m![1 # 256], m![A], m![B]> = SwitchTensor::new(&mut device.main, Tensor::zero());
let _o = collect_multi_flit(c);
}

입력 패킷이 32바이트를 초과하면 collect는 패킷을 플릿 단위로 분할하고 바깥쪽 플릿 수를 Time에 흡수합니다(bf16의 경우 B = 32개 요소 × 2바이트 = 64바이트이므로 B / 16 = 2개 플릿).

Before:   Time = m![A]
          Packet = m![B]
          ┌──────────────────────────┬──────────────────────────┐
          │       B / 16 == 0        │       B / 16 == 1        │  64 bytes
          └──────────────────────────┴──────────────────────────┘
                    32 bytes                   32 bytes

After:    Time = m![A, B / 16]
          Packet = m![B % 16]
          ┌──────────────────────────┐
          │          B % 16          │  32 bytes  × B/16 time steps
          └──────────────────────────┘

패딩이 있는 다중 플릿 패킷

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, B = 56];

fn collect_multi_flit_padded<'l, const T: Tu>(
    input: SwitchTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B]>,
) -> CollectTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![A, B # 64 / 32], m![B # 64 % 32]> {
    // B is not 32-byte aligned; first pad B to a multiple of 32 bytes.
    // B # 64=64 elements × 1 byte (i8) = 64 bytes = 2 flits.
    // Inner 32 elements = 32 bytes → Packet2 = m![B # 64 % 32].
    // Outer 2 flits → absorbed into Time2 = m![A, B # 64 / 32].
    input.collect()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: SwitchTensor<'_, _, i8, m![1], m![1 # 2], m![1 # 256], m![A], m![B]> = SwitchTensor::new(&mut device.main, Tensor::zero());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { collect_multi_flit_padded(c) }));
}

입력 패킷이 32바이트로 정렬되어 있지 않으면 먼저 패딩됩니다(i8의 경우 B = 51개 요소 × 1바이트 = 51바이트이며, 64바이트로 패딩). 그런 다음 collect는 패킷을 플릿 단위로 분할하고 바깥쪽 플릿 수(B # 64 / 32 = 2)를 Time에 흡수합니다.

Before:   Time = m![A]
          Packet = m![B]
          ┌──────────────────────────┬───────────────┐
          │       B / 32 == 0        │  B / 32 == 1  │  51 bytes
          └──────────────────────────┴───────────────┘
                    32 bytes             19 bytes

Padded:   Time = m![A]
          Packet = m![B # 64]
          ┌──────────────────────────┬───────────────┬──────────┐
          │       B / 32 == 0        │  B / 32 == 1  │   pad    │  64 bytes
          └──────────────────────────┴───────────────┴──────────┘
                    32 bytes                   32 bytes

After:    Time = m![A, B # 64 / 32]
          Packet = m![B # 64 % 32]
          ┌──────────────────────────┐
          │       B # 64 % 32        │  32 bytes  × B # 64 / 32 time steps
          └──────────────────────────┘

Register File 로딩

정규화 후에는 .to_trf()로 CollectTensor를 Tensor Register File에 저장하거나 .to_vrf()로 Vector Register File에 저장하십시오. 해당 API, 영역, 용량, 주소 모드, 예제는 Register File 장에서 다룹니다.

Register File

Collect Engine은 Contraction Engine과 Vector Engine으로 데이터를 스트리밍합니다. 두 엔진은 Slice마다 하나씩 있는 Register File에서도 입력을 받습니다. Tensor Register File(TRF)은 Contraction Engine에 입력을 공급하고, Vector Register File(VRF)은 Vector Engine에 입력을 공급합니다. 이 Register File들은 이를 소비하는 엔진이 실행되기 전에 채워져 있어야 합니다.

Tensor Register File

인터페이스

TrfTensor는 TRF에 저장된 텐서입니다:

/// Tensor stored in the tensor register file.
#[primitive(TrfTensor)]
#[derive(Debug)]
pub struct TrfTensor<D: Scalar, Chip: M, Cluster: M, Slice: M, Lane: M, Element: M, B: Backend = CurrentBackend> {
    pub(crate) inner: Tensor<D, Pair<Chip, Pair<Cluster, Pair<Slice, Pair<Lane, Element>>>>, B>,
    _marker: PhantomData<(D, Chip, Cluster, Slice, Lane, Element)>,
}

Chip / Cluster / Slice는 소스에서 그대로 전달됩니다. Lane은 공간 병렬성(활성 레인 1, 2, 4 또는 8개)을 인덱싱합니다. Element는 레인별 레이아웃을 담습니다.

Collect Engine에서

CollectTensor에 .to_trf::<Lane, Element>()를 호출하면 TrfTensor가 생성됩니다. 데이터가 Register File의 어느 위치에 놓일지는 컴파일러가 결정하므로, 호출에서는 영역을 지정하지 않습니다:

impl<'l, const T: Tu, P: CanApplyToTrf, D: Scalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Stores to the tensor register file.
    #[primitive(TuTensor::to_trf)]
    pub fn to_trf<Lane: M, Element: M>(self) -> TrfTensor<D, Chip, Cluster, Slice, Lane, Element, B> {
        verify_to_trf::<D, Lane, Time, Packet, Element>();
        TrfTensor::new(self.inner.transpose(false))
    }
}

.to_trf는 스트리밍되는 Time / Packet을 Lane / Element로 재구성합니다:

Lane    = Time / FlitsPerLane
Element = [Time % FlitsPerLane, Packet]

여기서 FlitsPerLane은 컴파일러가 Lane과 Time으로부터 도출하는 값이며, 각 레인은 FlitsPerLane개의 연속된 플릿으로 채워집니다.

다음의 간단한 사례는 TRF 전체를 점유합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 32];

fn load_trf<'l, const T: Tu>(
    input: CollectTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![1], m![B]>,
) -> TrfTensor<i8, m![1], m![1 # 2], m![1 # 256], m![1], m![B]> {
    input.to_trf()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i8, m![1], m![1 # 2], m![1 # 256], m![1], m![B]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = load_trf(c);
}

예를 들어 matmul 커널에서 Lane은 출력 채널을 담고 Element는 축약되는 축을 담습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![V = 32, M = 32, N = 8, K = 32];

type Chip    = m![1];
type Cluster = m![V / 16];
type Slice   = m![V % 16 # 256];
type Lane    = m![N];

/// Stores matmul weights into TRF for consumption by `bmatmul` in
/// [Contraction Engine: Example: Batched MatMul](./contraction-engine/index.md#example-batched-matmul).
fn store_bmatmul_trf<'l, const T: Tu>(
    input: CollectTensor<'l, T, bf16, Chip, Cluster, Slice, m![N, K / 16], m![K % 16]>,
) -> TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![K]> {
    input.to_trf()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, bf16, Chip, Cluster, Slice, m![N, K / 16], m![K % 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = store_bmatmul_trf(c);
}

Vector Engine에서

저장이 반드시 collect에 이어서 이루어져야 하는 것은 아닙니다. 메인 컨텍스트 스트림은 Vector Engine을 먼저 실행한 뒤 그 패스가 계산한 결과를 저장할 수 있습니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 64];

fn store_pass(device: &mut Device) -> VrfTensor<f32, m![1], m![1 # 2], m![1 # 256], m![B]> {
    let dm: DmTensor<f32, m![1], m![1 # 2], m![1 # 256], m![B]> = DmTensor::new();
    device.main
        .begin(dm.view())
        .fetch::<m![1], m![B]>()
        .collect::<m![B / 8], m![B % 8]>()
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        .vector_narrow_split::<m![B / 8, B % 8 / 4 % 2], m![B % 4]>()
        .vector_fp_unary(FpUnaryOp::Sqrt)
        .vector_widen_concat::<m![B / 8], m![B % 8]>()
        .vector_final()
        .to_vrf(&mut device.sub)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let _o = store_pass(&mut device);
}

두 위치는 하나의 경로를 두 가지로 표기한 것이 아니라 서로 다른 데이터 경로입니다. collect에서 바로 저장하면 Fetch Network가 플릿을 Register File로 넘기며, 그 사이에 실행되는 엔진은 없습니다. vector_final에 이어 저장하면 패스가 Vector Engine 자체의 쓰기 포트에서 끝나므로, 레지스터에 놓이는 값은 패스가 계산한 값입니다. Element2는 패스 이후의 Time / Packet을 평탄화하며, 이는 도중에 reduce나 widen에 의해 형태가 바뀌었을 수 있습니다.

DM 직접 저장과 collect 위치 저장은 모두 Data Memory를 읽으므로, 계산된 값이 VRF에 도달하는 방법은 이것뿐입니다. 같은 명령에서 Vector Engine 이후에는 아무것도 실행될 수 없으므로, 저장을 cast, transpose 또는 commit과 결합할 수 없습니다.

저장이 점유하는 컨텍스트

main 컨텍스트에서 발행된 저장은 위의 두 위치 중 어느 쪽을 택하든 sub 컨텍스트를 통해 쓰기를 실행합니다. 따라서 두 컨텍스트를 모두 점유하며 &mut device.sub도 함께 받습니다. device.main과 device.sub는 별개의 필드이므로, &mut Device를 보유한 커널은 위 예제처럼 두 필드를 동시에 빌릴 수 있습니다.

빌림은 해당 문(statement)이 끝날 때까지 유지되며, 이것이 점유의 범위입니다. 명령이 완료되면 sub 컨텍스트는 다시 사용 가능해집니다. 레지스터가 얼마나 오래 점유되는지는 별개의 문제이며 답도 다릅니다(피연산자를 마지막으로 읽을 때까지). 이 할당은 컴파일러가 결정하므로, 반환된 VrfTensor는 빌림을 갖지 않습니다.

sub 컨텍스트에서 발행된 저장은 이미 해당 컨텍스트를 보유하고 있으므로 인수가 필요하지 않습니다.

Data Memory에서

완전히 연속적인 입력 접근(간격이나 재정렬 없음)의 경우, TRF는 단축 명령(StoTRF)을 지원합니다. 이는 전체 Fetch → Switch → Collect → to_trf() 파이프라인을 거치지 않고 Data Memory에서 TRF로 데이터를 직접 로드하는 간결한 하드웨어 명령입니다. 이 단축 경로는 임의 레이아웃 지원을 포기하는 대신 설정 오버헤드를 줄입니다.

Contraction Engine으로

각 읽기는 8개 레인 × (1개 또는 2개) 뱅크 × 1개 행 × 뱅크당 320비트를 다루며, 좁은 읽기(뱅크 1개)에서는 사이클당 레인마다 320비트를, 넓은 읽기(뱅크 2개)에서는 사이클당 레인마다 640비트를 만들어 냅니다. 모든 활성 레인은 같은 행의 뱅크 하나 또는 둘 모두에 병렬로 접근합니다(좁은 읽기는 뱅크 하나, 넓은 읽기는 두 뱅크 모두). 8개 lane 전체를 Slice 단위로 합산하면 사이클당 320바이트(좁은 읽기) 또는 사이클당 640바이트(넓은 읽기)입니다. Sequencer가 이러한 읽기를 row와 브로드캐스트에 걸쳐 어떻게 반복하는지는 TRF Sequencer를 참조하십시오.

아키텍처

TRF는 Slice당 8개 레인 × 2개 뱅크 × 128개 행 × 320비트 = 80 KB 구조를 가진 뱅크 방식 SRAM입니다. 8개 lane은 병렬로 동작하며, 접근마다 1개, 2개, 4개 또는 8개가 활성화됩니다.

320비트 row 하나에 몇 개의 요소가 들어가는지는 데이터 타입에 따라 달라집니다.

타입저장되는 요소 크기row당 요소 수
i4 → i5 (하드웨어 동작이지만 아직 연결되어 있지 않으므로 현재 i4 가중치는 i4로 유지됩니다)5비트64
i8 → i99비트 (근삿값이며, 320비트 row에 맞도록 올림됩니다)32
i8 / f88비트32 (row당 40바이트)
bf1616비트16 (row당 32바이트)

저장 시 승격되는 것은 정수뿐입니다. i4 → i5(5비트)와 i8 → i9(9비트)는 Fetch Adapter의 선택적 영점 차감을 위한 여유를 남겨 두며, 이 차감은 정수 가중치를 1비트 넓힙니다. i8 / f8과 bf16은 원래 너비(각각 8비트와 16비트)를 유지합니다. 320비트 행은 8비트 또는 16비트를 빈틈없이 패킹하는 경우보다 여유 공간을 더 가지므로, 같은 물리적 행 너비로 모든 타입을 처리할 수 있습니다.

활성 lane이 8개보다 적으면 각 활성 lane은 더 많은 row를 보게 됩니다(마치 row 수가 늘어난 것과 같습니다). 활성 레인 수를 절반으로 줄이면 활성 레인당 행 수가 두 배가 됩니다(예: 활성 4개 → 뱅크당 256개 행, 활성 1개 → 1024개 행).

더블 버퍼링

TRF는 각 뱅크를 두 절반으로 나누어 더블 버퍼링을 가능하게 합니다. TRF Sequencer가 한쪽 절반에서 로드하는 동안 저장이 다른 쪽 절반을 채우며, 반복 사이에 두 절반을 서로 바꿀 수 있습니다. 세 가지 주소 모드가 영역을 지정하며, 스케줄러는 저장 시점에 그중 하나를 고릅니다. Full은 뱅크당 128개 행 전체를, FirstHalf는 0–63번 행을, SecondHalf는 64–127번 행을 사용합니다. 절반 모드에서는 Slice당 용량이 40 KB로 제한됩니다.

main 컨텍스트와 sub 컨텍스트에 걸쳐 이 두 절반을 사용하는 튜닝 패턴은 스케줄링과 튜닝: 더블 버퍼링을 참조하십시오.

두 절반은 같은 뱅크를 공유하므로, 읽기와 쓰기는 서로 다른 행을 대상으로 하더라도 뱅크 수준에서 경합합니다. 한 사이클에 둘이 같은 뱅크를 대상으로 하면 읽기가 우선합니다. 축약 파이프라인은 이번 사이클에 데이터가 필요하지만 저장은 기다릴 수 있기 때문입니다.

TRF는 읽기 캐시와 뱅크 교대로 이 경합을 완화합니다.

TRF 읽기는 재사용이 많습니다. 같은 데이터가 보통 여러 사이클에 걸쳐 브로드캐스트되므로, 직접 매핑 읽기 캐시(8 레인 × 2 뱅크 × 4 행 × 320비트 = 2.5 KB)가 뱅크 앞에 위치하여 반복되는 읽기를 흡수합니다. 캐시는 동시에 수행되는 저장과의 경합도 줄여 줍니다. 히트 시에는 읽기가 뱅크를 거치지 않으므로, 저장이 그 사이클에 뱅크를 사용할 수 있습니다. 미스 시에는 캐시가 뱅크에서 다시 채워지며, 그 사이클 동안 뱅크를 점유합니다.

좁은 읽기(≤ 32바이트)의 경우, 뱅크 교대가 두 번째 완화 수단을 더합니다. 읽기는 뱅크 하나만 사용하므로, 두 뱅크에 걸쳐 32바이트 단위로 교대할 수 있습니다. 그러면 연속된 사이클에서 읽기와 쓰기가 서로 다른 뱅크에 놓이게 되어, 캐시 미스가 나더라도 경합을 피합니다. 넓은 읽기(64바이트)는 매 사이클 두 뱅크를 모두 점유하므로 캐시 미스가 날 때마다 동시에 수행되는 저장이 막히지만, 좁은 읽기는 캐시 미스가 나더라도 절반 대역폭의 교대를 유지합니다.

Vector Register File

VRF는 Collect Engine에서 쓰이거나 Data Memory에서 직접 쓰이며, Vector Engine이 읽습니다.

인터페이스

VrfTensor는 VRF에 저장된 텐서입니다:

/// Tensor stored in the vector register file (VRF).
#[primitive(VrfTensor)]
#[derive(Debug, Clone)]
pub struct VrfTensor<D: VeScalar, Chip: M, Cluster: M, Slice: M, Element: M, B: Backend = CurrentBackend> {
    pub(crate) inner: Tensor<D, Pair<Chip, Pair<Cluster, Pair<Slice, Element>>>, B>,
    _marker: PhantomData<(D, Chip, Cluster, Slice, Element)>,
}

Chip / Cluster / Slice는 소스에서 그대로 전달됩니다. Element는 (Slice별) 레이아웃을 담습니다.

Vector Engine 연산은 이 세 가지를 기준으로 피연산자를 대조하므로, 피연산자가 공급 대상 스트림과 다른 분할로 전달되면 다른 Slice의 데이터를 읽는 대신 컴파일 오류가 발생합니다(Operands 참조). .reshape::<Chip2, Cluster2, Slice2, Element2>()는 같은 Slice들의 다른 분할로 피연산자를 다시 기술하며, 익명 브로드캐스트(m![256])로 복제된 피연산자가 이름 있는 축으로 분할된 스트림에 공급되는 방식이 바로 이것입니다. 이 메서드는 매핑의 레이블만 바꾸고 데이터는 옮기지 않으므로, DmTensor::reshape와 같은 이유로 unsafe입니다. 즉, 모든 물리적 위치가 새 매핑이 주장하는 값을 이미 담고 있어야 합니다. 축을 재그룹화하는 것과 브로드캐스트 분배 축에 이름을 붙이는 것이 이를 만족하는 두 가지 형태이며, VrfTensor::reshape의 # Safety 섹션에서 둘 다 설명합니다.

Collect Engine에서

CollectTensor에 대해 .to_vrf::<Element2>()를 호출하면 플릿을 VRF에 저장하고 VrfTensor를 생성합니다. 플릿이 Register File의 어디에 놓일지는 컴파일러가 결정하므로, 호출에서 주소를 지정하지 않습니다:

impl<'l, P: CanApplyToVrf, D: VeScalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, { Tu::Sub }, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Stores to the vector register file.
    #[primitive(TuTensor::to_vrf)]
    pub fn to_vrf<Element: M>(self) -> VrfTensor<D, Chip, Cluster, Slice, Element, B> {
        verify_to_vrf::<D, Time, Packet, Element>();
        VrfTensor::new(self.inner.transpose(false))
    }
}

impl<'l, P: CanApplyToVrf, D: VeScalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, { Tu::Main }, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Stores to the vector register file, occupying the sub context the write runs through.
    ///
    /// The borrow lasts the statement. How long the *register* stays occupied is the compiler's
    /// allocation to make, so the returned [`VrfTensor`] keeps no borrow.
    #[primitive(TuTensor::to_vrf_from_main)]
    pub fn to_vrf<Element: M>(
        self,
        _sub: &mut TuContext<{ Tu::Sub }>,
    ) -> VrfTensor<D, Chip, Cluster, Slice, Element, B> {
        verify_to_vrf::<D, Time, Packet, Element>();
        VrfTensor::new(self.inner.transpose(false))
    }
}

.to_vrf는 스트리밍되는 Time / Packet을 Element2로 평탄화합니다:

Element2 = [Time, Packet]

Element2는 사용자가 선택합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 64];

fn store_vrf<'l>(
    input: CollectTensor<'l, { Tu::Sub }, i32, m![1], m![1 # 2], m![1 # 256], m![B / 8], m![B % 8]>,
) -> VrfTensor<i32, m![1], m![1 # 2], m![1 # 256], m![B]> {
    input.to_vrf()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, { Tu::Sub }, i32, m![1], m![1 # 2], m![1 # 256], m![B / 8], m![B % 8]> = CollectTensor::new(&mut device.sub, Tensor::zero());
let _o = store_vrf(c);
}

저장은 컨텍스트마다 하나의 시그니처를 가지며, 위 예제는 sub 컨텍스트의 시그니처를 사용합니다. 메인 컨텍스트에서 발행한 저장은 device.sub를 인자로 받습니다. 쓰기 레지스터가 sub 컨텍스트의 레지스터 맵에 있기 때문입니다. 저장이 어느 컨텍스트를 점유하는가를 참고하십시오.

Data Memory에서

입력 접근이 완전히 연속적인 경우(간격이나 재정렬이 없는 경우), VRF는 짧은 명령(StoVRF)을 지원합니다. 이는 Fetch → Switch → Collect → to_vrf()로 이어지는 전체 파이프라인을 우회하여 Data Memory에서 VRF로 데이터를 직접 로드하는 간결한 하드웨어 명령어입니다. 이 지름길은 임의 레이아웃 지원을 포기하는 대신 설정 오버헤드를 줄입니다.

아키텍처

Contraction Engine

Contraction Engine은 matmul, convolution 같은 이항 텐서 축약을 수행합니다. 빠른 시작에서 다룬 내용을 다시 떠올려 보십시오:

  • 텐서 축약은 두 입력 텐서를 받아 두 텐서가 공유하는(축약되는) 축을 따라 리덕션합니다. 내적, GEMV, GEMM이 대표적인 예입니다.
  • 축약은 브로드캐스트, 곱셈, 리덕션의 세 단계로 분해됩니다.
  • 한 피연산자는 Collect Engine에서 스트리밍되고, 다른 피연산자는 TRF(Tensor Register File)에 저장되어 있습니다.
  • 축약은 메인 컨텍스트에서 실행됩니다. TRF 준비는 .to_trf()를 통해 sub 컨텍스트에서 실행됩니다.

아키텍처

네 개의 파이프라인 단계가 워크로드를 나누어 맡습니다. 하나는 브로드캐스트와 곱셈을, 세 개는 리덕션을 담당합니다. 각 단계는 다른 단계와 겹치지 않는 자신만의 차원을 처리합니다.

%%{init: {'flowchart': {'htmlLabels': true}, 'themeCSS': '.cluster-label .nodeLabel { font-size: 16px; font-weight: 600; }'}}%%
flowchart TB
    CO[Collect Engine] --> SA
    TRF[(TRF)] --> TS

    subgraph CE[Contraction Engine]
        direction TB
        subgraph BC[Outer]
            direction LR
            SA[Stream Adapter]
            TS[TRF Sequencer]
            MUL[Elementwise Multiply]
            SA --> MUL
            TS --> MUL
        end
        SC[Packet Reducer]
        TR[Time Reducer]
        RR[Lane Folder]
        MUL --> SC
        SC --> TR
        TR --> RR
    end

    RR --> VE[Vector Engine]

    click SA "./outer.html#stream-adapter" "Stream Adapter"
    click TS "./outer.html#trf-sequencer" "TRF Sequencer"
    click SC "./packet-reducer.html" "Packet Reducer"
    click TR "./time-reducer.html" "Time Reducer"
    click RR "./lane-folder.html" "Lane Folder"
    click CO "../collect-engine.html" "Collect Engine"
    click TRF "../register-files.html#tensor-register-file" "Tensor Register File"
    click VE "../vector-engine/index.html" "Vector Engine"
  • Outer (브로드캐스트와 곱셈): 두 피연산자를 일치하는 형태 [Chip, Cluster, Slice, Lane, Time, Packet]로 브로드캐스트하고 요소별로 곱하여 하나의 곱 텐서를 만듭니다. Chip / Cluster / Slice는 그대로 통과합니다. Lane은 TRF와 후속 리듀서가 공유하는 공간 병렬성을 인덱싱합니다. Time과 Packet은 함께 패킷 스트림을 나타냅니다. 세 개의 하위 단계가 직렬로 실행됩니다. Stream Adapter는 스트리밍 피연산자를 브로드캐스트하고, TRF Sequencer는 TRF 피연산자를 브로드캐스트하며, Multiplier는 축약 출력 타입으로 확장한 뒤(i4/i8 -> i32, f8/bf16 -> f32) 두 피연산자를 요소별로 곱합니다.
  • Packet Reducer (Packet 내부 리덕션): Packet에 매핑된 축약 축을 따라 병렬 트리로 리덕션하며, 레인마다 트리가 하나씩 있습니다.
  • Time Reducer (Time에 걸친 리덕션): 사이클별 결과를 공유 누산기에 누적합니다.
  • Lane Folder (Lane 폴딩): 버퍼를 출력 스트림으로 내보내며, 모드에 따라 Lane을 OutPacket 또는 OutTime에 흡수시킵니다. Slice 간 또는 Chip 간 리덕션은 후속 단계에서 Vector Engine이 처리합니다.

Outer 단계는 (RNGD에서) Lane ≤ 8 및 Packet ≤ 64 B로 제한됩니다. 자세한 내용은 Packet Reducer와 Time Reducer를 참고하십시오.

예제: Batched MatMul

빠른 시작에서는 내적, GEMV, GEMM을 차례로 살펴봅니다. 이 절은 batched matmul 변형에 대한 아키텍처 레퍼런스로서 실행 가능한 변형 코드 조각을 모두 한곳에 모아 둡니다. 엔드투엔드 입문 경로는 여전히 빠른 시작입니다. Batched matmul은 GEMM에 선행 배치 축 V를 추가하여 확장한 것입니다: \(VMK, KN \rightarrow VMN\). 커널은 V개의 독립적인 (M × K) 입력 각각과 공유 (K × N) 가중치로부터 (M × N) 곱을 생성합니다.

아래의 세 가지 변형은 어떤 축이 Time에 놓이는지에 따라 커널을 분류합니다. 나머지 축은 공간 병렬성으로 활용됩니다. 세 변형은 다음 축을 공유합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![V = 32, M = 32, N = 8, K = 32];   // V batch, M×N output, K contraction
}

Time에 놓인 K

K(축약 축)는 Time에 놓입니다. M은 Cluster와 Slice에 걸쳐 분할되고 V도 분할됩니다. V % 16은 Slice에 합류하고 V / 16 = 2는 K와 함께 Time에 합류합니다(V × M = 1024는 RNGD의 Chip당 공간 셀 512개에 들어가지 않으므로 V의 바깥쪽 청크는 반복되어야 합니다). Packet은 1 # 32로 패딩되고, 리덕션은 Packet Reducer의 공간 트리를 거치지 않고 사이클에 걸쳐 순차적으로 진행됩니다. 따라서 사이클마다 32개의 곱셈기 중 1개만 유효한 작업을 수행합니다(bf16 기준 MAC 활용률 1/32). 결과적으로 퇴화된 커널이 되며, 이 커널은 교육용 기준선으로만 제시합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![V = 32, M = 32, N = 8, K = 32];   // V batch, M×N output, K contraction

type Chip    = m![1];                   // single chip
type Cluster = m![M / 16];              // outer M split across clusters (M / 16 = 2)
type Slice   = m![M % 16, V % 16];      // inner M × inner V = 16 × 16 = 256 slices per cluster
type Lane    = m![N];                   // N (output channels) partitions the 8 hardware lanes

/// Batched matmul with K placed in Time.
fn bmatmul_k_in_time<'l, const T: Tu>(
    // Streaming operand: V outer + K in Time, with a one-element Packet m![1].
    input: CollectTensor<'l, T, bf16, Chip, Cluster, Slice, m![V / 16, K], m![1 # 16]>,
    // TRF operand: N in Lane, K in Element. Stored into TRF by a prior .to_trf() call.
    trf: &TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![K]>,
    // Output: one (M × N) f32 matrix per (slice, V-outer) pair.
) -> ContractTensor<'l, T, f32, Chip, Cluster, Slice, m![V / 16], m![N]> {
    input
         // Outer: Lane = m![N] (inferred from trf), OutTime = m![V / 16, K], OutPacket = m![1 # 32].
         // input: 1 K-element broadcast across all N lanes.
         // trf:   1 K-element per lane, advancing one K-step per cycle.
         .contract_outer::<m![V / 16, K], m![1 # 16], _, _, _>(trf)
         // Packet Reducer: OutPacket = m![1]. Nothing to reduce.
         .contract_packet::<m![1]>()
         // Time Reducer: OutTime = m![V / 16]. K iterates over Time and accumulates; V outer survives.
         .contract_time::<m![V / 16]>()
         // Lane Folder: Lane folds into OutPacket. Interleaved mode emits 8 lanes per cycle.
         .contract_lane::<m![V / 16], m![N]>(LaneMode::Interleaved)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, Chip, Cluster, Slice, m![V / 16, K], m![1 # 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![K]> = TrfTensor::zero();
let _o = bmatmul_k_in_time(a, &b);
}

이러한 병리적 사례를 피하려면 K를 Packet에 유지하고(Packet Reducer의 트리를 통한 병렬 리덕션) 남은 축(V, M, N)을 Cluster, Slice, Lane에 분산하여 공간 병렬성을 극대화하십시오. 아래의 두 전략은 이 원칙을 적용하며, 단순화를 위해 클래스마다 축을 하나씩 배치합니다. 실제 커널에서는 크기상 필요한 경우 하나의 축을 여러 클래스에 걸쳐 분할할 수 있습니다.

M을 Time에 배치

V(배치)는 Cluster와 Slice에 분산됩니다(Slice당 배치 요소 하나). M은 Time에, K는 Packet에 배치합니다. 이 전략은 (1) Slice 수가 배치를 감당할 수 있고, (2) N이 Lane에 들어가며, (3) K가 단일 Packet에 들어갈 때 적용할 수 있습니다. K가 Packet보다 크면 K를 Packet(공간)과 Time(시간)에 걸쳐 분할하십시오. 이 전략은 Lane 전반의 MAC 활용도를 극대화합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![V = 32, M = 32, N = 8, K = 32];   // V batch, M×N output, K contraction

type Chip    = m![1];                   // single chip
type Cluster = m![V / 16];              // outer V split across clusters (V / 16 = 2)
type Slice   = m![V % 16 # 256];        // inner V split across slices (V % 16 = 16 per cluster)
type Lane    = m![N];                   // N (output channels) partitions the 8 hardware lanes (N = 8 fills the cap)

/// Batched matmul: V slices × (M × K) · (K × N) → V × M × N.
fn bmatmul_m_in_time<'l, const T: Tu>(
    // Streaming operand: M in Time, K in Packet.
    // Element type can be i4, i8, f8, or bf16; integers widen to i32 output, floats to f32.
    input: CollectTensor<'l, T, bf16, Chip, Cluster, Slice, m![M, K / 16], m![K % 16]>,
    // TRF operand: N in Lane (one output channel per lane), K in Element.
    // Stored into TRF by a prior .to_trf() call in the sub context.
    trf: &TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![K]>,
    // Output: one (M × N) f32 matrix per slice.
) -> ContractTensor<'l, T, f32, Chip, Cluster, Slice, m![M], m![N]> {
    input
         // Outer: broadcast input and trf, multiply elementwise.
         // Lane = m![N] (inferred from trf), OutTime = m![M], OutPacket = m![K].
         // input: K elements broadcast across all N lanes.
         // trf:   K elements per lane, broadcast across all M cycles.
         .contract_outer::<m![M], m![K], _, _, _>(trf)
         // Packet Reducer: OutPacket = m![1]. Sum K spatially via the reduction tree.
         .contract_packet::<m![1]>()
         // Time Reducer: OutTime = m![M]. Nothing to reduce.
         .contract_time::<m![M]>()
         // Lane Folder: Lane folds into OutPacket. Interleaved mode emits 8 lanes per cycle.
         .contract_lane::<m![M], m![N]>(LaneMode::Interleaved)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, Chip, Cluster, Slice, m![M, K / 16], m![K % 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![K]> = TrfTensor::zero();
let _o = bmatmul_m_in_time(a, &b);
}

V를 Time에 배치

V(배치)는 Time에, K는 Packet에 배치합니다. M은 Cluster와 Slice에 걸쳐 분할됩니다. 이 전략은 (1) Slice 수가 M을 감당할 수 있고(M / 16은 Cluster에, M % 16은 Slice에), (2) N이 Lane에 들어가며, (3) K가 단일 Packet에 들어갈 때 적용할 수 있습니다. 배치가 지배적인 축일 때(예: 배치 추론) 유용합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![V = 32, M = 32, N = 8, K = 32];   // V batch, M×N output, K contraction

type Chip    = m![1];                   // single chip
type Cluster = m![M / 16];              // outer M split across clusters (M / 16 = 2)
type Slice   = m![M % 16 # 256];        // inner M split across slices (M % 16 = 16 per cluster)
type Lane    = m![N];                   // N (output channels) partitions the 8 hardware lanes

/// Batched matmul with V (batch) placed in Time.
fn bmatmul_v_in_time<'l, const T: Tu>(
    // Streaming operand: V in Time, K in Packet.
    input: CollectTensor<'l, T, bf16, Chip, Cluster, Slice, m![V, K / 16], m![K % 16]>,
    // TRF operand: N in Lane, K in Element. Stored into TRF by a prior .to_trf() call.
    trf: &TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![K]>,
    // Output: one (V × N) f32 matrix per slice.
) -> ContractTensor<'l, T, f32, Chip, Cluster, Slice, m![V], m![N]> {
    input
         // Outer: Lane = m![N] (inferred from trf), OutTime = m![V], OutPacket = m![K].
         // input: K elements broadcast across all N lanes.
         // trf:   K elements per lane, broadcast across all V cycles.
         .contract_outer::<m![V], m![K], _, _, _>(trf)
         // Packet Reducer: OutPacket = m![1]. Sum K spatially via the reduction tree.
         .contract_packet::<m![1]>()
         // Time Reducer: OutTime = m![V]. Nothing to reduce.
         .contract_time::<m![V]>()
         // Lane Folder: Lane folds into OutPacket. Interleaved mode emits 8 lanes per cycle.
         .contract_lane::<m![V], m![N]>(LaneMode::Interleaved)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, Chip, Cluster, Slice, m![V, K / 16], m![K % 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, Chip, Cluster, Slice, Lane, m![K]> = TrfTensor::zero();
let _o = bmatmul_v_in_time(a, &b);
}

Outer

Outer 단계는 두 피연산자를 같은 형태로 브로드캐스트한 뒤 요소별로 곱합니다.

“Outer“라는 이름은 선형대수의 외적(outer product)에서 유래했습니다. 벡터 u(길이 n)와 v(길이 m)에 대해 u v^T는 (u v^T)[i, j] = u[i] × v[j]를 만족하는 n × m 행렬입니다. 이 행렬은 u를 열 축(길이 m)을 따라 브로드캐스트하고, v를 행 축(길이 n)을 따라 브로드캐스트한 뒤, 요소별로 곱해서 얻습니다. Outer 단계의 세 하위 단계는 바로 이 의미론을 하드웨어로 구현한 것이며, 차례로 실행됩니다.

  • Stream Adapter는 Collect Engine에서 오는 스트리밍 피연산자를 처리하고 브로드캐스트합니다.
  • TRF Sequencer는 TRF SRAM에서 오는 TRF 피연산자를 처리하고 브로드캐스트합니다.
  • Multiplier는 피연산자 타입을 넓힌 뒤(i4 / i8은 i32로, f8 / bf16은 f32로) 정렬된 두 피연산자를 요소별로 곱합니다.

출력은 결합 매핑 [Chip, Cluster, Slice, Lane, Time, Packet]을 따르는 곱셈 결과 텐서 하나이며, Packet Reducer가 리덕션 덧셈을 수행할 수 있는 상태입니다.

인터페이스

CollectTensor에서 .contract_outer(&trf)를 호출하면 Outer 단계가 실행됩니다.

impl<
    'l,
    const T: Tu,
    P: CanApplyContractOuter,
    D: Scalar + ContractionCast,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Runs the Outer stage: stashes the two un-broadcast operands (widened to the accumulator type)
    /// and the layouts [`super::ContractTimeTensor::contract_lane`] needs to fuse them into a [`LazyContraction`]. No
    /// materializing alternative, no per-backend branch -- every backend fuses the same way.
    ///
    /// The impl block binds [`ContractionCast`], so this method does not exist on a `TuTensor` whose
    /// element type the Multiplier has no MAC for.
    #[primitive(TuTensor::contract_outer)]
    pub fn contract_outer<OutTime: M, OutPacket: M, Lane: M, TrfElement: M, TrfD>(
        self,
        trf_tensor: &TrfTensor<TrfD, Chip, Cluster, Slice, Lane, TrfElement, B>,
    ) -> ContractOuterTensor<'l, T, <D as ContractionCast>::Output, D, Chip, Cluster, Slice, Lane, OutTime, OutPacket, B>
    where
        D: Cast<<D as ContractionCast>::Output>,
        // The weight (TRF) type must form a valid contraction-engine operand pair with the
        // stream type `D`: same type, or a mixed integer precision within a
        // family (i4/i5 x i4/i5, i8/i9 x i8/i9). Both operands widen to the
        // stream's accumulator for the multiply.
        TrfD: Scalar + ContractionWeight<D> + Cast<<D as ContractionCast>::Output>,
    {
        type Out<D> = <D as ContractionCast>::Output;

        // Skipping the broadcast transpose does not skip its validity contract -- a malformed
        // contraction would otherwise slip past these asserts and panic far downstream instead.
        stream_adapter::verify_stream_adapter::<D, Lane, Time, Packet, OutTime, OutPacket>();
        trf_sequencer::verify_trf_sequencer::<TrfD, Lane, TrfElement, OutTime, OutPacket>();

        // The operands keep their own compact layouts: lhs is `[Chip, Cluster, Slice, Time, Packet]`
        // (`self.inner`), rhs is `[Chip, Cluster, Slice, Lane, TrfElement]` (`trf_tensor`). A
        // bare-buffer backend reads its strides from these; `MathStorage` ignores them (its axes live
        // in the storage).
        let lhs_map = <m![{ Chip }, { Cluster }, { Slice }, { Time }, { Packet }]>::to_value();
        let rhs_map = <m![{ Chip }, { Cluster }, { Slice }, { Lane }, { TrfElement }]>::to_value();
        let pre_reduce = <m![{ Chip }, { Cluster }, { Slice }, { Lane }, { OutTime }, { OutPacket }]>::to_value();

        // Widen each operand to the `Out<D>` accumulator up front, at the operand's own (compact) size,
        // not pre_reduce's -- the fold at `contract_lane` then runs entirely in `Out<D>`. Parity-identical
        // to the old per-cell widen (same `Cast::cast` per element), it just never allocates the
        // pre_reduce-shaped broadcast this stage used to build.
        //
        let contraction = LazyContraction {
            lhs: self.inner.map(|v| -> Out<D> { v.cast() }).inner,
            rhs: trf_tensor.inner.map(|v| -> Out<D> { v.cast() }).inner,
            lhs_map,
            rhs_map,
            pre_reduce,
        };
        ContractOuterTensor::new(self.device, contraction)
    }
}

두 경로는 각자의 어댑터(스트리밍 경로는 Stream Adapter, TRF 경로는 TRF Sequencer)를 거친 뒤 같은 형태의 Lane / Time / Packet을 공급하고, Multiplier는 정렬된 위치끼리 요소별로 곱합니다.

스트리밍 피연산자의 Time / Packet은 출력의 OutTime / OutPacket에 매핑됩니다. OutPacket은 패킹을 통해 Time의 가장 안쪽에 있는 크기 1 또는 2의 인수를 흡수합니다. OutTime은 Time의 나머지 인수를 유지하며, 브로드캐스트를 통해 가장 안쪽 위치에 브로드캐스트 인수가 추가됩니다. 브로드캐스트 인수는 TRF 피연산자의 Lane / Element(스트리밍 피연산자가 TRF 매핑에 맞춰 복제되는 부분)에서 나오며, OutTime / OutPacket에는 나타나지만 입력과 TRF 어디에도 없는 순수 출력 축에서도 나옵니다(예: einsum AB, BC -> ABCD에서 브로드캐스트되는 D).

TrfTensor의 형태는 [Chip, Cluster, Slice, Lane, Element]이며, Chip / Cluster / Slice / Lane은 공간적으로 병렬입니다. Chip / Cluster / Slice는 그대로 출력으로 전달되고, Lane은 레인별 데이터를 1–8개의 하드웨어 레인에 나눕니다. Element(.to_trf()가 정한 레인별 레이아웃)는 TRF Sequencer가 OutTime / OutPacket을 채우도록 형태를 바꿉니다.

Stream Adapter

Stream Adapter는 패킹과 브로드캐스트라는 두 연산으로 스트리밍 Time / Packet을 계산 형태(Lane / OutTime / OutPacket)로 변환합니다. 컴파일러는 사용자가 지정한 OutTime / OutPacket, TRF 피연산자, 순수 출력 브로드캐스트 축으로부터 세 자유 변수(PackSize, LaneBroadcast, TimeBroadcast)를 도출하며, 그 결과 매핑은 다음과 같습니다.

Lane      = LaneBroadcast
OutTime   = [Time / PackSize, TimeBroadcast]
OutPacket = [Time % PackSize, Packet] # (64 / D::SIZE)

패킹

Collect Engine은 32 B 플릿을 생성하고, Outer 단계는 PackSize × 32 B(RNGD에서는 32 또는 64) 크기의 패킷을 내보냅니다. 패킹은 연속된 플릿 PackSize ∈ {1, 2}개를 패킷 하나로 합칩니다.

PackTime   = [Time / PackSize]
PackPacket = [Time % PackSize, Packet] # (PackSize × 32 / D::SIZE)

PackSize는 OutPacket을 입력 Packet과 대조해 정해집니다. OutPacket이 Time의 가장 안쪽에 있는 크기 2의 인수를 흡수하면 PackSize = 2이고, 그렇지 않으면 PackSize = 1입니다. 동등하게 PackSize = OutPacket::SIZE * D::SIZE / 32이므로, 사용자가 OutPacket(32 B 또는 64 B)을 선택하면 패킹의 Collect 플릿 수가 그에 따라 결정됩니다.

하드웨어는 내부적으로 항상 64 B 패킷으로 동작합니다. PackSize = 1이면 사용하지 않는 32 B 절반은 0으로 채워지며, 이 값은 논리적인 OutPacket 타입으로 전파되지 않습니다. 따라서 후속 단계(Packet Reducer, Lane Folder)는 PackSize × 32 B 페이로드만 보게 되어 더미 사이클을 피할 수 있습니다. 자세한 내용은 Lane Folder Sequential 참고 사항을 참조하십시오.

브로드캐스트

패킹이 끝나면 Stream Adapter는 LaneBroadcast(TRF의 Lane 매핑, ∈ {1, 2, 4, 8})로 데이터를 공간적으로 브로드캐스트하고, TimeBroadcast로 시간적으로 브로드캐스트합니다. TimeBroadcast는 입력 Time에 없는 TRF Element의 인수를 다루며, OutTime에 있지만 입력과 TRF 어디에도 없는 순수 출력 축도 함께 다룹니다. 두 경우 모두 같은 브로드캐스트 메커니즘이 패킷을 복제합니다. 각 목적지는 같은 OutPacket을 받습니다.

Lane      = LaneBroadcast
OutTime   = [PackTime, TimeBroadcast]
OutPacket = PackPacket

TimeBroadcast 인수는 OutTime의 가장 안쪽 위치를 차지합니다. 따라서 바깥쪽 PackTime 인수를 순회하기 전에 같은 OutPacket이 이 인수들에 걸쳐 반복 전송됩니다.

예제

아래 예제는 두 연산을 모두 사용합니다. 패킹은 Time의 가장 안쪽에 있는 크기 2의 인수 L을 Packet에 흡수하고(PackSize = 2), Lane 브로드캐스트는 그렇게 만들어진 패킷을 N = 8개 레인에 분배하며, Time 브로드캐스트는 TRF에만 있는 B = 5 축을 따라 스트리밍 데이터를 타일링합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![M = 32, N = 8, K = 16, L = 2, B = 5];

fn stream_adapter_example<'l, const T: Tu>(
    input: CollectTensor<'l, { T }, bf16, m![1], m![1 # 2], m![1 # 256], m![M, L], m![K]>,
    trf: &TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![B, L, K]>,
) -> ContractOuterTensor<'l, { T }, f32, bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![M, B], m![L, K]> {
    // Packing (PackSize = 2):
    //   L = 2 (innermost Time) absorbed into Packet.
    //   PackTime = [M = 32], PackPacket = [L = 2, K = 16] = 32 bf16 = 64B.
    // Lane Broadcast: same packet to all N = 8 lanes.
    // Time Broadcast: B = 5 (TRF-only) added at innermost OutTime.
    //   OutTime = [M, B = 5], OutPacket = [L = 2, K = 16].
    input.contract_outer::<m![M, B], m![L, K], _, _, _>(trf)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![1 # 256], m![M, L], m![K]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![B, L, K]> = TrfTensor::zero();
let _o = stream_adapter_example(a, &b);
}

제약 조건

  • OutPacket::SIZE * Storage::SIZE ∈ {32, 64} 바이트(RNGD 기준)이며, 여기서 Storage는 확장 전 피연산자 dtype입니다(예: bf16 = 2 B이며, 결과 텐서가 담는 확장된 f32 누산기가 아닙니다). 이 크기는 PackSize = 1일 때 32이고 PackSize = 2일 때 64입니다. 사용자가 이 크기를 선택하면 패킹의 Collect 플릿 수가 그에 따라 결정됩니다.
  • PackSize ∈ {1, 2}(패킹 참조).
  • Lane::SIZE ∈ {1, 2, 4, 8}입니다.

성능

PackSize는 MAC 활용률을 결정합니다. PackSize = 2는 64 B 전체를 채워 모든 MAC을 사용합니다. PackSize = 1은 32 B만 채우므로 0으로 패딩된 절반은 항상 0과 곱해지고 실효 처리량이 절반으로 줄어듭니다.

PackSize = 2는 Packet당 2사이클이 걸리지만(32 B 플릿 두 개가 64 B Packet 하나로 합쳐짐), 이는 파이프라인 병목이 아닙니다. 업스트림이 최대 fetch 속도로 매 사이클 32 B 플릿 하나를 공급하므로, Stream Adapter는 플릿이 도착하는 즉시 소비하고 다운스트림 소비 속도에 맞춰 2사이클마다 Packet 하나를 내보냅니다.

Time Broadcasting은 fetch 비용을 여러 사이클에 분산합니다. 브로드캐스트 인자는 다시 fetch하지 않고 같은 스트리밍 패킷을 여러 사이클에 걸쳐 재사용하므로, 해당 인자에 대한 대역폭 비용이 없어집니다.

Fetch 대역폭(fetch당 최대 32 B/사이클)이 Stream Adapter 전체의 상한을 결정합니다. 활용률을 극대화하려면 여러 Slice에 걸쳐 fetch 패턴을 인터리브하십시오.

TRF Sequencer

TRF Sequencer는 TrfTensor를 읽고 그 Element를 Packet Reducer에 맞게 OutTime / OutPacket으로 재구성합니다. TRF 저장 레이아웃(레인, 뱅크, 행, 더블 버퍼링, 캐시)은 Register Files를 참조하십시오.

매핑은 다음과 같습니다:

OutTime   = (sequencing over [Element / ReadSize] with broadcasts)
OutPacket = [PacketBroadcast, Element % ReadSize]

OutPacket은 매 사이클 TRF 읽기 한 번으로 채워집니다. 즉, 매 사이클 완전한 OutPacket 하나(두 뱅크에 걸쳐 레인당 640비트, 한 뱅크만 읽을 때는 320비트)가 생성됩니다. 8개 레인에 걸친 Slice당 총량(바이트 단위)은 Contraction Engine으로를 참조하십시오. 읽기는 Element의 가장 안쪽 연속 부분을 가져와 64 B OutPacket을 채우도록 복제합니다. 컴파일러는 Element % ReadSize == OutPacket % ReadSize와 ReadSize * D::SIZE ≤ 64 바이트를 만족하는 가장 큰 ReadSize를 선택합니다. ReadSize가 넓으면 레인당 두 TRF 뱅크에 걸치고, 좁으면 한 뱅크만 사용합니다.

OutTime은 Element / ReadSize(및 선택적인 브로드캐스트)를 순차 처리하여 여러 사이클에 걸쳐 채워집니다. TRF Sequencer는 다른 모든 Sequencer와 동일한 중첩 루프 구성을 사용합니다.

예제

이 예시에서는 ReadSize가 64 B 읽기 한 번으로 Element 전체를 포괄하므로 Element / ReadSize는 자명하며, Sequencer는 브로드캐스트만 순회합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![M = 32, N = 8, K = 32];

fn trf_sequencer_full_read<'l, const T: Tu>(
    input: CollectTensor<'l, T, bf16, m![1], m![1 # 2], m![1 # 256], m![M, K / 16], m![K % 16]>,
    trf: &TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![K]>,
) -> ContractOuterTensor<'l, T, f32, bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![M], m![K]> {
    // Element         = K
    // ReadSize        = 32
    // PacketBroadcast = 1
    // OutTime         = M      (sequencing over [K / 32] (= 1), broadcast M)
    // OutPacket       = K      (= [1, K % 32])
    input.contract_outer::<m![M], m![K], _, _, _>(trf)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![1 # 256], m![M, K / 16], m![K % 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![K]> = TrfTensor::zero();
let _o = trf_sequencer_full_read(a, &b);
}

이 예시에서는 ReadSize가 Element의 일부만 포괄하므로 Element / ReadSize는 자명하지 않으며, Sequencer는 브로드캐스트와 함께 바깥쪽 Element 인자를 순회합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![M = 32, N = 8, K = 16, L = 2, O = 2];

fn trf_sequencer_partial_read<'l, const T: Tu>(
    input: CollectTensor<'l, T, bf16, m![1], m![1 # 2], m![1 # 256], m![O, M, L], m![K]>,
    trf: &TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![O, K]>,
) -> ContractOuterTensor<'l, T, f32, bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![O, M], m![L, K]> {
    // Element         = [O, K]
    // ReadSize        = 16
    // PacketBroadcast = L
    // OutTime         = [O, M]    (sequencing over [O, K] / 16 (= O), broadcast M)
    // OutPacket       = [L, K]    (= [L, [O, K] % 16])
    input.contract_outer::<m![O, M], m![L, K], _, _, _>(trf)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![1 # 256], m![O, M, L], m![K]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![O, K]> = TrfTensor::zero();
let _o = trf_sequencer_partial_read(a, &b);
}

제약 조건

  • 하드웨어 차원: Chip::SIZE, Cluster::SIZE, Slice::SIZE는 하드웨어 구성과 일치해야 합니다(Sequencer 참조).
  • 주소 정렬: Element % ReadSize가 64 B 전체를 포괄하면 읽기가 레인당 두 TRF 뱅크에 걸치므로, Sequencer의 기준 주소와 모든 스트라이드는 64 B에 정렬되어야 합니다.

아키텍처

Sequencer는 다른 모든 Sequencer와 동일한 중첩 루프 구성을 사용하여 여러 사이클에 걸쳐 Element의 바깥쪽 인자(즉, Element / ReadSize)를 순회하므로, 하나의 TrfTensor는 내용을 모두 소진하기 전까지 Element / ReadSize 사이클을 거칩니다. PacketBroadcast 인자는 한 사이클 안에서 같은 행을 복제하여, 추가 TRF 읽기 대역폭을 소비하지 않고 본래의 ReadSize를 넘어 64 B OutPacket을 채웁니다.

성능

처리량은 레인당 사이클마다 완전한 OutPacket 하나입니다. 두 뱅크를 모두 읽으면 레인당 640비트이고, 한 뱅크만 읽으면 레인당 320비트입니다. Slice별 총 바이트 수는 Register Files: To Contraction Engine을 참조하십시오. TRF 읽기 캐시와 뱅크 교대(Register Files: 더블 버퍼링 참조) 덕분에 브로드캐스트 재사용과 좁은 읽기가 일어나는 동안에도 동시 서브 컨텍스트 저장이 막히지 않습니다.

Multiplier

Multiplier는 Stream Adapter와 TRF Sequencer에서 정렬된 두 피연산자를 받아, 하류 누산기에서 오버플로가 일어나지 않도록 각 입력 요소를 축약 출력 타입으로 확장한 다음 요소별로 곱합니다. 그 출력은 결합 매핑 [Chip, Cluster, Slice, Lane, Time, Packet]의 단일 텐서이며, Packet Reducer의 입력이 됩니다. 매 Time 사이클마다 모든 Lane이 곱셈 결과로 채운 완전한 packet을 병렬로 생성합니다.

피연산자 타입

다음은 Multiplier가 허용하는 피연산자 쌍과 각 쌍이 확장되는 타입입니다.

Stream(활성화)가중치(TRF)누산기
i4 또는 i5i4 또는 i5i32
i8 또는 i9i8 또는 i9i32
f8e4m3f8e4m3f32
f8e5m2f8e5m2f32
bf16bf16f32

정수 피연산자는 원래 형식(i4/i8)일 수도 있고, 영점을 차감해 준비한 형식(i5/i9, 영점 차감에서 생성)일 수도 있으며, 같은 계열 안에서는 두 피연산자가 일치하지 않아도 됩니다. 서로 다른 계열(i4와 i8 불가)이나 종류(정수와 부동소수점 불가)를 섞을 수 없으며, 부동소수점 쌍은 타입이 정확히 같아야 합니다.

이 표에 없는 요소 타입에 대한 축약은 대신 Vector Engine에서 실행됩니다. 요소별로 곱한 다음 Intra-Slice Reduce로 리덕션하며, 이때 계산은 i32 / f32로 수행됩니다.

Packet Reducer

Packet Reducer는 lane마다 리덕션 트리 하나를 두고, 단일 Packet 안에서 가장 안쪽의 축약 축들을 reduce-add합니다.

인터페이스

.contract_packet()는 Packet Reducer를 호출합니다. 각 lane은 Outer 단계의 OutPacket에서 물려받은 i4, i8, f8 또는 bf16 요소로 이루어진 32 B 또는 64 B Packet을 받습니다. 형식적으로는 \(\text{output}[i] = \sum_{j} \text{input}[i, j]\)를 계산하며, 여기서 i는 남는 (출력) 축들에 걸쳐 있고 j는 Packet 내부의 축약 축들에 걸쳐 있습니다.

impl<
    'l,
    const T: Tu,
    D: Scalar,
    Storage: ContractionCast<Output = D>,
    Chip: M,
    Cluster: M,
    Slice: M,
    Lane: M,
    Time: M,
    Packet: M,
    B: Backend,
> ContractOuterTensor<'l, T, D, Storage, Chip, Cluster, Slice, Lane, Time, Packet, B>
{
    /// Spatial reduction within `Packet`: validates the reduce-add along the contracted axes inside
    /// `Packet` that the fused fold at `contract_lane` will perform. `D` is the widened accumulator the
    /// deferred carrier stays keyed on; the DPE input packet is still sized in `Storage` bytes.
    #[primitive(ContractOuterTensor::contract_packet)]
    pub fn contract_packet<OutPacket: M>(
        self,
    ) -> ContractPacketTensor<'l, T, D, Chip, Cluster, Slice, Lane, Time, OutPacket, B> {
        verify_contract_packet(ContractPacketInput {
            in_packet: Packet::to_value(),
            out_packet: OutPacket::to_value(),
            element_bits: Storage::BITS,
        });
        // Carry the deferred operands forward unreduced: the fused contraction at `contract_lane`
        // performs this Packet reduction too. This stage only re-types the carrier to `OutPacket`.
        ContractPacketTensor::new(self.device, self.inner)
    }
}

아래 커널은 8개 lane을 모두 병렬로 사용합니다. 트리 깊이 5로 B의 bf16 요소 32개를 합산하여 A 위치마다 f32 하나를 생성합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 32, B = 32, C = 8];

fn matmul<'l, const T: Tu>(
    input: CollectTensor<'l, T, bf16, m![1], m![1 # 2], m![1 # 256], m![A, B / 16], m![B % 16]>,
    trf: &TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![C], m![B]>,
) -> ContractTensor<'l, T, f32, m![1], m![1 # 2], m![1 # 256], m![A], m![C]> {
    //
    // Spatial reduction: tree depth 5 reduces 32 bf16 elements along B → f32
    // Output (Interleaved): Time = [A], Packet = [C]
    input.contract_outer::<m![A], m![B], _, _, _>(&trf)
         .contract_packet::<m![1]>()
         .contract_time::<m![A]>()
         .contract_lane::<m![A], m![C]>(LaneMode::Interleaved)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![1 # 256], m![A, B / 16], m![B % 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![C], m![B]> = TrfTensor::zero();
let _o = matmul(a, &b);
}

아키텍처

ReducePacket = Packet / 2^d        for 0 ≤ d ≤ log2(Packet::SIZE)
OutPacket    = ReducePacket, outermost padding removed
               (size ≤ 32; when ReducePacket::SIZE > 32 only the innermost 32 survive)

Packet Reducer는 먼저 입력 Packet에 대해 lane마다 독립적인 리덕션 트리를 실행합니다. 깊이 0에서 트리의 리프는 입력 Packet의 요소를 담고 있으며, 이후 각 깊이에서는 쌍을 합산하여 요소 수를 절반으로 줄입니다. 최대 트리 깊이는 log2(Packet::SIZE)이므로 i4(Packet::SIZE = 128)는 7, i8 / f8(64)는 6, bf16(32)는 5입니다. 사용자의 OutPacket이 주어지면 컴파일러가 트리 깊이 d를 도출하고, 트리는 가장 안쪽의 2^d개 요소를 소비하여 ReducePacket을 생성합니다.

그다음 Packet Reducer는 ReducePacket을 OutPacket으로 잘라내는데, 하류에 있는 Time Reducer의 lane별 누산기에는 열이 32개뿐이므로 OutPacket의 크기는 최대 32개 요소로 제한됩니다. ReducePacket::SIZE > 32이면 바깥쪽 더미가 잘려 나가고 가장 안쪽의 32개 요소만 남습니다. 예를 들어 i4는 128개 요소의 Packet으로 도착하므로 d ∈ {0, 1}은 128개 또는 64개 요소의 ReducePacket을 생성하며, 둘 다 OutPacket::SIZE = 32로 잘립니다.

ReducePacket은 가장 바깥쪽 패딩(패딩된 packet 축)을 가질 수도 있으며, Packet Reducer는 그 패딩을 버리고 유효한 열만 남깁니다. 예를 들어 [A # 16]의 ReducePacket(A는 유효하며 16개 열까지 패딩됨)은 OutPacket = [A]로 리덕션됩니다. 따라서 OutPacket은 패딩이 있는 상태([A # 16])로 선언할 수도 있고 이미 제거된 상태([A])로 선언할 수도 있으며, 둘 다 같은 결과를 가리킵니다.

성능

지연 시간은 트리 깊이에 따라 달라지며, i4는 7사이클, i8/f8는 6사이클, bf16는 5사이클입니다. 요소 타입이 넓을수록 각 Packet에 들어가는 요소 수가 적으므로 깊이가 줄어듭니다. 가산기 트리는 완전히 파이프라인화되어 있으므로 깊이는 첫 출력까지의 지연 시간을 늘리지만 정상 상태의 처리량을 떨어뜨리지는 않습니다. 파이프라인이 채워지고 나면 매 사이클마다 Packet 하나가 들어가고 리덕션된 출력 하나가 나옵니다.

Lane < 8이면 비활성 lane의 리덕션 트리는 유휴 상태가 되므로 사이클당 처리량은 Lane::SIZE / 8에 비례하여 감소합니다.

Time Reducer

Time Reducer는 Packet Reducer의 [Lane, Packet] 출력을 시간 누산기로 Time에 걸쳐 OutTime으로 누산합니다.

인터페이스

.contract_time::<OutTime>()는 Time Reducer를 호출합니다. OutTime은 남는 Time 차원을 지정합니다(나머지는 합산되어 사라집니다).

impl<'l, const T: Tu, D: Scalar, Chip: M, Cluster: M, Slice: M, Lane: M, Time: M, Packet: M, B: Backend>
    ContractPacketTensor<'l, T, D, Chip, Cluster, Slice, Lane, Time, Packet, B>
{
    /// Accumulates per-cycle contractions over the `Time` dimension via the shared
    /// accumulator buffer, shrinking input `Time` to `OutTime`. The axes present in
    /// `Time` but absent from `OutTime` are reduce-added.
    #[primitive(ContractPacketTensor::contract_time)]
    pub fn contract_time<OutTime: M>(
        self,
    ) -> ContractTimeTensor<'l, T, D, Chip, Cluster, Slice, Lane, OutTime, Packet, B> {
        verify_contract_time(ContractTimeInput {
            in_time: Time::to_value(),
            out_time: OutTime::to_value(),
        });
        // Carry the deferred operands forward unreduced: the fused contraction at `contract_lane`
        // performs this Time reduction too. This stage only re-types the carrier to `OutTime`.
        ContractTimeTensor::new(self.device, self.inner, Time::to_value())
    }
}

예를 들어, 아래 커널은 2D 텐서를 B를 따라 리덕션합니다(A만 남습니다).

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 32];

/// Reduces along B; A survives.
fn reduce_b<'l, const T: Tu>(
    // Streaming operand: Slice = m![A / 8] (256 outer A chunks across slices).
    // Time = m![B / 16, A % 8]; Packet = m![B % 16].
    // B splits across Packet (B % 16) and Time (B / 16): each cycle produces a partial sum.
    input: CollectTensor<'l, T, bf16, m![1], m![1 # 2], m![A / 8], m![B / 16, A % 8], m![B % 16]>,
    // TRF operand: single-lane weight per slice.
    trf: &TrfTensor<bf16, m![1], m![1 # 2], m![A / 8], m![1], m![B]>,
    // Output: one f32 per (slice, A % 8) cell.
) -> ContractTensor<'l, T, f32, m![1], m![1 # 2], m![A / 8], m![A % 8], m![1 # 8]> {
    input
         // Outer: Lane = m![1], OutTime = m![B / 16, A % 8], OutPacket = m![B % 16].
         .contract_outer::<m![B / 16, A % 8], m![B % 16], _, _, _>(trf)
         // Packet Reducer: OutPacket = m![1]. Collapses B % 16 spatially.
         .contract_packet::<m![1]>()
         // Time Reducer: OutTime = m![A % 8]. Accumulator receives
         // Time::SIZE = (B / 16) × (A % 8) = 2 × 8 = 16 flits; B / 16 outer
         // chunks accumulate into 8 slots indexed by A % 8.
         .contract_time::<m![A % 8]>()
         // Lane Folder: Lane folds into OutPacket. Sequential mode (Lane = m![1]).
         .contract_lane::<m![A % 8], m![1 # 8]>(LaneMode::Sequential)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![A / 8], m![B / 16, A % 8], m![B % 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, m![1], m![1 # 2], m![A / 8], m![1], m![B]> = TrfTensor::zero();
let _o = reduce_b(a, &b);
}

아키텍처

Time Reducer는 Packet Reducer가 사이클마다 내보내는 [Lane, Packet] 출력을 받습니다. 하드웨어는 상류에서 Lane::SIZE ≤ 8(공간적으로 병렬인 레인)과 Packet::SIZE ≤ 32로 상한을 둡니다.

Time Reducer는 매 사이클 [Lane, Packet] 공간 그리드를 Time에 걸쳐 OutTime으로 접습니다. OutTime은 남는 차원의 상대적 순서를 보존한 Time의 부분집합이어야 합니다(verify_contract_time이 이를 강제합니다). Time에는 있지만 OutTime에는 없는 차원은 합산되어 사라지며, 그러한 차원 중 가장 바깥 차원이 플릿을 순회합니다.

InnerTime을 Time의 안쪽 비리덕션 차원(가장 바깥 리덕션 차원보다 안쪽에 있으면서 OutTime에 남는 차원)이라고 합시다. 위의 reduce_b에서 Time = m![B / 4, A % 8]이고 OutTime = m![A % 8]이므로, B / 4가 가장 바깥 리덕션 차원이며(Time::SIZE = 2 × 8 = 16개의 플릿을 순회합니다) InnerTime = m![A % 8]입니다.

누산에는 InnerTime 튜플 값마다 하나씩, InnerTime::SIZE개의 [Lane, Packet] 슬롯이 필요합니다. 튜플이 같은 플릿은 같은 슬롯에 누산됩니다. reduce_b의 경우 8개의 슬롯이 B / 4 = 2회의 반복에 걸쳐 누산되며, 플릿 15 이후 버퍼에는 최종 리덕션 결과가 담기고 버퍼는 이를 Lane Folder에 넘깁니다:

Time = m![B / 4, A % 8]
          ~~~~~  ~~~~~
        outer R  inner non-R (A % 8)

Flit sequence (B / 4 has values 0,1; A % 8 has values 0..7):

  flit #0:  B/4=0, A%8=0  ──→ ┌─────────────────┐
  flit #8:  B/4=1, A%8=0  ──→ │ slot 0 (A%8=0)  │  accumulates B for A%8=0
                              └─────────────────┘

  flit #1:  B/4=0, A%8=1  ──→ ┌─────────────────┐
  flit #9:  B/4=1, A%8=1  ──→ │ slot 1 (A%8=1)  │  accumulates B for A%8=1
                              └─────────────────┘
   ⋮

  flit #7:  B/4=0, A%8=7  ──→ ┌─────────────────┐
  flit #15: B/4=1, A%8=7  ──→ │ slot 7 (A%8=7)  │  accumulates B for A%8=7
                              └─────────────────┘

  8 non-reduce positions → 8 slots used

제약 조건

InnerTime::SIZE가 슬롯 용량(버퍼가 담을 수 있는 슬롯 수)을 넘지 않으면 매핑이 버퍼에 들어맞습니다.

슬롯 용량은 버퍼의 1,024개 셀과 하류 Lane Folder의 LaneMode에 따라 정해집니다. 각 슬롯은 LaneMode가 형태를 결정하는 [Lane, Packet] 청크이므로, 슬롯 수는 1,024를 청크당 셀 수로 나눈 값입니다.

LaneMode청크 형태청크당 셀 수슬롯 용량
Interleaved[Lane # 8, Packet]8 × Packet::SIZE128 / Packet::SIZE
Sequential[Lane, Packet # 32]Lane::SIZE × 3232 / Lane::SIZE

Lane::SIZE = 1인 하위 단계 .contract_lane(LaneMode::Sequential) 아래의 reduce_b에서는 슬롯 용량이 32이므로 InnerTime::SIZE = 8이 여유 있게 들어갑니다. InnerTime::SIZE가 슬롯 용량을 초과한다면 Time을 재구성하거나(예: B를 더 분할) LaneMode를 전환하여 처리량을 일부 포기하는 대신 슬롯 여유 공간을 확보해야 합니다.

성능

처리량은 입력 측 기준으로 사이클당 한 패킷입니다. N개의 입력을 하나의 출력으로 리덕션하므로 실효 출력 속도는 입력의 1 / N입니다.

크기가 N인 Time 차원 리덕션의 지연 시간은 약 N 사이클입니다.

Lane Folder

Lane Folder는 Contraction Engine의 마지막 단계입니다. Lane Folder는 Lane 차원의 8개 값을 OutPacket(Interleaved) 또는 OutTime(Sequential)으로 옮겨 이 차원을 제거합니다. 값을 합산하지는 않습니다. 이 단계는 Lane을 리덕션하지 않고 다른 축으로 접어 넣습니다.

인터페이스

.contract_lane(mode)는 Lane Folder를 호출합니다. 이 단계는 상류 Time Reducer의 버퍼를 8개 요소 폭의 출력 버스로 한 사이클씩 비우며, LaneMode는 각 사이클의 플릿에 무엇을 실을지 선택합니다.

impl<'l, const T: Tu, D: ContractionAccumulator, Chip: M, Cluster: M, Slice: M, Lane: M, Time: M, Packet: M, B: Backend>
    ContractTimeTensor<'l, T, D, Chip, Cluster, Slice, Lane, Time, Packet, B>
{
    /// Folds the `Lane` dimension into the output stream.
    /// `LaneMode::Interleaved` relocates `Lane` into `OutPacket`;
    /// `LaneMode::Sequential` relocates `Lane` into `OutTime`.
    #[primitive(ContractTimeTensor::contract_lane)]
    pub fn contract_lane<OutTime: M, OutPacket: M>(
        self,
        mode: LaneMode,
    ) -> ContractTensor<'l, T, D, Chip, Cluster, Slice, OutTime, OutPacket, B> {
        verify_contract_lane(ContractLaneInput {
            in_lane: Lane::to_value(),
            in_time: Time::to_value(),
            in_packet: Packet::to_value(),
            out_time: OutTime::to_value(),
            out_packet: OutPacket::to_value(),
            pre_reduce_time: self.pre_reduce_time,
            mode: mode.into(),
        });
        // Earlier stages only retype the deferred operands. Perform their fused contraction here,
        // then apply the lane-fold relayout.
        // `contraction_prewidened` validates `out` through mapping carve. Symbol-wise comparison is
        // invalid because one contracted symbol may span spatial and reduced slots.
        let contraction = self.inner;
        let out = <m![{ Chip }, { Cluster }, { Slice }, { Lane }, { Time }, { Packet }]>::to_value();
        let reduced: Tensor<D, m![{ Chip }, { Cluster }, { Slice }, { Lane }, { Time }, { Packet }], B> =
            Tensor::from_inner(B::contraction_prewidened(
                &contraction.lhs,
                &contraction.rhs,
                &contraction.lhs_map,
                &contraction.rhs_map,
                &contraction.pre_reduce,
                &out,
            ));
        ContractTensor::new(self.device, reduced.transpose(false))
    }
}

아래의 최소 예제는 ContractTimeTensor(상류 Time Reducer의 출력)를 받아 .contract_lane(...)만 호출하므로, 각 예제에서 Lane Folder만 따로 떼어 볼 수 있습니다. 입력 Packet의 크기는 Packet Reducer를 거친 뒤 남은 크기로, 레인당 {1, 2, 4, 8, 16, 32}개 요소 중 하나입니다.

Interleaved

Lane 차원은 OutPacket으로 접혀 들어갑니다. 각 사이클은 8개 레인 전체에서 하나의 열 위치를 읽으며(레인당 값 1개, 플릿당 값 8개), Lane은 가장 안쪽의 OutPacket으로 구체화됩니다.

OutTime   = [Time, Packet]
OutPacket = [Lane # 8]
#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![N = 8, M = 4, P = 16];

/// Lane folds into OutPacket.
fn lane_interleaved<'l, const T: Tu>(
    // Input from upstream Time Reducer: Lane = m![N], Time = m![M], Packet = m![P].
    input: ContractTimeTensor<'l, T, f32, m![1], m![1 # 2], m![1 # 256], m![N], m![M], m![P]>,
    // Output: OutTime = m![M, P] = [Time, Packet], OutPacket = m![N] = [Lane].
) -> ContractTensor<'l, T, f32, m![1], m![1 # 2], m![1 # 256], m![M, P], m![N]> {
    input.contract_lane::<m![M, P], m![N]>(LaneMode::Interleaved)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![1 # 256], m![M], m![P]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![P]> = TrfTensor::zero();

let i: ContractTimeTensor<'_, _, f32, m![1], m![1 # 2], m![1 # 256], m![N], m![M], m![P]> = a 
    .contract_outer::<m![M], m![P], m![N], m![P], _>(&b)
    .contract_packet::<m![P]>()
    .contract_time::<m![M]>();

let _o = lane_interleaved(i);
}

Sequential

Lane 차원은 OutTime으로 접혀 들어갑니다. 각 사이클은 한 레인의 Packet에서 8개의 열 위치를 읽으며(플릿당 값 8개), Lane은 연속된 사이클에 걸쳐 순회합니다. 각 사이클의 폭이 8개 요소이므로, Packet은 먼저 8(버스 폭)의 배수로 패딩된 뒤 레인당 PadPacket / 8개 사이클과 사이클당 PadPacket % 8개 요소로 분할됩니다.

PadPacket = Packet # align_up(Packet::SIZE, 8)   (pad Packet up to the next multiple of 8)
OutTime   = [Time, Lane, PadPacket / 8]
OutPacket = [PadPacket % 8]

Packet::SIZE < 32인 경우 [PadPacket / 8]::SIZE = ceil(Packet::SIZE / 8)가 패킷당 사이클 수입니다(예: Packet::SIZE = 4이면 1사이클, Packet::SIZE = 16이면 2사이클).

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![N = 8, M = 4, P = 16];

/// Lane folds into OutTime.
fn lane_sequential<'l, const T: Tu>(
    // Input from upstream Time Reducer: Lane = m![N], Time = m![M], Packet = m![P].
    input: ContractTimeTensor<'l, T, f32, m![1], m![1 # 2], m![1 # 256], m![N], m![M], m![P]>,
    // Output: OutTime = m![M, N, P / 8] = [Time, Lane, Packet / 8], OutPacket = m![P % 8] = [Packet % 8].
) -> ContractTensor<'l, T, f32, m![1], m![1 # 2], m![1 # 256], m![M, N, P / 8], m![P % 8]> {
    input.contract_lane::<m![M, N, P / 8], m![P % 8]>(LaneMode::Sequential)
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let a: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![1 # 256], m![M], m![P]> = CollectTensor::new(&mut device.main, Tensor::zero());
let b: TrfTensor<bf16, m![1], m![1 # 2], m![1 # 256], m![N], m![P]> = TrfTensor::zero();

let i: ContractTimeTensor<'_, _, f32, m![1], m![1 # 2], m![1 # 256], m![N], m![M], m![P]> = a 
    .contract_outer::<m![M], m![P], m![N], m![P], _>(&b)
    .contract_packet::<m![P]>()
    .contract_time::<m![M]>();

let _o = lane_sequential(i);
}

제약 조건

Lane Folder 자체에는 제약 조건이 없습니다. 여기에서 선택한 LaneMode에 따라 상류 Time Reducer가 적용하는 슬롯 용량 한도가 결정됩니다(Time Reducer 제약 조건 참조).

성능

Interleaved 모드에서 Lane < 8이면 처리량이 Lane::SIZE / 8만큼 감소합니다(비활성 레인이 버스 위치를 비워 두기 때문입니다).

Sequential 모드에서 Packet::SIZE < 8이면(예: Packet Reducer가 bf16 패킷의 절반을 축소한 뒤의 Packet::SIZE = 4) 각 사이클은 8개 요소의 전체 버스 폭이 아니라 정확히 Packet::SIZE개의 요소를 전달합니다. 사이클당 출력은 더 좁아지지만 패딩은 없으며 낭비되는 버스 슬롯도 없습니다.

지연 시간은 무시할 수 있는 수준입니다. Lane Folder는 레인별 출력의 형태만 바꿀 뿐이며, 버퍼를 비우는 데 걸리는 시간 외에 사이클을 추가하지 않습니다.

Vector Engine

Vector Engine은 요소별 연산과 리덕션을 수행합니다. 활성화 함수(GELU, SiLU), 정규화(softmax, layer norm), 이항 연산, Slice 내부 및 Slice 간 리덕션 등이 그 예입니다.

이 엔진은 32비트 타입인 i32와 f32만 받습니다. 앞 단계의 Contraction Engine은 타입을 자동으로 확장합니다(bf16 곱은 f32로, i8 곱은 i32로 누적됩니다). 이 엔진을 거치지 않는 경우에는 Fetch Adapter가 타입 캐스트 어댑터로 입력을 확장해야 합니다.

인터페이스

한 번의 Tensor Unit 호출에서 Vector Engine 부분은 vector_init()부터 vector_final()까지의 메서드 체인입니다. 이 엔진은 두 하위 구성 요소로 이루어집니다. 하나는 Intra-Slice Chain(요소별 / 이항 / Slice별 리덕션 단계)이고, 다른 하나는 Inter-Slice Reducer(Cluster 내 256개 Slice에 걸쳐 리덕션)입니다. vector_init()와 vector_final() 사이에서는 체인만 실행하거나, 리듀서만 실행하거나, 둘 다 실행할 수 있습니다. 둘 다 실행하는 경우 순서는 IntraFirst(체인 다음 리듀서) 또는 InterFirst(리듀서 다음 체인)입니다.

Intra-Slice Chain은 vector_intra_slice_tag()로 진입하며, 입력에 두 개의 병렬 스트림으로 나눌 2-way 그룹화 축이 있는 경우에는 vector_intra_slice_unzip()로 진입합니다(Pair Mode 참조). 두 진입점 모두 vector_init() 직후 또는 Inter-Slice Reducer의 출력에서 실행됩니다. Inter-Slice Reducer는 vector_init() 직후 또는 호환되는 Intra-Slice 단계에서 vector_inter_slice_reduce()로 진입합니다. 단계별 API 설명은 Intra-Slice Chain과 Inter-Slice Reducer를 참조하십시오.

아래 시그니처는 vector_init() 쪽 진입 메서드만 다룹니다. 같은 이름의 메서드(vector_intra_slice_tag, vector_inter_slice_reduce)는 체인↔리듀서 전환을 위해 체인 텐서와 리듀서 텐서에도 있으며, 이는 하위 페이지에서 설명합니다.

impl<'l, const T: Tu, P: CanApplyVectorInit, D: VeScalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M>
    TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet>
{
    /// Initializes Vector Engine processing for this tensor.
    #[primitive(TuTensor::vector_init)]
    pub fn vector_init(self) -> VectorInitTensor<'l, T, D, Chip, Cluster, Slice, Time, Packet> {
        VectorInitTensor::new(self.device, self.inner)
    }
}

impl<'l, const T: Tu, D: VeScalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M>
    VectorInitTensor<'l, T, D, Chip, Cluster, Slice, Time, Packet>
{
    /// Enters VE intra-slice pipeline (single stream).
    #[primitive(VectorInitTensor::vector_intra_slice_tag)]
    pub fn vector_intra_slice_tag(
        self,
        branch: TagMode<D>,
    ) -> VectorBranchTensor<'l, T, D, Chip, Cluster, Slice, Time, Packet, Fresh, { VeOrder::IntraFirst }> {
        VectorBranchTensor::new(self.device, self.inner, branch)
    }

    /// Enters VE intra-slice pipeline (two-group / unzip).
    #[primitive(VectorInitTensor::vector_intra_slice_unzip)]
    pub fn vector_intra_slice_unzip<I: AxisName, SplitTime: M>(
        self,
    ) -> VectorTensorPair<'l, T, D, stage::Tag, Chip, Cluster, Slice, SplitTime, Packet> {
        verify_vector_intra_slice_unzip::<I, Time, SplitTime, Packet>();
        VectorTensorPair::new::<I, Time>(self.device, self.inner)
    }
}

impl<'l, const T: Tu, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M>
    VectorInitTensor<'l, T, i32, Chip, Cluster, Slice, Time, Packet>
{
    /// Performs inter-slice reduce for i32 as the first VE operation.
    #[primitive(VectorInitTensor::vector_inter_slice_reduce)]
    pub fn vector_inter_slice_reduce<OutSlice: M, OutTime: M>(
        self,
        op: InterSliceReduceOpI32,
    ) -> VectorInterSliceReduceTensor<'l, T, i32, Chip, Cluster, OutSlice, OutTime, Packet, { VeOrder::InterFirst }>
    {
        let reduced = self.inner.reduce(op.reduce_fn(), op.identity(), true);
        create_inter_slice_reduce_tensor(self.device, reduced)
    }
}

예제

다음은 Vector Engine 호출이 어떤 모습인지 감을 잡을 수 있도록 보여 주는 몇 가지 대표 예제입니다. 전체 API 안내는 하위 페이지인 Intra-Slice Chain과 Inter-Slice Reducer에서 다룹니다.

ReLU 활성화

이 패스는 ReLU를 요소별로 적용하여 \(output[b, k, m, n] = \max(input[b, k, m, n], 0)\)를 계산합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 2, K = 256, M = 16, N = 16];

// ReLU activation after batched matrix multiplication.
// Chain-only pass, so the reducer is skipped and the path trivially resolves to IntraFirst.
// Both clusters (B = 2) and all 256 slices (K) carry real data, no padding.
fn relu<'l, const T: Tu>(
    input: ContractTensor<'l, T, f32, m![1], m![B], m![K], m![M, N / 8], m![N % 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![K], m![M, N / 8], m![N % 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        // max(x, 0), the ReLU itself
        .vector_clip(ClipBinaryOpF32::Max, 0.0f32)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: ContractTensor<'_, _, f32, m![1], m![B], m![K], m![M, N / 8], m![N % 8]> = ContractTensor::new(&mut device.main, Tensor::zero());
let _o = relu(c);
}

ReLU 후 리덕션

이 패스는 Slice별로 ReLU를 적용한 다음 R에 걸쳐 리덕션하여 \(output[a, b] = \sum_{r \in R} \max(input[a, b, r], 0)\)를 얻습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, R = 4];

// Chain applies ReLU, then reducer sums across slices.
// IntraFirst shape (chain runs first, then reducer).
// Both clusters (B = 2), all 256 slices (A / 8 * R), and full Way8 packet (A % 8) carry real data.
fn relu_then_reduce<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8, R], m![1], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8, 1 # 4], m![1], m![A % 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        // max(x, 0), the ReLU
        .vector_clip(ClipBinaryOpI32::Max, 0)
        // sum across R slices
        .vector_inter_slice_reduce::<m![A / 8, 1 # 4], m![1]>(InterSliceReduceOpI32::AddSat)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8, R], m![1], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = relu_then_reduce(c);
}

리덕션 후 바이어스

이 패스는 R에 걸쳐 리덕션한 다음 상수 바이어스를 더하여 \(output[a, b] = \left(\sum_{r \in R} input[a, b, r]\right) + 100\)를 얻습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, R = 4];

// Reducer sums across slices, then chain adds a bias to the reduced result.
// InterFirst shape (reducer runs first, then chain).
// Both clusters (B = 2), all 256 slices, and full Way8 packet carry real data.
fn reduce_then_add<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8, R], m![1], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8, 1 # 4], m![1], m![A % 8]> {
    input
        .vector_init()
        // sum across R slices
        .vector_inter_slice_reduce::<m![A / 8, 1 # 4], m![1]>(InterSliceReduceOpI32::AddSat)
        .vector_intra_slice_tag(TagMode::Zero)
        // add bias 100
        .vector_fxp(FxpBinaryOp::AddFxp, 100)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8, R], m![1], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = reduce_then_add(c);
}

Slice 내부 및 Slice 간 리덕션

이 패스는 Slice 내부 리듀서(R의 Time 및 Packet 부분 담당)와 Inter-Slice Reducer(R의 Slice 부분 담당)를 결합하여 R을 완전히 리덕션합니다. einsum 형식은 BR -> B이며, 포화 덧셈을 사용합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 2, R = 8192];

// R splits across Slice (R / 32 = 256), Time (R % 32 / 4 = 8), and Packet (R % 4, padded to 8 in Way8).
// Chain runs intra-slice reduce over R's Time and Packet portions, then the reducer collapses the Slice portion.
// IntraFirst shape (chain runs first, then reducer).
fn full_sum<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![R / 32], m![R % 32 / 4], m![R % 4 # 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![1 # 256], m![1], m![1 # 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        // Way8 → Way4 (back 4 packet positions were padding)
        .vector_narrow_trim::<m![R % 4]>()
        // sum over R's Time and Packet portions
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(IntraSliceReduceOpI32::AddSat)
        // Way4 → Way8
        .vector_widen_pad::<m![1 # 8]>()
        // sum over R's Slice portion across all 256 slices
        .vector_inter_slice_reduce::<m![1 # 256], m![1]>(InterSliceReduceOpI32::AddSat)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![B], m![R / 32], m![R % 32 / 4], m![R % 4 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = full_sum(c);
}

쌍 덧셈

이 패스는 I를 따라 교차 배치된 두 그룹을 분리한 다음 쌍별로 더합니다. einsum 형식은 ABI -> AB입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 2, I = 2];

// Pair-mode entry via unzip, then a zip op fuses the two streams with an add.
// Both clusters (B = 2), all 256 slices (A / 8), and full Way8 packet (A % 8) carry real data.
fn pair_add<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8], m![I], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> {
    input
        .vector_init()
        // split into group 0 and group 1 along I
        .vector_intra_slice_unzip::<I, m![1]>()
        // group0 + group1
        .vector_clip_zip(ClipBinaryOpI32::AddFxp)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8], m![I], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = pair_add(c);
}

Intra-Slice Chain

Intra-Slice Chain은 각 Slice의 데이터에 대해 요소별 연산, 이항 연산, Intra-Slice Reduce 연산을 독립적으로 수행합니다. 활성화와 정규화 같은 축약 이후의 처리를 담당합니다. 예를 들어 \(\operatorname{sigmoid}(XW + b)\) 계산에서는 \(XW\)가 Contraction Engine에서 실행되고, 덧셈과 sigmoid 활성화는 Intra-Slice Chain에서 실행됩니다.

인터페이스

아래 예시는 고정소수점 바이어스를 적용하고, 부동소수점 경로에서 sigmoid를 실행한 뒤(4-way로 좁혔다가 다시 넓힘), ReLU clip으로 마무리합니다. 이 예제가 계산하는 식은 \(output[a, b] = \max(\operatorname{sigmoid}(input[a, b] + 100), 0)\)입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2];

fn staged_pipeline<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
    .vector_init()
    .vector_intra_slice_tag(TagMode::Zero)
    // input + 100
    .vector_fxp(FxpBinaryOp::AddFxp, 100)
    // i32 → f32 (fixed-point, int_width = 31)
    .vector_fxp_to_fp(31)
    // Way8 → Way4 for the float path
    .vector_narrow_trim::<m![A % 2 # 4]>()
    // sigmoid(input + 100)
    .vector_fp_unary(FpUnaryOp::Sigmoid)
    // Way4 → Way8
    .vector_widen_pad::<m![A % 2 # 8]>()
    // f32 → i32
    .vector_fp_to_fxp(31)
    // max(sigmoid(input + 100), 0)
    .vector_clip(ClipBinaryOpI32::Max, 0)
    .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = staged_pipeline(c);
}

파이프라인

체인은 (위에서 보인 것처럼) vector_intra_slice_tag()로 시작하거나(vector_init() 직후 또는 Inter-Slice Reducer의 출력에서), Pair Mode에서는 vector_init() 직후에 vector_intra_slice_unzip()으로 시작합니다(아래 참조).

    #[primitive(VectorInitTensor::vector_intra_slice_tag)]
    pub fn vector_intra_slice_tag(
        self,
        branch: TagMode<D>,
    ) -> VectorBranchTensor<'l, T, D, Chip, Cluster, Slice, Time, Packet, Fresh, { VeOrder::IntraFirst }> {

    #[primitive(VectorInitTensor::vector_intra_slice_unzip)]
    pub fn vector_intra_slice_unzip<I: AxisName, SplitTime: M>(
        self,
    ) -> VectorTensorPair<'l, T, D, stage::Tag, Chip, Cluster, Slice, SplitTime, Packet> {

진입 후 체인은 아래의 파이프라인 단계를 정해진 순서대로 거칩니다. 소프트웨어는 필요한 단계만 연결하고 나머지는 건너뛰며, 예제에서는 Logic, FpDiv, Filter를 건너뜁니다. 각 행에는 체인 내 단계의 위치(#), 단계 이름(Stage), 해당 단계를 트리거하는 API 메서드(Method), 실행되는 way(Way, 사이클당 8개 또는 4개 요소), 피연산자를 받는지 여부(Operand)가 나열되어 있습니다. 타입 시스템이 모든 단계 전환을 컴파일 시점에 강제하므로, 메서드는 앞선 체인이 호환되는 상태에 도달한 뒤에만 호출할 수 있습니다. 단계별 세부 사항은 아래의 단계에 있습니다.

#단계메서드Way피연산자→ Inter-Slice Reducer
1항목vector_intra_slice_tag()Way8––
2Logicvector_logic()Way8예예
3Fxpvector_fxp()Way8예예
4FxpToFpvector_fxp_to_fp()Way8–예
5Narrowvector_narrow_split() / vector_narrow_trim()Way8 → Way4––
6Floatvector_fp_unary/binary/ternary()Way4예–
7IntraSliceReducevector_intra_slice_reduce()Way4––
8FpDivvector_fp_div()Way4예–
9Widenvector_widen_concat() / vector_widen_pad()Way4 → Way8–예
10FpToFxpvector_fp_to_fxp()Way8–예
11Clipvector_clip()Way8예예
12Filtervector_filter()Way8––

vector_reinterpret()는 의도적으로 표에서 제외했습니다. 이 함수는 어떤 단계도 차지하지 않으며 임의의 두 단계 사이에 위치할 수 있습니다(Reinterpret 참조).

각 단계는 8-way(사이클당 8개 요소) 또는 4-way(사이클당 4개 요소)로 실행됩니다. 부동소수점 Cluster는 처리량이 절반인 ALU를 체인의 나머지 부분과 균형 맞추기 위해 4-way로 실행됩니다. 따라서 부동소수점 경로를 사용하는 체인은 8-way로 진입하여 부동소수점 단계 앞에서 Narrow(vector_narrow_split 또는 vector_narrow_trim)를 호출하고, 그 뒤에 Widen(vector_widen_concat 또는 vector_widen_pad)을 호출하여 8-way로 돌아갑니다. 예제가 바로 이렇게 동작합니다. vector_narrow_trim, vector_fp_unary(Sigmoid), vector_widen_pad를 차례로 호출합니다.

체인은 (예시에서처럼) vector_final()로 빠져나가거나, vector_inter_slice_reduce()로 빠져나가 Inter-Slice Reducer에 넘깁니다. 두 출구 모두 8-way를 요구하므로, 활성 상태인 4-way 단계는 먼저 Widen을 거쳐야 합니다.

피연산자

이항 연산과 삼항 연산에는 두 종류의 슬롯이 있습니다. 하나는 스트림(진행 중인 텐서, 즉 메서드 체인의 self로, 체인에 의해 고정됨)이고, 다른 하나는 한두 개의 피연산자(추가 입력)입니다. 각 피연산자는 세 가지 출처 중 하나에서 옵니다.

출처예시설명
상수100, 2.5f32모든 요소에 브로드캐스트되는 스칼라
VRF 텐서&vrf_tensorVector Engine에 들어가기 전에 .to_vrf()로 미리 로드됨
스태시Stash앞선 체인 단계의 스냅샷으로, 아래에서 설명함

삼항 연산(FmaF)은 (operand0, operand1) 쌍을 받습니다.

VRF 피연산자는 인덱서가 Slice마다 읽으며, 인덱서는 여기에 세 가지 규칙을 부과합니다.

첫째, 피연산자의 Chip / Cluster / Slice는 스트림의 것과 같습니다. Slice s에서 연산은 Slice s가 가진 것을 읽으므로, 다른 파티션 아래의 피연산자는 다른 Slice의 데이터를 조용히 읽는 대신 컴파일 오류가 됩니다. 두 파티션이 같은 Slice를 기술하는 경우 VrfTensor::reshape는 피연산자를 스트림의 파티션 아래로 다시 기술합니다. 예를 들어 익명의 m![256] 아래에 복제된 피연산자가 이름 있는 축으로 파티션된 스트림에 공급되는 경우가 이에 해당합니다.

둘째, 한 번의 접근(Packet: 8-way에서는 8개 레인, 4-way에서는 4개 레인)은 인덱서의 한 단계이며, 인덱서가 제공하는 읽기 방식은 두 가지뿐입니다. 브로드캐스트는 한 주소에서 모든 레인에 값을 공급하고, 연속 읽기는 연속된 Element 셀을 Packet개 읽습니다. 따라서 패킷 축은 피연산자의 가장 안쪽에 있는 연속 축이거나 피연산자에 아예 없어야 합니다. 피연산자의 바깥쪽 축을 순회하는 패킷이나, 한 번의 접근 안에서 브로드캐스트 레인과 실제 레인을 섞는 패킷은 인코딩이 없으므로 거부됩니다.

셋째, 축 불일치의 두 방향은 서로 같지 않습니다. 피연산자에는 있고 스트림에는 없는 축은 그 축이 인덱싱하는 셀이 결코 읽히지 않으므로 the stream leaves operand axes unmatched와 함께 거부됩니다. 스트림에는 있고 피연산자에는 없는 축은 브로드캐스트이며, 이를 통해 하나의 피연산자가 스트림의 모든 행에 공급됩니다.

같은 연산 메서드는 인자 타입에 따라 서로 다른 출처를 받아들입니다.

.vector_fxp(FxpBinaryOp::AddFxp, 100)         // operand from constant
.vector_fxp(FxpBinaryOp::MulInt, &vrf)        // operand from VRF tensor
.vector_clip(ClipBinaryOpI32::Max, Stash)     // operand from stash (set earlier)

Stash 소스는 vector_stash()에서 나옵니다. vector_stash()는 진행 중인 텐서의 스냅샷을 만들어, 이후의 이항 또는 삼항 연산이 이를 Stash 피연산자로 다시 읽을 수 있게 합니다. 대표적인 용도는 max(f(x), x)와 같은 잔차 연결(residual connection)이나 스킵 연결(skip connection)입니다. 이 경우 원래의 x가 중간 단계를 거치는 동안 유지되어야 합니다. 스태시는 별도의 피연산자 소스로 제시되지만 실제로는 VRF를 기반으로 합니다. 현재 진행 중인 텐서의 일부가 VRF에 저장되고 그곳에서 다시 읽힙니다. Tensor Unit 호출이 스태시를 사용할 때마다 컴파일러는 실제로 스태시되는 데이터의 양과 관계없이 Slice당 1,024 B의 VRF 용량을 스태시용으로 보수적으로 예약합니다. 해당 호출이 미리 로드된 VRF 텐서도 RHS 피연산자로 읽는다면 이 예약을 고려하여 피연산자의 크기를 정하십시오. 스태시와 RHS는 8 KiB VRF를 공유하므로 RHS에 남는 용량은 Slice당 최대 7 KiB입니다. vector_stash()는 Stashable 단계(Branch, Logic, Fxp, Narrow, Fp, FpDiv, Clip) 중 어디에서든 호출하십시오. 스냅샷은 Tensor Unit 호출이 끝날 때까지 유지되며, Stash를 받는 이후의 이항 또는 삼항 호출에 전달됩니다. 이 슬롯은 한 번만 쓸 수 있고(두 번째 vector_stash()는 컴파일 타임 오류입니다) 타입이 지정되므로, f32 스태시는 f32 연산에만 전달됩니다. 쓰기와 읽기 사이에 변환이나 재해석이 있다면 먼저 이를 되돌려야 합니다. 스태시는 또한 한 번만 읽을 수 있습니다. 스태시는 이후의 연산 정확히 하나에만 전달됩니다(스태시를 읽으면 슬롯이 Occupied 상태를 지나가므로 두 번째 Stash 읽기는 컴파일 타임 오류입니다). 두 번 이상 읽어야 하는 값은 스태시에 넣지 않습니다. 그런 값은 여러 번 읽을 수 있는 VRF에 넣고 &vrf로 전달하십시오. 매핑은 진행 중인 텐서를 따라가므로, Narrow 이전에 만든 스태시는 Widen 이후에도 여전히 사용할 수 있습니다. 이 일곱 단계는 VRF 쓰기 포트를 가진 일곱 개의 체인 단계이며, Widen이 여기에 포함되지 않는 이유도 이 때문입니다. 피연산자 레지스터에는 widen의 출력을 아예 쓸 수 없습니다. 따라서 4-way 영역에서 계산된 값을 스태시에 넣으려면 먼저 widen한 뒤 Clip 연산에서 스태시해야 합니다. 이렇게 하면 쓰기와 읽기가 모두 8-way 쪽에서 이루어집니다. 스태시는 Pair Mode에서 사용할 수 없으며, Inter-Slice Reducer로 넘어가는 IntraFirst 전환에서 제거됩니다. 따라서 vector_inter_slice_reduce() 이전에 스태시한 내용은 그 이후에 사라집니다.

그런 다음 인자 모드가 어느 슬롯에 스트림을 두고 어느 슬롯에 피연산자를 둘지 선택하므로, 같은 연산으로 예를 들어 stream + operand나 operand - stream을 계산할 수 있습니다. 예를 들어 BinaryArgMode::Mode10은 슬롯을 서로 바꾸어 SubFxp가 operand - stream을 계산하게 합니다.

.vector_fxp_with_mode(FxpBinaryOp::SubFxp, BinaryArgMode::Mode10, 7)  // computes 7 - stream

BinaryArgMode는 이항 연산의 두 슬롯 중 어느 쪽이 스트림이고 어느 쪽이 피연산자인지 선택합니다(단항 연산에는 모드가 없으며 항상 op(stream)으로 실행됩니다).

BinaryArgMode슬롯계산
Mode00스트림 / 스트림op(stream, stream)
Mode01스트림 / 피연산자op(stream, operand) (기본값)
Mode10피연산자 / 스트림op(operand, stream)
Mode11피연산자 / 피연산자op(operand, operand)

TernaryArgMode는 삼항 연산에 대해서도 같은 역할을 합니다:

TernaryArgMode슬롯계산
Mode012stream / operand0 / operand1op(stream, operand0, operand1) (기본값)
Mode002stream / stream / operand1op(stream, stream, operand1)
Mode102operand0 / stream / operand1op(operand0, stream, operand1)
Mode112operand0 / operand0 / operand1op(operand0, operand0, operand1)
Mode020stream / operand1 / streamop(stream, operand1, stream)
Mode021stream / operand1 / operand0op(stream, operand1, operand0)
Mode120operand0 / operand1 / streamop(operand0, operand1, stream)

Pair Mode

Pair Mode는 요소가 서로 인터리빙된 두 그룹으로 나뉘는 텐서에서 체인을 실행하므로, 연산이 두 그룹을 서로 연관 지을 수 있습니다(예: 쌍별 덧셈, 비대칭 스케일). 진입점은 vector_init() 바로 다음에 오는 vector_intra_slice_unzip()이며, 2-way 그룹화 축을 가진 수집된 텐서에 적용됩니다. 체인을 vector_intra_slice_unzip()으로 시작하면 하류의 Filter 단계를 사용할 수 없습니다. 내부적으로 vector_intra_slice_unzip()은 2-way 그룹화 축을 따른 위치로부터 각 요소의 GroupId를 도출하며, 이는 TagMode가 아니라 branch unit의 count augmenter를 구동합니다. 체인을 시작할 때 사용한 태그는 변경되지 않습니다. 이 때문에 그룹 분할에는 이 방법이 지원되는 경로이며 TagMode::AxisToggle은 지원되지 않습니다.

이 흐름은 네 단계로 이루어집니다:

  1. vector_intra_slice_unzip()는 입력을 두 개의 병렬 스트림(group 0과 group 1)으로 나눕니다.
  2. 체인은 두 그룹이 보조를 맞추면서 여러 단계를 거쳐 실행됩니다(paired 페이즈).
  3. _zip 연산이 두 스트림을 다시 하나로 합칩니다(merged 페이즈).
  4. 병합된 스트림은 일반 체인과 마찬가지로 vector_final()까지 이어집니다.

paired 페이즈 동안의 단계는 두 종류로 나뉩니다.

  • 공통 단계(vector_fxp_to_fp, vector_narrow_split, vector_widen_concat, vector_fp_to_fxp)는 두 그룹에 동일하게 작용합니다. vector_narrow_trim과 vector_widen_pad는 쌍에서 사용할 수 없으므로 대신 _split / _concat 변형을 사용하십시오.
  • 그룹별 연산은 그룹마다 인자를 하나씩 받습니다.
    • 이항 및 삼항 연산(vector_fxp, vector_fp_binary, vector_fp_ternary, vector_clip 등)은 한쪽에 ()를 넘겨 해당 쪽을 건너뛰거나, 양쪽에 서로 다른 피연산자를 받을 수 있습니다.
    • 단항 연산(vector_fp_unary)은 예외입니다. 이 연산은 플래그 (op, group0_apply, group1_apply)를 받으며, false이면 해당 그룹을 건너뜁니다.

Pair Mode는 연산에 따라 BinaryArgMode를 다르게 해석합니다. 그룹별 연산(vector_fxp_with_mode, vector_fp_binary_with_mode 등)은 각 그룹 안에서 독립적으로 모드를 적용하며(0은 해당 그룹의 스트림, 1은 해당 그룹의 피연산자), _zip 연산(vector_fxp_zip_with_mode 등)은 두 슬롯을 그룹으로 나뉜 두 스트림으로 받습니다(0은 Group 0의 스트림, 1은 Group 1의 스트림).

_zip BinaryArgMode슬롯계산
Mode00group0 / group0op(group0, group0)
Mode01group0 / group1op(group0, group1) (기본값)
Mode10group1 / group0op(group1, group0)
Mode11group1 / group1op(group1, group1)

페어 모드의 제약 조건은 다음과 같습니다.

  • stash()와 filter()는 Pair Mode 전체(쌍 단계와 병합 단계 모두)에서 사용할 수 없습니다.
  • vector_intra_slice_reduce() 역시 _zip 전후를 막론하고 Pair Mode 전체에서 사용할 수 없습니다. 리듀서가 그룹 조건을 받지 않기 때문입니다.
  • _zip 이전(paired 페이즈)에는 그룹별 텐서에서 vector_inter_slice_reduce()를 사용할 수 없으므로 체인이 Inter-Slice Reducer로 전환할 수 없습니다. _zip 이후(merged 페이즈)에는 결과가 다시 Commitable이 되며, 현재 단계가 해당 전환을 지원하면 vector_inter_slice_reduce()를 호출할 수 있습니다.
  • ALU 사용량은 두 그룹이 공유합니다. 어느 한 그룹에서 사용한 ALU는 두 그룹 모두에서 소모된 것으로 간주됩니다.

단계

하나의 단계 안에서 각 ALU는 Tensor Unit 호출 한 번당 최대 한 번만 실행됩니다. 이는 주로 여러 연산자가 단계 로컬 ALU 풀을 공유하는 Logic, Fxp, Fp, Clip에서 중요합니다. 예를 들어 tanh(sqrt(x))는 tanh와 sqrt가 모두 FpFpu ALU를 사용하므로 단일 Tensor Unit 호출에 들어갈 수 없습니다.

Tag

Tag 단계는 체인의 진입점으로, 플릿 안의 각 32비트 요소에 4비트 Tag(0-15)를 부여하며, 이후 단계에서는 이를 사용해 조건부 연산을 적용합니다. 비트 3(MSB)은 GroupId이며, Filter와 Pair Mode에서 요소를 Group 0 / Group 1로 나누는 데 사용됩니다. 비트 0..2는 비교 결과로 채워지는 범용 플래그 비트입니다.

TagMode는 각 요소에 대해 4개의 비트를 계산하는 방식을 선택합니다. 현재는 Zero와 Comparison만 사용할 수 있습니다. 마지막 열에 기록된 대로 AxisToggle은 선언되어 있지만 컴파일할 수 없으며, ValidCount와 Vrf는 두 경로 모두에서 실행될 때까지 열거형에서 아예 제외되어 있습니다.

TagMode각 태그 비트를 채우는 방식상태
Zero네 비트가 모두 0입니다. 모든 요소의 태그가 0입니다.동작함
Comparison([cmp0, cmp1, cmp2, cmp3])각 요소 x에 대해 cmp_i(x)가 성립하는 경우에만 비트 i = 1입니다. 각 cmp_i는 비교 대상 경계값을 담은 Cmp<D>이며, 스트림의 스칼라 타입으로 타입이 지정되므로 경계값은 그 타입에 맞는지 검사됩니다: Equal, Less, Greater, LessUnsigned, GreaterUnsigned, True, False. *Unsigned 쌍은 원시 비트 패턴을 비교합니다.동작함
AxisToggle { axis }비트 3(GroupId) = axis를 따라 axis_index % 2입니다. 비트 0..2는 0으로 유지됩니다.컴파일할 수 없습니다. 호스트에서는 실행되지만, lowering 시 커널 컴파일 오류로 거부됩니다. 이 그룹화를 얻는 지원되는 방법은 vector_intra_slice_unzip()입니다.

예를 들어 i32 데이터와 Comparison([Cmp::Less(0), Cmp::Equal(5), Cmp::Greater(100), Cmp::True])를 사용하면, 요소 x = 7은 비트 0/0/0/1(LSB부터)을 만들어 내므로 태그는 0b1000 = 8입니다.

태그가 할당되면 이후의 이항 및 삼항 연산은 태그에 따라 피연산자에 조건을 걸 수 있으므로, 요소마다 다른 값을 보게 됩니다. 규칙은 한 문장입니다. 조건이 없으면 피연산자를 그대로 쓰고, 조건이 있으면 Branched를 통해 그 피연산자가 구동하는 하드웨어 슬롯을 지정하십시오. furiosa-opt-examples의 ve_elementwise_branched는 바로 그렇게 하는 컴파일된 커널이며(조건이 걸린 즉시 값 하나와 그 뒤에 오는 무조건 rf 읽기), ve_branch_bit_order는 즉시 값 세 개와 조건이 걸린 스태시 읽기를 추가합니다. 두 커널은 여기서 다시 서술하지 않고 직접 읽어 오므로, API가 바뀌면 설명이 뒤처진 채 남는 대신 해당 커널이 깨집니다.

TagGuard는 TagGuard::all(), 하나의 그룹에 대한 TagGuard::group(id), 또는 LSB부터 작성하고 태그 비트마다 BitReq를 하나씩 두는 [BitReq; 4] 패턴의 matches/not_matches 중 하나입니다. 비트가 1이어야 하면 One, 0이어야 하면 Zero, 상관없으면 Ignore를 씁니다. 이 요구 조건은 태그 모드가 무엇으로 비트를 채웠든 비트의 값에 적용됩니다.

한 패스에는 피연산자 슬롯이 네 개 있습니다. 즉시 레지스터 세 개와 레지스터 파일 포트이며, 이 포트는 VRF 레지스터 또는 스태시를 읽습니다. 조건부 피연산자는 자신이 구동하는 슬롯의 종류를 지정합니다. 즉시값에는 Branched::imm, 포트에는 Branched::rf를 사용하며, 그 결과로 뒤에 이어질 수 있는 것만 제공하는 빌더를 돌려받습니다. imm은 세 번, rf는 마지막에 한 번 호출할 수 있으며, 네 번째 imm이나 rf 뒤의 imm은 컴파일되지 않습니다.

커널은 어느 즉시값 레지스터를 쓸지 고르지 않습니다. imm은 다음 레지스터를 가져가며, 값이 최종적으로 어느 물리 레지스터에 들어가는지는 명령 생성 단계가 결정합니다. 보존되는 것은 호출 순서입니다. 슬롯은 채워진 순서대로 하드웨어에 도달하고 그 순서가 곧 매치 우선순위입니다. 따라서 요소는 자신이 만족하는 가드를 가진 첫 번째 슬롯을 취하며, 무조건 슬롯은 뒤에 아무것도 오지 않는 패스의 else가 됩니다.

두 가지 가드는 곧바로 거부됩니다. 이러한 가드를 가진 슬롯은 자리만 차지하고 아무 동작도 하지 않는데, 패스에는 슬롯이 네 개뿐이기 때문입니다. 어떤 실행 id도 만족하지 않는 TagGuard::not_matches([Ignore, Ignore, Ignore, Ignore]), 그리고 무조건 슬롯 뒤에 놓인 모든 슬롯이 이에 해당합니다. TagGuard::matches([Ignore, Ignore, Ignore, Ignore])는 허용되며, 같은 조건을 더 짧게 표기한 TagGuard::all()로 저장됩니다.

레이아웃을 만들어 돌려주는 헬퍼는 일반 함수이므로, 커널 라이브러리는 자주 쓰는 조합을 무엇이든 감쌀 수 있습니다. 이러한 헬퍼가 할 수 없는 일이 두 가지 있으며, 둘 다 operand 모듈에 자세히 문서화되어 있습니다. 첫째, 새로운 피연산자 타입을 도입할 수 없습니다(피연산자는 스태시에 무엇을 하는지에 대한 선언을 담고 있으므로, 이를 나타내는 트레이트는 닫혀 있습니다). 둘째, 슬롯을 조건부로 채울 수 없습니다. 몇 개의 슬롯이 사용되었는지가 빌더 타입의 일부이므로 if의 두 갈래가 서로 일치하지 않게 되기 때문입니다. 슬롯 개수 대신 페이로드나 가드를 바꾸거나, 연산 전체를 분기로 감싸십시오.

Logic Cluster

Logic Cluster는 i32 또는 f32에 대해 비트 연산(비트 수준)을 수행합니다. 8-way로 동작합니다.

이 단계는 다섯 가지 ALU 클래스(LogicAnd, LogicOr, LogicXor, LogicLshift, LogicRshift)를 제공하며, 각 클래스는 Tensor Unit 호출당 한 번씩 실행할 수 있습니다. 같은 클래스를 공유하는 연산자들은 하나의 호출로 융합할 수 없습니다.

i32 연산:

연산ALU비고
BitAndLogicAnd비트 AND
BitOrLogicOr비트 OR
BitXorLogicXor비트 XOR
LeftShiftLogicLshift논리 왼쪽 시프트
LogicRightShiftLogicRshift논리 오른쪽 시프트
ArithRightShiftLogicRshift산술 오른쪽 시프트

f32 연산:

연산ALU비고
BitAndLogicAnd부동소수점 비트 패턴에 대한 비트 단위 AND
BitOrLogicOr부동소수점 비트 패턴에 대한 비트 단위 OR
BitXorLogicXor부동소수점 비트 패턴에 대한 비트 단위 XOR

Fxp Cluster

Fxp Cluster는 i32에 대해 정수 및 고정소수점 산술 연산을 수행합니다. 8-way로 동작합니다.

이 단계는 네 가지 ALU 클래스(FxpAdd, FxpLshift, FxpMul, FxpRshift)를 제공하며, 각 클래스는 Tensor Unit 호출당 한 번씩 실행할 수 있습니다. 같은 클래스를 공유하는 연산자들은 하나의 호출로 융합할 수 없습니다.

연산ALU비고
AddFxpFxpAdd래핑 덧셈
AddFxpSatFxpAdd포화 덧셈
SubFxpFxpAdd래핑 뺄셈
SubFxpSatFxpAdd포화 뺄셈
LeftShiftFxpLshift논리 왼쪽 시프트
LeftShiftSatFxpLshift포화 왼쪽 시프트
MulFxpFxpMul고정소수점 곱셈
MulIntFxpMul정수 곱셈
LogicRightShiftFxpRshift논리 오른쪽 시프트
ArithRightShiftFxpRshift산술 오른쪽 시프트
ArithRightShiftRoundFxpRshift반올림을 적용한 산술 오른쪽 시프트

예를 들어 단일 ALU 규칙은 둘 다 FxpAdd를 대상으로 하는 두 연산을 거부합니다:

// PANICS: "FxpAdd is already in use"
input
    .vector_init()
    .vector_intra_slice_tag(TagMode::Zero)
    .vector_fxp(FxpBinaryOp::AddFxp, 10)    // uses FxpAdd
    .vector_fxp(FxpBinaryOp::MulInt, 2)     // uses FxpMul ✓
    .vector_fxp(FxpBinaryOp::SubFxp, 5)     // uses FxpAdd again ✗
    .vector_final()

FxpToFp 변환

FxpToFp 변환 단계는 i32를 f32로 변환합니다. int_width 매개변수는 변환에 사용할 정수 비트 폭을 지정합니다. int_width = 31은 표준 i32 ↔ f32 변환입니다.

메서드효과
vector_fxp_to_fp(int_width)i32 스트림을 f32로 변환합니다

Narrow

Narrow 단계는 8-way를 4-way로 전환합니다. 8-way 패킷은 활성 요소 8개(Packet = m![... # 8])를, 4-way 패킷은 4개(Packet = m![... # 4])를 전달합니다. Narrowing을 하면 float 경로와 reduce 경로의 처리량이 절반으로 줄어들므로, 논리적 텐서 형태가 같아도 패킷 수나 Tensor Unit 호출 횟수가 두 배로 늘어납니다.

메서드사용 시점효과
vector_narrow_split()앞뒤 절반 모두에 실제 데이터가 있는 경우8-way 플릿 하나를 앞쪽 4개와 뒤쪽 4개 패킷으로 나누고 Time과 Packet을 갱신합니다
vector_narrow_trim()뒤쪽 4개 요소가 이미 패딩이거나 필요 없는 경우앞쪽 4개 요소만 남깁니다

형태 의미론:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, S = 64];

fn vector_narrow_split_semantics<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![B], m![S / 4 # 256], m![S % 4], m![A % 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorNarrowTensor<'l, T, i32, m![1], m![B], m![S / 4 # 256], m![S % 4, A / 4 % 2], m![A % 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input.vector_narrow_split::<m![S % 4, A / 4 % 2], m![A % 4]>()
    // shape semantics: [T], [P] -> [T, P / 2], [P % 4]
}

fn vector_narrow_trim_semantics<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorNarrowTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input.vector_narrow_trim::<m![A % 2 # 4]>()
    // shape semantics: [T], [P] -> [T], [P = 4]
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![B], m![S / 4 # 256], m![S % 4], m![A % 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = vector_narrow_split_semantics(i);

let i: VectorBranchTensor<'_, _, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = vector_narrow_trim_semantics(i);
}

Float Cluster

Float Cluster는 f32에 대한 단항, 이항, 삼항 부동소수점 연산을 제공합니다. 4-way로 동작하므로 입력이 이미 Narrow를 거친 상태여야 합니다.

서로 독립적인 ALU 5개(FpFma, FpFpu, FpExp, FpMul0, FpMul1)를 제공하며, 각 ALU는 Tensor Unit 호출 한 번에 한 번씩 실행할 수 있습니다. ALU 계획이 가장 중요한 단계가 바로 이 단계입니다.

단항 연산:

연산ALU비고
ExpFpExp지수 함수
NegExpFpExp음의 지수 함수
SqrtFpFpu제곱근
TanhFpFpu쌍곡탄젠트
SigmoidFpFpu시그모이드
ErfFpFpu오차 함수
LogFpFpu자연로그
SinFpFpu사인
CosFpFpu코사인

이항 연산:

연산ALU비고
AddFFpFma부동소수점 덧셈
SubFFpFma부동소수점 뺄셈
MulF(FpMulAlu::Mul0)FpMul0곱셈
MulF(FpMulAlu::Mul1)FpMul1곱셈
MulF(FpMulAlu::Fma)FpFma곱셈
DivFFpFpuFp 단계 내부의 나눗셈

삼항 연산:

연산ALU비고
FmaFFpFma융합 곱셈-덧셈(fused multiply-add)

예를 들어 exp(sqrt(((x + 1) * 2) * 3))를 계산하려면 다음과 같이 합니다.

  • FpFma(FpBinaryOp::AddF)로 x1 = x + 1 계산
  • FpMul0(FpBinaryOp::MulF(FpMulAlu::Mul0))으로 x2 = x1 * 2 계산
  • FpMul1(FpBinaryOp::MulF(FpMulAlu::Mul1))로 x3 = x2 * 3 계산
  • FpFpu(FpUnaryOp::Sqrt)로 x4 = sqrt(x3) 계산
  • FpExp(FpUnaryOp::Exp)로 x5 = exp(x4) 계산

IntraSliceReduce

IntraSliceReduce 단계는 단일 Slice 내의 축을 리덕션합니다. 이 단계는 4-way로 실행됩니다. 이 단계는 전용 누산기 트리(accumulator-tree) ALU를 사용하므로 사용자가 선택할 수 있는 ALU는 제공되지 않습니다.

데이터 타입지원 연산
i32AddSat, Max, Min
f32Add, Max, Min

자세한 내용은 Intra-Slice Reduce를 참고하십시오.

FpDiv

FpDiv 단계는 부동소수점 나눗셈을 수행합니다. 이 단계는 4-way로 동작합니다. 이 단계는 전용 부동소수점 나눗셈기를 사용하므로 사용자가 선택할 수 있는 ALU를 제공하지 않습니다.

연산비고
FpDivBinaryOp::DivF전용 부동소수점 나눗셈

Widen

Widen 단계는 4-way에서 다시 8-way로 전환합니다. 이후 단계(FpToFxp, Clip, Filter, Output)는 다시 8개 요소로 이루어진 패킷을 받습니다.

메서드사용 시점효과
vector_widen_concat()앞서 수행한 vector_narrow_split()을 되돌릴 때4-way 패킷 두 개를 다시 8-way 플릿 하나로 병합합니다
vector_widen_pad()앞서 수행한 vector_narrow_trim()을 되돌릴 때4-way 패킷을 유효하지 않은 채움 값으로 패딩하여 다시 8개 요소로 복원

형태 의미론:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, S = 64, R = 8];

fn vector_widen_concat_semantics<'l, const T: Tu>(
    input: VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![B], m![S / 4 # 256], m![A / 4 % 2], m![A % 4], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorWidenTensor<'l, T, i32, m![1], m![B], m![S / 4 # 256], m![1], m![A % 8], Fresh, { stage::VeOrder::IntraFirst }>
{
    input.vector_widen_concat::<m![1], m![A % 8]>()
    // shape semantics: [T, P / 2], [P % 4] -> [T], [P]
}

fn vector_widen_pad_semantics<'l, const T: Tu>(
    input: VectorFpTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 4], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorWidenTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8], Fresh, { stage::VeOrder::IntraFirst }>
{
    input.vector_widen_pad::<m![A % 2 # 8]>()
    // shape semantics: [T], [P] -> [T], [P # 8]
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![B], m![S / 4 # 256], m![R, A / 4 % 2], m![A % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let i = i
    .vector_narrow_trim::<m![A % 4]>()
    .vector_intra_slice_reduce::<R, m![A / 4 % 2], m![A % 4]>(IntraSliceReduceOpI32::AddSat);

let _o = vector_widen_concat_semantics(i);

let i: VectorBranchTensor<'_, _, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let i = i.vector_narrow_trim::<m![A % 2 # 4]>().vector_fp_unary(FpUnaryOp::Exp);
let _o = vector_widen_pad_semantics(i);
}

FpToFxp 변환

FpToFxp 변환 단계는 f32를 다시 i32로 변환합니다. int_width 매개변수는 정수 비트 폭을 지정합니다.

메서드효과
vector_fp_to_fxp(int_width)f32 스트림을 다시 i32로 변환

Clip Cluster

Clip Cluster는 클램핑 연산과 비교 연산을 수행합니다. 8-way로 동작합니다.

이 단계는 세 가지 ALU 클래스(ClipAdd, ClipMax, ClipMin)를 제공하며, 각 클래스는 Tensor Unit 호출 한 번당 한 번씩 실행할 수 있습니다.

i32 연산:

연산ALU비고
MinClipMin최솟값
MaxClipMax최댓값
AbsMinClipMin절댓값 기준 최솟값
AbsMaxClipMax절댓값 기준 최댓값
AddFxpClipAdd래핑 덧셈
AddFxpSatClipAdd포화 덧셈

f32 연산:

연산ALU비고
MinClipMin최솟값
MaxClipMax최댓값
AbsMinClipMin절댓값 기준 최솟값
AbsMaxClipMax절댓값 기준 최댓값
AddClipAdd부동소수점 덧셈

Filter

Filter 단계는 TagGuard에서 도출한 실행 마스크(일반적으로 각 요소 Tag의 GroupId MSB와 매칭)를 적용하여 출력 플릿을 필터링합니다. 8-way 및 Standalone 컨텍스트에서만 사용할 수 있습니다. 원본 impl은 CanTransitionTo<Filter>를 가진 모든 단계에 대해 VectorTensor에 정의되어 있으며, 여기에는 모든 intra-slice 단계와 InterSliceReduce가 포함됩니다.

출력

Output 단계에서 Vector Engine 파이프라인을 빠져나갑니다. 결과는 Cast Engine, Transpose Engine 또는 Commit Engine으로 이어질 수 있습니다.

Reinterpret

vector_reinterpret::<D2>()는 비트를 그대로 둔 채 스트림을 다른 32비트 스칼라로 다시 읽으므로, 1.0f32는 1이 아니라 0x3f80_0000이 됩니다. 모든 요소는 32비트이고 모든 Cluster는 실행하는 연산에서 피연산자 타입을 가져오므로, reinterpret는 명령어를 생성하지 않고 ALU를 점유하지 않으며 현재 단계와 way를 모두 그대로 유지합니다. 따라서 reinterpret는 같은 Cluster의 두 연산 사이를 포함하여 체인의 어느 위치에나 몇 번이든 나타날 수 있습니다.

reinterpret의 한 가지 활용 예는 f32에 대한 비트 단위 abs에서처럼 부동소수점 값에 비트 연산을 적용하는 것입니다. 값을 보존하는 변환에는 대신 FxpToFp / FpToFxp를 사용하십시오.

메서드효과
vector_reinterpret::<i32>()f32 스트림의 비트를 i32로 읽습니다
vector_reinterpret::<f32>()i32 스트림의 비트를 f32로 읽습니다

재해석은 뒤따르는 vector_stash()가 무엇을 쓰는지도 결정합니다. 스태시는 쓰기 시점에 스트림이 담고 있는 스칼라를 받기 때문입니다. 따라서 값을 다른 스칼라로 읽는 쪽에 전달하려면 먼저 reinterpret한 뒤 stash해야 합니다. 다른 스칼라로 읽으면 컴파일되지 않습니다.

예제

i32 파이프라인

분기 후 상수를 더하는 최소한의 i32 체인입니다. Fxp 단계는 8-way로 실행되므로 narrow나 widen이 필요하지 않습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 2];

fn add_constant<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        .vector_fxp(FxpBinaryOp::AddFxp, 100)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = add_constant(i);
}

f32 파이프라인

vector_narrow_trim()은 부동소수점 연산 전에 텐서를 8-way에서 4-way로 변환하는 Narrow 단계입니다. vector_widen_pad()는 연산 후에 다시 8-way로 변환하는 Widen 단계입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2];

fn sigmoid<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        .vector_narrow_trim::<m![A % 2 # 4]>() // Narrow: Way8 -> Way4
        .vector_fp_unary(FpUnaryOp::Sigmoid)
        .vector_widen_pad::<m![A % 2 # 8]>() // Widen: Way4 -> Way8
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = sigmoid(i);
}

f32에 대한 비트 단위 Abs

부호 비트를 지우면 \(|x|\)이 되며, 이는 부동소수점 ALU 없이 얻을 수 있습니다. 이 연산이 필요한 단 하나의 op에서만 스트림을 i32로 읽으므로 마스크는 일반적인 i32 리터럴입니다. 두 재해석은 모두 비용이 들지 않으므로, 이 연산의 비용은 정확히 LogicAnd 하나입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2];

fn abs<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        .vector_reinterpret::<i32>() // read the bits as i32
        .vector_logic(LogicBinaryOpI32::BitAnd, 0x7fff_ffff) // clear the sign bit
        .vector_reinterpret::<f32>() // and back
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = abs(i);
}

단일 스트림 인자 모드

BinaryArgMode::Mode10은 스트림과 피연산자의 위치를 서로 바꾸므로, SubFxp는 기본값인 stream - operand 대신 operand - stream(여기서는 7 - x)을 계산합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 2];

fn bias_minus_x<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        .vector_fxp_with_mode(FxpBinaryOp::SubFxp, BinaryArgMode::Mode10, 7) // compute 7 - x
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = bias_minus_x(i);
}

VRF 피연산자

미리 로드된 VRF 데이터를 피연산자로 사용합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 2, N = 256];

fn vrf_add<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8], m![N], m![A % 8]>,
    vrf: &VrfTensor<i32, m![1], m![B], m![A / 8], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8], m![N], m![A % 8]> {
    input
        .vector_init()
        .vector_intra_slice_tag(TagMode::Zero)
        .vector_fxp(FxpBinaryOp::AddFxp, vrf)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8], m![N], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let v: VrfTensor<i32, m![1], m![B], m![A / 8], m![A % 8]> = VrfTensor::zero();
let _o = vrf_add(i, &v);
}

Fp 전용 경로에서의 스태시

이른 단계에서 스태시한 다음, 이후 Clip 연산에서 사용하십시오. 이는 max(2 * x, x)를 구현합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2];

fn residual_max<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
        .vector_init()                                        // enter VE
        .vector_intra_slice_tag(TagMode::Zero) // start the intra-slice path
        .vector_stash()                                       // save original x
        .vector_narrow_trim::<m![A % 2 # 4]>()                  // narrow to Way4
        .vector_fp_binary(FpBinaryOp::MulF(FpMulAlu::Mul0), 2.0f32) // compute 2 * x
        .vector_widen_pad::<m![A % 2 # 8]>()                   // widen back to Way8
        .vector_clip(ClipBinaryOpF32::Max, Stash)             // max(2 * x, x)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = residual_max(i);
}

Fxp 전용 경로에서의 스태시

이른 단계에서 스태시한 다음, 이후 Clip 연산에서 사용하십시오. 이는 max(x + bias, x)를 구현합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 2];

fn stash_at_fxp<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> {
    input
        .vector_init()                                      // enter VE
        .vector_intra_slice_tag(TagMode::Zero) // start the intra-slice path
        .vector_stash()                                     // save original x
        .vector_fxp(FxpBinaryOp::AddFxp, 100)               // compute x + bias
        .vector_clip(ClipBinaryOpI32::Max, Stash)           // compute max(x + bias, x)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = stash_at_fxp(i);
}

좁히기와 넓히기를 가로지르는 스태시

좁히기 전에 스태시하고, 넓힌 후에 사용하십시오. 이는 max(sigmoid(x), x)를 계산합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2];

fn stash_across_narrow_widen<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
        .vector_init()                                     // enter VE
        .vector_intra_slice_tag(TagMode::Zero) // start the intra-slice path
        .vector_stash()                                    // save x (Way8)
        .vector_narrow_trim::<m![A % 2 # 4]>()               // narrow to Way4
        .vector_fp_unary(FpUnaryOp::Sigmoid)               // compute sigmoid(x) in Way4
        .vector_widen_pad::<m![A % 2 # 8]>()                // widen back to Way8
        .vector_clip(ClipBinaryOpF32::Max, Stash)          // compute max(sigmoid(x), x)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = stash_across_narrow_widen(i);
}

Pair 덧셈

인터리브된 두 그룹을 정수 덧셈으로 zip합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 2, I = 2];

fn pair_add<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8], m![I], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> {
    input
        .vector_init()
        .vector_intra_slice_unzip::<I, m![1]>()
        .vector_clip_zip(ClipBinaryOpI32::AddFxp)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8], m![I], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = pair_add(i);
}

Pair 측별 전처리

비대칭 전처리는 zip 전에 그룹 0만 스케일링합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 2048, B = 2, I = 2];

fn pair_preprocess_one_side<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8], m![I], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8], m![1], m![A % 8]> {
    input
        .vector_init()
        .vector_intra_slice_unzip::<I, m![1]>()
        .vector_fxp(FxpBinaryOp::MulInt, 10, ())   // group 0 only
        .vector_clip_zip(ClipBinaryOpI32::AddFxp)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8], m![I], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = pair_preprocess_one_side(i);
}

Zip을 사용하는 Pair 부동소수점 파이프라인

두 그룹 모두 부동소수점 경로(narrow -> fp -> zip -> widen)를 거칩니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, I = 2];

fn pair_fp_mul_zip<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![A / 2], m![I], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
        .vector_init()
        .vector_intra_slice_unzip::<I, m![1]>()
        .vector_narrow_split::<m![1 # 2], m![A % 2 # 4]>()        // both groups: Way8 -> Way4
        .vector_fp_zip(FpBinaryOp::MulF(FpMulAlu::Mul0))   // group0 * group1 (Way4)
        .vector_widen_concat::<m![1], m![A % 2 # 8]>()           // Way4 -> Way8
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, f32, m![1], m![B], m![A / 2], m![I], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = pair_fp_mul_zip(i);
}

Pair 그룹별 전처리

zip하기 전에 각 그룹에 서로 다른 연산을 적용합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, I = 2];

fn pair_asymmetric_preprocess<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![A / 2], m![I], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
        .vector_init()
        .vector_intra_slice_unzip::<I, m![1]>()
        .vector_narrow_split::<m![1 # 2], m![A % 2 # 4]>()
        .vector_fp_unary(FpUnaryOp::Exp, true, false)         // group 0: exp(x), group 1: skip
        .vector_fp_zip(FpBinaryOp::MulF(FpMulAlu::Mul0))   // exp(group0) * group1
        .vector_widen_concat::<m![1], m![A % 2 # 8]>()
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, f32, m![1], m![B], m![A / 2], m![I], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = pair_asymmetric_preprocess(i);
}

Pair Zip 인자 모드

BinaryArgMode::Mode10은 zip할 때 그룹화된 두 스트림을 서로 바꿉니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, I = 2];

fn pair_sub_reverse<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![A / 2], m![I], m![A % 2 # 8]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![A / 2], m![1], m![A % 2 # 8]> {
    input
        .vector_init()
        .vector_intra_slice_unzip::<I, m![1]>()
        .vector_narrow_split::<m![1 # 2], m![A % 2 # 4]>()
        .vector_fp_zip_with_mode(FpBinaryOp::SubF, BinaryArgMode::Mode10) // compute group1 - group0
        .vector_widen_concat::<m![1], m![A % 2 # 8]>()
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: CollectTensor<'_, _, f32, m![1], m![B], m![A / 2], m![I], m![A % 2 # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = pair_sub_reverse(i);
}

성능

Logic, Fxp, Clip Cluster에서는 처리량이 완전한 8-way(사이클당 8개 요소)입니다. Float Cluster는 4-way로 동작하며, 실제로는 float 경로를 감싸는 Narrow/Widen 때문에 유효 처리량이 절반으로 줄어듭니다.

지연 시간은 사용하는 ALU마다 1사이클씩 늘어납니다. 여러 ALU에 걸친 연산은 각 ALU의 지연 시간이 누적됩니다. 예를 들어 exp(sqrt(x))는 지연 시간이 2사이클 늘어납니다(sqrt에 FpFpu, exp에 FpExp).

Intra-Slice Reduce

Intra-Slice Chain의 IntraSliceReduce 단계는 각 Slice의 Time과 Packet에 있는 차원을 리덕션합니다(Chip, Cluster, Slice는 변경 없이 그대로 통과합니다). 이와 상보적인 경우인 Cluster의 256개 Slice에 걸친 리덕션은 Inter-Slice Reducer가 담당합니다.

예제

reduce 호출의 주요 매개변수는 다음과 같습니다.

  • REDUCE_LABEL: 리덕션할 축입니다. 리덕션은 이 축을 담고 있는 Time과 Packet의 모든 인자를 제거하므로, 이 인자들은 출력 형태(OutTime, OutPacket)에 나타나서는 안 됩니다. 예를 들어 R이 Time에서 R / 4로, Packet에서 R % 4로 분할된 경우, REDUCE_LABEL = R을 지정하면 둘 다 제거됩니다.
  • op: 리덕션 연산입니다. IntraSliceReduceOpI32는 AddSat, Max, Min을 제공하고, IntraSliceReduceOpF32는 Add, Max, Min을 제공합니다.
  • OutTime, OutPacket: 리덕션 후의 출력 Time 및 Packet 형태입니다. 이는 입력 Time과 Packet에서 모든 REDUCE_LABEL 인자를 제거한 것과 같습니다.

이 호출은 Pair Mode에서는 사용할 수 없습니다. 아래 예제는 각 매개변수 조합을 다룹니다.

Time에서의 리덕션

R는 Time에만 존재하므로 이 단계는 시간 단계에 걸쳐 누산합니다. 포화 덧셈으로 \(output[a] = \sum_{r \in R} input[a, r]\)를 계산합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, R = 16];

// R in Time → temporal accumulation. Packet is non-reduce.
fn reduce_time<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![A / 2], m![R], m![A % 2 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![A / 2], m![1], m![A % 2 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![A % 2 # 4]>()       // 8-way → 4-way
        // R eliminated from Time
        .vector_intra_slice_reduce::<R, m![1], m![A % 2 # 4]>(
            IntraSliceReduceOpI32::AddSat,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![A / 2], m![R], m![A % 2 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_time(i);
}

Packet에서의 리덕션

R이 Packet에만 존재하므로, 하드웨어는 각 플릿 내에서 4-way 트리 리덕션을 수행하고 시간 방향 누적은 건너뜁니다. \(output[a] = \sum_{r \in R} input[a, r]\)를 계산합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, R = 4];

// R in Packet → tree reduce within flit.
fn reduce_packet<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, f32, m![1], m![1 # 2], m![A / 2], m![A % 2], m![R # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, f32, m![1], m![1 # 2], m![A / 2], m![A % 2], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![R]>()             // 8-way → 4-way
        // R eliminated from Packet
        .vector_intra_slice_reduce::<R, m![A % 2], m![1 # 4]>(
            IntraSliceReduceOpF32::Add,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, f32, m![1], m![1 # 2], m![A / 2], m![A % 2], m![R # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_packet(i);
}

양쪽 모두에서의 리덕션

R는 Packet과 Time에 나뉘어 분할됩니다. Packet의 R % 4는 각 플릿 내에서 트리 리덕션을 수행하고, Time의 R / 4는 시간 단계에 걸쳐 누산합니다. \(output[a] = \max_{r \in R} input[a, r]\)를 계산합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 256, R = 16];

// R % 4 in Packet → spatial tree reduce
// R / 4 in Time → temporal accumulation
fn reduce_time_packet<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, f32, m![1], m![1 # 2], m![A], m![R / 4], m![R % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, f32, m![1], m![1 # 2], m![A], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![R % 4]>()            // 8-way → 4-way
        // R eliminated from both Time and Packet
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpF32::Max,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, f32, m![1], m![1 # 2], m![A], m![R / 4], m![R % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_time_packet(i);
}

Slice별 리덕션

R은 Slice, Time, Packet에 나뉘어 있지만, Slice 내부 리듀서는 Time과 Packet 부분만 축소하므로 R의 Slice 부분은 출력에 남습니다. 여기서 R = 13은 레이아웃에 맞추기 위해 32로 패딩되고(R # 32), 이후 R은 4개의 Slice에 걸쳐 분할됩니다(Slice당 R 위치 8개). 위치 0-12만 실제 요소를 담고 있으므로, 그 경계에 걸친 Slice(위치 8-15: 실제 요소 5개 뒤에 패딩 3개)는 경계 Slice이며, 그 이후의 Slice는 전부 패딩입니다. VCG가 Slice별 리덕션 횟수를 제어하여 각 Slice가 자신의 실제 요소만 리덕션하도록 합니다(정확한 매핑은 Valid Count Generator를 참조하십시오).

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![R = 13];

// R split across all three: Slice (groups of 8), Time (pairs within group), Packet (4 elements).
fn reduce_slice_time_packet<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![R # 32 / 8 # 256], m![R # 32 / 4 % 2], m![R # 32 % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![R # 32 / 8 # 256], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![R # 32 % 4]>()       // 8-way → 4-way
        // R eliminated from Time and Packet (accumulated within each slice)
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpI32::Min,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![R # 32 / 8 # 256], m![R # 32 / 4 % 2], m![R # 32 % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_slice_time_packet(i);
}

아키텍처

이 단계는 Time 축과 Packet 축에 대해 별도의 장치를 운용합니다.

Time에서의 리덕션

이 단계는 슬롯 용량이 8인 시간 누산기 모델을 적용합니다(따라서 InnerTime::SIZE ≤ 8입니다).

위의 reduce_time에서는 Time = m![R]이고 OutTime = m![1]이므로 R이 가장 바깥쪽 리덕션 차원이며 InnerTime = m![1](InnerTime::SIZE = 1)입니다. 단일 슬롯이 모든 R 값을 출력에 누산합니다.

InnerTime::SIZE가 8을 초과하면 API가 호출을 거부합니다. 예를 들면 다음과 같습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 6, B = 16, R = 16];
fn invalid_too_many_slots<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![A / 3 # 256], m![R, A % 3, B % 4], m![B / 4 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![A / 3 # 256], m![A % 3, B % 4], m![B / 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![B / 4]>()
        // Time      = m![R, A % 3, B % 4]
        // OutTime   = m![A % 3, B % 4]
        // InnerTime = m![A % 3, B % 4], InnerTime::SIZE = 3 × 4 = 12 > 8
        .vector_intra_slice_reduce::<R, m![A % 3, B % 4], m![B / 4]>(
            IntraSliceReduceOpI32::AddSat,
        )
    // Rejected: 12 accumulator slots required, but only 8 are available.
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![A / 3 # 256], m![R, A % 3, B % 4], m![B / 4 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = invalid_too_many_slots(i);
}

Packet에서의 리덕션

플릿당 4개의 Packet 요소는 다음 두 경로 중 하나를 따릅니다.

  • OutPacket = Packet: 요소가 리덕션되지 않고 그대로 통과합니다. 사이클당 4개가 출력되며, 각 출력은 Time에 걸쳐 독립적으로 누산됩니다.
  • OutPacket = m![1 # 4]: 요소가 2단계 트리 op(op(a, b), op(c, d))를 거쳐 단일 값으로 축약됩니다. 사이클당 1개가 출력되며 패딩 위치 3개가 추가됩니다.

리덕션 축을 하드웨어 차원에 맞추기 위해 패딩하면, 패딩된 위치에는 리덕션에서 제외해야 하는 임의의 데이터가 들어 있습니다. 패딩을 제외하는 전략은 두 가지입니다.

  • VCG (Valid Count Generator): 해당 축 배치가 지원되는 경우 이 방식을 우선적으로 사용합니다. 컴파일러가 매핑으로부터 VCG를 자동으로 구성하고, VCG는 각 플릿에 valid_count를 태그로 붙여 패딩 요소가 자동으로 제외되도록 합니다. Slice, Time, Packet에 걸친 모든 축 배치가 지원되는 것은 아닙니다. 자세한 내용은 Valid Count Generator를 참고하십시오.

  • 항등 요소 패딩: 데이터가 Intra-Slice Chain에 도달하기 전에 패딩 위치를 리덕션 연산의 항등 요소로 채우십시오. 이 책에는 사용할 수 있는 공개 마스킹 연산이 문서화되어 있지 않으므로, 리덕션 전에 항등원을 생성하는 검증된 경로를 사용하십시오.

    연산항등 요소
    AddSat / Add0 / 0.0
    Maxi32::MIN / f32::NEG_INFINITY
    Mini32::MAX / f32::INFINITY

    이 전략은 리덕션 연산 앞에 역변환이 불가능한 변환이 없는 경우에만 적용됩니다. 예를 들어 exp(x) + exp(y) + ...(지수 함수의 합)에서는 exp(p) = 0(덧셈 항등원)을 만족하는 값 p가 존재하지 않습니다. 따라서 항등원 패딩을 적용할 수 없습니다.

성능

트리 리덕션이 Intra-Slice Chain 안에서 완전히 파이프라인화되어 플릿당 추가 비용이 없으므로, 처리량은 사이클당 플릿 하나로 유지됩니다.

이 단계는 결과를 내보내기 전에 리덕션 그룹의 모든 입력 플릿을 누산해야 하므로, 지연 시간에는 n 플릿 사이클만큼의 첫 출력 지연이 추가됩니다. 여기서 n은 리덕션 축의 시간 단계 수입니다. 여러 엔진으로 구성된 파이프라인에서는 이 누산 지연 때문에 첫 플릿을 기다리는 다운스트림 엔진이 정체됩니다.

Valid Count Generator

Intra-Slice Reduce 단계는 REDUCE_LABEL로 식별되는 축(예: R)을 해당 축의 Time 및 Packet 인수에 걸쳐 리덕션하며, Slice 인수는 출력에 남겨 둡니다. 이 축은 하드웨어 차원에 맞추기 위해 패딩이 필요한 경우가 많으며, 패딩으로 추가된 위치에는 리덕션에서 제외해야 하는 임의의 데이터가 들어 있습니다.

Valid Count Generator(VCG)가 이 문제를 해결합니다. 사용자는 매핑에서 R을 하위 표현식으로 나누어 Slice, Time, Packet 중 하나 이상에 걸쳐 배치합니다. 그런 다음 컴파일러는 VCG를 구성하여 8요소 플릿마다 그중 실제 데이터인 요소의 개수를 나타내는 valid_size 값을 태그로 붙입니다. Time 또는 Slice의 각 하위 표현식은 시간 필터에 할당된 Sequencer 카운터에 매핑됩니다. Packet에 있는 각 하위 표현식은 packet clipper를 구동합니다.

이 페이지 전체에서 대문자로 시작하는 Slice, Time, Packet은 매핑 차원을 가리키고, 소문자 slice와 time step은 런타임 인스턴스를 가리킵니다.

VCG가 동작하려면 매핑이 R을 특정한 형태로 표현해야 합니다. R은 하드웨어에 정렬된 크기로 패딩되며, 일반적으로 논의할 때는 R # PADDED_SIZE로 표기합니다. 구체적인 예시에서는 실제 패딩 값을 사용합니다(예: R # 16, R # 48). 그러면 각 하위 표현식은 R # PADDED_SIZE / n % m(스트라이드 n, 모듈로 m) 형태를 갖는 R # PADDED_SIZE의 인수가 됩니다. / n과 % m의 의미는 스트라이드와 모듈로를 참조하십시오. 각 하위 표현식은 하나의 하드웨어 차원에 할당됩니다. 가능한 분배 방식 중 하나는 R = 43을 R # 48로 패딩하여 세 차원 모두에 걸쳐 나누는 것입니다:

Slice:  R # 48 / 8       (stride 8, 6 positions)
Time:   R # 48 / 2 % 4   (stride 2, 4 positions)
Packet: R # 48 % 2       (stride 1, 2 positions)

아키텍처

VCG는 각 플릿에 valid_size(s, t) ∈ {0, 1, ..., 8}를 할당합니다. 여기서 s는 Slice ID(모든 Slice 하위 표현식에 걸친 플릿의 위치를 인코딩하는 정수)이고 t는 시간 단계입니다. 플릿의 처음 valid_size개 요소는 실제 데이터이고, 나머지는 패딩입니다.

struct VcgConfig {
    time_filters:   [TimeFilterConfig; 3], // for R's sub-expressions in Time and/or Slice
    packet_clipper: PacketClipperConfig,   // for R's sub-expressions in Packet
}

impl VcgConfig {
    fn valid_size(&self, s: u64, t: u64) -> u32 {
        if self.time_filters.iter().all(|tf| tf.valid(s, t)) {
            self.packet_clipper.valid_size(t)
        } else {
            0
        }
    }
}

모든 타이머가 플릿 (s, t)에 대해 유효를 보고하면 packet clipper가 해당 플릿의 요소 중 몇 개가 실제 데이터인지 결정합니다. 어느 타이머라도 무효를 보고하면 packet clipper의 판단과 관계없이 플릿의 모든 요소가 제외됩니다. 두 구성 요소인 TimeFilterConfig와 PacketClipperConfig는 아래 절에서 설명합니다.

시간 필터

각 Slice s에 대해 시간 필터는 각 시간 단계 t가 유효한 R 데이터를 담고 있는지 판단합니다. 아래 하위 절에서는 가장 단순한 경우부터 시작하여 복잡도를 더해 가며 fn valid()를 단계적으로 구성합니다. R에 Time이나 Slice의 하위 표현식이 없으면 slice_mask = 0과 slice_thres = 1로 설정하여 시간 필터를 비활성화합니다. 그러면 모든 s에 대해 s & 0 = 0 < 1이 성립하여 Less 분기에 해당하므로 fn valid()는 항상 true를 반환합니다. slice_thres = 1로 정하는 것은 관례일 뿐이며, 0은 항상 양수보다 작으므로 어떤 양수 값을 사용해도 됩니다.

struct TimeFilterConfig {
    // How R's index is reconstructed from t.
    sequencer: Sequencer,

    // Slice classification (see `R in Slice and Time`, `R in Time and Slice`).
    slice_mask:  u32,
    slice_thres: u32,
    time_thres:  u32,
    mode:        TimeFilterMode, // SliceMajor | TimeMajor
}

impl TimeFilterConfig {
    /// Returns true if flit (s, t) carries valid R data.
    fn valid(&self, s: u64, t: u64) -> bool {
        let idx = self.sequencer.index(t);
        match ((s & self.slice_mask).cmp(&self.slice_thres), self.mode) {
            (Less,    _)          => true,
            (Greater, SliceMajor) => false,
            _                     => idx < self.time_thres as u64,
        }
    }
}

Time으로서의 R

가장 단순한 경우에는 R이 다른 축 없이 Time 전체를 차지합니다. 각 시간 단계 t는 하나의 R 인덱스에 직접 대응하며, t < R::SIZE일 때 유효합니다.

// The compiler emits roughly:
TimeFilterConfig {
    sequencer:   [R # PADDED_SIZE -> size PADDED_SIZE : stride 1],  // idx = t
    slice_mask:  0,           // no slice partitioning
    slice_thres: 0,           // 0 cmp 0 = Equal -> falls to `idx < time_thres`
    time_thres:  R::SIZE,     // valid when idx < R::SIZE
    mode:        SliceMajor,  // arbitrary; only the Equal arm is hit
}
#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, R = 12, X = 128];

fn reduce_time_only<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![X, A / 4], m![R # 16], m![A % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![X, A / 4], m![1], m![A % 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![A % 4]>()
        //   Slice     = m![X, A / 4]
        //   Time      = m![R # 16]     (steps < R::SIZE valid)
        //   Packet    = m![A % 4]
        //   OutTime   = m![1]          (R eliminated from Time)
        //   OutPacket = m![A % 4]
        .vector_intra_slice_reduce::<R, m![1], m![A % 4]>(
            IntraSliceReduceOpI32::AddSat,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![X, A / 4], m![R # 16], m![A % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_time_only(i);
}

위 예제에서 Sequencer는 size 16 : stride 1로 R # 16을 한 번 순회하므로, 모든 시간 단계에서 idx = t입니다. time_thres = R::SIZE = 12이므로 처음 12개의 시간 단계(t = 0..11)는 유효하고, 나머지 4개(t = 12..15)는 걸러집니다. 이어서 Intra-Slice Reduce는 Slice마다 실제 R 요소 12개만 정확히 접습니다.

Time의 R

VCG는 R이 다른 축과 공간을 공유하면서 여러 하위 표현식으로 임의의 순서로 나타나는 Time 매핑을 지원합니다. 시간 필터는 Sequencer를 사용해 t를 하위 표현식별 카운터로 분해합니다. R에 할당된 카운터에 대해 value × stride를 합산하면 idx가 되며, 이 값은 해당 시간 단계에서의 R 인덱스를 나타냅니다.

다음 예제는 R = 10을 R # 12로 패딩하여 사용하며, Time에서 A가 R의 두 하위 표현식 사이에 위치합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 3, B = 4, R = 10, X = 64];

// R = 10, padded to R # 12, split as (size 3, stride 4) × (size 4, stride 1).
// time filter sums (R # 12 / 4 value) * 4 + (R # 12 % 4 value) * 1 to recover R index regardless of A.
fn reduce_time_reordered<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![X # 256], m![R # 12 / 4, A, R # 12 % 4], m![B # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![X # 256], m![A], m![B], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![B]>()
        //   Slice     = m![X # 256]
        //   Time      = m![R # 12 / 4, A, R # 12 % 4]
        //   Packet    = m![B]
        //   OutTime   = m![A]    (R eliminated; A survives)
        //   OutPacket = m![B]
        .vector_intra_slice_reduce::<R, m![A], m![B]>(
            IntraSliceReduceOpI32::AddSat,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![X # 256], m![R # 12 / 4, A, R # 12 % 4], m![B # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_time_reordered(i);
}

컴파일러는 이 배치에 대해 시간 필터를 다음과 같이 구성합니다.

TimeFilterConfig {
    sequencer:   [R # 12 / 4 -> size 3 : stride 4,   // assigned to time filter
                  A          -> size 3 : stride 0,   // not assigned (A's OutTime)
                  R # 12 % 4 -> size 4 : stride 1],  // assigned to time filter
    slice_mask:  0,           // no slice partitioning (R is only in Time)
    slice_thres: 0,           // every slice falls to the `idx < time_thres` arm
    time_thres:  R::SIZE,     // 10
    mode:        SliceMajor,  // arbitrary; only the Equal arm is hit
}

A 항목은 스트라이드가 0이므로 idx에 전혀 기여하지 않으며, 따라서 Time에서 A의 위치는 유효성에 아무런 영향을 주지 않습니다. 나머지 두 항목은 R을 (R # 12 / 4 value) × 4 + (R # 12 % 4 value)로 재구성합니다.

예를 들면 다음과 같습니다.

  • t = 13에서 Sequencer 상태는 (R # 12 / 4, A, R # 12 % 4) = (1, 0, 1)이므로 idx = 1 × 4 + 0 + 1 × 1 = 5가 됩니다. idx < 10이므로 유효합니다.
  • t = 27에서 Sequencer 상태는 (2, 0, 3)이므로 idx = 2 × 4 + 0 + 3 × 1 = 11이 됩니다. idx ≥ 10이므로 유효하지 않습니다(패딩된 두 R 위치 중 하나입니다).

Slice와 Time의 R

SliceMajor 모드에서는 Slice와 Time 모두에 여러 R 하위 표현식을 둘 수 있으며, 이때 Slice 하위 표현식이 Time 하위 표현식보다 더 상위(더 큰 스트라이드)입니다. Slice 안에서 하위 표현식은 스트라이드 내림차순(major가 minor보다 먼저)으로 나타나야 합니다. 또한 각 하위 표현식의 비트가 slice_mask의 연속 구간을 차지하도록, 각 하위 표현식의 크기와 스트라이드는 모두 2의 거듭제곱이어야 합니다. Time 안에서는 하위 표현식이 임의의 순서로 나타날 수 있습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![R = 11, X = 64];

fn reduce_slice_time_slicemajor<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![R # 16 / 8, X, R # 16 / 4 % 2], m![R # 16 % 2, R # 16 / 2 % 2], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![R # 16 / 8, X, R # 16 / 4 % 2], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![1 # 4]>()
        //   Slice     = m![R # 16 / 8, X, R # 16 / 4 % 2]   (major R sub-exprs, descending R-stride order required)
        //   Time      = m![R # 16 % 2, R # 16 / 2 % 2]      (minor R sub-exprs, any order OK)
        //   Packet    = m![1 # 4]
        //   OutTime   = m![1]          (R eliminated)
        //   OutPacket = m![1 # 4]
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpI32::Min,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![R # 16 / 8, X, R # 16 / 4 % 2], m![R # 16 % 2, R # 16 / 2 % 2], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_slice_time_slicemajor(i);
}

이 예제는 R = 11(R # 16으로 패딩)을 다음 하위 표현식으로 Slice와 Time에 걸쳐 배치합니다.

차원하위 표현식스트라이드
SliceR # 16 / 88
SliceR # 16 / 4 % 24
TimeR # 16 % 21
TimeR # 16 / 2 % 22

이 예제는 4개의 Slice 그룹(Slice에서 오는 R 기여분 0, 4, 8, 12마다 하나씩)으로 나뉘며, Slice마다 4번의 시간 반복을 수행합니다. 레이아웃은 세 가지 영역으로 구성됩니다. 2개의 Slice는 완전히 유효하고, 1개의 Slice는 부분적으로 유효하며, 1개의 Slice는 완전히 유효하지 않습니다.

Slice에서 오는 R 기여분반복에 따른 R 값유효한 시간 단계
00, 2, 1, 34 (모두 < R::SIZE)
44, 6, 5, 74 (모두 < R::SIZE)
88, 10, 9, 113 (R = 11은 유효하지 않음)
1212, 14, 13, 150 (모두 ≥ R::SIZE)

컴파일러는 아래와 같은 시간 필터 설정을 생성합니다.

TimeFilterConfig {
    sequencer:   [R # 16 % 2     -> size 2 : stride 1,
                  R # 16 / 2 % 2 -> size 2 : stride 2],
    slice_mask:  0b1000001,  // bits 0 (R # 16 / 4 % 2) and 6 (R # 16 / 8) carry the R contribution
    slice_thres: 64,         // masked id encoding the boundary R contribution (= 8)
    time_thres:  3,          // = R::SIZE - boundary = 11 - 8
    mode:        SliceMajor,
}

각 필드는 유효성 판정의 한 부분을 인코딩합니다:

  • sequencer는 각 Time 하위 표현식을 해당 R 스트라이드와 함께 기록합니다. 따라서 재구성된 idx는 각 시간 단계 t에서 Time이 R 인덱스에 기여하는 값과 같습니다. 이 예제에서 t가 [0, 4) 범위를 도는 동안 idx는 {0, 1, 2, 3} 중 하나의 값을 가집니다.
  • slice_mask는 Slice id에서 Slice에서 오는 R 기여분을 담는 비트들을 추출합니다. 이 예제에서 비트 0은 R # 16 / 4 % 2를, 비트 6은 R # 16 / 8을 담으므로 slice_mask = 0b1000001입니다.
  • slice_thres는 부분 Slice의 R 기여분을 인코딩하는, slice_mask 내의 비트 패턴입니다. 위 표에서 부분 Slice의 R 기여분은 8이며, 이는 비트 6을 설정하고(R # 16 / 8 = 1) 비트 0을 해제하여(R # 16 / 4 % 2 = 0) 인코딩됩니다. 따라서 slice_thres = 64입니다.
  • time_thres는 R::SIZE에서 부분 Slice의 R 기여분을 뺀 값입니다. 이 예제에서는 time_thres = 11 - 8 = 3입니다.

fn valid는 모든 플릿에 대해 올바른 유효성을 반환합니다. 이를 검증하려면 R 인덱스를 r = r_slice + idx로 분해한 뒤 Slice 비교의 세 가지 경우를 살펴보십시오. 여기서 r_slice는 Slice에서 오는 R 기여분(s & slice_mask로 인코딩됨)이고 idx는 Time에서 오는 기여분입니다.

  • (s & slice_mask) < slice_thres이면 모든 시간 단계가 유효합니다. r_slice가 부분 Slice의 기여분보다 Slice 간격 하나 이상 작으므로, 모든 idx에 대해 r = r_slice + idx < R::SIZE가 성립하기 때문입니다.
  • (s & slice_mask) = slice_thres일 때, 시간 단계는 idx < time_thres인 경우에만 유효합니다. 이것이 부분 Slice이며, time_thres의 정의에 따라 이 Slice의 r_slice = R::SIZE - time_thres입니다. 따라서 r = r_slice + idx < R::SIZE는 정확히 idx < time_thres일 때 성립합니다.
  • (s & slice_mask) > slice_thres일 때는 유효한 시간 단계가 없습니다. r_slice가 부분 Slice의 기여분보다 최소 Slice 간격 하나만큼 크므로 r_slice ≥ R::SIZE이기 때문입니다.

Time과 Slice에 걸친 R

TimeMajor 모드는 SliceMajor의 쌍대(dual)입니다. major/minor 역할이 뒤바뀌므로 Time 하위 표현식이 더 큰 스트라이드를, Slice 하위 표현식이 더 작은 스트라이드를 가집니다. Slice 내부 및 Time 내부의 순서 규칙과 Slice 하위 표현식의 크기/스트라이드가 2의 거듭제곱이어야 한다는 요구 사항은 SliceMajor에서 그대로 이어집니다.

TimeMajor는 이렇게 이어받은 규칙에 제약 조건 하나를 추가합니다. PADDED_SIZE는 slice_span × time_span으로 분해된다는 점을 상기하십시오. 여기서 slice_span과 time_span은 각각 Slice와 Time에 있는 R의 하위 표현식 크기들의 곱입니다. TimeMajor는 PADDED_SIZE - R::SIZE ≤ slice_span을 요구하며, 이는 초과 패딩될 수 있는 R 위치가 최대 slice_span개라는 뜻입니다. 이 제약 조건은 필수적이며, 이를 위반하는 배치는 VCG에서 지원되지 않습니다(Time과 Slice에 걸친 R 참조).

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![R = 13, X = 64];

fn reduce_time_slice_timemajor<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![R # 16 / 2 % 2, X, R # 16 % 2], m![R # 16 / 4 % 2, R # 16 / 8], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![R # 16 / 2 % 2, X, R # 16 % 2], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![1 # 4]>()
        //   Slice     = m![R # 16 / 2 % 2, X, R # 16 % 2]
        //   Time      = m![R # 16 / 4 % 2, R # 16 / 8]
        //   Packet    = m![1 # 4]
        //   OutTime   = m![1]          (R eliminated)
        //   OutPacket = m![1 # 4]
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpI32::AddSat,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![R # 16 / 2 % 2, X, R # 16 % 2], m![R # 16 / 4 % 2, R # 16 / 8], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_time_slice_timemajor(i);
}

이 예제는 R = 13(R # 16으로 패딩됨)을 다음 하위 표현식으로 Slice와 Time에 걸쳐 배치합니다.

차원하위 표현식스트라이드
TimeR # 16 / 4 % 24
TimeR # 16 / 88
SliceR # 16 / 2 % 22
SliceR # 16 % 21

이 하위 표현식들로부터 slice_span = 4, time_span = 4, 초과 패딩 PADDED_SIZE - R::SIZE = 3을 얻으며, 이는 제약 조건 3 ≤ 4를 만족합니다. Slice로부터의 R 기여분(0, 1, 2 또는 3)으로 구분되는 4개의 Slice는 각각 4번의 시간 반복에 걸쳐 4개의 R 값을 순회합니다. 이 레이아웃에는 두 가지 경우가 있습니다. 1개의 Slice는 완전히 유효하고, 3개는 부분적입니다(각각 반복 하나씩을 잃습니다).

Slice로부터의 R 기여분반복에 걸친 R 값유효한 시간 단계
00, 8, 4, 124개(모두 < R::SIZE)
11, 9, 5, 133개(R = 13은 무효)
22, 10, 6, 143개(R = 14는 무효)
33, 11, 7, 153개(R = 15는 무효)

컴파일러는 아래와 같은 시간 필터 설정을 생성합니다.

TimeFilterConfig {
    sequencer:   [R # 16 / 4 % 2 -> size 2 : stride 1,    // 1 = 4 / slice_span
                  R # 16 / 8     -> size 2 : stride 2],   // 2 = 8 / slice_span
    slice_mask:  0b1000001,  // bits 0 (R # 16 % 2) and 6 (R # 16 / 2 % 2) carry the R contribution
    slice_thres: 1,          // masked id encoding r_slice = 1
    time_thres:  3,          // = time_span - 1 (partial slices drop the over-padded last iteration)
    mode:        TimeMajor,
}

이 설정은 세 필드에서 SliceMajor와 다릅니다(slice_mask는 같은 패턴을 따릅니다):

  • sequencer: 모든 Time 하위 표현식의 스트라이드를 먼저 slice_span으로 나눕니다. 이 예에서는 스트라이드 4와 8이 1과 2가 됩니다.
  • slice_thres: 부분 Slice의 r_slice = slice_span - (PADDED_SIZE - R::SIZE)를 인코딩합니다. 이 예시에서 목표 값은 4 - 3 = 1이며, 비트 0을 설정하고(R # 16 % 2 = 1) 비트 6을 해제하여(R # 16 / 2 % 2 = 0) 인코딩하므로 slice_thres = 1이 됩니다.
  • time_thres: 항상 time_span - 1입니다. 이 예시에서는 time_thres = 3입니다.

fn valid는 모든 플릿에 대해 올바른 유효성을 반환합니다. 이를 검증하려면 R 인덱스를 r = idx * slice_span + r_slice로 분해하고(여기서 r_slice는 s & slice_mask에서 디코딩한 해당 Slice의 R 기여분입니다), Slice 비교의 두 경우를 살펴보십시오.

  • (s & slice_mask) < slice_thres이면 모든 시간 단계가 유효합니다. r_slice < slice_span - (PADDED_SIZE - R::SIZE)이므로 모든 idx에 대해 r ≤ (time_span - 1) * slice_span + r_slice < R::SIZE입니다.
  • (s & slice_mask) ≥ slice_thres이면 시간 단계는 idx < time_thres = time_span - 1일 때에만 유효합니다. 이때 해당 Slice의 r_slice ≥ slice_span - (PADDED_SIZE - R::SIZE)입니다. idx ≤ time_span - 2이면 r ≤ (time_span - 2) * slice_span + (slice_span - 1) < (time_span - 1) * slice_span ≤ R::SIZE입니다. idx = time_span - 1이면 r ≥ (time_span - 1) * slice_span + slice_span - (PADDED_SIZE - R::SIZE) = R::SIZE입니다.

SliceMajor와 달리 TimeMajor에는 “모두 무효“인 경우가 없습니다. 패딩 제약 조건 PADDED_SIZE - R::SIZE ≤ slice_span이 과잉 패딩을 충분히 엄격하게 제한하므로 완전히 무효가 되는 Slice는 없습니다.

Packet Clipper

packet clipper는 각 플릿에 대해 valid_size(t)를 계산합니다. 이 값은 Slice s가 아닌 t에만 의존하므로, 같은 시간 단계에서는 모든 Slice가 같은 개수를 받습니다. Slice와 무관한 이 성질 때문에 VCG가 표현할 수 있는 배치가 제한됩니다. 아래 하위 절에서는 fn valid_size()를 단계별로 구성합니다. R에 Packet의 하위 표현식이 없으면 빈 Sequencer와 함께 axis_size = packet_span = 8로 설정하여 packet clipper를 비활성화합니다. 이렇게 하면 fn valid_size()가 항상 8(전체 플릿)을 반환합니다.

struct PacketClipperConfig {
    sequencer:   Sequencer,
    axis_size:   u32, // R::SIZE
    packet_span: u32, // R positions per flit
}

impl PacketClipperConfig {
    /// Returns the valid element count for the flit at time step t.
    fn valid_size(&self, t: u64) -> u32 {
        let idx = self.sequencer.index(t);
        (self.axis_size - idx).clamp(0, self.packet_span)
    }
}

packet clipper에는 Packet = m![R # PADDED_SIZE % packet_span # 8] 형식이 필요합니다. 다른 축이 R과 함께 Packet을 공유하거나 R이 Packet 안에서 여러 하위 표현식으로 나뉘면, fn valid_size()가 의존하는 연속 접두사 성질이 깨집니다(표현할 수 없는 패턴 참조).

Packet으로서의 R

가장 단순한 경우에는 R이 단일 플릿에 들어가므로(R::SIZE ≤ 8), 시간 단계나 Slice와 관계없이 모든 플릿이 같은 valid_size = R::SIZE를 가집니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, R = 3, X = 64];

fn reduce_packet_only<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, f32, m![1], m![1 # 2], m![X, A / 2], m![1], m![R # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, f32, m![1], m![1 # 2], m![X, A / 2], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![R # 4]>()
        //   Slice     = m![X, A / 2]
        //   Time      = m![1]
        //   Packet    = m![R # 4]
        //   OutTime   = m![1]
        //   OutPacket = m![1 # 4]  (R eliminated from Packet)
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpF32::Add,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, f32, m![1], m![1 # 2], m![X, A / 2], m![1], m![R # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_packet_only(i);
}

위 예제는 R = 3(R # 8로 패딩)을 단일 하위 표현식으로 Packet에 모두 배치합니다. 컴파일러는 packet clipper를 다음과 같이 구성합니다:

PacketClipperConfig {
    sequencer:   [],   // empty: a single flit holds all of R
    axis_size:   3,    // R::SIZE
    packet_span: 8,    // R fits in 8 flit positions
}

모든 플릿의 valid_size = clamp(3 - 0, 0, 8) = 3입니다(시간 단계와 Slice에 관계없이 일정).

Time과 Packet에 걸친 R

VCG는 Time과 Packet 양쪽에 하위 표현식이 있는 R을 지원합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, R = 10, X = 64];

fn reduce_time_packet<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, f32, m![1], m![1 # 2], m![X, A / 2], m![R # 16 / 4], m![R # 16 % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, f32, m![1], m![1 # 2], m![X, A / 2], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![R # 16 % 4]>()
        //   Slice     = m![X, A / 2]
        //   Time      = m![R # 16 / 4]
        //   Packet    = m![R # 16 % 4 # 8]
        //   OutTime   = m![1]          (R eliminated)
        //   OutPacket = m![1 # 4]
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpF32::Add,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, f32, m![1], m![1 # 2], m![X, A / 2], m![R # 16 / 4], m![R # 16 % 4 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_time_packet(i);
}

위 예제는 R = 10(R # 16으로 패딩)을 Time과 Packet에 걸쳐 배치합니다.

차원하위 표현식스트라이드
TimeR # 16 / 44
PacketR # 16 % 4 # 81

컴파일러는 packet clipper를 다음과 같이 구성합니다:

PacketClipperConfig {
    sequencer:   [R # 16 / 4 -> size 4 : stride 4],
    axis_size:   10,   // R::SIZE
    packet_span: 4,    // 4 R positions per flit (the trailing 4 flit positions are always padding)
}

이 구성에서 Sequencer는 R 인덱스에 대한 Time의 기여분인 idx(t)를 재구성합니다(Time에 있는 R 참조). axis_size = 10개 요소 중 idx(t)개를 Time이 담당하고 나면 axis_size - idx(t)개가 남습니다. packet clipper는 남은 요소를 플릿에 최대한 많이 담되, 그 수는 최대 packet_span = 4개로 제한됩니다. 따라서 fn valid_size(t) = clamp(10 - idx(t), 0, 4)입니다. 뒤쪽 4개의 플릿 위치는 항상 패딩이며, R::SIZE가 packet_span의 배수가 아니면 마지막 플릿은 일부 개수만 담습니다:

  flit 0: idx =  0  →  clamp(10 -  0, 0, 4) = 4  [████    ]
  flit 1: idx =  4  →  clamp(10 -  4, 0, 4) = 4  [████    ]
  flit 2: idx =  8  →  clamp(10 -  8, 0, 4) = 2  [██      ]
  flit 3: idx = 12  →  clamp(10 - 12, 0, 4) = 0  [        ]

종합하기

앞 절에서는 하나 또는 두 차원에 배치된 단일 R 하위 표현식을 다루었습니다. 실제로 Intra-Slice Reduce는 단일 REDUCE_LABEL(R)과 함께 패딩된 리덕션 외 축을 추가로 받으며, VCG는 이 축을 모두 추적합니다. 패딩된 각 축(R이든 다른 축이든)은 시간 필터 슬롯 하나를 차지하고, R의 Packet 부분은 packet clipper를 차지합니다. 이 예제는 축 하나에서 시작해 세 개까지 확장하므로 각 차원의 기여분을 명확히 알 수 있습니다.

원래 형태는 [H, C, W] = [5, 5, 19]입니다. 각 축은 배치 방식에 따라 slice/시간/패킷 부분으로 분할됩니다:

축패딩Slice시간패킷
H# 8H # 8 / 2 (크기 4)H # 8 % 2 (크기 2)-
C# 8C # 8 / 2 (크기 4)C # 8 % 2 (크기 2)-
W# 24-W # 24 / 8 (크기 3)W # 24 % 8 (크기 8)

이후 단계에서는 간결하게 Ho/Co/Wo와 Hi/Ci/Wi를 약칭으로 사용합니다. *o는 표의 행에서 가장 왼쪽 인자(H/C는 slice, W는 시간)이고, *i는 그 바로 오른쪽 인자입니다.

1단계: W=19만 사용(packet clipper만 사용, 시간 필터 없음)

지금은 H와 C를 무시합니다. 시간 필터 0과 1을 비활성화합니다(slice_mask=0, slice_thres=1). 모든 Slice가 3개의 플릿(Wo 크기 3)을 처리하며, packet clipper는 다음과 같은 톱니 모양 패턴을 생성합니다:

valid_size: 8, 8, 3
              ^     ^
            full   19 - 16 = 3 (partial)

시간 필터가 없으므로 모든 Slice는 이와 정확히 같은 패턴을 받습니다:

All slices, all flits:
flit 0: ████████  (valid_size=8)
flit 1: ████████  (valid_size=8)
flit 2: ███       (valid_size=3)

2단계: C=5 추가(packet clipper + 시간 필터 0)

이제 C 축 타이머(시간 필터 0)를 활성화합니다. C=5는 Co(slice, 크기 4) × Ci(시간, 크기 2)로 분할됩니다. C 시간 필터 설정은 slice_mask=0b0011(slice_id에서 Co를 추출), slice_thres=2, time_thres=1, SliceMajor입니다.

이제 각 Slice는 6개의 플릿을 실행합니다: Ci 크기 2 × Wo 크기 3. C 시간 필터는 Slice를 Co 값에 따라 분류합니다:

Co그룹효과
0미만(< 2)6개 플릿 모두 packet clipper의 패턴을 통과합니다
1미만(< 2)동일합니다
2경계(= 2)Ci=0에서는 유효하고, Ci=1에서는 유효하지 않습니다
3초과(> 2)6개 플릿 모두 valid_size = 0입니다

Slice별 결과(6개 플릿 = Ci 크기 2 × Wo 크기 3):

Co=0:  [8,8,3, 8,8,3]   (both Ci steps valid)
Co=1:  [8,8,3, 8,8,3]   (same)
Co=2:  [8,8,3, 0,0,0]   (Ci=0 valid, Ci=1 invalid)
Co=3:  [0,0,0, 0,0,0]   (all invalid)

타이머의 효과에 주목하십시오. 일부 Slice는 전부 0이 되고, 경계 Slice는 후반부를 잃습니다. 하지만 유효한 플릿 안에서는 packet clipper가 만든 [8,8,3] 패턴이 그대로 유지됩니다.

3단계: H=5 추가(packet clipper + 시간 필터 0 + 시간 필터 1)

이제 H 축 타이머(시간 필터 1)를 활성화합니다. H=5는 Ho(slice, 크기 4) × Hi(시간, 크기 2)로 분할됩니다. H 시간 필터 설정은 다음과 같습니다: slice_mask=0b1100(slice_id에서 Ho를 추출), slice_thres=0b1000, time_thres=1, SliceMajor.

slice id는 두 slice 인자를 slice_id = Ho * 4 + Co로 인코딩하므로 Slice는 16개가 됩니다. 이제 각 Slice는 12개의 플릿을 실행합니다: Hi 크기 2 × Ci 크기 2 × Wo 크기 3.

구성 요소축추적 대상설정
packet clipperW=19플릿별 R 개수axis_size=19, packet_span=8, sequencer = [Wo -> size 3 : stride 8]
시간 필터 0C=5Slice별 Co 유효성slice_mask=0b0011, slice_thres=2, time_thres=1, SliceMajor
시간 필터 1H=5Slice별 Ho 유효성slice_mask=0b1100, slice_thres=0b1000, time_thres=1, SliceMajor

H 시간 필터는 C 시간 필터가 Co로 Slice를 분류하는 것과 같은 로직으로 Ho에 따라 Slice를 분류합니다.

Ho그룹효과
0미만열림
1미만열림
2경계Hi=0이면 열림, Hi=1이면 닫힘
3초과닫힘

최종 valid_size는 두 시간 필터가 모두 true를 반환하면 packet clipper의 개수가 되고, 어느 하나라도 false를 반환하면 0이 됩니다.

아래의 전체 히트맵은 16개 Slice(열, Ho별로 묶음) × 12개 플릿(행, (Hi, Ci)별로 묶음)으로 구성됩니다. 오른쪽 주석(H:, C:)은 각 행에서 어떤 타이머가 활성 상태인지 나타냅니다. v = 유효, > = 경계, x = 무효입니다. 먼저 이 주석을 훑어보고 어떤 행 × 열 블록이 전부 0이어야 하는지(타이머 중 하나라도 x), 어떤 블록에 데이터가 있어야 하는지 예측하십시오. 그런 다음 셀을 읽어 [8, 8, 3] 패킷 차원의 톱니 패턴을 확인하십시오.

                     Ho=0       |Ho=1       |Ho=2       |Ho=3
                Co:  0  1  2  3 | 0  1  2  3| 0  1  2  3| 0  1  2  3
     H time filter: v  v  v  v  | v  v  v  v| >  >  >  >| x  x  x  x
     C time filter: v  v  >  x  | v  v  >  x| v  v  >  x| v  v  >  x
--------------------------------------------------------------------------------
 t= 0  Hi=0,Ci=0  W  8  8  8  0 | 8  8  8  0| 8  8  8  0| 0  0  0  0  H:v C:v
 t= 1             |  8  8  8  0 | 8  8  8  0| 8  8  8  0| 0  0  0  0
 t= 2             |  3  3  3  0 | 3  3  3  0| 3  3  3  0| 0  0  0  0
                                |           |           |
 t= 3  Hi=0,Ci=1  W  8  8  0  0 | 8  8  0  0| 8  8  0  0| 0  0  0  0  H:v C:>
 t= 4             |  8  8  0  0 | 8  8  0  0| 8  8  0  0| 0  0  0  0
 t= 5             |  3  3  0  0 | 3  3  0  0| 3  3  0  0| 0  0  0  0
                                |           |           |
 t= 6  Hi=1,Ci=0  W  8  8  8  0 | 8  8  8  0| 0  0  0  0| 0  0  0  0  H:> C:v
 t= 7             |  8  8  8  0 | 8  8  8  0| 0  0  0  0| 0  0  0  0
 t= 8             |  3  3  3  0 | 3  3  3  0| 0  0  0  0| 0  0  0  0
                                |           |           |
 t= 9  Hi=1,Ci=1  W  8  8  0  0 | 8  8  0  0| 0  0  0  0| 0  0  0  0  H:> C:>
 t=10             |  8  8  0  0 | 8  8  0  0| 0  0  0  0| 0  0  0  0
 t=11             |  3  3  0  0 | 3  3  0  0| 0  0  0  0| 0  0  0  0

Legend: `v` = below (all valid), `>` = boundary (partial), `x` = above (all invalid)
  • Ho=3 열(가장 오른쪽 4개): 모두 0입니다(H 시간 필터가 x이며 항상 닫힘).
  • Co=3 열(4번째마다): 모두 0입니다(C 시간 필터가 x).
  • Co=2 열(H:v C:>): C 시간 필터가 경계이므로 Ci=0인 행만 통과합니다. Co=1과 Co=2를 비교해 보십시오.
  • Ho=2 열(H:> C:v): H 시간 필터가 경계이므로 Hi=0인 행만 통과합니다. Ho=1과 Ho=2를 비교해 보십시오.
  • Ho=2 × Co=2(둘 다 >): 두 경계의 교집합인 (Hi=0, Ci=0) 행만 통과합니다.
  • 유효한 셀 안에서는 packet clipper가 만드는 [8, 8, 3] 톱니 패턴이 항상 나타나며, 이는 Slice와 관계없이 동일합니다.

표현할 수 없는 패턴

다음 배치는 VCG로 표현할 수 없습니다. 각 하위 절에서 배치를 보여 주고 그 이유를 설명합니다.

Slice에 있는 R, 순서가 뒤바뀐 경우

Slice에 R의 하위 표현식이 여러 개 있으면, 바깥쪽 하위 표현식은 안쪽 하위 표현식보다 더 큰 스트라이드를 가져야 합니다. 이 순서를 뒤집으면 Slice별 R 인덱스 범위가 단조롭지 않게 되어, 단일 slice_thres로는 이를 포착할 수 없습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![R = 13, X = 32];

// NOT supported: inner sub-expression (/ 2 % 4, stride 2) placed outside major (/ 8, stride 8) in Slice.
// Produces non-monotonic slice validity (S6 valid after S5 partial); VCG cannot express this.
fn reduce_wrong_ordering<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![X, R # 16 / 2 % 4, R # 16 / 8], m![R # 16 % 2], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![X, R # 16 / 2 % 4, R # 16 / 8], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![1 # 4]>()
        //   Slice     = m![X, R # 16 / 2 % 4, R # 16 / 8]
        //   Time      = m![R # 16 % 2]
        //   Packet    = m![1 # 8]
        //   OutTime   = m![1]          (R eliminated)
        //   OutPacket = m![1 # 4]
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpI32::Min,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![X, R # 16 / 2 % 4, R # 16 / 8], m![R # 16 % 2], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_wrong_ordering(i);
}

Slice와 Time에 있는 R, 교차 배치된 경우

R의 하위 표현식이 두 Time 인수 사이에 Slice 인수가 끼도록 분산되면, Slice마다 필요한 유효 시간 단계 수가 달라집니다. 단일 slice_thres로는 이러한 Slice별 차이를 표현할 수 없습니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![R = 13, X = 64];

// NOT supported: Time-Slice-Time interleave.
// Different slices need different valid step counts (e.g., S2: 3/4, S3: 2/4); single threshold cannot express this.
fn reduce_wrong_interleave<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![X, R # 16 / 2 % 4], m![R # 16 / 8, R # 16 % 2], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![X, R # 16 / 2 % 4], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![1 # 4]>()
        //   Slice     = m![X, R # 16 / 2 % 4]
        //   Time      = m![R # 16 / 8, R # 16 % 2]
        //   Packet    = m![1 # 8]
        //   OutTime   = m![1]          (R eliminated)
        //   OutPacket = m![1 # 4]
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpI32::Min,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![X, R # 16 / 2 % 4], m![R # 16 / 8, R # 16 % 2], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_wrong_interleave(i);
}

Slice와 Time에 있는 R, 과도하게 패딩된 경우

TimeMajor 모드는 PADDED_SIZE - R::SIZE ≤ slice_span을 요구합니다. 과도하게 패딩될 수 있는 R 위치는 최대 slice_span개입니다.

아래 예시에서 R = 14, Slice = m![X, R # 20 % 4](slice_span = 4), Time = m![A, R # 20 / 4]이며 A = 3입니다(따라서 R # 20 / 4로부터 time_span = 5이고, Time::SIZE = A × time_span = 15입니다). 제약 조건은 Time::SIZE가 아니라 time_span에 걸립니다: A처럼 Time에 있는 R이 아닌 축은 R의 인덱스를 바꾸지 않고 사이클을 돌므로 제약 조건에 영향을 주지 않습니다. 올바른 패딩은 R # 16이며(16 - 14 = 2 ≤ slice_span = 4이므로), 이 경우 time_span = 4가 됩니다. R # 20을 사용하면 R이 과도하게 패딩되어 time_span = 5가 되고, 실제 데이터가 없는 Time 반복이 하나 더 추가됩니다. slice_thres 미만의 Slice(Slice 기여분 = 0)에서는 R # 20 / 4 = 4일 때 Sequencer가 재구성한 idx가 4 × 4 = 16에 도달하여 R 인덱스가 16 ≥ R::SIZE가 됩니다. 즉, 도달해서는 안 되는 패딩입니다. CPU 엔진은 바로 이 경우를 잡아냅니다. 과도하게 패딩된 레이아웃에 대해 리덕션을 수행하면, 유효하지 않고 도달할 수 없는 R 인덱스로 실행을 계속하는 대신 panic(out x residue must factor the operand)이 발생합니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 3, R = 14, X = 64];

fn reduce_time_major_wrong<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![X, R # 20 % 4], m![A, R # 20 / 4], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![X, R # 20 % 4], m![A], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![1 # 4]>()
        //   Slice     = m![X, R # 20 % 4]
        //   Time      = m![A, R # 20 / 4]   (A is non-R; time_span = 5 from R # 20 / 4)
        //   Packet    = m![1 # 8]
        //   OutTime   = m![A]              (R eliminated; A survives)
        //   OutPacket = m![1 # 4]
        // NOT supported: R # 20 over-pads (20 - 14 = 6 > slice_span = 4).
        // time_span = 5 > 4. Below-group slices include time steps where R # 20 / 4 = 4 (R = 16 padding).
        .vector_intra_slice_reduce::<R, m![A], m![1 # 4]>(
            IntraSliceReduceOpI32::AddSat,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![X, R # 20 % 4], m![A, R # 20 / 4], m![1 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_time_major_wrong(i);
}

Packet에 있는 R, 복잡한 경우

packet clipper는 Packet = m![R # PADDED_SIZE % packet_span # 8]을 요구합니다. 다른 형태는 fn valid_size()가 의존하는 연속 접두사 속성을 깨뜨립니다.

첫 번째 예제는 R의 상위 부분을 Packet에 배치하므로(R # 24 % 8 대신 R # 24 / 8 형식), 접두부가 R의 다음 연속 구간을 담지 않고 서로 다른 R 스트라이드의 위치를 섞게 됩니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 8, R = 19, X = 64];

fn reduce_wrong_packet_outer<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, f32, m![1], m![1 # 2], m![X, A / 2], m![R # 24 % 8], m![R # 24 / 8 # 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, f32, m![1], m![1 # 2], m![X, A / 2], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![R # 24 / 8 # 4]>()
        //   Slice     = m![X, A / 2]
        //   Time      = m![R # 24 % 8]
        //   Packet    = m![R # 24 / 8 # 8]   (NOT supported: major R in Packet)
        //   OutTime   = m![1]          (R eliminated)
        //   OutPacket = m![1 # 4]
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpF32::Add,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let i: VectorBranchTensor<'_, _, f32, m![1], m![1 # 2], m![X, A / 2], m![R # 24 % 8], m![R # 24 / 8 # 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_wrong_packet_outer(i);
}

두 번째 예시는 R이 다른 축 A와 Packet을 공유하므로, 접두사 기반 개수 계산에서 패딩으로 취급하는 위치를 A의 요소가 차지하게 됩니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 4, R = 19, X = 256];

fn reduce_wrong_mixed_packet<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, f32, m![1], m![1 # 2], m![X], m![R # 24 / 2], m![R # 24 % 2, A], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, f32, m![1], m![1 # 2], m![X], m![1], m![A], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_split::<m![R # 24], m![A]>()
        //   at Vector Init:
        //   Slice     = m![X]
        //   Time      = m![R # 24 / 2]
        //   Packet    = m![R # 24 % 2, A]   (NOT supported: A shares Packet with R)
        //   OutTime   = m![1]          (R eliminated; A silently excluded by prefix valid_size)
        //   OutPacket = m![A]
        .vector_intra_slice_reduce::<R, m![1], m![A]>(
            IntraSliceReduceOpF32::Add,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let i: VectorBranchTensor<'_, _, f32, m![1], m![1 # 2], m![X], m![R # 24 / 2], m![R # 24 % 2, A], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_wrong_mixed_packet(i);
}

Slice와 Packet에 있는 R

R이 Slice와 Packet으로 나뉘어 분할됩니다. 이것을 왜 표현할 수 없는지 알아보기 위해, R = 2045(R # 2048로 패딩)를 Slice = m![R # 2048 / 8](256개 Slice)와 Packet = m![R # 2048 % 8](8요소 플릿)로 분할하는 경우를 생각해 봅시다. 유일한 시간 단계인 t = 0에서 fn valid_size는 모든 Slice에 대해 clamp(2045 - 0, 0, 8) = 8을 계산합니다. 하지만 Slice 255의 플릿은 R 인덱스 2040–2047을 담고 있으며, 그중 실제 데이터는 다섯 개뿐입니다:

t = 0, slice = 255일 때:

플릿 위치01234567
R 인덱스20402041204220432044204520462047
유효 여부예예예예예[pad][pad][pad]

Slice 0–254에는 valid_size = 8이 필요한 것이 맞지만, Slice 255에는 valid_size = 5가 필요합니다. fn valid_size(t)는 주어진 t에 대해 하나의 값만 반환할 수 있으므로, 어떤 단일 설정으로도 동작하지 않습니다.

R::SIZE % packet_span = 0인 퇴화 하위 사례(모든 패킷이 가득 차 있거나 비어 있는 경우)는 Slice 단독 사례로 귀결되며 지원됩니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![R = 2045];

// NOT supported: R = 2045 split across Slice (/ 8, 256 slices) and Packet (% 8).
// Slices 0-254 need valid_size = 8; slice 255 needs valid_size = 5. fn valid_size(t) cannot vary by slice.
fn reduce_wrong_slice_packet<'l, const T: Tu>(
    input: VectorBranchTensor<'l, T, i32, m![1], m![1 # 2], m![R # 2048 / 8], m![1], m![R # 2048 % 8], Fresh, { stage::VeOrder::IntraFirst }>,
) -> VectorIntraSliceReduceTensor<'l, T, i32, m![1], m![1 # 2], m![R # 2048 / 8], m![1], m![1 # 4], Fresh, { stage::VeOrder::IntraFirst }>
{
    input
        .vector_narrow_trim::<m![R # 2048 % 4]>()
        //   Slice     = m![R # 2048 / 8]
        //   Time      = m![1]
        //   Packet    = m![R # 2048 % 8]
        //   OutTime   = m![1]          (R eliminated)
        //   OutPacket = m![1 # 4]
        .vector_intra_slice_reduce::<R, m![1], m![1 # 4]>(
            IntraSliceReduceOpI32::AddSat,
        )
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let i: VectorBranchTensor<'_, _, i32, m![1], m![1 # 2], m![R # 2048 / 8], m![1], m![R # 2048 % 8], Fresh, { stage::VeOrder::IntraFirst }> = VectorBranchTensor::new(&mut device.main, Tensor::zero(), TagMode::Zero);
let _o = reduce_wrong_slice_packet(i);
}

제약 조건

구성 요소용량
packet clipper인스턴스 1개
시간 필터인스턴스 3개
시간 필터 / packet clipper별 Sequencer 항목8(Sequencer 참조)

유효성 추적이 필요한 패딩된 축은 각각 시간 필터 하나 또는 packet clipper를 차지합니다. 한 번의 호출에서 최대 4개의 축을 추적할 수 있습니다(packet clipper 1개 + 시간 필터 3개). 패딩되지 않은 축에는 슬롯이 필요하지 않습니다(slice_mask=0, slice_thres=1은 시간 필터를 비활성화하여 항상 true를 반환하게 합니다).

Intra-Slice Reduce는 단일 REDUCE_LABEL만 받으므로, “다중 축“은 여러 리덕션을 동시에 수행한다는 뜻이 아니라 하나의 리덕션 축 R에 패딩된 비리덕션 축이 추가로 더해진다는 뜻입니다.

다운스트림 4-Way 연산

VCG는 좁히기가 일어나기 전에 8-way 플릿마다 valid_size를 생성합니다. 다운스트림 Narrow 단계는 각 8-way 플릿을 4-way 절반 두 개로 나누며, 좁히기를 적용하는 방식에 따라 각 valid_size가 두 절반에 어떻게 나뉘는지가 결정됩니다.

연산입력출력Valid Count 변환
vector_narrow_split8-way 플릿(valid_size = v)4-way 플릿 두 개low: min(v, 4), high: max(v - 4, 0)
vector_narrow_trim8-way 플릿(valid_size = v)4-way 플릿 한 개min(v, 4)
vector_widen_concat4-way 플릿 두 개(v_low, v_high)8-way 플릿v_low + v_high
vector_widen_pad4-way 플릿8-way 플릿변경 없음

vector_narrow_split과 vector_widen_concat은 접두사 속성을 보존합니다. vector_narrow_trim의 경우 매핑이 v <= 4임을 정적으로 보장해야 합니다. 상위 4개 요소가 유효할 수 있다면 이를 잘라낼 때 데이터가 손실됩니다.

Inter-Slice Reducer

Inter-Slice Reducer는 Cluster 안의 256개 Slice에 걸쳐 텐서를 리덕션합니다. Chip, Cluster, Packet은 그대로 유지하고, Slice와 Time은 OutSlice와 OutTime으로 바꿔 씁니다. 출력 텐서는 입력 모드와 관계없이 항상 Way8입니다.

인터페이스

리듀서에는 vector_init() 직후에 진입하거나(아래에 나오는 InterFirst 경로), 호환되는 intra-slice 단계에서 진입할 수 있습니다(intra-slice 텐서에 같은 vector_inter_slice_reduce() 메서드를 호출하는 IntraFirst 경로). 아래에 나오는 시그니처는 VectorInitTensor 변형입니다. 이 전환을 지원하는 단계의 intra-slice 텐서에도 같은 메서드가 있으므로 호출 코드의 모양은 똑같습니다.

Inter-Slice Reducer는 i32와 f32에 대해 별도의 API를 제공합니다.

i32 연산

impl<'l, const T: Tu, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M>
    VectorInitTensor<'l, T, i32, Chip, Cluster, Slice, Time, Packet>
{
    /// Performs inter-slice reduce for i32 as the first VE operation.
    #[primitive(VectorInitTensor::vector_inter_slice_reduce)]
    pub fn vector_inter_slice_reduce<OutSlice: M, OutTime: M>(
        self,
        op: InterSliceReduceOpI32,
    ) -> VectorInterSliceReduceTensor<'l, T, i32, Chip, Cluster, OutSlice, OutTime, Packet, { VeOrder::InterFirst }>
    {
        let reduced = self.inner.reduce(op.reduce_fn(), op.identity(), true);
        create_inter_slice_reduce_tensor(self.device, reduced)
    }
}

InterSliceReduceOpI32 연산:

연산설명
Add래핑 덧셈
AddSat포화 덧셈
Max최댓값
Min최솟값

f32 연산

impl<'l, const T: Tu, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M>
    VectorInitTensor<'l, T, f32, Chip, Cluster, Slice, Time, Packet>
{
    /// Performs inter-slice reduce for f32 as the first VE operation.
    #[primitive(VectorInitTensor::vector_inter_slice_reduce)]
    pub fn vector_inter_slice_reduce<OutSlice: M, OutTime: M>(
        self,
        op: InterSliceReduceOpF32,
    ) -> VectorInterSliceReduceTensor<'l, T, f32, Chip, Cluster, OutSlice, OutTime, Packet, { VeOrder::InterFirst }>
    {
        let reduced = self.inner.reduce(op.reduce_fn(), op.identity(), true);
        create_inter_slice_reduce_tensor(self.device, reduced)
    }
}

InterSliceReduceOpF32 연산:

연산설명
Add부동소수점 덧셈
Max최댓값
Min최솟값
Mul부동소수점 곱셈

제약 조건

지원되는 Slice → OutSlice 및 Time → OutTime 형태는 다음 네 가지 규칙을 따릅니다.

  1. 가장 안쪽부터 리덕션하십시오. Slice에서 리덕션되는 부분은 가장 안쪽 인수들이어야 하며, 연속적이어야 하고, 리덕션 비율 r까지 스트라이드가 1이어야 합니다.
  2. 리덕션된 축의 대체. OutSlice에서 리덕션된 각 인자의 자리는 더미(1 # n), 새 차원에 대한 브로드캐스트, Time으로부터의 승격 중 하나로 채워집니다.
  3. 대체 종류는 자유롭게 섞을 수 있습니다. 더미, 브로드캐스트, 승격 자리는 OutSlice에 순서와 관계없이 함께 나타날 수 있습니다.
  4. Time에서 OutSlice로의 승격은 순서를 바꿉니다. Time → OutTime 부분은 남아 있는 인자들의 상대적 순서를 유지하지만, Time → OutSlice 승격 경로는 순서를 유지하지 않습니다. 승격된 인자의 OutSlice 내 위치는 Time 내 위치와 무관합니다.

예제

아래 예제의 수식은 einsum 표기법을 사용합니다. 입력 쪽에는 나타나지만 출력 쪽에는 나타나지 않는 차원은 리덕션(합산)되고, 출력 쪽에는 나타나지만 입력 쪽에는 나타나지 않는 차원은 브로드캐스트됩니다.

더미 대체

이 패스는 입력을 R에 걸쳐 합산하고 그 결과를 더미 자리에 배치합니다. einsum 형식은 AR -> A입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 512, B = 2, R = 4];

// When R is reduced and no other dimension fills its slot, the output keeps the slot as a 1 # n dummy.
// One position holds the reduced value, and the remaining n - 1 are padding.
// `# n` denotes dimension multiplicity (see the Mapping Expressions doc).
fn inter_slice_add<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![B], m![A / 8, R], m![1], m![A % 8]>,
) -> VectorFinalTensor<'l, T, i32, m![1], m![B], m![A / 8, 1 # 4], m![1], m![A % 8]> {
    input
        .vector_init()
        // sum across R, the freed R-slot becomes the 1 # 4 dummy in OutSlice
        .vector_inter_slice_reduce::<m![A / 8, 1 # 4], m![1]>(InterSliceReduceOpI32::AddSat)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![B], m![A / 8, R], m![1], m![A % 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = inter_slice_add(c);
}
Slice = [A / 8, R]  ->  [A / 8, 1 # 4]
Time  = [1]         ->  [1]

새 Slice 차원으로의 브로드캐스트

이 패스는 R을 리덕션하고 그 결과를 새 차원 X에 대해 브로드캐스트합니다. einsum 형식은 PRW -> PWX이며, 출력 쪽의 새 X가 브로드캐스트를 나타냅니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 2, P = 8, R = 4, W = 64, X = 4];

// A fresh non-reduce dimension X takes the slot that R leaves behind.
// The reduced value broadcasts across every position of X.
fn broadcast_into_x<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![W, R], m![1], m![P]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![W, X], m![1], m![P]> {
    input
        .vector_init()
        // sum across R, broadcast result over X (fresh OutSlice dimension)
        .vector_inter_slice_reduce::<m![W, X], m![1]>(InterSliceReduceOpF32::Add)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, f32, m![1], m![B], m![W, R], m![1], m![P]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = broadcast_into_x(c);
}
Slice = [W, R]  ->  [W, X]
Time  = [1]     ->  [1]

Time에서 OutSlice로의 승격

이 패스는 R을 리덕션하고 Time 차원 V를 OutSlice로 승격합니다. einsum 형식은 PRSUVW -> PSUVW입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 2, P = 8, R = 4, S = 2, U = 2, V = 4, W = 64];

// A dimension from Time (here V) is promoted into OutSlice to fill R's slot.
// The promoted dimension does not need to be outermost in Time.
fn axis_promotion<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![B], m![W, R], m![S, V, U], m![P]>,
) -> VectorFinalTensor<'l, T, f32, m![1], m![B], m![W, V], m![S, U], m![P]> {
    input
        .vector_init()
        // sum across R, V moves from Time to OutSlice
        .vector_inter_slice_reduce::<m![W, V], m![S, U]>(InterSliceReduceOpF32::Add)
        .vector_final()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, f32, m![1], m![B], m![W, R], m![S, V, U], m![P]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = axis_promotion(c);
}
Slice = [W, R]     ->  [W, V]
Time  = [S, V, U]  ->  [S, U]

리듀서와 Intra-Slice Chain을 어느 순서로든 결합한 예제는 Vector Engine 예제를 참고하십시오.

성능

리덕션 비율 r(하나의 리덕션 그룹에 속한 Slice 수)이 주요 튜닝 요소입니다. Inter-Slice 리덕션 지연 시간은 O(r) 사이클로, 리덕션 그룹을 링으로 대략 한 바퀴 순회하는 시간입니다. 총 시간은 입력 스트리밍 시간에 링 크기만큼의 꼬리 구간을 더한 값입니다.

실제로는 업스트림 작업(부분합을 생성하는 축약 또는 vector_inter_slice_reduce() 이전의 intra-slice 작업)이 대개 더 오래 걸려 이 링 꼬리 구간을 가리므로, 리듀서가 병목이 되지 않습니다. 리듀서의 영향은 r이 클 때(꼬리 구간이 더 길어짐)와 고정된 꼬리 구간을 여러 패킷에 걸쳐 분산할 수 없는 작은 텐서에서 드러납니다.

Cast Engine

Cast Engine은 Commit Engine이 f32/i32 파이프라인 결과를 DM에 쓰기 전에 이를 더 낮은 정밀도 타입(예: bf16)으로 좁혀 저장 비용을 줄입니다.

인터페이스

CollectTensor, ContractTensor, VectorFinalTensor는 모두 동일한 의미의 .cast()를 제공합니다.

//
// The Cast Engine accepts only `VeScalar` inputs (hardware constraint), so the
// bound lives on the impl rather than on a wider trait.
impl<'l, const T: Tu, P: CanApplyCast, D: VeScalar, Chip: M, Cluster: M, Slice: M, Time: M, Packet: M, B: Backend>
    TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Casts each element to type `OutD` and pads the output packet back to one
    /// 32-byte flit.
    #[primitive(TuTensor::cast)]
    pub fn cast<OutD: Scalar, OutPacket: M>(self) -> CastTensor<'l, T, OutD, Chip, Cluster, Slice, Time, OutPacket, B>
    where
        D: CastEngineCast<OutD>,
    {
        verify_cast::<D, OutD, Packet, OutPacket>();
        CastTensor::new(self.device, self.inner.map(|v| v.cast()).transpose(false))
    }
}

.cast::<OutD, OutPacket>()는 각 요소를 OutD 타입으로 변환하고 출력을 다시 하나의 32바이트 플릿으로 패딩합니다. 커널 작성자는 OutD(대상 타입)와 OutPacket(출력 요소 레이아웃)을 선택합니다. 나머지는 컴파일러가 도출합니다.

Cast Engine은 수학적 텐서 이동은 아니지만, 텐서의 형태를 보존하고 요소 타입만 변경합니다. Packet 레이아웃을 제외한 모든 차원은 변경 없이 그대로 통과하며, Packet 레이아웃은 출력이 여전히 하나의 32바이트 플릿에 들어가도록 다시 패딩됩니다.

아래 예제는 8개 요소로 이루어진 i32 패킷(8 × 4 = 32바이트)을 i8로 캐스팅합니다. 캐스팅 후 8개 요소는 8바이트를 차지하므로, A # 32가 출력을 다시 32바이트로 패딩합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![B = 4, A = 8];

fn cast_i32_to_i8<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![1 # 2], m![1 # 256], m![B], m![A]>,
) -> CastTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![B], m![A # 32]> {
    input.cast()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![1 # 2], m![1 # 256], m![B], m![A]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = cast_i32_to_i8(c);
}

입력 데이터가 32바이트를 모두 채우지 않을 수도 있습니다. 아래 예제는 4개의 데이터 요소가 8로 패딩된(A # 8, 32바이트) i32 입력을, 동일한 4개 요소가 32로 패딩된(A # 32, 역시 32바이트) i8 출력으로 캐스팅합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![A = 4];

fn cast_padded<'l, const T: Tu>(
    input: CollectTensor<'l, T, i32, m![1], m![1 # 2], m![1 # 256], m![1], m![A # 8]>,
) -> CastTensor<'l, T, i8, m![1], m![1 # 2], m![1 # 256], m![1], m![A # 32]> {
    input.cast()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i32, m![1], m![1 # 2], m![1 # 256], m![1], m![A # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = cast_padded(c);
}

지원되는 캐스팅

각 입력은 32바이트 플릿입니다. 지원되는 원본 타입은 f32와 i32이며, 각각 지원하는 대상 타입이 정해져 있습니다:

입력 타입(D)지원되는 출력 타입(OutD)
i32i4, i8, i16
f32f8e5m2, f8e4m3, bf16

성능

Cast Engine은 결코 파이프라인 병목이 되지 않습니다. 플릿 중 유효한 데이터를 담은 부분이 얼마이든 상관없이 사이클당 하나의 플릿을 처리하기 때문입니다. 다운스트림 Commit Engine이 충분히 활용되지 않은 플릿을 모아 밀집된 DM 쓰기로 만들므로 DM 대역폭이 낭비되지 않습니다.

Transpose Engine

Transpose Engine은 Chip, Cluster, Slice 차원은 그대로 두고 Time 차원과 Packet 차원을 맞바꿉니다.

인터페이스

CollectTensor와 VectorFinalTensor는 모두 .transpose()를 제공합니다. VectorFinalTensor 진입점은 Vector Engine 출력을 Transpose Engine에 직접 공급합니다.

// `D: MaterializableScalar` (see its doc) excludes i5/i9 stagings from transpose.
impl<
    'l,
    const T: Tu,
    P: CanApplyTranspose,
    D: MaterializableScalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Performs the transpose operation.
    #[primitive(TuTensor::transpose)]
    pub fn transpose<OutTime: M, OutPacket: M>(
        self,
    ) -> TransposeTensor<'l, T, D, Chip, Cluster, Slice, OutTime, OutPacket, B> {
        verify_transpose::<D, Time, Packet, OutTime, OutPacket>();
        TransposeTensor::new(self.device, self.inner.transpose(false))
    }
}

커널 작성자는 OutTime과 OutPacket(출력 차원 레이아웃)을 선택하고, 컴파일러는 그 결과가 매개변수에 나열된 하드웨어 제약 조건을 만족하는지 검증합니다.

아래 예제는 너비 16인 각 행을 두 개의 입력 패킷(D = 2)에서 모아 구성한 8×16 i8 행렬을 전치합니다. 이 예제는 이 페이지의 나머지 부분에서 계속 사용하는 예제입니다.

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![P = 256, B = 2, C = 8, D = 2, E = 8];

fn basic_transpose<'l, const T: Tu>(
    input: CollectTensor<'l, T, i8, m![1], m![1 # 2], m![P], m![B, C, D], m![E # 32]>,
) -> TransposeTensor<'l, T, i8, m![1], m![1 # 2], m![P], m![B, D, E], m![C # 32]> {
    input.transpose()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i8, m![1], m![1 # 2], m![P], m![B, C, D], m![E # 32]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = basic_transpose(c);
}

아키텍처

아래의 다섯 가지 전치 단계는 인터페이스의 예제를 사용해 설명합니다.

매개변수

valid_size는 Transpose Engine이 32바이트 입력 버스에서 사이클마다 읽는 유효 요소의 수이며, 모든 입력 플릿은 bit-width × valid_size 형태로 도착하고, 32바이트 플릿에서 남는 바이트는 패딩으로 취급되어 Unpack 단계에서 버려집니다. 8비트 및 16비트 요소의 경우, 엔진은 이중 입력 패킹을 선택하여 각 플릿에서 기본값인 8개 대신 16개의 유효 요소를 읽을 수 있습니다. 4비트 또는 32비트 요소에서는 이중 패킹을 지원하지 않습니다. 데이터는 CollectTensor::transpose()(Fetch → [Switch →] Collect → [Cast →] Transpose 이후) 또는 VectorFinalTensor::transpose()(Vector Engine에서 직접)를 통해 Transpose Engine에 도달합니다. Contraction Engine은 32b × 8만 내보내는 반면, Vector Engine과 Fetch Engine은 아래 표의 어떤 조합이든 내보냅니다.

in_cols, in_rows, out_rows는 커널 작성자가 선택한 OutTime과 OutPacket에 따라 정해집니다. 네 가지 모두 요소 크기에 따라 제약을 받습니다:

요소 크기단일 valid_size이중 valid_size최대 in_rows유효한 in_cols
4비트16지원하지 않음1616, 32
8비트81688, 16, 32
16비트81648, 16, 32
32비트8지원되지 않음28, 16, 32

이 문서 전체에서 사용하는 예제(i8이므로 valid_size = 8)에서 컴파일러는 다음 값을 도출합니다.

매개변수값비고
in_cols16수집된 패킷 D = 2개 × valid_size = 8
in_rows8C::SIZE
out_rows16D·E(= in_cols, 완전 활용)
                 in_cols                  in_rows # F
           ┌─────────────────┐         ┌──────────────────┐
           │ 12 13 14 15 ... │         │ 3  7  11 15  ... │
 in_rows   │ 8  9  10 11 ... │  ────►  │ 2  6  10 14  ... │  out_rows
           │ 4  5  6  7  ... │         │ 1  5  9  13  ... │
           │ 0  1  2  3  ... │         │ 0  4  8  12  ... │
           └─────────────────┘         └──────────────────┘
                data_in                      data_out

Unpack

32바이트 입력 패킷마다 유효한 요소가 valid_size개 들어 있습니다. Unpack 단계는 플릿의 나머지 부분을 패딩으로 보고 버립니다. 8비트 또는 16비트 패킷에 기본 8요소 접두부를 넘어서는 유효 요소가 있으면 컴파일러는 이중 패킹을 선택합니다. 이때도 선택된 valid_size는 유효 접두부 전체를 포함해야 하며, 나머지 레인은 모두 패딩이어야 합니다.

이 문서의 예제에서는 [C, D, E # 32] → [C, D, E]입니다.

예를 들어 i8 패킷 [B=16 # 32]는 이중 패킹을 사용하여 유효 요소 16개를 모두 한 사이클에 전달합니다. 이와 동등한 단일 패킹 레이아웃이라면 연속된 두 패킷에서 각각 요소를 8개씩 전달하게 됩니다. i4 패킷은 이중 형식을 요청할 수 없습니다. 지원되는 valid_size가 16 하나뿐이기 때문입니다.

Gather

입력 행렬의 한 행은 너비가 in_cols = packets_per_col × valid_size개 요소이며, 연속된 packets_per_col개 패킷, 즉 가장 안쪽의 시간 단계들로부터 조립됩니다. Gather 단계는 이 패킷들을 하나의 행으로 이어 붙이고, 그 위의 in_rows개 시간 단계가 쌓여 [in_rows × in_cols] 입력 행렬을 이룹니다. (Unpack과 Gather는 모두 엔진이 입력을 읽는 동안 일어나며, 별도로 버퍼링되는 패스가 아닙니다.)

실행 예제에서 가장 안쪽의 D = 2개 패킷이 각각 valid_size = 8을 제공하여 너비 in_cols = 16의 행을 이루고, 그 위의 C = 8개 시간 단계가 쌓여 [8 × 16] 입력 행렬을 이룹니다.

Transpose

행렬을 전치합니다: [in_rows × in_cols] → [in_cols × in_rows].

계속 사용하는 예제에서는 [C, D, E] → [D, E, C]입니다.

Trim

일부 입력 패킷이 valid_size보다 적은 유효 요소를 담고 있으면 전치된 행렬에 패딩 행이 생깁니다. Trim 단계는 이러한 행을 제거하여 [out_rows × in_rows]를 만들며, 이때 out_rows ≤ in_cols입니다.

계속 사용하는 예제에서는 [D, E, C] → [D, E, C]입니다(입력이 완전히 활용되므로 제거되는 행이 없습니다). Trim이 실제로 행을 버리는 경우는 작은 행렬 예제를 참고하십시오.

Align

전치된 행은 in_rows개 요소 너비이지만, DM 패킷은 32바이트여야 합니다. Align 단계는 각 행을 32바이트 플릿으로 패딩하여 [out_rows × (in_rows # F)] 형태를 만들며, 여기서 F는 D[F]가 32바이트가 되도록 선택됩니다.

계속 사용하는 예제에서는 [D, E, C] → [D, E, C # 32]입니다.

지연 시간

Note

공식은 먼저 성능을 읽어 보십시오.

계속 사용하는 예제에서는 in_cols = 16 ≤ 16이므로 더블 버퍼링이 선택됩니다. in_flits = 16, out_rows = 16, n = 2일 때 총 지연 시간은 16 + 1 × max(16, 16) + 16 = 48 사이클입니다.

예제

작은 행렬

이 예제는 out_rows < in_cols일 때 Trim 단계가 패딩 행을 버리는 과정을 보여 줍니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![P = 256, A = 4, B = 2];

fn small_transpose<'l, const T: Tu>(
    input: CollectTensor<'l, T, i8, m![1], m![1 # 2], m![P], m![A], m![B # 32]>,
) -> TransposeTensor<'l, T, i8, m![1], m![1 # 2], m![P], m![B], m![A # 32]> {
    input.transpose()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i8, m![1], m![1 # 2], m![P], m![A], m![B # 32]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = small_transpose(c);
}

매개변수:

매개변수값비고
in_cols8B::SIZE = 2, 8로 패딩
in_rows4A::SIZE
out_rows2B::SIZE

단계:

  • Unpack: [A, B # 32] → [A, B # 8].
  • Gather: [A, B # 8] → [A, B # 8] (packets_per_col = 1이므로 각 패킷이 이미 온전한 in_cols = 8 행입니다).
  • Transpose: [A, B # 8] → [B # 8, A].
  • Trim: [B # 8, A] → [B, A] (패딩된 행 6개가 잘려 나갑니다).
  • Align: [B, A] → [B, A # 32].

지연 시간: in_cols = 8 ≤ 16이므로 더블 버퍼링이 선택됩니다. in_flits = 4, out_rows = 2, n = 1일 때 총 사이클 수는 4 + 0 × max(4, 2) + 2 = 6입니다.

큰 열

이 예제는 in_cols > 16으로 만들어 싱글 버퍼링을 강제합니다. 이로 인해 입력과 출력이 겹칠 수 없으므로 총 사이클이 증가합니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![P = 256, B = 2, C = 8, D = 4, E = 8];

fn large_col_transpose<'l, const T: Tu>(
    input: CollectTensor<'l, T, i8, m![1], m![1 # 2], m![P], m![B, C, D], m![E # 32]>,
) -> TransposeTensor<'l, T, i8, m![1], m![1 # 2], m![P], m![B, D, E], m![C # 32]> {
    input.transpose()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i8, m![1], m![1 # 2], m![P], m![B, C, D], m![E # 32]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = large_col_transpose(c);
}

매개변수:

매개변수값비고
in_cols32D::SIZE × E::SIZE
in_rows8C::SIZE
out_rows32D::SIZE × E::SIZE

단계:

  • Unpack: [C, D, E # 32] → [C, D, E].
  • Gather: 가장 안쪽의 D = 4개 패킷이 각 in_cols = 32 행을 이루고, C = 8개의 시간 단계가 쌓여 [8 × 32] 입력 행렬이 됩니다.
  • Transpose: [C, D, E] → [D, E, C].
  • Trim: [D, E, C] → [D, E, C] (잘려 나가는 행이 없습니다).
  • Align: [D, E, C] → [D, E, C # 32].

지연 시간: in_cols = 32 > 16이므로 싱글 버퍼링이 선택됩니다. in_flits = 32, out_rows = 32, n = 2(B)일 때 총 사이클 수는 2 × (32 + 32) = 128입니다.

16비트 데이터 타입

이 예제는 bf16을 사용합니다. 더 넓은 요소로 인해 최대 in_rows가 절반(8 대신 4)으로 줄고, 32바이트 출력 플릿이 16개 요소로 줄어듭니다(i8의 경우 32개):

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![P = 256, C = 8, D = 4, E = 8];

fn bf16_transpose<'l, const T: Tu>(
    input: CollectTensor<'l, T, bf16, m![1], m![1 # 2], m![P], m![C, D], m![E # 16]>,
) -> TransposeTensor<'l, T, bf16, m![1], m![1 # 2], m![P], m![C, E], m![D # 16]> {
    input.transpose()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, bf16, m![1], m![1 # 2], m![P], m![C, D], m![E # 16]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = bf16_transpose(c);
}

매개변수:

매개변수값비고
in_cols8E::SIZE
in_rows4D::SIZE
out_rows8E::SIZE

단계:

  • Unpack: [D, E # 16] → [D, E].
  • Gather: [D, E] → [D, E] (packets_per_col = 1, 각 패킷이 이미 in_cols = 8인 완전한 행입니다).
  • Transpose: [D, E] → [E, D].
  • Trim: [E, D] → [E, D] (잘라내는 행이 없습니다).
  • Align: [E, D] → [E, D # 16].

지연 시간: in_cols = 8 ≤ 16이므로 더블 버퍼링이 선택됩니다. in_flits = 4, out_rows = 8, n = 8(C)일 때 총 사이클 수는 4 + 7 × max(4, 8) + 8 = 68입니다.

4비트 데이터 타입

이 예제는 i4를 사용합니다. valid_size = 16으로 사이클당 요소 수가 두 배가 되고 최대 in_rows는 16(16 × 4비트 = 8바이트)으로 늘어나며, 32바이트 플릿은 64개 요소로 커집니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![P = 256, B = 4, C = 16, E = 16];

fn i4_transpose<'l, const T: Tu>(
    input: CollectTensor<'l, T, i4, m![1], m![1 # 2], m![P], m![B, C], m![E # 64]>,
) -> TransposeTensor<'l, T, i4, m![1], m![1 # 2], m![P], m![B, E], m![C # 64]> {
    input.transpose()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, i4, m![1], m![1 # 2], m![P], m![B, C], m![E # 64]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = i4_transpose(c);
}

매개변수:

매개변수값비고
in_cols16E::SIZE
in_rows16C::SIZE
out_rows16E::SIZE

단계:

  • Unpack: [C, E # 64] → [C, E].
  • Gather: [C, E] → [C, E] (packets_per_col = 1, 각 패킷이 이미 온전한 in_cols = 16 행입니다).
  • Transpose: [C, E] → [E, C].
  • Trim: [E, C] → [E, C] (잘라 내는 행이 없습니다).
  • Align: [E, C] → [E, C # 64].

지연 시간: in_cols = 16 ≤ 16이므로 더블 버퍼링이 선택됩니다. in_flits = 16, out_rows = 16, n = 4(B)일 때 총합은 16 + 3 × max(16, 16) + 16 = 80 사이클입니다.

32비트 데이터 타입

이 예제는 Contraction Engine이 출력하는 두 가지 32비트 형식(32b × 8, f32 또는 i32) 중 하나인 f32를 사용합니다. 더 넓은 요소로 인해 최대 in_rows가 2(2 × 4바이트 = 8바이트)로 줄고, 32바이트 플릿이 8개 요소로 줄어듭니다:

#![allow(unused)]
fn main() {
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![P = 256, B = 4, D = 2, E = 8];

fn f32_transpose<'l, const T: Tu>(
    input: CollectTensor<'l, T, f32, m![1], m![1 # 2], m![P], m![B, D], m![E # 8]>,
) -> TransposeTensor<'l, T, f32, m![1], m![1 # 2], m![P], m![B, E], m![D # 8]> {
    input.transpose()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();

let c: CollectTensor<'_, _, f32, m![1], m![1 # 2], m![P], m![B, D], m![E # 8]> = CollectTensor::new(&mut device.main, Tensor::zero());
let _o = f32_transpose(c);
}

매개변수:

매개변수값비고
in_cols8E::SIZE
in_rows2D::SIZE
out_rows8E::SIZE

단계:

  • Unpack: [D, E # 8] → [D, E].
  • Gather: [D, E] → [D, E] (packets_per_col = 1; 각 패킷이 이미 완전한 in_cols = 8 행입니다).
  • Transpose: [D, E] → [E, D].
  • Trim: [E, D] → [E, D] (잘라내는 행이 없습니다).
  • Align: [E, D] → [E, D # 8].

지연 시간: in_cols = 8 ≤ 16이므로 더블 버퍼링이 선택됩니다. in_flits = 2, out_rows = 8, n = 4(B)일 때 합계는 2 + 3 × max(2, 8) + 8 = 34 사이클입니다.

성능

버스트 하나는 n = OutTime::SIZE / out_rows회의 전치 반복을 실행합니다. 각 반복은 in_flits = in_rows × (in_cols / valid_size)개의 입력 플릿과 out_rows개의 출력 플릿을 이동합니다.

버퍼링 모드

Transpose Engine에는 내부 버퍼가 두 개 있으며, 각 버퍼는 16개의 열을 담습니다. 컴파일러는 in_cols ≤ 16일 때 더블 버퍼링을, 그 외에는 싱글 버퍼링을 선택합니다. 더블 버퍼링은 입력과 출력을 겹쳐 총 사이클 수를 줄이고, 싱글 버퍼링은 두 단계를 순차적으로 수행합니다.

싱글 버퍼링 지연 시간

버스트의 총 지연 시간은 n × (in_flits + out_rows)입니다. 두 버퍼를 함께 사용하므로 매 반복마다 입력과 출력 시간이 더해집니다.

더블 버퍼링 지연 시간

버스트의 총 지연 시간은 in_flits + (n - 1) × max(in_flits, out_rows) + out_rows이며, 다음 세 단계로 나뉩니다:

  • 입력 전용 단계(in_flits 사이클): 첫 번째 버퍼가 채워집니다.
  • 중첩 단계((n - 1) × max(in_flits, out_rows) 사이클): 한 버퍼가 입력을 받는 동안 다른 버퍼가 동시에 출력을 생성하므로, 매 반복은 더 느린 쪽에 의해 결정됩니다.
  • 출력 전용 단계(out_rows 사이클): 마지막 버퍼가 비워집니다.

Commit Adapter

Commit Adapter는 Commit Engine이 패킷 스트림을 DM에 쓰기 전에 해당 스트림에 요소별 변환을 적용합니다. Commit Adapter는 Tensor Unit의 출력 측에서 Fetch Adapter와 대칭을 이룹니다.

Adapter의 단계는 업스트림 텐서에 대한 전용 .commit_xxx(...) 메서드로 체인을 이루며, 체인은 항상 실제 DM 쓰기를 수행하는 .commit(...)으로 끝납니다. 트리밍은 필수 첫 단계입니다. .commit() / .commit_view()는 .commit_trim(...) 이후에만 호출할 수 있으므로 모든 commit은 먼저 트리밍됩니다(플릿 패딩은 이 단계에서 제거됩니다). 나머지 공개 단계는 선택적인 타입 캐스팅이며, 그다음부터 main 컨텍스트와 sub 컨텍스트가 갈라집니다. Generate Mode는 memset이 즉시 값 채우기에 내부적으로 사용하는 별도의 서브 컨텍스트 경로이며, TuTensor에서 체인으로 이어지지 않습니다.

  • Main 파이프라인: 트리밍 → 타입 캐스팅(선택적으로 ReLU 융합) → .commit().
  • Sub 우회 경로: Generate Mode는 업스트림 Tensor Unit 스트림을 소비하지 않고 memset을 위해 상수를 직접 씁니다.
연산MainSub
트리밍✅✅
타입 캐스팅(선택적 ReLU 융합)✅❌
Generate Mode(내부 memset 경로)❌✅

트리밍

Tensor Unit 파이프라인의 스트림 패킷은 항상 32바이트 플릿이지만(Collect Engine 참고), 플릿은 용량보다 적은 유효 요소를 담을 수 있으며 이 경우 뒤쪽 요소는 패딩으로 채워집니다. 플릿 전체를 그대로 쓰면 유효 영역 너머의 DM 바이트가 플릿의 패딩 값으로 덮어쓰입니다.

트리밍은 각 플릿의 앞쪽 valid_size개 요소만 DM에 쓰고 뒤쪽 패딩은 버림으로써 이 문제를 해결합니다. 컴파일러는 출력 텐서 매핑으로부터 valid_size를 도출합니다. 사용자가 이 값을 직접 설정하지는 않습니다. D[valid_size]는 8, 16, 24, 32바이트 중 하나여야 합니다(32는 트리밍하지 않음을 의미합니다). 트리밍은 지연 시간을 거의 추가하지 않습니다.

모든 커밋에 제거할 패딩이 있는 것은 아니지만, 트리밍은 Commit Adapter의 필수 첫 단계입니다. valid_size가 이미 32바이트이면 플릿 전체가 유효하므로 트리밍은 아무 작업도 하지 않습니다(no-op). 트리밍이 필수인 이유는 .commit()을 .commit_trim(...) 이후에만 호출할 수 있기 때문이며, 따라서 트리밍은 체인의 기점이 되고 타입 캐스팅(main)보다 먼저 실행됩니다.

// `D: MaterializableScalar` here (trim is the commit path's mandatory first stage) keeps i5/i9 uncommittable.
impl<
    'l,
    const T: Tu,
    P: CanApplyCommitTrim,
    D: MaterializableScalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Runs the Commit Adapter's trimming stage.
    ///
    /// Drops the trailing padding from each flit so DM stores only valid
    /// elements. `OutPacket` is the post-trim layout the kernel
    /// promises; the compiler derives the trim count from the input and
    /// output mappings.
    #[primitive(TuTensor::commit_trim)]
    pub fn commit_trim<OutPacket: M>(self) -> CommitTrimTensor<'l, T, D, Chip, Cluster, Slice, Time, OutPacket, B> {
        verify_commit_trim::<D, Packet, OutPacket>();
        // `transpose(false)` is type-system filler; real trim lowering lands with the backend wiring.
        CommitTrimTensor::new(self.device, self.inner.transpose(false))
    }
}

.commit_trim::<OutPacket>()은 트리밍 후의 패킷을 선언하고, 이어서 체인된 .commit(...)이 트리밍된 스트림에 대해 DM 쓰기를 수행합니다. 두 동작은 완전히 분리되어 있습니다.

#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![M = 4, K = 2, W = 8, N = 16, J = 64];

fn commit_trim_i8_padding<'l, const T: Tu>(
    input: CastTensor<'l, T, i8, m![1], m![1], m![1], m![M, K], m![W # 32]>,
) -> CommitTrimTensor<'l, T, i8, m![1], m![1], m![1], m![M, K], m![W]> {
    // 8 valid i8 out of 32 padded; OutPacket drops the `# 32` padding.
    input.commit_trim::<m![W]>()
}

fn commit_trim_f32_non_padding<'l, const T: Tu>(
    input: ContractTensor<'l, T, f32, m![1], m![1], m![1], m![M, K], m![W]>,
) -> CommitTrimTensor<'l, T, f32, m![1], m![1], m![1], m![M, K], m![W = 4]> {
    // 4 valid f32 out of 8; OutPacket resizes `W` to 4.
    input.commit_trim::<m![W = 4]>()
}

fn commit_trim_bf16_with_transpose<'l, const T: Tu>(
    input: CastTensor<'l, T, bf16, m![1], m![1], m![1], m![M, K], m![N]>,
) -> CommitTrimTensor<'l, T, bf16, m![1], m![1], m![1], m![M, K], m![N = 8]> {
    // 8 valid bf16 out of 16; OutPacket resizes `N` to 8.
    input.commit_trim::<m![N = 8]>()
}

fn commit_trim_i4_no_trim<'l, const T: Tu>(
    input: CastTensor<'l, T, i4, m![1], m![1], m![1], m![M, K], m![J]>,
) -> CommitTrimTensor<'l, T, i4, m![1], m![1], m![1], m![M, K], m![J]> {
    // No trimming; `OutPacket == Packet`.
    input.commit_trim::<m![J]>()
}

let mut device = Device::new(Topology { chips: 1, pes: 8 }).unwrap();
let a: CastTensor<'_, _, i8, m![1], m![1], m![1], m![M, K], m![W # 32]> = CastTensor::new(&mut device.main, Tensor::zero());
let _o = commit_trim_i8_padding(a);
let b: ContractTensor<'_, _, f32, m![1], m![1], m![1], m![M, K], m![W]> = ContractTensor::new(&mut device.main, Tensor::zero());
let _o = commit_trim_f32_non_padding(b);
let c: CastTensor<'_, _, bf16, m![1], m![1], m![1], m![M, K], m![N]> = CastTensor::new(&mut device.main, Tensor::zero());
let _o = commit_trim_bf16_with_transpose(c);
let d: CastTensor<'_, _, i4, m![1], m![1], m![1], m![M, K], m![J]> = CastTensor::new(&mut device.main, Tensor::zero());
let _o = commit_trim_i4_no_trim(d);

타입 캐스팅

타입 캐스팅은 커밋 경로에서 f32 데이터를 bf16 형식으로 변환하며, 필요에 따라 같은 패스에 ReLU 활성화를 융합할 수 있습니다. Tensor Unit 파이프라인에서 대부분의 타입 변환은 Cast Engine이 처리합니다. Commit Adapter 타입 캐스팅은 메인 컨텍스트의 축약을 서브 컨텍스트의 Vector Engine 작업과 병렬로 실행하는 한 가지 특정한 경우를 위해 존재합니다. Cast Engine은 Vector Engine 위에 위치하므로 변환하는 동안 Vector Engine을 점유합니다. 메인 컨텍스트가 f32 → bf16 변환을 Cast Engine으로 수행하면 Vector Engine이 사용 중이 되어 서브 컨텍스트를 병렬로 실행할 수 없습니다. 대신 변환을 Commit Adapter를 거치도록 하면 Vector Engine을 서브 컨텍스트가 사용할 수 있도록 비워 둘 수 있습니다. 서브 컨텍스트 자체는 타입 캐스팅을 지원하지 않습니다(위의 지원 매트릭스와 일치합니다).

commit_cast와 commit_cast_relu는 모드가 있는 하나의 변환이 아니라 서로 별개의 하드웨어 변환이므로 별도의 메서드로 나뉘어 있습니다. ReLU에는 실행 시점에 선택할 수 있는 독립된 단계가 없으며, 축소 캐스트와 융합된 형태로만 존재합니다.

impl<
    'l,
    const T: Tu,
    P: CanApplyCommitCast,
    D: MaterializableScalar,
    Chip: M,
    Cluster: M,
    Slice: M,
    Time: M,
    Packet: M,
    B: Backend,
> TuTensor<'l, T, P, D, Chip, Cluster, Slice, Time, Packet, B>
{
    /// Runs the Commit Adapter's type-casting stage.
    ///
    /// Folds the `f32` → `bf16` cast into the commit path, leaving the
    /// [Cast Engine](crate::engine::cast) free for sub-context Vector Engine
    /// work. See [`CommitCast`](crate::prelude::CommitCast) for why this is the
    /// only conversion, and [`commit_cast_relu`](Self::commit_cast_relu) for the
    /// variant that fuses a ReLU.
    #[primitive(TuTensor::commit_cast)]
    pub fn commit_cast<OutD: Scalar>(self) -> CommitCastTensor<'l, T, OutD, Chip, Cluster, Slice, Time, Packet, B>
    where
        D: CommitCast<OutD>,
    {
        verify_commit_cast::<Packet>();
        CommitCastTensor::new(self.device, self.inner.map(|v| v.cast()))
    }

    /// The same cast with a ReLU fused in, clamping negative values to zero.
    ///
    /// A separate method rather than an argument because the two are separate
    /// hardware conversions (`CommitF32ToBf16` and `CommitF32ToBf16Relu`), and
    /// ReLU has no standalone stage to select at run time.
    #[primitive(TuTensor::commit_cast_relu)]
    pub fn commit_cast_relu<OutD: Scalar>(self) -> CommitCastTensor<'l, T, OutD, Chip, Cluster, Slice, Time, Packet, B>
    where
        D: CommitCast<OutD>,
    {
        verify_commit_cast::<Packet>();
        CommitCastTensor::new(self.device, self.inner.map(|v| v.cast_relu()))
    }
}
#![feature(adt_const_params)]
extern crate furiosa_opt_std;
use furiosa_opt_std::prelude::*;
axes![N = 4, C = 3, H = 4, W = 8];

fn commit_cast_example<'l, const T: Tu>(
    input: ContractTensor<'l, T, f32, m![1], m![1], m![1], m![N, C, H], m![W]>,
) -> CommitCastTensor<'l, T, bf16, m![1], m![1], m![1], m![N, C, H], m![W]> {
    // Cast f32 to bf16 (values preserved), no activation. A real main
    // commit runs `.commit_trim()` first, then `.commit(...)` after.
    // W = 8 f32 elements (32 bytes) stays 8 bf16 elements (16 bytes).
    input.commit_cast::<bf16>()
}

fn commit_cast_relu_example<'l, const T: Tu>(
    input: ContractTensor<'l, T, f32, m![1], m![1], m![1], m![N, C, H], m![W]>,
) -> CommitCastTensor<'l, T, bf16, m![1], m![1], m![1], m![N, C, H], m![W]> {
    // f32 -> bf16 with a fused ReLU: negative values clamped to zero.
    // e.g. [-5.0, -0.1, 0.0, 3.7] -> [0.0, 0.0, 0.0, 3.7]
    input.commit_cast_relu::<bf16>()
}

사례 연구: Chip과 Cluster 리덕션

Chip과 Cluster 리덕션은 서로 다른 Chip 또는 Cluster 차원을 따라 저장된 부분 값을 결합합니다. 예제는 두 차원 모두에 대해 ReduceScatter, AllGather, 이 둘의 조합, 직접 AllReduce를 구현합니다. 별도의 Chip 예제는 Butterfly AllReduce를 구현합니다.

형태 변환

[dimension], [local] 표기법은 차원 형태와 각 차원 인덱스에 로컬로 저장된 형태를 구분합니다. R을 리덕션할 차원, S를 분배하거나 수집할 로컬 축, P...와 Q...를 각각 S 앞과 뒤의 로컬 형태라고 합시다. ReduceScatter와 AllGather에서는 R과 S의 크기가 N으로 같아야 합니다. Broadcast(N)은 해당 차원을 따라 있는 N개의 인덱스가 모두 같은 값을 가진다는 뜻입니다. 표는 논리적 결과를 나타내며, 제거되지 않는 로컬 축의 순서를 유지합니다.

연산형태 변환
ReduceScatter[R], [P..., S, Q...] -> [S], [P..., Q...]
AllGather[S], [P..., Q...] -> [Broadcast(N)], [P..., S, Q...]
AllReduce[R], [P...] -> [Broadcast(N)], [P...]

ReduceScatter는 R을 리덕션하고 그 결과인 S 축을 분배합니다. 차원 인덱스 s는 S의 좌표 s를 보유합니다. AllGather는 모든 차원 인덱스에서 모든 S 좌표를 수집하여 그 분배를 되돌립니다. AllReduce는 R을 리덕션하고 결과를 복제하면서 로컬 형태를 유지합니다. 직접 순환 리덕션, ReduceScatter 후 AllGather를 수행하는 방식, 버터플라이 교환은 모두 같은 AllReduce 형태 변환을 가집니다. 이 변환은 Chip 차원과 Cluster 차원 모두에 적용됩니다.

부동소수점 리덕션 순서

Broadcast(N)은 논리적 배치에 대한 보장입니다. 순환 리덕션은 0 + 1 + 2 + 3과 1 + 2 + 3 + 0 같은 순서를 사용하므로, 독립적으로 계산된 부동소수점 복제본은 서로 다를 수 있습니다. ReduceScatter 후의 AllGather는 각 샤드를 한 번만 계산한 뒤 수집하므로 해당 복제본은 동일합니다. 예제는 반올림 순서가 적용되지 않는 i32를 사용합니다.

재분배 프리미티브

예제는 지연된 SRAM 재분배 계획을 사용합니다. to_dm 이전의 shuffle 단계와 slice 단계는 하나의 DMA로 융합됩니다. 반면 asymmetric_chip_slice와 asymmetric_cluster_slice를 직접 호출하면 어느 차원으로도 데이터를 이동하지 않고 서브 컨텍스트를 사용합니다.

동기화

컴파일러는 to_dm이 최종 재분배 계획을 구체화할 때 그 계획에서 동기화를 도출합니다. 소스 Chip이 바뀌면 ChipSync가 삽입되고, 소스 Cluster가 바뀌면 ClusterSync가 삽입됩니다. 두 차원을 모두 바꾸는 융합 계획에는 두 동기화 프로토콜이 모두 적용됩니다. 로컬 DmTensor 슬라이싱은 둘 중 어느 것도 추가하지 않습니다.

컴파일러는 원격 DMA 접근 전에 목적지 준비 동기화를, 접근 후에 완료 동기화를 생성합니다. 자기 자신으로 향하는 Chip 경로는 Chip 간 송신이나 수신을 생성하지 않습니다.

예시재분배 호출자동으로 삽입되는 동기화
Chip ReduceScatterchip_shuffle().chip_slice().to_dm() 체인 4개ChipSync 프로토콜 3개. 라운드 0은 자기 자신으로 향하며, 라운드 1-3은 Chip 간에 통신합니다
Chip AllGather차원에서 요소로의 to_dm() 1개ChipSync 프로토콜 1개
ReduceScatter 이후의 Chip AllGatherReduceScatter와 AllGatherChipSync 프로토콜 4개
Chip AllReducechip_shuffle().to_dm() 체인 3개. 로컬 값은 이미 존재합니다ChipSync 프로토콜 3개
Chip Butterfly AllReducechip_shuffle().to_dm() 체인 2개ChipSync 프로토콜 2개
Cluster ReduceScatter로컬 슬라이스 2개와 cluster_swap().to_dm() 체인 1개ClusterSync 프로토콜 1개
Cluster AllGather차원에서 요소로의 to_dm() 1개ClusterSync 프로토콜 1개
ReduceScatter 이후의 Cluster AllGatherReduceScatter와 AllGatherClusterSync 프로토콜 2개
Cluster AllReducecluster_swap().to_dm() 체인 1개ClusterSync 프로토콜 1개

이 개수는 예제에 명시된 재분배 호출을 나타냅니다. HBM 전송과 로컬 Vector Engine 작업에도 각자의 의존성이 있지만, Chip 재분배 동기화나 Cluster 재분배 동기화를 추가하지는 않습니다.

Chip 예제

예제에서는 Chip 4개로 구성된 A 축과 B 축, 그리고 256바이트 [C, D] 페이로드를 사용합니다. Chip 인덱스와 샤드 인덱스의 범위는 0부터 3까지입니다.

use furiosa_opt_std::prelude::*;

axes![A = 4, B = 4, C = 8, D = 8, I = 2, X = 2];

리덕션 예제는 다음 Vector Engine 덧셈을 공통으로 사용합니다:

/// Adds two `[C, D]` DM tensors with the Vector Engine.
pub fn add_pair(
    device: &mut Device,
    lhs: &DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]>,
    rhs: &DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]>,
    output: &mut DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]>,
) {
    device
        .main
        .begin_interleaved::<I, _, _, _, _, _>(lhs.view(), rhs.view())
        .fetch::<m![C, I], m![D]>()
        .collect::<m![C, I], m![D]>()
        .vector_init()
        .vector_intra_slice_unzip::<I, m![C]>()
        .vector_clip_zip(ClipBinaryOpI32::AddFxp)
        .vector_final()
        .commit_trim::<m![D]>()
        .commit_view(output.view_mut());
}

ReduceScatter

입력은 A를 여러 Chip에 걸쳐 배치하고 [B, C, D]는 로컬에 유지합니다. 각 chip_shuffle().chip_slice().to_dm() 체인은 B 좌표 하나를 선택하여 대상 Chip으로 라우팅합니다. 그런 다음 세 개의 Vector Engine 체인이 라우팅된 [C, D] 값 네 개를 합산합니다. 여기서 in[p, s]는 소스 Chip p가 샤드 s에 대해 가진 값이며, out[s] = sum_p in[p, s]입니다.

/// Chip `i` keeps shard `i` of the sum over all chips.
#[device(chip = 4)]
pub fn reduce_scatter(
    device: &mut Device,
    hbm: &HbmTensor<i32, m![A], m![B, C, D]>,
) -> HbmTensor<i32, m![A], m![C, D]> {
    let full: DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![B, C, D]> = hbm.to_dm(&mut device.tdma);
    reduce_scatter_dm(device, &full).to_hbm(&mut device.tdma)
}
대상 Chip라운드 0라운드 1라운드 2라운드 3결과
0in[0,0]in[1,0]in[2,0]in[3,0]out[0]
1in[1,1]in[2,1]in[3,1]in[0,1]out[1]
2in[2,2]in[3,2]in[0,2]in[1,2]out[2]
3in[3,3]in[0,3]in[1,3]in[2,3]out[3]

결과는 샤딩된 상태로 유지됩니다. 대상 Chip s가 out[s]를 소유합니다.

AllGather

입력은 소스 Chip s에 [C, D] 샤드 하나를 제공하며, 이를 in[s]로 표기합니다. to_dm() 한 번으로 Chip 차원을 로컬 요소 형태로 옮기고, 그 형태를 모든 Chip에 브로드캐스트합니다.

/// Every chip receives every shard along `A`.
#[device(chip = 4)]
pub fn all_gather(device: &mut Device, hbm: &HbmTensor<i32, m![A], m![C, D]>) -> HbmTensor<i32, m![4], m![A, C, D]> {
    let shard: DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]> = hbm.to_dm(&mut device.tdma);
    all_gather_dm::<m![A], m![A, C, D]>(&mut device.tdma, &shard).to_hbm(&mut device.tdma)
}
대상 Chipto_dm() 이후의 로컬 요소
0[in[0], in[1], in[2], in[3]]
1[in[0], in[1], in[2], in[3]]
2[in[0], in[1], in[2], in[3]]
3[in[0], in[1], in[2], in[3]]

모든 대상이 모든 샤드를 받습니다.

ReduceScatter 이후의 AllGather

이 예제는 ReduceScatter 결과를 AllGather에 바로 입력합니다.

/// Composes ReduceScatter and AllGather so every chip receives the reduced shards.
#[device(chip = 4)]
pub fn all_gather_after_reduce_scatter(
    device: &mut Device,
    hbm: &HbmTensor<i32, m![A], m![B, C, D]>,
) -> HbmTensor<i32, m![4], m![B, C, D]> {
    let full: DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![B, C, D]> = hbm.to_dm(&mut device.tdma);
    let shard = reduce_scatter_dm(device, &full);
    // Target chip b owns coordinate b, so changing the axis name preserves wire order.
    let shard = unsafe { shard.reshape::<m![B], m![1 # 2], m![1 # 256], m![C, D]>() };
    all_gather_dm::<m![B], m![B, C, D]>(&mut device.tdma, &shard).to_hbm(&mut device.tdma)
}

최종 배치는 위에 나온 AllGather 상태와 같습니다.

AllReduce

입력은 Chip p에 형태가 [C, D]인 부분합 in[p] 하나를 제공합니다. 원본 텐서가 항등 회전을 제공하고, 세 번의 Chip 셔플이 나머지 순환 회전을 제공합니다. 네 회전을 모두 더하면 모든 대상 Chip에 out[target] = sum_i in[i]가 남습니다.

/// Sums four cyclic chip rotations so every chip receives the full reduction.
#[device(chip = 4)]
pub fn all_reduce(device: &mut Device, hbm: &HbmTensor<i32, m![A], m![C, D]>) -> HbmTensor<i32, m![A], m![C, D]> {
    let round0: DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]> = hbm.to_dm(&mut device.tdma);
    let round1 = round0.chip_shuffle([1, 2, 3, 0]).to_dm::<m![C, D]>(&mut device.tdma);
    let round2 = round0.chip_shuffle([2, 3, 0, 1]).to_dm::<m![C, D]>(&mut device.tdma);
    let round3 = round0.chip_shuffle([3, 0, 1, 2]).to_dm::<m![C, D]>(&mut device.tdma);

    let mut sum01 = DmTensor::new();
    let mut sum012 = DmTensor::new();
    let mut sum = DmTensor::new();
    add_pair(device, &round0, &round1, &mut sum01);
    add_pair(device, &round2, &sum01, &mut sum012);
    add_pair(device, &round3, &sum012, &mut sum);
    sum.to_hbm(&mut device.tdma)
}
대상 Chip로컬회전 1회전 2회전 3결과
0in[0]in[1]in[2]in[3]out[0] = sum_i in[i]
1in[1]in[2]in[3]in[0]out[1] = sum_i in[i]
2in[2]in[3]in[0]in[1]out[2] = sum_i in[i]
3in[3]in[0]in[1]in[2]out[3] = sum_i in[i]

Butterfly AllReduce

입력은 Chip p에 형태가 [C, D]인 부분 값 in[p] 하나를 제공합니다. 셔플 후 더하기를 두 라운드 거치면 모든 Chip에 sum_i in[i]가 남습니다. 여기서 i는 모든 소스 chip을 가리킵니다.

/// Reduces across chips with XOR-paired butterfly rounds.
#[device(chip = 4)]
pub fn butterfly_all_reduce(
    device: &mut Device,
    hbm: &HbmTensor<i32, m![A], m![C, D]>,
) -> HbmTensor<i32, m![A], m![C, D]> {
    let partials: DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]> = hbm.to_dm(&mut device.tdma);
    let swapped = partials.chip_shuffle([1, 0, 3, 2]).to_dm::<m![C, D]>(&mut device.tdma);
    let mut first: DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]> = DmTensor::new();
    add_pair(device, &swapped, &partials, &mut first);

    let swapped = first.chip_shuffle([2, 3, 0, 1]).to_dm::<m![C, D]>(&mut device.tdma);
    let mut sum: DmTensor<i32, m![A], m![1 # 2], m![1 # 256], m![C, D]> = DmTensor::new();
    add_pair(device, &swapped, &first, &mut sum);
    sum.to_hbm(&mut device.tdma)
}
대상 Chip초기첫 번째 피어첫 번째 합두 번째 피어 합최종
0in[0]in[1]in[1]+in[0]in[3]+in[2]out[0] = sum_i in[i]
1in[1]in[0]in[0]+in[1]in[2]+in[3]out[1] = sum_i in[i]
2in[2]in[3]in[3]+in[2]in[1]+in[0]out[2] = sum_i in[i]
3in[3]in[2]in[2]+in[3]in[0]+in[1]out[3] = sum_i in[i]

Cluster 예제

Cluster 예제에서는 동일한 256바이트 [C, D] 페이로드를 갖는, 요소가 두 개인 A 축과 B 축을 사용합니다. Cluster 인덱스와 샤드 인덱스의 범위는 0부터 1까지입니다.

use furiosa_opt_std::prelude::*;

axes![A = 2, B = 2, C = 8, D = 8, I = 2];

Cluster 예제에서는 이에 대응하는 두 Cluster 간 덧셈을 사용합니다:

/// Adds two `[C, D]` DM tensors with the Vector Engine.
pub fn add_pair(
    device: &mut Device,
    lhs: &DmTensor<i32, m![1], m![A], m![1 # 256], m![C, D]>,
    rhs: &DmTensor<i32, m![1], m![A], m![1 # 256], m![C, D]>,
    output: &mut DmTensor<i32, m![1], m![A], m![1 # 256], m![C, D]>,
) {
    device
        .main
        .begin_interleaved::<I, _, _, _, _, _>(lhs.view(), rhs.view())
        .fetch::<m![C, I], m![D]>()
        .collect::<m![C, I], m![D]>()
        .vector_init()
        .vector_intra_slice_unzip::<I, m![C]>()
        .vector_clip_zip(ClipBinaryOpI32::AddFxp)
        .vector_final()
        .commit_trim::<m![D]>()
        .commit_view(output.view_mut());
}

ReduceScatter

첫 번째 로컬 cluster_slice는 B 좌표 [0, 1]을 선택하며, Cluster마다 값을 하나씩 선택합니다. 두 번째는 [1, 0]을 선택하고, cluster_swap().to_dm()은 해당 값들을 반대편 Cluster로 이동합니다. 그런 다음 Vector Engine 체인이 로컬 [C, D] 값과 이동된 [C, D] 값을 더합니다. 여기서 in[c, s]는 샤드 s에 대한 소스 Cluster c의 값이며, out[s] = sum_c in[c, s]입니다.

/// Cluster `i` keeps shard `i` of the sum over both clusters.
#[device(chip = 1)]
pub fn reduce_scatter(
    device: &mut Device,
    hbm: &HbmTensor<i32, m![1], m![A, B, C, D]>,
) -> HbmTensor<i32, m![1], m![A, C, D]> {
    let full: DmTensor<i32, m![1], m![A], m![1 # 256], m![B, C, D]> = hbm.to_dm(&mut device.tdma);
    reduce_scatter_dm(device, &full).to_hbm(&mut device.tdma)
}
대상 Cluster로컬스왑 전 원격스왑 후 원격결과
0in[0,0]in[0,1]in[1,0]out[0]
1in[1,1]in[1,0]in[0,1]out[1]

AllGather

입력은 소스 Cluster s에 in[s]로 표기되는 [C, D] 샤드 하나를 제공합니다. to_dm() 한 번으로 Cluster 차원을 로컬 요소 형태로 이동하고, 그 형태를 두 Cluster 모두에 브로드캐스트합니다.

/// Both clusters receive every shard along `A`.
#[device(chip = 1)]
pub fn all_gather(device: &mut Device, hbm: &HbmTensor<i32, m![1], m![A, C, D]>) -> HbmTensor<i32, m![1], m![A, C, D]> {
    let shard: DmTensor<i32, m![1], m![A], m![1 # 256], m![C, D]> = hbm.to_dm(&mut device.tdma);
    all_gather_dm::<m![A], m![A, C, D]>(&mut device.tdma, &shard).to_hbm(&mut device.tdma)
}
대상 Clusterto_dm() 이후의 로컬 요소
0[in[0], in[1]]
1[in[0], in[1]]

ReduceScatter 후 AllGather

이 예제는 Cluster ReduceScatter 결과를 AllGather에 바로 입력합니다.

/// Composes ReduceScatter and AllGather so both clusters receive the reduced shards.
#[device(chip = 1)]
pub fn all_gather_after_reduce_scatter(
    device: &mut Device,
    hbm: &HbmTensor<i32, m![1], m![A, B, C, D]>,
) -> HbmTensor<i32, m![1], m![B, C, D]> {
    let full: DmTensor<i32, m![1], m![A], m![1 # 256], m![B, C, D]> = hbm.to_dm(&mut device.tdma);
    let shard = reduce_scatter_dm(device, &full);
    // Target cluster b owns coordinate b, so changing the axis name preserves wire order.
    let shard = unsafe { shard.reshape::<m![1], m![B], m![1 # 256], m![C, D]>() };
    all_gather_dm::<m![B], m![B, C, D]>(&mut device.tdma, &shard).to_hbm(&mut device.tdma)
}

AllReduce

입력은 Cluster c에 형태가 [C, D]인 부분합 in[c] 하나를 제공합니다. 한 번의 Cluster 스왑으로 두 부분합을 교환하고, 한 번의 덧셈으로 두 Cluster 모두에 완전한 리덕션 결과가 남습니다.

/// Adds the local and swapped cluster partials so both clusters receive the full reduction.
#[device(chip = 1)]
pub fn all_reduce(device: &mut Device, hbm: &HbmTensor<i32, m![1], m![A, C, D]>) -> HbmTensor<i32, m![1], m![A, C, D]> {
    let partials: DmTensor<i32, m![1], m![A], m![1 # 256], m![C, D]> = hbm.to_dm(&mut device.tdma);
    let remote = partials.cluster_swap().to_dm::<m![C, D]>(&mut device.tdma);

    let mut sum: DmTensor<i32, m![1], m![A], m![1 # 256], m![C, D]> = DmTensor::new();
    add_pair(device, &partials, &remote, &mut sum);
    sum.to_hbm(&mut device.tdma)
}
대상 Cluster로컬스왑된 피어결과
0in[0]in[1]out[0] = sum_i in[i]
1in[1]in[0]out[1] = sum_i in[i]

DMA 레이아웃 요구 사항

정렬, 활성 인덱스, 256바이트 연속 구간 요구 사항은 재분배 연산을 참조하십시오. 이 예제들은 m![B, C, D]를 사용하며, 그 내부의 m![C, D] 구간은 정확히 256바이트의 i32 데이터입니다.

엔드투엔드 사례

엔드투엔드 사례는 매핑, 이동, 계산, 스케줄링 계약을 조합된 워크로드로 연결합니다. 아래에서 가장 가까운 사례를 선택한 다음, 매핑을 조정하기 전에 해당 사례가 참조하는 API를 확인하십시오.

범례: 검증 및 테스트 완료는 현재 소스와 테스트를 갖추어 바로 읽고 활용할 수 있는 패턴을 나타냅니다. 설계 가이드는 구현 전에 API 세부 사항을 확인해야 하는 설명 자료를 나타냅니다.

시작점 선택

필요한 API 동작을 표현할 수 있다면 언제나 빠른 시작 패턴에서 구현을 시작하십시오. 나머지 페이지는 설계상의 선택을 설명합니다. 해당 코드를 사용하기 전에 현재 API와 대조하여 확인하십시오.

필요 사항시작 지점기술적 초점 및 상태
Qwen3 디코더 단계사례 연구: Transformer현재 transformer 예제의 모델 멘탈 모델, 기준 커널 맵, 디코드 전용 경계, 이식 가능한 오라클, 스케줄 데이터를 다룹니다.

사례 연구: Transformer

이 Transformer 페이지는 Qwen3-0.6B를 구체적인 튜토리얼 사례로 삼아 이 모델의 멘탈 모델을 설명하고, 디코더 토큰 하나가 호스트에서 준비된 임베딩에서 출발해 패딩된 어휘 로짓에 이르는 과정을 따라갑니다. 각 경계는 이식 가능한 호스트 오라클과 재현 가능한 스케줄 산출물로 검사합니다. 이 예제를 응용할 때에는 호스트에서 준비된 이러한 경계를 유지하십시오.

이 모델은 28개 레이어로 구성된 디코더 전용 Transformer입니다. 각 레이어는 한 번에 토큰 하나에 대해 계속 갱신되는 은닉 벡터 하나를 변환합니다.

token id
   |
   v
[Embedding lookup]
   |
   v
+-----------------------------+
| Decoder layer × 28          |
| RMSNorm → Q/K/V projection  |
| QK-Norm → RoPE → Attention  |
| Output projection + residual|
| RMSNorm → SwiGLU MLP        |
| + residual                  |
+-----------------------------+
   |
   v
[Final RMSNorm] → [LM head] → logits

이 모델은 은닉 크기 1024, 쿼리 헤드 16개, 키/값 헤드 8개, 헤드 차원 128, 중간 크기 3072를 사용합니다. 그룹 쿼리 어텐션에서는 각 키/값 헤드를 두 개의 쿼리 헤드가 공유합니다. RoPE는 theta 1,000,000을 사용하며, 임베딩 가중치와 출력 가중치는 모델 설계상 묶여 있습니다.

구성 요소형태 또는 개수
디코더 레이어28
어휘151,936개 토큰
임베딩과 묶인 LM 헤드151,936 × 1,024
레이어당 어텐션 프로젝션6,291,456개 파라미터
레이어당 SwiGLU MLP9,437,184개 파라미터
전체 모델약 5억 9,600만 개 파라미터

RMSNorm, RoPE, 어텐션, KV 캐시, SwiGLU, 잔차, 출력 프로젝션에 관한 결정 사항은 이 사례 연구에 요약되어 있으며 로컬 transformer 예제에 구현되어 있습니다.

이 예제는 디코드 전용이며 단일 토큰만 처리합니다. 프리필, 샘플링, 디바이스 측 토큰 조회는 구현하지 않습니다. 실행 가능한 소스는 transformer 예제입니다. 이 사례 연구는 구현을 그대로 옮겨 싣는 대신 그 결정 사항을 설명하고 소스와 테스트를 안내합니다.

실행 가능한 디코더 흐름

로컬 furiosa-opt-examples/src/transformer/mod.rs는 다음 디바이스 실행을 제공합니다.

  • embedding
  • projection
  • attention_forward_first
  • attention_forward
  • decoder
  • final_layer

이 로컬 실행 맵은 이 사례 연구의 기준이 되는 실행 표면입니다. vISA와 API 계약은 레퍼런스 장에서 다룹니다.

워크로드 의미론

워크로드는 디코더 한 단계입니다:

host-prepared embedding → 28 decoder layers → final RMSNorm and LM head → logits [Wp]

현재 예제는 레이어 28개와 히든 크기 1024를 가진 Qwen3-0.6B를 다룹니다. 각 디코더 레이어에는 다음과 같은 경계가 있습니다:

  1. projection: 입력 RMSNorm, Q/K/V 프로젝션, Q/K RMSNorm, RoPE, KV 캐시 쓰기입니다.
  2. attention_forward_first 또는 attention_forward: 온라인 소프트맥스 어텐션 청크 하나입니다.
  3. decoder: 출력 프로젝션, 잔차, 어텐션 후 RMSNorm, MLP, 잔차입니다.

final_layer는 최종 RMSNorm을 적용하고 패딩된 어휘 형태로 로짓을 계산합니다. 정확한 축 크기와 텐서 타입은 axes.rs에 정의되어 있습니다.

결정 추적

이 사례 연구는 이 책의 나머지 부분과 같은 순서를 따릅니다:

  1. 워크로드 의미론: 토큰 하나와 점점 커지는 KV 캐시가 데이터 이동을 결정합니다.
  2. 엄격한 제약 조건: 모든 커널에는 타입이 지정된 공개 경계가 있습니다. 어휘 출력은 Wp를 사용하고, 모델 어휘는 W입니다.
  3. 커널 경계: Rust 모듈은 embedding, projection, attention_forward_first, attention_forward, decoder, final_layer를 별도의 디바이스 진입점으로 노출합니다.
  4. 매핑과 이동: 소스는 호스트에서 준비한 텐서를 HBM과 DM을 거쳐 이동시키며, 각 엔진이 요구하는 표현을 사용합니다. 이러한 계약은 텐서 매핑과 텐서 이동을 참고하십시오.
  5. 엔진 선택: 프로젝션과 LM 헤드 축약에는 Contraction Engine을 사용합니다. RMSNorm과 요소별 단계에는 Vector Engine을 사용합니다. 전송에는 DMA 경로와 Fetch/Commit 경로를 사용합니다. 해당 계약은 텐서 계산을 참고하십시오.
  6. 검증: 호스트 오라클 테스트로 값을 확인하고, 덤프된 스케줄에 정적 실행 계획을 기록합니다. 스케줄링과 튜닝을 참고하십시오.

따라서 이 사례 연구는 여러 선택이 어떻게 조합되는지 보여 줍니다. 개별 엔진 API를 새로 정의하지는 않습니다.

호스트에서 준비한 임베딩

embedding 진입점은 호스트가 준비한 HBM 복사입니다. 이 진입점은 HbmTensor<bf16, ..., m![H]>를 받아 to_hbm_view로 그 뷰를 출력 HBM 텐서에 복사합니다. 이는 토큰 ID로 수행하는 디바이스 조회가 아니며, 그렇게 설명해서는 안 됩니다.

현재 소스는 이 단계를 ops::embedding으로 구현합니다. 스모크 테스트는 test_embedding입니다.

디코더 단계

projection은 하나의 은닉 벡터, 프로젝션 및 RMSNorm 가중치, 위치에 따라 달라지는 cos 및 sin 벡터, 그리고 변경 가능한 K/V 캐시를 받습니다. 이 함수는 Q를 HBM에 쓰고 캐시를 갱신합니다. 첫 번째 어텐션 청크는 attention_forward_first를 사용합니다. 이후 청크는 attention_forward를 사용하여 온라인 리덕션 상태를 이어 갑니다.

decoder는 어텐션 출력과 그 리덕션 상태를 소비하고, 잔차 스트림을 HBM에 다시 쓰며, 출력 프로젝션, 잔차 경로, RMSNorm, MLP, 최종 잔차 경로를 적용합니다. 필요한 입력과 출력은 함수 시그니처가 정의합니다. 구현 세부 사항은 furiosa-opt-examples/src/transformer/mod.rs에 있습니다.

경계 테스트 test_decoder_step_wires_kernel_boundaries는 호스트가 준비한 입력을 0 가중치로 프로젝션, 하나의 어텐션 청크, 디코더에 차례로 통과시킵니다. 이 테스트의 스칼라 오라클 테스트는 프로젝션, 청크 단위 어텐션, 디코더 연산을 각각 따로 검증합니다.

최종 레이어와 패딩된 로짓

final_layer는 잔차 스트림을 받아 RMSNorm을 적용하고, 모델 어휘 가중치 뷰를 패딩된 출력 표현으로 재구성한 뒤, 로짓을 m![Wp]에 씁니다. 소스는 별도의 lm_head_weight를 받습니다. 이 예제는 임베딩 가중치 공유(tied embedding)를 주장하지 않습니다.

결정적 테스트 test_final_layer_matches_scalar_reference는 유효한 W개의 로짓을 호스트의 RMSNorm 및 내적 오라클과 비교합니다. 패딩은 텐서의 필수 형태와 저장 동작의 일부입니다. 패딩된 항목의 해석은 호출자의 몫입니다.

검증과 검사

저장소의 일반 명령으로 Transformer 테스트 대상을 실행하십시오:

cargo furiosa-opt test --test transformer_tests

이 테스트는 시드가 고정된 작은 픽스처를 사용하며, 지원되는 각 경계를 호스트 수식과 비교합니다. 모델 파일이나 샘플링 루프는 필요하지 않습니다. 선택한 디바이스 진입점을 --dump-schedule과 함께 컴파일하여 스케줄 데이터를 생성하십시오. Schedule Viewer로 결과를 검사하고 스케줄링과 튜닝의 비교 절차를 따르십시오.

변경은 규율 있게 진행하십시오. 먼저 오라클을 확립하고, 기준 스케줄을 기록하고, 매핑, 이동, 엔진, 형태 선택 중 하나만 변경한 뒤, 같은 산출물을 비교하십시오. 오라클이나 소스 순서로부터 처리량을 추론하지 마십시오.

스케줄링과 튜닝

스케줄링과 튜닝은 올바르게 동작하는 커널을 측정에 근거한 계획으로 바꿉니다. 스케줄을 살펴보고, 성능을 제한하는 의존성이나 자원을 찾아내고, 지원되는 선택지 하나를 변경한 뒤, 동일한 조건에서 비교하십시오.

스케줄링은 컴파일된 커널을 컨텍스트 중첩, 메모리 의존성, 자원 충돌에 관한 검증 가능한 가설로 바꿉니다. 이 장은 다음과 같은 하나의 루프를 따릅니다.

  1. 스케줄: 컨텍스트, 의존성, 연산 순서를 이해합니다.
  2. 진단: 덤프된 스케줄을 살펴보고 병목에 대한 가설을 세웁니다.
  3. 튜닝: 변경 하나를 테스트하고, 동일한 지표를 비교한 뒤, 그 변경을 유지하거나 되돌립니다.

이 장에서는 매핑, 텐서 이동, 연산 엔진 계약을 다시 설명하지 않습니다. 스케줄링 결정이 해당 내용에 의존하는 경우에는 관련 장으로 링크합니다.

네 가지 검증의 의미와 주의 사항은 빠른 시작: 커널 검증을 참조하십시오. 고정된 스케줄 후보를 해석하고 결정을 내리려면 진단과 튜닝을 활용하십시오.

스케줄

스케줄러는 컨텍스트, 소스 순서, 메모리 위치를 사용하여 연산을 구성합니다. 스케줄러는 순차 실행과 동등한 결과를 얻는 데 필요한 의존성과 리소스 제약 조건을 보존합니다.

실행 컨텍스트

공개 실행 컨텍스트는 다음과 같습니다.

  • Main (device.main): 주 Tensor Unit 파이프라인을 구동합니다.
  • Sub (device.sub): register-file 프리로드 같은 연산이 사용하는 Tensor Unit 파이프라인의 일부를 구동합니다.
  • DMA (device.tdma): HBM, DM 및 기타 메모리 계층 간의 텐서 이동을 구동합니다.

하나의 컨텍스트 안에 있는 연산들은 순서가 정해져 있습니다. MNIST 스케줄에서 서로 다른 컨텍스트의 연산들은 스케줄링 리소스를 공유하지 않고 메모리 의존성을 위반하지 않는 한 겹쳐서 실행됩니다. 예를 들어, DMA 로드는 소비자가 로드된 값을 필요로 할 때까지 메인 컨텍스트 계산과 겹쳐 실행됩니다.

리소스 및 뱅크 경합

  • 스케줄링 모델: 스케줄러는 컨텍스트 점유 정보를 사용합니다. 연산 A가 컨텍스트(예: 메인 컨텍스트)를 점유하면, 그 컨텍스트를 사용하는 다음 연산 B는 A가 완료될 때까지 대기합니다. 연산이 어떤 컨텍스트를 점유하는지 이해하면 병렬 실행을 예측할 수 있습니다.
  • 컴파일러 스케줄링 동작: Tensor Unit 연산이 64회 접근 제한을 위반하게 되는 경우, 컴파일러는 해당 연산이 DMA를 점유하는 것처럼 스케줄링하여 동시 DMA 연산을 막습니다. 이는 데이터 준비와 계산이 병렬로 이루어지는 TCP 아키텍처 고유의 main/sub/DMA 컨텍스트 병렬성을 희생하지만, 치명적인 하드웨어 리셋을 방지합니다. 이를 엄격한 제약 조건으로 취급하십시오. 동일 뱅크에 64회 이상 연속으로 접근하는 패턴은 절대 사용하지 마십시오.
  • 64회 접근 제한 세부 사항:
    • 이 제한은 누적됩니다. 명령 간에 교차된 접근도 이 합계에 누적되므로, 모든 엔진이 동일 뱅크에 접근하는 총 횟수는 64 미만으로 유지되어야 합니다. 예를 들어, main: 30, sub: 20, DMA: 1은 합계 51(안전)이지만, main: 30, sub: 35, DMA: 1은 합계 66(기아 상태 유발)입니다.
    • 컴파일러는 개별 명령 각각의 동일 뱅크 연속 접근을 64회 미만으로 유지하지만, 여러 명령이 동시에 실행될 때 합계가 64에 도달하는 것은 막을 수 없습니다.
    • 실제로 서브 컨텍스트가 같은 뱅크에 연속으로 접근하는 경우는 드뭅니다(StoTrf, StoVrf 연산은 일반적으로 순차 주소를 사용하며, 타일링이 같은 뱅크 접근을 방지합니다).
    • 한도를 초과하게 되는 서브 컨텍스트 연산 또한 DMA와 동시에 스케줄링되지 않습니다.
  • 메인/서브 컨텍스트 경합: 메인 컨텍스트가 서브 컨텍스트를 기아 상태에 빠뜨릴 수 있지만, 이는 덜 심각합니다.
    • DMA 기아와 달리, 서브 컨텍스트 기아는 NoC 타임아웃이나 하드웨어 리셋을 일으키지 않으며 처리 시간만 늘립니다.
    • 충돌 확률이 더 낮습니다. DMA Engine은 한 번에 16개 뱅크를 점유하는 반면, sub fetch/commit 엔진은 하나의 뱅크만 점유합니다.
    • 파이프라인 백프레셔로 인해 동일 컨텍스트 내의 fetch 엔진과 commit 엔진 사이에서는 기아 상태가 발생하지 않습니다.
    • 성능 영향 예시: 서브 컨텍스트 stos 명령이 스케줄링된 동안 메인 컨텍스트 exec 명령이 특정 뱅크에 계속 접근하면, 서브 컨텍스트 처리가 지연됩니다. 최악의 경우: 전체 시간 = 메인 컨텍스트 시간 + 서브 컨텍스트 시간입니다. 이상적인 경우: 메인과 서브가 서로 다른 뱅크에 접근하여 전체 시간 = max(메인 컨텍스트 시간, 서브 컨텍스트 시간)을 달성합니다.

연산 순서와 의존성

스케줄러는 겹침을 드러내기 위해 독립적인 연산의 순서를 바꿀 수 있습니다. 순서를 바꾸면 값이 달라질 수 있는 경우에는 의존성 때문에 순서를 바꿀 수 없습니다. 소스 코드에 작성된 순서만 보고 성능이 개선되었다고 판단하지 마십시오. 생성된 스케줄을 살펴보고 튜닝 프로토콜에 따라 makespan을 비교하십시오.

Tensor Unit 연산은 HBM 텐서를 직접 사용할 수 없습니다. 데이터는 빠른 시작에서 설명한 메모리 계층을 거쳐 이동해야 합니다. 메모리 위치는 여러 컨텍스트가 공유하므로 스케줄러는 다음 해저드를 지켜야 합니다.

  • 쓰기 후 읽기(Read after write): 소비자는 생산자의 쓰기를 기다립니다.
  • 읽기 후 쓰기(Write after read): 뒤에 오는 쓰기는 앞선 읽기가 모두 끝날 때까지 기다립니다.
  • 쓰기 후 쓰기(Write after write): 겹치는 쓰기는 프로그램 순서를 유지합니다.

연산 유형이 서로 다른 독립 컨텍스트도 때때로 기다리는 이유가 바로 이러한 해저드 때문입니다.

서로 다른 컨텍스트라도 같은 스케줄링 자원이 필요하면 여전히 직렬화됩니다. 예를 들어 메인 컨텍스트의 Vector Engine 연산과 서브 컨텍스트의 Vector Engine 연산은 해당 자원을 동시에 점유할 수 없습니다. 튜닝 수단을 고르기 전에 이러한 자원 대기와 데이터 의존성을 구별하여 진단해야 합니다.

MNIST 예제는 2계층 MLP의 스케줄 예제를 제공하며, 첫 번째 계층에서 ReLU를 적용합니다. 이 예제의 스케줄 이미지는 컨텍스트 간 겹침과 대기를 보여 줍니다.

flowchart LR
    X["Input<br>(X = 800)"]
    H["Hidden<br>(H = 256)"]
    C["Output<br>(C = 16)"]
    X -- "FC1 + ReLU" --> H
    H -- "FC2" --> C

디바이스 함수는 두 계층을 소스 코드 순서대로 호출합니다. 각 계층은 자신의 계산 경로에 필요한 메모리 계층에 피연산자를 준비합니다.

axes![X = 800, H = 256, C = 16];

type Chip = m![1];
type Cluster = m![1 # 2];

#[device(chip = 1)]
pub fn forward(
    device: &mut Device,
    input: &HbmTensor<bf16, Chip, m![X]>,
    fc1_weight: &HbmTensor<bf16, Chip, m![H, X]>,
    fc1_bias: &HbmTensor<bf16, Chip, m![H]>,
    fc2_weight: &HbmTensor<bf16, Chip, m![C, H]>,
    fc2_bias: &HbmTensor<bf16, Chip, m![C]>,
) -> HbmTensor<bf16, Chip, m![C]> {
    let hidden = fc1_relu(device, input, fc1_weight, fc1_bias);
    fc2(device, hidden, fc2_weight, fc2_bias)
}

각 완전 연결 계층은 행렬-벡터 곱을 계산하고 바이어스를 더합니다. 첫 번째 계층은 같은 패스에서 ReLU도 적용합니다.

전체 스케줄은 다음과 같습니다. 이어지는 이미지는 컨텍스트 동작과 의존성 동작을 확대하여 보여 줍니다.

MNIST 스케줄에서는 긴 입력 fetch가 진행되는 동안 바이어스 준비가 행렬-벡터 연산보다 먼저 실행될 수 있습니다.

진단

진단은 스케줄 뷰를 검증할 수 있는 병목 가설 하나로 바꾸는 과정입니다. 데이터 의존성과 공유 자원 대기를 구분하십시오. 스케줄 뷰를 살펴보는 것만으로는 속도 향상을 입증할 수 없습니다.

덤프된 스케줄 살펴보기

Schedule Viewer 도구 레퍼런스를 참고하여 JSON을 생성하고 여십시오. Viewer는 스케줄된 타임라인, 연산 컨텍스트, 수명, 연결된 텐서, 연산자 설명을 보여 줍니다. 노드를 선택하여 해당 노드의 입력과 출력을 추적하십시오. 사이클 범위를 확대하여 의심되는 영역을 분리하십시오.

타임라인 읽기

마지막으로 스케줄링된 사이클이 메이크스팬(makespan)을 정하며, 이 메이크스팬을 결정하는 구간이 진단의 근거가 됩니다:

  1. 마지막 사이클까지 이어지는 구간을 찾으십시오.
  2. 그 구간의 선행 요소가 메모리 의존성인지, 공유 자원인지, 스케줄링되지 않은 공백인지 확인하십시오.
  3. 관련 컨텍스트 레인을 비교하여 다른 연산이 겹쳐 실행될 수 있는지 확인하십시오.
  4. 후보 변경 사항 하나와 그 변경이 해당 구간을 바꿀 것으로 보는 이유를 밝히십시오.

스케줄은 컴파일러의 정적 동작을 보여 줍니다. 스케줄은 값 오라클을 대신하지 않으며, 디바이스 처리량을 입증하지도 않습니다.

비교의 유효성 유지하기

각 후보마다 소스 리비전, 릴리스 프로필, 디바이스 함수 필터, 형태, 요소 타입, 매핑, 스케줄 파일을 기록하십시오. 후보 간에는 선택한 커널 선택 사항만 바꾸십시오. 이러한 입력 중 하나라도 바뀌면 그 결과를 튜닝 비교가 아닌 새로운 기준선으로 취급하십시오.

튜닝

튜닝은 통제된 실험입니다. 정확성을 유지하고, 기준선을 기록하고, 지원되는 레버 하나를 변경한 뒤, 변경을 유지할지 결정하기 전에 동일한 스케줄 지표를 비교합니다.

실험 통제하기

모든 후보에 대해 다음 순서를 따르십시오.

  1. 타입 검사 경로를 실행하여 매핑 및 형태 제약 조건을 검증하십시오.
  2. 관련 경계 사례를 포함하여 호스트 오라클로 CPU 테스트를 실행합니다.
  3. 기준선을 컴파일하고 해당 스케줄 makespan을 기록합니다.
  4. 릴리스, 형태, 데이터 타입, 매핑은 고정한 채 한 번에 하나의 요소만 변경하십시오.
  5. 동일한 검사를 실행하고 후보 스케줄을 덤프합니다.
  6. 정확성 검사를 여전히 통과하고 측정된 지표가 개선된 경우에만 변경을 유지하고, 그렇지 않으면 기준선으로 되돌리십시오.

이 장에서는 스케줄 makespan이 주요 정적 지표입니다. 재현 가능한 스케줄 비교나 별도로 문서화된 디바이스 증거 없이 처리량 또는 사이클 개선을 보고하지 마십시오.

레버 하나 선택하기

진단을 통해 레버가 겨냥할 의존성이나 자원을 특정한 뒤에만 레버를 선택하십시오.

  • 현재 자원이 병목이면 실행 엔진 경로를 변경합니다.
  • 스케줄에서 피할 수 있는 직렬 작업이나 잘 맞지 않는 리덕션이 드러나면 타일 또는 분할 형태를 변경하십시오.
  • 제한 구간이 이동이나 주소 의존성이면 매핑, 패딩 또는 전송 경계를 변경합니다.

이러한 선택지는 텐서 이동, 텐서 계산, 텐서 매핑 장에서 정의합니다.

루프 언롤링

#[unroll]을 사용하여 작은 정적 루프의 모든 반복을 스케줄러에 노출하십시오.

#![feature(proc_macro_hygiene, register_tool, stmt_expr_attributes)]
#![register_tool(furiosa_opt)]
extern crate furiosa_opt_std;
extern crate tokio;
use furiosa_opt_std::prelude::*;
axes![Group = 4];
fn consume(_: usize) {}
#[device]
fn example(_: &mut Device) {
#[unroll]
for group in 0..Group::SIZE {
    consume(group);
}
}
#[tokio::main]
async fn main() {
    let mut device = Device::new(example.topology()).unwrap();
    launch(example, &mut device).await.unwrap();
}

커널 크레이트에는 #![feature(proc_macro_hygiene, stmt_expr_attributes)]가 필요합니다. 반복 횟수는 컴파일 타임 상수여야 합니다. 루프는 디바이스 진입점 안에 있거나, 진입점에서 호출하는 헬퍼 안에 있을 수 있습니다. 중첩 루프는 자동으로 언롤링되지 않습니다. 모든 수준을 언롤링하려면 각 루프에 #[unroll]을 추가하십시오.

컴파일러는 #[unroll]을 적용한 뒤 남아 있는 루프가 있는지 검사합니다. Register File 재사용은 커널 어디에도 루프가 남아 있지 않을 때만 활성화됩니다. 일부 루프만 언롤링해도 해당 반복을 스케줄링에 노출할 수 있지만, 바깥 루프나 중첩 루프가 남아 있으면 루프가 없어야 가능한 최적화는 활성화되지 않습니다.

더블 버퍼링

더블 버퍼링을 사용하면 SubContext에서 다음 가중치 그룹을 스테이징하는 작업과 MainContext에서 현재 그룹을 축약하는 작업을 겹칠 수 있습니다. 스케줄러는 두 연산이 모두 같은 스케줄링 단위 안에서 보일 때만 이 겹침을 고려할 수 있습니다. 이러한 기회를 만들려면 수동 2단계 루프나 #[unroll]을 사용하십시오.

이 비교에서는 모든 사례에 20개의 가중치 그룹과 동일한 스케줄 윈도를 사용합니다.

루프 형태스케줄링 단위당 보이는 그룹 수반복 간 중첩makespan
일반 롤드 루프1아니요19,106 사이클
수동 2단계 소프트웨어 파이프라인2예15,477 사이클
완전 언롤링20예15,086 사이클

StoTrf는 가중치 그룹을 Tensor Register File(TRF)에 단계적으로 적재합니다. TRF는 각 절반에 그룹을 하나씩 배치할 수 있지만, 두 절반은 뱅크를 공유합니다. 용량과 주소 모드에 대한 자세한 내용은 Register File: 더블 버퍼링을 참조하십시오. Schedule Viewer를 사용하여 결과로 나타나는 중첩을 확인하십시오.

파란색 상자는 StoTrf를, 주황색 상자는 축약을, 초록색 띠는 측정된 중첩을 나타냅니다.

일반 롤드 루프: 19,106 사이클

스케줄러는 롤드 루프 본문에 대해 하나의 스케줄을 만들고, 모든 반복에서 그 스케줄을 되풀이합니다. 따라서 Contraction(i)와 StoTrf(i + 1)는 중첩될 수 없습니다.

#![allow(incomplete_features)]
#![feature(adt_const_params, proc_macro_hygiene, register_tool)]
#![register_tool(furiosa_opt)]
extern crate furiosa_opt_std;
extern crate tokio;
use furiosa_opt_std::prelude::*;
axes![Tok = 16, Red = 64, Out = 8, Group = 20];
use furiosa_opt_std::prelude::*;

type Chip = m![1];
type Cluster = m![Tok / 8 % 2];
type Slice = m![Tok % 8 # 256];

/// Stages and contracts one weight group per rolled iteration.
#[device(chip = 1)]
pub fn rolled(
    device: &mut Device,
    activation: &HbmTensor<bf16, Chip, m![Tok, Red]>,
    weight: &HbmTensor<bf16, Chip, m![Group, Out, Red]>,
) -> HbmTensor<bf16, Chip, m![Tok, Group, Out]> {
    let activation: DmTensor<bf16, Chip, Cluster, Slice, m![Red]> = activation.to_dm(&mut device.tdma);
    let weight: DmTensor<bf16, Chip, Cluster, Slice, m![Group, Out, Red]> = weight.to_dm(&mut device.tdma);
    let mut output: DmTensor<bf16, Chip, Cluster, Slice, m![Group, Out]> = DmTensor::new();

    for g in 0..Group::SIZE {
        let weight_group = weight.view().tile::<m![Group], 1, m![1 # 20, Out, Red]>(g);
        let weight_trf: TrfTensor<bf16, Chip, Cluster, Slice, m![Out], m![Red]> = device
            .sub
            .begin(weight_group)
            .fetch::<m![Out, Red / 16], m![Red % 16]>()
            .collect::<m![Out, Red / 16], m![Red % 16]>()
            .to_trf();

        device
            .main
            .begin(activation.view())
            .fetch::<m![Red / 16], m![Red % 16]>()
            .collect::<m![Red / 16], m![Red % 16]>()
            .contract_outer::<m![Red / 32], m![Red % 32], _, _, _>(&weight_trf)
            .contract_packet::<m![1]>()
            .contract_time::<m![1]>()
            .contract_lane::<m![1], m![Out]>(LaneMode::Interleaved)
            .cast::<bf16, m![Out # 16]>()
            .commit_trim::<m![Out]>()
            .commit_view(output.view_mut().tile::<m![Group], 1, m![1 #{!} 20, Out]>(g));
    }

    let mut result = HbmTensor::<bf16, Chip, m![Tok, Group, Out]>::new();
    output.view().to_hbm_view(&mut device.tdma, result.view_mut());
    result
}
#[tokio::main]
async fn main() {
let mut device = Device::new(rolled.topology()).unwrap();
let activation = HbmTensor::<bf16, m![1], m![Tok, Red]>::new();
let weight = HbmTensor::<bf16, m![1], m![Group, Out, Red]>::new();
let _output = launch(rolled, (&mut device, &activation, &weight)).await.unwrap();
}

수동 2단계 소프트웨어 파이프라인: 15,477 사이클

수동 파이프라인은 하나의 루프 본문에 두 그룹을 배치합니다. 이렇게 하면 Contraction(first)와 StoTrf(second)를 스케줄러가 함께 다룰 수 있습니다.

#![allow(incomplete_features)]
#![feature(adt_const_params, proc_macro_hygiene, register_tool)]
#![register_tool(furiosa_opt)]
extern crate furiosa_opt_std;
extern crate tokio;
use furiosa_opt_std::prelude::*;
axes![Tok = 16, Red = 64, Out = 8, Group = 20, Pairs = 10];
use furiosa_opt_std::prelude::*;

type Chip = m![1];
type Cluster = m![Tok / 8 % 2];
type Slice = m![Tok % 8 # 256];

/// Exposes two weight groups per iteration for software pipelining.
#[device(chip = 1)]
pub fn software_pipelined(
    device: &mut Device,
    activation: &HbmTensor<bf16, Chip, m![Tok, Red]>,
    weight: &HbmTensor<bf16, Chip, m![Group, Out, Red]>,
) -> HbmTensor<bf16, Chip, m![Tok, Group, Out]> {
    let activation: DmTensor<bf16, Chip, Cluster, Slice, m![Red]> = activation.to_dm(&mut device.tdma);
    let weight: DmTensor<bf16, Chip, Cluster, Slice, m![Group, Out, Red]> = weight.to_dm(&mut device.tdma);
    let mut output: DmTensor<bf16, Chip, Cluster, Slice, m![Group, Out]> = DmTensor::new();

    for pair in 0..Pairs::SIZE {
        let first = pair * 2;
        let second = first + 1;
        let first_weight = weight.view().tile::<m![Group], 1, m![1 # 20, Out, Red]>(first);
        let first_trf: TrfTensor<bf16, Chip, Cluster, Slice, m![Out], m![Red]> = device
            .sub
            .begin(first_weight)
            .fetch::<m![Out, Red / 16], m![Red % 16]>()
            .collect::<m![Out, Red / 16], m![Red % 16]>()
            .to_trf();
        let second_weight = weight.view().tile::<m![Group], 1, m![1 # 20, Out, Red]>(second);
        let second_trf: TrfTensor<bf16, Chip, Cluster, Slice, m![Out], m![Red]> = device
            .sub
            .begin(second_weight)
            .fetch::<m![Out, Red / 16], m![Red % 16]>()
            .collect::<m![Out, Red / 16], m![Red % 16]>()
            .to_trf();

        device
            .main
            .begin(activation.view())
            .fetch::<m![Red / 16], m![Red % 16]>()
            .collect::<m![Red / 16], m![Red % 16]>()
            .contract_outer::<m![Red / 32], m![Red % 32], _, _, _>(&first_trf)
            .contract_packet::<m![1]>()
            .contract_time::<m![1]>()
            .contract_lane::<m![1], m![Out]>(LaneMode::Interleaved)
            .cast::<bf16, m![Out # 16]>()
            .commit_trim::<m![Out]>()
            .commit_view(output.view_mut().tile::<m![Group], 1, m![1 #{!} 20, Out]>(first));
        device
            .main
            .begin(activation.view())
            .fetch::<m![Red / 16], m![Red % 16]>()
            .collect::<m![Red / 16], m![Red % 16]>()
            .contract_outer::<m![Red / 32], m![Red % 32], _, _, _>(&second_trf)
            .contract_packet::<m![1]>()
            .contract_time::<m![1]>()
            .contract_lane::<m![1], m![Out]>(LaneMode::Interleaved)
            .cast::<bf16, m![Out # 16]>()
            .commit_trim::<m![Out]>()
            .commit_view(output.view_mut().tile::<m![Group], 1, m![1 #{!} 20, Out]>(second));
    }

    let mut result = HbmTensor::<bf16, Chip, m![Tok, Group, Out]>::new();
    output.view().to_hbm_view(&mut device.tdma, result.view_mut());
    result
}
#[tokio::main]
async fn main() {
let mut device = Device::new(software_pipelined.topology()).unwrap();
let activation = HbmTensor::<bf16, m![1], m![Tok, Red]>::new();
let weight = HbmTensor::<bf16, m![1], m![Group, Out, Red]>::new();
let _output = launch(software_pipelined, (&mut device, &activation, &weight)).await.unwrap();
}

완전 언롤링: 15,086 사이클

#[unroll]은 수동으로 짝을 짓지 않고도 20개 그룹을 모두 드러냅니다.

#![allow(incomplete_features)]
#![feature(adt_const_params, proc_macro_hygiene, register_tool)]
#![register_tool(furiosa_opt)]
extern crate furiosa_opt_std;
extern crate tokio;
use furiosa_opt_std::prelude::*;
axes![Tok = 16, Red = 64, Out = 8, Group = 20];
use furiosa_opt_std::prelude::*;

type Chip = m![1];
type Cluster = m![Tok / 8 % 2];
type Slice = m![Tok % 8 # 256];

/// Exposes every weight group by fully unrolling the group loop.
#[device(chip = 1)]
pub fn unrolled(
    device: &mut Device,
    activation: &HbmTensor<bf16, Chip, m![Tok, Red]>,
    weight: &HbmTensor<bf16, Chip, m![Group, Out, Red]>,
) -> HbmTensor<bf16, Chip, m![Tok, Group, Out]> {
    let activation: DmTensor<bf16, Chip, Cluster, Slice, m![Red]> = activation.to_dm(&mut device.tdma);
    let weight: DmTensor<bf16, Chip, Cluster, Slice, m![Group, Out, Red]> = weight.to_dm(&mut device.tdma);
    let mut output: DmTensor<bf16, Chip, Cluster, Slice, m![Group, Out]> = DmTensor::new();

    #[unroll]
    for g in 0..Group::SIZE {
        let weight_group = weight.view().tile::<m![Group], 1, m![1 # 20, Out, Red]>(g);
        let weight_trf: TrfTensor<bf16, Chip, Cluster, Slice, m![Out], m![Red]> = device
            .sub
            .begin(weight_group)
            .fetch::<m![Out, Red / 16], m![Red % 16]>()
            .collect::<m![Out, Red / 16], m![Red % 16]>()
            .to_trf();

        device
            .main
            .begin(activation.view())
            .fetch::<m![Red / 16], m![Red % 16]>()
            .collect::<m![Red / 16], m![Red % 16]>()
            .contract_outer::<m![Red / 32], m![Red % 32], _, _, _>(&weight_trf)
            .contract_packet::<m![1]>()
            .contract_time::<m![1]>()
            .contract_lane::<m![1], m![Out]>(LaneMode::Interleaved)
            .cast::<bf16, m![Out # 16]>()
            .commit_trim::<m![Out]>()
            .commit_view(output.view_mut().tile::<m![Group], 1, m![1 #{!} 20, Out]>(g));
    }

    let mut result = HbmTensor::<bf16, Chip, m![Tok, Group, Out]>::new();
    output.view().to_hbm_view(&mut device.tdma, result.view_mut());
    result
}
#[tokio::main]
async fn main() {
let mut device = Device::new(unrolled.topology()).unwrap();
let activation = HbmTensor::<bf16, m![1], m![Tok, Red]>::new();
let weight = HbmTensor::<bf16, m![1], m![Group, Out, Red]>::new();
let _output = launch(unrolled, (&mut device, &activation, &weight)).await.unwrap();
}

모든 캡처는 4,000 사이클부터 8,000 사이클까지를 보여 줍니다.

도구

이 도구들은 커널 작업을 위한 설치와 개발 과정을 지원합니다.

  • Kernel Optimizer: 툴체인을 구동하는 cargo 하위 명령으로, 백엔드 선택, 자동 커널 빌드, 직접 커널 컴파일을 담당합니다.
  • 언어 서버: IDE 통합을 위한 furiosa-rust-analyzer-proxy의 설치와 구성을 다룹니다.
  • Schedule Viewer: 스케줄 JSON 파일을 살펴볼 수 있는 대화형 뷰어입니다.

Kernel Optimizer

Kernel Optimizer는 Furiosa Optimizer 커널을 위한 빌드, 테스트, 실행, 직접 컴파일 워크플로를 제공합니다. cargo-furiosa-opt 명령이 이러한 워크플로의 진입점입니다.

cargo-furiosa-opt는 Furiosa NPU 컴파일러 툴체인을 위한 cargo의 얇은 래퍼입니다. cargo furiosa-opt는 NPU 빌드이며, 일반 cargo 호출은 CPU에서 빌드하고 실행합니다. 모든 cargo 인수는 그대로 전달되며, 래퍼는 빌드에 필요한 커널을 컴파일합니다.

설치 방법은 설치와 첫 프로그램을 참고하십시오.

첫 명령

CPU에서 바이너리를 실행한 다음, 매핑을 확인하고, 오라클과 값을 비교하고, 스케줄 데이터를 내보내십시오.

cargo run --release --bin gemm
cargo furiosa-opt compile
cargo test --release --bin gemm
cargo furiosa-opt compile gemm_kernel --dump-schedule schedule.json

빠른 시작에서는 이 명령들을 맥락(컨텍스트) 속에서 소개합니다. 이 절에서는 cargo-furiosa-opt 명령의 전체 레퍼런스를 제공합니다. 컴파일, CPU, NPU, 스케줄 결과를 해석하려면 커널 검증을 참고하십시오.

사용법

cargo furiosa-opt <command> [args]
cargo furiosa-opt compile [FILTER]... [options]

첫 번째 형식은 cargo 패스스루입니다. <command>는 임의의 cargo 하위 명령(build, test, run, …)이며, [args]는 변경 없이 cargo에 전달되어 NPU용으로 컴파일됩니다. 두 번째 형식은 커널을 직접 컴파일합니다. 직접 컴파일을 참고하십시오.

일반 cargo build/cargo test/cargo run은 CPU 빌드입니다. 커널이 호스트에서 실행되며, NPU 하드웨어나 SDK가 필요하지 않습니다.

래퍼가 추가하는 기능

일반 cargo와 비교하면, cargo furiosa-opt는 cargo 호출을 전후로 두 가지 작업을 수행합니다.

  • NPU를 대상으로 합니다. 커널이 실제 하드웨어로 디스패치됩니다. 물리 NPU와 Furiosa SDK가 필요합니다.
  • 필요한 커널을 자동으로 빌드합니다. 빌드 결과 바이너리가 런타임에 로드할 수 있도록 cargo 빌드 전에 필요한 #[device] 함수를 컴파일합니다. 자동 커널 빌드를 참고하십시오.

작업 중심의 개요는 빠른 시작: Kernel Optimizer를 참고하십시오.

자동 커널 빌드

cargo furiosa-opt는 cargo에 넘기기 전에 커널 사전 컴파일 단계를 실행합니다. 사전 단계는 필요한 경우에만 실행됩니다. 다음 조건을 모두 만족하지 않으면 이 단계를 건너뜁니다.

  • cargo 하위 명령이 코드를 빌드하거나 실행하는 명령(build, check, test, run, bench, doc)입니다.
  • 호출이 -h / --help 조회가 아닙니다.

사전 단계가 실행되면 빌드에 실제로 필요한 커널만 컴파일합니다.

  • cargo의 유닛 그래프를 읽고, 지정한 -p / --package 및 워크스페이스 선택을 반영하여 커널 패키지를 찾습니다. 크레이트의 Cargo.toml이 [package.metadata.furiosa-opt]를 선언하면 그 크레이트는 커널 패키지입니다.
  • 명령이 테스트, 예제, 바이너리 같은 특정 실행 대상으로 해석되면 컴파일러는 각 대상을 스캔하여 그 대상에서 도달할 수 있는 #[device] 함수만 컴파일합니다.
  • 그렇지 않으면 선택된 패키지의 모든 커널을 컴파일하는 방식으로 대체합니다.

컴파일 결과는 커널별로 캐시되므로 변경되지 않은 커널은 다음 실행 시 다시 컴파일되지 않습니다. 산출물은 출력 디렉터리 아래에 기록됩니다(FURIOSA_OPT_OUT_DIR 참조).

직접 컴파일: cargo furiosa-opt compile

cargo furiosa-opt compile은 cargo 패스스루 없이 #[device] 함수를 직접 컴파일합니다.

# Compile every #[device] function in every kernel package.
cargo furiosa-opt compile

# Compile only the functions matching a filter, in one package.
cargo furiosa-opt compile transpose_simple -p my_kernels

# Compile a single function and dump its schedule for the Schedule Viewer.
cargo furiosa-opt compile transpose::transpose_simple \
  --dump-schedule schedule.json

컴파일 파이프라인과 덤프 옵션

아래 덤프 플래그는 각각 커널 컴파일 파이프라인의 서로 다른 지점을 캡처하며, 이 파이프라인은 세 단계로 실행됩니다.

  1. 1단계 — MIR → vISA.
  2. 2단계 — vISA → LIR.
  3. 3단계 — LIR → EDF(커널 바이너리).

공개된 각 덤프 플래그는 하나의 단계를 대상으로 하며 특정 사용처를 위한 산출물을 내보냅니다. 모든 덤프 플래그는 단일 커널 옵션입니다.

  • --dump-visa: 컴파일러/백엔드 검사를 위한 1단계 vISA 텍스트입니다.
  • --dump-ir: 컴파일러 도구를 위한 bincode 바이너리 형식의 2단계 LIR입니다.
  • --dump-dfg: 그래프 검사를 위해 컴파일러 IR 바이너리 데이터로 직렬화되어 요청한 파일에 기록되는 1단계 vISA 데이터 흐름 그래프입니다.
  • --dump-graph: IR 그래프 Viewer에서 사용할 수 있도록 2단계 LIR 그래프를 JSON으로 출력합니다.
  • --dump-summary: 요청한 디렉터리에 기록되는 3단계 LIR→EDF 컴파일 요약입니다.
  • --dump-schedule: LIR이 ResourceLir로 로워링된 후의 3단계 스케줄링으로, Schedule Viewer를 위한 JSON 형식으로 출력됩니다.

[FILTER]...

컴파일할 #[device] 함수의 집합을 지정합니다. 필터는 전체 경로(abc::def::foo)로 표현된 #[device] 함수 이름에 대해 부분 문자열로 매칭됩니다. 함수가 필터 중 하나에라도 매칭되면 컴파일됩니다. 생략하면 모든 디바이스 함수가 컴파일됩니다.

-p, --package name

컴파일 대상을 지정한 이름의 커널 패키지로 제한합니다. 이 옵션을 반복 지정하여 여러 커널 패키지를 선택할 수 있습니다. 생략하면 모든 커널 패키지가 컴파일됩니다.

--message-format format

컴파일러에 전달되는 진단 형식(예: json)으로, 도구가 커널 컴파일 실패 진단을 기계적으로 파싱할 수 있게 합니다.

--dump-visa file

중간 vISA를 파일로 덤프합니다. 단일 커널을 컴파일할 때만 사용해야 합니다.

--dump-ir file

중간 LIR(Low-Level IR)을 bincode 바이너리 형식으로 파일에 덤프합니다. 단일 커널을 컴파일할 때만 사용해야 합니다.

--dump-schedule file

LIR을 ResourceLir로 낮춘 뒤, 그 결과로 생성된 스케줄을 Schedule Viewer용 JSON으로 덤프합니다. 단일 커널을 컴파일할 때만 사용해야 합니다.

--dump-dfg file

1단계 vISA 데이터 흐름 그래프를 파일로 덤프합니다. 단일 커널을 컴파일할 때만 사용해야 합니다.

--dump-graph file

IR 그래프 Viewer에서 사용할 수 있도록 2단계 LIR 그래프를 JSON으로 덤프합니다. 단일 커널을 컴파일할 때만 사용해야 합니다.

--dump-summary dir

진단 도구에서 사용할 수 있도록 3단계 LIR-to-EDF 컴파일 요약을 디렉터리에 덤프합니다. 단일 커널을 컴파일할 때만 사용해야 합니다.

환경 변수

FURIOSA_OPT_OUT_DIR

커널 출력 디렉터리입니다. 기본값은 <workspace target>/furiosa-opt/kernel입니다.

언어 서버

언어 서버 프록시는 IDE 탐색, 진단, 읽기 쉬운 매핑 표현식 타입을 제공합니다. 에디터에서 표시되는 방식은 타입 검사에 영향을 주지 않습니다.

이 도구 레퍼런스에서는 furiosa-rust-analyzer-proxy의 설치와 설정을 자세히 설명합니다. 이 도구는 매핑 표현식에 대한 IDE 지원을 제공하는 rust-analyzer용 프록시입니다. 빠른 시작에서는 다른 개발 도구와 함께 이 도구를 소개하며, 이 섹션에서는 설치와 설정 단계를 안내합니다. 프록시는 내부에서 rust-analyzer를 실행하고 일반적인 Rust 언어 서버 트래픽을 그쪽으로 전달하며, 에디터에 표시되는 결과를 다시 작성하여 매핑 타입이 m![...] 표기법으로 표시되도록 합니다.

설치

  1. rust-analyzer가 설치되어 있고 PATH에서 사용할 수 있는지 확인하십시오. 프록시는 이 업스트림 rust-analyzer 프로세스를 실행하여 표준 Rust IDE 기능을 제공합니다.

  2. GitHub 릴리스에서 furiosa-rust-analyzer-proxy 바이너리를 다운로드하고 실행 권한을 부여하십시오:

    curl -L -o furiosa-rust-analyzer-proxy \
      https://github.com/furiosa-ai/furiosa-opt/releases/latest/download/furiosa-rust-analyzer-proxy-x86_64-unknown-linux-gnu
    chmod +x furiosa-rust-analyzer-proxy
    
  3. 기본 언어 서버 대신 다운로드한 바이너리를 사용하도록 IDE를 설정하십시오. 예를 들어 VSCode에서는 settings.json을 다음과 같이 수정하십시오:

    {
      "rust-analyzer.server.path": "/path/to/furiosa-rust-analyzer-proxy",
      "rust-analyzer.inlayHints.maxLength": null  // recommended to reduce '_' truncation
    }
    

환경 변수

환경 변수를 사용하여 언어 서버를 설정할 수 있습니다. 예를 들어 VSCode에서는 settings.json을 다음과 같이 수정하십시오:

{
  "rust-analyzer.server.extraEnv": {
    "ENV_NAME": "env_value"
  }
}
  • FURIOSA_RUST_ANALYZER_PROXY_UPSTREAM: 프록시가 실행하는 업스트림 rust-analyzer 바이너리의 사용자 지정 경로입니다. 기본값은 PATH에 있는 rust-analyzer입니다.

기능

프록시는 표준 Rust IDE 기능을 rust-analyzer에 위임하고, 에디터에 표시되는 결과의 매핑 표현식을 다시 작성합니다.

호출 계층

들어오는 호출과 나가는 호출의 계층 보기를 제공합니다. 계층 항목에 표시되는 함수 세부 정보는 매핑 표현식으로 변환됩니다.

코드 액션

빠른 수정, 리팩터링 및 기타 에디터 동작을 제공합니다. 액션 제목과 텍스트 편집 내용은 매핑 표현식으로 변환됩니다.

코드 완성

이름, 메서드, 함수, 타입, 스니펫에 대한 완성 항목을 제공합니다. 완성 레이블, 세부 정보 텍스트, 텍스트 편집 내용은 매핑 표현식으로 변환됩니다.

진단

rust-analyzer와 rustc의 진단 정보를 제공합니다. 진단 메시지와 관련 정보는 매핑 표현식으로 변환됩니다.

호버

심볼 위에 마우스를 올리면 추가 정보를 표시합니다. 추론된 타입, 함수 시그니처, 문서와 같은 호버 내용은 매핑 표현식으로 변환됩니다.

인레이 힌트

소스 코드 안에 추가 정보를 인라인으로 표시합니다. 인레이 힌트는 매핑 표현식으로 변환됩니다.

Tip

가장 정확하게 변환하려면 rust-analyzer.inlayHints.maxLength를 null(길이 제한 없음)로 설정하십시오. 이렇게 하면 긴 인레이 힌트가 ’_’로 잘리는 경우가 줄어듭니다.

시그니처 도움말

함수 호출을 작성하는 동안 함수 시그니처와 현재 활성화된 매개변수를 표시합니다. 시그니처 레이블, 매개변수 레이블, 문서는 매핑 표기법을 사용하도록 다시 작성되며, LSP 클라이언트가 반환하는 오프셋 기반 매개변수 레이블도 여기에 포함됩니다.

주의 사항

사용자 정의 타입의 이름이 내부 매핑 구성 요소의 이름과 같으면 언어 서버가 해당 타입을 매핑 표현식으로 잘못 해석할 수 있습니다. 예를 들어 사용자 정의 Symbol<T> 구조체를 정의하면 언어 서버가 IDE에서 이를 m![T]로 잘못 표시할 수 있습니다. 이는 순전히 UI 표시 문제이며 다른 LSP 동작에는 영향을 주지 않습니다.

Schedule Viewer

Schedule Viewer는 스케줄 컴파일을 지원하며, 고정된 후보 간의 makespan 비교도 지원합니다. Viewer는 정적 실행 계획을 보여 줄 뿐, 값이나 처리량을 검증하지는 않습니다.

Schedule Viewer는 cargo furiosa-opt가 생성한 스케줄 JSON 파일을 불러와 대화형 실행 타임라인으로 표시합니다. 빠른 시작에서는 Viewer를 개발 도구의 일부로 소개했으며, 이 절에서는 Viewer를 설치하고 스케줄을 살펴보는 방법을 설명합니다. 뷰어를 사용하면 어떤 연산이 병렬로 실행되는지, 각 연산이 어떤 컨텍스트나 자원을 점유하는지, 어떤 연산이 파이프라인 진행을 막는지 살펴볼 수 있습니다.

스케줄 검토

고정된 커널 워크로드를 비교하려면 최종 스케줄 사이클인 스케줄 makespan을 사용하십시오. 후보 간에 릴리스, 형태, 데이터 타입, 매핑을 동일하게 유지하십시오. 타임라인을 사용하여 중첩, 의존성, 자원 점유의 차이를 설명하십시오.

시작하기

Viewer 설치 및 실행

crates.io에서 furiosa-schedule-viewer 바이너리를 설치하십시오:

cargo install furiosa-schedule-viewer

Viewer를 실행하여 로컬 웹 UI를 시작하십시오:

furiosa-schedule-viewer

기본적으로 서버는 127.0.0.1:9254에 바인딩되며 기본 브라우저에서 페이지를 엽니다. 서버가 수신 대기하는 주소를 변경하려면 --host와 --port를 사용하십시오:

furiosa-schedule-viewer --host 127.0.0.1 --port 9254

스케줄 JSON 파일 생성

--dump-schedule은 컴파일된 스케줄을 JSON 파일로 기록합니다. 이 옵션은 cargo furiosa-opt compile로 단일 커널을 컴파일할 때 사용할 수 있습니다:

cargo furiosa-opt compile <device-function> \
  --dump-schedule <path-to-json-file>

함수 이름을 위치 인자 필터로 지정하십시오. 함수 이름이 모호하면 kernel::gemm_kernel::gemm_kernel과 같이 전체 Rust 경로를 사용하십시오. 이 명령은 일반 커널 산출물도 그대로 생성합니다. 이 JSON 파일이 Schedule Viewer의 입력입니다.

사용법

드롭 영역을 클릭하거나 스케줄 JSON 파일을 페이지로 끌어다 놓아 시각화하십시오.

텐서와 연산자 살펴보기

노드를 클릭하면 해당 노드를 살펴볼 수 있습니다. 왼쪽 사이드바에는 이름, 수명, 컨텍스트, 연결된 노드 등의 세부 정보가 표시됩니다.

노드 위에 마우스를 올리거나 노드를 선택하면 관련 노드가 강조 표시됩니다. 예를 들어 연산자를 선택하면 해당 연산자의 입력 텐서와 출력 텐서가 강조 표시됩니다. 텐서를 선택하면 해당 텐서에 연결된 연산자가 강조 표시됩니다.

텐서와 연산자는 실제 하드웨어 명령어를 기반으로 하므로 vISA에 정의된 텐서 및 연산자와 다를 수 있습니다. Schedule Viewer는 가능한 경우 vISA 텐서의 이름과 형태, 그리고 연산자 설명을 표시합니다.

스케줄 확대하기

스케줄을 확대하여 특정 영역을 살펴볼 수 있습니다.

사이클 범위만 조정하려면 상단의 타임라인을 드래그하거나 오른쪽 상단의 Cycle Range 입력란을 사용하십시오. 메모리 주소 범위를 조정하려면 Enable Brush를 클릭한 다음 스케줄 뷰를 드래그하십시오. 브러시는 사이클 범위와 메모리 주소 범위를 모두 설정합니다.

전체 스케줄 뷰로 되돌리려면 타임라인 배경을 클릭하거나 오른쪽 상단의 Reset을 클릭하십시오.


원문 © FuriosaAI · Programming Tensor Contraction Processors · Apache License 2.0 · NOTICE
yeokja 한국어 번역 · Anthropic 사의 claude-sonnet-5 모델을 활용하여 번역되었으며 학습을 모두 비허용한 상태로 작업하였습니다 · 이 문서는 원문을 수정한 비공식 번역본이며 Apache License 2.0으로 제공합니다.