diff --git a/src/benchmark/Benchmark.ts b/src/benchmark/Benchmark.ts index afb3e61..e7102b6 100644 --- a/src/benchmark/Benchmark.ts +++ b/src/benchmark/Benchmark.ts @@ -3,6 +3,7 @@ import { BitonicSorter } from '../sorting/BitonicSorter'; import { RadixSorter } from '../sorting/RadixSorter'; import { BenchmarkResult } from '../shared/types'; import { DEFAULT_BENCHMARK_SIZES } from '../shared/constants'; +import { createRandomUint32Array } from '../shared/random'; /** * Performance benchmark for sorting algorithms @@ -37,18 +38,7 @@ export class Benchmark { * Generate random test data using crypto for better randomness */ static generateRandomData(size: number): Uint32Array { - const data = new Uint32Array(size); - // Use crypto.getRandomValues for cryptographically secure random numbers - // Falls back to Math.random if crypto is not available (e.g., older Node.js) - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - crypto.getRandomValues(data); - } else { - // Fallback for environments without crypto.getRandomValues - for (let i = 0; i < size; i++) { - data[i] = Math.floor(Math.random() * 0xffffffff); - } - } - return data; + return createRandomUint32Array(size); } /** diff --git a/src/core/BufferManager.ts b/src/core/BufferManager.ts index ce47925..6d1a402 100644 --- a/src/core/BufferManager.ts +++ b/src/core/BufferManager.ts @@ -1,4 +1,5 @@ -import { BufferAllocationError, BufferMapError } from './errors'; +import { BufferAllocationError, BufferMapError, GPUTimeoutError } from './errors'; +import { withTimeout } from './timeout'; /** * Formats an unknown error into a string for error messages @@ -102,14 +103,16 @@ export class BufferManager { const alignedSize = BufferManager.alignSize(size, 4); const stagingBuffer = this.createStagingBuffer(alignedSize); - // Copy from source to staging - const commandEncoder = this.device.createCommandEncoder(); - commandEncoder.copyBufferToBuffer(sourceBuffer, 0, stagingBuffer, 0, alignedSize); - this.device.queue.submit([commandEncoder.finish()]); - - // Map and read try { - await stagingBuffer.mapAsync(GPUMapMode.READ); + // Copy from source to staging + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyBufferToBuffer(sourceBuffer, 0, stagingBuffer, 0, alignedSize); + this.device.queue.submit([commandEncoder.finish()]); + + // Map and read + await withTimeout(stagingBuffer.mapAsync(GPUMapMode.READ), { + message: 'Buffer mapping timed out', + }); const mappedRange = stagingBuffer.getMappedRange(); const result = new Uint32Array(mappedRange.slice(0, size)); stagingBuffer.unmap(); @@ -120,6 +123,9 @@ export class BufferManager { return result; } catch (e) { this.releaseBuffer(stagingBuffer); + if (e instanceof GPUTimeoutError) { + throw e; + } throw new BufferMapError(`Failed to read buffer: ${formatError(e)}`); } } diff --git a/src/core/BufferScope.ts b/src/core/BufferScope.ts new file mode 100644 index 0000000..3ab97ea --- /dev/null +++ b/src/core/BufferScope.ts @@ -0,0 +1,38 @@ +/** + * Tracks temporary GPU buffers for a single operation and releases them once. + */ +export class BufferScope { + private releasers = new Map void>(); + + /** + * Track a buffer with an optional custom release strategy. + */ + track(buffer: T, release?: (buffer: T) => void): T { + this.releasers.set(buffer, () => { + if (release) { + release(buffer); + return; + } + buffer.destroy(); + }); + + return buffer; + } + + /** + * Stop tracking a buffer so later releaseAll() calls leave it alone. + */ + untrack(buffer: GPUBuffer): void { + this.releasers.delete(buffer); + } + + /** + * Release every tracked buffer exactly once. + */ + releaseAll(): void { + for (const release of this.releasers.values()) { + release(); + } + this.releasers.clear(); + } +} diff --git a/src/core/GPUContext.ts b/src/core/GPUContext.ts index 3512803..2dd8ab5 100644 --- a/src/core/GPUContext.ts +++ b/src/core/GPUContext.ts @@ -1,5 +1,7 @@ import { GPUContextConfig, GPULimitsInfo } from '../shared/types'; import { WebGPUNotSupportedError, GPUAdapterError, GPUDeviceError } from './errors'; +import { browserGPURuntime } from './runtime/browserGPURuntime'; +import type { GPURuntime } from './runtime/GPURuntime'; /** * Callback type for device loss events @@ -15,12 +17,17 @@ export class GPUContext { private initialized = false; private deviceLossCallbacks: Set = new Set(); private limitsInfo: GPULimitsInfo | null = null; + private runtime: GPURuntime; + + constructor(runtime: GPURuntime = browserGPURuntime) { + this.runtime = runtime; + } /** * Check if WebGPU is supported in the current environment */ - static isSupported(): boolean { - return typeof navigator !== 'undefined' && 'gpu' in navigator; + static isSupported(runtime: GPURuntime = browserGPURuntime): boolean { + return runtime.isSupported(); } /** @@ -69,12 +76,12 @@ export class GPUContext { return; } - if (!GPUContext.isSupported()) { + if (!this.runtime.isSupported()) { throw new WebGPUNotSupportedError(); } // Request adapter - this.adapter = await navigator.gpu.requestAdapter({ + this.adapter = await this.runtime.requestAdapter({ powerPreference: config?.powerPreference ?? 'high-performance', }); diff --git a/src/core/runtime/GPURuntime.ts b/src/core/runtime/GPURuntime.ts new file mode 100644 index 0000000..504dbac --- /dev/null +++ b/src/core/runtime/GPURuntime.ts @@ -0,0 +1,4 @@ +export interface GPURuntime { + isSupported(): boolean; + requestAdapter(options?: GPURequestAdapterOptions): Promise; +} diff --git a/src/core/runtime/browserGPURuntime.ts b/src/core/runtime/browserGPURuntime.ts new file mode 100644 index 0000000..84fd9ea --- /dev/null +++ b/src/core/runtime/browserGPURuntime.ts @@ -0,0 +1,19 @@ +import type { GPURuntime } from './GPURuntime'; + +function getBrowserGPU(): GPU | null { + if (typeof navigator === 'undefined' || !('gpu' in navigator)) { + return null; + } + + return navigator.gpu; +} + +export const browserGPURuntime: GPURuntime = { + isSupported(): boolean { + return getBrowserGPU() !== null; + }, + + async requestAdapter(options?: GPURequestAdapterOptions): Promise { + return (await getBrowserGPU()?.requestAdapter(options)) ?? null; + }, +}; diff --git a/src/index.ts b/src/index.ts index 348a80d..e1cdbfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ // Core exports export { GPUContext } from './core/GPUContext'; export type { DeviceLossCallback } from './core/GPUContext'; +export { browserGPURuntime } from './core/runtime/browserGPURuntime'; +export type { GPURuntime } from './core/runtime/GPURuntime'; export { BufferManager } from './core/BufferManager'; export { Validator } from './core/Validator'; export { withTimeout, createTimeoutWrapper } from './core/timeout'; diff --git a/src/shared/random.ts b/src/shared/random.ts new file mode 100644 index 0000000..362920e --- /dev/null +++ b/src/shared/random.ts @@ -0,0 +1,32 @@ +const MAX_CRYPTO_FILL_BYTES = 65536; +const MAX_CRYPTO_FILL_U32 = MAX_CRYPTO_FILL_BYTES / Uint32Array.BYTES_PER_ELEMENT; +const MAX_U32_EXCLUSIVE = 0x100000000; + +/** + * Fill an existing Uint32Array with random data. + * Uses chunked crypto fills to stay within Web Crypto per-call quotas. + */ +export function fillRandomUint32Array(data: Uint32Array): Uint32Array { + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + for (let offset = 0; offset < data.length; offset += MAX_CRYPTO_FILL_U32) { + const chunkLength = Math.min(MAX_CRYPTO_FILL_U32, data.length - offset); + const chunk = new Uint32Array(chunkLength); + crypto.getRandomValues(chunk); + data.set(chunk, offset); + } + return data; + } + + for (let i = 0; i < data.length; i++) { + data[i] = Math.floor(Math.random() * MAX_U32_EXCLUSIVE); + } + + return data; +} + +/** + * Create a random Uint32Array of the requested size. + */ +export function createRandomUint32Array(size: number): Uint32Array { + return fillRandomUint32Array(new Uint32Array(size)); +} diff --git a/src/sorting/BitonicSorter.ts b/src/sorting/BitonicSorter.ts index e3a22a9..2826700 100644 --- a/src/sorting/BitonicSorter.ts +++ b/src/sorting/BitonicSorter.ts @@ -1,5 +1,6 @@ import { GPUContext } from '../core/GPUContext'; import { BufferManager } from '../core/BufferManager'; +import { BufferScope } from '../core/BufferScope'; import { SortResult, SortOptions } from '../shared/types'; import { ShaderCompilationError } from '../core/errors'; import { Validator } from '../core/Validator'; @@ -188,114 +189,125 @@ export class BitonicSorter { } // Check if preallocated buffer can be used - const usePreallocated = this.preallocatedBuffer && this._preallocatedSize >= originalSize; + const preallocatedBuffer = this.preallocatedBuffer; + const usePreallocated = preallocatedBuffer !== null && this._preallocatedSize >= originalSize; + const bufferScope = new BufferScope(); let dataBuffer: GPUBuffer; - let needsCleanup = false; - - if (usePreallocated) { - // Write data to preallocated buffer - this.device.queue.writeBuffer( - this.preallocatedBuffer!, - 0, - paddedData.buffer, - paddedData.byteOffset, - paddedData.byteLength - ); - dataBuffer = this.preallocatedBuffer!; - } else { - // Fall back to temporary allocation - dataBuffer = this.bufferManager.createStorageBuffer(paddedData, 'sort-data'); - needsCleanup = true; - } - - const uniformBuffer = this.bufferManager.createUniformBuffer(16, 'sort-uniforms'); - - // Create bind group - const bindGroupLayout = this.bindGroupLayout; - if (!bindGroupLayout) { - throw new ShaderCompilationError('Shader pipelines not initialized'); - } + let sortedData: Uint32Array | undefined; + let gpuTimeMs: number | undefined; + + try { + if (usePreallocated) { + this.device.queue.writeBuffer( + preallocatedBuffer, + 0, + paddedData.buffer, + paddedData.byteOffset, + paddedData.byteLength + ); + dataBuffer = preallocatedBuffer; + } else { + dataBuffer = bufferScope.track( + this.bufferManager.createStorageBuffer(paddedData, 'sort-data'), + (buffer) => this.bufferManager.releaseBuffer(buffer) + ); + } - const bindGroup = this.device.createBindGroup({ - label: 'bitonic-bind-group', - layout: bindGroupLayout, - entries: [ - { binding: 0, resource: { buffer: dataBuffer } }, - { binding: 1, resource: { buffer: uniformBuffer } }, - ], - }); + const uniformBuffer = bufferScope.track( + this.bufferManager.createUniformBuffer(16, 'sort-uniforms'), + (buffer) => this.bufferManager.releaseBuffer(buffer) + ); - const gpuStartTime = performance.now(); + const bindGroupLayout = this.bindGroupLayout; + if (!bindGroupLayout) { + throw new ShaderCompilationError('Shader pipelines not initialized'); + } - // Validate paddedSize is a valid power of 2 (defensive check) - if (!BitonicSorter.isPowerOf2(paddedSize)) { - throw new Error(`Invalid paddedSize: ${paddedSize} is not a power of 2`); - } + const bindGroup = this.device.createBindGroup({ + label: 'bitonic-bind-group', + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: dataBuffer } }, + { binding: 1, resource: { buffer: uniformBuffer } }, + ], + }); - // Calculate number of workgroups - const numWorkgroups = Math.ceil(paddedSize / WORKGROUP_SIZE); - // Safe integer log2 - paddedSize is guaranteed to be power of 2 - const numStages = Math.trunc(Math.log2(paddedSize)); + const gpuStartTime = performance.now(); - // First, do local sort within each workgroup - { - const localPipeline = this.localPipeline; - if (!localPipeline) { - throw new ShaderCompilationError('Local pipeline not initialized'); + // Validate paddedSize is a valid power of 2 (defensive check) + if (!BitonicSorter.isPowerOf2(paddedSize)) { + throw new Error(`Invalid paddedSize: ${paddedSize} is not a power of 2`); } - const uniformData = new Uint32Array([0, 0, paddedSize, 0]); - this.device.queue.writeBuffer(uniformBuffer, 0, uniformData); - - const commandEncoder = this.device.createCommandEncoder(); - const passEncoder = commandEncoder.beginComputePass(); - passEncoder.setPipeline(localPipeline); - passEncoder.setBindGroup(0, bindGroup); - passEncoder.dispatchWorkgroups(numWorkgroups); - passEncoder.end(); - this.device.queue.submit([commandEncoder.finish()]); - } + // Calculate number of workgroups + const numWorkgroups = Math.ceil(paddedSize / WORKGROUP_SIZE); + // Safe integer log2 - paddedSize is guaranteed to be power of 2 + const numStages = Math.trunc(Math.log2(paddedSize)); - // Then do global merge stages - // Safe integer log2 - WORKGROUP_SIZE is guaranteed to be power of 2 - const localStages = Math.trunc(Math.log2(WORKGROUP_SIZE)); - const globalPipeline = this.globalPipeline; - if (!globalPipeline) { - throw new ShaderCompilationError('Global pipeline not initialized'); - } + // First, do local sort within each workgroup + { + const localPipeline = this.localPipeline; + if (!localPipeline) { + throw new ShaderCompilationError('Local pipeline not initialized'); + } - for (let stage = localStages; stage < numStages; stage++) { - for (let passNum = stage; passNum >= 0; passNum--) { - const uniformData = new Uint32Array([stage, passNum, paddedSize, 0]); + const uniformData = new Uint32Array([0, 0, paddedSize, 0]); this.device.queue.writeBuffer(uniformBuffer, 0, uniformData); const commandEncoder = this.device.createCommandEncoder(); const passEncoder = commandEncoder.beginComputePass(); - passEncoder.setPipeline(globalPipeline); + passEncoder.setPipeline(localPipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.dispatchWorkgroups(numWorkgroups); passEncoder.end(); this.device.queue.submit([commandEncoder.finish()]); } - } - // Wait for GPU to finish - await this.device.queue.onSubmittedWorkDone(); + // Then do global merge stages + // Safe integer log2 - WORKGROUP_SIZE is guaranteed to be power of 2 + const localStages = Math.trunc(Math.log2(WORKGROUP_SIZE)); + const globalPipeline = this.globalPipeline; + if (!globalPipeline) { + throw new ShaderCompilationError('Global pipeline not initialized'); + } - const gpuEndTime = performance.now(); + for (let stage = localStages; stage < numStages; stage++) { + for (let passNum = stage; passNum >= 0; passNum--) { + const uniformData = new Uint32Array([stage, passNum, paddedSize, 0]); + this.device.queue.writeBuffer(uniformBuffer, 0, uniformData); + + const commandEncoder = this.device.createCommandEncoder(); + const passEncoder = commandEncoder.beginComputePass(); + passEncoder.setPipeline(globalPipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.dispatchWorkgroups(numWorkgroups); + passEncoder.end(); + this.device.queue.submit([commandEncoder.finish()]); + } + } + + // Wait for GPU to finish + await this.device.queue.onSubmittedWorkDone(); - // Read back results - const result = await this.bufferManager.readBuffer(dataBuffer, paddedSize * 4); + const gpuEndTime = performance.now(); - // Remove padding - const sortedData = result.slice(0, originalSize); + // Read back results + const result = await this.bufferManager.readBuffer(dataBuffer, paddedSize * 4); - // Cleanup - if (needsCleanup) { - this.bufferManager.releaseBuffer(dataBuffer); + // Remove padding + sortedData = result.slice(0, originalSize); + gpuTimeMs = gpuEndTime - gpuStartTime; + } finally { + bufferScope.releaseAll(); + } + + if (!sortedData) { + throw new Error('Bitonic sort completed without producing output'); + } + if (gpuTimeMs === undefined) { + throw new Error('Bitonic sort completed without timing information'); } - this.bufferManager.releaseBuffer(uniformBuffer); const totalEndTime = performance.now(); @@ -309,7 +321,7 @@ export class BitonicSorter { return { sortedData, - gpuTimeMs: gpuEndTime - gpuStartTime, + gpuTimeMs, totalTimeMs: totalEndTime - totalStartTime, }; } diff --git a/src/sorting/RadixSorter.ts b/src/sorting/RadixSorter.ts index 0542439..3fae6df 100644 --- a/src/sorting/RadixSorter.ts +++ b/src/sorting/RadixSorter.ts @@ -1,5 +1,6 @@ import { GPUContext } from '../core/GPUContext'; import { BufferManager } from '../core/BufferManager'; +import { BufferScope } from '../core/BufferScope'; import { SortResult, SortOptions } from '../shared/types'; import { ShaderCompilationError } from '../core/errors'; import { Validator } from '../core/Validator'; @@ -344,82 +345,91 @@ export class RadixSorter { const numScanBlocks = Math.ceil(histogramSize / ELEMENTS_PER_SCAN_BLOCK); // Check if preallocated buffers can be used - const usePreallocated = this.preallocatedBuffers && this._preallocatedSize >= size; + const preallocatedBuffers = this.preallocatedBuffers; + const usePreallocated = preallocatedBuffers !== null && this._preallocatedSize >= size; + const bufferScope = new BufferScope(); let inputBuffer: GPUBuffer; let outputBuffer: GPUBuffer; let histogramBuffer: GPUBuffer; let prefixSumBuffer: GPUBuffer; let blockSumsBuffer: GPUBuffer; - let uniformBuffer: GPUBuffer; - let scanUniformBuffer: GPUBuffer; - let needsCleanup = false; - - if (usePreallocated) { - // Write data to preallocated input buffer - this.device.queue.writeBuffer( - this.preallocatedBuffers!.input, - 0, - data.buffer, - data.byteOffset, - data.byteLength - ); - inputBuffer = this.preallocatedBuffers!.input; - outputBuffer = this.preallocatedBuffers!.output; - histogramBuffer = this.preallocatedBuffers!.histogram; - prefixSumBuffer = this.preallocatedBuffers!.prefixSum; - blockSumsBuffer = this.preallocatedBuffers!.blockSums; - - // Uniform and scan uniform buffers are small, allocate on-demand - uniformBuffer = this.bufferManager.createUniformBuffer(16, 'radix-uniforms'); - scanUniformBuffer = this.bufferManager.createUniformBuffer(16, 'scan-uniforms'); - } else { - // Fall back to temporary allocation - inputBuffer = this.bufferManager.createStorageBuffer(data, 'radix-input'); - outputBuffer = this.device.createBuffer({ - label: 'radix-output', - size: BufferManager.alignSize(size * 4, 4), - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, - }); - histogramBuffer = this.device.createBuffer({ - label: 'radix-histogram', - size: BufferManager.alignSize(histogramSize * 4, 4), - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, - }); - prefixSumBuffer = this.device.createBuffer({ - label: 'radix-prefix-sum', - size: BufferManager.alignSize(histogramSize * 4, 4), - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, - }); - blockSumsBuffer = this.device.createBuffer({ - label: 'radix-block-sums', - size: BufferManager.alignSize(numScanBlocks * 4, 4), - usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, - }); - uniformBuffer = this.bufferManager.createUniformBuffer(16, 'radix-uniforms'); - scanUniformBuffer = this.bufferManager.createUniformBuffer(16, 'scan-uniforms'); - needsCleanup = true; - } - // Track current input/output for swapping - let currentInput = inputBuffer; - let currentOutput = outputBuffer; - - const cleanupTempBuffers = () => { - // Only destroy non-preallocated buffers - if (needsCleanup) { - inputBuffer.destroy(); - outputBuffer.destroy(); - histogramBuffer.destroy(); - prefixSumBuffer.destroy(); - blockSumsBuffer.destroy(); - } - // Always clean up small uniform buffers - this.bufferManager.releaseBuffer(uniformBuffer); - this.bufferManager.releaseBuffer(scanUniformBuffer); - }; + let sortedData: Uint32Array | undefined; + let gpuTimeMs: number | undefined; try { + let uniformBuffer: GPUBuffer; + let scanUniformBuffer: GPUBuffer; + + if (usePreallocated) { + this.device.queue.writeBuffer( + preallocatedBuffers.input, + 0, + data.buffer, + data.byteOffset, + data.byteLength + ); + inputBuffer = preallocatedBuffers.input; + outputBuffer = preallocatedBuffers.output; + histogramBuffer = preallocatedBuffers.histogram; + prefixSumBuffer = preallocatedBuffers.prefixSum; + blockSumsBuffer = preallocatedBuffers.blockSums; + + uniformBuffer = bufferScope.track( + this.bufferManager.createUniformBuffer(16, 'radix-uniforms'), + (buffer) => this.bufferManager.releaseBuffer(buffer) + ); + scanUniformBuffer = bufferScope.track( + this.bufferManager.createUniformBuffer(16, 'scan-uniforms'), + (buffer) => this.bufferManager.releaseBuffer(buffer) + ); + } else { + inputBuffer = bufferScope.track( + this.bufferManager.createStorageBuffer(data, 'radix-input'), + (buffer) => this.bufferManager.releaseBuffer(buffer) + ); + outputBuffer = bufferScope.track( + this.device.createBuffer({ + label: 'radix-output', + size: BufferManager.alignSize(size * 4, 4), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }) + ); + histogramBuffer = bufferScope.track( + this.device.createBuffer({ + label: 'radix-histogram', + size: BufferManager.alignSize(histogramSize * 4, 4), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }) + ); + prefixSumBuffer = bufferScope.track( + this.device.createBuffer({ + label: 'radix-prefix-sum', + size: BufferManager.alignSize(histogramSize * 4, 4), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }) + ); + blockSumsBuffer = bufferScope.track( + this.device.createBuffer({ + label: 'radix-block-sums', + size: BufferManager.alignSize(numScanBlocks * 4, 4), + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }) + ); + uniformBuffer = bufferScope.track( + this.bufferManager.createUniformBuffer(16, 'radix-uniforms'), + (buffer) => this.bufferManager.releaseBuffer(buffer) + ); + scanUniformBuffer = bufferScope.track( + this.bufferManager.createUniformBuffer(16, 'scan-uniforms'), + (buffer) => this.bufferManager.releaseBuffer(buffer) + ); + } + + let currentInput = inputBuffer; + let currentOutput = outputBuffer; + const gpuStartTime = performance.now(); // Perform 8 passes (4 bits each) @@ -504,27 +514,34 @@ export class RadixSorter { const gpuEndTime = performance.now(); // Read results (currentInput has final sorted data after even number of swaps) - const result = await this.bufferManager.readBuffer(currentInput, size * 4); + sortedData = await this.bufferManager.readBuffer(currentInput, size * 4); + gpuTimeMs = gpuEndTime - gpuStartTime; + } finally { + bufferScope.releaseAll(); + } - const totalEndTime = performance.now(); + if (!sortedData) { + throw new Error('Radix sort completed without producing output'); + } + if (gpuTimeMs === undefined) { + throw new Error('Radix sort completed without timing information'); + } - // Validate if requested - if (options?.validate) { - const validation = Validator.validate(data, result); - if (!validation.isValid) { - throw new Error(`Sort validation failed: ${validation.errors.join(', ')}`); - } - } + const totalEndTime = performance.now(); - return { - sortedData: result, - gpuTimeMs: gpuEndTime - gpuStartTime, - totalTimeMs: totalEndTime - totalStartTime, - }; - } finally { - // Cleanup - guaranteed to run even if an exception is thrown - cleanupTempBuffers(); + // Validate if requested + if (options?.validate) { + const validation = Validator.validate(data, sortedData); + if (!validation.isValid) { + throw new Error(`Sort validation failed: ${validation.errors.join(', ')}`); + } } + + return { + sortedData, + gpuTimeMs, + totalTimeMs: totalEndTime - totalStartTime, + }; } /** diff --git a/test/benchmark/Benchmark.test.ts b/test/benchmark/Benchmark.test.ts index c920d02..db55428 100644 --- a/test/benchmark/Benchmark.test.ts +++ b/test/benchmark/Benchmark.test.ts @@ -1,8 +1,13 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as fc from 'fast-check'; import { Benchmark } from '../../src/benchmark/Benchmark'; describe('Benchmark', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + // Feature: webgpu-sorting, Property 6: Speedup Calculation Correctness // Validates: Requirements 5.3 describe('Property 6: Speedup Calculation Correctness', () => { @@ -95,6 +100,29 @@ describe('Benchmark', () => { expect(value).toBeLessThanOrEqual(0xffffffff); } }); + + it('fills large arrays in crypto-safe chunks', () => { + const getRandomValues = vi.fn((view: Uint32Array) => { + if (view.byteLength > 65536) { + throw new Error('QuotaExceededError'); + } + view.fill(7); + return view; + }); + + vi.stubGlobal('crypto', { getRandomValues }); + + const data = Benchmark.generateRandomData(20000); + + expect(data.length).toBe(20000); + expect(data[0]).toBe(7); + expect(data[data.length - 1]).toBe(7); + expect(getRandomValues).toHaveBeenCalledTimes(2); + + for (const [view] of getRandomValues.mock.calls) { + expect((view as Uint32Array).byteLength).toBeLessThanOrEqual(65536); + } + }); }); describe('formatResults', () => { diff --git a/test/browser/context.e2e.ts b/test/browser/context.e2e.ts index 6af7146..1512466 100644 --- a/test/browser/context.e2e.ts +++ b/test/browser/context.e2e.ts @@ -32,7 +32,7 @@ test.describe('GPUContext', () => { const { GPUContext } = await import('/src/index.ts'); const gpu = new GPUContext(); await gpu.initialize(); - const isInitialized = gpu.isInitialized; + const isInitialized = gpu.isInitialized(); gpu.destroy(); return { success: true, isInitialized }; } catch (error) { diff --git a/test/browser/sorting.e2e.ts b/test/browser/sorting.e2e.ts index bdaf5f1..e35cc8a 100644 --- a/test/browser/sorting.e2e.ts +++ b/test/browser/sorting.e2e.ts @@ -89,13 +89,14 @@ test.describe('BitonicSorter', () => { try { // @ts-expect-error - module import in browser const { GPUContext, BitonicSorter, Validator } = await import('/src/index.ts'); + // @ts-expect-error - module import in browser + const { createRandomUint32Array } = await import('/src/shared/random.ts'); const gpu = new GPUContext(); await gpu.initialize(); const sorter = new BitonicSorter(gpu); - const data = new Uint32Array(4096); - crypto.getRandomValues(data); + const data = createRandomUint32Array(4096); const result = await sorter.sort(data); const isSorted = Validator.isSorted(result.sortedData); @@ -199,13 +200,14 @@ test.describe('RadixSorter', () => { try { // @ts-expect-error - module import in browser const { GPUContext, RadixSorter, Validator } = await import('/src/index.ts'); + // @ts-expect-error - module import in browser + const { createRandomUint32Array } = await import('/src/shared/random.ts'); const gpu = new GPUContext(); await gpu.initialize(); const sorter = new RadixSorter(gpu); - const data = new Uint32Array(4096); - crypto.getRandomValues(data); + const data = createRandomUint32Array(4096); const result = await sorter.sort(data); const isSorted = Validator.isSorted(result.sortedData); @@ -229,13 +231,14 @@ test.describe('RadixSorter', () => { try { // @ts-expect-error - module import in browser const { GPUContext, RadixSorter, Validator } = await import('/src/index.ts'); + // @ts-expect-error - module import in browser + const { createRandomUint32Array } = await import('/src/shared/random.ts'); const gpu = new GPUContext(); await gpu.initialize(); const sorter = new RadixSorter(gpu); - const data = new Uint32Array(100000); - crypto.getRandomValues(data); + const data = createRandomUint32Array(100000); const result = await sorter.sort(data); const isSorted = Validator.isSorted(result.sortedData); @@ -259,6 +262,8 @@ test.describe('RadixSorter', () => { try { // @ts-expect-error - module import in browser const { GPUContext, BitonicSorter, RadixSorter } = await import('/src/index.ts'); + // @ts-expect-error - module import in browser + const { createRandomUint32Array } = await import('/src/shared/random.ts'); const gpu = new GPUContext(); await gpu.initialize(); @@ -266,8 +271,7 @@ test.describe('RadixSorter', () => { const bitonic = new BitonicSorter(gpu); const radix = new RadixSorter(gpu); - const data = new Uint32Array(1000); - crypto.getRandomValues(data); + const data = createRandomUint32Array(1000); const r1 = await bitonic.sort(data); const r2 = await radix.sort(data); @@ -295,6 +299,8 @@ test.describe('Preallocation', () => { try { // @ts-expect-error - module import in browser const { GPUContext, BitonicSorter } = await import('/src/index.ts'); + // @ts-expect-error - module import in browser + const { createRandomUint32Array } = await import('/src/shared/random.ts'); const gpu = new GPUContext(); await gpu.initialize(); @@ -305,12 +311,10 @@ test.describe('Preallocation', () => { const sizeBefore = sorter.preallocatedSize; // Sort multiple times - const data1 = new Uint32Array(1000); - crypto.getRandomValues(data1); + const data1 = createRandomUint32Array(1000); await sorter.sort(data1); - const data2 = new Uint32Array(5000); - crypto.getRandomValues(data2); + const data2 = createRandomUint32Array(5000); await sorter.sort(data2); sorter.clearPreallocation(); @@ -335,6 +339,8 @@ test.describe('Preallocation', () => { try { // @ts-expect-error - module import in browser const { GPUContext, RadixSorter } = await import('/src/index.ts'); + // @ts-expect-error - module import in browser + const { createRandomUint32Array } = await import('/src/shared/random.ts'); const gpu = new GPUContext(); await gpu.initialize(); @@ -345,12 +351,10 @@ test.describe('Preallocation', () => { const sizeBefore = sorter.preallocatedSize; // Sort multiple times - const data1 = new Uint32Array(1000); - crypto.getRandomValues(data1); + const data1 = createRandomUint32Array(1000); await sorter.sort(data1); - const data2 = new Uint32Array(5000); - crypto.getRandomValues(data2); + const data2 = createRandomUint32Array(5000); await sorter.sort(data2); sorter.clearPreallocation(); diff --git a/test/core/BufferManager.test.ts b/test/core/BufferManager.test.ts index 7a161b2..50e3b75 100644 --- a/test/core/BufferManager.test.ts +++ b/test/core/BufferManager.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import * as fc from 'fast-check'; import { BufferManager } from '../../src/core/BufferManager'; +import { BufferMapError, GPUTimeoutError } from '../../src/core/errors'; describe('BufferManager', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + // Feature: webgpu-sorting, Property 2: Buffer Size Alignment // Validates: Requirements 2.5 describe('Property 2: Buffer Size Alignment', () => { @@ -55,4 +62,79 @@ describe('BufferManager', () => { // which is not available in Node.js test environment. // These tests would need to run in a browser environment with WebGPU support. // For now, we test the alignSize function which is pure and testable. + + describe('readBuffer', () => { + it('wraps copy failures and releases the staging buffer', async () => { + const sourceBuffer = {} as GPUBuffer; + const stagingBuffer = { + destroy: vi.fn(), + } as unknown as GPUBuffer; + + const commandEncoder = { + copyBufferToBuffer: vi.fn(() => { + throw new Error('copy failed'); + }), + finish: vi.fn(() => ({})), + }; + + const device = { + createCommandEncoder: vi.fn(() => commandEncoder), + queue: { + submit: vi.fn(), + }, + } as unknown as GPUDevice; + + const manager = new BufferManager(device); + vi.spyOn(manager, 'createStagingBuffer').mockReturnValue(stagingBuffer); + const releaseBuffer = vi.spyOn(manager, 'releaseBuffer').mockImplementation(() => {}); + + await expect(manager.readBuffer(sourceBuffer, 4)).rejects.toThrow(BufferMapError); + expect(releaseBuffer).toHaveBeenCalledWith(stagingBuffer); + }); + + it('times out stalled mapping and releases the staging buffer', async () => { + vi.useFakeTimers(); + vi.stubGlobal('GPUMapMode', { READ: 1 }); + + const sourceBuffer = {} as GPUBuffer; + const stagingBuffer = { + mapAsync: vi.fn(() => new Promise(() => {})), + getMappedRange: vi.fn(), + unmap: vi.fn(), + destroy: vi.fn(), + } as unknown as GPUBuffer; + + const commandEncoder = { + copyBufferToBuffer: vi.fn(), + finish: vi.fn(() => ({})), + }; + + const device = { + createCommandEncoder: vi.fn(() => commandEncoder), + queue: { + submit: vi.fn(), + }, + } as unknown as GPUDevice; + + const manager = new BufferManager(device); + vi.spyOn(manager, 'createStagingBuffer').mockReturnValue(stagingBuffer); + const releaseBuffer = vi.spyOn(manager, 'releaseBuffer').mockImplementation(() => {}); + + const readPromise = manager.readBuffer(sourceBuffer, 4); + const outcome = Promise.race([ + readPromise.then( + () => new Error('readBuffer should not resolve'), + (error: unknown) => error + ), + new Promise((resolve) => setTimeout(() => resolve('pending'), 30001)), + ]); + + await vi.advanceTimersByTimeAsync(30001); + + const result = await outcome; + + expect(result).toBeInstanceOf(GPUTimeoutError); + expect(releaseBuffer).toHaveBeenCalledWith(stagingBuffer); + }); + }); }); diff --git a/test/core/BufferScope.test.ts b/test/core/BufferScope.test.ts new file mode 100644 index 0000000..5dc1d92 --- /dev/null +++ b/test/core/BufferScope.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BufferScope } from '../../src/core/BufferScope'; + +function createBufferMock(): GPUBuffer { + return { + destroy: vi.fn(), + } as unknown as GPUBuffer; +} + +describe('BufferScope', () => { + it('releases tracked buffers once', () => { + const scope = new BufferScope(); + const first = createBufferMock(); + const second = createBufferMock(); + + expect(scope.track(first)).toBe(first); + scope.track(second); + + scope.releaseAll(); + scope.releaseAll(); + + expect(first.destroy).toHaveBeenCalledTimes(1); + expect(second.destroy).toHaveBeenCalledTimes(1); + }); + + it('does not release untracked buffers', () => { + const scope = new BufferScope(); + const buffer = createBufferMock(); + + scope.track(buffer); + scope.untrack(buffer); + scope.releaseAll(); + + expect(buffer.destroy).not.toHaveBeenCalled(); + }); + + it('uses a custom releaser when provided', () => { + const scope = new BufferScope(); + const buffer = createBufferMock(); + const release = vi.fn(); + + scope.track(buffer, release); + scope.releaseAll(); + + expect(release).toHaveBeenCalledWith(buffer); + expect(buffer.destroy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/core/GPUContext.test.ts b/test/core/GPUContext.test.ts index b8a7dd5..e3b4392 100644 --- a/test/core/GPUContext.test.ts +++ b/test/core/GPUContext.test.ts @@ -1,13 +1,47 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { GPUContext } from '../../src/core/GPUContext'; -import { WebGPUNotSupportedError } from '../../src/core/errors'; +import { GPUAdapterError, GPUDeviceError, WebGPUNotSupportedError } from '../../src/core/errors'; + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + +function createRuntimeHarness() { + const lost = createDeferred(); + const device = { + lost: lost.promise, + destroy: vi.fn(), + } as unknown as GPUDevice; + + const adapter = { + limits: { + maxStorageBufferBindingSize: 4096, + maxComputeInvocationsPerWorkgroup: 256, + maxComputeWorkgroupSizeX: 256, + maxBufferSize: 8192, + }, + requestDevice: vi.fn().mockResolvedValue(device), + } as unknown as GPUAdapter; + + const runtime = { + isSupported: vi.fn(() => true), + requestAdapter: vi.fn().mockResolvedValue(adapter), + }; + + return { runtime, adapter, device, lost }; +} describe('GPUContext', () => { - describe('isSupported', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + describe('isSupported', () => { it('should return false when navigator.gpu is not available', () => { vi.stubGlobal('navigator', {}); expect(GPUContext.isSupported()).toBe(false); @@ -25,16 +59,94 @@ describe('GPUContext', () => { }); describe('initialize', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - it('should throw WebGPUNotSupportedError when WebGPU is not supported', async () => { vi.stubGlobal('navigator', {}); const context = new GPUContext(); await expect(context.initialize()).rejects.toThrow(WebGPUNotSupportedError); }); + + it('initializes through an injected runtime and stores limits info', async () => { + const { runtime, adapter } = createRuntimeHarness(); + const context = Reflect.construct(GPUContext, [runtime]) as GPUContext; + + await context.initialize({ + powerPreference: 'low-power', + requiredLimits: { + maxStorageBufferBindingSize: 1024, + maxBufferSize: 2048, + }, + }); + + expect(runtime.requestAdapter).toHaveBeenCalledWith({ + powerPreference: 'low-power', + }); + expect(adapter.requestDevice).toHaveBeenCalledWith({ + requiredFeatures: [], + requiredLimits: { + maxStorageBufferBindingSize: 1024, + maxBufferSize: 2048, + }, + }); + expect(context.getLimitsInfo()).toEqual({ + maxStorageBufferBindingSize: 4096, + maxComputeInvocationsPerWorkgroup: 256, + maxComputeWorkgroupSizeX: 256, + maxBufferSize: 8192, + }); + }); + + it('throws GPUAdapterError when injected runtime cannot provide an adapter', async () => { + const context = Reflect.construct(GPUContext, [ + { + isSupported: () => true, + requestAdapter: vi.fn().mockResolvedValue(null), + }, + ]) as GPUContext; + + await expect(context.initialize()).rejects.toThrow(GPUAdapterError); + }); + + it('throws GPUDeviceError when injected adapter cannot provide a device', async () => { + const context = Reflect.construct(GPUContext, [ + { + isSupported: () => true, + requestAdapter: vi.fn().mockResolvedValue({ + limits: { + maxStorageBufferBindingSize: 4096, + maxComputeInvocationsPerWorkgroup: 256, + maxComputeWorkgroupSizeX: 256, + maxBufferSize: 8192, + }, + requestDevice: vi.fn().mockResolvedValue(null), + }), + }, + ]) as GPUContext; + + await expect(context.initialize()).rejects.toThrow(GPUDeviceError); + }); + + it('notifies device loss callbacks from an injected runtime device', async () => { + const { runtime, lost } = createRuntimeHarness(); + const callback = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const context = Reflect.construct(GPUContext, [runtime]) as GPUContext; + context.onDeviceLoss(callback); + + await context.initialize(); + lost.resolve({ + message: 'device gone', + reason: 'unknown', + } as GPUDeviceLostInfo); + await Promise.resolve(); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'device gone', + }) + ); + expect(context.isInitialized()).toBe(false); + }); }); describe('getDevice', () => {