blob: 9342a227053ac88bcd17b046ef3a03fcfc6b45b2 [file] [log] [blame]
// Copyright 2022 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import { globalTestConfig } from '../third_party/webgpu-cts/src/common/framework/test_config.js';
import { dataCache } from '../third_party/webgpu-cts/src/common/framework/data_cache.js';
import { getResourcePath } from '../third_party/webgpu-cts/src/common/framework/resources.js';
import { DefaultTestFileLoader } from '../third_party/webgpu-cts/src/common/internal/file_loader.js';
import { prettyPrintLog } from '../third_party/webgpu-cts/src/common/internal/logging/log_message.js';
import { Logger } from '../third_party/webgpu-cts/src/common/internal/logging/logger.js';
import { parseQuery } from '../third_party/webgpu-cts/src/common/internal/query/parseQuery.js';
import { parseSearchParamLikeWithCTSOptions } from '../third_party/webgpu-cts/src/common/runtime/helper/options.js';
import { setDefaultRequestAdapterOptions } from '../third_party/webgpu-cts/src/common/util/navigator_gpu.js';
import { unreachable } from '../third_party/webgpu-cts/src/common/util/util.js';
import { TestDedicatedWorker, TestSharedWorker, TestServiceWorker } from '../third_party/webgpu-cts/src/common/runtime/helper/test_worker.js';
// The Python-side websockets library has a max payload size of 72638. Set the
// max allowable logs size in a single payload to a bit less than that.
const LOGS_MAX_BYTES = 72000;
var socket;
// Returns a wrapper around `fn` which gets called at most once every `intervalMs`.
// If the wrapper is called when `fn` was called too recently, `fn` is scheduled to
// be called later in the future after the interval passes.
// Returns [ wrappedFn, {start, stop}] where wrappedFn is the rate-limited function,
// and start/stop control whether or not the function is enabled. If it is stopped, calls
// to the fn will no-op. If it is started, calls will be rate-limited, starting from
// the time `start` is called.
function rateLimited(fn, intervalMs) {
let last = undefined;
let timer = undefined;
const wrappedFn = (...args) => {
if (last === undefined) {
// If the function is not enabled, return.
return;
}
// Get the current time as a number.
const now = +new Date();
const diff = now - last;
if (diff >= intervalMs) {
// Clear the timer, if there was one. This could happen if a timer
// is scheduled, but it never runs due to long-running synchronous
// code.
if (timer) {
clearTimeout(timer);
timer = undefined;
}
// Call the function.
last = now;
fn(...args);
} else if (timer === undefined) {
// Otherwise, we have called `fn` too recently.
// Schedule a future call.
timer = setTimeout(() => {
// Clear the timer to indicate nothing is scheduled.
timer = undefined;
last = +new Date();
fn(...args);
}, intervalMs - diff + 1);
}
};
return [
wrappedFn,
{
start: () => {
last = +new Date();
},
stop: () => {
last = undefined;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
},
}
];
}
function byteSize(s) {
return new Blob([s]).size;
}
async function setupWebsocket(port) {
socket = new WebSocket('ws://127.0.0.1:' + port)
socket.addEventListener('open', () => {
socket.send('{"type":"CONNECTION_ACK"}');
});
socket.addEventListener('message', runCtsTestViaSocket);
}
async function runCtsTestViaSocket(event) {
let input = JSON.parse(event.data);
runCtsTest(input['q']);
}
dataCache.setStore({
load: async (path) => {
const fullPath = getResourcePath(`cache/${path}`);
const response = await fetch(fullPath);
if (!response.ok) {
return Promise.reject(`failed to load cache file: '${fullPath}'
reason: ${response.statusText}`);
}
return new Uint8Array(await response.arrayBuffer());
}
});
// Make a rate-limited version `sendMessageTestHeartbeat` that executes
// at most once every 500 ms.
const [sendHeartbeat, {
start: beginHeartbeatScope,
stop: endHeartbeatScope
}] = rateLimited(sendMessageTestHeartbeat, 500);
function wrapPromiseWithHeartbeat(prototype, key) {
const old = prototype[key];
prototype[key] = function (...args) {
return new Promise((resolve, reject) => {
// Send the heartbeat both before and after resolve/reject
// so that the heartbeat is sent ahead of any potentially
// long-running synchronous code awaiting the Promise.
old.call(this, ...args)
.then(val => { sendHeartbeat(); resolve(val) })
.catch(err => { sendHeartbeat(); reject(err) })
.finally(sendHeartbeat);
});
}
}
wrapPromiseWithHeartbeat(GPU.prototype, 'requestAdapter');
wrapPromiseWithHeartbeat(GPUAdapter.prototype, 'requestAdapterInfo');
wrapPromiseWithHeartbeat(GPUAdapter.prototype, 'requestDevice');
wrapPromiseWithHeartbeat(GPUDevice.prototype, 'createRenderPipelineAsync');
wrapPromiseWithHeartbeat(GPUDevice.prototype, 'createComputePipelineAsync');
wrapPromiseWithHeartbeat(GPUDevice.prototype, 'popErrorScope');
wrapPromiseWithHeartbeat(GPUQueue.prototype, 'onSubmittedWorkDone');
wrapPromiseWithHeartbeat(GPUBuffer.prototype, 'mapAsync');
wrapPromiseWithHeartbeat(GPUShaderModule.prototype, 'getCompilationInfo');
globalTestConfig.testHeartbeatCallback = sendHeartbeat;
globalTestConfig.noRaceWithRejectOnTimeout = true;
// FXC is very slow to compile unrolled const-eval loops, where the metal shader
// compiler (Intel GPU) is very slow to compile rolled loops. Intel drivers for
// linux may also suffer the same performance issues, so unroll const-eval loops
// if we're not running on Windows.
const isWindows = navigator.userAgent.includes("Windows");
if (!isWindows) {
globalTestConfig.unrollConstEvalLoops = true;
}
let lastOptionsKey, testWorker;
async function runCtsTest(queryString) {
const { queries, options } = parseSearchParamLikeWithCTSOptions(queryString);
// Set up a worker with the options passed into the test, avoiding creating
// a new worker if one was already set up for the last test.
// In practice, the options probably won't change between tests in a single
// invocation of run_gpu_integration_test.py, but this handles if they do.
const currentOptionsKey = JSON.stringify(options);
if (currentOptionsKey !== lastOptionsKey) {
lastOptionsKey = currentOptionsKey;
testWorker =
options.worker === null ? null :
options.worker === 'dedicated' ? new TestDedicatedWorker(options) :
options.worker === 'shared' ? new TestSharedWorker(options) :
options.worker === 'service' ? new TestServiceWorker(options) :
unreachable();
}
const loader = new DefaultTestFileLoader();
const filterQuery = parseQuery(queries[0]);
const testcases = Array.from(await loader.loadCases(filterQuery));
if (testcases.length === 0) {
sendMessageInfraFailure('Did not find matching test');
return;
}
if (testcases.length !== 1) {
sendMessageInfraFailure('Found more than 1 test for given query');
return;
}
const testcase = testcases[0];
const { compatibility, powerPreference } = options;
globalTestConfig.compatibility = compatibility;
if (powerPreference || compatibility) {
setDefaultRequestAdapterOptions({
...(powerPreference && { powerPreference }),
// MAINTENANCE_TODO(gman): Change this to whatever the option ends up being
...(compatibility && { compatibilityMode: true }),
});
}
const expectations = [];
const log = new Logger();
const name = testcase.query.toString();
// Logs look like: " - EXPECTATION FAILED: subcase: foobar=2;foo=a;bar=2\n...."
// or "EXCEPTION: Name!: Message!\nsubcase: fail = true\n..."
const subcaseLogPrefixRegex = /\s?subcase: .*$/m;
const wpt_fn = async () => {
sendMessageTestStarted();
const [rec, res] = log.record(name);
beginHeartbeatScope();
if (testWorker) {
await testWorker.run(rec, name, expectations);
} else {
await testcase.run(rec, expectations);
}
endHeartbeatScope();
sendMessageTestStatus(res.status, res.timems);
if (res.status === 'pass') {
// Send an "OK" log. Passing tests don't report logs to Telemetry.
sendMessageTestLogOK();
} else {
// Log all the logs to the console so they are visible.
if (res.logs) {
// Log the query string first as logs from multiple browsers may be interleaved and prefixed
// with their process id. Logging the test name lets us see what process a test ran in.
console.log(`Logs from ${queryString}`);
for (const l of res.logs) {
console.log(l);
}
}
// Report non-INFO logs to the harness so they don't show up in the LUCI failure reason.
// Strip out subcase information for better clustering.
sendMessageTestLog((res.logs || [])
.filter(l => l.name !== 'INFO')
.map(prettyPrintLog)
.map(l => l.replace(subcaseLogPrefixRegex, '')));
}
sendMessageTestFinished();
};
await wpt_fn();
}
function splitLogsForPayload(fullLogs) {
let logPieces = [fullLogs]
// Split the log pieces until they all are guaranteed to fit into a
// websocket payload.
while (true) {
let tempLogPieces = []
for (const piece of logPieces) {
if (byteSize(piece) > LOGS_MAX_BYTES) {
let midpoint = Math.floor(piece.length / 2);
tempLogPieces.push(piece.substring(0, midpoint));
tempLogPieces.push(piece.substring(midpoint));
} else {
tempLogPieces.push(piece)
}
}
// Didn't make any changes - all pieces are under the size limit.
if (logPieces.every((value, index) => value == tempLogPieces[index])) {
break;
}
logPieces = tempLogPieces;
}
return logPieces
}
function sendMessageTestStarted() {
socket.send('{"type":"TEST_STARTED"}');
}
function sendMessageTestHeartbeat() {
socket.send('{"type":"TEST_HEARTBEAT"}');
}
function sendMessageTestStatus(status, jsDurationMs) {
socket.send(JSON.stringify({
'type': 'TEST_STATUS',
'status': status,
'js_duration_ms': jsDurationMs
}));
}
function sendMessageTestLogOK() {
socket.send('{"type":"TEST_LOG","log":"OK"}');
}
function sendMessageTestLog(logs) {
splitLogsForPayload(logs.join('\n\n'))
.forEach((piece) => {
socket.send(JSON.stringify({
'type': 'TEST_LOG',
'log': piece
}));
});
}
function sendMessageTestFinished() {
socket.send('{"type":"TEST_FINISHED"}');
}
function sendMessageInfraFailure(message) {
socket.send(JSON.stringify({
'type': 'INFRA_FAILURE',
'message': message,
}));
}
window.setupWebsocket = setupWebsocket