모든 이야기

한쪽이 먼저 받았을 때 stream fork는 무엇을 기억할까

데이터 한 개를 두 소비자에게 보낸다. A는 즉시 받고 B는 세 사이클을 기다린다. 입력을 B가 받을 때까지 유지하면 A는 같은 데이터를 여러 번 받지 않을까. 입력을 먼저 넘기면 B는 데이터를 잃는다. stream fork는 하나의 입력 항목을 여러 출력으로 복제하되, 각 출력의 수락을 한 번씩만 완료하는 스트림 분기 회로다. 배선을 갈라 놓는 일보다 완료의 범위를 기억하는 일이 핵심이다.

여기서는 소비자 두 개가 독립적으로 ready를 바꾸고, 생산자가 입력 항목의 최종 수락 전까지 valid와 데이터를 유지하는 규칙을 사용한다. 각 출력도 같은 클록 에지에서 valid와 ready가 함께 높으면 항목을 수락한다. 소비자들은 정확히 같은 입력 순서를 한 번씩 받아야 한다.

valid를 단순 복제하면 먼저 받은 쪽이 중복된다

다음처럼 연결했다고 생각하자.

assign valid_a  = valid_in;
assign valid_b  = valid_in;
assign ready_in = ready_a && ready_b;

ready_a=1, ready_b=0이면 입력은 아직 수락되지 않았으므로 생산자는 X라는 항목을 계속 유지한다. 하지만 A의 valid와 ready는 매 에지 1이다. A는 같은 X를 계속 새로운 거래로 수락한다. 입력 수락 조건을 두 출력의 AND로 만들었다는 사실만으로, 이미 끝난 출력의 거래를 막지는 못한다.

반대로 ready_in을 ready_a OR ready_b로 만들면 A가 수락한 순간 입력이 다음 항목으로 바뀔 수 있다. B는 X를 못 받는다. AND와 OR 중 하나를 고르는 문제라기보다, 각 소비자의 완료 이력을 저장하지 않았다는 문제다.

받은 소비자를 기억하는 두 비트

seen_a와 seen_b를 두고 현재 항목을 이미 수락한 소비자에게는 valid를 다시 내지 않는 방식을 생각하자. 설명을 위해 두 비트를 하나의 벡터 seen으로 묶었다.

assign valid_a = valid_in && !seen[0];
assign valid_b = valid_in && !seen[1];
assign fire_a  = valid_a && ready_a;
assign fire_b  = valid_b && ready_b;
assign completed = seen | {fire_b, fire_a};
assign ready_in  = &completed;

always_ff @(posedge clk or negedge rst_n) begin
  if (!rst_n)
    seen <= 2'b00;
  else if (valid_in && ready_in)
    seen <= 2'b00;
  else
    seen <= completed;
end

이것은 원리를 보여 주는 최소 예제이며, 취소나 clear는 포함하지 않는다. 두 출력 모두 현재 항목을 받으면 입력 거래도 완료되고 seen을 다음 항목을 위해 지운다. 한쪽만 받으면 그 사실을 유지한다. payload는 입력에서 각 출력으로 직접 제공한다고 가정한다.

가상의 X 항목을 시간 순서로 추적해 보자.

에지ready A/Bvalid A/B수락에지 뒤 seen A/B
01 / 01 / 1A가 X 수락1 / 0
11 / 00 / 1없음1 / 0
21 / 10 / 1B가 X 수락, 입력 완료0 / 0

A가 계속 ready=1이어도 에지 1과 2에는 valid_a=0이므로 중복 거래가 없다. B가 받는 에지 2에 입력도 완료된다. 생산자는 그 뒤에 다음 항목을 제시할 수 있다. 두 소비자의 수락 에지가 같지 않아도 각자 정확히 한 번씩 받는다.

출력 수락이 입력 수락보다 먼저 일어나도 되는가

이 방식에서는 A가 받은 뒤에도 입력의 ready_in은 0이다. 즉 출력 일부의 수락이 입력의 최종 수락보다 앞선다. 이상해 보이지만, 입력의 최종 수락을 “모든 복제 목적지의 책임이 끝난 시점”으로 정의한 구조다. A가 이미 받은 payload는 생산자가 그동안 유지하고 있는 현재 항목과 같다.

PULP의 stream fork 구현은 출력들이 동시에 수락할 필요 없이 각 출력이 한 번씩 수락하고, 모두 끝나야 입력을 수락하는 계약을 명시한다. 실제 구현은 상태기계로 그 완료를 관리한다. 위 seen 코드는 같은 핵심 원리를 설명하기 위해 구성한 별도의 예제다.

이 구조에는 중요한 전제가 있다. 생산자가 ready_in을 기다리는 중에 valid를 내리거나 데이터를 바꾸면, B는 A가 받았던 X와 다른 값을 받을 수 있다. 일부 소비자가 이미 실행한 부작용도 되돌릴 수 없다. 따라서 입력의 지속 제시 규칙이 필수다. Chisel의 인터페이스 문서에서 구분하는 약한 Decoupled 관례만으로는 이런 유지 의무가 자동 보장되지 않는다.

가장 느린 소비자를 기다리는 범위

위 회로는 현재 항목 하나의 완료를 기억할 뿐, 입력을 복사해 여러 항목을 쌓는 버퍼는 아니다. B가 계속 멈춰 있으면 A가 X를 받았더라도 다음 입력으로 넘어가지 않는다. A는 빠르지만 전체 입력 흐름은 가장 느린 소비자의 완료를 기다린다.

출력별 큐를 추가하면 A가 다음 항목들을 미리 받을 여유가 생길 수 있다. 그러나 B 쪽 큐가 차면 결국 입력도 멈춘다. 느린 소비자에게 모든 데이터를 전달한다는 요구를 유지하는 한, 유한한 저장 공간으로 지속적인 속도 차이를 무한히 숨길 수는 없다. 중복 방지와 속도 격리는 서로 다른 기능이다.

모든 출력이 준비되었을 때만 동시에 전달하는 방식도 가능하다. 하지만 모든 소비자의 동시 준비를 요구하는 만큼 처리 기회가 줄어들 수 있고, valid를 ready에 어떻게 의존시키는지에 따라 연결의 진행 조건도 달라진다. 어떤 방식이든 각 출력의 “한 번”을 보장한다는 원칙은 같아야 한다.

부분 완료 뒤의 취소는 단순 clear가 아니다

에지 0에서 A가 X를 받고 난 뒤 취소가 왔다고 하자. seen을 지워 입력을 없애면 B는 X를 받지 못하지만 A는 이미 받았다. 소비자의 동작을 되돌리는 별도 규칙이 없다면 모든 목적지가 같은 순서를 받는 계약이 깨진다.

따라서 clear나 reset이 부분 전달을 버려도 되는지, 이미 수락한 소비자에게 취소 정보를 전달해야 하는지 정해야 한다. 완료 비트를 지우는 내부 동작과 시스템의 취소 의미가 일치해야 한다. 최소 코드가 정상 흐름에서 맞다는 사실만으로 이런 경계를 해결했다고 볼 수 없다.

처음의 고민은 입력을 붙들 것인가 넘길 것인가라는 양자택일이 아니다. stream fork는 입력을 붙드는 동안 누가 이미 받았는지를 기억해, 붙들린 데이터가 새로운 거래로 반복되지 않게 한다. 이 원리를 기준으로 보면 중복과 손실, 가장 느린 소비자의 정지, 부분 취소의 책임을 서로 다른 문제로 나누어 설계할 수 있다.

한쪽이 먼저 받았을 때 stream fork는 무엇을 기억할까 · ariser