Bonsai-Chat-WebGPU / src /engine /native-log.ts
Valeriy Selitskiy
Release v1.2.2 interactive chat workspace
21ad36a
Raw
History Blame Contribute Delete
10.5 kB
export interface GpuLayerReport {
offloaded: number;
total: number;
}
export interface BackendReport {
backends: string[];
nGraphSplits: number | null;
opsOnCpu: number;
layersGpu: GpuLayerReport | null;
flashAttention: boolean | null;
cacheTypeK: string | null;
cacheTypeV: string | null;
webgpuKvBufferBytes: number | null;
modelBufferBytes?: number;
computeBufferBytes?: number;
outputBufferBytes?: number;
allocatedBufferBytes?: number;
webgpuTrace: string[];
}
export interface NativeDeviceLostSignal {
line: string;
reason: number | null;
message: string | null;
}
export interface NativeDeviceLostWatch {
promise: Promise<NativeDeviceLostSignal>;
dispose(): void;
}
export interface NativeModelLoadProgress {
state: 'loading' | 'ready';
stages: string[];
current: string | null;
value: number;
}
const BACKEND_NAMES = ['WebGPU', 'CPU', 'Metal', 'CUDA', 'Vulkan'] as const;
const EVIDENCE = /using device|model buffer size|KV buffer size|compute buffer size|output buffer size|offloaded \d+\/\d+ layers|graph splits\s*=|graph_compute\(|backend\s*=\s*CPU|assigned to (?:device )?CPU|(?:ops?|nodes?).*(?:on|to) CPU|flash_attn\s*=|flash attention.*(?:enabled|disabled)|\bK\s*\([^)]+\).*\bV\s*\([^)]+\)/i;
const WEBGPU_DEVICE_LOST = /\bdevice\s+lost!?(?:\s+reason:\s*(-?\d+),\s*message:\s*(.*))?/i;
const WEBGPU_KV_BUFFER = /\bWebGPU\s+KV buffer size\s*=\s*(\d+(?:\.\d+)?)\s*(KiB|MiB|GiB)\b/i;
const WEBGPU_TRACE_PREFIX = '@@WEBGPU_TRACE@@';
const MODEL_LOAD_PROGRESS_PREFIX = '@@MODEL_LOAD_PROGRESS@@';
const WEBGPU_TRACE_LINE_LIMIT = 16_384;
const BINARY_UNIT_BYTES = {
kib: 1024,
mib: 1024 ** 2,
gib: 1024 ** 3,
} as const;
function canonicalBackend(value: string): string {
const match = BACKEND_NAMES.find((backend) => value.toLowerCase().startsWith(backend.toLowerCase()));
return match ?? value;
}
function cpuOpsInLine(line: string): number {
const nodePatterns = [
/ggml_backend_cpu_graph_compute\(\s*(\d+)\s+nodes?\s*\)/i,
/backend\s*=\s*CPU\b.*?n_(?:nodes|ops)\s*=\s*(\d+)/i,
/(?:backend|device)\s+CPU\b.*?(?:graph|compute).*?(\d+)\s+(?:nodes?|ops?)/i,
/(\d+)\s+(?:nodes?|ops?).*?(?:on|to)\s+CPU\b/i,
];
for (const pattern of nodePatterns) {
const match = line.match(pattern);
if (match) {
return Number(match[1]);
}
}
if (/\b(?:op|node)\b.*?assigned to (?:device )?CPU\b/i.test(line)) {
return 1;
}
return 0;
}
export function isNativeLogEvidenceLine(line: string): boolean {
return EVIDENCE.test(line);
}
export function parseNativeDeviceLostSignal(line: string): NativeDeviceLostSignal | null {
if (!/\b(?:ggml[_ -]?webgpu|webgpu)\b/i.test(line)) {
return null;
}
const match = line.match(WEBGPU_DEVICE_LOST);
if (!match) {
return null;
}
const reason = match[1] === undefined ? null : Number(match[1]);
const message = match[2]?.trim() || null;
return {
line: line.slice(0, 4096),
reason: Number.isSafeInteger(reason) ? reason : null,
message,
};
}
export function parseNativeModelLoadProgress(line: string): NativeModelLoadProgress | null {
if (!line.startsWith(MODEL_LOAD_PROGRESS_PREFIX)) return null;
try {
const payload = JSON.parse(line.slice(MODEL_LOAD_PROGRESS_PREFIX.length)) as Record<string, unknown>;
if (payload.state !== 'loading' && payload.state !== 'ready') return null;
const stages = Array.isArray(payload.stages)
? payload.stages.filter((stage): stage is string => typeof stage === 'string')
: [];
const current = typeof payload.current === 'string' ? payload.current : null;
const value = payload.state === 'ready'
? 1
: typeof payload.value === 'number' && Number.isFinite(payload.value)
? Math.min(1, Math.max(0, payload.value))
: 0;
return { state: payload.state, stages, current, value };
} catch {
return null;
}
}
export function parseNativeLog(input: string | readonly string[]): BackendReport {
const lines = typeof input === 'string' ? input.split(/\r?\n/) : input;
const backends = new Set<string>();
const graphSplits: number[] = [];
let opsOnCpu = 0;
let layersGpu: GpuLayerReport | null = null;
let flashAttention: boolean | null = null;
let cacheTypeK: string | null = null;
let cacheTypeV: string | null = null;
let webgpuKvBufferBytes: number | null = null;
let modelBufferBytes = 0;
let computeBufferBytes = 0;
let outputBufferBytes = 0;
for (const line of lines) {
const deviceMatch = line.match(/using device\s+([^\s(]+)\s+\(([^)]+)\)/i);
if (deviceMatch) {
backends.add(canonicalBackend(deviceMatch[2] ?? deviceMatch[1] ?? 'unknown'));
}
for (const backend of BACKEND_NAMES) {
const escaped = backend.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`\\b${escaped}\\s+(?:model|KV|compute|output) buffer size`, 'i').test(line)) {
backends.add(backend);
}
}
const layerMatch = line.match(/offloaded\s+(\d+)\s*\/\s*(\d+)\s+layers to GPU/i);
if (layerMatch) {
layersGpu = {
offloaded: Number(layerMatch[1]),
total: Number(layerMatch[2]),
};
}
const graphMatch = line.match(/graph splits\s*=\s*(\d+)/i);
if (graphMatch) {
graphSplits.push(Number(graphMatch[1]));
}
const contextFlashMatch = line.match(/\bflash_attn\s*=\s*(enabled|disabled)\b/i);
const resolvedFlashMatch = line.match(/\bFlash Attention\b.*\b(enabled|disabled)\b/i);
const flashState = (contextFlashMatch ?? resolvedFlashMatch)?.[1];
if (flashState) {
flashAttention = flashState.toLowerCase() === 'enabled';
}
const cacheTypesMatch = line.match(/\bK\s*\(\s*([^)]+?)\s*\).*\bV\s*\(\s*([^)]+?)\s*\)/i);
const cacheK = cacheTypesMatch?.[1];
const cacheV = cacheTypesMatch?.[2];
if (cacheK && cacheV) {
cacheTypeK = cacheK.trim().toLowerCase();
cacheTypeV = cacheV.trim().toLowerCase();
}
const kvBufferMatch = line.match(WEBGPU_KV_BUFFER);
const kvBufferSize = kvBufferMatch?.[1];
const kvBufferUnit = kvBufferMatch?.[2];
if (kvBufferSize && kvBufferUnit) {
const unitBytes = BINARY_UNIT_BYTES[kvBufferUnit.toLowerCase() as keyof typeof BINARY_UNIT_BYTES];
const bytes = Math.round(Number(kvBufferSize) * unitBytes);
webgpuKvBufferBytes = (webgpuKvBufferBytes ?? 0) + bytes;
}
const bufferMatch = line.match(/\b(model|compute|output) buffer size\s*=\s*(\d+(?:\.\d+)?)\s*(KiB|MiB|GiB)\b/i);
const bufferKind = bufferMatch?.[1]?.toLowerCase();
const bufferSize = bufferMatch?.[2];
const bufferUnit = bufferMatch?.[3];
if (bufferKind && bufferSize && bufferUnit) {
const unitBytes = BINARY_UNIT_BYTES[bufferUnit.toLowerCase() as keyof typeof BINARY_UNIT_BYTES];
const bytes = Math.round(Number(bufferSize) * unitBytes);
if (bufferKind === 'model') modelBufferBytes += bytes;
if (bufferKind === 'compute') computeBufferBytes += bytes;
if (bufferKind === 'output') outputBufferBytes += bytes;
}
opsOnCpu += cpuOpsInLine(line);
}
const allocatedBufferBytes = modelBufferBytes
+ computeBufferBytes
+ outputBufferBytes
+ (webgpuKvBufferBytes ?? 0);
return {
backends: [...backends],
nGraphSplits: graphSplits.length > 0 ? Math.max(...graphSplits) : null,
opsOnCpu,
layersGpu,
flashAttention,
cacheTypeK,
cacheTypeV,
webgpuKvBufferBytes,
...(modelBufferBytes > 0 ? { modelBufferBytes } : {}),
...(computeBufferBytes > 0 ? { computeBufferBytes } : {}),
...(outputBufferBytes > 0 ? { outputBufferBytes } : {}),
...(allocatedBufferBytes > 0 ? { allocatedBufferBytes } : {}),
webgpuTrace: [],
};
}
export class NativeLogCollector {
private lines: string[] = [];
private webgpuTrace: string[] = [];
private recentLines: string[] = [];
private deviceLost: NativeDeviceLostSignal | null = null;
private readonly deviceLostListeners = new Set<(signal: NativeDeviceLostSignal) => void>();
private readonly modelLoadListeners = new Set<(progress: NativeModelLoadProgress) => void>();
clear(): void {
this.lines = [];
this.webgpuTrace = [];
this.recentLines = [];
this.deviceLost = null;
}
append(values: readonly unknown[]): void {
const line = values
.map((value) => (typeof value === 'string' ? value : JSON.stringify(value)))
.join(' ');
if (line) {
this.recentLines.push(line.slice(0, 4096));
if (this.recentLines.length > 64) {
this.recentLines.shift();
}
}
const modelLoadProgress = parseNativeModelLoadProgress(line);
if (modelLoadProgress) {
for (const listener of this.modelLoadListeners) listener(modelLoadProgress);
}
if (!this.deviceLost) {
const deviceLost = parseNativeDeviceLostSignal(line);
if (deviceLost) {
this.deviceLost = deviceLost;
for (const listener of this.deviceLostListeners) listener(deviceLost);
this.deviceLostListeners.clear();
}
}
if (isNativeLogEvidenceLine(line)) {
this.lines.push(line.slice(0, 4096));
if (this.lines.length > 512) {
this.lines.shift();
}
}
if (line.startsWith(WEBGPU_TRACE_PREFIX)) {
this.webgpuTrace.push(line.slice(0, 4096));
if (this.webgpuTrace.length > WEBGPU_TRACE_LINE_LIMIT) {
this.webgpuTrace.shift();
}
}
}
report(): BackendReport {
return {
...parseNativeLog(this.lines),
webgpuTrace: [...this.webgpuTrace],
};
}
recent(limit = 16): string[] {
return this.recentLines.slice(-Math.max(0, limit));
}
deviceLostSignal(): NativeDeviceLostSignal | null {
return this.deviceLost;
}
watchDeviceLost(): NativeDeviceLostWatch {
if (this.deviceLost) {
return { promise: Promise.resolve(this.deviceLost), dispose: () => undefined };
}
let listener: ((signal: NativeDeviceLostSignal) => void) | null = null;
const promise = new Promise<NativeDeviceLostSignal>((resolve) => {
listener = resolve;
this.deviceLostListeners.add(resolve);
});
return {
promise,
dispose: () => {
if (listener) this.deviceLostListeners.delete(listener);
listener = null;
},
};
}
onModelLoadProgress(listener: (progress: NativeModelLoadProgress) => void): () => void {
this.modelLoadListeners.add(listener);
return () => this.modelLoadListeners.delete(listener);
}
}