0x00

리서치 / 일일 리포트

  • 읽은 자료 2건
  • 정본 반영 2개 파일
  • 레이더 1건
한 줄 요약X가 포스트 위험도와 광고 등급을 함께 보고 광고 자리를 정하는 새 광고 블렌더를 추가했다. 아직 기본 블렌더가 아니라 실험 게이트 뒤에 있다.

오늘 함께 본 다른 변화는 다음과 같다.

  • X는 같은 커밋에서 노출 판정 서비스의 안전 라벨 캐시가 놓친 항목을 표본으로 골라 미리 채우는 워머를 추가했다.
  • X 랭킹 모델의 학습 코드가 스트리밍 체크포인트 복원을 실제로 호출하게 됐고, 서빙은 gRPC 트레일러 유실 버그를 고쳤다.
  • Meta와 Netflix 블로그에는 새 글이 없었고, arXiv에서는 친구 추천 GNN 논문 1건이 레이더를 통과했다.

1포스트 위험도와 광고 등급의 조합으로 광고 자리를 정하는 블렌더가 생겼다 — X

자료 xai-org/x-algorithm@24c6094 — xAI 공개 저장소, 2026-08-28T01:00:41Z 커밋(파일 41, +2071/−265), 코드. 이 노트들은 4세대 공개 뒤의 X 커밋에 차례로 번호를 붙여 부르며, 이 커밋은 4세대+10이다.

  • 커밋 메시지는 세대 시리즈 관행대로 "Open-source X Recommendation Algorithm" 한 줄뿐이다. 그래서 실제 내용은 파일 diff로만 판독할 수 있다.
  • 줄 수(+2071/−265, 파일 41)로 보면 4세대+7(+1,418)과 +6(+856)보다 크다.
  • 건드린 서브시스템은 6곳으로 폭이 넓다.
    • home-mixer(For You 피드를 조립하는 서비스)의 광고·필터·util
    • visibility-filtering(VF, 포스트마다 이 뷰어에게 보여도 되는지 판정하는 서비스)의 안전 라벨 조회 인프라
    • Phoenix(X For You의 랭킹 모델)의 학습·서빙
    • grox(콘텐츠 분류 서비스)의 reply_spam
    • abuse-enforcement
    • simclusters 배치 잡

배경 X For You 타임라인의 4세대 오픈소스 릴리스는 2026-08-13 헤드라인 공개(47c1bcd, +363,246/−11,640) 뒤로 하루에서 사흘 간격으로 후속 커밋이 이어지고 있다. 바로 앞 커밋인 4세대+9(2026-08-26, 45b48ba)는 여러 인프라 정리를 한꺼번에 담은 넓은 커밋이었다. Phoenix 체크포인트 스택의 저장·복원 정리, trainer_recsys valid_step lock-step 통일, grox reply_spam LM 프롬프트 확장, VF media fallback cache 제거가 그 내용이다. 이번 4세대+10 커밋(24c6094)은 세 가지 성격을 띤다.

  • 2026-08-26 커밋(4세대+9)에 들어온 Phoenix streamed restore와 grox monotone-only가 소비자 연결까지 완결됐다.
  • 광고 안전 스택의 마지막 조각인 tier×tier 블렌더 옵션이 새로 생겼다.
  • VF 요청 경로의 P99 지연을 좌우하는 twemcache miss rate를 낮추는 워머 인프라가 추가됐다.

2026-08-26 커밋(4세대+9)의 대칭적 보강이 이번 커밋에서 실제 소비 경로로 이어지는 흐름이 되풀이되고 있다.

1-A. 중위험 포스트 옆 광고 자리는 BSR-high 광고만 얻고, 고위험 포스트 옆에는 광고를 두지 않는다

home-mixer에 네 번째 광고 블렌더 옵션이 생겼다. 광고 블렌더는 피드의 포스트 사이에 광고를 배치하는 단계이고, 새 블렌더는 포스트 위험도 3단과 광고 등급(BSR, brand safety risk) 2단의 조합표로 광고 자리를 정한다.

  • 새 블렌더 MultiRiskAdsBlender가 네 번째 광고 블렌더 옵션으로 연결됐다 확인 — home-mixer/ads/multi_risk_blender.rs.
    • 구현은 home-mixer/ads/multi_risk_blender.rs(332줄 신규), 테스트는 home-mixer/ads/tests/multi_risk_blender_tests.rs(215줄 신규)다.
    • 블렌더를 고르는 home-mixer/selectors/blender_selector.rs(+8/−4)도 함께 바뀌었다(home-mixer/selectors/blender_selector.rs).
  • 기존 세 블렌더 safe_gap·partition_organic·time_gap은 "모든 광고 → Safe 포스트 옆에만 배치"라는 대칭 정책을 강제했다.
  • 새 옵션 multi_risk는 포스트를 3단, 광고를 2단으로 나누고, 인접성 매트릭스로 슬롯을 정한다.
    • 포스트 단계는 Safe·MediumRisk·HighRisk, 광고 단계는 BsrNormal·BsrHigh다.
  • 포스트의 3단 분류는 4세대+7 (2026-08-24)에 신설된 BrandSafetyVerdict::HighRisk tier에 의존한다.
    • HighRisk가 도입되면서, 이전에 medium에 있던 5종과 신규 SOFT_NSFW가 승격됐다.
    • 5종은 NSFW_HIGH_PRECISION·NSFW_HIGH_RECALL·GORE_AND_VIOLENCE_HIGH_PRECISION·PDNA·EGREGIOUS_NSFW다.
  • 광고의 2단 분류는 BSR 필드 값을 따르는데, 이 값은 광고주가 자기 광고를 medium-risk 컨텍스트에 노출하는 것을 감수하겠다고 밝히는 opt-in 신호로 보인다 추정(코드에는 필드 정의만 있고, 광고주 UI 흐름은 공개되지 않았다).
    • 필드는 AdIndexInfo.ad_adjacency_control.brand_safety_risk이고, 값은 BsrNormal|BsrHigh다.

광고 등급마다 놓일 수 있는 자리는 다음 표와 같다.

광고 tier Safe 옆 MediumRisk 옆 HighRisk 옆
BsrNormal (기본) 배치 안 함 안 함
BsrHigh (광고주 opt-in) 배치 배치 안 함

즉 BsrHigh 광고는 medium-medium 슬롯을 먼저 얻고, 겹침 검사에 걸리면 남은 safe 슬롯으로 넘어간다 확인 — multi_risk_blender.rs:96-130. 다만 광고주 handle이 이웃 포스트에 언급됐거나 광고 키워드가 이웃 텍스트와 겹치면 여전히 빠진다. HighRisk 포스트는 어떤 광고와도 인접할 수 없다.

  • 슬롯이 왜 비었는지와 어느 제약이 binding이었는지는 새 metric 3종으로 관측한다.
    • 3종은 MultiRisk.enforcement·slot_outcome·serving_limitation이다.
  • 안전 슬롯 예산은 광고 수, 간격 상한, 안전 포스트 수의 절반 가운데 가장 작은 값이다.
    • 식은 safe_budget = min(num_ads, spacing_cap, safe_count / 2)다.
    • 세 상한 중 어느 것이 binding이었는지는 MultiRisk.serving_limitation{factor=ads_supply|spacing|safe_posts} 카운터로 관측한다.
    • 예를 들어 광고 공급이 충분한데도 슬롯이 채워지지 않으면 factor로 safe_posts가 뜬다. 이는 뷰어의 피드에 안전 포스트가 부족했다는 신호다.
  • 슬롯마다 광고로 채워졌는지, 아니면 어느 안전 필터에 걸려 비었는지를 나눠 기록한다.
    • 카운터는 MultiRisk.slot_outcome{outcome=filled|unfilled_bsr_low|unfilled_handle|unfilled_keyword|unfilled_no_ads}다.
    • SlotRejections::stuck_outcome()은 세 rejection reason(bsr_low·handle·keyword) 중 가장 많이 발생한 것을 그 슬롯의 대표 원인으로 뽑는다.
    • 그래서 A/B 대시보드에서 "슬롯 하나가 비었을 때 가장 자주 걸린 안전 필터"를 직접 관측할 수 있다.
  • 같은 커밋에서 기존 블렌더를 가리키는 앨리어스가 하나 생겨, 블렌더 옵션은 모두 5개가 됐다.
    • AdsBlenderType param에 "partition_organic_low_risk" 앨리어스가 추가됐다. 이 앨리어스는 기존 PartitionOrganicAdsBlender로 라우팅된다.
    • 옵션은 safe_gap / partition_organic / partition_organic_low_risk / time_gap / multi_risk다.

"if any_bsr_high && max_medium_slots > 0 && spacing_cap > 0 { ... } else if safe_budget == 0 { return posts_to_feed_items(scored_posts); }" — home-mixer/ads/multi_risk_blender.rs, 2026-08-28

정본 반영 services/x.md 광고 절에 "4세대+10 (2026-08-28, 24c6094) — MultiRiskAdsBlender 신설" 소절을 추가했다. 소절에는 인접성 매트릭스 표, 슬롯 예산 산정식, 3종 metric 정의를 담았다.

의미 4세대+7의 HighRisk tier 신설에서 시작한 5일치 흐름이 이번 tier×tier 매트릭스 블렌더로 완결됐다. 흐름은 4세대+7 HighRisk tier 신설 → 4세대+9 광고 픽셀·conv_asset_ids 연결 → 4세대+10 매트릭스 블렌더 순이다.

  • HighRisk가 tier로 분리됐고 광고 픽셀 컨버전 헤드가 붙었으니, 이제 광고주가 자기 노출 범위를 tier 축에서 opt-in할 수 있는 blender 옵션까지 갖춰진 셈이다.
  • 광고주 관점에서는 BSR-high opt-in의 인센티브 구조가 코드에 명시적으로 들어갔다는 신호이기도 하다. medium-risk 컨텍스트를 감수하면 medium-risk 슬롯을 얻어 노출 총량이 늘어나기 때문이다.
  • 다만 AdsBlenderType은 아직 default가 아니라 experiment 게이트 뒤에 있다. 다음 관측 포인트는 이 param의 기본값이 언제 multi_risk로 승격되는지다.

1-B. VF가 안전 라벨 캐시에서 놓친 항목을 표본으로 골라 비동기로 미리 채운다

VF가 안전 라벨을 조회하다 캐시가 놓친 항목을 일부 골라, 요청과 따로 캐시를 미리 채우는 워머가 생겼다. 워머는 요청을 기다리게 하지 않고, 판정 규칙도 바꾸지 않는다.

VF는 요청마다 후보 포스트 수십~수백 개의 안전 라벨을 조회해 Allow / Interstitial / Drop을 판정한다. 라벨 조회 앞단에는 twemcache가 look-aside cache로 있고, miss가 나면 Manhattan(KV 저장소)으로 폴백한다. 이 조회의 hit rate가 VF P99 지연을 좌우하는 핵심 변수다.

  • 라벨은 Strato(X 코드가 데이터를 조회하거나 저장할 때 거치는 내부 시스템)의 visibility/baseTweetSafetyLabelMap 컬럼에서 읽는다.
  • 판정은 3-tier 정책을 따르고, 규칙은 base_home_rules() 28개 + OON 추가 26개 = 54 규칙이다. OON(out-of-network)은 뷰어가 팔로우하지 않은 계정의 포스트다.

워머는 네 단계로 동작한다.

  1. 캐시 결과를 네 종류로 나누고, 캐시가 키를 모르는 경우만 워머 대상으로 모은다.
    • RemoteSource::get(ids)가 twemcache 결과를 Hit, NotFound, Miss, FallThrough(reason)으로 나눈다.
    • Miss(cache가 이 키를 모름)인 경우에만 별도 벡터 warm_ids에 모은다.
    • NotFound(정의상 존재하지 않음)는 정의상 라벨이 없는 트윗이라 워머 대상이 아니다.
    • FallThrough(cache 에러나 타임아웃)는 캐시 인프라 문제라 워머로 해소할 성격이 아니다.
    • 이 3분법이 워머 도입의 원칙이다. 워머는 "캐시가 아직 채워지지 않은 상태"만 해소한다.
  2. Manhattan 폴백 요청이 나가기 전에 warmer.warm(warm_ids)를 fire-and-forget으로 호출한다. 요청 응답 지연에는 영향을 주지 않는다.
  3. 환경변수로 정한 확률로 표본을 골라 bounded 큐에 넣고, 큐가 가득 차면 카운터만 올린 채 요청 처리를 계속한다.
    • SampledWarmer가 VF_CACHE_WARM_SAMPLE_PCT 환경변수(0–100, panic on out-of-range)로 확률 샘플링을 한다.
    • 통과한 ID만 tokio mpsc::channel bounded queue(용량 1024)에 try_send한다.
    • 채널이 가득 차서 실패하면 WarmKeyResult::DroppedChannelFull 카운터만 올린다.
    • 워머 채널이 포화되더라도 요청 경로에는 backpressure가 절대 걸리지 않도록 설계했다.
  4. 백그라운드 drain 태스크가 ID를 모아 Strato에 일괄 조회를 보낸다.
    • 태스크는 큐에서 벡터를 pop한 뒤, 500ms linger 동안 뒤따라 오는 벡터를 합쳐 최대 50 ID까지 한 배치로 묶는다.
    • 묶은 배치는 StratoWarmFetcher::fetch(chunk)로 Strato에 batch_call을 보낸다(WARM_COLUMN_PATH = "visibility/baseTweetSafetyLabelMap").
    • Strato가 write-back한 결과가 twemcache에 채워진다. 다만 warmer 코드가 fetch 응답을 직접 twemcache에 쓰지는 않으므로, 별도의 read-through 캐시 계층이 있는 것으로 추정한다 추정.
  • 워머의 단계별 통과율과 병목은 6종 outcome 카운터로 관측한다.
    • record_cache_warm_keys(result, count)가 EligibleMiss·SampledOut·Enqueued·DroppedChannelFull·FetchIssued·FetchFailed를 남긴다.
    • EligibleMiss는 miss로 워머 대상이 된 총량이며, 샘플링 이전 수치다.
    • 예를 들어 FetchFailed / FetchIssued가 높으면 Strato 인프라 문제이고, DroppedChannelFull이 계속 발생하면 채널 용량(1024)이 부족한 것이다.
  • 같은 파일에서 환경변수 3개를 새로 만들어 설정을 정리했다.
    • 새 env는 VF_GIZMODUCK_CLIENT_ID·VF_TWEMCACHE_CLIENT_NAME·APP_ENV다.
    • gizmoduck_client_id()는 설정된 값이 없으면 visibility-filtering-service.{APP_ENV|prod} 템플릿으로 값을 자동 조립한다.
    • VF가 dark_traffic으로 staging에서 돌 때 staging용 gizmoduck(X의 유저 정보 서비스) 쿼터와 레이트리밋을 프로덕션과 분리하려는 목적으로 추정한다 추정.

정본 반영 services/x.md 안전성/visibility-filtering 절에 4세대+10 소절을 추가했다. 소절에는 워머 흐름, bounded 채널 원칙, 6종 metric, config 정리를 담았다.

의미 이 워머는 VF의 요청 경로에 새 계층을 연 것이 아니라 하부 인프라를 최적화한 것이다. 규칙 판정(3-tier / 54 규칙)은 그대로다.

  • twemcache miss가 나는 경우는 두 가지다.
    • 트윗이 아직 어떤 라벨링도 받지 못한 신선한 상태여서, 캐시는 물론 라벨도 없는 경우다.
    • TTL이 만료됐거나 evict되어 캐시만 비어 있는 경우다.
  • 이 워머는 특히 둘째 경우, 즉 Manhattan에는 라벨이 있는데 캐시가 놓쳐서 매 요청마다 Manhattan을 조회하는 낭비를 해소한다.
  • sample-pct 게이트를 둬서, 트래픽이 급증해도 워머 자체가 Strato 폭주를 유발하지 않도록 설계했다.
  • VF_CACHE_WARM_SAMPLE_PCT를 낮게 시작해 점진적으로 올리는 dial-up 롤아웃도 가능하다. 4세대의 다른 dial-up 패턴(Grok 4.6 10%→30%→100%, 4세대+4→+5→+6)과 유사한 관행이다.

1-C. Phoenix 학습 코드가 스트리밍 체크포인트 복원을 실제로 호출한다

4세대+9(2026-08-26)에 스캐폴딩만 들어왔던 스트리밍 체크포인트 복원을 이번 커밋부터 학습기가 실제로 호출한다. 이 경로의 핵심은 optimizer state 사본을 pinned-host에 만들지 않고, tensor를 하나씩 device로 옮겨 원래 참조 자리에 끼워 넣는 것이다.

  • 학습기의 체크포인트 로드 함수가 스트리밍 복원의 실제 소비자가 됐다 확인 — phoenix/xrex/train/trainer.py:1177-1330.
  • 새 경로는 세 조건이 모두 맞을 때만 켜지고, 하나라도 어긋나면 기존 복원 경로로 돌아간다.
    • 새 경로를 켜는 config 플래그는 restore_streamed다.
    • 세 조건은 restore_streamed=True, format == "orbax", save_method != "tensorstore"다.
    • 하나라도 맞지 않으면 로그를 남기고 기존 load_checkpoint(전량 pinned-host 스테이지) 경로로 폴백한다.
    • 즉 config를 잘못 설정했거나 orbax가 아닌 checkpoint(tensorstore 등)를 만나면 안전한 기본 경로로 fall-through하므로, 하위 호환이 유지된다.
  • 불러온 tensor는 원래 state tree의 자리에 in-place로 끼워 넣는다(grafting).
    • use_streamed_restore=True 경로는 그 자체로 checkpointing_load.copy_aliased_arrays(self.state)를 호출해 aliased state leaves를 미리 분리한다.
    • 끼워 넣기는 새 함수 _graft_replaced(pairs)가 맡는다.
    • aliased leaf가 두 자리에 걸쳐 있으면 어느 슬롯에 넣을지 모호해지므로, _graft_replaced는 이 경우를 조기에 assertion으로 막는다.
    • assertion 메시지는 restore_streamed does not support aliased state leaves: multiple loaded tensors share one device array다.
  • 스트리밍 복원 함수가 받는 새 인자 두 개도 이 커밋에서 라우팅됐다.
    • 함수는 load_checkpoint_streamed이고, window_gb는 해당 배치의 pinned-host 예산이다. None이면 auto-window로 동작한다.
    • on_replaced 콜백은 완료된 배치의 old/new device array pair 리스트를 받아, 호출자가 state tree에 끼워 넣도록 한다.
    • window_cap_gb는 window_gb가 명시되지 않았을 때만 restore_concurrent_gb 값으로 세팅되는 안전벨트다. 이전 API의 concurrent_gb를 streamed 경로에서 window cap 상한으로 재활용한 것이다.
  • 이 흐름의 이득은 warm_start 케이스에서 특히 크다. warm start는 pretrained params를 로드하고 opt state는 새로 만드는 경우다.
    • 기존 do_not_load_opt_state=True 경로가 warm_purge(self.state)로 opt state 필드를 미리 잘라내고, streamed 경로는 host_state를 None으로 둔 채 loaded params만 끼워 넣는다. 그래서 opt state의 pinned-host 사본이 아예 생성되지 않는다.
    • 4세대+6·+7의 Muon과 GB300 조합 flagship이 pretrained 파라미터에 새 optimizer를 붙여 시작하는 표준 흐름을, 이제 checkpoint stack이 원자적으로 지원하게 됐다.

정본 반영 services/x.md Phoenix 학습·인덱스 절의 "4세대+10 (2026-08-28, 24c6094)" 소절에 첫 갈래로 추가했다.

의미 4세대+9에서 +10으로 이어지는 이틀 흐름이 "스캐폴딩 → 소비자 배선"으로 완결됐다. restore_streamed가 현재 default인지 experiment flag 뒤에 있는지는 코드만으로 분명하지 않으나, config에 1줄만 추가된 것으로 보아 아직 default가 false라고 추정한다 추정. 다음 관측 포인트는 flagship config에서 이 플래그가 True로 승격되는 커밋이다.

1-D. gRPC 응답 압축이 상태 트레일러를 잃던 버그를 고치고 압축 시간을 재기 시작했다

Phoenix 서빙이 응답을 zstd로 압축할 때 gRPC 상태 코드를 담은 트레일러가 사라지던 버그가 고쳐졌다. 같은 파일에 압축 시간을 method별로 재는 히스토그램도 생겼다.

  • 이전 로직은 압축 과정에서 gRPC trailers를 전부 잃었다 확인 — phoenix/crates/serving/xai-recsys-engine/src/grpc_compression.rs.
    • GrpcCompressionService가 응답 본문을 zstd로 압축할 때, collected.to_bytes()로 body만 뽑아 새 axum::body::Body::from(data)로 다시 감쌌다.
    • 이 과정에서 특히 grpc-status·grpc-message가 사라졌다.
  • gRPC 프레임은 body 뒤에 trailer로 상태 코드를 실어 보내는 구조인데, 그 부분이 버려진 것이다.
  • 클라이언트 쪽에서는 응답이 정상으로 도착하면서도 status trailer가 없어 UNKNOWN이나 EOF로 처리됐을 가능성이 있다. 그렇다면 재시도 로직과 에러 로깅, 서킷브레이커가 모두 오작동했을 것이다.
  • 새 코드는 trailer를 미리 뽑아 두었다가 응답을 다시 만들 때 붙인다. 압축에 성공하든 실패하든 트레일러가 유지되도록 통일했다.
    • collected.trailers().cloned()로 trailer를 미리 뽑아 둔다.
    • rebuild_response(parts, data, trailers) 헬퍼가 Full::new(data).with_trailers(async move { Some(Ok(tr)) })로 트레일러를 다시 붙인 뒤 응답 본문을 구성한다. 줄여 말하면 Full::new(data).with_trailers(...)로 트레일러를 복원한 것이다.
  • 회귀 테스트 두 개가 이 동작을 고정한다.
    • 테스트는 compressed_response_forwards_trailers와 non_compressed_response_forwards_trailers다.
    • 이 두 테스트가 남아 있는 한, 나중에 비슷한 리팩터링으로 trailer가 다시 새면 CI가 잡아낸다.
  • 압축 시간을 method별로 재는 히스토그램이 새로 생겼다.
    • 이름은 recsys_engine_response_compress_ms{method}이고, 같은 파일에 넣었다.
    • tokio::task::spawn_blocking(compress_grpc_frame)에 들어가기 직전에 Instant::now()를 찍고, 완료된 뒤 elapsed().as_secs_f64() * 1000.0으로 관측한다.
    • 즉 zstd 인코딩의 큐잉 시간과 CPU 시간을 method별로 나눠 볼 수 있다.
    • buckets는 request latency 버킷과 공유한다(crate::request_metrics::latency_buckets_ms()).

정본 반영 services/x.md Phoenix 절에 두 번째 갈래로 추가했다.

의미 trailer 유실은 서빙 계약을 조용히 깨뜨리는 심각한 버그다. 이 커밋으로 회귀 방지 테스트까지 붙어서, 같은 문제가 다시 생길 확률이 크게 낮아졌다.

  • _compress_ms 히스토그램으로 GB300에서 큰 응답의 zstd 인코딩이 응답 지연에 얼마나 기여하는지 나눠 볼 수 있다. 그래서 압축 레벨을 튜닝할 근거가 된다.
  • 이 버그가 프로덕션에 언제부터 있었는지, 어떤 증거가 관측됐는지(고객 티켓인지 재시도 폭주인지)는 공개되지 않았다.

1-E. pod마다 shard를 다른 순서로 받게 해 peer 부하를 고르게 나눴다

각 pod가 텐서의 여러 shard를 병렬로 내려받을 때, pod마다 shard 요청 순서를 다르게 섞는 셔플이 생겼다. 순서는 pod 이름과 텐서 이름의 hash로 결정론적으로 정해져서, 같은 pod는 restart돼도 같은 텐서에 대해 같은 순서로 요청한다.

  • shard 요청 순서를 섞는 함수 shuffle_sharded_schedule(n, tensor_name)이 새로 생겼다 확인 — phoenix/crates/serving/xai-recsys-engine/src/copy_port_client.rs, phoenix/crates/serving/xai-recsys-engine/src/emb_table.rs.
  • 각 pod가 텐서의 여러 shard를 병렬로 내려받을 때, 이전에는 모든 pod가 index 0부터 순서대로 요청했다. 그래서 첫 shard를 가진 owner peer가 순간적으로 과부하됐다.
  • 새 로직은 pod 이름과 텐서 이름의 hash로 결정론적 셔플을 걸어 각 pod가 서로 다른 순서로 shard를 요청하게 만들었고, shard 0 peer의 스파이크가 사라졌다.
    • pod 이름은 POD_NAME(또는 HOSTNAME)을 쓰고, 셔플은 StdRng::seed_from_u64(hash)로 초기화한다.
  • seed가 결정론적이라 같은 pod가 restart돼도 같은 순서를 유지하므로, 로그를 재현할 수 있다.
    • 대신 완료된 결과를 원래 순서로 되돌리기 위해 restore_piece_order(shuffled, schedule) -> Option<Vec<T>> 헬퍼를 뒀다.
  • emb_table.rs와 copy_port_client.rs 두 소비 지점 모두 새 헬퍼로 통일했다.
  • 셔플은 sharded ownership에만 적용하고 replicated는 in-order를 유지한다. 모든 pod가 같은 데이터를 받으므로 셔플할 이득이 없기 때문이다.
  • 첫 8개 index만 로그로 남겨, 셔플이 실제로 걸렸는지 진단할 수 있다.
    • 로그 형식은 copy_port: shard schedule shuffled name={tensor} n={total} first={:?}다.
  • TRANSFER_FAILED_SENTINEL을 만나면 restore를 하기 전에 에러로 조기 반환한다.

정본 반영 services/x.md Phoenix 절에 세 번째 갈래로 추가했다.

의미 이번 셔플은 shard 0 peer에 남아 있던 마지막 병목을 pod 수에 반비례하게 나눈다. 대형 임베딩 테이블과 dense weight을 여러 pod가 restore할 때 shard 0을 담당하는 peer에 traffic spike가 몰리는 문제가 있었다. 이 문제는 4세대+7의 노드-serialize와 concurrent_gb로 완화됐지만, peer 쪽 스파이크는 여전히 남아 있었다.

  • 결정론적 셔플이 중요한 이유는 두 가지다.
    • 셔플 자체를 로그로 재현할 수 있다.
    • 같은 pod가 같은 tensor를 여러 번 로드해도 순서가 같아서, 캐시 hit rate 관측이 안정적이다.

1-F. 나머지 6건은 관측 지표를 늘리고 저장 순서, 테스트, 배치 폴백을 손봤다

4세대+10의 나머지 6건은 서로 독립적인 보강이다. home-mixer, grox, abuse-enforcement, simclusters에 걸쳐 관측 지표, 저장 순서, 회귀 테스트, 주석 정리, 스냅샷 폴백이 바뀌었다.

  • home-mixer 서빙 지표에서 정기 polling refresh와 유저 초기 요청을 나눠 볼 수 있게 됐다 확인 — home-mixer/util/strato_context.rs (75줄 신규).
    • 새 유틸이 tonic gRPC의 MetadataMap에서 stratocontext 또는 stratocontext-bin 키를 뽑아 base64로 디코드한 뒤, proto StratoContext { is_polling: bool }로 파싱한다.
    • 즉 stratocontext metadata에서 is_polling proto 필드를 뽑아 정기 refresh와 유저 초기 요청을 나눠 본다.
    • 카운터를 올리는 쪽은 home-mixer/side_effects/response_stats_side_effect.rs(+34/−0)다.
    • response_stats_side_effect.rs의 새 함수 stat_request_context(feed, query)가 요청마다 세 카운터를 발화한다.
      • <feed>.request.polling{polling=true|false, client}
      • <feed>.request.request_context{request_context, client}
      • <feed>.request.client{client, client_version}
    • polling 요청은 시퀀스가 짧고 has_cached_posts 확률도 달라서, 이 축을 분리해 관측하는 것이 랭킹 실험 해석에 중요하다.
  • grox reply_spam이 Manhattan 저장 조건을 더 조이고 저장 순서를 Kafka 먼저로 바꿨으며, coordinated spam 판정에 팔로워 수를 넣었다 확인 — grox/flows/reply_spam/task_write.py (+15/−21).
    • _publish_to_reply_ranking_manhattan이 4세대+9의 monotone-only 위에서 세 갈래로 더 조여졌다. >= idempotency, Kafka-first 저장 순서, fetch fail-loud다.
      1. 비교 연산자가 >에서 >=로 바뀌었다. 이전에는 새 스코어가 기존 값과 같으면 그대로 썼다(중복 write). 이번에는 score >= existing.score이면 건너뛴다. 카운터 이름도 higher_than_existing에서 not_lower_than_existing으로 바꿔 시맨틱을 명시했다.
      2. Kafka 이벤트를 Manhattan write 앞으로 옮겼다. 이전 순서는 save_reply_ranking_score(Manhattan) 다음에 save_reply_ranking_kafka_v2(Kafka)였고, 새 순서는 그 반대다.
        • Kafka는 append-only라 실패해도 재시도할 수 있다. 반면 Manhattan은 monotone-only 로직에 걸려 재시도 자체를 건너뛸 수 있고, upsert side-effect까지 있어 무겁다.
        • Kafka-first가 시스템 전체의 관측 가능성 정합성을 보장하는 durability 순서다.
        • Kafka publish가 성공한 뒤 Manhattan write가 실패하면 오프라인 재처리 파이프라인이 복구할 수 있다. 반대 순서에서는 downstream(서빙 캐시 warm-back, 다른 소비자)이 Manhattan에 이미 반영된 새 스코어를 보지 못하게 된다.
      3. fetch_reply_ranking_score의 try/except를 걷어냈다. 이전에는 fetch가 실패하면 warning 로그를 남기고 existing = None으로 진행해서 결국 항상 write했다. 이번에는 예외를 그대로 위로 던져 flow task가 실패로 마크되고 재시도된다. 방어적인 예외 처리를 걷어내고 로직의 정확성을 우선했다.
    • 팔로워 수 변경은 두 번째 파일 grox/flows/reply_spam/classifier_coordinated_spam.py (+5/−1)에 있다.
    • 같은 커밋에서 classifier_coordinated_spam.py의 _to_convo가 PostRenderer.render(p, max_media=..., include_follower_count=True)로 호출되도록 인자가 추가됐다.
    • 4세대+9에 옵션으로 도입된 include_follower_count가 여기서 상시 활성으로 채택된 것이다. 이제 coordinated spam 판정이 스레드에 참여한 각 계정의 팔로워 카운트를 프롬프트로 함께 받는다.
  • abuse-enforcement에는 모든 action 형태가 실제 AIS(ActionIntakeService) thrift 스키마로 인코드되는지 확인하는 conformance 테스트가 생겼다 확인 — abuse-enforcement-service/service-lib/src/service.rs (+58/−0).
    • 새 테스트는 every_action_shape_encodes_against_the_ais_schema다.
    • 이 테스트는 8종 EnforcementAction 형태를 실제 AIS thrift 스키마로 인코드해 보면서 회귀를 막는다.
      • 형태 목록은 suspend_user·add_user_label·add_post_label·bounce_arkose·bounce_captcha·spam_liveness_check다.
    • 대상 메서드는 ActionIntakeService.intakeAction이다.
    • Transcoder::from_schema_bytes(SCHEMA_AIS)로 스키마를 로드하고 lookup_method로 메서드를 찾은 다음, 각 action에 대해 build_intake_request와 encode_call을 수행한다.
    • AES(abuse-enforcement-service)의 payload 스키마가 다운스트림 thrift 서비스와 drift하지 않도록 CI가 보장하는 셈이다.
    • 4세대+8에서 도입된 generic_actions 패스스루로 스코어러가 임의의 action을 실어 보낼 수 있게 된 흐름에 붙인 안전벨트다.
  • home-mixer 뮤트 키워드 필터가 CJK 키워드를 온전한 토큰으로 나올 때만 거른다는 동작을 회귀 테스트로 고정했다 확인 — home-mixer/filters/viewer_muted_keyword_filter.rs (+18/−0).
    • 새 테스트는 test_cjk_keyword_whole_token_only다.
    • 京都 뮤트는 I visited 京都 last week를 걸러낸다. 이 문장에서는 공백 경계 안에 京都가 온전히 등장한다.
    • 東京都に行くのが楽しみ는 걸러내지 않아야 한다. 여기서 京都는 東京都의 부분 문자열이다.
    • 필터 본체의 diff는 없다. 즉 필터는 이미 CJK word boundary를 다루고 있었고, 이번에는 그 시맨틱을 회귀 테스트로 고정했다.
    • 뮤트 키워드가 부분 문자열 매칭으로 확장되어 유저가 예상 밖의 포스트를 보지 못하게 되는 사고를 막는다.
  • Brazil2026ElectionFilter의 계정 목록 주석을 다시 정리했고, 필터 동작은 그대로다 확인 — home-mixer/filters/brazil_2026_election_filter.rs (+63/−77).
    • 이전에는 665개 계정을 no live account was found나 deleted his account 같은 반복 주석 하나로 나열했다.
    • 이번에는 10개 계정에 "We believe the account @X reported by the candidate is not the candidate's actual account, so we are not currently filtering it"라는 판정을 달고, 나머지 다수는 no live account found로 정리했다.
    • 필터의 시맨틱은 그대로이고 팔로우 예외도 유지된다.
    • 4세대+8의 리스트 갱신 관행과는 별개로, 이번 변경은 문서와 주석의 서술을 정리한 것이다.
  • SimClusters(팔로우 그래프를 커뮤니티로 나눠 만든 임베딩) 배치 잡이 스냅샷을 읽을 때 28일 룩백, 데이터센터 pin, 임시 복구 경로 폴백을 함께 쓰도록 헬퍼를 인라이닝했다 확인 — simclusters/simclusters_v2/scalding/embedding/EntityToSimClustersEmbeddingsJob.scala (+17/−2).
    • 대상은 SimClustersInterestedIn2020 스냅샷이다.
    • InterestedInSources.simClustersInterestedIn2020Source(dateRange, timeZone) 헬퍼를 다음 세 설정으로 직접 인라이닝했다.
      • DAL.readMostRecentSnapshot(interestedIn2020WithFallback, dateRange.prepend(Days(28)(timeZone)))
      • .withRemoteReadPolicy(ExplicitLocation(ProcAtla))
      • fallbackPath = Some("viewfs://.../simclusters_v2_interested_in_20M_145K_2020/_TMP_RECOVERY/{timestamp}")
    • 즉 28일 룩백에 특정 데이터센터(ProcAtla) pin과 임시 복구 경로 폴백을 함께 적용한 것이다.
    • 배치 잡이 스냅샷 결측(dataset partition 유실이나 업스트림 지연 등)에 더 관대해졌다. SimClusters 임베딩 잡의 데이터 신뢰도를 보강한 변경이다.

정본 반영 이 여섯 가지 변경은 x.md의 4세대+10 공개로 해소된 것 / 신규 버킷에 각각 한 줄씩 체크마크로 남겼다. 항목들이 서로 독립적이어서, 별도의 절로 뽑기보다 버킷에서 색인하는 편이 찾기 쉽다.

2친구 추천 GNN을 프로덕션 소셜 그래프 규모로 키운 논문이 나왔다 — arXiv

arXiv cs.IR 30건을 훑어 수용 기준(플랫폼 기업 저자 / 프로덕션·A/B / 메커니즘 신규성 / 스케일)을 넘긴 것은 1건뿐이다. 나머지 29건은 순수 학술 증분이거나, 도메인이 한정되어 있거나, 프로덕션 진술이 없어서 탈락했다.

자료 Scaling Graph Neural Networks for Friend Recommendation: Multi-Hash User Embeddings and Temporal Neighbor Sampling — arXiv 2608.27413, 2026-08-27 v1, 논문, CIKM 2026 accepted. 저자는 3명(Maksim Utushkin·Andrei Ovsiannikov·Alexander D'yakonov)이다. arXiv abstract 페이지에 소속이 표기되어 있지 않지만, 본문에서 참조하는 그래프 규모와 러시아어 저자명을 함께 놓고 보면 VK(러시아의 대형 소셜 네트워크)로 추정한다 추정.

배경 친구 추천(People You May Know, Suggested Friends 같은 소셜 플랫폼 기능)에서는 관심사·나이·위치 같은 유저 속성보다, 친구의 친구나 공통 친구 수 같은 다중 홉 소셜 컨텍스트가 관련성의 주된 신호다. 그래서 GNN(Graph Neural Network, 노드가 자기 이웃의 임베딩을 aggregate해서 자기 임베딩을 갱신하는 message-passing 모델)이 자연스러운 프레임워크가 된다. 다만 프로덕션 소셜 그래프는 수억 유저와 수백억 엣지 규모라 시스템 문제가 두 가지 생긴다.

  • 유저 ID 임베딩 테이블이 폭발한다.
  • 슈퍼노드(팔로우 수가 극단적으로 많은 계정)의 이웃 순회가 O(deg) 비용으로 병목이 된다.

이 논문은 이 두 시스템 문제를 정면으로 푼다.

새로 알게 된 것

  • Multi-hash user embeddings가 ID 임베딩 테이블을 98% 이상 줄였다 확인 — 원문.
    • 각 유저 ID를 여러 해시 함수의 임베딩 lookup 조합으로 표현한다.
    • hash collision은 감수하되 ranking quality는 유지한다.
  • Temporal neighbor sampling은 이웃 샘플링 비용을 로그 시간으로 줄였다 확인.
    • timestamp-sorted CSR(Compressed Sparse Row, 인접 리스트를 큰 array 하나와 offset array로 압축하는 그래프 저장의 표준 자료구조) 저장과 binary search를 결합했다.
    • 복잡도는 O(deg(v) + k)에서 O(log(deg(v)) + k)로 단축됐다.
    • 즉 팔로우 수가 큰 노드도 이웃 순회 비용이 로그 시간에 그친다.
  • 프로덕션 소셜 그래프의 규모는 194M users · 28B edges다 확인.
  • Strong production baseline 대비 온라인 지표는 friend additions from recommendations +16%, unique friend adders +11.5%다 확인.

"On a graph with 194M users and 28B edges, our system increases friend additions from recommendations by 16 percent and unique friend adders by 11.5 percent over a strong production baseline." — arXiv 2608.27413 abstract, 2026-08-27

레이더 반영 radar.md의 "이번 달 신규"에 새 항목으로 추가했다. 이 논문은 LLM-native나 스케일링 법칙 흐름에는 걸리지 않는다. 대신 명시적 그래프 도메인에서 GNN이 여전히 살아남는 사례로서, Meta ConnectionMind(그래프 위에서 LLM으로 경로를 추론하는 모델)와 대조하는 역할을 한다.

의미 strong prod baseline 대비 두 자릿수 uplift(+16%)는 GNN 논문에서 드물다. GNN이 실무에서 결국 살아남는 도메인은 완전히 명시적인 그래프(친구 그래프, follow 그래프)라는 관찰이 강화된다.

  • Meta ConnectionMind가 LLM으로 그래프 경로 추론을 시도하는 것과는 방향이 다르다. VK는 순수 GNN을 소셜 그래프의 스케일 한계까지 밀어붙였다.
  • Multi-hash 임베딩 -98% 축소는, embedding table 크기가 서빙 인프라의 핵심 제약이라는 X SimClusters의 관측과도 방향이 같다.
  • 서빙 지연을 늘리지 않고 적용했다는 진술은 abstract에 없다. 다만 두 최적화(테이블 축소와 이웃 순회의 로그화)의 목적이 정확히 그것이다.
  • 다음 관찰 포인트는 두 가지다. CIKM 2026(10월) 발표에서 소속과 인프라 세부가 추가로 공개되는지, 그리고 LinkedIn People You May Know와 Facebook People You May Know의 GNN 갱신 진술이 나오는지다.

다음에 볼 것