export const description = ` Tests to check datatype clamping in shaders is correctly implemented for all indexable types (vectors, matrices, sized/unsized arrays) visible to shaders in various ways. TODO: add tests to check that textureLoad operations stay in-bounds. `; import { makeTestGroup } from '../../../common/framework/test_group.js'; import { assert } from '../../../common/util/util.js'; import { Float16Array } from '../../../external/petamoriken/float16/float16.js'; import { AllFeaturesMaxLimitsGPUTest, GPUTest } from '../../gpu_test.js'; import { align } from '../../util/math.js'; import { generateTypes, supportedScalarTypes, supportsAtomics } from '../types.js'; export const g = makeTestGroup(AllFeaturesMaxLimitsGPUTest); const kMaxU32 = 0xffff_ffff; const kMaxI32 = 0x7fff_ffff; const kMinI32 = -0x8000_0000; /** * Wraps the provided source into a harness that checks calling `runTest()` returns 0. * * Non-test bindings are in bind group 1, including: * - `constants.zero`: a dynamically-uniform `0u` value. */ async function runShaderTest( t: GPUTest, enables: string, stage: GPUShaderStageFlags, testSource: string, layout: GPUPipelineLayout, testBindings: GPUBindGroupEntry[], dynamicOffsets?: number[] ): Promise { assert(stage === GPUShaderStage.COMPUTE, 'Only know how to deal with compute for now'); // Contains just zero (for now). const constantsBuffer = t.createBufferTracked({ size: 4, usage: GPUBufferUsage.UNIFORM }); const resultBuffer = t.createBufferTracked({ size: 4, usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.STORAGE, }); const source = `${enables} struct Constants { zero: u32 }; @group(1) @binding(0) var constants: Constants; struct Result { value: u32 }; @group(1) @binding(1) var result: Result; ${testSource} @compute @workgroup_size(1) fn main() { _ = constants.zero; // Ensure constants buffer is statically-accessed result.value = runTest(); }`; t.debug(source); const module = t.device.createShaderModule({ code: source }); const pipeline = await t.device.createComputePipelineAsync({ layout, compute: { module, entryPoint: 'main' }, }); const group = t.device.createBindGroup({ layout: pipeline.getBindGroupLayout(1), entries: [ { binding: 0, resource: { buffer: constantsBuffer } }, { binding: 1, resource: { buffer: resultBuffer } }, ], }); const testGroup = t.device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: testBindings, }); const encoder = t.device.createCommandEncoder(); const pass = encoder.beginComputePass(); pass.setPipeline(pipeline); pass.setBindGroup(0, testGroup, dynamicOffsets); pass.setBindGroup(1, group); pass.dispatchWorkgroups(1); pass.end(); t.queue.submit([encoder.finish()]); t.expectGPUBufferValuesEqual(resultBuffer, new Uint32Array([0])); } /** Fill an ArrayBuffer with sentinel values, except clear a region to zero. */ function testFillArrayBuffer( array: ArrayBuffer, type: 'u32' | 'i32' | 'f16' | 'f32', { zeroByteStart, zeroByteCount }: { zeroByteStart: number; zeroByteCount: number } ) { const constructor = { u32: Uint32Array, i32: Int32Array, f16: Float16Array, f32: Float32Array }[ type ]; assert(zeroByteCount % constructor.BYTES_PER_ELEMENT === 0); new constructor(array).fill(42); new constructor(array, zeroByteStart, zeroByteCount / constructor.BYTES_PER_ELEMENT).fill(0); } /** * Generate a bunch of indexable types (vec, mat, sized/unsized array) for testing. */ g.test('linear_memory') .desc( `For each indexable data type (vec, mat, sized/unsized array, of various scalar types), attempts to access (read, write, atomic load/store) a region of memory (buffer or internal) at various (signed/unsigned) indices. Checks that the accesses conform to robust access (OOB reads only return bound memory, OOB writes don't write OOB). TODO: Test in/out storage classes. TODO: Test vertex and fragment stages. TODO: Test using a dynamic offset instead of a static offset into uniform/storage bindings. TODO: Test types like vec2>, if that's allowed. TODO: Test exprIndexAddon as constexpr. TODO: Test exprIndexAddon as pipeline-overridable constant expression. TODO: Adjust test logic to support array of f16 in the uniform address space ` ) .params(u => u .combineWithParams([ { addressSpace: 'storage', storageMode: 'read', access: 'read', dynamicOffset: false }, { addressSpace: 'storage', storageMode: 'read_write', access: 'read', dynamicOffset: false, }, { addressSpace: 'storage', storageMode: 'read_write', access: 'write', dynamicOffset: false, }, { addressSpace: 'storage', storageMode: 'read', access: 'read', dynamicOffset: true }, { addressSpace: 'storage', storageMode: 'read_write', access: 'read', dynamicOffset: true }, { addressSpace: 'storage', storageMode: 'read_write', access: 'write', dynamicOffset: true, }, { addressSpace: 'uniform', access: 'read', dynamicOffset: false }, { addressSpace: 'uniform', access: 'read', dynamicOffset: true }, { addressSpace: 'private', access: 'read' }, { addressSpace: 'private', access: 'write' }, { addressSpace: 'function', access: 'read' }, { addressSpace: 'function', access: 'write' }, { addressSpace: 'workgroup', access: 'read' }, { addressSpace: 'workgroup', access: 'write' }, ] as const) .combineWithParams([ { containerType: 'array' }, { containerType: 'matrix' }, { containerType: 'vector' }, ] as const) .combineWithParams([ { shadowingMode: 'none' }, { shadowingMode: 'module-scope' }, { shadowingMode: 'function-scope' }, ]) .expand('isAtomic', p => (supportsAtomics(p) ? [false, true] : [false])) .expand('baseType', supportedScalarTypes) .beginSubcases() .expandWithParams(generateTypes) ) .fn(async t => { const { addressSpace, storageMode, access, dynamicOffset, isAtomic, containerType, baseType, type, shadowingMode, _kTypeInfo, } = t.params; if (baseType === 'f16') { t.skipIfDeviceDoesNotHaveFeature('shader-f16'); } assert(_kTypeInfo !== undefined, 'not an indexable type'); assert('arrayLength' in _kTypeInfo); let usesCanary = false; let globalSource = ''; let testFunctionSource = ''; const testBufferSize = 512; const bufferBindingOffset = 256; /** Undefined if no buffer binding is needed */ let bufferBindingSize: number | undefined = undefined; // Declare the data that will be accessed to check robust access, as a buffer or a struct // in the global scope or inside the test function itself. const structDecl = ` struct S { startCanary: array, data: ${type}, endCanary: array, };`; const testGroupBGLEntires: GPUBindGroupLayoutEntry[] = []; switch (addressSpace) { case 'uniform': case 'storage': { assert(_kTypeInfo.layout !== undefined); const layout = _kTypeInfo.layout; bufferBindingSize = align(layout.size, layout.alignment); const qualifiers = addressSpace === 'storage' ? `storage, ${storageMode}` : addressSpace; globalSource += ` struct TestData { data: ${type}, }; @group(0) @binding(0) var<${qualifiers}> s: TestData;`; testGroupBGLEntires.push({ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: addressSpace === 'uniform' ? 'uniform' : storageMode === 'read' ? 'read-only-storage' : 'storage', hasDynamicOffset: dynamicOffset, }, }); } break; case 'private': case 'workgroup': usesCanary = true; globalSource += structDecl; globalSource += `var<${addressSpace}> s: S;`; break; case 'function': usesCanary = true; globalSource += structDecl; testFunctionSource += 'var s: S;'; break; } // Build the test function that will do the tests. // If we use a local canary declared in the shader, initialize it. if (usesCanary) { testFunctionSource += ` for (var i = 0u; i < 10u; i = i + 1u) { s.startCanary[i] = 0xFFFFFFFFu; s.endCanary[i] = 0xFFFFFFFFu; }`; } /** Returns a different number each time, kind of like a `__LINE__` to ID the failing check. */ const nextErrorReturnValue = (() => { let errorReturnValue = 0x1000; return () => { ++errorReturnValue; return `0x${errorReturnValue.toString(16)}u`; }; })(); // This is here, instead of in subcases, so only a single shader is needed to test many modes. for (const indexSigned of [false, true]) { const indicesToTest = indexSigned ? [ // Exactly in bounds (should be OK) '0', `${_kTypeInfo.arrayLength} - 1`, // Exactly out of bounds '-1', `${_kTypeInfo.arrayLength}`, // Far out of bounds '-1000000', '1000000', `${kMinI32}`, `${kMaxI32}`, ] : [ // Exactly in bounds (should be OK) '0u', `${_kTypeInfo.arrayLength}u - 1u`, // Exactly out of bounds `${_kTypeInfo.arrayLength}u`, // Far out of bounds '1000000u', `${kMaxU32}u`, `${kMaxI32}u`, ]; const indexTypeLiteral = indexSigned ? '0' : '0u'; const indexTypeCast = indexSigned ? 'i32' : 'u32'; for (const exprIndexAddon of [ '', // No addon ` + ${indexTypeLiteral}`, // Add a literal 0 ` + ${indexTypeCast}(constants.zero)`, // Add a uniform 0 ]) { // Produce the accesses to the variable. for (const indexToTest of indicesToTest) { testFunctionSource += ` { let index = (${indexToTest})${exprIndexAddon};`; const exprZeroElement = `${_kTypeInfo.elementBaseType}()`; const exprElement = `s.data[index]`; const suffices = _kTypeInfo.accessSuffixes ?? ['']; switch (access) { case 'read': { const exprLoadElement = isAtomic ? `atomicLoad(&${exprElement})` : exprElement; let conditions = suffices.map(x => `${exprLoadElement}${x} != ${exprZeroElement}`); if (containerType === 'matrix') { // The comparison is a vector bool result. // Convert that to a scalar bool. conditions = conditions.map(c => `any(${c})`); } conditions.forEach(c => { testFunctionSource += ` if (${c}) { return ${nextErrorReturnValue()}; }`; }); } break; case 'write': if (isAtomic) { testFunctionSource += ` atomicStore(&s.data[index], ${exprZeroElement});`; } else { suffices.forEach(x => { testFunctionSource += ` s.data[index]${x} = ${exprZeroElement};`; }); } break; } testFunctionSource += ` }`; } } } // Check that the canaries haven't been modified if (usesCanary) { testFunctionSource += ` for (var i = 0u; i < 10u; i = i + 1u) { if (s.startCanary[i] != 0xFFFFFFFFu) { return ${nextErrorReturnValue()}; } if (s.endCanary[i] != 0xFFFFFFFFu) { return ${nextErrorReturnValue()}; } }`; } // Shadowing case declarations let moduleScopeShadowDecls = ''; let functionScopeShadowDecls = ''; switch (shadowingMode) { case 'module-scope': // Shadow the builtins likely used by robustness as module-scope variables moduleScopeShadowDecls = ` var min = 0; var max = 0; var arrayLength = 0; `; // Make sure that these are referenced by the function. // This ensures that compilers don't strip away unused variables. functionScopeShadowDecls = ` _ = min; _ = max; _ = arrayLength; `; break; case 'function-scope': // Shadow the builtins likely used by robustness as function-scope variables functionScopeShadowDecls = ` let min = 0; let max = 0; let arrayLength = 0; `; break; } // Run the test // First aggregate the test source const testSource = ` ${globalSource} ${moduleScopeShadowDecls} fn runTest() -> u32 { ${functionScopeShadowDecls} ${testFunctionSource} return 0u; }`; const layout = t.device.createPipelineLayout({ bindGroupLayouts: [ t.device.createBindGroupLayout({ entries: testGroupBGLEntires, }), t.device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform', }, }, { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage', }, }, ], }), ], }); const enables = t.params.baseType === 'f16' ? 'enable f16;' : ''; // Run it. if (bufferBindingSize !== undefined && baseType !== 'bool') { const expectedData = new ArrayBuffer(testBufferSize); const bufferBindingEnd = bufferBindingOffset + bufferBindingSize; testFillArrayBuffer(expectedData, baseType, { zeroByteStart: bufferBindingOffset, zeroByteCount: bufferBindingSize, }); // Create a buffer that contains zeroes in the allowed access area, and 42s everywhere else. const testBuffer = t.makeBufferWithContents( new Uint8Array(expectedData), GPUBufferUsage.COPY_SRC | GPUBufferUsage.UNIFORM | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST ); // Run the shader, accessing the buffer. await runShaderTest( t, enables, GPUShaderStage.COMPUTE, testSource, layout, [ { binding: 0, resource: { buffer: testBuffer, offset: dynamicOffset ? 0 : bufferBindingOffset, size: bufferBindingSize, }, }, ], dynamicOffset ? [bufferBindingOffset] : undefined ); // Check that content of the buffer outside of the allowed area didn't change. const expectedBytes = new Uint8Array(expectedData); t.expectGPUBufferValuesEqual(testBuffer, expectedBytes.subarray(0, bufferBindingOffset), 0); t.expectGPUBufferValuesEqual( testBuffer, expectedBytes.subarray(bufferBindingEnd, testBufferSize), bufferBindingEnd ); } else { await runShaderTest(t, enables, GPUShaderStage.COMPUTE, testSource, layout, []); } });