WebGPU 추론에서 제출 횟수와 완료 대기가 속도에 미치는 영향

브라우저에서 언어 모델을 돌릴 때 GPU 에 작업을 몇 번 나눠 보내는지가 속도를 얼마나 바꾸는지 직접 쟀습니다. 제출 횟수보다 완료를 기다리는 지점이 훨씬 비쌌고, 모델과 문맥이 커질수록 그 차이는 사라졌습니다.

브라우저 안에서 언어 모델을 돌린 기록 하나를 봤습니다. 한 토큰을 만드는 계산 전체를 GPU 에 한 번에 넘기도록 바꿔 제출 횟수를 427회에서 1회로 줄였더니 초당 120토큰이 566토큰이 되었다는 내용입니다. 배수가 커서 궁금했습니다. 제출 횟수를 줄인 것이 원인인지, 아니면 그 과정에서 함께 사라진 다른 무언가가 원인인지 그 글만으로는 갈라낼 수 없었습니다. 정적 사이트에 추론을 붙여도 되는지 판단하려면 그 구분이 필요해서 직접 쟀습니다.

분리해야 할 세 가지

WebGPU 에서 계산을 GPU 에 넘길 때 세 가지 횟수가 서로 따로 정해집니다. 구분하지 않으면 무엇이 비용을 만드는지 알 수 없습니다.

  • 디스패치 횟수: 계산 셰이더를 실행하는 횟수입니다. 행렬곱 하나, 정규화 하나가 각각 한 번입니다. 계산량 자체이므로 줄이려면 커널을 합쳐야 합니다.
  • 제출 횟수: queue.submit() 을 호출한 횟수입니다. 디스패치를 몇 개씩 묶어 보낼지의 문제이고 계산량은 바뀌지 않습니다.
  • 완료 대기 횟수: 보낸 작업이 끝날 때까지 자바스크립트가 기다린 횟수입니다. onSubmittedWorkDone() 이나 결과 버퍼의 mapAsync() 가 여기에 해당합니다.

층마다 제출하던 것을 한 번으로 묶으면 제출 횟수와 완료 대기 횟수가 동시에 줄어듭니다. 앞의 기록에서 배수가 컸던 이유가 둘 중 어느 쪽인지 구분하려면 하나씩 따로 바꿔 봐야 합니다.

측정에 쓴 구성

디코더 한 층을 실제 순서대로 쪼갠 추론기를 WGSL 로 직접 썼습니다. 커널을 일부러 합치지 않았습니다. 디스패치 수를 실제 구현과 비슷하게 두는 것이 이 실험의 전제이기 때문입니다. 층 하나가 디스패치 15회이고, 여기에 임베딩 조회와 마지막 정규화, 어휘 투영, 최댓값 탐색이 붙습니다.

const perLayerOps = layerGroups.map((g) => [
  op(pNorm, gNorm, 1),
  op(pMv, g.q, mvGroups(dim)),
  op(pMv, g.k, mvGroups(kvDim)),
  op(pMv, g.v, mvGroups(kvDim)),
  op(pRope, g.rope, cdiv(ropeThreads, 64)),
  (pass, ctx) => {
    pass.setPipeline(pScore);
    pass.setBindGroup(0, g.score);
    pass.dispatchWorkgroups(cdiv(ctx, 64), heads);
  },
  op(pAv, g.av, heads),
  op(pMv, g.o, mvGroups(dim)),
  op(pRes, gRes1, cdiv(dim, 256)),
  op(pNorm, gNorm, 1),
  op(pMv, g.gate, mvGroups(ffn)),
  op(pMv, g.up, mvGroups(ffn)),
  op(pSwi, gSwi, cdiv(ffn, 256)),
  op(pMv, g.down, mvGroups(dim)),
  op(pRes, gRes2, cdiv(dim, 256)),
]);

구성을 바꿀 때 건드리는 것은 이 목록을 몇 덩어리로 나눠 인코더에 담느냐뿐입니다. 파이프라인·바인드 그룹·워크그룹 수는 모든 구성에서 같습니다.

const groups = rt.plans[mode.key];
for (let gi = 0; gi < groups.length; gi += 1) {
  const enc = rt.device.createCommandEncoder();
  const pass = enc.beginComputePass();
  for (const o of groups[gi]) o(pass, ctx);
  pass.end();
  if (gi === groups.length - 1) enc.copyBufferToBuffer(rt.outTok, 0, rt.staging, 0, 4);
  rt.device.queue.submit([enc.finish()]);
  if (mode.sync) await rt.device.queue.onSubmittedWorkDone();
}
await rt.staging.mapAsync(GPUMapMode.READ);

토큰 하나를 만들 때마다 최댓값 탐색 결과 4바이트를 읽어 다음 입력으로 씁니다. 실제 생성 과정과 같은 의존 관계를 두려는 것입니다. 그래서 어떤 구성이든 토큰마다 완료 대기가 최소 한 번은 있습니다.

구성토큰당 제출토큰당 완료 대기
디스패치마다 제출하고 매번 대기185186
디스패치마다 제출1851
층마다 제출하고 층마다 대기1213
층마다 제출121
4층씩 제출31
토큰당 한 번 제출11

제출 횟수는 0.1B급 모델(12층) 기준입니다. 24층 모델은 층마다 제출이 24회, 28층 모델은 28회입니다.

측정 환경

항목내용
GPUIntel Arc 140T(내장) · NVIDIA RTX 5080 Laptop(외장)
브라우저Chrome 153 · Edge 153 · Claude 데스크톱 앱 내장 브라우저(Chrome 152)
모델 형상0.1B급 12층 768차원 · 0.5B급 24층 896차원 · 1.5B급 28층 1536차원
토큰당 디스패치185회 · 365회 · 425회
문맥 길이128 · 512 · 2048 · 8192
측정구성마다 32토큰씩 5회 반복한 중앙값, 워밍업 6토큰 제외

가중치는 무작위 값입니다. 재는 것이 연산 시간이라 값의 내용은 결과에 영향을 주지 않습니다. 정밀도는 32비트 부동소수점으로 고정했고 양자화는 적용하지 않았습니다. 외장 GPU 는 Chrome 을 --force_high_performance_gpu 로 띄워 선택했습니다. 이 플래그가 없으면 두 브라우저 모두 내장 GPU 를 고릅니다.

제출 횟수는 시간을 거의 바꾸지 않았다

0.1B급 모델을 문맥 128에서 돌린 결과입니다. 같은 Chrome 153에서 GPU 만 바꿨습니다.

구성제출대기내장 GPU외장 GPU
디스패치마다 제출185127.30ms13.90ms
층마다 제출하고 층마다 대기121341.20ms39.81ms
층마다 제출12116.27ms4.58ms
4층씩 제출3114.59ms3.91ms
8층씩 제출2113.75ms3.62ms
토큰당 한 번 제출1114.59ms4.11ms

층마다 제출하는 것과 한 번에 묶는 것 사이에 의미 있는 차이가 없습니다. 외장 GPU 에서 12회 제출이 4.58ms, 1회 제출이 4.11ms 입니다. 반면 층마다 완료를 기다리는 구성은 39.81ms 로 크게 벌어집니다. 제출 횟수는 같고 대기 지점만 추가된 것인데 8.7배 느립니다.

디스패치마다 제출하고 매번 기다리는 극단까지 가면 더 분명해집니다. 같은 계산이 외장 GPU 에서 625.11ms, 내장 GPU 에서 595.48ms 입니다. 내장 쪽은 앱 내장 브라우저로 잰 값입니다. GPU 타임스탬프로 잰 계산 시간은 두 GPU 가 5.4배 차이인데 이 구성에서는 총 시간이 거의 같습니다. 병목이 GPU 바깥에 있다는 뜻입니다.

제출 1회와 완료 대기 1회의 비용

구성 사이의 차이를 횟수 차로 나누면 단위 비용이 나옵니다. 제출은 제출 횟수만 다른 두 구성에서, 완료 대기는 대기 지점만 다른 두 구성에서 구했습니다.

환경모델제출 1회완료 대기 1회
RTX 5080 · Chrome0.1B급53µs2.94ms
RTX 5080 · Chrome0.5B급38µs3.08ms
RTX 5080 · Chrome1.5B급32µs2.99ms
RTX 5080 · Edge0.1B급45µs2.95ms
Arc 140T · Chrome0.1B급69µs2.08ms
Arc 140T · 내장 브라우저0.1B급42µs2.48ms

완료 대기 한 번이 제출 한 번보다 50배 안팎으로 비쌉니다. 그리고 대기 비용은 GPU 성능·브라우저 종류·모델 크기와 거의 무관하게 2ms 에서 3ms 사이입니다. 자바스크립트가 GPU 작업 완료 통지를 받고 실행을 재개하는 왕복 시간이 그 정도로 고정돼 있다고 보면 수치가 설명됩니다.

값이 고정돼 있다는 것은 연산을 아무리 최적화해도 이 비용은 그대로 남는다는 뜻입니다. 층마다 기다리는 구현에서는 더 빠른 GPU 로 바꿔도 시간이 줄지 않습니다. 앞의 표에서 두 GPU 의 층마다 대기 구성이 41.20ms 와 39.81ms 로 가까운 것이 그 결과입니다.

GPU 작업이 길어지면 차이가 사라진다

대기 비용이 고정이라면 GPU 가 한 번에 하는 계산이 그보다 길어질수록 대기 비용의 비중이 줄어듭니다. 아래는 층마다 대기하는 구성을 한 번 제출 구성으로 나눈 배수입니다.

모델문맥 128문맥 512문맥 2048문맥 8192
0.1B급9.7배6.3배3.0배1.4배
0.5B급9.9배7.0배3.3배1.5배
1.5B급5.9배4.6배2.5배1.4배
내장 GPU(Arc 140T) · 앱 내장 브라우저 기준
모델문맥 128문맥 512문맥 2048문맥 8192
0.1B급3.6배2.9배1.8배1.3배
0.5B급2.7배2.3배1.5배1.2배
1.5B급1.6배1.7배1.4배1.1배

기준선은 층 하나가 GPU 에서 쓰는 시간입니다. GPU 타임스탬프로 잰 토큰당 실행 시간을 층 수로 나눠 대기 비용과 비교하면 앞의 배수가 그대로 설명됩니다.

0.17ms (0.1B급 · 문맥 128 · 외장)9.7
0.22ms (0.5B급 · 문맥 128 · 외장)9.9
0.50ms (1.5B급 · 문맥 128 · 외장)5.9
0.79ms (0.1B급 · 문맥 128 · 내장)3.6
3.52ms (0.1B급 · 문맥 8192 · 외장)1.4
8.57ms (1.5B급 · 문맥 8192 · 내장)1.1
층 하나의 GPU 시간이 대기 비용(2~3ms)을 넘어서면 손해가 사라진다

브라우저에서 돌릴 만한 크기의 모델은 대부분 연산 시간이 짧은 구간에 있습니다. 1.5B급을 외장 GPU 로 돌려도 층 하나가 0.5ms 입니다. 층마다 기다리는 구현이라면 계산보다 기다리는 시간이 더 깁니다.

첫 토큰까지 걸리는 시간

문맥을 채우는 구간(프리필)도 쟀습니다. 512토큰을 하나씩 순차로 넣은 시간입니다.

구성512토큰 처리
층마다 제출하고 층마다 대기21,437ms
층마다 제출2,583ms
토큰당 한 번 제출2,548ms

8.4배 차이입니다. 다만 이 수치는 상한으로 읽어야 합니다. 실제 추론 엔진은 문맥을 채울 때 여러 토큰을 한 번에 계산하므로 완료 대기 횟수가 토큰 수만큼 늘지 않습니다. 여기서는 그 최적화를 넣지 않아 대기 비용이 512번 그대로 쌓였습니다.

브라우저와 탭 상태

브라우저 세 가지에서 같은 값이 나왔습니다. Chrome 153과 Edge 153의 문맥 128 결과가 각각 4.11ms 와 3.73ms, 층마다 대기는 39.81ms 와 39.35ms 입니다. 셋 다 Chromium 계열이라 예상한 결과입니다. 다른 엔진은 이 기기에 설치돼 있지 않아 측정하지 못했습니다.

측정 대부분은 창이 가려진 상태에서 돌았습니다. 브라우저가 보이지 않는 탭의 실행을 늦추는 일이 있어 확인이 필요했습니다. 백그라운드 억제를 끄는 플래그를 주고 같은 구성을 다시 측정했습니다.

구성가려진 상태보이는 상태차이
층마다 제출하고 층마다 대기 · 문맥 204838.85ms39.48ms1.6%
층마다 제출 · 문맥 204813.41ms13.43ms0.1%
토큰당 한 번 제출 · 문맥 204813.56ms13.57ms0.1%

차이가 없습니다. 이 경로에는 타이머나 화면 갱신 주기가 끼어 있지 않아 탭이 가려져도 속도가 유지됩니다. GPU 타임스탬프로 잰 실행 시간이 두 상태에서 같았던 것도 같은 결론을 뒷받침합니다.

정적 사이트에 붙일 수 있는 범위

측정 결과를 실제 판단으로 옮기면 다음과 같습니다.

  • 층마다 제출해도 됩니다. 제출 한 번은 50µs 수준이라 28층 모델이라도 1.4ms 입니다. 코드를 읽기 쉽게 유지하는 편이 낫습니다.
  • 핵심은 층 사이에서 완료를 기다리지 않는 것입니다. 중간 결과를 CPU 로 읽어 확인하는 코드가 하나라도 남아 있으면 그 지점마다 3ms 가 추가됩니다.
  • 디버깅 코드를 지우고 다시 측정하십시오. 층마다 출력을 읽어 보던 코드를 남긴 채로 측정하면 GPU 성능과 무관하게 같은 값이 나옵니다. 두 GPU 에서 값이 비슷하게 나오면 그 지점을 의심할 만합니다.
  • 작은 모델일수록 이 문제가 큽니다. 0.1B급을 외장 GPU 로 돌릴 때 층 하나가 0.17ms 입니다. 대기 한 번이 층 17개 분량입니다.
  • 문맥이 길어지면 신경 쓸 이유가 줄어듭니다. 문맥 8192에서는 어느 구성이든 1.4배 안쪽입니다. 그 구간에서는 커널 자체를 최적화하는 편이 낫습니다.

이 측정이 답하지 못하는 것

  • 커널이 최적화돼 있지 않습니다. 행렬-벡터 곱은 워크그룹 하나가 출력 4행을 맡는 단순한 구현입니다. 실제 엔진보다 GPU 시간이 길어 대기 비용의 비중이 실제보다 작게 보일 수 있습니다.
  • 32비트 부동소수점만 썼습니다. 16비트나 양자화를 적용하면 GPU 시간이 줄어 대기 비용의 비중은 오히려 커집니다.
  • 브라우저 엔진이 한 계열뿐입니다. Chromium 계열 세 종만 쟀습니다. 다른 엔진의 완료 통지 비용은 다를 수 있습니다.
  • 문맥을 채우는 구간을 순차로만 처리했습니다. 여러 토큰을 한 번에 계산하는 실제 방식과 다릅니다.
  • 가중치가 무작위 값입니다. 출력의 품질은 보지 않았고 연산 시간만 쟀습니다.