🏡 index : cold-air-networks/mirror-webgpu-cts.git

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<void> {
  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<uniform> constants: Constants;

struct Result {
  value: u32
};
@group(1) @binding(1) var<storage, read_write> 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<atomic<i32>>, 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<u32, 10>,
  data: ${type},
  endCanary: array<u32, 10>,
};`;

    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<private> min = 0;
var<private> max = 0;
var<private> 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, []);
    }
  });