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

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을 소비할 수 없습니다.


원문 © FuriosaAI · Programming Tensor Contraction Processors · Apache License 2.0 · NOTICE
yeokja 한국어 번역 · Anthropic 사의 claude-sonnet-5 모델을 활용하여 번역되었으며 학습을 모두 비허용한 상태로 작업하였습니다 · 이 문서는 원문을 수정한 비공식 번역본이며 Apache License 2.0으로 제공합니다.