傳統推薦檢索棧裡,item index、eligibility filter、reranker、user tower 是四個各自部署、各自版本、各自跨網路呼叫的微服務。SilverTorch 問的問題很直接——既然這四件事最後都要被同一張 GPU 上的同一批 tensor 算過去,為什麼不讓它們就是同一個 PyTorch model 的四個 module,在一次 forward pass 裡跑完?
Index as Model——Meta 把推薦檢索棧縮進一個 PyTorch forward pass
推薦系統的線上檢索,二十年來幾乎都是同一個形狀:先用 user embedding 對龐大的 item index 做 approximate nearest neighbor(ANN)搜尋,取回幾百到幾千個候選;再把這批候選餵給一個 eligibility filter 過濾掉不可投放的項目;最後送進一個 neural reranker 做精排,輸出 top-K。這三段——retrieval、filtering、ranking——在 Meta 的線上服務裡長年被拆成三組以上獨立的微服務:item index 是一套 service、user tower 是另一套、filtering 走 CPU 上的 inverted index、reranking 又是一套。SilverTorch 的論點是把整條棧表達成「Index as Model」:item index、eligibility filter、scoring layer、user tower 全都變成同一個 PyTorch model 內部的 tensor 或 operator,在單張 GPU 上的單次 forward pass 內、在 sub-100ms 的延遲預算裡跑完。對照舊架構,throughput 拉到 23.7 倍、compute-cost 效率對 CPU baseline 達 20.9 倍(開啟 reranking 後仍有 13.35 倍)。
這不是把舊服務「搬上 GPU」這麼簡單。把四段檢索流程塞進一張 computation graph,每一段都得重新設計成「GPU 喜歡的形狀」——ANN 要 fused Int8 kernel、eligibility 要用 bit operation 取代 posting list、reranking 要能在放大數十倍的候選池上跑、scoring 要把多個 task 的預測合成單一分數。下面四個小節各自處理一個 module,順序對應一筆 request 在 forward pass 裡實際被算過去的順序:先有 user embedding 對 index 的 ANN(哪些 item 在 embedding 空間裡離 user 近),再有 eligibility filtering(這些 item 哪些此刻可投放),再有 neural reranking(在放大的候選池上精排),最後是 multi-task composite scoring(把多個行為預測合成最終排序分數)。先看整張圖——下面的互動小工具把這四個 module 畫成一條 pipeline,點任一個 module 看它負責什麼、又刻意不知道隔壁的什麼。
click any module to read its responsibility · 4 modules
click a module above
fused Int8 ANN · 責任邊界
把整個 item index 以 Int8 的 embedding tensor 形式存在 model 裡(約是 16-bit 的一半記憶體),用一個 fused GPU kernel 對 user embedding 跑 IVF 風格的 ANN——在 64 個 cluster 上 probe、回傳 top-2048。索引不再是外部 service,而是 forward pass 讀到的一塊 tensor。
不知道的事:item 此刻是否可投放(那是 eligibility 的事)、最終排序分數怎麼算(那是 scoring 的事)。它只負責「embedding 空間裡誰離 user 近」。
Bloom eligibility · 責任邊界
把 eligibility 規則編成一個直接存在 model 裡的 Bloom index,用 bit operation 判斷 candidate 是否通過,取代 CPU 上的 inverted index(posting list)。posting list 在不同 attribute、不同 query 之間長度差異巨大,在 GPU 上造成 warp divergence;bit-ops 是 dense、parallel 的工作,正是 GPU 擅長的。
不知道的事:candidate 是怎麼被 ANN 選出來的、reranker 會怎麼重排。它只回答 boolean——「這個 item 此刻合不合格」。
neural reranking · 責任邊界
在一個被放大到 100s of thousands 的候選池上跑 MLP、stacked self-attention,或更結構化的 mixture of logits(MoL)。因為前三段都在同一張 GPU graph 裡、沒有跨 service 的 round-trip,reranker 才負擔得起對遠超 2,048 的候選做 neural 精算。
不知道的事:候選的 embedding 是 Int8 還是 fp16(ANN 那層已經處理完)、最終要輸出幾個(那是 scoring 決定 top-K)。它只負責「在擴大的候選池上把相關性算細」。
multi-task scoring · 責任邊界
把多個 user action 的預測(點擊、停留、互動……)合成單一 composite score,讓系統可以對更廣的目標做最佳化,最後輸出 top-K。這一步落在 forward pass 末端,吃 reranker 輸出的 representation。
不知道的事:候選是怎麼被檢索、過濾、精排到這一步的。它只負責「把多個 task head 的輸出折成一個可排序的純量」。
四個 module 同處一張 computation graph
SilverTorch 把四段檢索收進單一 forward pass;module 間的資料移動退化成 GPU 記憶體內 tensor,不再跨 service。
把整條棧視為一個 model 的代價與收益,都集中在「資料不再跨 service 移動」這一件事上。下面先解釋舊的兩段式 ANN+rank 微服務架構到底慢在哪、脆在哪,再逐一拆開四個 module 的設計。在進入細節之前,先把「Index as Model」這句口號落成一段可讀的 pseudocode——整條檢索棧在 SilverTorch 裡長這樣,每一段都只是同一個 forward 方法裡的一行 tensor 操作:
class SilverTorchRetrieval(nn.Module):
// 所有 item 的 embedding 是 model 內的 Int8 tensor,不是外部 service
item_index_int8 : Tensor[num_items, dim] // Int8,約 16-bit 的一半記憶體
ivf_centroids : Tensor[num_clusters, dim] // IVF cluster 中心
bloom_bits : Tensor[num_attrs, bloom_len] // eligibility 編成 bit 陣列
rerank_mlp : nn.Module // MLP / self-attention / MoL
task_heads : List[nn.Module] // 多個 multi-task head
def forward(user_features):
u = user_tower(user_features) // → user embedding
cand = fused_int8_ann(u, item_index_int8, // 64 probes, top-2048
ivf_centroids, probes=64, topk=2048)
cand = bloom_eligible(cand, bloom_bits) // bit-ops,無 posting list
cand = expand_pool(cand) // 放大到 100s of thousands
scores = rerank_mlp(u, cand) // neural 精排
composite = combine([h(scores) for h in task_heads])
return topk(composite, K) // 全程一張 graph,sub-100ms
關鍵不在每一行做什麼——retrieval、filter、rerank、score 的邏輯跟舊架構一樣——而在這六行全都在同一個 forward 裡、同一張 GPU 上、同一次 call 內跑完。舊架構裡,這六行之間的每一個箭頭都是一次跨 service 的網路呼叫。下面三節先講舊架構為什麼那些箭頭這麼貴,再回頭逐一拆 module。
兩段式微服務棧的三個結構性病灶
傳統檢索棧是經典的 two-stage 設計:第一段 retrieval 用 ANN 從整個 corpus 裡撈出候選,第二段 ranking 用 neural model 精排。問題不在這個邏輯分層——邏輯上 retrieval 跟 ranking 本來就該分開——而在於 Meta 把這個邏輯分層直接對應成了物理上的服務分層。user tower 是一套 service,算出 user embedding;item index 是另一套 service,持有所有 item 的 embedding 並做 ANN;filtering 走 CPU 上的 inverted index service;reranking 又是一套 GPU service。一筆 request 要在這些 service 之間來回穿梭,而這帶來三個結構性的病灶。
第一個是資料移動的延遲。每一次跨 service 的 hop 都要付網路 round-trip 加上序列化/反序列化的成本。user embedding 算完要序列化、傳給 index service;index service 回傳幾千個候選 id 加 embedding,又要序列化、傳給 filtering;filtering 回傳合格集合,再傳給 reranking。在 sub-100ms 的延遲預算裡,這些 hop 的累加會直接吃掉留給「真正計算」的時間。更糟的是,這個成本跟候選數量正相關——你想讓 reranker 看更多候選,跨 service 傳的 payload 就更大,於是候選池被網路成本反向壓制。FAISS-GPU 這類 GPU baseline 的 top-k 上限大約落在 2,048,部分原因就是把更大的候選集搬出 service 邊界本身就不划算。
這個延遲的形狀值得拆細一點,因為它不是「網路慢」這麼籠統。一次跨 service hop 的成本大致由三段組成:把記憶體裡的 tensor 序列化成 wire format(protobuf / thrift 之類)、推過網路、在對端反序列化回 tensor。對 embedding 這種「大塊浮點數陣列」,序列化與反序列化本身就是 CPU 上的 memory-bound 工作;而候選集越大,這兩段的時間越長。把這條鏈展開成偽碼,舊架構的一次檢索其實是這樣的:
// 舊的 two-stage 微服務棧——每個箭頭都是一次 RPC
u = user_tower_service.embed(user) // RPC 1: 算 user embedding
// serialize u → 過網 → deserialize
cand = index_service.ann(u, topk=2048) // RPC 2: ANN,回 2048 個候選
// serialize 2048 × (id, emb) → 過網 → deserialize
ok = filter_service.eligible(cand) // RPC 3: CPU inverted index 過濾
// serialize cand → 過網 → deserialize
ranked = rank_service.rerank(u, ok) // RPC 4: GPU reranker 精排
// serialize ok → 過網 → deserialize
return ranked
// 想讓 reranker 看 10× 候選?RPC 2/3/4 的 payload 全部 ×10。
// sub-100ms 預算裡,序列化 + 網路就先吃掉一大塊。
注意每一條 RPC 的 payload 都跟候選集大小成正比。當 reranker 想看的候選從 2,048 變成 20,480,RPC 2、3、4 傳的資料量同步放大十倍——這不是「略慢一點」,而是序列化成本與網路頻寬被候選數量直接乘上去。這就是為什麼舊架構的候選池實質上被鎖在幾千:不是 reranker 算不動,是把候選搬到 reranker 面前的成本扛不住。
第二個是版本 skew。user-tower model、item index、filtering 規則是各自獨立更新的。當 user tower 升級到新版、產出的 embedding 分布變了,而 item index 還是舊版訓練出來的 embedding,兩邊的 embedding 就不在同一個空間裡——ANN 的距離計算失去意義,recall 悄悄崩壞。要避免這種 skew,得在多個獨立部署的 service 之間做嚴格的版本協調,這在工程上是長期的痛點:任何一次模型更新都要跨團隊、跨 service 對齊上線時機。
第三個是語言與團隊的裂縫。ML engineer 寫 PyTorch、infra engineer 寫 C++,檢索棧橫跨這兩個世界。一個看似簡單的改進——例如想在 reranker 前多塞一層 attention——可能要 ML 改 PyTorch model、infra 改 C++ serving,協調下來一個迭代週期要數週。模型側的創新被 serving 側的工程節奏拖住,這是 two-stage 微服務架構最隱性、卻最致命的稅。
SilverTorch 的「Index as Model」一次性地把這三個病灶接地:四段流程同處一張 PyTorch computation graph,module 之間的資料移動退化成 GPU 記憶體內的 tensor 傳遞,沒有網路 hop、沒有序列化;版本 skew 消失,因為 index、tower、filter 是同一個 model 的一部分,一起訓練、一起部署、天然同版本;語言裂縫合上,因為整條棧都是 PyTorch,ML engineer 改 model 就等於改了 serving 行為。下面這張對照表把舊棧與 SilverTorch 的關鍵維度並排——點欄位標題可排序。
click column header to sort · 3 columns × 6 rows
| 維度 | two-stage 微服務棧 | SilverTorch(index as model) |
|---|---|---|
| 部署形狀 | user tower / index / filter / rerank 各自獨立部署 | 單一 PyTorch model,一次 forward pass |
| module 間資料移動 | 跨 service 網路 hop + 序列化 | GPU 記憶體內 tensor 傳遞 |
| eligibility filter | CPU inverted index(posting list) | model 內 Bloom index,快 291–523× |
| 候選池上限(max top-k) | FAISS-GPU ≈ 2,048 | 100s of thousands |
| throughput(req/s) | multi-service baseline = 1× | 23.7× |
| compute-cost 效率(vs CPU) | 1× | 20.9×(含 reranking 13.35×) |
互動圖表
Bloom eligibility 比 CPU inverted index 快 291–523×;消除跨 service 後候選池從 2,048 擴到十萬級。
fused Int8 ANN:item index 變成一塊 tensor
第一個 module 把 item index 從「外部 service」降維成「forward pass 讀到的一塊 tensor」。所有 item 的 embedding 以 Int8 格式存在 model 裡——Int8 是 8-bit 整數量化,相對 16-bit 浮點數大約省一半記憶體,這在 item corpus 動輒上億的規模下不是錦上添花,而是讓整個 index 能塞進單張 GPU 記憶體的前提。量化最直覺的擔憂是精度損失:把 fp16 embedding 壓成 Int8,距離計算會不會失真到傷害 recall?Meta 的實測結論是,在「64 probes and top-2048」的設定下,Int8 量化沒有可測量的 recall loss。
這裡的「probes」指的是 IVF(inverted file)式 ANN 的 cluster 探查。IVF 的做法是先把整個 embedding 空間用 k-means 之類的方法分成許多 cluster,query 進來時不必跟全部 item 算距離,只挑離 query 最近的若干個 cluster(即 probes)去搜。64 probes 意思是每次 query 探查 64 個 cluster,再從中回傳 top-2048 個最近鄰。這個 (64 probes, top-2048) 的組合是 recall 與計算量的平衡點——probe 太少 recall 會掉、probe 太多計算量上去;Meta 找到的點是:在這個平衡點上,Int8 的量化誤差被 ANN 本身的近似性質吸收掉了,量化不再是新的 recall 瓶頸。
為什麼量化誤差會被「吸收」值得多想一句。ANN 本身就是近似的——IVF 只探查 64 個 cluster、本來就會漏掉落在未探查 cluster 裡的真實最近鄰,這個近似誤差遠大於 Int8 量化引入的距離誤差。換句話說,當你已經接受了 IVF 的 recall 不是 100%,再疊一層 Int8 的小誤差,對最終 recall 的邊際影響趨近於零。這跟「在乾淨的 exact search 上做 Int8 量化」是兩回事——後者量化誤差會直接顯現,前者量化誤差淹沒在 ANN 的近似海裡。Meta 的「no measurable recall loss」要在這個前提下讀:它是針對 (64 probes, top-2048) 這個特定 operating point 的觀察,不是「Int8 對任何 ANN 設定都無損」的普適宣稱。
「fused」是另一個關鍵字。fused kernel 指的是把多個原本分離的 GPU 操作(解量化、距離計算、top-k 選取)合併進單一 kernel,避免中間結果反覆寫回 GPU global memory 再讀出來。對 ANN 這種 memory-bandwidth-bound 的工作,kernel 之間的 round-trip 往往比算術本身更貴;fused kernel 把這些中間 tensor 留在暫存器與 shared memory 裡,是把 Int8 ANN 跑進延遲預算的工程核心。把這層拿掉、退回外部 index service,你失去的不只是這顆 kernel 的效率,而是「index 與 user tower 同版本」這個性質——index 變回獨立 service 的那一刻,版本 skew 的風險就回來了。
把 IVF + Int8 + fused 三件事疊起來看,這顆 kernel 在一次 query 裡做的事大致如下:先用 user embedding 跟 IVF centroid 算距離、挑出最近的 64 個 cluster(probe),只在這 64 個 cluster 持有的 item 上算距離,邊算邊用 Int8 的整數運算解量化、邊維護一個 top-2048 的小堆。整段不寫回 global memory:
fn fused_int8_ann(u, index_int8, centroids, probes=64, topk=2048):
// 1. 挑要 probe 的 cluster——只跟 num_clusters 個 centroid 算距離
nearest_cells = top_n(dot(u, centroids), n=probes) // 64 cells
// 2. 只在這 64 個 cell 的 item 上算距離,邊解量化邊算
heap = TopK(topk) // 維持在暫存器/shared mem
for cell in nearest_cells:
for item_int8 in index_int8[cell]:
d = int8_dot(u_int8, item_int8) // 整數內積,硬體吃得很快
heap.offer(item_id, d) // 不寫回 global memory
return heap.items // top-2048
// 量化誤差被 ANN 的近似性質吸收——(64 probes, top-2048) 下無可測 recall loss
Bloom eligibility:bit operation 取代 posting list
候選撈出來之後,要過 eligibility filter——把此刻不可投放的 item 濾掉(下架的、地區限制的、使用者已看過的……)。傳統做法是 CPU 上的 inverted index:每個 eligibility attribute 對應一個 posting list(持有具備該屬性的 item id),filtering 就是對這些 posting list 做交集/差集。這個結構在 CPU 上沒問題,但搬上 GPU 就撞牆。
問題出在 posting list 的長度在不同 attribute、不同 query 之間差異劇烈。GPU 的執行單位是 warp(一組 lockstep 執行的 thread),如果同一個 warp 裡的 thread 各自要走訪長度懸殊的 posting list,就會產生 intra-warp load imbalance 與 warp divergence——快的 thread 得空等慢的 thread,硬體利用率塌掉。Meta 的原文講得很直接:「posting lists can also vary dramatically in length across attributes and queries, creating intra-warp load imbalance and warp divergence on GPUs。」posting list 這個資料結構本質上是「不規則、長度可變」的,而 GPU 要的是「規則、定長、densely parallel」的工作。
SilverTorch 換成把 eligibility 編成一個直接存在 model 裡的 Bloom index,用 simple bit operation 判斷成員資格。Bloom filter 是一個定長的 bit 陣列加幾個 hash function——判斷一個 item 合不合格,就是算幾個 hash、檢查對應 bit 是否都為 1,全程是定長的 bit 運算,沒有可變長度的走訪。把這兩種資料結構並排寫成偽碼,差別一眼可見:
// 舊:inverted index,posting list 長度可變 → warp divergence
fn eligible_inverted(item, posting_lists):
for attr in required_attrs:
if item not in posting_lists[attr]: // 走訪可變長 list
return false // 不同 thread 走的長度差很多
return true
// 新:Bloom index,定長 bit-ops → dense parallel
fn eligible_bloom(item, bloom_bits):
h1, h2, h3 = hash3(item) // 定長:永遠算 3 個 hash
return bloom_bits[h1] & bloom_bits[h2] & bloom_bits[h3]
// 每個 thread 做完全相同的工作量,warp 不 diverge
左邊的 eligible_inverted 裡,每個 thread 要走訪的 posting list 長度由 attribute 決定,同一個 warp 裡的 thread 可能一個走 10 個元素、一個走 10,000 個,硬體只能等最慢的。右邊的 eligible_bloom 裡,每個 thread 永遠算固定數量的 hash、做固定數量的 bit-and,工作量齊一,warp 不 diverge。這正好把不規則的 posting-list 交集,換成「dense, parallel work GPUs are good at」。代價是 Bloom filter 的本質:它有 false positive(可能把不合格的誤判為合格),但沒有 false negative(不會把合格的誤殺)。對 eligibility 這個場景,false positive 的後果是「某個本該濾掉的 item 漏進了 reranker」,而 reranker 後面還有 scoring,誤放的成本可控;用一點 false positive 換 GPU 上的 dense parallelism,是划算的。
效果是 Bloom index 比 CPU inverted index 快 291–523 倍。這個倍數有個區間,而區間本身透露了機制:spread 反映了不同 query、不同 attribute 組合下的 filter selectivity 差異——有多少 item 通過 eligibility 檢查會浮動,posting list 的不規則程度也隨之浮動。換句話說,舊架構在最不規則的 query 上最慢(523× 的那端),而 Bloom 的定長 bit-ops 對所有 query 都是同樣的 dense 工作,於是「最壞情況」反而是加速倍數最高的地方。這也是 GPU 加速一個老套但重要的教訓:硬體在意的不是「平均工作量」而是「工作量的方差」——一個 warp 的吞吐由它最慢的 thread 決定,把方差壓成零(定長 bit-ops)往往比把平均壓低更值錢。
還有一個容易被忽略的細節:Bloom index 是「直接存在 model 裡」的。這意味著 eligibility 規則跟 item index、user tower 一樣,是 model artifact 的一部分,一起部署、天然同版本。舊架構裡 filtering 規則是獨立 service 的狀態,更新 filter 與更新 index 的時機需要協調;當 filter 變成 model 內的 bloom_bits tensor,這個協調問題消失了——重新訓練/重新打包 model 的那一刻,index、filter、tower 一起換新。下面這張表把四個 module 在候選池規模上的角色串起來——每個 tab 是 pipeline 的一個 stage。
switch tabs to walk the candidate funnel · 4 stages
stage 1 · fused Int8 ANN
corpus → 2048top-k
整個 item corpus(上億級)是存在 model 裡的 Int8 tensor。fused kernel 在 64 個 IVF cluster 上 probe,回傳 top-2048。這是 FAISS-GPU 這類 baseline 的 top-k 上限——但對 SilverTorch 只是起點,不是終點。
stage 2 · Bloom eligibility
2048 → eligiblebit-ops
對候選跑 Bloom index 的 bit operation,濾掉此刻不可投放的 item。比 CPU inverted index 快 291–523×,且沒有 warp divergence。false positive 可容忍(後面還有 scoring),false negative 為零。
stage 3 · neural reranking
100s of thousandscandidates
關鍵反直覺處:候選池在這裡被放大到數十萬,遠超舊架構的 ~2,048。因為沒有跨 service 的資料移動成本,reranker(MLP / self-attention / MoL)才負擔得起對這麼大的候選池做 neural 精算。
stage 4 · multi-task scoring
composite → top-Koutput
把多個 user action 的預測折成單一 composite score,輸出最終 top-K。整段 1→4 在同一張 GPU graph 裡跑完,落在 sub-100ms 預算內。
互動圖表
ANN 取 top-2048、Bloom 過濾、候選池放大到十萬級讓 reranker 精算,最後 multi-task scoring 輸出 top-K。
neural reranking:候選池放大兩個數量級的反直覺
第三個 module 是 neural reranking,但這一段真正值得停下來想的,不是「用了什麼 model」(MLP、stacked self-attention、或更結構化的 mixture of logits 都用得上),而是「reranker 看得到多少候選」。舊架構裡,能送進 reranker 的候選大約是 2,048 量級——這個數字不是 reranker 算不動更多,而是把更大的候選集從 retrieval service 搬到 ranking service 的網路成本扛不住。候選池被服務邊界鎖死在幾千。
SilverTorch 把這個上限抬到 100s of thousands——兩個數量級。能這麼做,純粹是因為候選不再需要跨 service 搬運:ANN 撈出的候選、Bloom 過濾後的合格集合,都還是同一張 GPU graph 裡的 tensor,reranker 直接在記憶體裡讀。沒有序列化、沒有 round-trip,候選池能放大到多少,由 GPU 記憶體與計算預算決定,而不是由「跨 service payload 多大才划算」決定。
這個放大的意義是質變而非量變。retrieval 階段的 ANN 是近似的——它用 embedding 距離當相關性的 proxy,會漏掉「embedding 不夠近、但在精排 model 眼裡其實高度相關」的 item。候選池越大,這種 ANN 漏網之魚被撈回來的機會越高。換句話說,把 reranker 的候選池從 2,048 放大到數十萬,等於讓「便宜但粗糙的 ANN」少做篩選、讓「昂貴但精準的 neural reranker」多做判斷——把篩選的責任往精排端推。這是 two-stage 架構長年想做卻被服務邊界擋住的事:理論上你總希望 retrieval 寬鬆一點、ranking 嚴格一點,但跨 service 的資料移動成本一直在反方向拉扯。Index-as-Model 把這個拉扯拿掉了。
下面這個互動小工具讓你直接操作候選池大小這個變數——拖動 slider,看候選池在 2,048(FAISS-GPU 上限)到 300,000(SilverTorch 級)之間移動時,需要的跨 service 資料移動倍數如何爆炸,以及為什麼舊架構不可能跟上。
drag the slider to sweep candidate-pool size · 2,048 → 300,000
拖動 slider 改變候選池大小
舊架構的跨 service 搬運成本隨候選池線性膨脹;SilverTorch 候選留在 GPU graph 裡,放大兩個數量級也不付網路稅。
multi-task composite scoring:把多個目標折成一個排序純量
最後一個 module 是 scoring。推薦系統很少只優化單一目標——點擊率、觀看時長、互動率、長期留存往往同時是目標,現代做法是訓練多個 task head 各自預測一種 user action,再把這些預測組合成單一分數來排序。SilverTorch 把這個 multi-task scoring 也納進 forward pass:reranker 輸出的 representation 直接餵給多個 task head,輸出合成的 composite score,排序取 top-K。
composite score 的形狀通常是各 task head 預測的加權組合——權重反映業務上對不同行為的相對重視。寫成偽碼,scoring module 是這樣把多個 head 折成一個排序純量:
fn multi_task_score(rep, task_heads, weights):
// rep:reranker 對每個候選輸出的 representation
p_click = task_heads.click(rep) // 預測點擊
p_dwell = task_heads.dwell(rep) // 預測停留時長
p_engage = task_heads.engage(rep) // 預測互動
// 折成單一可排序純量——權重是業務目標的旋鈕
composite = (weights.click * p_click
+ weights.dwell * p_dwell
+ weights.engage * p_engage)
return composite
// 因為這一步跟 retrieval / rerank 同處一張可訓練 graph,
// 梯度能從 composite 一路回傳到更前面的 module。
把 scoring 放進同一張 graph 還有一個 retrieval 設計上的連鎖效應。傳統 two-stage 架構裡,retrieval 用的相關性(embedding 距離)跟最終 ranking 用的目標(composite multi-task score)是兩套不同的東西——retrieval 不知道下游 scoring 真正在乎什麼,只能用 embedding 近似。當 retrieval、rerank、score 同處一張可一起訓練的 graph,梯度可以從 multi-task 目標一路回傳,讓更前面的 module 的行為跟最終目標對齊。這不是這篇工程文的主軸,但它是「Index as Model」在「省延遲」之外的第二層價值:把整條棧變成一個可端到端最佳化的對象。
把這個性質跟前面三節接起來看,四個 module 之所以能彼此「不知道對方的細節」卻又一起最佳化,靠的正是「它們是同一個 model 的 module」這個事實:責任邊界由 forward pass 的資料流定義(ANN 的輸出是 eligibility 的輸入、eligibility 的輸出是 rerank 的輸入),而最佳化邊界由共享的 computation graph 定義(梯度穿過所有 module)。舊架構裡這兩種邊界是錯位的——責任靠 RPC 介面切開,最佳化卻無法跨 service 流動,於是 retrieval 只能拿 embedding 距離當 ranking 目標的拙劣 proxy。Index-as-Model 讓這兩種邊界對齊。
把倍數讀對:23.7× 吞吐、20.9× / 13.35× 成本,與它們的前提
SilverTorch 公布的核心數字有三組,把它們對齊到「相對什麼 baseline、在什麼條件下」才有意義。整理如下:
throughput 23.7× // req/s,相對 multi-service baseline
compute-cost eff 20.9× // 相對 CPU baseline,未開 reranking
13.35× // 相對 CPU baseline,開啟 reranking(較接近 production)
bloom filtering 291–523× // 相對 CPU inverted index,視 query selectivity
candidate pool 100s of thousands // vs FAISS-GPU 的 ~2,048 上限
latency budget < 100 ms // 整段 forward pass 的目標
這六個數字橫跨三個數量級——從 13.35× 到 523×——擺在同一條線性軸上後面的會把前面的壓成看不見。下面這張圖把它們畫在對數軸上,讓「吞吐」「成本效率」「filtering」「候選池」四類加速並排可比,每一條都標註了它各自是相對哪個 baseline 量的。
log-scale bar chart · 6 Meta-measured multipliers
SilverTorch 公布的六個加速倍數,畫在對數軸上(線性軸會把 13.35× 壓成 523× 旁的一條細線)
六組加速倍數跨三個數量級:throughput 23.7×、Bloom 291–523×、compute-cost 效率 13.35–20.9×。
23.7× 的吞吐是對「multi-service baseline」量的——也就是前面拆解的那套跨 service 棧。這個數字最直接地反映了「消滅資料移動」的紅利:當 module 之間不再付網路 round-trip 與序列化,同一張 GPU 在同樣時間裡能服務的 request 數量自然倍增。20.9× 與 13.35× 則是 compute-cost 效率,相對的是 CPU baseline——20.9× 是不開 reranking 的數字,13.35× 是開了 reranking 之後的。兩者的落差(20.9 → 13.35)本身就是一個訊號:neural reranking 在放大的候選池上做精算,吃掉了相當一部分效率紅利。哪個數字「對」取決於你要比的場景——如果你的系統一定要 reranking(多數 production 推薦系統都要),13.35× 才是你該拿來做容量規劃的數字,20.9× 是個沒有 reranking 的上界。
這篇來源是 Meta 的工程部落格、不是同儕審查的論文,所有倍數都是 Meta 自己在自己的 baseline 上量的,baseline 的選取與量測方法沒有外部驗證。把「100s of thousands」候選 reranking 跟「sub-100ms 延遲」並陳時,也值得記得這兩個極端數字未必是在同一個 model 規模、同一個候選池設定下同時成立的——「候選池能到數十萬」與「延遲在 100ms 內」可能各自是不同 operating point 上的觀察。部署面,SilverTorch 取代的是 FAISS 與既有的 inverted index filtering service,跑在 Feed 與影音內容(Reels)這類 family of apps 上;這篇沒有明確點名 Ads ranking 是不是也在其中。這些保留不否定 Index-as-Model 的工程價值,但讀的時候要把它當「Meta 在自家規模、自家 workload 上跑通的架構選擇」,而不是「對所有推薦系統都成立的普適結論」。
那麼,這套架構對「不是 Meta」的團隊有什麼可遷移的東西?最可遷移的不是「把 index 塞進 model」這個具體做法——多數團隊的 corpus 規模與 GPU 預算都還沒到非這麼做不可——而是底層的判斷:當你的延遲預算被「邏輯分層直接對應成物理服務分層」的資料移動成本吃掉時,把相鄰的 stage 合進同一個進程/同一張 graph、讓中間結果留在記憶體裡,往往比個別優化每個 service 更有效。這個判斷在 io_uring 合併 syscall、在 fused kernel 合併 GPU op、在這裡合併檢索 stage,是同一個形狀的工程直覺:邊界本身有成本,而最便宜的邊界是沒有邊界。
What this enables:當 item index、eligibility filter、reranker、user/item tower 全變成同一個 PyTorch model 的 module,檢索棧不再是一串靠網路串起來、各自版本、跨 PyTorch/C++ 語言邊界的微服務,而是一個可以一起訓練、一起部署、一次 forward pass 跑完、並且讓 reranker 在放大兩個數量級的候選池上做精算的單一最佳化對象——two-stage 微服務架構長年想做卻被服務邊界擋住的「retrieval 寬鬆、ranking 嚴格」,第一次在物理上變得可行。