모든 이야기

덧셈은 맞는데 쌍이 틀렸다, stream join의 수락 조건

두 입력을 더하는 회로에서 값 하나가 잘못 짝지어졌다. A 쪽 입력이 먼저 도착했을 때 포인터를 넘겼고, 한 사이클 뒤 B가 도착하자 A의 다음 값과 더했다. 덧셈기 자체는 정확하다. stream join은 여러 입력 스트림의 현재 항목을 하나의 출력 거래로 묶는 동기화 회로다. 연산이 맞아도 어느 항목끼리 연산하는지가 틀리면 결과는 틀린다.

여기서는 A0와 B0, A1과 B1이 각각 한 쌍이라고 정한다. 두 입력은 같은 순서로 항목을 만들지만 도착 시각은 다를 수 있다. 생산자는 수락 전까지 valid와 payload를 유지하고, 모두 같은 클록 에지에서 거래를 센다. 출력은 두 값의 합이며 결과 폭은 손실 없이 충분하다고 가정한다.

먼저 도착한 항목을 먼저 소비하면 안 되는 이유

입력 ready를 출력 ready에 그대로 연결하는 잘못된 구조를 생각하자.

assign valid_out = valid_a && valid_b;
assign ready_a   = ready_out;
assign ready_b   = ready_out;

에지 0에 A0는 valid=1이고 B0는 아직 valid=0이라고 하자. ready_out=1이면 ready_a도 1이다. A 생산자는 A0를 수락한 것으로 알고 다음 A1로 넘어간다. 그러나 valid_out=0이므로 출력 거래는 없었다. 어디에도 A0를 저장하지 않았다면 A0는 사라졌다.

에지 1에 B0가 도착하고 A1도 준비되어 있으면 출력은 A1+B0가 된다. 입력별 전송 개수는 정상처럼 보일 수 있지만, 쌍의 의미가 바뀌었다. 이 문제는 값의 진리표가 아니라 입력 수락과 출력 수락을 함께 추적해야 발견할 수 있다.

둘 다 있을 때 같은 에지에 넘기기

저장 없는 최소 join에서는 모든 항목이 준비되고 출력이 수락될 때 입력도 함께 수락하게 할 수 있다.

assign valid_out = valid_a && valid_b;
assign ready_a   = valid_out && ready_out;
assign ready_b   = valid_out && ready_out;
assign sum_out   = {1'b0, data_a} + {1'b0, data_b};

data_a와 data_b가 각각 8비트 unsigned이면 sum_out은 9비트로 둔다. 앞에 0을 붙여 0~510의 합을 표현하는 설명용 예제다. valid_out이 0일 때 sum_out은 의미 없는 값일 수 있다. 소비자는 valid_out과 ready_out이 함께 높은 에지의 결과만 받는다.

이 구조에서는 출력 fire와 두 입력 fire가 같은 조건에서 성립한다. A가 먼저 도착하면 A를 유지한다. B도 도착했지만 출력이 막히면 둘 다 유지한다. 출력이 받는 에지에 두 항목의 책임이 동시에 끝난다.

가상의 숫자로 보면 관계가 명확하다. A0=3, A1=7, B0=8이라고 하자. A0만 있는 에지 0에서는 두 ready가 0이어서 A0를 보존한다. 에지 1에 B0가 도착하고 출력이 ready라면 3+8=11을 수락한다. 다음 쌍으로 넘어가는 것은 그 에지 이후다. 잘못된 구조의 7+8=15와 차이가 생긴 위치는 덧셈이 아니라 A0의 조기 수락이다.

PULP의 stream join과 그 동적 입력 선택 구현은 선택한 입력들이 모두 유효할 때 출력과 입력을 함께 수락시키는 사례다. payload는 별도 경로로 전달한다. 위 덧셈 코드는 그 제어 원리를 설명하기 위한 별도의 예제다.

ready를 기다리는 생산자는 여기서 멈춘다

join은 다른 입력의 도착을 기다리기 위해 ready를 낮출 수 있다. 두 생산자가 모두 ready를 보고 나서 valid를 올리는 구조라면, join은 valid를 기다리고 생산자는 ready를 기다린다. 둘 다 값이 준비되어 있어도 전달이 시작되지 않을 수 있다.

따라서 이 최소 구조에서는 생산자가 수락 여부와 독립적으로 준비된 값을 제시하고, 수락 전까지 유지해야 한다. ready가 “이제 데이터를 만들어라”는 요청이 아니라 “제시한 항목을 이 에지에 받을 수 있다”는 수락 정보라는 구분이 중요하다. 버퍼가 있어도 모듈마다 제시 규칙이 다르면 진행 조건을 다시 확인해야 한다.

CIRCT의 Handshake 문서는 join을 모든 입력이 준비되었을 때 유효한 출력을 만드는 동기화 연산으로 설명한다. 이런 데이터 흐름 모델에서도 연산자는 값만 소비하는 것이 아니라, 필요한 토큰의 준비를 함께 기다린다.

큐를 추가해도 '어떤 쌍인가'는 자동으로 알지 못한다

각 입력 앞에 큐를 두면 먼저 도착한 A0를 저장하고 상류를 더 진행시킬 수 있다. join은 두 큐의 맨 앞 항목을 함께 소비한다. 앞의 조기 수락이 올바른 버퍼링으로 바뀌는 것이다. 그러나 큐가 있다고 항목의 의미까지 정렬되는 것은 아니다.

A 스트림에서 항목 하나를 생략했는데 B 스트림에는 그대로 있다면, 단순한 FIFO 순서의 join은 그 뒤부터 잘못된 쌍을 계속 만든다. 거래 식별자가 있는 경우에는 두 식별자가 같은지 확인해야 할 수 있다. 불일치했을 때 정지할지 오류를 낼지, 한쪽을 버리거나 다시 맞출지는 상위 계약으로 정한다. join이 단순히 nth 항목을 nth 항목과 묶는지, 식별자로 짝을 찾는지도 다르다.

배열 연산의 좌표나 분기 후 합류하는 제어 토큰처럼, 두 스트림이 같은 논리 작업을 대표한다는 성질이 필요하다. 완료 순서가 바뀌는 생산자와 FIFO join을 연결하면 순서 보정이 별도로 필요할 수 있다. 제어 신호가 정상인 것과 의미가 같은 쌍을 고르는 것은 별도 검증 대상이다.

정렬 검증은 값과 수량을 함께 본다

검증에서는 한쪽만 먼저 도착하는 경우, 모두 준비되었는데 출력이 오래 막히는 경우, 연속해서 모든 입력이 준비되는 경우를 구분해 추적할 수 있다. 저장 없는 join이라면 출력 한 번에 각 입력 한 번이라는 수량 관계가 성립해야 한다. 저장이 있으면 입력 수락과 출력 사이의 차이는 해당 큐에 남은 항목 수로 설명되어야 한다.

그 위에서 A0와 B0가 실제로 한 작업인지 식별자나 독립된 기대 결과로 확인한다. 덧셈 결과가 우연히 같아도 잘못된 쌍을 고른 사실은 사라지지 않는다. 숫자만 비교하는 테스트에서 빠지는 정렬 정보를 남기는 이유다.

처음의 틀린 합은 두 생산자의 속도가 달라서 필연적으로 생긴 것이 아니다. stream join이 모든 피연산자의 준비와 결과 수락을 한 거래로 묶지 못해서 생긴 것이다. 도착 시각이 달라도 쌍의 책임을 함께 끝내고, 순서 또는 식별자의 계약을 유지하면 서로 다른 stall을 겪는 입력도 정확한 한 결과로 합칠 수 있다.