Skip to content

Conversation

@shaoboyan091
Copy link
Contributor

@shaoboyan091 shaoboyan091 commented Dec 31, 2025

Adds pipeline_immediate.spec.ts to validate immediate data usage in RenderPassEncoder, ComputePassEncoder, and RenderBundleEncoder.

Tests cover:

  • Required immediate data slots are set.
  • Unused variables do not require slots.
  • Pipeline creation fails if shader immediate size exceeds layout limit.
  • RenderBundle execution invalidates pipeline and immediate data state.

Issue: #


Requirements for PR author:

  • All missing test coverage is tracked with "TODO" or .unimplemented().
  • New helpers are /** documented */ and new helper files are found in helper_index.txt.
  • Test behaves as expected in a WebGPU implementation. (If not passing, explain above.)
  • Test have be tested with compatibility mode validation enabled and behave as expected. (If not passing, explain above.)

Requirements for reviewer sign-off:

  • Tests are properly located in the test tree.
  • Test descriptions allow a reader to "read only the test plans and evaluate coverage completeness", and accurately reflect the test code.
  • Tests provide complete coverage (including validation control cases). Missing coverage MUST be covered by TODOs.
  • Helpers and types promote readability and maintainability.

When landing this PR, be sure to make any necessary issue status updates.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Comment on lines +525 to +526
const maxImmediateSize = t.device.limits.maxImmediateSize ?? 64;

Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test uses a fallback value of 64 for maxImmediateSize when it's undefined, but this might not match the actual spec default. The test should either skip when maxImmediateSize is not defined (similar to how the init method checks for support), or use the actual spec-defined default value if there is one.

Suggested change
const maxImmediateSize = t.device.limits.maxImmediateSize ?? 64;
const maxImmediateSize = t.device.limits.maxImmediateSize;
if (maxImmediateSize === undefined) {
t.skip('maxImmediateSize limit is not supported on this device.');
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines +516 to +543
g.test('immediate_data_size')
.desc(
`
Test that creating a pipeline layout with immediateSize validates:
- immediateSize must be a multiple of 4.
- immediateSize must be <= device.limits.maxImmediateSize.
`
)
.fn(t => {
const maxImmediateSize = t.device.limits.maxImmediateSize ?? 64;

const kTestSizes = [0, 4, maxImmediateSize, maxImmediateSize + 4, 1, 2, 3, 5];

for (const size of kTestSizes) {
const descriptor = {
bindGroupLayouts: [],
immediateSize: size,
};

const isMultipleOf4 = size % 4 === 0;
const isWithinLimit = size <= maxImmediateSize;
const shouldSucceed = isMultipleOf4 && isWithinLimit;

t.expectValidationError(() => {
t.device.createPipelineLayout(descriptor);
}, !shouldSucceed);
}
});
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test validates immediateSize constraints but doesn't test edge cases like negative values, NaN, or Infinity. While these may be caught by TypeScript, runtime validation should still be tested for robustness, especially since JavaScript allows these values.

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +169
case 'struct_padding':
kImmediateSize = 64;
code = `
struct A { v: vec3<u32>, }
struct B { v: vec2<u32>, }
struct Data { a: A, @align(32) b: B, }
var<immediate> data: Data;
@compute @workgroup_size(1) fn main_compute() { _ = data.b.v; }
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { _ = data.b.v; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { _ = data.b.v; return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the struct_padding scenario, the test accesses only 'data.b.v' in the shader but still requires setting bytes for 'data.a' as well (lines 212-213). The test description should clarify that even though only 'data.b.v' is accessed, all non-padding bytes of the struct must be set because the entire struct variable is considered "statically used" once any part of it is accessed. This is an important validation rule that deserves explicit documentation in the test description.

Copilot uses AI. Check for mistakes.
`;

import { makeTestGroup } from '../../../../../common/framework/test_group.js';
import { hasFeature } from '../../../../../common/util/util.js';
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import of hasFeature is unused since the WGSL feature check should be done using getGPU(this.rec).wgslLanguageFeatures.has() instead. Consider removing this import and adding import { getGPU } from '../../../../../common/util/navigator_gpu.js'; to match the pattern in setImmediates.spec.ts.

Copilot uses AI. Check for mistakes.
Comment on lines +170 to +180
case 'functions':
kImmediateSize = 16;
code = `
var<immediate> data: vec4<u32>;
fn use_data() { _ = data; }
@compute @workgroup_size(1) fn main_compute() { use_data(); }
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { use_data(); return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { use_data(); return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
break;
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test checks that immediate data used in a function called by the entry point requires slots to be set. However, it doesn't test the negative case where the function is declared but never called. Consider adding test coverage for a scenario where an immediate variable is used in a function that is never called by any entry point, which should not require slots to be set.

Copilot uses AI. Check for mistakes.
Comment on lines 97 to 244
g.test('required_slots_set')
.desc(
`
Validate that all immediate data slots required by the pipeline are set on the encoder.
- For each immediate data variable statically used by the pipeline:
- All accessible slots (4-byte words) must be set via setImmediates.
- Scenarios:
- scalar: Simple u32 usage.
- vector: Simple vec4<u32> usage.
- struct_padding: Struct with padding. Padding bytes do not need to be set.
- functions: Immediate data used in a function called by entry point.
- dynamic_indexing: Array with dynamic indexing.
- Usage:
- full: Set all declared bytes.
- split: Set all declared bytes in multiple calls.
- partial: Set only a subset of bytes.
- For struct_padding, this means setting only members (no padding), which is valid.
- For others, this means missing required data, which is invalid.
`
)
.params(u =>
u
.combine('encoderType', kProgrammableEncoderTypes)
.combine('scenario', [
'scalar',
'vector',
'struct_padding',
'functions',
'dynamic_indexing',
] as const)
.combine('usage', ['full', 'partial', 'split'] as const)
.filter(t => {
if (t.scenario === 'scalar' && t.usage === 'split') return false;
return true;
})
)
.fn(t => {
const { encoderType, scenario, usage } = t.params;

let code = '';
let kImmediateSize = 0;

switch (scenario) {
case 'scalar':
kImmediateSize = 4;
code = `
var<immediate> data: u32;
@compute @workgroup_size(1) fn main_compute() { _ = data; }
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { _ = data; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { _ = data; return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
break;
case 'vector':
kImmediateSize = 16;
code = `
var<immediate> data: vec4<u32>;
@compute @workgroup_size(1) fn main_compute() { _ = data; }
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { _ = data; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { _ = data; return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
break;
case 'struct_padding':
kImmediateSize = 64;
code = `
struct A { v: vec3<u32>, }
struct B { v: vec2<u32>, }
struct Data { a: A, @align(32) b: B, }
var<immediate> data: Data;
@compute @workgroup_size(1) fn main_compute() { _ = data.b.v; }
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { _ = data.b.v; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { _ = data.b.v; return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
break;
case 'functions':
kImmediateSize = 16;
code = `
var<immediate> data: vec4<u32>;
fn use_data() { _ = data; }
@compute @workgroup_size(1) fn main_compute() { use_data(); }
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { use_data(); return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { use_data(); return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
break;
case 'dynamic_indexing':
kImmediateSize = 16;
code = `
var<immediate> data: array<u32, 4>;
@compute @workgroup_size(1) fn main_compute(@builtin(local_invocation_index) i: u32) { _ = data[i]; }
@vertex fn main_vertex(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> { _ = data[i]; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let i = u32(pos.x);
_ = data[i];
return vec4<f32>(0.0, 1.0, 0.0, 1.0);
}
`;
break;
}

const pipeline = t.createPipeline(encoderType, code, kImmediateSize);
const { encoder, validateFinishAndSubmit } = t.createEncoder(encoderType);

const setImmediates = (offset: number, size: number) => {
const data = new Uint8Array(size);
encoder.setImmediates(offset, data, 0, size);
};

if (scenario === 'struct_padding') {
// Size 64. A: 0-12. Padding: 12-32. B: 32-40. Padding: 40-64.
if (usage === 'full') {
setImmediates(0, 64);
} else if (usage === 'split') {
setImmediates(0, 32);
setImmediates(32, 32);
} else if (usage === 'partial') {
// Set members only
setImmediates(0, 12);
setImmediates(32, 8);
}
} else if (scenario === 'scalar') {
// Size 4.
if (usage === 'full') {
setImmediates(0, 4);
} else if (usage === 'partial') {
// Set nothing
}
} else {
// Vector (size 16), functions (size 16), dynamic_indexing (size 16)
if (usage === 'full') {
setImmediates(0, 16);
} else if (usage === 'split') {
setImmediates(0, 8);
setImmediates(8, 8);
} else if (usage === 'partial') {
setImmediates(0, 8); // Missing last 8 bytes
}
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
t.runPass(encoderType, encoder as any, pipeline);

const shouldSucceed =
usage === 'full' ||
usage === 'split' ||
(scenario === 'struct_padding' && usage === 'partial');
validateFinishAndSubmit(shouldSucceed, true);
});
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test validates that all immediate data slots required by the pipeline must be set via setImmediates. However, it doesn't test the case where more data than required is set (e.g., setting 32 bytes when only 16 are required by the shader). This should be valid and could be worth testing to ensure overprovision doesn't cause issues.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +424
g.test('render_bundle_execution_state_invalidation')
.desc(
`
Validate that executeBundles invalidates the current pipeline and immediate data state
in the RenderPassEncoder.
- Pipeline must be re-set after executeBundles.
- Immediate data must be re-set after executeBundles.
- setImmediates in bundle does not leak to pass.
`
)
.params(u =>
u.combine('check', [
'pipeline_invalidated',
'immediates_invalidated',
'bundle_no_leak',
] as const)
)
.fn(t => {
const { check } = t.params;
const kImmediateSize = 16;

// Create a pipeline requiring immediate data
const code = `
var<immediate> data: vec4<u32>;
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { _ = data; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { _ = data; return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
const pipeline = t.createPipeline('render pass', code, kImmediateSize) as GPURenderPipeline;

// Create an empty bundle (or one that sets immediates for the leak test)
const bundleEncoder = t.device.createRenderBundleEncoder({
colorFormats: ['rgba8unorm'],
});
if (check === 'bundle_no_leak') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bundleEncoder as any).setPipeline(pipeline);
const d = new Uint8Array(16);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bundleEncoder as any).setImmediates(0, d, 0, 16);
bundleEncoder.draw(3);
}
const bundle = bundleEncoder.finish();

const { encoder, validateFinishAndSubmit } = t.createEncoder('render pass');
const pass = encoder;

// Initial setup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setPipeline(pipeline);
const d = new Uint8Array(16);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setImmediates(0, d, 0, 16);

// Execute bundle - this should invalidate state
pass.executeBundles([bundle]);

// Try to draw
if (check === 'pipeline_invalidated') {
// Don't re-set pipeline. Should fail.
pass.draw(3);
validateFinishAndSubmit(false, true);
} else if (check === 'immediates_invalidated') {
// Re-set pipeline, but not immediates. Should fail.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setPipeline(pipeline);
pass.draw(3);
validateFinishAndSubmit(false, true);
} else if (check === 'bundle_no_leak') {
// Re-set pipeline. Bundle had setImmediates, but it shouldn't leak.
// We didn't re-set immediates in the pass. Should fail.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setPipeline(pipeline);
pass.draw(3);
validateFinishAndSubmit(false, true);
}
});
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test validates that executeBundles invalidates pipeline and immediate data state, but it doesn't test the positive control case where the pipeline and immediates are properly re-set after executeBundles, which should succeed. Consider adding a test case where both pipeline and immediates are re-set after executeBundles to validate that the state can be correctly restored.

Copilot uses AI. Check for mistakes.
Comment on lines 234 to 420
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t.runPass(encoderType, encoder as any, pipeline);

const shouldSucceed =
usage === 'full' ||
usage === 'split' ||
(scenario === 'struct_padding' && usage === 'partial');
validateFinishAndSubmit(shouldSucceed, true);
});

g.test('unused_variable')
.desc(
`
Validate that if an immediate data variable is declared but not statically used,
it does not require slots to be set.
`
)
.params(u =>
u
.combine('encoderType', kProgrammableEncoderTypes)
.combine('usage', ['none', 'full', 'partial_start'] as const)
)
.fn(t => {
const { encoderType, usage } = t.params;
const kImmediateSize = 16;

const code = `
var<immediate> data: vec4<u32>;
@compute @workgroup_size(1) fn main_compute() {
// data is not used
}
@vertex fn main_vertex() -> @builtin(position) vec4<f32> {
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
}
@fragment fn main_fragment() -> @location(0) vec4<f32> {
return vec4<f32>(0.0, 1.0, 0.0, 1.0);
}
`;

const pipeline = t.createPipeline(encoderType, code, kImmediateSize);

const { encoder, validateFinishAndSubmit } = t.createEncoder(encoderType);

if (usage === 'full') {
const data = new Uint8Array(16);
encoder.setImmediates(0, data, 0, 16);
} else if (usage === 'partial_start') {
const data = new Uint8Array(8);
encoder.setImmediates(0, data, 0, 8);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
t.runPass(encoderType, encoder as any, pipeline);

validateFinishAndSubmit(true, true);
});

g.test('pipeline_creation_immediate_size_mismatch')
.desc(
`
Validate that creating a pipeline fails if the shader uses immediate data
larger than the immediateSize specified in the pipeline layout.
`
)
.params(u => u.combine('encoderType', kProgrammableEncoderTypes))
.fn(t => {
const { encoderType } = t.params;
const kLayoutSize = 16;
const kShaderSize = 32; // Larger than layout

const layout = t.device.createPipelineLayout({
bindGroupLayouts: [],
immediateSize: kLayoutSize,
});

const code = `
var<immediate> data: array<u32, ${kShaderSize / 4}>;
@compute @workgroup_size(1) fn main_compute() { _ = data[0]; }
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { _ = data[0]; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { _ = data[0]; return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;

t.expectValidationError(() => {
if (encoderType === 'compute pass') {
t.device.createComputePipeline({
layout,
compute: {
module: t.device.createShaderModule({ code }),
entryPoint: 'main_compute',
},
});
} else {
t.device.createRenderPipeline({
layout,
vertex: {
module: t.device.createShaderModule({ code }),
entryPoint: 'main_vertex',
},
fragment: {
module: t.device.createShaderModule({ code }),
entryPoint: 'main_fragment',
targets: [{ format: 'rgba8unorm' }],
},
});
}
});
});

g.test('render_bundle_execution_state_invalidation')
.desc(
`
Validate that executeBundles invalidates the current pipeline and immediate data state
in the RenderPassEncoder.
- Pipeline must be re-set after executeBundles.
- Immediate data must be re-set after executeBundles.
- setImmediates in bundle does not leak to pass.
`
)
.params(u =>
u.combine('check', [
'pipeline_invalidated',
'immediates_invalidated',
'bundle_no_leak',
] as const)
)
.fn(t => {
const { check } = t.params;
const kImmediateSize = 16;

// Create a pipeline requiring immediate data
const code = `
var<immediate> data: vec4<u32>;
@vertex fn main_vertex() -> @builtin(position) vec4<f32> { _ = data; return vec4<f32>(0.0, 0.0, 0.0, 1.0); }
@fragment fn main_fragment() -> @location(0) vec4<f32> { _ = data; return vec4<f32>(0.0, 1.0, 0.0, 1.0); }
`;
const pipeline = t.createPipeline('render pass', code, kImmediateSize) as GPURenderPipeline;

// Create an empty bundle (or one that sets immediates for the leak test)
const bundleEncoder = t.device.createRenderBundleEncoder({
colorFormats: ['rgba8unorm'],
});
if (check === 'bundle_no_leak') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bundleEncoder as any).setPipeline(pipeline);
const d = new Uint8Array(16);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(bundleEncoder as any).setImmediates(0, d, 0, 16);
bundleEncoder.draw(3);
}
const bundle = bundleEncoder.finish();

const { encoder, validateFinishAndSubmit } = t.createEncoder('render pass');
const pass = encoder;

// Initial setup
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setPipeline(pipeline);
const d = new Uint8Array(16);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setImmediates(0, d, 0, 16);

// Execute bundle - this should invalidate state
pass.executeBundles([bundle]);

// Try to draw
if (check === 'pipeline_invalidated') {
// Don't re-set pipeline. Should fail.
pass.draw(3);
validateFinishAndSubmit(false, true);
} else if (check === 'immediates_invalidated') {
// Re-set pipeline, but not immediates. Should fail.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setPipeline(pipeline);
pass.draw(3);
validateFinishAndSubmit(false, true);
} else if (check === 'bundle_no_leak') {
// Re-set pipeline. Bundle had setImmediates, but it shouldn't leak.
// We didn't re-set immediates in the pass. Should fail.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(pass as any).setPipeline(pipeline);
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test repeatedly casts encoder to 'any' to call setImmediates and setPipeline methods (lines 235, 289, 380, 383, 393, 396, 409, 416). While this is necessary due to the experimental nature of the API, the pattern could be improved by creating a helper type or using a consistent pattern. Consider defining a type alias at the file level to reduce repetition and improve maintainability.

Copilot uses AI. Check for mistakes.
'setImmediates' in (GPURenderBundleEncoder.prototype as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supportsLimit = (this.device.limits as any).maxImmediateSize !== undefined;
const supportsWgslFeature = hasFeature(this.device.features, 'immediate-address-space');
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for WGSL feature support should use getGPU(this.rec).wgslLanguageFeatures.has('immediate_address_space') instead of hasFeature(this.device.features, 'immediate-address-space'). This is the pattern used in setImmediates.spec.ts and correctly checks WGSL language features rather than device features. Note that the feature name is 'immediate_address_space' with an underscore.

Copilot uses AI. Check for mistakes.
Adds `pipeline_immediate.spec.ts` to validate immediate data usage in
RenderPassEncoder, ComputePassEncoder, and RenderBundleEncoder.

Tests cover:
- Required immediate data slots are set.
- Unused variables do not require slots.
- Pipeline creation fails if shader immediate size exceeds layout limit.
- RenderBundle execution invalidates pipeline and immediate data state.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant