링을 p4 위로 옮기기

엔진과 그 호출자 사이에서 어긋난 계약 일곱 가지. 각각 다르게 실패했고, 그중 오류처럼 보인 것은 하나뿐이었다.

p4 엔진의 새 릴리스를 머지했더니 링이 서빙을 멈췄다. 크래시가 난 것도 아니었다 — 로더는 성공을 보고했고, 에이전트들은 ready를 보고했고, 아무 일도 일어나지 않았다. 그 침묵에서 거슬러 올라가는 데 하루가 걸렸고, 우리 호출자와 엔진이 서로 어긋나 있던 곳을 일곱 군데 찾아냈다. 적어 둘 가치가 있는 이유는 개수가 아니다. 일곱 중 여섯이 오류를 내지 않았다는 점이다.

실패 하나

존재하지 않는 노드로 향한 이벤트는 거부되지 않고 전달된다

우리 로더는 LOAD 명령을 자기가 만들려는 바로 그 노드 앞으로 보냈다. 하지만 노드는 LOAD가 만들기 전까지 존재하지 않고, 알 수 없는 노드를 지목한 이벤트에 대한 브로커의 규칙은 거부가 아니라 바깥으로 전달이다. 명령은 갈 곳을 찾아 에이전트를 떠났고, 아무 데도 찾지 못했고, 버려졌다. 로그 한 줄 남지 않았다 — 브로커 입장에서는 잘못된 일이 하나도 없었으니까.

수정은 LOAD를 *에이전트* 앞으로 보내되 엔진의 백엔드 중립 라이프사이클 봉투에 싸고, 어댑터 자신의 명령은 불투명한 본문으로 담는 것이었다. 지나고 보면 당연하고, 바깥에서는 보이지 않는다.

실패 둘

다른 숫자와 반드시 같아야 하는 숫자

모델은 load generation 아래 로드되고, 각 노드는 node generation으로 등록된다. 우리는 이 둘을 독립적인 값으로 다루고 있었고 — 하나는 타임스탬프, 다른 하나는 1 — 다 잘 돌았다. 링이 로드됐다. 요청 하나에 정확히 답했다. 그러고 나서 head 노드가 죽었다.

어댑터의 release 회계에 들어 있는 검사.

let Endpoint::Node { generation, .. } = &event.envelope.source;
if *generation != receipt.load_generation {
    return Err("release owner census generation differs from source");
}

끝난 요청을 닫는 receipt는 load generation을 담고, 그것을 보내는 노드는 자기 generation을 담는다. 둘이 다르면 그 노드는 정지된다. 그래서 이 버그의 모양은 이렇다: 로드 성공, 첫 요청 성공, head 사망, 그 뒤의 모든 세션이 부분 로드 상태로 멈춤. 부하를 받다 죽은 것처럼 읽히지, 값 불일치처럼은 전혀 읽히지 않는다. 이제 우리 로더는 두 숫자가 어긋나는 계획을 아무것도 로드하기 전에 거부한다.

실패 셋

결코 만족될 수 없는 한계값

어댑터는 스테이지가 반환할 수 있는 최대 결과 크기를, 스테이지 서버가 올라오며 보고하는 값과 정확히 같은지 비교한다. 그 값은 모델을 로드해 보기 전에는 알 수 없었다 — 그래서 추측값으로 로드했고, 실패가 네 스테이지의 실제 숫자를 한꺼번에 알려 줬다:

한 번의 실행, 네 개의 답.

step37-s0: profile=33554432, READY=34419218444
step37-s1: profile=33554432, READY=34419218444
step37-s2: profile=33554432, READY=34419218444
step37-s3: profile=33554432, READY=59136012

34 GB. 에이전트의 retained store는 256 MiB이니 그 링은 애초에 승인될 수 없었다. 스테이지 서버에서 유도식을 읽어 보니 이유가 드러났다: 이 한계값은 n_batch × n_ubatch에 비례하는데, 우리는 이 검사가 생기기 전의 설정에서 배치 폭 2048을 물려받고 있었다. 프로덕션 레이아웃이 실제로 쓰는 폭인 128행에서는 한계값이 138 MB이고 들어맞는다. 이제는 기억해 둔 상수를 들고 다니는 대신 계획 단계에서 같은 공식으로 유도하며, tail 스테이지의 예측값은 다른 배포가 기록해 둔 숫자와 정확히 일치했다 — 20분짜리 로드에 들어가기 전에 확보해 둘 만한 종류의 일치다.

나머지 넷

짧게

  • 두-호스트 링의 loopback 주소. 스테이지는 다음 스테이지의 에이전트를 그 에이전트가 광고하는 주소로 dial한다. 127.0.0.1을 광고하면 호스트 A는 자기 자신을 dial한다. 이 링의 기록된 설정은 처음부터 loopback이었다 — 호스트를 넘어서는 애초에 동작할 수 없었다.
  • 저널은 필수다. 에이전트의 운영 저널 없이는 모델이 로드되지 않는다. 우리는 다른 오류를 쫓다가 저널을 껐고, 증상을 진전처럼 보이는 방식으로 더 나쁘게 만들었다.
  • 디바이스 이름은 백엔드마다 다르다. 레퍼런스 계획 빌더는 CUDA를 겨냥해 --device CUDA0을 내보낸다. HIP 빌드는 디바이스를 ROCm0이라 부른다. 그 계획은 모델 전체를 로드하고 *그다음에* 디바이스를 찾지 못한다.
  • 네이티브 서버는 릴리스의 일부다. 더 새 트리에서 빌드한 에이전트는 설치된 스테이지 서버가 보고하지 않는 능력을 요구한다. 이것 역시 모델을 전부 로드한 뒤에 발견된다.
여기서 얻은 것

비싼 실패 모드는 침묵이다

하나하나는 고치기 싸고 찾기 비쌌으며, 패턴은 일관된다: 비싼 실패는 멀쩡해 보이는 시스템이 아무것도 하지 않거나 딱 한 번만 하던 경우였다. 우리가 추가한 가드는 전부 같은 모양이다 — 실수가 아직 읽히는 자리에서 일찍 거부하기. 로더는 첫 명령이 떠나기 *전에* load generation을 디스크에 쓴다. 그러지 않으면 복구가 불가능하기 때문이다. generation 불일치는 첫 요청 뒤에 발견하는 대신 미리 거부한다. 결과 한계값은 기억하는 대신 유도한다.

링이 서빙 중이다. 머신 두 대에 걸친 네 개 스테이지, 상주 가중치 113 GiB, 첫 토큰은 콜드 1.4초·웜 약 0.3초, 그리고 노드별 기여가 처음으로 정산 원장까지 흘러갔다.