GPUサーバーは要らなくなる?ChromeをAI推論ノードにして社内PCをWebGPUクラスタ化する
会社の中を見渡すと、意外なほど多くのGPUが眠っている。
- GeForce RTXを積んだ開発PC
- Intel内蔵GPUを搭載した事務PC
- Apple SiliconのMac
- GPUを搭載したノートPC
- 昼休みや夜間にはほぼ使われないPC
通常、これらはそれぞれ独立した端末だ。
しかし2026年現在、ChromeからWebGPUを利用してLLMをGPU推論できる。
ならば、こう考えられないだろうか。
社内にあるChromeを全部、AI推論ノードとして束ねられないか。
例えば、
Broker
/ | \
/ | \
Chrome Chrome Chrome
PC-01 PC-02 Mac-01
RTX GPU Intel GPU Apple GPU
↓ ↓ ↓
WebGPU WebGPU WebGPU
↓ ↓ ↓
LLM LLM LLM
という構成である。
今回はこれを本当に作る。
中央に置くのは巨大なGPUサーバーではない。
Node.jsで動く小さなBrokerだけ。
実際のAI推論は、各PCで開いているChromeが担当する。
2026年、ブラウザ内LLMは研究のおもちゃではなくなった
2026年5月に公開された「Llamas on the Web」、通称LlamaWebは、llama.cppへWebGPUバックエンドを実装した研究である。
研究では16デバイス、8ベンダー、10種類のモデル、複数のweight formatで評価している。
既存のブラウザ向けLLMフレームワークとの比較では、複数の環境でメモリ使用量を29〜33%削減し、4種類の異なるGPUでdecode throughputを45〜69%改善したと報告されている。
さらに現在のllama.cpp本体にもWebGPU backendが入り、ブラウザからWebGPUを使ってGGUFモデルをGPU実行する方向が正式に進んでいる。
つまり、
Browser
↓
WebGPU
↓
NVIDIA / AMD / Intel / Apple GPU
↓
LLM inference
が現実になった。
今回はLlamaWebではなくWebLLMをクラスタ基盤に使う
LlamaWebは非常に面白い。
ただし今回の目的はブラウザ推論エンジンそのものを開発することではない。
複数ブラウザをクラスタとして束ねることだ。
そこで実装にはWebLLMを使う。
WebLLMはWebGPUを利用してブラウザ内だけでLLM推論を実行できるオープンソース基盤で、OpenAI互換Chat Completion APIも持っている。
さらにWeb Workerから実行できる。
今回の構成には都合がいい。
Browser UI Thread
↓
Web Worker
↓
WebLLM
↓
WebGPU
↓
Local GPU
最初に重要なこと:これはMulti-GPU推論ではない
ここはかなり重要なので明確にしておく。
今回作るのは、巨大な1個のLLMを複数PCのGPUへ分割して載せるTensor Parallelismではない。
今回やらないもの
Model
├─ Layer 1-10 → RTX5060
├─ Layer 11-20 → Intel GPU
└─ Layer 21-30 → Apple GPU
今回実装するのはRequest-level Horizontal Scalingである。
Job A → PC-01
Job B → PC-02
Job C → Mac-01
Job D → PC-01
Job E → PC-03
各PCがモデルを一式持ち、独立した推論要求を処理する。
Webサーバーの水平分散に近い。
この構成なら異なるGPUメーカーでも問題になりにくい。
RTX5060
Intel Arc
Intel UHD
Apple M3
AMD Radeon
↓
全部WebGPU
今回作るクラスタ
┌─────────────┐
User / Application ─────▶│ Broker │
│ Node.js │
└──────┬──────┘
│
┌─────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Chrome #1 │ │ Chrome #2 │ │ Chrome #3 │
│ RTX 5060 │ │ Intel GPU │ │ Apple GPU │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
WebGPU WebGPU WebGPU
│ │ │
WebLLM WebLLM WebLLM
│ │ │
LLM LLM LLM
Brokerは推論しない。
仕事は5つだけ。
- Worker登録
- Worker状態監視
- ジョブ受付
- 最適Worker選択
- 推論結果回収
VRAM空き容量で振り分ける、は実はできない
当初、私はスケジューラに次を使おうと考えた。
VRAM free
model warm/cold
tokens/sec
network latency
queue depth
しかしWebGPUを調べると、ここに重要な制約がある。
ブラウザはGPUの正確な空きVRAM容量をWebページへ公開していない。
WebGPUから取得できるのは、GPUAdapterの情報や、
maxBufferSize
maxStorageBufferBindingSize
maxComputeWorkgroupStorageSize
などの制限値である。
しかもブラウザによってはfingerprinting対策として、実機そのものの値ではなくtier化された値を返す。
したがって「RTX5060のVRAMが現在6.2GB空いている」というような情報をWebGPUだけから正確に取得する設計はできない。
そこで今回のSchedulerでは、別の値を使う。
モデルが既にload済みか
+
実測 decode tokens/sec
+
Broker RTT
+
Worker busy/idle
+
過去の失敗回数
+
GPU buffer limits
机上のGPUスペックではなく、実際にそのPCでLLMを走らせた速度を使う。
こちらの方が異種GPUクラスタでは合理的である。
さらに重要なのがWarm Model
LLMでは推論時間だけを考えてはいけない。
モデルを最初にロードする処理が重い。
Cold Worker
Job
↓
Model download/cache read
↓
WebGPU initialization
↓
Weight loading
↓
Inference
Warm Worker
Job
↓
Inference
例えばPC-01にLlama 3.2 1Bが既にロードされているなら、多少GPUが遅くてもPC-01へ投げた方が速い場合がある。
今回のSchedulerではWarm Modelに非常に大きなボーナスを与える。
Schedulerの計算式
score =
warmModelBonus
+ decodeTokensPerSec × 4
+ completedBonus
- brokerRTT × 0.25
- failurePenalty
実装では、
const warmBonus =
worker.warmModel === job.model
? 500
: 0;
const speedScore =
Math.min(decodeTokensPerSec, 200) * 4;
const rttPenalty =
Math.min(brokerRttMs, 1000) * 0.25;
const failurePenalty =
worker.failed * 15;
としている。
この式は絶対的なものではない。
重要なのは、スペック表ではなく実測値をSchedulerへフィードバックすることである。
実装する
必要なのはNode.js 22以降とChrome。
Broker自体にはnpm packageすら使わない。
Node.js標準モジュールだけで構築する。
browser-ai-cluster/
├── server.mjs
└── public/
├── admin.html
├── worker.html
├── worker-node.js
└── inference-worker.js
WebSocketを使わずHTTP Pollingにした理由
今回はあえてWebSocketも外した。
AI推論ジョブは数秒〜数十秒かかる。
ミリ秒単位でジョブ通知する必要はない。
Worker
↓ 1秒
GET /jobs/next
↓
Broker
で十分である。
これによってBrokerをほぼNode標準機能だけにできる。
server.mjs
import http from "node:http";
import https from "node:https";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
const __dirname =
path.dirname(fileURLToPath(import.meta.url));
const publicDir =
path.join(__dirname, "public");
const port =
Number(process.env.PORT || 8443);
const certPath =
process.env.TLS_CERT || "";
const keyPath =
process.env.TLS_KEY || "";
const workers = new Map();
const jobs = new Map();
const queue = [];
const now = () => Date.now();
const createId = prefix =>
`${prefix}_${crypto.randomUUID()}`;
function sendJson(res, status, body) {
const data =
Buffer.from(JSON.stringify(body));
res.writeHead(status, {
"content-type":
"application/json; charset=utf-8",
"content-length": data.length,
"cache-control": "no-store",
});
res.end(data);
}
async function readJson(req) {
let raw = "";
for await (const chunk of req) {
raw += chunk;
if (raw.length > 1_000_000) {
throw new Error("payload too large");
}
}
return raw
? JSON.parse(raw)
: {};
}
function workerScore(worker, job) {
if (
worker.status !== "ready" ||
worker.currentJobId
) {
return -Infinity;
}
if (
now() - worker.lastSeen > 15000
) {
return -Infinity;
}
const warmBonus =
worker.warmModel === job.model
? 500
: 0;
const decode =
Number(
worker.metrics
?.decodeTokensPerSec || 0
);
const speedScore =
Math.min(decode, 200) * 4;
const rttPenalty =
Math.min(
Number(
worker.metrics
?.brokerRttMs || 0
),
1000
) * 0.25;
const failurePenalty =
worker.failed * 15;
const completionBonus =
Math.min(
worker.completed,
50
) * 0.5;
return (
warmBonus
+ speedScore
+ completionBonus
- rttPenalty
- failurePenalty
);
}
function assignJobs() {
for (
const jobId of [...queue]
) {
const job =
jobs.get(jobId);
if (
!job ||
job.status !== "queued"
) {
continue;
}
let best = null;
let bestScore = -Infinity;
for (
const worker
of workers.values()
) {
const score =
workerScore(
worker,
job
);
if (
score > bestScore
) {
best = worker;
bestScore = score;
}
}
if (!best) {
continue;
}
job.status =
"assigned";
job.assignedWorkerId =
best.id;
job.assignedAt =
now();
job.schedulerScore =
bestScore;
best.status =
"assigned";
best.currentJobId =
job.id;
const index =
queue.indexOf(job.id);
if (index >= 0) {
queue.splice(index, 1);
}
}
}
function publicWorker(worker) {
return {
id: worker.id,
name: worker.name,
status: worker.status,
lastSeen: worker.lastSeen,
gpu: worker.gpu,
limits: worker.limits,
warmModel: worker.warmModel,
metrics: worker.metrics,
currentJobId:
worker.currentJobId,
completed:
worker.completed,
failed:
worker.failed,
};
}
function expireWorkers() {
const cutoff =
now() - 20000;
for (
const worker
of workers.values()
) {
if (
worker.lastSeen >= cutoff
) {
continue;
}
if (
worker.currentJobId
) {
const job =
jobs.get(
worker.currentJobId
);
if (
job &&
["assigned", "running"]
.includes(job.status)
) {
job.status =
"queued";
job.assignedWorkerId =
null;
queue.push(job.id);
}
}
worker.status =
"offline";
worker.currentJobId =
null;
}
assignJobs();
}
setInterval(
expireWorkers,
5000
).unref();
function serveStatic(
req,
res
) {
let pathname =
new URL(
req.url,
"http://localhost"
).pathname;
if (pathname === "/") {
pathname =
"/admin.html";
}
if (
pathname === "/worker"
) {
pathname =
"/worker.html";
}
const safe =
path.normalize(pathname)
.replace(
/^([.][.][/\\])+/,
""
);
const filePath =
path.join(
publicDir,
safe
);
if (
!filePath.startsWith(
publicDir
)
) {
return false;
}
if (
!fs.existsSync(filePath) ||
fs.statSync(filePath)
.isDirectory()
) {
return false;
}
const ext =
path.extname(filePath);
const types = {
".html":
"text/html; charset=utf-8",
".js":
"text/javascript; charset=utf-8",
};
const data =
fs.readFileSync(filePath);
res.writeHead(200, {
"content-type":
types[ext] ||
"application/octet-stream",
"content-length":
data.length,
"cache-control":
"no-store",
});
res.end(data);
return true;
}
async function handler(
req,
res
) {
const url =
new URL(
req.url,
"http://localhost"
);
const pathname =
url.pathname;
try {
if (
req.method === "GET" &&
pathname === "/api/status"
) {
assignJobs();
return sendJson(
res,
200,
{
workers:
[...workers.values()]
.map(publicWorker),
jobs:
[...jobs.values()]
.sort(
(a, b) =>
b.createdAt
- a.createdAt
)
.slice(0, 100),
queued:
queue.length,
}
);
}
if (
req.method === "POST" &&
pathname ===
"/api/workers/register"
) {
const body =
await readJson(req);
const workerId =
body.id &&
workers.has(body.id)
? body.id
: createId("worker");
const old =
workers.get(workerId);
workers.set(
workerId,
{
id:
workerId,
name:
String(
body.name ||
old?.name ||
"Chrome Worker"
),
status:
old?.currentJobId
? old.status
: "ready",
lastSeen:
now(),
gpu:
body.gpu ||
old?.gpu ||
{},
limits:
body.limits ||
old?.limits ||
{},
warmModel:
body.warmModel ??
old?.warmModel ??
null,
metrics: {
...(old?.metrics || {}),
...(body.metrics || {}),
},
currentJobId:
old?.currentJobId ||
null,
completed:
old?.completed || 0,
failed:
old?.failed || 0,
}
);
assignJobs();
return sendJson(
res,
200,
{
id: workerId
}
);
}
const heartbeat =
pathname.match(
/^\/api\/workers\/([^/]+)\/heartbeat$/
);
if (
req.method === "POST" &&
heartbeat
) {
const worker =
workers.get(
heartbeat
);
if (!worker) {
return sendJson(
res,
404,
{
error:
"worker not found"
}
);
}
const body =
await readJson(req);
worker.lastSeen =
now();
worker.gpu =
body.gpu ||
worker.gpu;
worker.limits =
body.limits ||
worker.limits;
worker.warmModel =
body.warmModel ??
worker.warmModel;
worker.metrics = {
...worker.metrics,
...(body.metrics || {}),
};
if (
!worker.currentJobId
) {
worker.status =
body.status ||
"ready";
}
assignJobs();
return sendJson(
res,
200,
{
ok: true,
serverTime: now(),
}
);
}
const next =
pathname.match(
/^\/api\/workers\/([^/]+)\/jobs\/next$/
);
if (
req.method === "GET" &&
next
) {
const worker =
workers.get(next
);
if (!worker) {
return sendJson(
res,
404,
{
error:
"worker not found"
}
);
}
worker.lastSeen =
now();
assignJobs();
if (
!worker.currentJobId
) {
return sendJson(
res,
200,
{
job: null
}
);
}
const job =
jobs.get(
worker.currentJobId
);
if (
job.status ===
"assigned"
) {
job.status =
"running";
job.startedAt =
now();
worker.status =
"running";
}
return sendJson(
res,
200,
{
job
}
);
}
if (
req.method === "POST" &&
pathname ===
"/api/jobs"
) {
const body =
await readJson(req);
if (
!String(
body.prompt || ""
).trim()
) {
return sendJson(
res,
400,
{
error:
"prompt required"
}
);
}
const job = {
id:
createId("job"),
prompt:
String(body.prompt),
system:
String(
body.system ||
"You are a concise, helpful assistant."
),
model:
String(
body.model ||
"SmolLM2-360M-Instruct-q4f32_1-MLC"
),
maxTokens:
Math.max(
8,
Math.min(
Number(
body.maxTokens ||
128
),
1024
)
),
temperature:
Math.max(
0,
Math.min(
Number(
body.temperature ??
0.2
),
2
)
),
status:
"queued",
createdAt:
now(),
assignedWorkerId:
null,
result:
null,
metrics:
null,
error:
null,
};
jobs.set(
job.id,
job
);
queue.push(
job.id
);
assignJobs();
return sendJson(
res,
201,
job
);
}
const result =
pathname.match(
/^\/api\/jobs\/([^/]+)\/result$/
);
if (
req.method === "POST" &&
result
) {
const job =
jobs.get(result
);
if (!job) {
return sendJson(
res,
404,
{
error:
"job not found"
}
);
}
const body =
await readJson(req);
const worker =
workers.get(
body.workerId
);
if (
!worker ||
job.assignedWorkerId
!== worker.id
) {
return sendJson(
res,
409,
{
error:
"worker/job mismatch"
}
);
}
job.status =
body.error
? "failed"
: "completed";
job.completedAt =
now();
job.result =
body.result ??
null;
job.metrics =
body.metrics ||
null;
job.error =
body.error ||
null;
worker.currentJobId =
null;
worker.status =
"ready";
worker.lastSeen =
now();
worker.warmModel =
body.model ||
worker.warmModel;
worker.metrics = {
...worker.metrics,
...(body.metrics || {}),
};
if (body.error) {
worker.failed += 1;
} else {
worker.completed += 1;
}
assignJobs();
return sendJson(
res,
200,
{
ok: true
}
);
}
if (
serveStatic(
req,
res
)
) {
return;
}
sendJson(
res,
404,
{
error: "not found"
}
);
} catch (error) {
console.error(error);
sendJson(
res,
500,
{
error:
String(
error.message ||
error
)
}
);
}
}
let server;
if (
certPath &&
keyPath
) {
server =
https.createServer(
{
cert:
fs.readFileSync(
certPath
),
key:
fs.readFileSync(
keyPath
),
},
handler
);
} else {
server =
http.createServer(
handler
);
}
server.listen(
port,
"0.0.0.0",
() => {
const scheme =
certPath &&
keyPath
? "https"
: "http";
console.log(
`Browser AI Cluster: ${scheme}://0.0.0.0:${port}`
);
if (
scheme === "http"
) {
console.log(
"LANのWebGPU WorkerではHTTPSを使用してください"
);
}
}
);
推論処理をUI threadから分離する
LLM推論は重い。
メインのJavaScript threadで実行するとWorker画面まで固まる可能性がある。
そのため、WebLLM自体はDedicated Web Worker内で動かす。
Chrome Main Thread
↓
postMessage()
↓
Inference Web Worker
↓
WebLLM
↓
WebGPU
↓
GPU
public/inference-worker.js
import {
CreateMLCEngine
} from "https://esm.run/@mlc-ai/web-llm@0.2.84";
let engine = null;
let loadedModel = null;
async function ensureModel(
model
) {
if (
engine &&
loadedModel === model
) {
return;
}
if (engine) {
try {
await engine.unload();
} catch {}
engine = null;
loadedModel = null;
}
self.postMessage({
type: "loading",
model,
progress: 0,
});
engine =
await CreateMLCEngine(
model,
{
initProgressCallback:
progress => {
self.postMessage({
type:
"loading",
model,
progress:
Number(
progress.progress ||
0
),
text:
String(
progress.text ||
""
),
});
},
}
);
loadedModel =
model;
self.postMessage({
type: "loaded",
model,
});
}
async function infer(job) {
await ensureModel(
job.model
);
const started =
performance.now();
const response =
await engine
.chat
.completions
.create({
messages: [
{
role:
"system",
content:
job.system,
},
{
role:
"user",
content:
job.prompt,
},
],
temperature:
job.temperature,
max_tokens:
job.maxTokens,
stream:
false,
stream_options: {
include_usage:
true,
},
enable_latency_breakdown:
true,
});
const elapsedMs =
performance.now()
- started;
const text =
response
.choices
?.[0]
?.message
?.content ||
"";
const usage =
response.usage ||
{};
const extra =
usage.extra ||
{};
let gpuVendor =
null;
let maxStorageBufferBindingSize =
null;
try {
gpuVendor =
await engine
.getGPUVendor();
} catch {}
try {
maxStorageBufferBindingSize =
await engine
.getMaxStorageBufferBindingSize();
} catch {}
const completionTokens =
Number(
usage.completion_tokens ||
extra.completion_tokens ||
0
);
const decodeTokensPerSec =
Number(
extra.decode_tokens_per_s ||
(
completionTokens &&
elapsedMs
? completionTokens /
(
elapsedMs /
1000
)
: 0
)
);
return {
text,
model:
loadedModel,
metrics: {
elapsedMs,
promptTokens:
Number(
usage.prompt_tokens ||
extra.prompt_tokens ||
0
),
completionTokens,
decodeTokensPerSec,
prefillTokensPerSec:
Number(
extra.prefill_tokens_per_s ||
0
),
ttftMs:
Number(
extra.ttft_s ||
0
) * 1000,
gpuVendor,
maxStorageBufferBindingSize,
},
};
}
self.onmessage =
async event => {
const message =
event.data;
if (
message?.type !==
"infer"
) {
return;
}
try {
const result =
await infer(
message.job
);
self.postMessage({
type:
"result",
jobId:
message.job.id,
...result,
});
} catch (error) {
self.postMessage({
type:
"error",
jobId:
message.job.id,
model:
loadedModel ||
message.job.model,
error:
String(
error?.stack ||
error?.message ||
error
),
});
}
};
使用するモデル
最初の動作確認には、
SmolLM2-360M-Instruct-q4f32_1-MLC
を使う。
WebLLMの現在のprebuilt model listに含まれており、必要VRAMの目安も600MB未満なので、クラスタの初期確認に向いている。
動作後は、
Llama-3.2-1B-Instruct-q4f16_1-MLC
Llama-3.2-3B-Instruct-q4f16_1-MLC
などへ変更できる。
Worker本体を作る
ブラウザ側はまずWebGPU Adapterを調べる。
const adapter =
await navigator.gpu
.requestAdapter({
powerPreference:
"high-performance",
});
const info =
adapter.info;
console.log(
info.vendor
);
console.log(
adapter
.limits
.maxBufferSize
);
ここで取得した値をBrokerへ登録する。
public/worker-node.js
const params =
new URLSearchParams(
location.search
);
const workerName =
params.get("name") ||
`Browser-${crypto.randomUUID()
.slice(0, 6)}`;
let workerId =
localStorage.getItem(
"browser-ai-worker-id"
);
let warmModel =
null;
let busy =
false;
let gpu =
{};
let limits =
{};
let metrics = {
decodeTokensPerSec:
0,
brokerRttMs:
0,
};
const inference =
new Worker(
"/inference-worker.js",
{
type: "module",
}
);
const pending =
new Map();
async function inspectGpu() {
if (!navigator.gpu) {
throw new Error(
"WebGPUが利用できません"
);
}
const adapter =
await navigator.gpu
.requestAdapter({
powerPreference:
"high-performance",
});
if (!adapter) {
throw new Error(
"GPUAdapter取得失敗"
);
}
const info =
adapter.info ||
{};
gpu = {
vendor:
info.vendor ||
"",
architecture:
info.architecture ||
"",
device:
info.device ||
"",
description:
info.description ||
"",
};
limits = {
maxBufferSize:
adapter
.limits
.maxBufferSize,
maxStorageBufferBindingSize:
adapter
.limits
.maxStorageBufferBindingSize,
maxComputeWorkgroupStorageSize:
adapter
.limits
.maxComputeWorkgroupStorageSize,
};
}
async function post(
url,
body
) {
const response =
await fetch(
url,
{
method: "POST",
headers: {
"content-type":
"application/json",
},
body:
JSON.stringify(
body
),
}
);
if (!response.ok) {
throw new Error(
await response.text()
);
}
return response.json();
}
async function register() {
const data =
await post(
"/api/workers/register",
{
id:
workerId,
name:
workerName,
gpu,
limits,
warmModel,
metrics,
}
);
workerId =
data.id;
localStorage.setItem(
"browser-ai-worker-id",
workerId
);
}
async function heartbeat() {
const started =
performance.now();
await post(
`/api/workers/${workerId}/heartbeat`,
{
status:
busy
? "running"
: "ready",
gpu,
limits,
warmModel,
metrics,
}
);
metrics.brokerRttMs =
performance.now()
- started;
}
function runInference(
job
) {
return new Promise(
(resolve, reject) => {
pending.set(
job.id,
{
resolve,
reject,
}
);
inference.postMessage({
type:
"infer",
job,
});
}
);
}
inference.onmessage =
event => {
const message =
event.data;
if (
message.type ===
"loaded"
) {
warmModel =
message.model;
return;
}
const item =
pending.get(
message.jobId
);
if (!item) {
return;
}
pending.delete(
message.jobId
);
if (
message.type ===
"result"
) {
item.resolve(
message
);
} else {
item.reject(
new Error(
message.error ||
"inference failed"
)
);
}
};
async function poll() {
if (busy) {
return;
}
const response =
await fetch(
`/api/workers/${workerId}/jobs/next`,
{
cache:
"no-store",
}
);
const data =
await response.json();
if (!data.job) {
return;
}
busy =
true;
const job =
data.job;
try {
const result =
await runInference(
job
);
warmModel =
result.model;
metrics = {
...metrics,
...(result.metrics || {}),
};
await post(
`/api/jobs/${job.id}/result`,
{
workerId,
result:
result.text,
model:
result.model,
metrics:
result.metrics,
}
);
} catch (error) {
await post(
`/api/jobs/${job.id}/result`,
{
workerId,
model:
warmModel ||
job.model,
error:
String(
error.message ||
error
),
metrics,
}
);
} finally {
busy =
false;
}
}
async function main() {
await inspectGpu();
await register();
await heartbeat();
setInterval(
heartbeat,
5000
);
setInterval(
poll,
1000
);
}
main().catch(
error =>
console.error(error)
);
worker.html
最低限ならこれだけでもよい。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1"
>
<title>
Browser AI Worker
</title>
</head>
<body>
<h1>
Browser AI Worker
</h1>
<p>
このChromeは
WebGPU AI Workerとして
Brokerへ接続しています。
</p>
<script
type="module"
src="/worker-node.js"
>
</script>
</body>
</html>
Brokerを起動する
node server.mjs
同じPCで試すならChromeから、
http://localhost:8443/worker?name=PC-01
を開く。
localhostはブラウザ上で特別扱いされるため、WebGPUを利用できる。
社内LANではHTTPSが必要
ここは非常に重要。
WebGPUはSecure Contextでしか利用できない。
したがって別PCから、
http://192.168.1.10:8443/worker
のようにアクセスしても、通常はWebGPUが有効にならない。
LANクラスタでは、
https://ai-broker.company.local:8443/worker
のようなHTTPS化が必要である。
社内検証ならmkcertなどで社内端末から信頼できる証明書を作成してもよい。
mkcert \
ai-broker.company.local \
192.168.1.10
生成したcertとkeyを指定する。
TLS_CERT=./cert.pem \
TLS_KEY=./key.pem \
PORT=8443 \
node server.mjs
本番運用なら正式な社内CAやReverse Proxyを使った方がよい。
Workerを3台起動する
例えば、
PC-01
https://ai-broker.company.local:8443/worker?name=RTX5060-PC
PC-02
https://ai-broker.company.local:8443/worker?name=Intel-PC
Mac
https://ai-broker.company.local:8443/worker?name=M3-Mac
を開く。
これだけで3台がWorkerとしてBrokerへ登録される。
ジョブを投入する
APIは単純。
curl \
-X POST \
https://ai-broker.company.local:8443/api/jobs \
-H "content-type: application/json" \
-d '{
"model":
"SmolLM2-360M-Instruct-q4f32_1-MLC",
"prompt":
"WebGPUの利点を3点説明してください",
"maxTokens":
128,
"temperature":
0.2
}'
Brokerはその時点で利用可能なWorkerを採点する。
RTX5060-PC
warm model +500
55 tok/s +220
RTT 2ms -0.5
score ≒ 719.5
Intel-PC
cold model 0
22 tok/s +88
RTT 3ms -0.75
score ≒ 87.25
この場合、RTX5060-PCへジョブを振る。
推論が終わると実測値がBrokerへ返る
{
"elapsedMs": 3120,
"promptTokens": 28,
"completionTokens": 128,
"decodeTokensPerSec": 41.0,
"prefillTokensPerSec": 213.5,
"ttftMs": 181
}
この値が次のScheduler判断へ使われる。
推論
↓
実測性能
↓
Broker
↓
Scheduler更新
↓
次のJob割当
つまりクラスタ自身が、そのPCが実際にどれくらい速いかを学習していく。
モデルは毎回ダウンロードされるのか
最初のロードではモデルデータのダウンロードが発生する。
しかしWebLLMはブラウザCache API、IndexedDB、OPFSなどをモデルキャッシュに利用できる。
そのため一度ロードしたモデルは、次回以降キャッシュから再利用できる。
First run
Internet
↓
Model
↓
Browser Cache
↓
WebGPU
Second run
Browser Cache
↓
WebGPU
ただしブラウザのStorage削除やサイトデータ削除を行えば消える。
社内AIとして面白いのはここ
モデルの初回取得後、推論時に入力文をクラウドLLM APIへ送る必要はない。
社内業務システム
↓
Broker
↓
社内Chrome Worker
↓
WebGPU
↓
Local Model
プロンプトと回答は社内ネットワークの中だけで処理する構成を作れる。
例えば、
- 社内文章の要約
- 定型メール生成
- 議事録整理
- マニュアル検索
- 軽量RAG
- コード説明
- 分類処理
- JSON生成
程度なら、巨大なクラウドGPUを必ずしも必要としない可能性が出てくる。
ただし「GPUサーバー不要」は言い過ぎでもある
タイトルでは「GPUサーバーは要らなくなる?」と書いた。
結論は、全部は無理。
大規模LLM、高コンテキスト、大量batch、学習処理などは依然として専用GPUサーバーが圧倒的に有利である。
ブラウザクラスタが面白いのは別の場所だ。
すでに会社に存在している計算資源を、追加設備なしでAI推論基盤へ変えられる。
10台あればどうなるか
仮に1台20 tokens/secしか出ないPCでも、10台あれば独立した10リクエストを同時処理できる。
1 node
20 tok/s
1 request
10 nodes
20 tok/s × 10 jobs
=
10 simultaneous requests
一つの回答生成速度が200 tok/sになるわけではない。
しかしクラスタ全体のThroughputは上げられる。
この違いは重要。
さらに面白いのが異種GPU
PC-01
RTX5060
55 tok/s
PC-02
Intel Arc
28 tok/s
PC-03
Intel UHD
8 tok/s
Mac-01
Apple M3
34 tok/s
通常のGPUクラスタならCUDA、ROCm、Metalなど実行backendがバラバラになる。
WebGPUならブラウザ側がその差を吸収する。
Application
↓
WebGPU
/ | \
D3D12 Metal Vulkan
| | |
NVIDIA Apple AMD/Intel
WebGPUが本当に面白いのはここだと思う。
GPUを単なる描画装置ではなく、Webから利用できるポータブルな計算装置へ変えている。
ブラウザを閉じたらどうなる?
Workerは5秒ごとにHeartbeatを送る。
Brokerは20秒以上Heartbeatが来ないWorkerをofflineと判断する。
Worker
↓ heartbeat
Broker
Chrome close
↓
20 sec timeout
↓
Worker offline
もしJob実行中にWorkerが消えた場合、BrokerはJobをqueueへ戻す。
PC-01 crashed
↓
Job requeue
↓
Scheduler
↓
PC-02
簡易的ではあるが分散処理らしくなってきた。
Broker単体の動作確認もできるようにする
いきなりGPU推論まで試すと、問題がBrokerなのかWebGPUなのかWebLLMなのか分からない。
そこでMock Workerを作る。
mock-worker.mjs
const base =
process.argv
||
"http://127.0.0.1:8443";
const name =
process.argv
||
"Mock-RTX5060";
let workerId =
null;
let busy =
false;
const metrics = {
decodeTokensPerSec:
55,
brokerRttMs:
2,
};
async function post(
path,
body
) {
const response =
await fetch(
base + path,
{
method:
"POST",
headers: {
"content-type":
"application/json",
},
body:
JSON.stringify(
body
),
}
);
return response.json();
}
const registration =
await post(
"/api/workers/register",
{
name,
gpu: {
vendor:
"NVIDIA",
description:
"Mock RTX 5060",
},
limits: {
maxBufferSize:
1073741824,
},
warmModel:
"SmolLM2-360M-Instruct-q4f32_1-MLC",
metrics,
}
);
workerId =
registration.id;
console.log(
"registered",
workerId
);
setInterval(
() => {
post(
`/api/workers/${workerId}/heartbeat`,
{
status:
busy
? "running"
: "ready",
metrics,
warmModel:
"SmolLM2-360M-Instruct-q4f32_1-MLC",
}
);
},
2000
);
setInterval(
async () => {
if (busy) {
return;
}
const response =
await fetch(
`${base}/api/workers/${workerId}/jobs/next`
);
const data =
await response.json();
if (!data.job) {
return;
}
busy =
true;
await new Promise(
resolve =>
setTimeout(
resolve,
100
)
);
await post(
`/api/jobs/${data.job.id}/result`,
{
workerId,
model:
data.job.model,
result:
`MOCK RESULT: ${data.job.prompt}`,
metrics: {
...metrics,
elapsedMs:
100,
completionTokens:
12,
},
}
);
busy =
false;
},
500
);
Mock Workerを2台起動する
node mock-worker.mjs \
http://127.0.0.1:8443 \
Mock-GPU-01
別ターミナルで、
node mock-worker.mjs \
http://127.0.0.1:8443 \
Mock-GPU-02
次にJobを投入する。
curl \
-X POST \
http://127.0.0.1:8443/api/jobs \
-H "content-type: application/json" \
-d '{
"model":
"SmolLM2-360M-Instruct-q4f32_1-MLC",
"prompt":
"hello cluster",
"maxTokens":
32
}'
実際にここまで動作確認した
今回の記事を書くにあたり、上記Brokerを実際にNode.js 22.16.0で起動した。
Mock Workerを2台登録し、Jobを1件投入したところ、BrokerはWorkerのscoreを計算し、1台へ割り当てた。
確認できた流れは次の通り。
Mock-GPU-01
↓ register
Mock-GPU-02
↓ register
Broker
Workers = 2
↓
POST /api/jobs
↓
Scheduler
↓
Mock-GPU-01
score = 719.5
↓
running
↓
result POST
↓
completed
↓
Mock-GPU-01
ready
実際のBroker状態は、
{
"status":
"completed",
"assignedWorkerId":
"worker_...",
"result":
"MOCK RESULT: hello cluster",
"metrics": {
"decodeTokensPerSec":
55,
"elapsedMs":
100,
"completionTokens":
12
},
"schedulerScore":
719.5
}
となった。
つまりGPUを使わない状態でも、分散Scheduler部分は単独で試験できる。
実GPU Workerの確認方法
次に本物のChromeを使う。
Chrome DevToolsのConsoleで、
navigator.gpu
を実行する。
次に、
const adapter =
await navigator.gpu
.requestAdapter({
powerPreference:
"high-performance"
});
adapter.info
を確認する。
そしてWorker URLを開く。
https://ai-broker.company.local:8443/worker?name=RTX5060-PC
最初のJobを送る。
初回だけモデルロードが走る。
Model loading
0%
10%
30%
70%
100%
↓
WebGPU engine ready
↓
Inference
↓
Broker result
2回目からはwarm modelとして扱われるため、Scheduler上の評価が大きく上がる。
ここから本当に面白い実験ができる
クラスタが動けば、社内PCの性能を自動測定できる。
RTX 5060
Llama 1B
XX tok/s
Intel Arc
Llama 1B
XX tok/s
Apple M3
Llama 1B
XX tok/s
Intel UHD
SmolLM
XX tok/s
さらにmodelごとに別の実測値を持つ。
PC-01
SmolLM
72 tok/s
Llama 1B
43 tok/s
Llama 3B
17 tok/s
PC-02
SmolLM
31 tok/s
Llama 1B
18 tok/s
Llama 3B
OOM
するとSchedulerを、
score(worker, model)
としてモデルごとに最適化できる。
さらに「モデル専門Worker」へ進化できる
RTX PC
↓
Llama 3B warm
Mac M3
↓
Qwen 1.5B warm
Intel PC
↓
SmolLM warm
Brokerは依頼内容に応じてモデルを選び、そのモデルがwarmなWorkerへ投げる。
短い分類
↓
SmolLM Worker
文章生成
↓
Llama Worker
コード処理
↓
Qwen Worker
ここまでくると単なるPCの寄せ集めではない。
一種のAI inference fabricになる。
将来的にはJobをさらに細かくできる
type:
chat
type:
summarize
type:
classify
type:
embedding
type:
rerank
type:
structured-json
Workerは自身が得意なCapabilityをBrokerへ登録する。
{
"capabilities": [
"chat",
"json",
"embedding"
]
}
そうすればGPU性能だけでなく機能でroutingできる。
RAGもブラウザ側へ持っていける
EmbeddingモデルまでWebGPUで動かせば、
Question
↓
Browser Embedding
↓
Vector Search
↓
Context
↓
Browser LLM
↓
Answer
という構成も可能になる。
つまり中央GPUサーバーが担当していたAI処理を少しずつEdgeへ押し出せる。
ブラウザが「計算資源」になる意味
Webの歴史では、長い間ブラウザはサーバーから結果を受け取るClientだった。
昔
Server
↓
Browser
今
Server
↓
Browser
↓
GPU Compute
WebGPUによってブラウザ側がかなり強い計算能力を持ち始めた。
WGSL Compute Shaderを使えば、LLM以外にも、
- 画像処理
- 行列演算
- 科学計算
- SQL処理
- グラフ処理
- Embedding
- 物理simulation
などができる。
2026年にはWebGPUだけでブラウザ内の大規模recursive queryをGPU実行するWGLogのような研究も登場している。
ブラウザは表示装置から、ポータブルGPU runtimeへ変わり始めている。
LlamaWebが示したもう一つの重要な点
GPUはメーカーごとに全く違う。
NVIDIA
AMD
Intel
Apple
LlamaWebでは、この異種環境に対応するため、WebGPU上に調整可能なkernel libraryを作り、複数quantization formatへ対応している。
ブラウザAIが面白い理由は単に「Chromeで動いた」ではない。
GPUメーカーが違っても、WebGPUという一つの計算APIで扱える可能性が現実になってきた。
もちろんWebGPUにもオーバーヘッドはある
WebGPUはNative CUDAと同じではない。
ブラウザは安全性のため、GPU操作ごとにvalidationなどを行う。
2026年のWebGPU dispatch overhead研究では、GPU backendやbrowserによって差があり、LLM推論のように大量の小さなdispatchを行う処理ではoverheadが重要になることが示されている。
そのためkernel fusionやdispatch数削減が非常に重要になる。
LlamaWebが単にllama.cppをWebAssemblyへcompileしただけではなく、WebGPU専用kernelをかなり作り込んでいる理由もここにある。
社内PCクラスタをさらに進化させるなら
今回の実装は最小構成。
実運用へ進めるなら、次を追加したい。
- Worker認証
- Broker API認証
- ジョブ優先度
- 部署ごとのQueue
- モデル別性能履歴
- モデル事前warm-up
- Worker maintenance mode
- CPU/GPU負荷による自動停止
- 業務時間外だけ参加
- Job timeout
- Retry回数制限
- Prometheus metrics
- Grafana dashboard
特に面白いのは「業務時間外だけ参加」だ。
08:00 - 18:00
社員がPCを利用
AI Worker low priority
18:00 - 08:00
AI Worker high priority
会社中のPCが夜間だけAIクラスタになる。
中央GPUサーバーと競合するのではなく補完できる
最終的にはHybrid構成も面白い。
AI Broker
/ | \
/ | \
Browser Browser GPU Server
Worker Worker vLLM
↓ ↓ ↓
WebGPU WebGPU CUDA
小さいJobはBrowser。
大きいモデルはGPU Server。
Browserが全部busyならGPU Serverへfallback。
あるいは逆に、GPU Serverが混雑していればEdgeへ逃がす。
if model <= 3B
and Browser Worker available
→ Browser
else
→ GPU Server
この方が現実的な未来だと思う。
AIインフラの単位が変わるかもしれない
これまでAIインフラと言えば、
GPU Server
Cloud GPU
Kubernetes GPU Node
だった。
しかしWebGPUが成熟すると、
Laptop
Desktop
Mac
Browser
Mobile Device
もAIインフラの一部になり得る。
巨大な計算機を中央に置くだけではない。
既に存在している数百台のEdge Deviceを、必要な時だけ計算資源として使う。
分散コンピューティングの考え方自体は古い。
SETI@homeやBOINCの時代から存在する。
違うのは、2026年にはそれをChromeを開くだけで実現できるところまでWeb Platformが進化してきたことである。
まとめ
今回作ったものは巨大なAI基盤ではない。
Node.js BrokerとChromeだけの小さな実験クラスタである。
しかし構造としては、本物の分散推論基盤と同じ要素を持っている。
Worker discovery
Heartbeat
Queue
Scheduling
Warm model affinity
Performance feedback
Failure detection
Retry
Result collection
違うのはWorkerがGPUサーバーではなくブラウザというだけだ。
そして、この「だけ」がかなり大きい。
Chromeは既に世界中のPCに入っている。
WebGPUがそのGPUへ共通APIを提供する。
WebLLMやLlamaWebのような実装が、そのGPU上でLLMを動かす。
すると、
「AIを使う端末」と「AIを計算する端末」が同じものになる。
これはクラウドAIの否定ではない。
GPUサーバーの否定でもない。
AIを実行できる場所が増える。
Cloud
GPU Server
Edge Server
Desktop
Laptop
Browser
そしてBrokerが、その時一番適した場所へJobを流す。
AIインフラが「どこかにある巨大GPU」から、会社中に散らばる計算資源の集合へ変わる可能性がある。
ブラウザは、Webページを見るだけのソフトではなくなりつつある。
WebGPUによって、ブラウザそのものが分散GPUコンピューティングのWorkerになり始めている。
次回
次はこのクラスタをさらに進める。
100 Job
↓
3〜10 Browser Workers
↓
Weighted Scheduling
↓
Model Affinity
↓
Failure Retry
↓
Throughput Benchmark
そして、
1台のRTX GPU Server
vs
10台のBrowser WebGPU Worker
で、総Throughput、消費電力、1リクエストあたりのコスト、p95/p99、モデルロード時間を比較してみたい。
さらにその先では、RAG、Embedding、構造化JSON生成までBrowser Clusterへ流す。
「社内に既にあるPCだけで、どこまでAI基盤を作れるか」。
これはかなり面白い実験になりそうだ。
今回使用したBroker、Chrome WebGPU Worker、WebLLM推論Worker、Mock Workerをまとめた実装一式をZIPで公開します。Mock Workerを利用すれば、GPUがない環境でもBrokerのWorker登録、スケジューリング、Job割当、結果回収まで確認できます。
Node.js Broker、Chrome WebGPU Worker、WebLLM推論Worker、Mock Workerを含む 実験用ソースコード一式です。
💾 Browser AI Cluster をダウンロード参考資料
- Llamas on the Web: Memory-Efficient, Performance-Portable, and Multi-Precision LLM Inference with WebGPU
- llama.cpp — WebGPU backend
- WebLLM — High-Performance In-Browser LLM Inference Engine
- WebLLM Documentation — Web Workers / GPU Information
- Chrome for Developers — WebGPU Troubleshooting
- MDN — GPUAdapter.info / GPUAdapter.limits
- Characterizing WebGPU Dispatch Overhead for LLM Inference Across Four GPU Vendors, Three Backends, and Three Browsers