Spaces:
Running
Running
| import { describe, expect, it } from 'vitest'; | |
| import { | |
| NativeLogCollector, | |
| parseNativeDeviceLostSignal, | |
| parseNativeLog, | |
| parseNativeModelLoadProgress, | |
| } from './native-log'; | |
| describe('parseNativeLog', () => { | |
| it('extracts WebGPU placement without treating bookkeeping buffers as CPU ops', () => { | |
| const report = parseNativeLog(` | |
| llama_prepare_model_devices: using device WebGPU (WebGPU) (unknown id) | |
| load_tensors: offloaded 65/65 layers to GPU | |
| load_tensors: CPU model buffer size = 72.00 MiB | |
| load_tensors: WebGPU model buffer size = 3616.77 MiB | |
| llama_context: flash_attn = disabled | |
| llama_kv_cache: WebGPU KV buffer size = 56.00 MiB | |
| llama_kv_cache: size = 56.00 MiB, K (f16): 28.00 MiB, V (f16): 28.00 MiB | |
| sched_reserve: WebGPU compute buffer size = 301.00 MiB | |
| sched_reserve: CPU compute buffer size = 13.01 MiB | |
| llama_context: CPU output buffer size = 0.95 MiB | |
| sched_reserve: graph splits = 1 | |
| `); | |
| const modelBufferBytes = Math.round(72 * 1024 ** 2) + Math.round(3616.77 * 1024 ** 2); | |
| const computeBufferBytes = Math.round(301 * 1024 ** 2) + Math.round(13.01 * 1024 ** 2); | |
| const outputBufferBytes = Math.round(0.95 * 1024 ** 2); | |
| expect(report).toEqual({ | |
| backends: ['WebGPU', 'CPU'], | |
| nGraphSplits: 1, | |
| opsOnCpu: 0, | |
| layersGpu: { offloaded: 65, total: 65 }, | |
| flashAttention: false, | |
| cacheTypeK: 'f16', | |
| cacheTypeV: 'f16', | |
| webgpuKvBufferBytes: 56 * 1024 ** 2, | |
| modelBufferBytes, | |
| computeBufferBytes, | |
| outputBufferBytes, | |
| allocatedBufferBytes: modelBufferBytes | |
| + computeBufferBytes | |
| + outputBufferBytes | |
| + (56 * 1024 ** 2), | |
| webgpuTrace: [], | |
| }); | |
| }); | |
| it('parses and clamps structured native model-load progress', () => { | |
| expect(parseNativeModelLoadProgress( | |
| '@@MODEL_LOAD_PROGRESS@@{"state":"loading","stages":["metadata","tensors"],"current":"tensors","value":0.375}', | |
| )).toEqual({ | |
| state: 'loading', | |
| stages: ['metadata', 'tensors'], | |
| current: 'tensors', | |
| value: 0.375, | |
| }); | |
| expect(parseNativeModelLoadProgress( | |
| '@@MODEL_LOAD_PROGRESS@@{"state":"loading","value":7}', | |
| )).toEqual({ state: 'loading', stages: [], current: null, value: 1 }); | |
| expect(parseNativeModelLoadProgress( | |
| '@@MODEL_LOAD_PROGRESS@@{"state":"ready","value":0}', | |
| )).toEqual({ state: 'ready', stages: [], current: null, value: 1 }); | |
| expect(parseNativeModelLoadProgress('@@MODEL_LOAD_PROGRESS@@not-json')).toBeNull(); | |
| }); | |
| it('delivers native model-load progress to active listeners only', () => { | |
| const collector = new NativeLogCollector(); | |
| const observed: unknown[] = []; | |
| const unsubscribe = collector.onModelLoadProgress((progress) => observed.push(progress)); | |
| collector.append([ | |
| '@@MODEL_LOAD_PROGRESS@@{"state":"loading","stages":["tensors"],"current":"tensors","value":0.5}', | |
| ]); | |
| collector.append(['@@MODEL_LOAD_PROGRESS@@invalid']); | |
| unsubscribe(); | |
| collector.append(['@@MODEL_LOAD_PROGRESS@@{"state":"ready"}']); | |
| expect(observed).toEqual([{ | |
| state: 'loading', | |
| stages: ['tensors'], | |
| current: 'tensors', | |
| value: 0.5, | |
| }]); | |
| }); | |
| it('counts explicit CPU graph nodes and keeps the worst graph split observation', () => { | |
| const report = parseNativeLog([ | |
| 'sched_reserve: graph splits = 1', | |
| 'sched_reserve: graph splits = 3', | |
| 'backend = CPU, n_nodes = 7', | |
| 'ggml_backend_cpu_graph_compute(5 nodes)', | |
| ]); | |
| expect(report.nGraphSplits).toBe(3); | |
| expect(report.opsOnCpu).toBe(12); | |
| }); | |
| it('reports unknown graph evidence as null', () => { | |
| expect(parseNativeLog('load_tensors: offloaded 29/29 layers to GPU').nGraphSplits).toBeNull(); | |
| }); | |
| it('reports only resolved Flash Attention state and lets later evidence win', () => { | |
| expect(parseNativeLog('llama_context: flash_attn = auto').flashAttention).toBeNull(); | |
| expect(parseNativeLog([ | |
| 'llama_context: flash_attn = auto', | |
| 'resolve_fused_ops: Flash Attention enabled', | |
| ]).flashAttention).toBe(true); | |
| expect(parseNativeLog([ | |
| 'llama_context: flash_attn = enabled', | |
| 'resolve_fused_ops: Flash Attention not supported, set to disabled', | |
| ]).flashAttention).toBe(false); | |
| }); | |
| it('retains cache tuning evidence and sums binary WebGPU KV buffer units', () => { | |
| const collector = new NativeLogCollector(); | |
| collector.append(['llama_context: flash_attn = disabled']); | |
| collector.append(['llama_kv_cache: K (Q8_0): 1.00 MiB, V (Q4_0): 1.00 MiB']); | |
| collector.append(['llama_kv_cache: WebGPU KV buffer size = 512 KiB']); | |
| collector.append(['llama_kv_cache: WebGPU KV buffer size = 2.50 MiB']); | |
| collector.append(['llama_kv_cache: WebGPU KV buffer size = 1.00 GiB']); | |
| expect(collector.report()).toMatchObject({ | |
| flashAttention: false, | |
| cacheTypeK: 'q8_0', | |
| cacheTypeV: 'q4_0', | |
| webgpuKvBufferBytes: (512 * 1024) + (2.5 * 1024 ** 2) + (1024 ** 3), | |
| }); | |
| }); | |
| it('parses the native llama.cpp WebGPU device-lost callback', () => { | |
| expect(parseNativeDeviceLostSignal( | |
| 'ggml_webgpu: Device lost! Reason: 2, Message: GPU process crashed', | |
| )).toEqual({ | |
| line: 'ggml_webgpu: Device lost! Reason: 2, Message: GPU process crashed', | |
| reason: 2, | |
| message: 'GPU process crashed', | |
| }); | |
| expect(parseNativeDeviceLostSignal('Device lost while reading a document')).toBeNull(); | |
| }); | |
| it('retains device loss outside the backend-evidence ring until explicitly cleared', () => { | |
| const collector = new NativeLogCollector(); | |
| collector.append(['ggml_webgpu: Device lost! Reason: 1, Message: unknown']); | |
| expect(collector.report()).toEqual({ | |
| backends: [], | |
| nGraphSplits: null, | |
| opsOnCpu: 0, | |
| layersGpu: null, | |
| flashAttention: null, | |
| cacheTypeK: null, | |
| cacheTypeV: null, | |
| webgpuKvBufferBytes: null, | |
| webgpuTrace: [], | |
| }); | |
| expect(collector.deviceLostSignal()).toMatchObject({ reason: 1, message: 'unknown' }); | |
| collector.clear(); | |
| expect(collector.deviceLostSignal()).toBeNull(); | |
| }); | |
| it('retains WebGPU trace markers in a separate report channel', () => { | |
| const collector = new NativeLogCollector(); | |
| collector.append(['@@WEBGPU_TRACE@@completion_begin id=1']); | |
| collector.append(['@@WEBGPU_TRACE@@graph_end id=1 nodes=3735 dispatches=1940 submits=31']); | |
| collector.append(['unrelated native output']); | |
| expect(collector.report().webgpuTrace).toEqual([ | |
| '@@WEBGPU_TRACE@@completion_begin id=1', | |
| '@@WEBGPU_TRACE@@graph_end id=1 nodes=3735 dispatches=1940 submits=31', | |
| ]); | |
| collector.clear(); | |
| expect(collector.report().webgpuTrace).toEqual([]); | |
| }); | |
| it('retains a bounded diagnostic tail independently of backend evidence', () => { | |
| const collector = new NativeLogCollector(); | |
| for (let index = 0; index < 70; index += 1) { | |
| collector.append([`diagnostic ${index}`]); | |
| } | |
| expect(collector.recent()).toEqual( | |
| Array.from({ length: 16 }, (_, index) => `diagnostic ${index + 54}`), | |
| ); | |
| expect(collector.recent(100)).toHaveLength(64); | |
| expect(collector.report().webgpuTrace).toEqual([]); | |
| collector.clear(); | |
| expect(collector.recent()).toEqual([]); | |
| }); | |
| it('bounds the trace independently with room for a complete 64-token run', () => { | |
| const collector = new NativeLogCollector(); | |
| for (let index = 0; index <= 16_384; index += 1) { | |
| collector.append([`@@WEBGPU_TRACE@@step_end completion=1 step=${index}`]); | |
| } | |
| const trace = collector.report().webgpuTrace; | |
| expect(trace).toHaveLength(16_384); | |
| expect(trace[0]).toContain('step=1'); | |
| expect(trace.at(-1)).toContain('step=16384'); | |
| }); | |
| it('notifies an active native-operation watcher without polling', async () => { | |
| const collector = new NativeLogCollector(); | |
| const watch = collector.watchDeviceLost(); | |
| collector.append(['ggml_webgpu: Device lost! Reason: 3, Message: reset']); | |
| await expect(watch.promise).resolves.toMatchObject({ reason: 3, message: 'reset' }); | |
| watch.dispose(); | |
| }); | |
| }); | |