From 7aafbc2a0fdc80081c9ca529310f33447c8eeee1 Mon Sep 17 00:00:00 2001 From: Junhyung Shim Date: Wed, 12 Aug 2026 17:25:59 +0000 Subject: [PATCH 1/4] ze_validator: graft validator work onto clean alcf/devel Replay the ze_validator work on top of alcf/devel (ab4bc29) without the accidental reverts of upstream that had accumulated on first-half-merge (notably PR #484 'H2yaml is now mandatory' and the Level Zero 1.18.2 header update, which commit 4c34a85 had rolled back). Adds: ze_validator.in, ze_validator_zemodel.rb, ze_validator_function_entry_exit_callbacks.rb, ze_validator_entry_exit_helpers.rb, ze_validator_state_object.rb, ze_thread_safety.yaml, ze_deprecated.json, ze_device_property.cpp Build glue: configure.ac generates backends/ze/ze_validator from ze_validator.in Makefile.am installs the validator script, builds ze_device_property with icpx, and ships the validator data files via data_DATA/EXTRA_DIST. --- backends/ze/Makefile.am | 48 +- backends/ze/ze_deprecated.json | 8 + backends/ze/ze_device_property.cpp | 147 +++ backends/ze/ze_thread_safety.yaml | 29 + backends/ze/ze_validator.in | 282 +++++ .../ze/ze_validator_entry_exit_helpers.rb | 997 ++++++++++++++++++ ...validator_function_entry_exit_callbacks.rb | 912 ++++++++++++++++ backends/ze/ze_validator_state_object.rb | 817 ++++++++++++++ backends/ze/ze_validator_zemodel.rb | 792 ++++++++++++++ configure.ac | 1 + 10 files changed, 4029 insertions(+), 4 deletions(-) create mode 100644 backends/ze/ze_deprecated.json create mode 100644 backends/ze/ze_device_property.cpp create mode 100644 backends/ze/ze_thread_safety.yaml create mode 100644 backends/ze/ze_validator.in create mode 100644 backends/ze/ze_validator_entry_exit_helpers.rb create mode 100644 backends/ze/ze_validator_function_entry_exit_callbacks.rb create mode 100644 backends/ze/ze_validator_state_object.rb create mode 100644 backends/ze/ze_validator_zemodel.rb diff --git a/backends/ze/Makefile.am b/backends/ze/Makefile.am index 942c0947..595855e2 100644 --- a/backends/ze/Makefile.am +++ b/backends/ze/Makefile.am @@ -107,7 +107,12 @@ btx_ze_model.yaml: $(srcdir)/gen_babeltrace_ze_model.rb $(ZE_LIB_GEN) $(ZE_MODEL EXTRA_DIST += \ ze_model.rb \ - gen_babeltrace_ze_model.rb + gen_babeltrace_ze_model.rb \ + ze_validator_zemodel.rb \ + ze_validator_function_entry_exit_callbacks.rb \ + ze_validator_entry_exit_helpers.rb \ + ze_validator_state_object.rb \ + ze_deprecated.json ZE_PROBES = $(ZE_NAMESPACES:=_tracepoints) $(ZE_STRUCTS_NAMESPACES:=_tracepoints) @@ -173,7 +178,35 @@ EXTRA_DIST += \ CLEANFILES += tracer_ze.c bin_SCRIPTS = \ - tracer_ze.sh + tracer_ze.sh \ + ze_validator + +# Standalone Level Zero utility that dumps per-device command-queue-group +# topology (ordinal -> engine type + numQueues) to a JSON file. Meant to be +# invoked by a separate program. Per the requirement, it is compiled with icpx +# rather than the project's default $(CXX). +ICPX = icpx +ZE_DEVICE_PROPERTY_CXXFLAGS = -std=c++17 -Wall -Wextra -O2 +ZE_DEVICE_PROPERTY_CPPFLAGS = -I$(srcdir)/include +ZE_DEVICE_PROPERTY_LIBS = -lze_loader + +ze_device_property$(EXEEXT): $(srcdir)/ze_device_property.cpp + $(ICPX) $(ZE_DEVICE_PROPERTY_CXXFLAGS) $(ZE_DEVICE_PROPERTY_CPPFLAGS) \ + $< -o $@ $(ZE_DEVICE_PROPERTY_LIBS) + +all-local: ze_device_property$(EXEEXT) + +install-exec-local: ze_device_property$(EXEEXT) + $(MKDIR_P) $(DESTDIR)$(bindir) + $(INSTALL_PROGRAM) ze_device_property$(EXEEXT) \ + $(DESTDIR)$(bindir)/ze_device_property$(EXEEXT) + +uninstall-local: + $(RM) $(DESTDIR)$(bindir)/ze_device_property$(EXEEXT) + +CLEANFILES += ze_device_property$(EXEEXT) + +EXTRA_DIST += ze_device_property.cpp noinst_LTLIBRARIES = libzetracepoints.la @@ -242,14 +275,21 @@ EXTRA_DIST += \ gen_ze_library.rb \ gen_babeltrace_ze_lib.rb \ gen_ze_refinements.rb \ - gen_ze_bindings.rb + gen_ze_bindings.rb \ + ze_thread_safety.yaml CLEANFILES += $(ZE_BINDINGS) data_DATA = \ $(ZE_BINDINGS) \ ze_bindings_base.rb \ - babeltrace_zeprofiling_apis.txt + babeltrace_zeprofiling_apis.txt \ + ze_thread_safety.yaml \ + ze_deprecated.json \ + ze_validator_zemodel.rb \ + ze_validator_function_entry_exit_callbacks.rb \ + ze_validator_entry_exit_helpers.rb \ + ze_validator_state_object.rb xprof_utils.hpp: $(top_srcdir)/utils/xprof_utils.hpp cp $< $@ diff --git a/backends/ze/ze_deprecated.json b/backends/ze/ze_deprecated.json new file mode 100644 index 00000000..c2ccb34c --- /dev/null +++ b/backends/ze/ze_deprecated.json @@ -0,0 +1,8 @@ +{ + "zeInit": ["1.10", "zeInitDrivers"], + "zeDriverGet": ["1.10", "zeInitDrivers"], + "zeCommandListImmediateAppendCommandListsExp": ["1.16", "zeCommandListImmediateAppendCommandListsWithParameters"], + "zeImageViewCreateExp": ["", "zeImageViewCreateExt"], + "zesRasGetConfig": ["1.16", "zesRasGetConfigExp"], + "zesRasSetConfig": ["1.16", "zesRasSetConfigExp"] +} \ No newline at end of file diff --git a/backends/ze/ze_device_property.cpp b/backends/ze/ze_device_property.cpp new file mode 100644 index 00000000..edaa0baa --- /dev/null +++ b/backends/ze/ze_device_property.cpp @@ -0,0 +1,147 @@ +// ze_device_property.cpp +// +// Standalone Level Zero utility that queries every device on the system and +// dumps its command-queue-group topology to `ze_device_property.json`. +// +// For each command queue group it reports: +// - the group ordinal (the value passed as `ordinal` in ze_command_queue_desc_t +// or `commandQueueGroupOrdinal` in ze_command_list_desc_t) +// - the engine type derived from the group flags: +// "compute", "copy", or "compute-and-copy" +// - numQueues: the number of physical engines (queue indices) in the group, +// i.e. the valid range for ze_command_queue_desc_t::index is [0, numQueues-1] +// +// This binary is meant to be invoked by a separate program; it performs no +// argument parsing beyond an optional output path. +// +// Build: compiled with icpx (see Makefile.am). + +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +const char *result_to_string(ze_result_t r) { + switch (r) { + case ZE_RESULT_SUCCESS: + return "ZE_RESULT_SUCCESS"; + case ZE_RESULT_ERROR_UNINITIALIZED: + return "ZE_RESULT_ERROR_UNINITIALIZED"; + case ZE_RESULT_ERROR_DEVICE_LOST: + return "ZE_RESULT_ERROR_DEVICE_LOST"; + case ZE_RESULT_ERROR_INVALID_NULL_HANDLE: + return "ZE_RESULT_ERROR_INVALID_NULL_HANDLE"; + case ZE_RESULT_ERROR_INVALID_NULL_POINTER: + return "ZE_RESULT_ERROR_INVALID_NULL_POINTER"; + case ZE_RESULT_ERROR_UNSUPPORTED_FEATURE: + return "ZE_RESULT_ERROR_UNSUPPORTED_FEATURE"; + default: + return "ZE_RESULT_ERROR (unlisted)"; + } +} + +// Fatal-checks a Level Zero call; on failure prints the site and exits(1). +#define ZE_CHECK(call) \ + do { \ + ze_result_t _res = (call); \ + if (_res != ZE_RESULT_SUCCESS) { \ + std::fprintf(stderr, "%s failed: %s (0x%x)\n", #call, \ + result_to_string(_res), (unsigned)_res); \ + std::exit(1); \ + } \ + } while (0) + +// Classify a command queue group by its flags. +const char *engine_type(ze_command_queue_group_property_flags_t flags) { + const bool compute = flags & ZE_COMMAND_QUEUE_GROUP_PROPERTY_FLAG_COMPUTE; + const bool copy = flags & ZE_COMMAND_QUEUE_GROUP_PROPERTY_FLAG_COPY; + if (compute && copy) + return "compute-and-copy"; + if (compute) + return "compute"; + if (copy) + return "copy"; + return "other"; +} + +} // namespace + +int main(int argc, char **argv) { + const char *out_path = + (argc > 1) ? argv[1] : "ze_device_property.json"; + + ZE_CHECK(zeInit(ZE_INIT_FLAG_GPU_ONLY)); + + uint32_t driver_count = 0; + ZE_CHECK(zeDriverGet(&driver_count, nullptr)); + std::vector drivers(driver_count); + if (driver_count > 0) + ZE_CHECK(zeDriverGet(&driver_count, drivers.data())); + + std::ofstream out(out_path); + if (!out) { + std::fprintf(stderr, "unable to open '%s' for writing\n", out_path); + return 1; + } + + out << "{\n \"devices\": ["; + + bool first_device = true; + for (uint32_t d = 0; d < driver_count; ++d) { + uint32_t device_count = 0; + ZE_CHECK(zeDeviceGet(drivers[d], &device_count, nullptr)); + std::vector devices(device_count); + if (device_count > 0) + ZE_CHECK(zeDeviceGet(drivers[d], &device_count, devices.data())); + + for (uint32_t i = 0; i < device_count; ++i) { + ze_device_properties_t dev_props{}; + dev_props.stype = ZE_STRUCTURE_TYPE_DEVICE_PROPERTIES; + ZE_CHECK(zeDeviceGetProperties(devices[i], &dev_props)); + + uint32_t group_count = 0; + ZE_CHECK(zeDeviceGetCommandQueueGroupProperties(devices[i], &group_count, + nullptr)); + std::vector groups(group_count); + for (auto &g : groups) + g.stype = ZE_STRUCTURE_TYPE_COMMAND_QUEUE_GROUP_PROPERTIES; + if (group_count > 0) + ZE_CHECK(zeDeviceGetCommandQueueGroupProperties( + devices[i], &group_count, groups.data())); + + out << (first_device ? "\n" : ",\n"); + first_device = false; + + out << " {\n"; + out << " \"driver_index\": " << d << ",\n"; + out << " \"device_index\": " << i << ",\n"; + out << " \"name\": \"" << dev_props.name << "\",\n"; + out << " \"command_queue_groups\": ["; + + for (uint32_t g = 0; g < group_count; ++g) { + out << (g == 0 ? "\n" : ",\n"); + out << " {\n"; + out << " \"ordinal\": " << g << ",\n"; + out << " \"type\": \"" << engine_type(groups[g].flags) + << "\",\n"; + out << " \"numQueues\": " << groups[g].numQueues << "\n"; + out << " }"; + } + + out << (group_count == 0 ? "" : "\n ") << "]\n"; + out << " }"; + } + } + + out << (first_device ? "" : "\n ") << "]\n}\n"; + out.close(); + + std::fprintf(stderr, "wrote %s\n", out_path); + return 0; +} diff --git a/backends/ze/ze_thread_safety.yaml b/backends/ze/ze_thread_safety.yaml new file mode 100644 index 00000000..b87a46f8 --- /dev/null +++ b/backends/ze/ze_thread_safety.yaml @@ -0,0 +1,29 @@ +--- +zeCommandListDestroy: + - [hCommandList, command_list] +zeCommandListClose: + - [hCommandList, command_list] +zeCommandListReset: + - [hCommandList, command_list] +zeCommandListAppendWriteGlobalTimestamp: + - [hCommandList, command_list] +zeCommandListAppendLaunchKernel: + - [hCommandList, command_list] +zeCommandListAppendBarrier: + - [hCommandList, command_list] +zeCommandListAppendLaunchCooperativeKernel: + - [hCommandList, command_list] +zeCommandListAppendMemoryCopy: + - [hCommandList, command_list] +zeCommandListAppendMemoryFill: + - [hCommandList, command_list] +zeCommandListAppendMemoryCopyRegion: + - [hCommandList, command_list] +zeCommandListAppendSignalEvent: + - [hCommandList, command_list] +zeCommandListAppendWaitOnEvents: + - [hCommandList, command_list] +zeCommandListAppendEventReset: + - [hCommandList, command_list] +zeCommandQueueExecuteCommandLists: + - [phCommandLists_vals, command_list] diff --git a/backends/ze/ze_validator.in b/backends/ze/ze_validator.in new file mode 100644 index 00000000..eaafa1d8 --- /dev/null +++ b/backends/ze/ze_validator.in @@ -0,0 +1,282 @@ +#!/usr/bin/env ruby +# coding: utf-8 +# ============================================================================= +# ze_validator -- a correctness checker for Intel Level Zero ("ze") programs. +# +# WHAT THIS IS +# ------------ +# THAPI (Tracing Heterogeneous APIs) ships a tracer that intercepts every call +# an application makes into the Level Zero runtime and writes it to an LTTng +# trace on disk (a "CTF" trace). This script reads such a trace back and +# replays it against a software model of what the Level Zero runtime would have +# been doing, looking for API misuse the runtime itself does not diagnose: +# leaks, use-after-free, deadlocks, cross-context handle mixing, and so on. +# +# It never runs on the GPU and never touches the application. It is a pure +# post-mortem analysis of a recorded trace. +# +# THE FIVE FILES THAT MAKE UP THE VALIDATOR +# ----------------------------------------- +# ze_validator.in <-- YOU ARE HERE +# Executable entry point. Parses CLI options, wires up the babeltrace2 +# graph that decodes the trace, and hands each decoded event to the +# StateObject. ".in" means autoconf preprocesses it at build time, +# substituting @prefix@ below; the installed file is named `ze_validator`. +# +# ze_validator_zemodel.rb +# Pure data model. Plain Ruby classes mirroring Level Zero objects +# (Device, Context, CommandList, Event, Fence, Memory, ...) plus the +# bookkeeping types used for deferred execution (RecordedOp, DeferredUnit). +# No checking logic lives here -- just state. +# +# ze_validator_state_object.rb +# The engine. Holds the whole model (per host / per process / per thread), +# drives the trace-consumption loop, runs the deferred-execution +# scheduler, and owns every error-reporting method (print_usage_error etc). +# +# ze_validator_function_entry_exit_callbacks.rb +# The dispatch tables. Three global hashes mapping a ze API name to a +# lambda: $upon_entry, $on_successful_exit, $on_erroneous_exit. This is +# where "what does zeMemAllocDevice do to the model" is written. +# +# ze_validator_entry_exit_helpers.rb +# The check library. The `check_*` functions those callbacks call -- +# out-of-bounds copies, use-after-free, deadlock cycles, context matching. +# +# HOW A TRACE BECOMES A DIAGNOSTIC (the data flow) +# ------------------------------------------------ +# trace on disk +# -> babeltrace2 graph (built in build_and_run_graph below) +# -> StateObject#consume receives one CTF event at a time +# -> event name is matched against /:(z.*)_(entry|exit)/ so +# "lttng_ust_ze:zeMemAllocDevice_entry" yields api="zeMemAllocDevice", +# phase="entry" +# -> StateObject#on_entry / #on_exit look the API up in the dispatch tables +# -> the callback mutates the model and/or calls a check_* helper +# -> a violated check prints "Level Zero Error: ..." to stderr +# -> at end of trace StateObject#check_issues reports leaks and deadlocks +# +# WHY EVERY API APPEARS TWICE (entry and exit) +# -------------------------------------------- +# The tracer emits one event just BEFORE the call enters the driver (_entry, +# carrying the input arguments) and one just AFTER it returns (_exit, carrying +# the return code and any output pointers). This matters constantly in the +# callback code: +# * Output handles (the thing a Create call produced) only exist at _exit. +# * If the driver CRASHES inside the call, the _exit event is never written -- +# so any check that could be the thing that crashes must run at _entry, or +# it will silently never fire. Several callbacks carry a comment saying +# exactly this. +# +# TYPICAL USE +# ----------- +# iprof -t -- ./my_app # record a trace with the THAPI tracer +# ze_validator ~/lttng-traces/... # replay it through this validator +# ============================================================================= + +# Where the installed data files live (ze_thread_safety.yaml, ze_deprecated.json, +# ze_device_property.json) and where the ze_library Ruby bindings are found. +# @prefix@ is replaced by autoconf with the configure --prefix at build time. +DATADIR = File.join("@prefix@", "share") +BINDIR = File.join("@prefix@", "bin") +$:.unshift(DATADIR) if File.directory?(DATADIR) +require 'optparse' +require 'babeltrace2' # Ruby bindings for babeltrace2, the CTF trace reader +require 'find' +require 'ze_library' # generated FFI bindings: ZE::ZEResult, ZE::ZE*Desc structs +require 'pp' +require 'set' +require 'ze_validator_zemodel' # the object model (ZEModel::*) +require 'ze_validator_function_entry_exit_callbacks' # populates $upon_entry / $on_*_exit +require 'ze_validator_state_object' # the StateObject engine +require 'yaml' + +# Don't complain about broken pipe +# (restores the default kill-on-SIGPIPE so piping our output into e.g. `head` +# terminates quietly instead of raising Errno::EPIPE out of a puts) +Signal.trap('SIGPIPE', 'SYSTEM_DEFAULT') + +# Runs the ze_device_property helper binary to (re)generate ze_device_property.json +# in DATADIR, where StateObject reads it. Best-effort: if the binary is missing or +# fails (e.g. no accessible GPU on this node), the validator continues without the +# device topology data rather than aborting. +def generate_device_properties + json_path = File.join(DATADIR, 'ze_device_property.json') + binary = File.join(BINDIR, 'ze_device_property') + binary = 'ze_device_property' unless File.executable?(binary) + + ok = system(binary, json_path, out: File::NULL, err: File::NULL) + unless ok + $stderr.puts "Warning: could not generate #{json_path} via '#{binary}'; " \ + "continuing without device properties." + end +rescue SystemCallError => e + $stderr.puts "Warning: could not run ze_device_property: #{e.message}; " \ + "continuing without device properties." +end + +# Command-line defaults. All checking categories are ON unless explicitly +# disabled, so a plain `ze_validator ` gives the most thorough report. +# live - read a live LTTng session instead of a trace directory +# device_agnostic - report portability hazards (hardcoded ordinals etc.) +# performance - report API usage that costs performance +# gen_device_properties - shell out to the ze_device_property helper first +$options = { live: false, device_agnostic: true, performance: true, + gen_device_properties: true } + +OptionParser.new do |opts| + opts.banner = 'Usage: ze_validator [OPTIONS] trace_directory...' + + opts.on('-h', '--help', 'Prints this help') do + puts opts + exit + end + + opts.on('--no-device-properties', + 'Skip running the ze_device_property helper to (re)generate ze_device_property.json') do + $options[:gen_device_properties] = false + end + + opts.on('--live', 'Enable live processing of the trace') do + $options[:live] = true + end + + opts.on('--disable-dagn', 'Disables device agnostic semantic misuse checking') do + $options[:device_agnostic] = false + end + + opts.on('--disable-performance', 'Disables reporting of API usages that results in downgrade of performance') do + $options[:performance] = false + end +end.parse! + + +# Builds and runs the babeltrace2 processing graph that decodes the trace and +# feeds it to the validator, then triggers the end-of-trace reporting pass. +# +# babeltrace2 works as a dataflow graph of components connected port-to-port: +# +# [source: trace_0] --\ +# [source: trace_1] ----> [filter: muxer] --> [sink: our StateObject] +# [source: trace_2] --/ +# +# * SOURCES decode one CTF trace directory each into a stream of messages. +# A traced run produces one trace per process (and per node on a cluster), +# hence potentially many sources. +# * The MUXER merges those streams into a single stream ordered by timestamp. +# This is essential: the validator's model assumes it sees events in the +# order they really happened, across all processes and threads. +# * The SINK is us -- sink_object.consume is a lambda invoked with each batch +# of messages (see StateObject#consume). +# +# `source_location` is the list of paths given on the command line. +def build_and_run_graph( source_location, sink_object ) + # build graph and set up source + graph = BT2::BTGraph.new + + # Look up the three component classes we need from babeltrace2's plugins. + # ctf.fs - read a CTF trace from the filesystem + # ctf.lttng-live - attach to a running LTTng session over the network + # utils.muxer - timestamp-order-merge several streams into one + ctf_fs = BT2::BTPlugin.find('ctf').get_source_component_class_by_name('fs') + ctf_lttng_live = BT2::BTPlugin.find("ctf").get_source_component_class_by_name("lttng-live") + utils_muxer = BT2::BTPlugin.find('utils').get_filter_component_class_by_name('muxer') + + if !$options[:live] + # Offline mode: the user hands us a directory that may contain many traces + # nested at arbitrary depth (LTTng lays out one subdirectory per process, + # per node, per UST channel). Walk it and work out which directories are + # actually readable CTF traces, in four steps: + # 1. recursively enumerate every path, keeping only files + # 2. keep the ones literally named "metadata" -- every CTF trace directory + # contains exactly one such file describing its event layout + # 3. take that file's parent directory: that IS the trace directory + trace_locations = + Find.find(*source_location).reject do |path| + FileTest.directory?(path) + end.select do |path| + File.basename(path) == 'metadata' + end.collect do |path| + File.dirname(path) + end.select do |path| + qe = BT2::BTQueryExecutor.new(component_class: ctf_fs, object_name: 'babeltrace.support-info', + params: { 'input' => path, 'type' => 'directory' }) + qe.query.value['weight'] > 0.5 + end + else + # Live mode: the arguments are LTTng relay-daemon URLs, not paths, so there + # is nothing on disk to search -- pass them through untouched. + trace_locations = source_location + end + raise 'Could not find lttng trace' if trace_locations.size == 0 + + # Add one source component per trace. Each gets a unique name ("trace_0", + # "trace_1", ...) because babeltrace requires component names to be distinct. + # In live mode, "session-not-found-action" => "end" makes the component finish + # cleanly rather than hang forever when the named session does not exist. + if !$options[:live] + comp_sources = trace_locations.each_with_index.collect { |trace_location, i| graph.add_component(ctf_fs, "trace_#{i}", params: {"inputs" => [ trace_location ] }) } + else + comp_sources = trace_locations.each_with_index.collect { |trace_location, i| graph.add_component(ctf_lttng_live, "trace_#{i}", params: {"inputs" => [ trace_location ], "session-not-found-action" => "end" }) } + end + + # Muxer + comp_muxer = graph.add_component(utils_muxer, 'mux') + + # The sink is where our code plugs in: `sink_object.consume` returns a lambda + # that babeltrace calls with an iterator over each batch of decoded messages. + # That lambda is the top of the whole validation pipeline. + sink = graph.add_simple_sink('babeltrace_thapi', sink_object.consume) + + # Sources to muxer + # A single trace may expose several output ports (one per CTF stream, i.e. + # roughly per traced thread), so flat_map over all sources' ports and wire + # port i of the collected list into muxer input port i. The muxer grows its + # input ports on demand, which is why we can index it freely. + comp_sources.flat_map(&:output_ports).each_with_index do |op, i| + ip = comp_muxer.input_port(i) + graph.connect_ports(op, ip) + end + + # Chain the rest + # Only one link is left (muxer -> sink). It is written as a generic + # each_cons(2) pairwise chain so extra filter components could be spliced into + # the array without changing this code. + [comp_muxer, sink].flatten.each_cons(2) do |_out, _in| + op = _out.output_port(0) + ip = _in.input_port(0) + graph.connect_ports(op, ip) + end + + # Pull the whole trace through the graph. This call does not return until + # every event has been consumed, so by the time it finishes the model + # reflects the entire run. + graph.run + # End-of-trace pass: drain any still-pending deferred command lists, report + # deadlocks, and report every object that was created but never destroyed. + # Errors found DURING the run were already printed as they were discovered; + # these are the ones only visible once you know nothing more is coming. + sink_object.check_issues() + +end + +# only executive this code if we launch this as the main +# script. if it's just included with "require" we just want access to the functions. +# (Ruby's equivalent of C's `int main` guard: $0 is the script that was invoked, +# __FILE__ is this file, so they match only when run directly. Tests can then +# `require` this file to reuse build_and_run_graph without starting an analysis.) +if __FILE__ == $0 + # Refresh ze_device_property.json first, so StateObject picks up the real + # command-queue-group topology of this machine when it is constructed below. + generate_device_properties if $options[:gen_device_properties] + # One StateObject holds the entire model for the whole run and acts as the + # graph's sink. + sink_obj = StateObject.new(device_agnostic: $options[:device_agnostic], + performance: $options[:performance]) + # Whatever is left in ARGV after OptionParser#parse! are the trace paths. + # Deduplicate: feeding the same trace twice would double-apply every event and + # corrupt the model (e.g. a second create for an already-live handle). + ARGV.uniq! + source_location = ARGV + build_and_run_graph(source_location, sink_obj) +end \ No newline at end of file diff --git a/backends/ze/ze_validator_entry_exit_helpers.rb b/backends/ze/ze_validator_entry_exit_helpers.rb new file mode 100644 index 00000000..0b060405 --- /dev/null +++ b/backends/ze/ze_validator_entry_exit_helpers.rb @@ -0,0 +1,997 @@ +require 'ze_validator_zemodel' +require 'ze_library' + +# ============================================================================= +# The check library: the actual rules the validator enforces. +# +# Every function here is called from a callback in +# ze_validator_function_entry_exit_callbacks.rb. They are plain top-level +# functions rather than methods, so they all take the engine as an explicit +# first argument. +# +# THE SHARED ARGUMENT CONVENTION +# ------------------------------ +# state - the StateObject. Used for three things: looking objects up +# (find_objects / find_param), reporting (print_usage_error etc.), +# and deduplicating (print_tracker). +# ctx - the TRACE context {hostname, vpid, vtid, api}: who is calling. +# NOT a Level Zero context. Where a Level Zero context is meant, the +# parameter is named ctx_handle or resolves to a ZEModel::Context. +# defi - the decoded event payload. Input arguments at _entry, results at +# _exit. An exit-time check that needs an input calls +# state.find_param instead. +# +# TWO RULES THAT EXPLAIN MOST OF THE CODE BELOW +# ----------------------------------------------- +# +# 1. REPORT ONCE. GPU code loops; the same defect recurs constantly. Checks +# build a key naming the specific violation and consult +# state.print_tracker[key] before printing. The pattern is always +# `return unless state.print_tracker[key] == 0` followed by setting it to 1. +# +# 2. CHECK AT THE RIGHT MOMENT. Three timings appear throughout: +# at _entry - when the call itself might crash the process (no _exit +# would then be traced) or when pre-call state is needed +# at _exit - when the check needs the call to have succeeded +# deferred - when the operation is asynchronous and has not actually +# happened yet at append or submit time; the scheduler runs +# these later, when the op's wait-events are satisfied +# ============================================================================= + +# PORTABILITY / CRASH CHECK: a command queue is created with an (ordinal, index) +# pair -- which engine group, and which queue within that group. Each group +# exposes only numQueues queues; asking for an index at or beyond that limit +# segfaults rather than returning an error, so it is worth catching. The real +# limits come from ze_device_property.json, so the check is skipped entirely +# when that file was unavailable. +def check_valid_index_for_ordinal(state,ctx,queue_handle,ordinal,index) + #puts "entered" + if state.device_properties + command_queue_prop = state.device_properties["devices"][0]["command_queue_groups"] + command_queue_prop.each do |prop| + if prop["ordinal"] == ordinal && (index >= prop["numQueues"] || index < 0)#oob index results in segfault + state.print_usage_error(ctx, "command queue (#{state.get_handle_str(queue_handle)}) with ordinal = #{ordinal} was created " + + "with index = #{index}. Index value should be: 0<= index < #{prop["numQueues"]}") + end + end + end +end + +# PORTABILITY CHECK: did the application ever ask the device what engines it +# has (zeDeviceGetCommandQueueGroupProperties) before passing an ordinal? +# If not, the ordinal was hardcoded. That may work on today's GPU and silently +# select the wrong engine -- or none at all -- on the next one. Reported once +# per run, since it is a property of how the program was written, not of any +# individual call. +def check_group_property_queued(state, ctx, defi, device) + #puts "device = #{device}" + if !(device.cmd_queue_group_properties_queried) && state.print_tracker["check_group_property"] == 0 + state.print_tracker["check_group_property"] = 1 + state.print_usage_error(ctx,"command queue group wasn't queried. Hardcoded group properties may break the code on different devices") + end +end + + +# ADDED: the set of command-queue-group ordinals that belong to copy-only +# engines, as gathered by the device profiler (ze_device_property.json). When the +# device-property file is absent, fall back to the hardcoded ordinals that are +# copy-only on the Intel Data Center Max GPUs we target. Extracted so both the +# append-time check (check_valid_ordinal) and the execute-time check +# (check_copy_only_queue_submission) share one definition. +def copy_only_ordinals(state) + return [1, 2] unless state.device_properties + state.device_properties["devices"][0]["command_queue_groups"] + .select { |prop| prop["type"] == "copy" } + .map { |prop| prop["ordinal"] } +end + +# CRASH CHECK (append-time): launching a compute kernel onto a command list +# whose ordinal belongs to a copy-only engine. A copy engine cannot run compute +# kernels; on the Intel Data Center Max GPUs this targets, the result is a +# segfault with no diagnostic from the runtime. +# +# This is the append-time half of the check -- it inspects the ordinal the +# command list itself was created with. The other half runs at submit time, +# when the queue's ordinal is finally known: see +# check_copy_only_queue_submission. +def check_valid_ordinal(state, ctx, defi, cqg_ordinal) + copy_only_ords = copy_only_ordinals(state) + + if copy_only_ords.include?(cqg_ordinal) && state.print_tracker["zeCommandListAppendLaunchKernel::K2CopyOrdinal"] == 0 + state.print_tracker["zeCommandListAppendLaunchKernel::K2CopyOrdinal"] = 1 + kernels = state.find_objects(ctx, 'kernel') + kernel_handle = state.find_param(ctx, 'hKernel') + kernel_name = "UNKNOWN" + # CHANGED: was `state.find_object(ctx, 'hCommandList')` -- wrong arity + # (find_object needs type+handle) and returned an object, not a handle. Read + # the handle from the entry params and format it for the message. + command_list_handle = state.find_param(ctx, 'hCommandList') + if kernels[kernel_handle] + kernel_name = kernels[kernel_handle].name + end + state.print_usage_error(ctx, "Launching kernel (#{kernel_name}) to a command list with Copy Ordinal: #{state.get_handle_str(command_list_handle)}") + end +end + +# ADDED: true if the command list contains a compute kernel launch. A launch op +# is recorded with kind :launch, but so is zeCommandListAppendMemoryCopyRegion +# (which is a copy, not compute), so we match on the appending API name rather +# than the kind alone. Cooperative kernel launches count as compute too. +COMPUTE_LAUNCH_APIS = ['zeCommandListAppendLaunchKernel', + 'zeCommandListAppendLaunchCooperativeKernel'].freeze +def command_list_has_kernel_launch?(cmd_list) + cmd_list && cmd_list.ops.any? { |op| op.kind == :launch && COMPUTE_LAUNCH_APIS.include?(op.api) } +end + +# ADDED: execute-time check for submitting a command list that contains a compute +# kernel launch to a command queue associated with a copy-only engine. This +# complements check_valid_ordinal (which fires at append time on the list's own +# ordinal): here the queue is only known at zeCommandQueueExecuteCommandLists, so +# we compare the QUEUE's group ordinal (queue.desc[:ordinal]) against the +# copy-only set. On the Intel Data Center Max GPUs we target, running compute on a +# copy-only engine segfaults with no diagnostic from the runtime. Reported once +# per (queue, list) pair to avoid duplicate spam across repeated submits. +def check_copy_only_queue_submission(state, ctx, queue, cmd_list) + return unless queue && queue.desc + return unless command_list_has_kernel_launch?(cmd_list) + queue_ordinal = queue.desc[:ordinal] + return unless copy_only_ordinals(state).include?(queue_ordinal) + key = "copyq-submit-#{state.get_handle_str(queue.handle)}-#{state.get_handle_str(cmd_list.handle)}" + return unless state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + state.print_usage_error(ctx, "command list #{state.get_handle_str(cmd_list.handle)} contains a compute kernel " \ + "launch but was submitted to command queue #{state.get_handle_str(queue.handle)} " \ + "with copy-only ordinal #{queue_ordinal}") +end + +# ADDED: check that a launched kernel's module was created on the SAME Level Zero +# context as the command list the kernel is appended to. The spec requires it: +# zeCommandListAppendLaunchKernel/LaunchCooperativeKernel state "the command list, +# kernel and events were created on the same context." A kernel has no context of +# its own -- zeKernelCreate takes a module (hModule), and the module carries the +# context it was created on (zeModuleCreate's hContext) -- so the kernel's context +# is kernel.module.context. Runs at ENTRY (a launch can abort without an _exit), +# reading handles from defi. Unknown kernel/module/list, or a module/list whose +# context we never saw (tracing started mid-stream), are skipped rather than +# flagged. Reported once per (command list, kernel) pair. +def check_kernel_list_context_match(state, ctx, defi) + command_lists = state.find_objects(ctx, 'command_list') + kernels = state.find_objects(ctx, 'kernel') + cmd_list = command_lists[defi['hCommandList']] + kernel = kernels[defi['hKernel']] + return unless cmd_list && cmd_list.context && kernel + mod = kernel.module + return unless mod && mod.context + return if mod.context == cmd_list.context + key = "kernel-list-ctx-#{state.get_handle_str(cmd_list.handle)}-#{state.get_handle_str(kernel.handle)}" + return unless state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + state.print_usage_error(ctx, + "kernel #{state.get_handle_str(kernel.handle)} (from module " \ + "#{state.get_handle_str(mod.handle)} on context #{state.get_handle_str(mod.context.handle)}) " \ + "does not share the context of command list #{state.get_handle_str(cmd_list.handle)} " \ + "(context #{state.get_handle_str(cmd_list.context.handle)})") +end + +# USAGE CHECK: the handle passed to a kernel launch was never produced by a +# zeKernelCreate we saw -- an uninitialized, stale, or wrong variable. +def check_kernel_created(state, ctx, defi) + kernels = state.find_objects(ctx, 'kernel') + kernel_handle = defi['hKernel'] + unless kernels[kernel_handle] + state.print_usage_error(ctx, "kernel: #{state.get_handle_str(kernel_handle)} wasn't created. Consider calling zeKernelCreate") + end +end + +#Checks for misuse of fences. +#Not a proper use of fence if it was already signaled, +#or being used by other commandslist. +# A fence must be reset (zeFenceReset) between submissions. Reusing one that is +# still in_use, or that was signaled and never reset, means the program cannot +# tell which submission the fence refers to -- so it will either wait on the +# wrong thing or not wait at all. +def check_fence_misuse(state, ctx, defi) + fence_handle = defi['hFence'] + fence = get_fence(state,ctx,fence_handle) + if fence && (fence.status == fence.signaled || fence.status == fence.in_use) + state.print_usage_error(ctx, "Used fence: #{state.get_handle_str(fence_handle)} twice without resetting it") + end +end + +# USAGE CHECK: the queue handed to ExecuteCommandLists was never created (or +# was already destroyed). +def check_valid_command_queue(state,ctx,defi, cmd_queues, cmd_queue_ptr) + cmd_queue = cmd_queues[cmd_queue_ptr] + unless cmd_queue + state.print_usage_error(ctx, "Invalid commandQueue (#{state.get_handle_str(cmd_queue_ptr)}) was handed to zeCommandQueueExecuteCommandLists") + end + +end + +# USAGE CHECK on the lists passed to ExecuteCommandLists. Three ways to get it +# wrong: submitting nothing at all, submitting a handle that was never created, +# or submitting an IMMEDIATE list -- immediate lists carry their own implicit +# queue and execute at append time, so passing one to a queue is invalid. +def check_valid_command_lists(state, ctx, defi) + command_lists = defi['phCommandLists_vals'] + known_command_lists = state.find_objects(ctx, 'command_list') + if command_lists.nil? || command_lists.empty? + state.print_usage_error(ctx, "No valid commandlist was chosen at zeCommandQueueExecuteCommandLists") + end + + command_lists.each do |command_list_handle| + if !(known_command_lists[command_list_handle]) + state.print_usage_error(ctx, "Invalid commandlist (#{command_list_handle}) was handed to zeCommandQueueExecuteCommandLists") + elsif known_command_lists[command_list_handle] && known_command_lists[command_list_handle].immediate + state.print_usage_error(ctx, "Immediate Command List was chosen for the Command Queue: #{state.get_handle_str(command_queue_handle)}") + end + end +end + +#change it to calculating the memory overlap region? +# Returns [start, end] of the region two allocations share, or [] if they are +# disjoint (or of different memory types, which cannot alias). Used by the data +# race detection work. +def get_memory_overlap(mem1, mem2) + overlap = [] + if mem1 && mem2 && mem1.memtypestr == mem2.memtypestr + #Check if mem2 is contained in mem1 + if mem1.base <= mem2.base && mem2.base <= mem1.base + mem1.size + overlap << mem2.base + overlap << [mem2.base+mem2.size, mem1.base+mem1.size].min + elsif mem2.base <= mem1.base && mem1.base <= mem2.base + mem2.size + overlap << mem1.base + overlap << [mem2.base+mem2.size, mem1.base+mem1.size].min + end + end + overlap +end + +# REMOVED: record_copy_over / add_api_call_to_cmd_list. +# These were the earlier "memory_in_transit" and "api_calls history" approaches +# to correlating copies. They are superseded by the RecordedOp / DeferredUnit +# model (each copy is recorded as an op and checked at execute time), and +# add_api_call_to_cmd_list referenced undefined locals (state/ctx) so it could +# never have run. See record_op / record_copy_op below. + +# Resolve a fence handle to its model object (nil if unknown). +def get_fence(state,context,fence_handle) + fences = state.find_objects(context, 'fence') + fence = fences[fence_handle] #returns fence +end + +# ADDED: resolve the Level Zero context handle that owns a command list, given +# the list handle. Memory maps are keyed by context handle, but copy/fill append +# APIs identify only the command list -- the context is reachable through the +# list's Context object. Returns nil for an unknown list (tracing started +# mid-stream); callers fall back to the shared nil bucket in that case. +def cmd_list_ctx_handle(state, ctx, cmd_list_handle) + cmd_list = state.find_objects(ctx, 'command_list')[cmd_list_handle] + cmd_list && cmd_list.context ? cmd_list.context.handle : nil +end + +# ADDED: read the wait-event handles from an append call's input params. Both +# spellings appear in the trace: copies/barriers/launches use phWaitEvents_vals, +# zeCommandListAppendWaitOnEvents uses phEvents_vals. find_param is used because +# exit callbacks do not see input params in defi. Null/empty entries are dropped. +def wait_event_handles(state, ctx) + handles = state.find_param(ctx, 'phWaitEvents_vals') || + state.find_param(ctx, 'phEvents_vals') || [] + handles.reject { |h| h.nil? || h == 0 } +end + +# ADDED: record one op onto its command list. A regular (non-immediate) list +# stores it for replay at execute time; an immediate list executes right away, so +# the op is scheduled immediately. No-op if the list handle is unknown. +def record_op(state, ctx, cmd_list_handle, op) + cmd_list = state.find_objects(ctx, 'command_list')[cmd_list_handle] + return unless cmd_list + if cmd_list.immediate + # ADDED: an immediate list never reaches zeCommandQueueExecuteCommandLists, so + # check its events' context here (against the list's own context) before the op + # is scheduled. + check_event_pool_immediate_list_context_match(state, ctx, cmd_list, op) + state.enqueue_immediate_op(ctx, op, cmd_list_handle) + else + cmd_list.ops << op + end +end + +# ADDED: record a memory-copy op (zeCommandListAppendMemoryCopy / MemoryFill). +# Values are snapshotted now (via find_param) because the per-call context is +# gone by the time the op is replayed. The out-of-bounds check is deferred to +# execute time; see check_oob_copy. +def record_copy_op(state, ctx, api, dst_key, src_key) + cmd_list_handle = state.find_param(ctx, 'hCommandList') + op = ZEModel::RecordedOp.new(:copy, + signal: state.find_param(ctx, 'hSignalEvent'), + waits: wait_event_handles(state, ctx), + params: { api: api, + # ADDED: snapshot the owning context handle so the deferred OOB/ + # UAF checks look in the right per-context allocation map when the + # copy replays at execute time (the per-call context is gone by then). + ctx_handle: cmd_list_ctx_handle(state, ctx, cmd_list_handle), + dst: (dst_key ? state.find_param(ctx, dst_key) : nil), + src: (src_key ? state.find_param(ctx, src_key) : nil), + size: state.find_param(ctx, 'size') }) + record_op(state, ctx, cmd_list_handle, op) +end + +# ADDED: record a zeCommandListAppendMemoryRangesBarrier op. Like a barrier, it +# waits on its wait-events and signals its completion event, so it must appear in +# the deferred op stream for the event-ordering / deadlock checks to see it. It +# also names memory ranges whose coherency it guarantees; each range is snapshot +# as {base:, size:} so a deferred check can verify the range lies within a live +# allocation (see check_ranges_barrier). The two InArray params arrive in the +# trace as pRanges_vals (the base addresses) and pRangeSizes_vals (byte sizes), +# positionally paired. Missing/short arrays degrade to nil entries rather than +# crashing (tracing may have started mid-stream). +def record_ranges_barrier_op(state, ctx) + cmd_list_handle = state.find_param(ctx, 'hCommandList') + bases = state.find_param(ctx, 'pRanges_vals') || [] + sizes = state.find_param(ctx, 'pRangeSizes_vals') || [] + ranges = bases.each_with_index.map { |base, i| { base: base, size: sizes[i] } } + op = ZEModel::RecordedOp.new(:ranges_barrier, + signal: state.find_param(ctx, 'hSignalEvent'), + waits: wait_event_handles(state, ctx), + params: { api: 'zeCommandListAppendMemoryRangesBarrier', + ctx_handle: cmd_list_ctx_handle(state, ctx, cmd_list_handle), + ranges: ranges }) + record_op(state, ctx, cmd_list_handle, op) +end + +# USAGE CHECK: a command list must be finalized with zeCommandListClose before +# it may be submitted. Submitting an open list means the driver may see a +# partially recorded sequence; submitting a destroyed one is a use-after-free of +# the handle. +def check_command_list_closed(state, ctx, defi) + command_queue_handle = defi['hCommandQueue'] + # CHANGED: guard against nil (empty submit) so .each does not crash + command_lists = defi['phCommandLists_vals'] || [] + known_command_lists = state.find_objects(ctx, 'command_list') + command_lists.each do |command_list_handle| + # CHANGED: was `knwon_command_lists` (typo -> NameError). Also skip unknown + # handles rather than calling .status on nil, and report the actual handle + # instead of the undefined local `cl`. + cmd_list = known_command_lists[command_list_handle] + next unless cmd_list + if cmd_list.status == ZEModel::CommandList.class_variable_get(:@@INITIALIZED) + state.print_usage_error(ctx, "commandlist: #{state.get_handle_str(command_list_handle)} wasn't closed before executing on #{state.get_handle_str(command_queue_handle)}") + elsif cmd_list.status == ZEModel::CommandList.class_variable_get(:@@DESTROYED) + state.print_usage_error(ctx, "commandlist: #{state.get_handle_str(command_list_handle)} was already destroyed #{state.get_handle_str(command_queue_handle)}") + end + end +end + + +# ADDED: validate a zeCommandListReset. Runs at ENTRY, before the model applies +# the reset, reading the list handle from defi. Reports three misuses: +# * already-destroyed list -- resetting a destroyed handle is a usage error. +# * immediate command list -- zeCommandListReset is invalid on an immediate list +# (it has no closed/execute lifecycle to reset); the runtime returns +# ZE_RESULT_ERROR_INVALID_ARGUMENT. +# * reset while in-flight -- the list is still executing a prior +# zeCommandQueueExecuteCommandLists submission. Resetting it now races the +# device and is undefined behavior in Level Zero. +# Every command list handle is tracked, so the list is always found. +# Reported once per (command list, reason) pair. +def check_command_list_reset(state, ctx, defi) + handle = defi['hCommandList'] + cmd_list = state.find_objects(ctx, 'command_list')[handle] + + if cmd_list.status == ZEModel::CommandList.class_variable_get(:@@DESTROYED) + key = "clreset-destroyed-#{state.get_handle_str(handle)}" + if state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + state.print_usage_error(ctx, "command list #{state.get_handle_str(handle)} was already destroyed before zeCommandListReset") + end + return + end + + if cmd_list.immediate + key = "clreset-immediate-#{state.get_handle_str(handle)}" + if state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + state.print_usage_error(ctx, "zeCommandListReset called on immediate command list #{state.get_handle_str(handle)}; " \ + "immediate command lists cannot be reset") + end + end + + if state.command_list_in_flight?(ctx, handle) + key = "clreset-inflight-#{state.get_handle_str(handle)}" + if state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + state.print_usage_error(ctx, "command list #{state.get_handle_str(handle)} is being reset while a prior " \ + "zeCommandQueueExecuteCommandLists submission is still in-flight; the device may " \ + "still be executing it (undefined behavior)") + end + end +end + +# USAGE CHECK: zeKernelCreate was given a null module handle. +def check_valid_module(state,ctx,defi) + module_handle = state.find_param(ctx, 'hModule') + if !module_handle || module_handle == 0 + state.print_usage_error(ctx, "Improper hModule was handed") + end +end + +# --------------------------------------------------------------------------- +# CONTEXT-MATCHING CHECKS. +# A Level Zero context is an isolation domain: objects used together must all +# belong to one. The next three functions cover the triangle involved in a +# submission -- the command list, the queue it goes to, and the fence that +# signals its completion. All three must agree, so each pairing is checked. +# --------------------------------------------------------------------------- + +# The fence's queue and the command list must be on the same context. +def check_list_and_fence_have_matching_context(state,ctx,defi,cmd_list,fence) + if fence + unless cmd_list && fence.command_queue && + cmd_list.context == fence.command_queue.context + list_handle = cmd_list ? state.get_handle_str(cmd_list.handle) : "nullptr" + fence_handle = fence + state.print_usage_error(ctx, "Mismatching context between command list #{list_handle} and fence #{fence_handle}") + end + end +end + +# Stronger than a context match: a fence is bound to ONE specific queue at +# creation, and may only be used with that queue. +def check_fence_and_queue_compatibility(state,ctx,defi,cmd_queue,fence) + if fence + unless cmd_queue && cmd_queue == fence.command_queue + queue_handle = cmd_queue ? state.get_handle_str(cmd_queue.handle) : "nullptr" + fence_handle = fence + state.print_usage_error(ctx, "Associated command queue (#{state.get_handle_str(fence.command_queue.handle)}) of fence #{fence_handle} " + + "is different from the one that was provided #{queue_handle}") + end + end +end + +# A command list may only be submitted to a queue on its own context. +def check_list_and_queue_have_matching_context(state,ctx,defi,cmd_list, cmd_queue) + unless cmd_queue && cmd_list && cmd_list.context == cmd_queue.context + queue_handle = cmd_queue ? state.get_handle_str(cmd_queue.handle) : "nullptr" + list_handle = cmd_list ? state.get_handle_str(cmd_list.handle) : "nullptr" + state.print_usage_error(ctx, "Mismatching context between command queue #{queue_handle} and command list #{list_handle}") + end +end + +# ADDED: op kinds whose events are subject to the spec's same-context requirement. +# Not every append that carries events requires them to share the list's context: +# the Level Zero spec attaches "the command list and events were created on the +# same context" to launch/copy/fill/signal/wait ops, but zeCommandListAppendBarrier +# and zeCommandListAppendMemoryRangesBarrier use the WEAKER "events must be +# accessible by the device on which the command list was created" wording -- no +# same-context clause. So :barrier and :ranges_barrier are deliberately excluded +# here to avoid false positives; their events would need a device-accessibility +# model (which the validator does not currently have) rather than a context match. +SAME_CONTEXT_EVENT_OP_KINDS = [:copy, :launch, :signal, :wait, :reset].freeze + +# ADDED: the distinct event handles one recorded op references -- the completion +# event it signals (op.signal) and the events it waits on (op.waits). Null handles +# are already normalized away (op.signal is nil when 0; op.waits drops nil/0 at +# record time). Returns [] for ops whose events are not subject to the same-context +# rule (see SAME_CONTEXT_EVENT_OP_KINDS). +def same_context_event_handles_in_op(op) + return [] unless SAME_CONTEXT_EVENT_OP_KINDS.include?(op.kind) + handles = [] + handles << op.signal if op.signal + handles.concat(op.waits) if op.waits + handles +end + +# ADDED: distinct event handles a command list references across all of its +# recorded ops that ARE subject to the same-context requirement. +def same_context_event_handles_in_list(cmd_list) + cmd_list.ops.flat_map { |op| same_context_event_handles_in_op(op) }.uniq +end + +# ADDED: core context-consistency check shared by the queue-submission and +# immediate-list cases. For each event handle, resolve its context THROUGH its +# event pool (an event has no context of its own -- zeEventCreate takes no context +# and derives it from the pool, which is bound to a context at zeEventPoolCreate or, +# for an IPC-shared pool, at the hContext passed to zeEventPoolOpenIpcHandle) and +# report if it differs from ref_context. `ref_kind`/`ref_handle` name the object +# the events are expected to match (a command queue or an immediate command list), +# for the message and the dedup key. Reported once per (ref, event) pair. Unknown +# events, or events whose pool/context we never saw (tracing started mid-stream), +# are skipped. +def check_events_share_context(state, ctx, event_handles, ref_context, ref_kind, ref_handle) + return unless ref_context + events = state.find_objects(ctx, 'event') + event_handles.uniq.each do |h| + ev = events[h] + next unless ev && ev.event_pool && ev.event_pool.context + next if ev.event_pool.context == ref_context + key = "evpool-#{ref_kind}-ctx-#{state.get_handle_str(ref_handle)}-#{state.get_handle_str(h)}" + next unless state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + state.print_usage_error(ctx, + "event #{state.get_handle_str(h)} (from event pool " \ + "#{state.get_handle_str(ev.event_pool.handle)} on context " \ + "#{state.get_handle_str(ev.event_pool.context.handle)}) does not share the context of " \ + "#{ref_kind} #{state.get_handle_str(ref_handle)} " \ + "(context #{state.get_handle_str(ref_context.handle)})") + end +end + +# ADDED: execute-time check that every event used by a submitted command list (in +# an op subject to the same-context rule) comes from an event pool on the SAME +# Level Zero context as the command queue the list is submitted to. The spec +# requires such a command list and its events to share a context; since the list +# must also match the queue's context, an event pool whose context differs from the +# queue's is a mismatch. +def check_event_pool_queue_context_match(state, ctx, queue, cmd_list) + return unless queue && queue.context && cmd_list + check_events_share_context(state, ctx, same_context_event_handles_in_list(cmd_list), + queue.context, 'command queue', queue.handle) +end + +# ADDED: append-time analogue for IMMEDIATE command lists. An immediate list is its +# own implicit queue -- it never goes through zeCommandQueueExecuteCommandLists (and +# is in fact rejected there), so the queue-submission check above never sees it. Its +# ops execute at append time, so we validate each appended op's events here against +# the immediate list's OWN context (which stands in for the queue's). Called from +# record_op for the immediate branch. +def check_event_pool_immediate_list_context_match(state, ctx, cmd_list, op) + return unless cmd_list && cmd_list.context + check_events_share_context(state, ctx, same_context_event_handles_in_op(op), + cmd_list.context, 'immediate command list', cmd_list.handle) +end + +# REPLACED check_oob_memory_copy (it iterated a CommandList as if enumerable and +# referenced an undefined `defi`) with the helpers below. + +# --------------------------------------------------------------------------- +# MEMORY CHECKS: out-of-bounds copies and use-after-free. +# +# Memory is the one kind of object not identified by an opaque handle but by an +# ADDRESS, so these checks work on ranges: an allocation covers +# [base, base + size), and a pointer into the middle of it is a legitimate +# reference to it. That is why lookups here are two-step -- try the exact base +# address, then fall back to a containment scan. +# --------------------------------------------------------------------------- + +# ADDED: find the allocation that contains ptr, so a copy into an offset of a +# base allocation is matched, not only an exact base-pointer copy. +def find_allocation_containing(allocations, ptr) + allocations.each_value.find { |m| m.base && m.base <= ptr && ptr < m.base + m.size } +end + +# ADDED: out-of-bounds check for one endpoint (source or destination) of a copy. +# Measures the copy size against the bytes remaining from ptr's offset within its +# allocation. An unknown pointer is left alone (nothing to compare against). +def check_copy_endpoint(state, ctx, allocations, ptr, size, api, role) + return if ptr.nil? || ptr == 0 || size.nil? + mem = allocations[ptr] || find_allocation_containing(allocations, ptr) + return unless mem + #the copy starts partway into the allocation, so the space it can legally use + #is what remains from that offset -- not the allocation's full size + offset = ptr - mem.base + available = mem.size - offset + if available < size + state.print_usage_error(ctx, "#{api}: #{role} memory #{state.get_handle_str(ptr)} only has #{available} " \ + "bytes available from this offset but the copy needs #{size} bytes") + end +end + +# ADDED: deferred out-of-bounds check for a recorded copy op. Called from the +# scheduler once the copy's wait-events are satisfied, so it runs against the +# memory model as it stands at the point the copy actually executes -- avoiding +# the false positives that checking at execute-entry would give (the destination +# may only be allocated after execute, by whoever signals the wait-event). +def check_oob_copy(state, ctx, params) + api = params[:api] || 'zeCommandListAppendMemoryCopy' + size = params[:size] + # CHANGED: look up allocations in the copy's own context sub-map. The context + # handle was snapshotted into params at record/append time (see record_copy_op). + allocations = state.memory_allocations(ctx, params[:ctx_handle]) + check_copy_endpoint(state, ctx, allocations, params[:dst], size, api, 'destination') + check_copy_endpoint(state, ctx, allocations, params[:src], size, api, 'source') +end + +# ADDED: null-pointer check for a memory copy/fill. In Level Zero, a null dstptr +# or srcptr on a copy (or a null ptr on a fill) is a usage error +# (ZE_RESULT_ERROR_INVALID_NULL_POINTER) and can crash the driver inside the +# append -- which then emits no _exit event -- so this runs at ENTRY, before the +# (possibly fatal) call, reading the input pointers directly from defi. Null-ness +# is a static property of the arguments, so unlike the OOB/UAF checks it needs no +# deferral to execute time. `endpoints` is an ordered role -> pointer map; a fill +# passes only the destination, so a fill's (absent) source is never flagged. +# Reports every null endpoint found. +def check_null_copy_ptr(state, ctx, api, endpoints) + endpoints.each do |role, ptr| + if ptr.nil? || ptr == 0 + state.print_usage_error(ctx, "#{api}: #{role} pointer is nullptr") + end + end +end + +# ADDED: a successful allocation may reuse an address previously freed. Drop any +# freed record whose former range overlaps the new allocation so it is not +# mistaken for a still-dangling pointer. Call from the alloc callbacks. +def mark_reallocated(state, ctx, ctx_handle, handle, size) + # CHANGED: only scan the freed registry of the context this allocation belongs + # to -- a reused address in one context says nothing about another context. + freed = state.freed_memory_allocations(ctx, ctx_handle) + return if freed.empty? + freed.delete_if { |_addr, m| ranges_overlap?(m.base, m.size, handle, size) } +end + +# ADDED: like find_allocation_containing, but over the freed-allocation registry +# -- finds a released allocation whose (former) range still contains ptr. +def find_freed_allocation_containing(freed, ptr) + freed.each_value.find { |m| m.base && m.base <= ptr && ptr < m.base + m.size } +end + +# ADDED: use-after-free check for one endpoint (dst/src) of a copy/fill. If the +# pointer does NOT resolve to a live allocation but DOES fall inside an +# allocation that was already zeMemFree'd, report a use-after-free. A pointer +# that matches neither is left alone (unknown / untraced -- nothing to assert). +def check_uaf_endpoint(state, ctx, live, freed, ptr, api, role) + return if ptr.nil? || ptr == 0 + #still live (exact base or an offset within a live allocation) -> fine + return if live[ptr] || find_allocation_containing(live, ptr) + mem = freed[ptr] || find_freed_allocation_containing(freed, ptr) + return unless mem + #dedup: the same freed pointer can be seen both at append-entry and again at + #deferred execute time -- report it once per (api, pointer). + key = "uaf-#{api}-#{state.get_handle_str(ptr)}" + return unless state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + offset = ptr - mem.base + where = offset == 0 ? "" : " (offset #{offset} into the freed allocation)" + state.print_memory_error(ctx, "#{api}: #{role} memory #{state.get_handle_str(ptr)}#{where} was already " \ + "freed#{mem.freed_by ? " by #{mem.freed_by}" : ""}; use-after-free") +end + +# ADDED: deferred use-after-free check for a recorded copy/fill op. Runs from the +# scheduler at the point the copy actually executes (alongside check_oob_copy), +# so a pointer freed before the copy's turn is caught, while a destination only +# allocated later is not falsely flagged. +def check_use_after_free(state, ctx, params) + api = params[:api] || 'zeCommandListAppendMemoryCopy' + # CHANGED: resolve both maps within the copy's own context (see check_oob_copy). + live = state.memory_allocations(ctx, params[:ctx_handle]) + freed = state.freed_memory_allocations(ctx, params[:ctx_handle]) + return if freed.empty? + check_uaf_endpoint(state, ctx, live, freed, params[:dst], api, 'destination') + check_uaf_endpoint(state, ctx, live, freed, params[:src], api, 'source') +end + +# ADDED: append-time UAF check that RESPECTS the op's own wait events. A copy +# does not touch memory until its wait events are signaled: an immediate append +# executes only once its waits are satisfied, and a recorded (regular-list) +# append executes later still. So render a verdict at append entry only when the +# waits are already satisfied (empty waits count as satisfied). If the waits are +# still unmet, the copy has not accessed memory yet -- leave it to the deferred +# scheduler (run_deferred_op), which re-checks at the exact point the copy +# becomes runnable. This keeps the crash-safety benefit for the common +# no-wait / ready case while staying event-aware for gated copies. +def check_use_after_free_on_append(state, ctx, params, waits) + return unless state.waits_satisfied?(ctx, waits) + check_use_after_free(state, ctx, params) +end + +# ADDED: deferred validation of a zeCommandListAppendMemoryRangesBarrier's memory +# ranges. Runs from the scheduler when the barrier executes (its waits satisfied), +# so it sees the memory model as it stands at that point -- mirroring the copy +# checks. For each range, if its base was already zeMemFree'd we report a +# use-after-free (the barrier references memory that is gone); a base that matches +# no live allocation is left alone, since it may be IPC/untracked memory (see +# check_uaf_endpoint's rationale). A range base of nullptr is skipped. +def check_ranges_barrier(state, ctx, params) + api = params[:api] || 'zeCommandListAppendMemoryRangesBarrier' + live = state.memory_allocations(ctx, params[:ctx_handle]) + freed = state.freed_memory_allocations(ctx, params[:ctx_handle]) + return if freed.empty? + (params[:ranges] || []).each do |r| + check_uaf_endpoint(state, ctx, live, freed, r[:base], api, 'range') + end +end + +# ADDED: true if [a, a+asize) and [b, b+bsize) overlap. +def ranges_overlap?(a, asize, b, bsize) + return false unless a && b && asize && bsize + a < b + bsize && b < a + asize +end + +# ADDED: free-while-in-flight check. Called from zeMemFree BEFORE the allocation +# is removed. If any copy/fill op still pending in an in-flight deferred command +# list references (overlaps) the allocation being freed, the device may still +# read/write it after the free -- report it. mem is the ZEModel::Memory about to +# be freed. +def check_free_in_flight(state, ctx, mem) + return unless mem + # ADDED: the buffer being freed belongs to one context; only an in-flight copy + # in that SAME context can alias it. Comparing across contexts would be a false + # positive now that addresses may repeat between contexts. + mem_ctx_handle = mem.context ? mem.context.handle : nil + state.each_inflight_copy_op(ctx) do |unit, op| + p = op.params + next unless p[:ctx_handle] == mem_ctx_handle + hit = [[p[:dst], 'destination'], [p[:src], 'source']].find do |ptr, _role| + ptr && ptr != 0 && ranges_overlap?(mem.base, mem.size, ptr, p[:size]) + end + next unless hit + _ptr, role = hit + state.print_memory_error(ctx, "memory #{state.get_handle_str(mem.base)} is being freed while still in use as " \ + "the #{role} of an in-flight #{p[:api] || 'copy'} on #{unit.label}; the device " \ + "may access freed memory") + end +end + +def check_ptrs_have_same_context(state,ctx,params) + # CHANGED: resolve within the copy's context sub-map. NOTE: this is currently a + # stub (empty body) and unregistered. With allocations now keyed by context, + # both endpoints found in one sub-map necessarily share a context by + # construction, so a genuine cross-context-copy check would instead have to + # search every context's sub-map for each pointer -- left for when this is implemented. + allocations = state.memory_allocations(ctx, params[:ctx_handle]) + if allocations[params[:dst]] && allocations[params[:src]] && (allocations[params[:dst]].context != allocations[params[:src]].context) + + end +end + +# ADDED: search a single context sub-map for the allocation matching ptr -- an +# exact base hit first, then an allocation whose [base, base+size) range contains +# ptr (offset copy). Returns the Memory or nil. +def find_memory_in_submap(submap, ptr) + submap[ptr] || find_allocation_containing(submap, ptr) +end + +# ADDED: locate the KNOWN allocation for ptr across this process's per-context +# allocation sub-maps, preferring the command list's own context. Returns +# [memory, ctx_handle], or [nil, nil] if ptr matches no tracked allocation. +# Preferring the list's context keeps the check false-positive-free under address +# aliasing: L0 addresses are unique only within a context, so the same numeric +# address can exist in several contexts. If ptr resolves in the list's own context +# we return that (a correct, in-context copy) and stop; only if it resolves solely +# in a foreign context do we surface a mismatch. +# +# NOTE: IPC-imported memory is intentionally NOT handled here -- the validator does +# not model zeMemOpenIpcHandle, so such pointers are simply "not found" and skipped +# (no callback registers them). Only pointers we positively tracked are considered. +def find_known_memory_preferring_context(state, ctx, ptr, prefer_ctx_handle) + return [nil, nil] if ptr.nil? || ptr == 0 + all_maps = state.get_process(ctx).memory_allocations + if prefer_ctx_handle && all_maps.key?(prefer_ctx_handle) + mem = find_memory_in_submap(all_maps[prefer_ctx_handle], ptr) + return [mem, prefer_ctx_handle] if mem + end + all_maps.each do |cth, submap| + next if cth == prefer_ctx_handle + mem = find_memory_in_submap(submap, ptr) + return [mem, cth] if mem + end + [nil, nil] +end + +# ADDED: report one endpoint (dst/src) of a copy/fill only when its pointer +# resolves to a KNOWN allocation on a DIFFERENT context than the command list. The +# spec requires the command list and the copied memory to share a context +# (zeCommandListAppendMemory{Copy,Fill}: "the command list and events were created, +# and the memory was allocated, on the same context"). No false positives: a ptr +# found in the list's own context is accepted, and a ptr found in NO tracked +# context -- system/malloc host memory, an untracked/IPC allocation, or memory from +# before tracing started -- is skipped (nothing is asserted about unknown pointers, +# mirroring the OOB/UAF checks). Deduped once per (command list, endpoint, pointer). +def check_ptr_endpoint_list_context(state, ctx, list_ctx_handle, list_handle, ptr, api, role) + return if ptr.nil? || ptr == 0 + return if list_ctx_handle.nil? # unknown command list context (mid-stream) -> skip + mem, found_ctx = find_known_memory_preferring_context(state, ctx, ptr, list_ctx_handle) + return unless mem # unknown pointer -> skip (no false alarm) + return if found_ctx == list_ctx_handle # correctly in the list's context -> fine + key = "ptr-list-ctx-#{state.get_handle_str(list_handle)}-#{role}-#{state.get_handle_str(ptr)}" + return unless state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + mem_ctx_str = mem.context ? state.get_handle_str(mem.context.handle) : state.get_handle_str(found_ctx) + state.print_usage_error(ctx, + "#{api}: #{role} memory #{state.get_handle_str(ptr)} was allocated on context #{mem_ctx_str} " \ + "but command list #{state.get_handle_str(list_handle)} is on context #{state.get_handle_str(list_ctx_handle)}; " \ + "the command list and copied memory must share a context") +end + +# ADDED: append-entry check that a copy/fill's KNOWN memory endpoints were +# allocated on the same context as the command list. Runs at ENTRY (a cross-context +# copy can be rejected inside the append, which then emits no _exit event), reading +# the input pointers directly from defi. Context is a static property of the +# pointer and the list, so unlike OOB/UAF this needs no deferral to execute time. +def check_copy_ptr_list_context(state, ctx, api, list_handle, endpoints) + list_ctx_handle = cmd_list_ctx_handle(state, ctx, list_handle) + endpoints.each do |role, ptr| + check_ptr_endpoint_list_context(state, ctx, list_ctx_handle, list_handle, ptr, api, role) + end +end + +# ADDED: detect misuse of an event that is signaled while already signaled, with +# no intervening reset. Mirrors the fence double-signal check. Two shapes: +# * reuse-no-reset -- the prior signal WAS observed by the host (e.g. it +# synchronized on the event) and the event is reused as a +# signal target without a reset first. +# * double-signal -- the prior signal was never observed; two signalers target +# the same event with no consumer between them. +# Called just before an op applies its own signal, in execution order, so any +# intervening reset/observe has already been recorded. `who` names the signaler. +def check_event_signal_reuse(state, ctx, handle, who) + ev = state.event_by_handle(ctx, handle) + return unless ev && ev.signaled? + if ev.observed + state.print_usage_error(ctx, "event #{state.get_handle_str(handle)} was reused as a signal target by #{who} " \ + "without calling zeEventHostReset/zeCommandListAppendEventReset after it was " \ + "signaled#{ev.signaled_by ? " by #{ev.signaled_by}" : ""}") + else + state.print_usage_error(ctx, "event #{state.get_handle_str(handle)} was signaled by #{who} before being reset " \ + "or consumed#{ev.signaled_by ? " (already signaled by #{ev.signaled_by})" : ""}; " \ + "concurrent signals of the same event are undefined") + end +end + +# ADDED: report wait-events that were never signaled by end of trace -- a +# deferred op that could never complete (missing signal or deadlock). +def report_unsignaled_waits(state, ctx, waits) + (waits || []).each do |h| + ev = state.event_by_handle(ctx, h) + next unless ev && !ev.signaled? + state.print_usage_error(ctx, "event #{state.get_handle_str(h)} was never signaled; a deferred command list " \ + "operation could not complete (possible deadlock or missing signal)") + end +end + +# --------------------------------------------------------------------------- +# DEADLOCK DETECTION. +# +# GPU work is ordered by events, and it is easy to write an event graph that +# can never make progress. The classic shape: +# +# command list A: wait(evB) ... signal(evA) +# command list B: wait(evA) ... signal(evB) +# +# Neither can start, because each is waiting for something the other only +# produces after it has itself started. On real hardware this manifests as an +# application that simply hangs, with no error from the runtime. +# +# The validator sees this for free from its deferred-execution machinery: a +# DeferredUnit that is still parked when the trace ends could never proceed. +# The two functions below classify why: +# check_circular_deadlock - a cycle ACROSS units (the shape above) +# check_in_order_self_deadlock - a cycle WITHIN one in-order list +# --------------------------------------------------------------------------- + +# ADDED: detect a circular event dependency (deadlock) among the deferred units +# still stuck at end of trace, and report the FIRST cycle found. Builds a +# wait-for graph -- unit U points to unit V when U is blocked on an event that +# only V can still signal (it is in V's pending_signals) -- then searches for one +# cycle. A cycle means every unit on it waits for an event another unit on the +# cycle only signals after finishing, so none can ever start (e.g. clA waits +# evB/signals evA while clB waits evA/signals evB). +# +# Only the first cycle is reported: cycles overlap and share units, so emitting +# every one found would restate a single underlying bug many times over. +def check_circular_deadlock(state, units) + stuck = units.select { |u| u.blocked_on && !u.blocked_on.empty? } + return if stuck.empty? + + # event handle -> units that may still signal it + signalers = Hash.new { |h, k| h[k] = [] } + stuck.each { |u| u.pending_signals.each { |ev| signalers[ev] << u } } + + # adjacency: U -> V if U waits on an event V still owes + succ = Hash.new { |h, k| h[k] = [] } + stuck.each do |u| + u.blocked_on.each do |ev| + signalers[ev].each { |v| succ[u] << v unless v.equal?(u) } + end + end + + # DFS; stop at the first cycle and report only that one + # Textbook cycle detection by depth-first search with two marker sets: + # on_path - units on the current DFS branch. Reaching one again closes a + # cycle, and the cycle is the tail of `path` from that unit on. + # visited - units fully explored on some earlier branch; no need to redo + # them, which keeps this linear in the size of the graph. + # The lambda returns true once a cycle is found, so the recursion and the + # outer loop both unwind immediately. + path = [] + on_path = {} + visited = {} + found = nil + dfs = lambda do |u| + return true if found + on_path[u] = true + path.push(u) + succ[u].uniq.each do |v| + if on_path[v] + found = path[path.index(v)..] # the cycle, from v back to the current node + break + elsif !visited[v] + break if dfs.call(v) + end + end + path.pop + on_path[u] = false + visited[u] = true + !found.nil? + end + #the graph may be disconnected, so start a search from each stuck unit until + #one of them turns up a cycle + stuck.each { |u| break if dfs.call(u); } + report_deadlock_cycle(state, found) if found +end + +# ADDED: report one deadlock cycle, naming each unit AND the specific command +# (op) it is stuck on -- e.g. "command_list 0x..::zeCommandListAppendMemoryCopy". +# The blocked command is the unit's current_op (the cursor is parked on it and +# blocked_on holds exactly that op's unsatisfied waits), so the chain reads +# :: -> :: -> ... back to the first. +def deadlock_node_label(state, unit) + op = unit.current_op + # op.api is set for every op that can carry waits (copy/launch/barrier/wait); + # fall back to the op kind for anything else so the label is never blank. + api = op ? (op.api || op.kind.to_s) : 'unknown' + waits = unit.blocked_on.map { |h| state.get_handle_str(h) }.join(', ') + "#{unit.label}::#{api} (waiting on event #{waits})" +end + +def report_deadlock_cycle(state, cycle) + ctx = cycle.first.context + desc = cycle.map { |u| deadlock_node_label(state, u) }.join(" -> ") + # close the loop for readability + desc << " -> #{deadlock_node_label(state, cycle.first)}" + state.print_deadlock_error(ctx, "circular event dependency among command list operations; none can start: #{desc}") +end + +# ADDED: detect an intra-list deadlock in an IN-ORDER command list. Such a list +# runs its ops strictly in append order (op N+1 cannot start until op N +# completes), so if the op the unit is parked on waits on an event that only a +# LATER op in the SAME list will signal, that later op can never be reached -- +# the list deadlocks on itself. The cross-list detector cannot see this because +# it drops self-edges. Run at end-of-trace (flush): a unit still parked here was +# never rescued by an external host signal, so the wait is genuinely unmet. +# +# unit.pending_signals holds exactly the events signaled by ops at/after the +# cursor, so blocked_on & pending_signals = waits only a later op in this list +# owes -- the self-deadlock condition -- with no extra bookkeeping. +def check_in_order_self_deadlock(state, units) + units.each do |unit| + next unless unit.in_order + next if unit.blocked_on.nil? || unit.blocked_on.empty? + self_waits = unit.blocked_on & unit.pending_signals + self_waits.each do |ev| + #the later op in this same list that would signal ev (but never runs) + later = unit.ops[(unit.cursor + 1)..]&.find { |o| o.signal == ev } + report_in_order_self_deadlock(state, unit, ev, later) + end + end +end + +# ADDED: report one intra-list self-deadlock in the op-level arrow format: +# :: (waits on event 0xE) -> +# :: (signals event 0xE later in the same in-order list) +def report_in_order_self_deadlock(state, unit, ev, signaling_op) + waiting = unit.current_op + waiting_api = waiting ? (waiting.api || waiting.kind.to_s) : 'unknown' + signaling_api = signaling_op ? (signaling_op.api || signaling_op.kind.to_s) : 'unknown' + ev_str = state.get_handle_str(ev) + desc = "#{unit.label}::#{waiting_api} (waits on event #{ev_str}) -> " \ + "#{unit.label}::#{signaling_api} (signals event #{ev_str} later in the same in-order list)" + state.print_deadlock_error(unit.context, + "in-order command list cannot complete; an earlier command waits on an event a later " \ + "command in the same list signals: #{desc}") +end + +# USAGE CHECK: every Level Zero descriptor struct begins with an `stype` field +# naming its own type (ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC and so on). Setting +# it wrong -- usually by copy-pasting the initialization of a different +# descriptor -- does not fail today: current drivers ignore it. But the field +# exists so the runtime can version and extend structs, so a wrong stype is a +# latent bug that a future driver is entitled to reject. Reported once per +# expected stype. +def check_struct_stype_misuse(state,ctx,defi,expected_stype, observed_stype) + if expected_stype != observed_stype && state.print_tracker[expected_stype] == 0 + state.print_tracker[expected_stype] = 1 + state.print_usage_error(ctx,"\nExpected stype of #{expected_stype}\nbut #{observed_stype} was observed.") + end +end diff --git a/backends/ze/ze_validator_function_entry_exit_callbacks.rb b/backends/ze/ze_validator_function_entry_exit_callbacks.rb new file mode 100644 index 00000000..1ae1feed --- /dev/null +++ b/backends/ze/ze_validator_function_entry_exit_callbacks.rb @@ -0,0 +1,912 @@ +require 'ze_validator_entry_exit_helpers' +require 'ze_validator_zemodel' +require 'ze_library' + +# ============================================================================= +# THE DISPATCH TABLES -- where each Level Zero API is taught to the validator. +# +# This file is a big flat registry. For every API the validator cares about, +# it registers a lambda in one of three global hashes, keyed by API name. +# StateObject#on_entry / #on_exit look the current API up in these and call +# whatever it finds; an API with no entry here is simply ignored. +# +# $upon_entry[api] runs when the call STARTS +# $on_successful_exit[api] runs when it RETURNS SUCCESSFULLY +# $on_erroneous_exit[api] runs when it RETURNS AN ERROR +# +# WHICH TABLE DOES A GIVEN THING BELONG IN? +# ----------------------------------------- +# $on_successful_exit is the default, and holds most MODEL UPDATES: +# * Create calls -- the new handle only exists in the exit payload, and a +# failed create produced no object to record. +# * Destroy calls -- a failed destroy did not destroy anything. +# * Recording appended operations -- only an append that succeeded will +# actually execute later. +# +# $upon_entry holds checks that CANNOT wait for the exit, for two reasons: +# * The call may kill the process (a bad kernel launch, a copy from freed +# memory). If it does, the tracer never writes an _exit event, so an +# exit-time check would silently never run. Several callbacks below say +# exactly this in their comment. +# * The check needs pre-call state -- e.g. zeMemFree must inspect what is +# still in flight BEFORE the buffer is released. +# +# $on_erroneous_exit is for the rarer case where a FAILURE is itself +# informative: the driver rejecting a copy is a good moment to point out that +# the copy was out of bounds, and a failed free must undo the model change that +# was applied optimistically at entry. +# +# READING THE LAMBDAS +# ------------------- +# Every lambda takes |state, ctx, defi| -- see the argument conventions at the +# top of ze_validator_entry_exit_helpers.rb. The recurring idiom +# +# handle = defi['hFoo'] # in an ENTRY callback +# handle = state.find_param(ctx, 'hFoo') # in an EXIT callback +# +# is not inconsistency: at exit, defi holds only outputs, so inputs must be read +# from the saved entry payload via find_param. +# ============================================================================= + +$upon_entry = {} #called to modify program state on entry +$on_successful_exit = {} #called upon seeing exit functions with a successful return code +$on_erroneous_exit = {} #called upon seeing exit functions with a non-successful return code + +# --- Memory residency ------------------------------------------------------- +# Device allocations must be resident in GPU memory to be usable. These two APIs +# move them in and out explicitly; the model just tracks the flag. + +$on_successful_exit["zeContextEvictMemory"] = lambda{|state, ctx, defi| + mem_addr = state.find_param(ctx,"ptr") + # CHANGED: scope the lookup to the evicting context (hContext is a param of + # this API), so an address live in another context is not touched by mistake. + memory_allocations = state.memory_allocations(ctx, state.find_param(ctx, 'hContext')) + if memory_allocations[mem_addr] + mem = memory_allocations[mem_addr] + mem.resident = false + end +} + +#Check whether that memory is accessible by device? +$on_successful_exit["zeContextMakeMemoryResident"] = lambda {|state, ctx, defi| + mem_addr = state.find_param(ctx,"ptr") + # CHANGED: scope the lookup to this context (see zeContextEvictMemory). + memory_allocations = state.memory_allocations(ctx, state.find_param(ctx, 'hContext')) + if memory_allocations[mem_addr] + mem = memory_allocations[mem_addr] + mem.resident = true #Does the driver automatically evict memory if the virtual mem exceeds the physical mem? + end +} + + +# --- Device introspection --------------------------------------------------- +# These two record only that the application ASKED. The portability checks care +# about the question being asked at all: code that never queries the device but +# still passes ordinals and group indices is working from hardcoded assumptions +# that will not survive a hardware change. + +$on_successful_exit["zeDeviceGetProperties"] = lambda{|state, ctx, defi| + device_ptr = state.find_param(ctx,'hDevice') + devices = state.find_objects(ctx, 'device') + devices[device_ptr].property_fetched = true +} + +$on_successful_exit["zeDeviceGetCommandQueueGroupProperties"] = lambda{|state, ctx, defi| + device_ptr = state.find_param(ctx,'hDevice') + devices = state.find_objects(ctx, 'device') + devices[device_ptr].cmd_queue_group_properties_queried = true +} + +# --- Kernel launches -------------------------------------------------------- +# Note the split that recurs for every append API below: VALIDATION at entry +# (because the call may crash), RECORDING at exit (because only a successful +# append will ever execute). + +# CHANGED: validation moved to ENTRY. Launching a kernel on a copy-only ordinal +# can crash the process, so the append may emit no _exit event -- checking at +# exit (the old code, which also had a `stat.find_param` typo that would NameError) +# would miss it. Entry callbacks may read input params directly from defi. +$upon_entry["zeCommandListAppendLaunchKernel"] = lambda { |state, ctx, defi| + #Retrieve the compute ordinal from the command list + command_lists = state.find_objects(ctx, 'command_list') + cmd_list = command_lists[defi['hCommandList']] + cqg_ordinal = 0 + #a normal list carries the ordinal in desc; an immediate list in altdesc + if cmd_list && cmd_list.desc + cqg_ordinal = cmd_list.desc[:commandQueueGroupOrdinal] + elsif cmd_list && cmd_list.altdesc + cqg_ordinal = cmd_list.altdesc[:ordinal] + end + #both checks must run even if the launch later aborts + check_valid_ordinal(state, ctx, defi, cqg_ordinal) + check_kernel_created(state, ctx, defi) + #the kernel's module must be on the same context as the command list + check_kernel_list_context_match(state, ctx, defi) +} + +# CHANGED: op-recording (for deferred execution ordering) stays at EXIT, since +# only a launch that successfully appended actually executes later on the queue. +$on_successful_exit["zeCommandListAppendLaunchKernel"] = lambda { |state, ctx, defi| + record_op(state, ctx, state.find_param(ctx, 'hCommandList'), + ZEModel::RecordedOp.new(:launch, + signal: state.find_param(ctx, 'hSignalEvent'), + waits: wait_event_handles(state, ctx), + api: 'zeCommandListAppendLaunchKernel')) +} + +# ADDED: zeCommandListReset returns a command list to its initial, empty, +# appendable state so it can be reused without destroy+recreate. Misuse checks run +# at ENTRY (a reset can be rejected/crash without emitting an _exit -- e.g. on an +# immediate list) reading the input handle from defi. +$upon_entry["zeCommandListReset"] = lambda { |state, ctx, defi| + check_command_list_reset(state, ctx, defi) +} + +# ADDED: on success the list is empty and open again. Clear the recorded ops so a +# later close/execute replays only ops appended after the reset (in-flight +# executions from before are unaffected -- enqueue_deferred_execution snapshotted +# a dup of the ops at submit time), and return the status to INITIALIZED so the +# closed-before-execute check applies to the reused list. +$on_successful_exit["zeCommandListReset"] = lambda { |state, ctx, defi| + command_lists = state.find_objects(ctx, 'command_list') + cmd_list = command_lists[state.find_param(ctx, 'hCommandList')] + return unless cmd_list + cmd_list.ops.clear + cmd_list.status = ZEModel::CommandList.class_variable_get(:@@INITIALIZED) +} + +#when command queue is executed, the associated fence's status is set to IN_USE +# Closing finalizes the list: no more appends, and it may now be submitted. +# check_command_list_closed later verifies this happened before any submission. +$on_successful_exit["zeCommandListClose"] = lambda { |state, ctx, defi| + command_lists = state.find_objects(ctx, 'command_list') + command_list_handle = state.find_param(ctx,"hCommandList") + cmd_list = command_lists[command_list_handle] + cmd_list.status = ZEModel::CommandList.class_variable_get(:@@CLOSED) +} + +# CHANGED: validation at ENTRY (a cooperative launch can likewise abort without +# an exit). Also fixes the `stte` typo (undefined -> NameError) and nil-guards +# the command list. +$upon_entry["zeCommandListAppendLaunchCooperativeKernel"] = lambda { |state, ctx, defi| + command_lists = state.find_objects(ctx, 'command_list') + cmd_list = command_lists[defi['hCommandList']] + check_group_property_queued(state,ctx,defi,cmd_list.device) if cmd_list + #the kernel's module must be on the same context as the command list + check_kernel_list_context_match(state, ctx, defi) +} + +# CHANGED: recording at EXIT. +$on_successful_exit["zeCommandListAppendLaunchCooperativeKernel"] = lambda { |state, ctx, defi| + record_op(state, ctx, state.find_param(ctx, 'hCommandList'), + ZEModel::RecordedOp.new(:launch, + signal: state.find_param(ctx, 'hSignalEvent'), + waits: wait_event_handles(state, ctx), + api: 'zeCommandListAppendLaunchCooperativeKernel')) +} + +# ---- ADDED: copy / event ops recorded onto the command list for deferred replay +# Each records the op in list order. For copies the out-of-bounds check is +# deferred until the op's wait-events are satisfied (see the scheduler in +# ze_validator_state_object.rb), so it runs against the memory state at the point +# the copy actually executes rather than at append or execute time. Exit +# callbacks read input params via find_param (defi holds only exit output). + +# ADDED: use-after-free check at ENTRY. A copy/fill whose pointer was already +# freed can crash the driver inside the append, so no _exit event is emitted and +# an exit-only check would miss it (the trace shows only the entry, then a crash). +# Entry callbacks read input params directly from defi. This runs the same UAF +# check against the freed registry before the (possibly fatal) append. +$upon_entry['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, defi| + check_null_copy_ptr(state, ctx, 'zeCommandListAppendMemoryCopy', + { 'destination' => defi['dstptr'], 'source' => defi['srcptr'] }) + check_use_after_free_on_append(state, ctx, + { api: 'zeCommandListAppendMemoryCopy', + ctx_handle: cmd_list_ctx_handle(state, ctx, defi['hCommandList']), + dst: defi['dstptr'], src: defi['srcptr'], size: defi['size'] }, + wait_event_handles(state, ctx)) + # known memory endpoints must be allocated on the command list's context + check_copy_ptr_list_context(state, ctx, 'zeCommandListAppendMemoryCopy', defi['hCommandList'], + { 'destination' => defi['dstptr'], 'source' => defi['srcptr'] }) +} + +$upon_entry['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, defi| + check_null_copy_ptr(state, ctx, 'zeCommandListAppendMemoryFill', + { 'destination' => defi['ptr'] }) + check_use_after_free_on_append(state, ctx, + { api: 'zeCommandListAppendMemoryFill', + ctx_handle: cmd_list_ctx_handle(state, ctx, defi['hCommandList']), + dst: defi['ptr'], src: nil, size: defi['size'] }, + wait_event_handles(state, ctx)) + # known memory endpoint must be allocated on the command list's context + check_copy_ptr_list_context(state, ctx, 'zeCommandListAppendMemoryFill', defi['hCommandList'], + { 'destination' => defi['ptr'] }) +} + +$on_successful_exit['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, defi| + record_copy_op(state, ctx, 'zeCommandListAppendMemoryCopy', 'dstptr', 'srcptr') +} + +$on_successful_exit['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, defi| + #a fill only touches the destination; model it as a copy with no source + record_copy_op(state, ctx, 'zeCommandListAppendMemoryFill', 'ptr', nil) +} + +# ADDED: a copy append that FAILED is never recorded and never executed, so the +# deferred check would never see it. But a copy whose size exceeds its +# destination/source allocation is out-of-bounds regardless of the error code -- +# and a failing append is exactly where the driver rejects such a copy (e.g. +# ZE_RESULT_ERROR_OUT_OF_DEVICE_MEMORY). Check it now, against the current memory +# state (the pointers are already allocated at append time). +$on_erroneous_exit['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, defi| + params = { api: 'zeCommandListAppendMemoryCopy', + ctx_handle: cmd_list_ctx_handle(state, ctx, state.find_param(ctx, 'hCommandList')), + dst: state.find_param(ctx, 'dstptr'), + src: state.find_param(ctx, 'srcptr'), + size: state.find_param(ctx, 'size') } + check_oob_copy(state, ctx, params) + check_use_after_free(state, ctx, params) +} + +$on_erroneous_exit['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, defi| + params = { api: 'zeCommandListAppendMemoryFill', + ctx_handle: cmd_list_ctx_handle(state, ctx, state.find_param(ctx, 'hCommandList')), + dst: state.find_param(ctx, 'ptr'), + src: nil, + size: state.find_param(ctx, 'size') } + check_oob_copy(state, ctx, params) + check_use_after_free(state, ctx, params) +} + +$on_successful_exit['zeCommandListAppendMemoryCopyRegion'] = lambda { |state, ctx, defi| + #region copies carry 2D/3D extents, so `size` is not a flat byte count; we only + #record ordering + event effects and skip the flat OOB comparison for now + record_op(state, ctx, state.find_param(ctx, 'hCommandList'), + ZEModel::RecordedOp.new(:launch, + signal: state.find_param(ctx, 'hSignalEvent'), + waits: wait_event_handles(state, ctx), + api: 'zeCommandListAppendMemoryCopyRegion')) +} + +#A device-side signal: the event is signaled when this op executes (after waits). +$on_successful_exit['zeCommandListAppendSignalEvent'] = lambda { |state, ctx, defi| + record_op(state, ctx, state.find_param(ctx, 'hCommandList'), + ZEModel::RecordedOp.new(:signal, signal: state.find_param(ctx, 'hEvent'), + api: 'zeCommandListAppendSignalEvent')) +} + +#A device-side wait: this op blocks the list until phEvents are signaled. +$on_successful_exit['zeCommandListAppendWaitOnEvents'] = lambda { |state, ctx, defi| + record_op(state, ctx, state.find_param(ctx, 'hCommandList'), + ZEModel::RecordedOp.new(:wait, waits: wait_event_handles(state, ctx), + api: 'zeCommandListAppendWaitOnEvents')) +} + +#A device-side reset: returns the event to unsignaled when this op executes. +$on_successful_exit['zeCommandListAppendEventReset'] = lambda { |state, ctx, defi| + record_op(state, ctx, state.find_param(ctx, 'hCommandList'), + ZEModel::RecordedOp.new(:reset, params: { reset_handle: state.find_param(ctx, 'hEvent') })) +} + +#A barrier waits on its events and signals its completion event. +$on_successful_exit['zeCommandListAppendBarrier'] = lambda { |state, ctx, defi| + record_op(state, ctx, state.find_param(ctx, 'hCommandList'), + ZEModel::RecordedOp.new(:barrier, + signal: state.find_param(ctx, 'hSignalEvent'), + waits: wait_event_handles(state, ctx), + api: 'zeCommandListAppendBarrier')) +} + +# ADDED: a memory-ranges barrier has the same event semantics as a plain barrier +# (waits on its events, signals its completion event), so record it in the op +# stream for the deferred scheduler and deadlock detection. It additionally names +# memory ranges whose coherency it guarantees; those ranges are snapshotted and +# validated against the allocation model when the barrier executes (see +# record_ranges_barrier_op / check_ranges_barrier). +$on_successful_exit['zeCommandListAppendMemoryRangesBarrier'] = lambda { |state, ctx, defi| + record_ranges_barrier_op(state, ctx) +} + +# ADDED: host-side event operations, effective immediately (in trace order). +#Signaling an already-signaled event without a reset is the same misuse we catch +#on device ops. +$on_successful_exit['zeEventHostSignal'] = lambda { |state, ctx, defi| + handle = state.find_param(ctx, 'hEvent') + check_event_signal_reuse(state, ctx, handle, 'zeEventHostSignal') + state.signal_event(ctx, handle, 'zeEventHostSignal') +} + +$on_successful_exit['zeEventHostReset'] = lambda { |state, ctx, defi| + state.reset_event(ctx, state.find_param(ctx, 'hEvent')) +} + +#The host waited until the event was signaled and observed it. This does NOT +#signal the event; it records that the signaled state was consumed, so a later +#signal without a reset reads as reuse-without-reset, not a double-signal. +$on_successful_exit['zeEventHostSynchronize'] = lambda { |state, ctx, defi| + state.observe_event(ctx, state.find_param(ctx, 'hEvent')) +} + +#A successful status query also observes the signaled state. +$on_successful_exit['zeEventQueryStatus'] = lambda { |state, ctx, defi| + state.observe_event(ctx, state.find_param(ctx, 'hEvent')) +} + +# ADDED: device-wide host synchronization points. The host waited for all +#submitted work, so every currently-signaled event has been consumed. +#zeCommandQueueSynchronize covers regular queues; zeCommandListHostSynchronize is +#the immediate-command-list analogue (an immediate list is its own implicit +#queue) -- giving immediate lists the same event-observation semantics. +$on_successful_exit['zeCommandQueueSynchronize'] = lambda { |state, ctx, defi| + state.observe_all_signaled_events(ctx) +} + +$on_successful_exit['zeCommandListHostSynchronize'] = lambda { |state, ctx, defi| + state.observe_all_signaled_events(ctx) +} + +# --- Submission ------------------------------------------------------------- +# zeCommandQueueExecuteCommandLists is the busiest callback in the file, because +# submission is where most of the "these objects must belong together" rules +# finally become checkable: the queue, the lists, their events, and the fence +# are only brought into contact here. +# +# It is also ASYNCHRONOUS -- it returns as soon as the work is queued, long +# before the GPU runs it. Hence the split: validation at entry (below), and at +# exit the lists become deferred units to be replayed as their events fire. +$upon_entry["zeCommandQueueExecuteCommandLists"] = lambda { |state, ctx, defi| + command_queues = state.find_objects(ctx, 'command_queue') + command_queue_handle = defi['hCommandQueue'] + command_queue = command_queues[command_queue_handle] + + #check if any command list is null + check_valid_command_lists(state,ctx,defi) + check_valid_command_queue(state,ctx,defi,command_queues,command_queue_handle) + #Check if command list was closed before executing it on the queue + #ignore if it is the first execute call + check_command_list_closed(state, ctx, defi) + check_fence_misuse(state,ctx,defi) + + known_command_lists = state.find_objects(ctx, 'command_list') + # CHANGED: was `state.find_objects(ctx, 'phCommandLists_vals')` -- that is not + # an object store; the submitted list handles are the array payload itself. + command_list_handles = defi['phCommandLists_vals'] || [] + + fences = state.find_objects(ctx, 'fence') + fence_handle = defi['hFence'] + fence = fences[fence_handle] + + if fence + fence.status = fence.in_use #set this at the entry so that other command lists can view it + end + + if command_queue + check_group_property_queued(state,ctx,defi,command_queue.device) + check_fence_and_queue_compatibility(state,ctx,defi,command_queue,fence) + command_list_handles.each do |command_list_handle| + check_list_and_queue_have_matching_context(state,ctx,defi,known_command_lists[command_list_handle],command_queue) + check_list_and_fence_have_matching_context(state,ctx,defi,known_command_lists[command_list_handle],fence) + # ADDED: a list with a compute kernel launch must not go to a copy-only queue + check_copy_only_queue_submission(state,ctx,command_queue,known_command_lists[command_list_handle]) + # ADDED: events used by the list must come from an event pool on the queue's context + check_event_pool_queue_context_match(state,ctx,command_queue,known_command_lists[command_list_handle]) + end + else + # CHANGED: was raise_internal_error, which aborted the whole validator on one + # unknown queue handle (common if tracing started after zeCommandQueueCreate). + # Report and continue so the deferred execution below still runs. + state.print_usage_error(ctx, "command queue #{state.get_handle_str(command_queue_handle)} was not found ") + end +} + +# CHANGED: execute is asynchronous. On success, the submitted lists' recorded ops +# become deferred execution units (each list its own unit). Their memory copies +# are checked for out-of-bounds when their wait-events are signaled, not here (see +# the scheduler in ze_validator_state_object.rb). Previously this was empty. +$on_successful_exit["zeCommandQueueExecuteCommandLists"] = lambda { |state, ctx, defi| + known_command_lists = state.find_objects(ctx, 'command_list') + command_list_handles = state.find_param(ctx, 'phCommandLists_vals') || [] + command_lists = command_list_handles.map { |h| known_command_lists[h] } + state.enqueue_deferred_execution(ctx, command_lists) +} + +#When a fence signals the host, set the fence's status to signaled +$on_successful_exit["zeFenceHostSynchronize"] = lambda { |state, ctx, defi| + # CHANGED: was `get_fence(state,ctx,defi)` -- get_fence expects a handle, not + # defi. Read the handle from the entry params and pass it. + fence_handle = state.find_param(ctx,"hFence") + fence = get_fence(state, ctx, fence_handle) + if fence + fence.status = fence.signaled + else + state.print_usage_error(ctx, "nullptr fence was used for zeFenceHostSynchronize") + end +} + +#should a double reset be considered as a usage error? +#Also, a fence can be shared throughout the threads and is modeled correctly (if you are wondering about whether the model treats fence associated with different thread-id differently). +$upon_entry["zeFenceReset"] = lambda { |state, ctx, defi| + # CHANGED: was ZEModel::Fence.get_fence(...) and @@INITIALIZED -- Fence has no + # such class method or class variable (that method was removed from the model; + # @@INITIALIZED belongs to CommandList). get_fence is a top-level helper, and a + # reset fence returns to the not_signaled instance state. This is an entry + # callback, so input params are available directly in defi. + curr_fence = get_fence(state, ctx, defi['hFence']) + return unless curr_fence + curr_fence.status = curr_fence.not_signaled +} + +# ============================================================================ +# OBJECT LIFECYCLE CALLBACKS. +# +# The remainder of the file is largely mechanical and follows one pattern per +# object type: +# +# CREATE -> build the ZEModel object, file it in the process table AND in +# its parent's table, and validate the descriptor's stype +# DESTROY -> remove it from both tables, and report any children that should +# have been destroyed first +# +# The two-table bookkeeping is what makes leak reporting possible: the process +# table drives the end-of-trace sweep, while the parent's table lets a destroy +# notice that, say, an event pool still holds live events. +# +# The `{ ... }` block passed to Hash#delete is Ruby's "key not found" handler -- +# it fires when a destroy names a handle the model never recorded. +# ============================================================================ + +#Set the driver for the current context +# zeDriverGet reports all installed drivers at once, so this registers each +# handle it returns (skipping any already known -- the app may call it twice). +$on_successful_exit['zeDriverGet'] = lambda { |state, ctx, defi| + drivers = state.get_process(ctx).drivers + defi['phDrivers_vals'].each { |h| + drivers[h] = ZEModel::Driver.new(h) unless drivers[h] + } +} + +#Set device +$on_successful_exit['zeDeviceGet'] = lambda { |state, ctx, defi| + devices = state.find_objects(ctx, 'device') + driver = state.find_object(ctx, 'driver', 'hDriver') + if driver + defi['phDevices_vals'].each { |h| + unless devices[h] + devices[h] = ZEModel::Device.new(h) + driver.devices.push devices[h] + end + } + end +} + + + +$on_successful_exit['zeDeviceGetSubDevices'] = lambda { |state, ctx, defi| + devices = state.find_objects(ctx, 'device') + device = state.find_object(ctx, 'device', 'hDevice') + defi['phSubdevices_vals'].each { |h| + unless devices[h] + devices[h] = ZEModel::SubDevice.new(h, device) + device.sub_devices.push devices[h] + end + } +} + +$on_successful_exit['zeContextCreate'] = lambda { |state, ctx, defi| + contexts = state.find_objects(ctx, 'context') + driver = state.find_object(ctx, 'driver', 'hDriver') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZEContextDesc) + handle = defi['phContext_val'] + contexts[handle] = ZEModel::Context.new(handle, driver, desc) + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_CONTEXT_DESC,desc[:stype]) +} + +$on_successful_exit['zeContextCreateEx'] = lambda { |state, ctx, defi| + contexts = state.find_objects(ctx, 'context') + devices = state.find_objects(ctx, 'device') + driver = state.find_object(ctx, 'driver', 'hDriver') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZEContextDesc) + devs = state.find_param(ctx, 'phDevices_vals').collect { |h| devices[h] } + devs = nil unless state.find_param(ctx, 'phDevices') != 0 + handle = defi['phContext_val'] + contexts[handle] = ZEModel::Context.new(handle, driver, desc, devs) +} + +$on_successful_exit['zeContextDestroy'] = lambda { |state, ctx, defi| + contexts = state.find_objects(ctx, 'context') + contexts.delete(state.find_param(ctx, 'hContext')) { |h| + raise_internal_error(ctx, "context #{state.get_handle_str(h)} does not exist") + } +} + +$on_successful_exit['zeEventPoolCreate'] = lambda { |state, ctx, defi| + context = state.find_object(ctx, 'context', 'hContext') + devices = state.find_objects(ctx, 'device') + event_pools = state.find_objects(ctx, 'event_pool') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZEEventPoolDesc) + devs = state.find_param(ctx, 'phDevices_vals').collect { |h| devices[h] } + devs = nil unless state.find_param(ctx, 'phDevices') != 0 + handle = defi['phEventPool_val'] + event_pools[handle] = ZEModel::EventPool.new(handle, context, desc, devs) + context.event_pools[handle] = event_pools[handle] + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_EVENT_POOL_DESC,desc[:stype]) +} + +# Destroying a pool while events carved out of it are still alive leaves those +# events dangling, so each survivor is reported. +$on_successful_exit['zeEventPoolDestroy'] = lambda { |state, ctx, defi| + event_pools = state.find_objects(ctx, 'event_pool') + handle = state.find_param(ctx, 'hEventPool') + event_pool = event_pools.delete(handle) { + state.object_not_found(ctx, 'event_pool', handle) + } + event_pool.context.event_pools.delete(handle) { + state.object_not_found(ctx, 'event_pool', handle, 'context') + } + event_pool.events.each { |h, _| + state.print_usage_error(ctx, "event #{state.get_handle_str(h)} was not destroyed prior to event_pool #{state.get_handle_str(handle)} destruction") + } +} + +# Events are carved out of a pool's fixed set of slots: desc[:index] picks one. +# Set#delete? returns nil when the index was not free, which means two live +# events claim the same slot -- they would then alias each other's signal state. +$on_successful_exit['zeEventCreate'] = lambda { |state, ctx, defi| + events = state.find_objects(ctx, 'event') + event_pool = state.find_object(ctx, 'event_pool', 'hEventPool') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZEEventDesc) + handle = defi['phEvent_val'] + events[handle] = ZEModel::Event.new(handle, event_pool, desc) + if !event_pool.indices.delete?(desc[:index]) + state.print_usage_error(ctx, "event_pool #{state.get_handle_str(event_pool.handle)} index #{desc[:index]} is already used") + end + event_pool.events[handle] = events[handle] + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_EVENT_DESC,desc[:stype]) +} + +# Releasing an event returns its slot to the pool. Set#add? returning nil means +# the slot was already free -- a double destroy of the same index. +$on_successful_exit['zeEventDestroy'] = lambda { |state, ctx, defi| + events = state.find_objects(ctx, 'event') + handle = state.find_param(ctx, 'hEvent') + event = events.delete(handle) { + state.object_not_found(ctx, 'event', handle) + } + event_pool = event.event_pool + event_pool.events.delete(handle) { + state.object_not_found(ctx, 'event', handle, 'event_pool') + } + if !event_pool.indices.add?(event.desc[:index]) + state.print_usage_error(ctx, "event_pool #{state.get_handle_str(event_pool.handle)} index #{event.desc[:index]} is already freed") + end +} + +$on_successful_exit['zeCommandQueueCreate'] = lambda { |state, ctx, defi| + command_queues = state.find_objects(ctx, 'command_queue') + context = state.find_object(ctx, 'context', 'hContext') + device = state.find_object(ctx, 'device', 'hDevice') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZECommandQueueDesc) + handle = defi['phCommandQueue_val'] + command_queues[handle] = ZEModel::CommandQueue.new(handle, context, device, desc) + context.command_queues[handle] = command_queues[handle] + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC,desc[:stype]) +} + +# A queue creation that FAILED is the likely symptom of an (ordinal, index) pair +# the device does not have, so this is the moment to check the index against the +# real topology and explain the failure. +$on_erroneous_exit['zeCommandQueueCreate'] = lambda { |state, ctx, defi| + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZECommandQueueDesc) + handle = state.find_param(ctx, 'phCommandQueue') + check_valid_index_for_ordinal(state,ctx,handle,desc[:ordinal],desc[:index]) +} + +$on_successful_exit['zeCommandQueueDestroy'] = lambda { |state, ctx, defi| + command_queues = state.find_objects(ctx, 'command_queue') + handle = state.find_param(ctx, 'hCommandQueue') + command_queue = command_queues.delete(handle) { + state.object_not_found(ctx, 'command_queue', handle) + } + command_queue.context.command_queues.delete(handle) { + state.object_not_found(ctx, 'command_queue', handle, 'context') + } + command_queue.fences.each { |h, _| + state.print_usage_error(ctx, "fence #{state.get_handle_str(h)} was not destroyed prior to command_queue #{state.get_handle_str(handle)} destruction") + } +} + +$on_successful_exit['zeFenceCreate'] = lambda { |state, ctx, defi| + fences = state.find_objects(ctx, 'fence') + command_queue = state.find_object(ctx, 'command_queue', 'hCommandQueue') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZEFenceDesc) + handle = defi['phFence_val'] + fence = ZEModel::Fence.new(handle, command_queue, desc) + fences[handle] = fence + command_queue.fences[handle] = fence + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_FENCE_DESC,desc[:stype]) +} + +$on_successful_exit['zeFenceDestroy'] = lambda { |state, ctx, defi| + fences = state.find_objects(ctx, 'fence') + handle = state.find_param(ctx, 'hFence') + fence = fences.delete(handle) { + state.object_not_found(ctx, 'fence', handle) + } + command_queue = fence.command_queue + command_queue.fences.delete(handle) { + state.object_not_found(ctx, 'fence', handle, 'command_queue') + } +} + +$on_successful_exit['zeCommandListCreate'] = lambda { |state, ctx, defi| + command_lists = state.find_objects(ctx, 'command_list') + context = state.find_object(ctx, 'context', 'hContext') + device = state.find_object(ctx, 'device', 'hDevice') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZECommandListDesc) + handle = defi['phCommandList_val'] + command_lists[handle] = ZEModel::CommandList.new(handle, context, device, desc, nil) + # ADDED: remember whether this list is in-order. desc[:flags] decodes (via the + # FFI zebitmask) to an array of symbols; IN_ORDER means appended ops run strictly + # in order, enabling the intra-list self-deadlock check. + command_lists[handle].in_order = !!(desc && desc[:flags].respond_to?(:include?) && + desc[:flags].include?(:ZE_COMMAND_LIST_FLAG_IN_ORDER)) + context.command_lists[handle] = command_lists[handle] + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_COMMAND_LIST_DESC,desc[:stype]) +} + +$on_successful_exit['zeCommandListCreateImmediate'] = lambda { |state, ctx, defi| + command_lists = state.find_objects(ctx, 'command_list') + context = state.find_object(ctx, 'context', 'hContext') + device = state.find_object(ctx, 'device', 'hDevice') + altdesc_val = state.find_param(ctx, 'altdesc_val') + altdesc = state.to_struct(altdesc_val, ZE::ZECommandQueueDesc) + handle = defi['phCommandList_val'] + check_group_property_queued(state,ctx,defi,device) + command_lists[handle] = ZEModel::CommandList.new(handle, context, device, nil, altdesc) + command_lists[handle].immediate = true #immdediate command lists cannot be passed to the execute command lists + command_lists[handle].associated_ordinal = altdesc[:ordinal] + # ADDED: immediate lists carry the queue desc (altdesc); its IN_ORDER flag is the + # queue-level one. Immediate appends still CAN deadlock among themselves (e.g. + # op1 waits A/signals B while op2 waits B/signals A) -- but each append is its + # own single-op DeferredUnit, so such a cycle is a CROSS-unit cycle already + # caught by check_circular_deadlock, not the single-unit case + # check_in_order_self_deadlock handles. Recorded here for consistency. + command_lists[handle].in_order = !!(altdesc && altdesc[:flags].respond_to?(:include?) && + altdesc[:flags].include?(:ZE_COMMAND_QUEUE_FLAG_IN_ORDER)) + context.command_lists[handle] = command_lists[handle] + + #immediate command list does not take in the list descriptor as an input + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC,altdesc[:stype]) +} + +$on_successful_exit['zeCommandListDestroy'] = lambda { |state, ctx, defi| + command_lists = state.find_objects(ctx, 'command_list') + handle = state.find_param(ctx, 'hCommandList') + command_list = command_lists.delete(handle) { + state.object_not_found(ctx, 'command_list', handle) + } + command_list.context.command_lists.delete(handle) { + state.object_not_found(ctx, 'command_list', handle, 'context') + } +} + +$on_successful_exit['zeModuleCreate'] = lambda { |state, ctx, defi| + modules = state.find_objects(ctx, 'module') + context = state.find_object(ctx, 'context', 'hContext') + device = state.find_object(ctx, 'device', 'hDevice') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZEModuleDesc) + handle = defi['phModule_val'] + mod = ZEModel::Module.new(handle, context, device, desc) + modules[handle] = mod + context.modules[handle] = mod + build_log_handle = defi['phBuildLog_val'] + if build_log_handle != 0 + module_build_logs = state.find_objects(ctx, 'module_build_log') + build_log = ZEModel::Module::BuildLog.new(build_log_handle, mod) + module_build_logs[build_log_handle] = build_log + context.module_build_logs[build_log_handle] = build_log + modules[handle].build_log = build_log + end + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_MODULE_DESC,desc[:stype]) + + +} + +$on_erroneous_exit['zeModuleCreate'] = lambda { |state, ctx, defi| + build_log_handle = defi['phBuildLog_val'] + if build_log_handle != 0 + module_build_logs = state.find_objects(ctx, 'module_build_log') + build_log = ZEModel::Module::BuildLog.new(build_log_handle) + module_build_logs[build_log_handle] = build_log + context.module_build_logs[build_log_handle] = build_log + end +} + +$on_successful_exit['zeModuleDestroy'] = lambda { |state, ctx, defi| + modules = state.find_objects(ctx, 'module') + handle = state.find_param(ctx, 'hModule') + mod = modules.delete(handle) { + state.object_not_found(ctx, 'module', handle) + } + mod.context.modules.delete(handle) { + state.object_not_found(ctx, 'module', handle, 'context') + } + mod.kernels.each { |h, _| + state.print_usage_error(ctx, "kernel #{state.get_handle_str(h)} was not destroyed prior to module #{state.get_handle_str(handle)} destruction") + } +} + +$on_erroneous_exit['zeModuleDynamicLink'] = $on_successful_exit['zeModuleDynamicLink'] = lambda { |state, ctx, defi| + build_log_handle = defi['phLinkLog_val'] + if build_log_handle != 0 + module_build_logs = state.find_objects(ctx, 'module_build_log') + build_log = ZEModel::Module::BuildLog.new(build_log_handle) + module_build_logs[build_log_handle] = build_log + context.module_build_logs[build_log_handle] = build_log + end +} + +$on_successful_exit['zeModuleBuildLogDestroy'] = lambda { |state, ctx, defi| + module_build_logs = state.find_objects(ctx, 'module_build_log') + handle = state.find_param(ctx, 'hModuleBuildLog') + module_build_log = module_build_logs.delete(handle) { + state.object_not_found(ctx, 'module_build_log', handle) + } + if module_build_log.module + module_build_log.module.context.module_build_logs.delete(handle) { + state.object_not_found(ctx, 'module_build_log', handle, 'context') + } + module_build_log.module.build_log = nil + end +} + +$upon_entry['zeKernelCreate'] = lambda {|state, ctx, defi| + check_valid_module(state,ctx, defi) +} + +$on_successful_exit['zeKernelCreate'] = lambda { |state, ctx, defi| + kernels = state.find_objects(ctx, 'kernel') + mod = state.find_object(ctx, 'module', 'hModule') + desc_val = state.find_param(ctx, 'desc_val') + desc = state.to_struct(desc_val, ZE::ZEKernelDesc) + handle = defi['phKernel_val'] + kernelName = state.find_param(ctx, 'desc__pKernelName_val') + kernel = ZEModel::Kernel.new(handle, mod, desc, kernelName) + kernels[handle] = kernel + mod.kernels[handle] = kernel + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_KERNEL_DESC, desc[:stype]) +} + +$on_successful_exit['zeKernelDestroy'] = lambda { |state, ctx, defi| + kernels = state.find_objects(ctx, 'kernel') + handle = state.find_param(ctx, 'hKernel') + kernel = kernels.delete(handle) { + state.object_not_found(ctx, 'kernel', handle) + } + mod = kernel.module + mod.kernels.delete(handle) { + state.object_not_found(ctx, 'kernel', handle, 'module') + } +} + +# REMOVED: a second, broken $on_successful_exit['zeCommandListAppendMemoryCopy'] +# used to live here. It called add_api_call_to_cmd_list (undefined locals) and, +# being defined later, would have overridden the recorder above. The single +# recorder near the other append callbacks now handles this API. + +# ============================================================================ +# MEMORY ALLOCATION AND FREEING. +# +# Three allocation flavors, one free. Each allocator does the same four things: +# 1. resolve the Level Zero context -- allocations are keyed by it, since an +# address is only guaranteed unique WITHIN a context +# 2. call mark_reallocated, because the driver may hand back an address that +# was freed earlier; without this the stale freed-record would make the +# fresh allocation look like a dangling pointer +# 3. build the Memory object and file it in the process's per-context map +# (and, for device memory, on the owning Device as well) +# 4. validate the descriptor's stype +# ============================================================================ + +#The implementation of this (zeMemAllocDevice) function must be thread-safe +$on_successful_exit['zeMemAllocDevice'] = lambda { |state, ctx, defi| + # memory is associated with devices + ctx_handle = state.find_param(ctx, 'hContext') # ADDED: key allocations by context + memory_allocations = state.memory_allocations(ctx, ctx_handle) + context = state.find_object(ctx, 'context', 'hContext') + device = state.find_object(ctx, 'device','hDevice') + size = state.find_param(ctx,"size") + device_desc_val = state.find_param(ctx,"device_desc_val") + handle = defi['pptr_val'] + mark_reallocated(state, ctx, ctx_handle, handle, size) # ADDED: address may reuse a freed range in this context + memory_allocation = ZEModel::Memory.new(handle, context, size, device, "device") + memory_allocations[handle] = memory_allocation + device.memory_allocations[ctx_handle][handle] = memory_allocation # CHANGED: per-context device sub-map + device_desc = state.to_struct(device_desc_val, ZE::ZEDeviceMemAllocDesc) + check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_DEVICE_MEM_ALLOC_DESC, device_desc[:stype]) +} + + +$on_successful_exit['zeMemAllocShared'] = lambda { |state, ctx, defi| + ctx_handle = state.find_param(ctx, 'hContext') # ADDED: key allocations by context + memory_allocations = state.memory_allocations(ctx, ctx_handle) + # finds the device and context objects associated with the params + context = state.find_object(ctx, 'context', 'hContext') + device = state.find_object(ctx, 'device','hDevice') + # Passing nullptr as the device handle does not associate the shared allocation with any device. + # For allocations with no associated device, ownership of the allocation is shared between the + # host and all devices supporting cross-device shared access capabilities. + # TODO: should add in code to add this mme allocation to all devices with that property + size = state.find_param(ctx,"size") + handle = defi['pptr_val'] + mark_reallocated(state, ctx, ctx_handle, handle, size) # ADDED: address may reuse a freed range in this context + memory_allocation = ZEModel::Memory.new(handle, context, size, device) + memory_allocations[handle] = memory_allocation + device.memory_allocations[ctx_handle][handle] = memory_allocation if device # CHANGED: per-context device sub-map +} + +$on_successful_exit['zeMemAllocHost'] = lambda { |state, ctx, defi| + # Host allocations are accessible by the host and all devices within the driver’s context. + # TODO: add this memory allocation to all devices in the context + ctx_handle = state.find_param(ctx, 'hContext') # ADDED: key allocations by context + memory_allocations = state.memory_allocations(ctx, ctx_handle) + context = state.find_object(ctx, 'context', 'hContext') + size = state.find_param(ctx,"size") + handle = defi['pptr_val'] + mark_reallocated(state, ctx, ctx_handle, handle, size) # ADDED: address may reuse a freed range in this context + memory_allocation = ZEModel::Memory.new(handle, context, size, nil, "host") + memory_allocations[handle] = memory_allocation +} + +# CHANGED: apply the free at ENTRY, not exit. On the program timeline the app +# relinquishes the buffer at the call to zeMemFree; nothing after that call may +# touch it. Applying the free at _exit is wrong when zeMemFree BLOCKS until the +# buffer is idle: a gated copy that reads the buffer can be released (by another +# thread signaling its wait event) and executed BETWEEN this call's _entry and +# _exit, so at _exit the copy has already drained (nothing looks in-flight) and, +# while the copy ran, the model had not yet marked the buffer freed (no UAF). +# Doing it at entry lets check_free_in_flight see the still-parked copy, and +# marks the buffer freed before that copy is later replayed, so the deferred UAF +# check fires too. If the free actually fails, the erroneous-exit handler below +# restores the allocation. +$upon_entry['zeMemFree'] = lambda { |state, ctx, defi| + ctx_handle = defi['hContext'] # ADDED: allocations are keyed by freeing context + memory_allocations = state.memory_allocations(ctx, ctx_handle) + handle = defi['ptr'] + memory_allocation = memory_allocations[handle] + next unless memory_allocation + # flag if this buffer is still referenced by a copy/fill that has been + # submitted but not yet completed (in-flight device work would touch freed mem) + check_free_in_flight(state, ctx, memory_allocation) + memory_allocations.delete(handle) + owned = memory_allocation.owned_by + owned.memory_allocations[ctx_handle].delete(handle) if owned # CHANGED: per-context device sub-map + # keep the freed allocation in this context's freed registry so a later + # copy/fill/kernel referencing this address is caught as use-after-free + memory_allocation.freed_by = state.get_api_context(ctx) + state.freed_memory_allocations(ctx, ctx_handle)[handle] = memory_allocation +} + +# ADDED: the free was applied at entry; if the driver reported failure, the +# buffer is actually still alive -- move it back from the freed registry to the +# live set so it is not falsely flagged as use-after-free later. +$on_erroneous_exit['zeMemFree'] = lambda { |state, ctx, defi| + ctx_handle = state.find_param(ctx, 'hContext') # ADDED: same context the entry freed under + handle = state.find_param(ctx, "ptr") + mem = state.freed_memory_allocations(ctx, ctx_handle).delete(handle) + if mem + mem.freed_by = nil + state.memory_allocations(ctx, ctx_handle)[handle] = mem + owned = mem.owned_by + owned.memory_allocations[ctx_handle][handle] = mem if owned # CHANGED: per-context device sub-map + end +} \ No newline at end of file diff --git a/backends/ze/ze_validator_state_object.rb b/backends/ze/ze_validator_state_object.rb new file mode 100644 index 00000000..1ef092cf --- /dev/null +++ b/backends/ze/ze_validator_state_object.rb @@ -0,0 +1,817 @@ +require 'babeltrace2' +require 'ze_library' +require 'set' +require 'ze_validator_zemodel' +require 'ze_validator_function_entry_exit_callbacks' +require 'ze_validator_state_object' +require 'yaml' +require 'json' + +# ============================================================================= +# StateObject -- the validator's engine. +# +# One instance exists for an entire run. It plays three roles: +# +# 1. THE BABELTRACE SINK. #consume returns the lambda the trace graph calls +# with each batch of decoded events. That lambda is the entry point for +# everything below. +# +# 2. THE MODEL OWNER. @state holds the whole ZEModel tree +# (hostname -> Node -> pid -> Process -> objects). Every find_* / get_* +# helper here is a path into that tree, and the callbacks in +# ze_validator_function_entry_exit_callbacks.rb reach the model only +# through these methods. +# +# 3. THE REPORTER AND SCHEDULER. All print_* methods (the only place +# diagnostics are emitted) live here, as does the deferred-execution +# scheduler that replays asynchronous GPU work at the right moment. +# +# THE LIFE OF ONE TRACE EVENT +# --------------------------- +# consume +# -> match name against /:(z.*)_(entry|exit)/ -> api name + entry|exit +# -> build `context` = {hostname, vpid, vtid, api}, the "who/where" tuple +# threaded through literally every method in the validator +# -> check_initialization (was zeInit called first?) +# -> print_deprecation_warning +# -> on_entry / on_exit +# - lock/unlock shared objects for the thread-safety check +# - run the API's callback +# - push/pop the thread's call stack +# -> pump_deferred (a signal may have unblocked pending GPU work) +# ...and once the trace is exhausted, ze_validator.in calls #check_issues. +# +# TWO VOCABULARY NOTES THAT UNLOCK THE REST OF THE CODE +# ----------------------------------------------------- +# `context` (lowercase, often `ctx`) -- the TRACE context hash above. It says +# which host/process/thread/API is currently executing. It is NOT a +# Level Zero context. +# `ctx_handle` / ZEModel::Context -- the LEVEL ZERO context, the isolation +# domain objects belong to. +# The two are unrelated and appear side by side constantly; keeping them +# distinct is the single biggest hurdle when reading this file. +# +# `defi` -- the decoded event payload ("definition"). At _entry it holds the +# call's input arguments; at _exit it holds the return code and output +# values. This asymmetry is why exit callbacks call find_param (which +# reads the saved _entry payload) to get at inputs. +# ============================================================================= +class StateObject + # hostname -> ZEModel::Node. The root of the entire model. + attr_reader :state + # Parsed ze_thread_safety.yaml: api name -> objects that must not be touched + # concurrently during that call. + attr_reader :ze_thread_safety + # Callback lists built from the YAML above, run around every traced call. + attr_reader :lock_shared_object_on_entry + attr_reader :unlock_shared_object_on_exit + # DEDUPLICATION TABLE. A GPU program is a loop: the same mistake in the same + # line will recur thousands of times and would bury the report. Checks build a + # key describing the specific violation, print only when its counter is 0, and + # then set it to 1. Seeing `state.print_tracker[key] == 0` guarding a + # print_usage_error is this idiom, and it is used all over the check library. + attr_accessor :print_tracker + attr_accessor :device_agnostic # --disable-dagn turns this off + attr_accessor :performance # --disable-performance turns this off + attr_accessor :memory_in_transit + # Real command-queue-group topology of this machine, or nil when unavailable. + attr_reader :device_properties + def initialize(**opts) + # api name -> [deprecated_since_version, replacement_api]. Loaded from a + # data file so new deprecations need no code change. + @deprecated = JSON.parse(File.read(File.join(DATADIR, 'ze_deprecated.json'))) + # Per-device command queue group topology (ordinal -> engine type + numQueues), + # generated on a real device by the ze_device_property helper binary and + # installed alongside the other data files. Loaded if present; validation that + # does not depend on it still runs when the file is absent. + @device_properties = load_device_properties + + #for supressing redundant error outputs + # Any key not yet seen reads as 0, so checks can test-and-set without + # initializing keys first. + @print_tracker = Hash.new { |h, k| h[k] = 0 } + + # Append a third slot to every entry: "has this warning been printed?". + @deprecated.each do |api, (version, replacement)| + @deprecated[api] = [version, replacement, false] + end + @performance = opts[:performance] + @device_agnostic = opts[:device_agnostic] + # The model root. Nodes, and in turn processes and threads, spring into + # existence the first time an event mentions them -- so no discovery pass is + # needed before consuming the trace. + @state = Hash.new { |h, k| h[k] = ZEModel::Node.new(k) } + @ze_thread_safety = YAML::load_file(File.join(DATADIR, 'ze_thread_safety.yaml')) + @lock_shared_object_on_entry = Hash.new { |h, k| h[k] = [] } + @unlock_shared_object_on_exit = Hash.new { |h, k| h[k] = [] } + @init_called = Hash.new { |h, k| h[k] = false } #pid : init called status + @memory_in_transit = Hash.new {|h,k| h[k] = []} #pid : [[mem, (src|dst)]] list of memories being transferred + @printed_init_error = false + # zeCommandQueueExecuteCommandLists is asynchronous, so we do not check a + # list's memory copies at execute time (the destination may only be allocated + # later by another unit that then signals a wait-event). Instead each + # submitted list becomes a ZEModel::DeferredUnit here and is advanced by + # pump_deferred as events get signaled -- NO Ruby threads/fibers; it is a + # plain cursor-based worklist driven by the single trace-consumption loop. + @deferred_units = [] + # ADDED: bumped whenever an event transitions to signaled, so pump_deferred + # knows something may have become unblocked and is worth another sweep. + @signal_epoch = 0 + # ------------------------------------------------------------------ + # Build the thread-safety callbacks from ze_thread_safety.yaml. + # + # The YAML maps each API to the objects it must hold exclusively, as + # [param_name, object_type] pairs -- e.g. for zeCommandListAppendBarrier, + # ['hCommandList', 'command_list'] means "the command list named by the + # hCommandList argument". So `o.first` is the parameter name to read the + # handle from, and `o.last` is which model table to look it up in. + # + # For every such pair we synthesize two lambdas: one that locks the object + # when the call starts, one that unlocks it when the call returns. Because + # trace events are timestamp-ordered, finding an object already locked means + # two calls genuinely overlapped -- see ZEModel::Object#lock. + # + # A parameter may name an ARRAY of handles (e.g. a list of command lists), + # hence the kind_of? Array branch in each lambda. + # ------------------------------------------------------------------ + @ze_thread_safety.each { |api, objects| + objects.each { |o| + @lock_shared_object_on_entry[api].push( lambda { |state, ctx, defi| + #at entry the input args are in defi directly + handle = defi[o.first] + if handle.kind_of? Array + handle.each { |h| + # CHANGED: nil-guard -- find_object may + # return nil for an unknown handle (e.g. + # tracing started mid-stream); do not crash + obj = state.find_object(ctx, o.last, h) + obj.lock(state, ctx) if obj + } + else + obj = state.find_object(ctx, o.last, handle) + obj.lock(state, ctx) if obj + end + }) + @unlock_shared_object_on_exit[api].push( lambda { |state, ctx, defi| + #at exit defi holds only outputs, so the input + #handle comes from the saved entry payload + handle = state.find_param(ctx, o.first) + if handle.kind_of? Array + handle.each { |h| + # CHANGED: nil-guard as above + obj = state.find_object(ctx, o.last, h) + obj.unlock(ctx) if obj + } + else + obj = state.find_object(ctx, o.last, handle) + obj.unlock(ctx) if obj + end + }) + } + } + + end + + # Reads ze_device_property.json from DATADIR. Returns the parsed hash, or nil + # if the file is missing or unparseable so the validator degrades gracefully. + def load_device_properties + path = File.join(DATADIR, 'ze_device_property.json') + return nil unless File.file?(path) + JSON.parse(File.read(path)) + rescue JSON::ParserError => e + $stderr.puts "Warning: could not parse #{path}: #{e.message}" + nil + end + + # Look up a command queue group by ordinal. Without a device index it returns + # the matching group from the first device (sufficient for homogeneous nodes). + # Returns a hash like {"ordinal"=>1, "type"=>"copy", "numQueues"=>8} or nil. + def command_queue_group(ordinal, device_index: nil) + return nil unless @device_properties + devices = @device_properties['devices'] || [] + devices = devices.select { |d| d['device_index'] == device_index } if device_index + devices.each do |dev| + group = (dev['command_queue_groups'] || []).find { |g| g['ordinal'] == ordinal } + return group if group + end + nil + end + + + # -------------------------------------------------------------------------- + # Model navigation. Each of these walks the trace context down the model tree + # (hostname -> Node -> pid -> Process -> tid -> Thread). Every level + # auto-creates on first access, so these never return nil for a new + # host/process/thread. + # -------------------------------------------------------------------------- + + # The innermost API call currently executing on this thread, or nil. + def get_last_entry(context) + @state[context['hostname']].processes[context['vpid']].threads[context['vtid']].last_entry + end + + def get_thread(context) + @state[context['hostname']].processes[context['vpid']].threads[context['vtid']] + end + + def get_process(context) + @state[context['hostname']].processes[context['vpid']] + end + + # Sanity check run at every _exit: the call we are returning from must be the + # one on top of this thread's stack. A mismatch means the model has lost sync + # with the trace (missing or reordered events), so it aborts loudly rather + # than producing nonsense diagnostics from a corrupt state. + def check_last_entry(context) + last_entry = get_last_entry(context) + unless last_entry && last_entry.name == context['api'] + raise "Invalid State in #{context['api']}" + end + end + + + # CHANGED: push a new call frame instead of overwriting a single slot, so a + # traced API that calls another traced API on the same thread nests correctly. + def set_last_entry(state, context, defi) + get_thread(context).call_stack.push(ZEModel::ApiCall.new(context['api'], defi)) + end + + # CHANGED: pop the innermost frame on return, exposing the caller's frame (if + # any) rather than clearing everything. + def reset_last_entry(context) + get_thread(context).call_stack.pop + end + + # Did the call succeed? The exit payload carries the raw integer return code; + # the generated FFI enum turns it back into a symbol to compare against + # ZE_RESULT_SUCCESS. This decides whether on_exit runs the success or the + # error callback for the API. + def validate_result(defi) + ZE::ZEResult.from_native(defi["zeResult"], nil) == :ZE_RESULT_SUCCESS + end + + # -------------------------------------------------------------------------- + # Message formatting. Handles are printed zero-padded to 16 hex digits so + # columns line up and the same object is textually identical everywhere -- + # which also makes these strings safe to use as print_tracker dedup keys. + # -------------------------------------------------------------------------- + def get_handle_str(handle) + '0x%016x' % handle + end + + # "hostname - pid": the right granularity for process-wide findings such as + # leaks and deadlocks, which no single API call is responsible for. + def get_proc_context_str(context) + "#{context['hostname']} - #{context['vpid']}" + end + + # "tid in zeSomeApi": identifies the specific call. + def get_api_context(context) + "#{context['vtid']} in #{context['api']}" + end + + # Fully qualified "hostname - pid - tid in zeSomeApi", used for findings + # attributable to one call. + def get_context_str(context) + "#{get_proc_context_str(context)} - #{get_api_context(context)}" + end + + # Warn once per deprecated API actually used. The third slot of the + # @deprecated entry is the already-printed flag. + def print_deprecation_warning(old_api) + if @deprecated.include?(old_api) and @deprecated[old_api][2] + deprecated_since = @deprecated[old_api][0] + new_api = @deprecated[old_api][1] + if deprecated_since == "" + puts "#{old_api} is deprecated. Please use #{new_api} instead." + else + puts "#{old_api} is deprecated since #{deprecated_since}. Please use #{new_api} instead." + end + end + end + + # -------------------------------------------------------------------------- + # DIAGNOSTIC CHANNELS. Every finding the validator reports goes through one of + # these; nothing else prints. Each writes to stderr with a distinct + # "Level Zero :" prefix so a report can be grepped or filtered by + # severity class. Findings are printed as they are discovered, so their order + # follows the trace timeline. + # + # Portability Error - works here, may break on other/newer hardware + # Performance Issue - correct but slow API usage + # Usage Error - violates the Level Zero specification + # Crash Error - a call that never returned (no _exit event in trace) + # Memory Error - use-after-free, freeing memory still in use + # Deadlock - circular event dependency; work can never proceed + # Leak - object created but never destroyed + # -------------------------------------------------------------------------- + def print_portability_error(context,str) + $stderr.puts "Level Zero Portability Error: on #{get_context_str(context)}: #{str}\n\n" + end + def print_performance_issue(context,str) + $stderr.puts "Level Zero Performance Issue: on #{get_context_str(context)}: #{str}\n\n" + end + def print_usage_error(context, str) + $stderr.puts "Level Zero Usage Error: on #{get_context_str(context)}: #{str}\n\n" + end + + def print_crash_error(context, str) + $stderr.puts "Level Zero Crash Error: on #{get_context_str(context)}: #{str}\n\n" + end + + # ADDED: reporting channel for memory-safety violations (use-after-free, + # freeing memory still in use by in-flight device work). + def print_memory_error(context, str) + $stderr.puts "Level Zero Memory Error: on #{get_context_str(context)}: #{str}\n\n" + end + + # ADDED: reporting channel for circular event dependency (deadlock). Uses the + # process-level context because a deadlock spans multiple command lists/threads + # rather than a single api call. + def print_deadlock_error(context, str) + $stderr.puts "Level Zero Deadlock: on #{get_proc_context_str(context)}: #{str}\n\n" + end + + + + # Reported from check_issues at end of trace for every object still alive. + # memtypestr distinguishes device/host/shared for memory allocations; it is + # empty for handle-based objects. + def print_leak_error(context, type, handle, memtypestr="") + if memtypestr.empty? + $stderr.puts "Level Zero Leak: on #{get_proc_context_str(context)}: #{type} #{get_handle_str(handle)}\n\n" + else + $stderr.puts "Level Zero Leak #{memtypestr}-memory: on #{get_proc_context_str(context)}: #{type} #{get_handle_str(handle)}\n\n" + end + end + + # NOT a finding about the traced program -- this aborts the validator itself. + # Used when the model reaches a state that should be impossible (e.g. deleting + # an object that was never created), which means the validator's own + # bookkeeping is wrong and any further output would be untrustworthy. + def raise_internal_error(context, str) + raise "Invalid state #{get_context_str(context)}: #{str}" + end + + # Reported from ZEModel::Object#lock when two calls hold the same object at + # once. Deduped per (object, other holder) so a racing loop reports once. + def print_race_condition(context, other_context, type, handle) + if @print_tracker["#{type}-#{get_handle_str(handle)}-#{get_api_context(other_context)}"] == 0 + @print_tracker["#{type}-#{get_handle_str(handle)}-#{get_api_context(other_context)}"] = 1 + print_usage_error(context, "concurrent acces to #{type} #{get_handle_str(handle)}, already held by #{get_api_context(other_context)}") + end + end + + # Passed as the block to Hash#delete by the destroy callbacks, so it fires + # when a destroy names a handle the model never recorded. `sub_context` says + # which secondary table was being cleaned up (e.g. the owning context's list). + def object_not_found(context, type, handle, sub_context = nil) + raise_internal_error(context, "event_pool #{get_handle_str(handle)} not found#{sub_context ? " in #{sub_context}" : ""}") + end + + # -------------------------------------------------------------------------- + # Lookup helpers used constantly by the callbacks and checks. + # -------------------------------------------------------------------------- + + # Read one INPUT argument of the call currently executing on this thread. This + # works at _exit too -- the exit payload has only outputs, but the entry + # payload is still on the thread's call stack, which is exactly what this + # reaches into. + def find_param(context, name) + get_last_entry(context).params[name] + end + + # The whole handle->object table for a type, e.g. find_objects(ctx, + # 'command_list') returns the process's @command_lists. + def find_objects(context, type) + get_process(context).instance_variable_get("@#{type}s") + end + + # Resolve a single object. `handle` may be either the integer handle itself + # or, as a convenience, the NAME of the parameter carrying it -- so + # find_object(ctx, 'context', 'hContext') reads hContext from the current call + # and looks the resulting handle up in one step. + def find_object(context, type, handle) + handle = find_param(context, handle) if handle.kind_of? String + find_objects(context, type)[handle] + end + + # ADDED: the live-allocation sub-map for one Level Zero context (address -> + # Memory). Allocations are keyed by context handle because the L0 unified + # virtual address space only guarantees non-aliasing addresses within a + # context (see Process#memory_allocations). ctx_handle nil falls back to a + # single shared bucket so a trace that started mid-stream -- where the owning + # context is unknown -- still tracks something rather than crashing. + def memory_allocations(context, ctx_handle) + get_process(context).memory_allocations[ctx_handle] + end + + # ADDED: per-context registry of allocations that have been zeMemFree'd but + # kept for use-after-free detection (address -> freed ZEModel::Memory). + # CHANGED: now scoped by context handle, mirroring the live map above. + def freed_memory_allocations(context, ctx_handle) + get_process(context).freed_memory_allocations[ctx_handle] + end + + # ADDED: yield [unit, op] for every copy/fill op still pending (at or after the + # cursor) in an in-flight deferred unit belonging to this process. Used by the + # free-in-flight check to see whether a buffer being freed is still referenced + # by device work that has been submitted but not yet completed. Deferred units + # do not carry a process id, so we match on the unit's context host+pid. + def each_inflight_copy_op(context) + @deferred_units.each do |unit| + next unless unit.context['hostname'] == context['hostname'] && + unit.context['vpid'] == context['vpid'] + unit.ops[unit.cursor..].each do |op| + next unless op && op.kind == :copy + yield unit, op + end + end + end + + # ADDED: true if command list `handle` still has an in-flight deferred + # execution in this process -- a prior zeCommandQueueExecuteCommandLists whose + # ops have not all drained yet. Used by zeCommandListReset, which must not run + # while the list is still executing (undefined behavior in Level Zero). Matches + # on the unit's originating list handle and the process it belongs to (deferred + # units carry no pid, so we compare context host+pid, mirroring + # each_inflight_copy_op). + def command_list_in_flight?(context, handle) + @deferred_units.any? do |unit| + unit.cmd_list_handle == handle && + unit.context['hostname'] == context['hostname'] && + unit.context['vpid'] == context['vpid'] && + !unit.done? + end + end + + # Decode a raw descriptor blob from the trace into a typed FFI struct. + # The tracer captures structs like ze_command_queue_desc_t as opaque bytes; + # this copies them into native memory and reinterprets them as `klass` (from + # the generated ze_library bindings) so callbacks can read desc[:ordinal], + # desc[:stype], and so on. Returns nil for an empty blob (a null descriptor + # pointer), which every caller must be prepared for. + def to_struct(memory, klass) + memory.size > 0 ? klass.new(FFI::MemoryPointer.from_string(memory)) : nil + end + + # ============================================================================ + # ADDED: Event semantics + non-concurrent deferred-execution scheduler. + # + # zeCommandQueueExecuteCommandLists is asynchronous. Checking a list's memory + # copies against the memory model at execute time gives false positives, + # because a copy's destination may only be allocated by another unit that + # signals a wait-event later. So each submitted command list is turned into a + # ZEModel::DeferredUnit and its ops are replayed only as their wait-events + # actually become signaled. + # + # There are NO Ruby threads or fibers. Each unit keeps an integer cursor into + # its op list. pump_deferred repeatedly sweeps all units, advancing any unit + # whose current op has all wait-events satisfied, and loops until a full sweep + # makes no progress. A unit left parked on an unsatisfied op is simply waiting + # for a future event (which arrives as later trace events are consumed). + # ============================================================================ + + # ADDED: look up an Event model object by raw handle. nil for a null/unknown + # handle (nothing to track). + def event_by_handle(context, handle) + return nil if handle.nil? || handle == 0 + find_objects(context, 'event')[handle] + end + + # ADDED: signal an event and note progress so pump_deferred re-sweeps. `by` + # records who signaled it, for diagnostics. + def signal_event(context, handle, by = nil) + ev = event_by_handle(context, handle) + if ev + ev.signal(by) + @signal_epoch += 1 + end + ev + end + + # ADDED: return an event to the unsignaled state. + def reset_event(context, handle) + event_by_handle(context, handle)&.reset + end + + # ADDED: record that the host observed an event's signaled state. + def observe_event(context, handle) + event_by_handle(context, handle)&.observe + end + + # ADDED: a device-wide host synchronization (zeCommandQueueSynchronize, or for + # immediate lists zeCommandListHostSynchronize) means the host waited for all + # submitted work -- so every currently-signaled event has been consumed. Mark + # them observed so a later signal without a reset reads as reuse-without-reset + # rather than a concurrent double-signal. + def observe_all_signaled_events(context) + find_objects(context, 'event').each_value { |ev| ev.observe if ev.signaled? } + end + + # ADDED: true once every wait handle is signaled (or is null/unknown, which we + # treat as satisfied: we cannot track it, and any unit may signal an event, so + # we must not invent a deadlock). + def waits_satisfied?(context, waits) + return true if waits.nil? || waits.empty? + waits.all? { |h| ev = event_by_handle(context, h); ev.nil? || ev.signaled? } + end + + # ADDED: execute one op of a unit (the op is known to be runnable). Runs the + # deferred checks, then applies the op's reset/signal side effects, and + # advances the cursor. Returns true if it signaled an event (progress that may + # unblock other units). + def run_deferred_op(unit) + context = unit.context + op = unit.current_op + if op.kind == :copy + check_oob_copy(self, context, op.params) + #a pointer freed before this copy's turn to execute is a use-after-free + check_use_after_free(self, context, op.params) + end + #a memory-ranges barrier references memory freed before its turn is a UAF + check_ranges_barrier(self, context, op.params) if op.kind == :ranges_barrier + #a reset takes effect before this op signals its own completion event + reset_event(context, op.params[:reset_handle]) if op.kind == :reset + signaled = false + if op.signal + #the completion event must be unsignaled here: reuse without an intervening + #reset (or a concurrent double-signal) is a misuse + check_event_signal_reuse(self, context, op.signal, op.api || 'a command list append') + signal_event(context, op.signal, op.api) + unit.pending_signals.delete(op.signal) + signaled = true + end + unit.cursor += 1 + unit.blocked_on = [] + signaled + end + + # ADDED: advance every deferred unit as far as its wait-events allow. Sweeps + # repeatedly until a whole pass makes no progress (completed an op or signaled + # an event), then drops finished units. Units still parked on an unmet wait + # stay queued for a future event or the end-of-trace flush. + # + # Why a repeat-until-quiet loop rather than one pass: advancing unit A can + # signal an event that unblocks unit B, which may already have been visited + # earlier in the same sweep. Iterating until a full pass changes nothing + # reaches the fixed point regardless of the order units happen to sit in. + # Termination is guaranteed because every unit of progress advances some + # cursor, and cursors only move forward over finite op lists. + # + # Called after every trace event (see #consume), so deferred work advances in + # lockstep with the real program's event signals. + def pump_deferred + progress = true + while progress + progress = false + @deferred_units.each do |unit| + until unit.done? + op = unit.current_op + if waits_satisfied?(unit.context, op.waits) + run_deferred_op(unit) + progress = true + else + #park the unit on this op and record what it is blocked on so the + #deadlock detector can see the wait-for edges + unit.blocked_on = op.waits.reject { |h| + ev = event_by_handle(unit.context, h); ev.nil? || ev.signaled? + } + break + end + end + end + @deferred_units.reject!(&:done?) + end + end + + # ADDED: register a command list's ops as a deferred unit and pump. `ops` is a + # snapshot (dup) taken by the caller so a later reset+re-append on the same + # list cannot mutate an in-flight execution. + def run_deferred_list(context, ops, label, in_order: false, cmd_list_handle: nil) + @deferred_units << ZEModel::DeferredUnit.new(ops, context, label, in_order: in_order, + cmd_list_handle: cmd_list_handle) + pump_deferred + end + + # ADDED: deferred execution of the lists submitted to + # zeCommandQueueExecuteCommandLists. Each list becomes its OWN unit: lists in + # one submit are ordered only by events, not by list order, so a circular + # event dependency across two lists in one submit is a real deadlock we must be + # able to see. + def enqueue_deferred_execution(context, command_lists) + command_lists.each do |cl| + next unless cl + run_deferred_list(context, cl.ops.dup, "command_list (#{get_handle_str(cl.handle)})", + in_order: cl.in_order, cmd_list_handle: cl.handle) + end + end + + # ADDED: immediate command lists execute each op as it is appended, so we + # schedule the single op immediately. It still honors wait-events and goes + # through the same machinery, giving immediate lists the same OOB-copy and + # event-reuse checks as regular lists. + def enqueue_immediate_op(context, op, handle = nil) + label = handle ? "immediate command list (#{get_handle_str(handle)})" \ + : 'immediate command list' + run_deferred_list(context, [op], label) + end + + # ADDED: end-of-trace drain. First pump normally in case ordering left work + # runnable. Whatever is still parked cannot progress on its own -- report any + # circular event dependency (deadlock) among the stuck units, then force each + # remaining unit's blocked op (reporting the never-signaled wait) so the + # deferred checks still run against the final memory state. + def flush_deferred + pump_deferred + return if @deferred_units.empty? + check_circular_deadlock(self, @deferred_units) + #ADDED: an in-order list where an earlier op waits on an event only a later op + #in the SAME list signals is a self-deadlock the cross-list check cannot see + check_in_order_self_deadlock(self, @deferred_units) + until @deferred_units.empty? + unit = @deferred_units.first + #force the op the unit is stuck on: report its unsignaled waits, then run it + report_unsignaled_waits(self, unit.context, unit.current_op.waits) if unit.current_op + run_deferred_op(unit) unless unit.done? + @deferred_units.reject!(&:done?) + #a forced completion may unblock others cleanly + pump_deferred + end + end + + # END-OF-TRACE REPORTING PASS, called once by ze_validator.in after the whole + # trace has been consumed. Findings here are the ones that are only knowable + # when you know nothing further is coming: + # 1. deferred work that never completed -> deadlocks (via flush_deferred) + # 2. API calls with an _entry but no _exit -> the process crashed inside them + # 3. objects still present in the model -> leaks + # Everything else was already reported inline as the trace was replayed. + def check_issues() + #ADDED: drain deferred command-list executions (and detect deadlocks) before + #reporting leaks/crashes + flush_deferred + crash = false + @state.each { |hostname, node| + node.processes.each { |pid, process| + process.threads.each { |tid, thread| + # CHANGED: iterate the whole call stack instead of a single slot. Any + # frame still on the stack is a traced call that never returned (a + # crash); a clean run pops every frame back to empty. + thread.call_stack.each { |frame| + ctx = {'hostname' => hostname, 'vpid'=> pid, 'vtid' => tid, 'api' => frame.name} + print_crash_error(ctx, 'command did not finish execution') + crash = true + } + } + } + } + + #if !crash || true + unless crash && false + @state.each { |hostname, node| + node.processes.each { |pid, process| + ctx = {'hostname' => hostname, 'vpid'=> pid} + [ 'context', + 'event_pool', + 'command_queue', + 'fence', + 'command_list', + 'module', + 'module_build_log', + 'kernel', + ].each { |t| + #objects that were created will be deleted upon successful exits. + #So, only the ones that didn't get deleted will be reported + process.objects(t).each { |h, c| + print_leak_error(ctx, t, h) #it prints the type as well + } + } + # CHANGED: memory_allocation is now nested by context handle + # (ctx_handle -> {address -> Memory}), so iterate one level deeper. + # Any allocation still live at end of trace, in any context, is a leak. + process.objects('memory_allocation').each { |_ctx_handle, allocs| + allocs.each { |h, c| + #puts "mem alloc type = #{c.instance_variable_get(:@memtypestr)}" + print_leak_error(ctx, 'memory_allocation', h, c.instance_variable_get(:@memtypestr)) + } + } + } + } + end + end + + + # The Level Zero spec requires zeInit (or zeInitDrivers) before any other API + # call. Initialization is per-process, so the flag is keyed by pid. `m` is the + # regex match from #consume, so m[1] is the API name. + # Reported at most once per run to avoid one missing init producing an error + # for every subsequent call in the trace. + def check_initialization(context,m) + if ZEModel::INIT_API_NAMES.include?(m[1]) + @init_called[context['vpid']] = true + end + + if !@init_called[context['vpid']] && !@printed_init_error + self.print_usage_error(context, "zeInit or zeDriversInit wasn't called before #{m[1]}") + @printed_init_error = true + end + end + + + # Handle a call ENTRY, in order: + # 1. push the call (with its input args) onto the thread's stack, so + # find_param can reach those args later, including from the exit callback + # 2. take the thread-safety locks this API requires + # 3. run the API's $upon_entry callback, if any + # + # Checks live at entry either because they need the input arguments in their + # pre-call state, or -- more often -- because the call being validated might + # crash the process, in which case no _exit event is ever written and an + # exit-time check would silently never run. + def on_entry(m,hostname, context,defi) + set_last_entry(self, context, defi) #sets the per-thread callstack of the APIs + @lock_shared_object_on_entry[m[1]].each { |l| + l.call(self, context, defi) + } + #modifies the satate based on entry fields. Needed because some fields are easier to access it from the entry + l = $upon_entry[m[1]] + l.call(self,context,defi) if l + end + + # Handle a call EXIT, the mirror image of on_entry: + # 1. release the thread-safety locks (in reverse order -- lock ordering + # discipline, so nested acquisitions unwind as a stack) + # 2. run the success OR the error callback, depending on the return code + # 3. verify and pop the thread's call stack + # + # Most model MUTATION happens here rather than at entry, because a call that + # failed must not be allowed to change the model (a failed Create produced no + # object), and because output handles only exist once the call has returned. + def on_exit(m,hostname,context,defi) + #unlock the shared object if the api name matches the predefined in ze_thread_safety.yaml + @unlock_shared_object_on_exit[m[1]].reverse_each { |l| + l.call(self, context, defi) + } + + #check if the return code indicates successful return from the API call + if validate_result(defi) + l = $on_successful_exit[m[1]] #This might be a problem for tracking erroneous exits. + l.call(self, context, defi) if l + else + #puts "failed: #{m[1]}" + l = $on_erroneous_exit[m[1]] + l.call(self, context, defi) if l + end + + check_last_entry(context) #When we return from _exit, we need to see what we saw in _entry for the current thread_id + reset_last_entry(context) #Reset the callstack for current thread_id + end + + # THE MAIN LOOP. Returns the lambda babeltrace invokes with each batch of + # decoded messages; it is installed as the graph's sink in ze_validator.in. + # Everything the validator does happens somewhere inside this callback. + # + # Note the Ruby endless-method syntax (`def consume = lambda {...}`): calling + # `consume` returns the lambda rather than running it. + def consume = lambda { |iterator, _| + iterator.next_messages.each do |m| + #the stream also carries stream-begin/end and packet messages; only + #actual trace events are of interest here + next unless m.type == :BT_MESSAGE_TYPE_EVENT + e = m.event + #Event names look like "lttng_ust_ze:zeMemAllocDevice_entry". This + #splits out the API name (m[1]) and the phase (m[2]). Non-matching + #events -- other backends sharing the trace, tracer-internal events -- + #fall through the `if` and are ignored. + m = e.name.match(/:(z.*)_(entry|exit)/) + if m + #hostname is trace-level metadata (recorded once per trace), while + #vpid/vtid come from LTTng's per-event common context. Merging them + #with the API name produces the `context` tuple that identifies + #"who is calling what", threaded through the entire validator. + hostname = e.stream.trace.get_environment_entry_value_by_name('hostname').value + context = e.get_common_context_field.value + #the event's own fields: input args at _entry, results at _exit + defi = e.payload_field.value + context['hostname'] = hostname + context['api'] = m[1] + #zeDriversInit or zeInit must be the first one to be called before any api calls + check_initialization(context,m) + #print the known deprecated APIs + print_deprecation_warning(m[1]) if @deprecated[m[1]] + + if m[2] == 'entry' + on_entry(m, hostname, context, defi) + elsif m[2] == 'exit' + #puts "#{m[1]}" + on_exit(m, hostname, context, defi) + end + #ADDED: this event may have signaled something a deferred command list + #was waiting on, so advance the deferred worklist now + pump_deferred + end + end + } + +end #end of StateObject diff --git a/backends/ze/ze_validator_zemodel.rb b/backends/ze/ze_validator_zemodel.rb new file mode 100644 index 00000000..9fc0c859 --- /dev/null +++ b/backends/ze/ze_validator_zemodel.rb @@ -0,0 +1,792 @@ +require 'set' + +# ============================================================================= +# ZEModel -- the data model the validator replays a trace into. +# +# Every class here is a plain Ruby mirror of a Level Zero concept. As the +# validator walks the trace, a `zeXxxCreate` event constructs one of these and +# files it in a lookup table; a `zeXxxDestroy` removes it. Anything still +# present when the trace ends is, by definition, leaked. +# +# LEVEL ZERO IN ONE PARAGRAPH (for readers new to the API) +# -------------------------------------------------------- +# Level Zero is Intel's low-level GPU compute API (the layer SYCL/OpenMP sit on +# top of). The object hierarchy is roughly: +# +# Driver one per installed GPU runtime +# └─ Device a physical GPU (may expose SubDevices, e.g. tiles) +# Context an isolation domain: memory and objects belong to exactly +# one context and may not be mixed across contexts +# ├─ Memory allocations (device / host / shared) +# ├─ Module a compiled GPU binary +# │ └─ Kernel one entry point within a module +# ├─ CommandList a recorded sequence of GPU operations ("append" to build +# │ it, "close" to finalize it) +# ├─ CommandQueue where a closed command list is submitted to actually run +# │ └─ Fence host-visible "this submission finished" signal +# └─ EventPool preallocated slots for Events +# └─ Event fine-grained GPU/host synchronization token +# +# Work is ASYNCHRONOUS: submitting a command list returns immediately, and the +# operations inside it run later, ordered by Events. That asynchrony is the +# reason for RecordedOp and DeferredUnit near the bottom of this file -- the +# validator cannot check a memory copy at the moment it is appended, because +# the copy has not happened yet. +# +# HANDLES +# ------- +# Level Zero identifies every object by an opaque pointer-sized "handle" +# (ze_command_list_handle_t and friends). In the trace these arrive as plain +# integers, and the validator uses them as hash keys throughout. When you see a +# bare `handle` in this codebase, it is that integer. +# +# WHERE THESE OBJECTS LIVE +# ------------------------ +# Node (one per hostname) +# └─ Process (one per pid) <- the main container; see Process below +# ├─ threads (per tid, each with a call stack) +# └─ one hash per object type: @devices, @contexts, @command_lists, ... +# StateObject#find_objects(ctx, 'command_list') is how the rest of the code +# reaches those hashes. +# +# A NOTE ON THE "ADDED:" / "CHANGED:" COMMENTS +# -------------------------------------------- +# Comments marked ADDED/CHANGED/REMOVED record deliberate deviations from an +# earlier version of the model and explain WHY the change was necessary. They +# are kept because the reasoning (e.g. why memory is keyed by context) is not +# recoverable from the code alone. +# ============================================================================= +module ZEModel + #One of these APIs must be called before any other calls + # The Level Zero spec requires the runtime to be initialized before any other + # entry point is used. Calling anything else first is undefined behavior, so + # StateObject#check_initialization watches for one of these appearing first. + INIT_API_NAMES = ['zeInit', 'zeInitDrivers'] + #This defines the object in which most ze objects (command list, command queue) extend form + # Common base class for every tracked Level Zero object. It provides two + # things every object needs: + # * @handle -- the integer identity from the trace + # * a lock -- NOT a real mutex. It is a marker used to detect data races: + # see #lock below. + class Object + attr_reader :handle + attr_accessor :status + + # returns what object the caller is + # e.g., 'Device' will return device + # Each subclass sets `@typename` at class level (a class instance variable); + # this reader exposes it for error messages, e.g. "concurrent access to + # command_list 0x...". + def self.typename + @typename + end + + # lock is needed to check for concurrent properties. + # e.g., calling the same APIs that can be called from simultaneous threads (zeCommandListAppendBarrier) + def initialize(handle) + @handle = handle + @lock = nil + #@status = -1 + end + + # THREAD-SAFETY CHECK. + # The Level Zero spec documents, per API, which objects may NOT be touched + # concurrently from two threads. ze_thread_safety.yaml encodes that table, + # and StateObject turns each entry into a pair of callbacks that lock the + # object on _entry and unlock it on _exit. + # + # Because the validator processes events one at a time (never truly in + # parallel), @lock is not a real mutex -- it is simply "which API call + # currently has this object open". Since the trace is timestamp-ordered, + # finding the object ALREADY locked when a second call tries to lock it + # means two calls overlapped in wall-clock time on the real machine: a + # genuine data race. `ctx` identifies the locking call (thread id + API). + def lock(state,ctx) + if @lock + state.print_race_condition(ctx, @lock, self.class.typename, @handle) + else + @lock = ctx + end + end + + # Release only if this same call is the holder. The guard matters for + # nested/overlapping calls: a thread that failed to acquire the lock above + # (and was reported as a race) must not then steal the real holder's lock + # by unlocking on its own way out. + def unlock(ctx) + if @lock == ctx + @lock = nil + end + end + end + + # 'A < B' means A inherits from B + # The driver is an interface that serves between the host and the devices + # Created by zeDriverGet, which reports every Level Zero driver installed. + # Holds the Devices discovered under it via zeDeviceGet. + class Driver < Object + @typename = 'driver' + attr_reader :devices + + def initialize(handle) + super + @devices = [] + end + end + + + # Device is mostly GPU + # A physical accelerator, created by zeDeviceGet. Besides identity, the model + # tracks two "did the application ask about me before assuming things?" flags + # used by the portability checks -- hardcoding device characteristics instead + # of querying them is the classic way GPU code breaks on the next generation + # of hardware. + class Device < Object + @typename = 'device' + attr_reader :properties #delete + attr_reader :sub_devices + # CHANGED: nested by Level Zero context handle -- ctx_handle -> {addr -> Memory} + # -- for the same reason as Process#memory_allocations: an address is only + # guaranteed unique within a context, and one device can back allocations in + # several contexts. Auto-vivifies an empty sub-map per context. + attr_accessor :memory_allocations + # set by zeDeviceGetProperties -- the app asked what this device is + attr_accessor :property_fetched + # set by zeDeviceGetCommandQueueGroupProperties -- the app asked which + # engines (compute / copy) this device has and how many queues each + # supports. If it never asks but still passes an ordinal, it is guessing: + # see check_group_property_queued. + attr_accessor :cmd_queue_group_properties_queried + #attr_accessor :p2p_list + + def initialize(handle) + super + @sub_devices = [] + @memory_allocations = Hash.new { |h, k| h[k] = {} } + @property_fetched = false + #This + @cmd_queue_group_properties_queried = false + end + end + + + + # A tile/slice of a Device exposed by zeDeviceGetSubDevices. It behaves like a + # Device in every respect (hence the inheritance) but remembers its parent. + # Note the unusual ordering below: @parent is assigned BEFORE calling super, + # because Device#initialize is what actually sets up the shared state. + class SubDevice < Device + attr_reader :parent + def initialize(handle, parent) + @parent = parent + super(handle) + end + end + + #create memory object so that device, shared, host mem allocs can be differentiated + # One GPU-visible allocation. Level Zero has three flavors, distinguished by + # @memtypestr because they have different accessibility and residency rules: + # "device" - lives in GPU memory; only that device can touch it + # "host" - lives in host memory; every device in the context can touch it + # "shared" - migrates between host and device on demand + # + # Unlike the handle-based objects, memory is identified by its ADDRESS, and + # the validator must reason about ranges rather than exact matches: a copy + # into `base + 64` is a legitimate use of the allocation starting at `base`. + # Hence @base and @size are the fields most checks actually consult. + class Memory < Object + @typename = 'memory_allocation' + attr_reader :context # the Context this allocation belongs to + attr_reader :size # length in bytes + attr_reader :owned_by # the Device for a device allocation; nil for host + # Whether the allocation is currently resident in device memory. Toggled by + # zeContextMakeMemoryResident / zeContextEvictMemory. Device allocations + # start non-resident; host/shared memory is always reachable. + attr_accessor :resident + attr_accessor :memtypestr # "device" | "host" | "shared" + # Start address. Equal to the handle -- for memory the "handle" IS the + # pointer -- but kept under a descriptive name because range arithmetic + # (base <= ptr < base + size) reads far better than handle arithmetic. + attr_accessor :base + # ADDED: api-context string of the zeMemFree that released this allocation, + # or nil while live. A freed allocation is moved to the process-level + # @freed_memory_allocations registry (kept, not discarded) so a later copy/ + # fill/kernel that still references its address range can be reported as a + # use-after-free instead of silently passing (unknown pointer). + attr_accessor :freed_by + def initialize(handle, context, size, owned_by, memtypestr="shared") + super(handle) + @context = context + @size = size + @owned_by = owned_by + @memtypestr = memtypestr + @base = handle + @freed_by = nil # ADDED + #puts "size = #{size}, handle = #{handle}, handle+size=#{handle + size}" + #for device memory. + # Device allocations must be explicitly made resident before the GPU can + # use them; host and shared memory is always accessible, so it starts + # resident and stays that way. + if memtypestr == "device" + @resident = false + else + @resident = true + end + end + end + + + # No need to create DeviceMemory class. Just create Memory with the specified type (device,shared,host) + # class DeviceMemory < Object + # @typename = 'memory_allocation_device' + # attr_reader :context + # attr_reader :size + # attr_reader :device + + # def initialize(handle, context, size, device) + # super(handle,context,size,device) + # @context = context + # @size = size + # @device = device + # end + # end + + + + + + # THE CENTRAL ISOLATION BOUNDARY. + # Nearly every "you cannot mix these" rule in Level Zero is really "these two + # objects were created on different contexts". A command list, the events it + # uses, the queue it is submitted to, and the memory it copies must all belong + # to one context. A large share of the validator's checks are variations on + # comparing two Context objects, and the per-context nesting of the memory + # maps (see Process below) exists for the same reason. + # + # The child collections below mirror what the context owns, so destroying a + # context can report anything still alive inside it. + class Context < Object + @typename = 'context' + attr_reader :driver + attr_reader :desc # the ze_context_desc_t passed to zeContextCreate + attr_reader :devices # nil means "all devices of the driver" + + attr_reader :event_pools + attr_reader :command_queues + attr_reader :command_lists + attr_reader :modules + attr_reader :module_build_logs + + def initialize(handle, driver, desc, devices = nil) + super(handle) + @driver = driver + @desc = desc + @devices = devices + + @event_pools = {} + @command_queues = {} + @command_lists = {} + @modules = {} #binaries for gpu + @module_build_logs = {} + end + end + + # A fixed-size block of event slots, allocated up front by zeEventPoolCreate. + # Events are not individually allocated: the pool reserves `desc[:count]` + # slots, and each zeEventCreate claims one by index. Two live events may not + # share an index, so the model tracks which indices are still free. + # + # An event pool is also how an Event acquires a context (an Event has no + # context of its own), which several checks rely on. + class EventPool < Object + @typename = 'event_pool' + attr_reader :context + attr_reader :desc + attr_reader :devices + attr_reader :events # handle -> Event, for leak reporting on destroy + # The set of slot indices NOT yet in use. Starts as {0, 1, ..., count-1}; + # zeEventCreate removes one (double use = error) and zeEventDestroy puts it + # back (double free = error). + attr_reader :indices + + def initialize(handle, context, desc, devices = nil) + super(handle) + @context = context + @desc = desc + @devices = devices + @events = {} + @indices = Set.new(desc[:count].times.to_a) + end + end + + # A synchronization token. An Event is a one-bit flag the GPU or host can + # SIGNAL, that other work can WAIT on, and that must be RESET before it is + # reused. Getting that lifecycle wrong is a major source of GPU hangs, so the + # model tracks more than just the bit -- see the accessors below. + class Event < Object + @typename = 'event' + attr_reader :event_pool + attr_reader :desc + attr_accessor :signaled + # ADDED: richer event state so we can model Level Zero event semantics. + # signaled_by - api-context string of whoever last signaled this event + # (used only for diagnostic messages). + # observed - whether the host has observed the signaled state since the + # last signal (via zeEventHostSynchronize / a successful + # zeEventQueryStatus / a device-wide synchronize). This lets + # us tell a genuine concurrent double-signal (signaled but + # never consumed) from a reuse-without-reset (signaled, + # consumed by the host, then signaled again with no reset). + attr_reader :signaled_by + attr_reader :observed + + def initialize(handle, event_pool, desc) + super(handle) + @event_pool = event_pool + @desc = desc + #event can have 2 states, not signaled or signaled + @signaled = false + @signaled_by = nil + @observed = false + end + + # ADDED: move the event to the signaled state. `by` records who signaled it + # (for messages). A fresh signal has not yet been observed by the host. + def signal(by = nil) + @signaled = true + @signaled_by = by + @observed = false + end + + # ADDED: zeEventHostReset / zeCommandListAppendEventReset return the event to + # the unsignaled state so it can be reused as a dependency again. + def reset + @signaled = false + @signaled_by = nil + @observed = false + end + + # ADDED: record that the host observed the signaled state. Distinguishes a + # later reuse-without-reset from a concurrent double-signal. + def observe + @observed = true + end + + def signaled? + @signaled + end + end + + # Where closed command lists are submitted for execution. A queue is bound at + # creation to one engine of one device, identified by desc[:ordinal] (which + # command queue GROUP -- compute, copy, or both) and desc[:index] (which + # queue within that group). Submitting compute work to a copy-only ordinal, or + # using an index beyond the group's numQueues, is checked against the real + # device topology in ze_device_property.json. + class CommandQueue < Object + @typename = 'command_queue' + attr_reader :context + attr_reader :device + attr_reader :desc # ze_command_queue_desc_t: carries :ordinal and :index + attr_reader :fences + + def initialize(handle, context, device, desc) + super(handle) + @context = context + @device = device + @desc = desc + @fences = {} + @valid_fences = Hash.new { |h, k| h[k] = true } #fences that have been reset or haven't been signaled + end + end + + # A coarse, host-visible completion signal for ONE submission to a queue. + # Where an Event synchronizes individual operations, a Fence answers "has this + # whole zeCommandQueueExecuteCommandLists finished?". + # + # A fence must be reset before it can be reused. The three-state lifecycle: + # not_signaled -> in_use (submitted with this fence) + # -> signaled (zeFenceHostSynchronize observed completion) + # -> not_signaled again (zeFenceReset) + # Reusing a fence that is still in_use or already signaled is the misuse + # check_fence_misuse reports. + # + # NOTE: not_signaled/in_use/signaled are per-instance constants exposed as + # readers, so comparisons read `fence.status == fence.signaled`. + class Fence < Object + @typename = 'fence' + attr_reader :command_queue # the queue this fence was created for + attr_reader :desc + attr_accessor :status # one of the three values below + attr_reader :not_signaled + attr_reader :in_use + attr_reader :signaled + + + def initialize(handle, command_queue, desc) + super(handle) + @command_queue = command_queue + @desc = desc + @not_signaled = 0 + @in_use = 1 + @signaled = 2 + @status = @not_signaled + end + + end + + # A recorded sequence of GPU operations -- the core object of the API. + # + # NORMAL LIFECYCLE: + # zeCommandListCreate -> zeCommandListAppendXxx (many) -> + # zeCommandListClose -> zeCommandQueueExecuteCommandLists + # Appending only RECORDS an operation; nothing runs until the closed list is + # submitted to a queue. Executing a list that was never closed is an error + # (check_command_list_closed). + # + # IMMEDIATE LISTS are the exception: created by zeCommandListCreateImmediate, + # they carry their own implicit queue and execute each operation the moment it + # is appended. They are never closed and must never be passed to + # ExecuteCommandLists. Because they take a QUEUE descriptor rather than a list + # descriptor, the model stores that in @altdesc and leaves @desc nil -- which + # is why so much code here branches on `desc ? ... : altdesc`. + class CommandList < Object + @typename = 'command_list' + attr_reader :context + attr_reader :device + attr_reader :desc # ze_command_list_desc_t (nil for immediate lists) + attr_reader :altdesc # ze_command_queue_desc_t (immediate lists only) + attr_accessor :associated_command_queue + attr_accessor :immediate # true for zeCommandListCreateImmediate lists + attr_accessor :associated_ordinal + # ADDED: true when the list was created with ZE_COMMAND_LIST_FLAG_IN_ORDER + # (or, for immediate lists, ZE_COMMAND_QUEUE_FLAG_IN_ORDER). In-order lists + # execute their appended ops strictly in append order -- op N+1 will not + # start until op N completes -- so an earlier op that waits on an event only + # a later op in the SAME list signals can never complete (an intra-list + # deadlock the cross-list detector cannot see). See check_in_order_self_deadlock. + attr_accessor :in_order + # ADDED: ordered list of RecordedOp appended to this command list. It is + # replayed when the list is executed on a queue, so that checks depending on + # event completion (out-of-bounds copy, event-signal reuse) run at the point + # the op would actually execute -- not at append or execute time. + attr_accessor :ops + # Lifecycle states, compared via + # ZEModel::CommandList.class_variable_get(:@@INITIALIZED) at the call sites. + # INITIALIZED - open for appending (fresh, or just reset) + # CLOSED - finalized by zeCommandListClose, ready to submit + # DESTROYED - zeCommandListDestroy was called; any further use is a bug + @@INITIALIZED = 0 #created or being properly recycled + @@CLOSED = 1 + @@DESTROYED = 2 + + def initialize(handle, context, device, desc, altdesc) + super(handle) + @context = context + @device = device + @desc = desc + @altdesc = altdesc + @associated_command_queue = nil + @status = @@INITIALIZED + @immediate = false + @associated_ordinal = 0 + @in_order = false # ADDED + @api_calls = [] + @ops = [] # ADDED + end + + # An immediate list has no list descriptor (it was given a queue descriptor + # instead), so a nil desc identifies it. NOTE: the @immediate flag set by + # the zeCommandListCreateImmediate callback is what most call sites actually + # test; this predicate derives the same fact from the descriptor. + def immediate? + return !desc + end + end + + # =========================================================================== + # DEFERRED EXECUTION -- why RecordedOp and DeferredUnit exist. + # + # THE PROBLEM. Consider this perfectly valid program: + # + # append a copy into buffer X onto list A, gated on event E + # submit list A <- returns immediately, copy has NOT run + # ... later ... + # allocate buffer X + # signal event E <- only NOW does the copy actually run + # + # If the validator checked the copy's bounds when it was APPENDED, or even + # when the list was SUBMITTED, buffer X would not exist yet and it would + # report a bogus error. The check is only meaningful at the moment the copy + # really executes -- which the trace tells us only indirectly, via events. + # + # THE SOLUTION. Appending records a RecordedOp instead of checking. Submitting + # turns the list's recorded ops into a DeferredUnit: a queued, half-executed + # program with a cursor. As the trace goes on and events get signaled, the + # scheduler in StateObject (pump_deferred) walks each unit's cursor forward + # over every op whose wait-events are now satisfied, running the deferred + # checks at that point -- the correct point on the program timeline. + # + # NO THREADS, NO FIBERS. Execution state is just an integer index. A unit that + # cannot advance is simply left parked, and retried after the next signal. + # Anything still parked when the trace ends could never have completed, which + # is exactly the definition of a deadlock -- so the same data structure that + # defers the memory checks also powers the deadlock detector. + # =========================================================================== + + # ADDED: A single operation recorded when it is appended to a command list. It + # snapshots everything the deferred checks need, because the trace's per-call + # context (find_param) is gone by the time the op is replayed at execute time. + # kind - :copy, :wait, :signal, :reset, :barrier, :ranges_barrier, :launch + # signal - handle of the completion event this op signals (nil/0 if none) + # waits - event handles that must be signaled before this op can execute + # params - kind-specific data (copy: api/dst/src/size; reset: reset_handle; + # ranges_barrier: api/ctx_handle/ranges, where ranges is an array of + # {base:, size:} for each memory range the barrier covers) + # api - the ZE API that appended this op (for diagnostics) + class RecordedOp + attr_reader :kind # :copy | :wait | :signal | :reset | :barrier | + # :ranges_barrier | :launch + attr_reader :signal # event this op signals on completion (nil if none) + attr_reader :waits # events that must be signaled before this op may run + attr_reader :params # kind-specific snapshot (see class comment above) + attr_reader :api # originating ze API name, for diagnostics + + def initialize(kind, signal: 0, waits: [], params: {}, api: nil) + @kind = kind + #normalize a null (0) signal handle to nil so "does this op signal?" is a + #simple truthiness test + @signal = (signal && signal != 0) ? signal : nil + @waits = waits || [] + @params = params + @api = api || params[:api] + end + end + + # ADDED: One deferred-execution unit -- a single submitted command list whose + # recorded ops are replayed cooperatively by the (non-concurrent) scheduler in + # StateObject. Instead of a Ruby Fiber, execution state is an explicit integer + # cursor into `ops`: the scheduler advances the cursor past every op whose + # waits are satisfied, and leaves it parked on the first op that is still + # blocked. `blocked_on` / `pending_signals` are the metadata the deadlock + # detector uses to build a wait-for graph across units. + # ops - snapshot (dup) of the list's ops for this execution + # context - trace context captured at submit time + # label - human label for messages (e.g. "command_list 0x..") + # cursor - index of the next op to execute + # blocked_on - event handles the current op is waiting for (or []) + # pending_signals - events this unit may still signal before it finishes + class DeferredUnit + attr_reader :ops # the ops to replay, in order + attr_reader :context # trace context (host/pid/tid/api) captured at submit + attr_reader :label # e.g. "command_list (0x00007f...)", used in messages + attr_accessor :cursor # index of the next op to run; == ops.size means done + attr_accessor :blocked_on # events the current op is still waiting for + # Events this unit has not signaled yet. Read by the deadlock detector: if + # unit U is blocked on an event that appears only in unit V's + # pending_signals, then U is waiting on V -- an edge in the wait-for graph. + attr_accessor :pending_signals + # ADDED: whether the originating command list is in-order (see CommandList#in_order). + # The intra-list self-deadlock check only applies to in-order units. + attr_reader :in_order + # ADDED: handle of the command list this unit was submitted from (nil if + # unknown/immediate without a handle). Lets checks scoped to a specific list -- + # e.g. resetting a list while a prior submission is still in-flight -- find the + # deferred units that belong to it without matching on the label string. + attr_reader :cmd_list_handle + + def initialize(ops, context, label, in_order: false, cmd_list_handle: nil) + @ops = ops + @context = context + @label = label + @cursor = 0 + @blocked_on = [] + @in_order = in_order # ADDED + @cmd_list_handle = cmd_list_handle # ADDED + #every event this unit will eventually signal, for the wait-for graph + @pending_signals = ops.map { |op| op.signal }.compact + end + + # true once every op has executed + def done? + @cursor >= @ops.size + end + + # the op the cursor currently points at (nil when done) + def current_op + @ops[@cursor] + end + end + + # A compiled GPU binary loaded into a context (zeModuleCreate takes SPIR-V or + # native code). Kernels are the individual entry points inside it. + # + # A Kernel has no context of its own, so "which context does this kernel + # belong to?" is answered by kernel.module.context -- the indirection + # check_kernel_list_context_match relies on. + class Module < Object + @typename = 'module' + + # The compiler diagnostics object optionally produced alongside a module. + # It is a separately destroyable handle, so failing to destroy it is its own + # leak; @module may be nil when the build FAILED (no module was produced, + # but the log describing why still exists). + class BuildLog < Object + @typename = 'module_build_log' + attr_reader :module + + def initialize(handle, mod = nil) + super(handle) + @module = mod + end + end + + attr_reader :context + attr_reader :device + attr_reader :desc + attr_accessor :build_log + attr_reader :kernels + + def initialize(handle, context, device, desc) + super(handle) + @context = context + @device = device + @desc = desc + @kernels = {} + end + end + + # One entry point within a Module, created by zeKernelCreate. @name is the + # source-level function name, kept so diagnostics can say which kernel + # misbehaved instead of only printing a handle. + class Kernel < Object + @typename = 'kernel' + attr_reader :module # owning Module -- also how the kernel's context is found + attr_reader :desc + attr_reader :name # human-readable kernel name from the descriptor + + def initialize(handle, mod, desc, name) + super(handle) + @module = mod + @desc = desc + @name = name + end + end + + # One in-flight API call: pushed on a thread's stack at _entry and popped at + # _exit. @params holds the entry payload -- the call's INPUT arguments -- which + # is why exit callbacks reach back through find_param to read inputs that the + # exit event itself does not carry. + class ApiCall + attr_reader :name # e.g. "zeCommandListAppendMemoryCopy" + attr_reader :params # the decoded _entry payload + + def initialize(name, params) + @name = name + @params = params + end + end + + # One OS thread of the traced process, identified by its LTTng vtid. + class Thread + attr_reader :vtid + # CHANGED: was a single `last_entry` slot, which broke when a traced API + # internally calls another traced API on the same thread (e.g. + # zelLoaderDriverCheck calls zeInit): the inner entry clobbered the outer + # frame and the outer _exit then failed check_last_entry. Modeling it as a + # stack lets nested calls push/pop correctly; the top of stack is the + # currently-executing call. + attr_reader :call_stack + + def initialize(vtid) + @vtid = vtid + @call_stack = [] + end + + # the innermost in-flight ApiCall, or nil if the thread has none. + # Kept as `last_entry` so existing callers (find_param, etc.) are unchanged. + def last_entry + @call_stack.last + end + end + + # One traced process. This is the main container: every Level Zero handle is + # only meaningful within the process that created it, so all the object tables + # live here rather than globally. + # + # The tables are reached generically -- #objects('command_list') and + # StateObject#find_objects both resolve the name to the matching @...s ivar -- + # which is what lets the leak reporter loop over object types by name. + class Process + attr_reader :vpid # LTTng virtual pid + attr_reader :threads # tid -> Thread (auto-created on first sight) + # handle -> object, one table per Level Zero object type + attr_reader :drivers + attr_reader :devices + attr_reader :contexts + attr_reader :event_pools + attr_reader :events + attr_reader :command_queues + attr_reader :fences + attr_reader :command_lists + attr_reader :modules + attr_reader :module_build_logs + # ADDED: address -> freed Memory objects (kept after zeMemFree) so a later + # reference to a released address can be flagged as use-after-free. + attr_reader :freed_memory_allocations + # ADDED: both memory maps are nested by Level Zero context handle -- + # { ctx_handle => { address => Memory } } -- because the L0 unified virtual + # address space only guarantees non-aliasing addresses WITHIN a context. + # Two live allocations in different contexts may share a numeric address, so + # a flat address-keyed map would let the second overwrite the first. Each + # inner sub-map has the same shape as the old flat map, so code that already + # holds a sub-map (allocations[ptr], .each_value, .delete) is unchanged. + attr_reader :memory_allocations + + def initialize(vpid) + @vpid = vpid + @threads = Hash.new { |h, k| h[k] = Thread.new(k) } + #can it model memory imports/exports?? + @drivers = {} + @event_dependencies = {} #for detecting deadlocks + @devices = {} + @contexts = {} + @event_pools = {} + @events = {} + @command_queues = {} + @fences = {} + @command_lists = {} + @modules = {} + @module_build_logs = {} + @kernels = {} + # CHANGED: nested by context handle -- ctx_handle -> { address -> Memory }. + # Auto-vivify an empty sub-map on first use of a context so callers never + # get nil for a context that has not allocated yet. + @memory_allocations = Hash.new { |h, k| h[k] = {} } + @freed_memory_allocations = Hash.new { |h, k| h[k] = {} } # ADDED: ctx -> {addr -> freed Memory} + #@initCalled = false + end + + # Generic accessor: objects('command_list') returns @command_lists. The + # singular type names come from the model's typename strings, so a caller + # can iterate ['context', 'fence', 'command_list', ...] and reach each table + # without a case statement -- see StateObject#check_issues. + def objects(type) + instance_variable_get(:"@#{type}s") + end + end + + # One machine in the trace, keyed by hostname. A multi-node MPI run produces + # one trace per node; they are merged into a single timestamp-ordered stream, + # so the top of the model is keyed by hostname to keep them apart. + class Node + attr_reader :name # hostname + attr_reader :processes # pid -> Process (auto-created on first sight) + + def initialize(name) + @name = name + @processes = Hash.new { |h, k| h[k] = Process.new(k) } + end + end + +end \ No newline at end of file diff --git a/configure.ac b/configure.ac index 27579632..f39fe943 100644 --- a/configure.ac +++ b/configure.ac @@ -181,6 +181,7 @@ AC_CONFIG_FILES([utils/test_wrapper_thapi_text_pretty.sh], [chmod +x utils/test_ AC_CONFIG_FILES([backends/opencl/tracer_opencl.sh], [chmod +x backends/opencl/tracer_opencl.sh]) AC_CONFIG_FILES([backends/opencl/extract_enqueues], [chmod +x backends/opencl/extract_enqueues]) AC_CONFIG_FILES([backends/ze/tracer_ze.sh], [chmod +x backends/ze/tracer_ze.sh]) +AC_CONFIG_FILES([backends/ze/ze_validator], [chmod +x backends/ze/ze_validator]) AC_CONFIG_FILES([backends/cuda/tracer_cuda.sh], [chmod +x backends/cuda/tracer_cuda.sh]) AC_CONFIG_FILES([backends/omp/tracer_omp.sh], [chmod +x backends/omp/tracer_omp.sh]) AC_CONFIG_FILES([backends/hip/tracer_hip.sh], [chmod +x backends/hip/tracer_hip.sh]) From 7a42cd08c8fdf2996e6bac316376b8b1d2205791 Mon Sep 17 00:00:00 2001 From: Junhyung Shim Date: Wed, 12 Aug 2026 21:38:48 +0000 Subject: [PATCH 2/4] WIP: cleaning up comments --- .../ze/ze_validator_entry_exit_helpers.rb | 377 ++++-------------- ...validator_function_entry_exit_callbacks.rb | 50 +-- backends/ze/ze_validator_state_object.rb | 2 +- 3 files changed, 74 insertions(+), 355 deletions(-) diff --git a/backends/ze/ze_validator_entry_exit_helpers.rb b/backends/ze/ze_validator_entry_exit_helpers.rb index 0b060405..fb812087 100644 --- a/backends/ze/ze_validator_entry_exit_helpers.rb +++ b/backends/ze/ze_validator_entry_exit_helpers.rb @@ -1,55 +1,17 @@ require 'ze_validator_zemodel' require 'ze_library' -# ============================================================================= -# The check library: the actual rules the validator enforces. -# -# Every function here is called from a callback in -# ze_validator_function_entry_exit_callbacks.rb. They are plain top-level -# functions rather than methods, so they all take the engine as an explicit -# first argument. -# -# THE SHARED ARGUMENT CONVENTION -# ------------------------------ -# state - the StateObject. Used for three things: looking objects up -# (find_objects / find_param), reporting (print_usage_error etc.), -# and deduplicating (print_tracker). -# ctx - the TRACE context {hostname, vpid, vtid, api}: who is calling. -# NOT a Level Zero context. Where a Level Zero context is meant, the -# parameter is named ctx_handle or resolves to a ZEModel::Context. -# defi - the decoded event payload. Input arguments at _entry, results at -# _exit. An exit-time check that needs an input calls -# state.find_param instead. -# -# TWO RULES THAT EXPLAIN MOST OF THE CODE BELOW -# ----------------------------------------------- -# -# 1. REPORT ONCE. GPU code loops; the same defect recurs constantly. Checks -# build a key naming the specific violation and consult -# state.print_tracker[key] before printing. The pattern is always -# `return unless state.print_tracker[key] == 0` followed by setting it to 1. -# -# 2. CHECK AT THE RIGHT MOMENT. Three timings appear throughout: -# at _entry - when the call itself might crash the process (no _exit -# would then be traced) or when pre-call state is needed -# at _exit - when the check needs the call to have succeeded -# deferred - when the operation is asynchronous and has not actually -# happened yet at append or submit time; the scheduler runs -# these later, when the op's wait-events are satisfied -# ============================================================================= - -# PORTABILITY / CRASH CHECK: a command queue is created with an (ordinal, index) -# pair -- which engine group, and which queue within that group. Each group -# exposes only numQueues queues; asking for an index at or beyond that limit -# segfaults rather than returning an error, so it is worth catching. The real -# limits come from ze_device_property.json, so the check is skipped entirely -# when that file was unavailable. + + +# A command queue is created with an (ordinal, index) +# pair -- which engine group, and which queue within that group. Asking oob index segfaults def check_valid_index_for_ordinal(state,ctx,queue_handle,ordinal,index) #puts "entered" if state.device_properties command_queue_prop = state.device_properties["devices"][0]["command_queue_groups"] command_queue_prop.each do |prop| - if prop["ordinal"] == ordinal && (index >= prop["numQueues"] || index < 0)#oob index results in segfault + #find matching ordinal, and check whether the index is oob + if prop["ordinal"] == ordinal && (index >= prop["numQueues"] || index < 0) state.print_usage_error(ctx, "command queue (#{state.get_handle_str(queue_handle)}) with ordinal = #{ordinal} was created " + "with index = #{index}. Index value should be: 0<= index < #{prop["numQueues"]}") end @@ -57,14 +19,9 @@ def check_valid_index_for_ordinal(state,ctx,queue_handle,ordinal,index) end end -# PORTABILITY CHECK: did the application ever ask the device what engines it -# has (zeDeviceGetCommandQueueGroupProperties) before passing an ordinal? -# If not, the ordinal was hardcoded. That may work on today's GPU and silently -# select the wrong engine -- or none at all -- on the next one. Reported once -# per run, since it is a property of how the program was written, not of any -# individual call. +# Checking whether the application ever called zeDeviceGetCommandQueueGroupProperties +# before calling command queue/list create. Not calling it implies hardcoded ordinals def check_group_property_queued(state, ctx, defi, device) - #puts "device = #{device}" if !(device.cmd_queue_group_properties_queried) && state.print_tracker["check_group_property"] == 0 state.print_tracker["check_group_property"] = 1 state.print_usage_error(ctx,"command queue group wasn't queried. Hardcoded group properties may break the code on different devices") @@ -72,12 +29,7 @@ def check_group_property_queued(state, ctx, defi, device) end -# ADDED: the set of command-queue-group ordinals that belong to copy-only -# engines, as gathered by the device profiler (ze_device_property.json). When the -# device-property file is absent, fall back to the hardcoded ordinals that are -# copy-only on the Intel Data Center Max GPUs we target. Extracted so both the -# append-time check (check_valid_ordinal) and the execute-time check -# (check_copy_only_queue_submission) share one definition. +# returns the copy ordinals if retrieved from the ze_device_property.json def copy_only_ordinals(state) return [1, 2] unless state.device_properties state.device_properties["devices"][0]["command_queue_groups"] @@ -85,26 +37,14 @@ def copy_only_ordinals(state) .map { |prop| prop["ordinal"] } end -# CRASH CHECK (append-time): launching a compute kernel onto a command list -# whose ordinal belongs to a copy-only engine. A copy engine cannot run compute -# kernels; on the Intel Data Center Max GPUs this targets, the result is a -# segfault with no diagnostic from the runtime. -# -# This is the append-time half of the check -- it inspects the ordinal the -# command list itself was created with. The other half runs at submit time, -# when the queue's ordinal is finally known: see -# check_copy_only_queue_submission. +# checks whether a command list attached to a copy-only engine receives a kernel def check_valid_ordinal(state, ctx, defi, cqg_ordinal) copy_only_ords = copy_only_ordinals(state) - - if copy_only_ords.include?(cqg_ordinal) && state.print_tracker["zeCommandListAppendLaunchKernel::K2CopyOrdinal"] == 0 - state.print_tracker["zeCommandListAppendLaunchKernel::K2CopyOrdinal"] = 1 + if copy_only_ords.include?(cqg_ordinal) && state.print_tracker["check_valid_ordinal"] == 0 + state.print_tracker["check_valid_ordinal"] = 1 kernels = state.find_objects(ctx, 'kernel') kernel_handle = state.find_param(ctx, 'hKernel') - kernel_name = "UNKNOWN" - # CHANGED: was `state.find_object(ctx, 'hCommandList')` -- wrong arity - # (find_object needs type+handle) and returned an object, not a handle. Read - # the handle from the entry params and format it for the message. + kernel_name = "UNKNOWN" #kernel name wasn't passed, so mark it as unknown command_list_handle = state.find_param(ctx, 'hCommandList') if kernels[kernel_handle] kernel_name = kernels[kernel_handle].name @@ -113,24 +53,14 @@ def check_valid_ordinal(state, ctx, defi, cqg_ordinal) end end -# ADDED: true if the command list contains a compute kernel launch. A launch op -# is recorded with kind :launch, but so is zeCommandListAppendMemoryCopyRegion -# (which is a copy, not compute), so we match on the appending API name rather -# than the kind alone. Cooperative kernel launches count as compute too. +#list of compute launches COMPUTE_LAUNCH_APIS = ['zeCommandListAppendLaunchKernel', 'zeCommandListAppendLaunchCooperativeKernel'].freeze def command_list_has_kernel_launch?(cmd_list) cmd_list && cmd_list.ops.any? { |op| op.kind == :launch && COMPUTE_LAUNCH_APIS.include?(op.api) } end -# ADDED: execute-time check for submitting a command list that contains a compute -# kernel launch to a command queue associated with a copy-only engine. This -# complements check_valid_ordinal (which fires at append time on the list's own -# ordinal): here the queue is only known at zeCommandQueueExecuteCommandLists, so -# we compare the QUEUE's group ordinal (queue.desc[:ordinal]) against the -# copy-only set. On the Intel Data Center Max GPUs we target, running compute on a -# copy-only engine segfaults with no diagnostic from the runtime. Reported once -# per (queue, list) pair to avoid duplicate spam across repeated submits. +#Checks whether a command list that has a compute kernel gets submitted to a command queue that is attached to a copy only engine. def check_copy_only_queue_submission(state, ctx, queue, cmd_list) return unless queue && queue.desc return unless command_list_has_kernel_launch?(cmd_list) @@ -144,16 +74,7 @@ def check_copy_only_queue_submission(state, ctx, queue, cmd_list) "with copy-only ordinal #{queue_ordinal}") end -# ADDED: check that a launched kernel's module was created on the SAME Level Zero -# context as the command list the kernel is appended to. The spec requires it: -# zeCommandListAppendLaunchKernel/LaunchCooperativeKernel state "the command list, -# kernel and events were created on the same context." A kernel has no context of -# its own -- zeKernelCreate takes a module (hModule), and the module carries the -# context it was created on (zeModuleCreate's hContext) -- so the kernel's context -# is kernel.module.context. Runs at ENTRY (a launch can abort without an _exit), -# reading handles from defi. Unknown kernel/module/list, or a module/list whose -# context we never saw (tracing started mid-stream), are skipped rather than -# flagged. Reported once per (command list, kernel) pair. +# Checks whether the kernel module's context matches that of the command list's. def check_kernel_list_context_match(state, ctx, defi) command_lists = state.find_objects(ctx, 'command_list') kernels = state.find_objects(ctx, 'kernel') @@ -183,13 +104,7 @@ def check_kernel_created(state, ctx, defi) end end -#Checks for misuse of fences. -#Not a proper use of fence if it was already signaled, -#or being used by other commandslist. -# A fence must be reset (zeFenceReset) between submissions. Reusing one that is -# still in_use, or that was signaled and never reset, means the program cannot -# tell which submission the fence refers to -- so it will either wait on the -# wrong thing or not wait at all. +#Checks for using fence without reset def check_fence_misuse(state, ctx, defi) fence_handle = defi['hFence'] fence = get_fence(state,ctx,fence_handle) @@ -198,7 +113,7 @@ def check_fence_misuse(state, ctx, defi) end end -# USAGE CHECK: the queue handed to ExecuteCommandLists was never created (or +# Check whether the queue handed to ExecuteCommandLists was never created (or # was already destroyed). def check_valid_command_queue(state,ctx,defi, cmd_queues, cmd_queue_ptr) cmd_queue = cmd_queues[cmd_queue_ptr] @@ -208,8 +123,7 @@ def check_valid_command_queue(state,ctx,defi, cmd_queues, cmd_queue_ptr) end -# USAGE CHECK on the lists passed to ExecuteCommandLists. Three ways to get it -# wrong: submitting nothing at all, submitting a handle that was never created, +# Checks for three things: submitting nothing at all, submitting a handle that was never created, # or submitting an IMMEDIATE list -- immediate lists carry their own implicit # queue and execute at append time, so passing one to a queue is invalid. def check_valid_command_lists(state, ctx, defi) @@ -228,31 +142,7 @@ def check_valid_command_lists(state, ctx, defi) end end -#change it to calculating the memory overlap region? -# Returns [start, end] of the region two allocations share, or [] if they are -# disjoint (or of different memory types, which cannot alias). Used by the data -# race detection work. -def get_memory_overlap(mem1, mem2) - overlap = [] - if mem1 && mem2 && mem1.memtypestr == mem2.memtypestr - #Check if mem2 is contained in mem1 - if mem1.base <= mem2.base && mem2.base <= mem1.base + mem1.size - overlap << mem2.base - overlap << [mem2.base+mem2.size, mem1.base+mem1.size].min - elsif mem2.base <= mem1.base && mem1.base <= mem2.base + mem2.size - overlap << mem1.base - overlap << [mem2.base+mem2.size, mem1.base+mem1.size].min - end - end - overlap -end -# REMOVED: record_copy_over / add_api_call_to_cmd_list. -# These were the earlier "memory_in_transit" and "api_calls history" approaches -# to correlating copies. They are superseded by the RecordedOp / DeferredUnit -# model (each copy is recorded as an op and checked at execute time), and -# add_api_call_to_cmd_list referenced undefined locals (state/ctx) so it could -# never have run. See record_op / record_copy_op below. # Resolve a fence handle to its model object (nil if unknown). def get_fence(state,context,fence_handle) @@ -260,34 +150,25 @@ def get_fence(state,context,fence_handle) fence = fences[fence_handle] #returns fence end -# ADDED: resolve the Level Zero context handle that owns a command list, given -# the list handle. Memory maps are keyed by context handle, but copy/fill append -# APIs identify only the command list -- the context is reachable through the -# list's Context object. Returns nil for an unknown list (tracing started -# mid-stream); callers fall back to the shared nil bucket in that case. +# Resolve the Level Zero context handle that owns a command list. def cmd_list_ctx_handle(state, ctx, cmd_list_handle) cmd_list = state.find_objects(ctx, 'command_list')[cmd_list_handle] cmd_list && cmd_list.context ? cmd_list.context.handle : nil end -# ADDED: read the wait-event handles from an append call's input params. Both -# spellings appear in the trace: copies/barriers/launches use phWaitEvents_vals, -# zeCommandListAppendWaitOnEvents uses phEvents_vals. find_param is used because -# exit callbacks do not see input params in defi. Null/empty entries are dropped. +# retrieves the wait event handles at the current state def wait_event_handles(state, ctx) handles = state.find_param(ctx, 'phWaitEvents_vals') || state.find_param(ctx, 'phEvents_vals') || [] handles.reject { |h| h.nil? || h == 0 } end -# ADDED: record one op onto its command list. A regular (non-immediate) list -# stores it for replay at execute time; an immediate list executes right away, so -# the op is scheduled immediately. No-op if the list handle is unknown. +# Record one op onto its command list. A regular (non-immediate) list def record_op(state, ctx, cmd_list_handle, op) cmd_list = state.find_objects(ctx, 'command_list')[cmd_list_handle] return unless cmd_list if cmd_list.immediate - # ADDED: an immediate list never reaches zeCommandQueueExecuteCommandLists, so + # An immediate list never reaches zeCommandQueueExecuteCommandLists, so # check its events' context here (against the list's own context) before the op # is scheduled. check_event_pool_immediate_list_context_match(state, ctx, cmd_list, op) @@ -297,19 +178,15 @@ def record_op(state, ctx, cmd_list_handle, op) end end -# ADDED: record a memory-copy op (zeCommandListAppendMemoryCopy / MemoryFill). -# Values are snapshotted now (via find_param) because the per-call context is -# gone by the time the op is replayed. The out-of-bounds check is deferred to -# execute time; see check_oob_copy. +# Record a memory-copy op (zeCommandListAppendMemoryCopy / MemoryFill). +# recording is necessary because the per-call context is +# gone by the time the op is replayed. def record_copy_op(state, ctx, api, dst_key, src_key) cmd_list_handle = state.find_param(ctx, 'hCommandList') op = ZEModel::RecordedOp.new(:copy, signal: state.find_param(ctx, 'hSignalEvent'), waits: wait_event_handles(state, ctx), params: { api: api, - # ADDED: snapshot the owning context handle so the deferred OOB/ - # UAF checks look in the right per-context allocation map when the - # copy replays at execute time (the per-call context is gone by then). ctx_handle: cmd_list_ctx_handle(state, ctx, cmd_list_handle), dst: (dst_key ? state.find_param(ctx, dst_key) : nil), src: (src_key ? state.find_param(ctx, src_key) : nil), @@ -317,15 +194,7 @@ def record_copy_op(state, ctx, api, dst_key, src_key) record_op(state, ctx, cmd_list_handle, op) end -# ADDED: record a zeCommandListAppendMemoryRangesBarrier op. Like a barrier, it -# waits on its wait-events and signals its completion event, so it must appear in -# the deferred op stream for the event-ordering / deadlock checks to see it. It -# also names memory ranges whose coherency it guarantees; each range is snapshot -# as {base:, size:} so a deferred check can verify the range lies within a live -# allocation (see check_ranges_barrier). The two InArray params arrive in the -# trace as pRanges_vals (the base addresses) and pRangeSizes_vals (byte sizes), -# positionally paired. Missing/short arrays degrade to nil entries rather than -# crashing (tracing may have started mid-stream). +# Records a zeCommandListAppendMemoryRangesBarrier op. def record_ranges_barrier_op(state, ctx) cmd_list_handle = state.find_param(ctx, 'hCommandList') bases = state.find_param(ctx, 'pRanges_vals') || [] @@ -340,19 +209,12 @@ def record_ranges_barrier_op(state, ctx) record_op(state, ctx, cmd_list_handle, op) end -# USAGE CHECK: a command list must be finalized with zeCommandListClose before -# it may be submitted. Submitting an open list means the driver may see a -# partially recorded sequence; submitting a destroyed one is a use-after-free of -# the handle. +# Check if a command list was closed before launching anything on it (called at the execute command lists, for non-immediate command queues) def check_command_list_closed(state, ctx, defi) command_queue_handle = defi['hCommandQueue'] - # CHANGED: guard against nil (empty submit) so .each does not crash command_lists = defi['phCommandLists_vals'] || [] known_command_lists = state.find_objects(ctx, 'command_list') command_lists.each do |command_list_handle| - # CHANGED: was `knwon_command_lists` (typo -> NameError). Also skip unknown - # handles rather than calling .status on nil, and report the actual handle - # instead of the undefined local `cl`. cmd_list = known_command_lists[command_list_handle] next unless cmd_list if cmd_list.status == ZEModel::CommandList.class_variable_get(:@@INITIALIZED) @@ -364,17 +226,8 @@ def check_command_list_closed(state, ctx, defi) end -# ADDED: validate a zeCommandListReset. Runs at ENTRY, before the model applies -# the reset, reading the list handle from defi. Reports three misuses: -# * already-destroyed list -- resetting a destroyed handle is a usage error. -# * immediate command list -- zeCommandListReset is invalid on an immediate list -# (it has no closed/execute lifecycle to reset); the runtime returns -# ZE_RESULT_ERROR_INVALID_ARGUMENT. -# * reset while in-flight -- the list is still executing a prior -# zeCommandQueueExecuteCommandLists submission. Resetting it now races the -# device and is undefined behavior in Level Zero. -# Every command list handle is tracked, so the list is always found. -# Reported once per (command list, reason) pair. +# check if the command list reset is valid or not. +# Invalid calls: reset on destroyed lists, reset on immeidate lists, and reset on command lists that are already exeucting. def check_command_list_reset(state, ctx, defi) handle = defi['hCommandList'] cmd_list = state.find_objects(ctx, 'command_list')[handle] @@ -408,7 +261,7 @@ def check_command_list_reset(state, ctx, defi) end end -# USAGE CHECK: zeKernelCreate was given a null module handle. +# checks whether zeKernelCreate was given a null module handle. def check_valid_module(state,ctx,defi) module_handle = state.find_param(ctx, 'hModule') if !module_handle || module_handle == 0 @@ -416,15 +269,8 @@ def check_valid_module(state,ctx,defi) end end -# --------------------------------------------------------------------------- -# CONTEXT-MATCHING CHECKS. -# A Level Zero context is an isolation domain: objects used together must all -# belong to one. The next three functions cover the triangle involved in a -# submission -- the command list, the queue it goes to, and the fence that -# signals its completion. All three must agree, so each pairing is checked. -# --------------------------------------------------------------------------- -# The fence's queue and the command list must be on the same context. +# Checks if the fence's queue and the command list is on the same context. def check_list_and_fence_have_matching_context(state,ctx,defi,cmd_list,fence) if fence unless cmd_list && fence.command_queue && @@ -436,8 +282,8 @@ def check_list_and_fence_have_matching_context(state,ctx,defi,cmd_list,fence) end end -# Stronger than a context match: a fence is bound to ONE specific queue at -# creation, and may only be used with that queue. +# Checks for context between queue and the fence. +# Stronger than a context match, as it checks for the matching of the queue. def check_fence_and_queue_compatibility(state,ctx,defi,cmd_queue,fence) if fence unless cmd_queue && cmd_queue == fence.command_queue @@ -449,7 +295,7 @@ def check_fence_and_queue_compatibility(state,ctx,defi,cmd_queue,fence) end end -# A command list may only be submitted to a queue on its own context. +# Check the contxt bettween the queue and the list def check_list_and_queue_have_matching_context(state,ctx,defi,cmd_list, cmd_queue) unless cmd_queue && cmd_list && cmd_list.context == cmd_queue.context queue_handle = cmd_queue ? state.get_handle_str(cmd_queue.handle) : "nullptr" @@ -458,46 +304,25 @@ def check_list_and_queue_have_matching_context(state,ctx,defi,cmd_list, cmd_queu end end -# ADDED: op kinds whose events are subject to the spec's same-context requirement. -# Not every append that carries events requires them to share the list's context: -# the Level Zero spec attaches "the command list and events were created on the -# same context" to launch/copy/fill/signal/wait ops, but zeCommandListAppendBarrier -# and zeCommandListAppendMemoryRangesBarrier use the WEAKER "events must be -# accessible by the device on which the command list was created" wording -- no -# same-context clause. So :barrier and :ranges_barrier are deliberately excluded -# here to avoid false positives; their events would need a device-accessibility -# model (which the validator does not currently have) rather than a context match. -SAME_CONTEXT_EVENT_OP_KINDS = [:copy, :launch, :signal, :wait, :reset].freeze - -# ADDED: the distinct event handles one recorded op references -- the completion -# event it signals (op.signal) and the events it waits on (op.waits). Null handles -# are already normalized away (op.signal is nil when 0; op.waits drops nil/0 at -# record time). Returns [] for ops whose events are not subject to the same-context -# rule (see SAME_CONTEXT_EVENT_OP_KINDS). -def same_context_event_handles_in_op(op) - return [] unless SAME_CONTEXT_EVENT_OP_KINDS.include?(op.kind) +# List of operations to collect the events from +EVENT_OP_KINDS = [:copy, :launch, :signal, :wait, :reset].freeze + +# retrieves the events in a given op +def event_handles_in_op(op) + return [] unless EVENT_OP_KINDS.include?(op.kind) handles = [] handles << op.signal if op.signal handles.concat(op.waits) if op.waits handles end -# ADDED: distinct event handles a command list references across all of its -# recorded ops that ARE subject to the same-context requirement. -def same_context_event_handles_in_list(cmd_list) - cmd_list.ops.flat_map { |op| same_context_event_handles_in_op(op) }.uniq +# returns the distinct event handles a command list references across all of its +# recorded ops that are subject to the same-context requirement. +def event_handles_in_list(cmd_list) + cmd_list.ops.flat_map { |op| event_handles_in_op(op) }.uniq end -# ADDED: core context-consistency check shared by the queue-submission and -# immediate-list cases. For each event handle, resolve its context THROUGH its -# event pool (an event has no context of its own -- zeEventCreate takes no context -# and derives it from the pool, which is bound to a context at zeEventPoolCreate or, -# for an IPC-shared pool, at the hContext passed to zeEventPoolOpenIpcHandle) and -# report if it differs from ref_context. `ref_kind`/`ref_handle` name the object -# the events are expected to match (a command queue or an immediate command list), -# for the message and the dedup key. Reported once per (ref, event) pair. Unknown -# events, or events whose pool/context we never saw (tracing started mid-stream), -# are skipped. +#Check if all events share the same context def check_events_share_context(state, ctx, event_handles, ref_context, ref_kind, ref_handle) return unless ref_context events = state.find_objects(ctx, 'event') @@ -517,58 +342,31 @@ def check_events_share_context(state, ctx, event_handles, ref_context, ref_kind, end end -# ADDED: execute-time check that every event used by a submitted command list (in -# an op subject to the same-context rule) comes from an event pool on the SAME -# Level Zero context as the command queue the list is submitted to. The spec -# requires such a command list and its events to share a context; since the list -# must also match the queue's context, an event pool whose context differs from the -# queue's is a mismatch. -def check_event_pool_queue_context_match(state, ctx, queue, cmd_list) - return unless queue && queue.context && cmd_list - check_events_share_context(state, ctx, same_context_event_handles_in_list(cmd_list), - queue.context, 'command queue', queue.handle) +# Check if event pool's context matches the command queue's context +def check_event_pool_list_context_match(state, ctx, cmd_list) + return unless cmd_list + check_events_share_context(state, ctx, event_handles_in_list(cmd_list), + cmd_list.context, 'command list', cmd_list.handle) end -# ADDED: append-time analogue for IMMEDIATE command lists. An immediate list is its -# own implicit queue -- it never goes through zeCommandQueueExecuteCommandLists (and -# is in fact rejected there), so the queue-submission check above never sees it. Its -# ops execute at append time, so we validate each appended op's events here against -# the immediate list's OWN context (which stands in for the queue's). Called from -# record_op for the immediate branch. +# Check if event pool's context matches the immediate command list's context def check_event_pool_immediate_list_context_match(state, ctx, cmd_list, op) return unless cmd_list && cmd_list.context - check_events_share_context(state, ctx, same_context_event_handles_in_op(op), + check_events_share_context(state, ctx, event_handles_in_op(op), cmd_list.context, 'immediate command list', cmd_list.handle) end -# REPLACED check_oob_memory_copy (it iterated a CommandList as if enumerable and -# referenced an undefined `defi`) with the helpers below. - -# --------------------------------------------------------------------------- -# MEMORY CHECKS: out-of-bounds copies and use-after-free. -# -# Memory is the one kind of object not identified by an opaque handle but by an -# ADDRESS, so these checks work on ranges: an allocation covers -# [base, base + size), and a pointer into the middle of it is a legitimate -# reference to it. That is why lookups here are two-step -- try the exact base -# address, then fall back to a containment scan. -# --------------------------------------------------------------------------- -# ADDED: find the allocation that contains ptr, so a copy into an offset of a -# base allocation is matched, not only an exact base-pointer copy. +#Find the memory allocation containing the ptr def find_allocation_containing(allocations, ptr) allocations.each_value.find { |m| m.base && m.base <= ptr && ptr < m.base + m.size } end -# ADDED: out-of-bounds check for one endpoint (source or destination) of a copy. -# Measures the copy size against the bytes remaining from ptr's offset within its -# allocation. An unknown pointer is left alone (nothing to compare against). -def check_copy_endpoint(state, ctx, allocations, ptr, size, api, role) +# Check whether the copy's endpoints have enough space to support the requested size +def check_copy_endpoint_oob(state, ctx, allocations, ptr, size, api, role) return if ptr.nil? || ptr == 0 || size.nil? mem = allocations[ptr] || find_allocation_containing(allocations, ptr) return unless mem - #the copy starts partway into the allocation, so the space it can legally use - #is what remains from that offset -- not the allocation's full size offset = ptr - mem.base available = mem.size - offset if available < size @@ -577,30 +375,16 @@ def check_copy_endpoint(state, ctx, allocations, ptr, size, api, role) end end -# ADDED: deferred out-of-bounds check for a recorded copy op. Called from the -# scheduler once the copy's wait-events are satisfied, so it runs against the -# memory model as it stands at the point the copy actually executes -- avoiding -# the false positives that checking at execute-entry would give (the destination -# may only be allocated after execute, by whoever signals the wait-event). +# Performs the oob check for copy for both endpoints (src and dst) def check_oob_copy(state, ctx, params) api = params[:api] || 'zeCommandListAppendMemoryCopy' size = params[:size] - # CHANGED: look up allocations in the copy's own context sub-map. The context - # handle was snapshotted into params at record/append time (see record_copy_op). allocations = state.memory_allocations(ctx, params[:ctx_handle]) - check_copy_endpoint(state, ctx, allocations, params[:dst], size, api, 'destination') - check_copy_endpoint(state, ctx, allocations, params[:src], size, api, 'source') -end - -# ADDED: null-pointer check for a memory copy/fill. In Level Zero, a null dstptr -# or srcptr on a copy (or a null ptr on a fill) is a usage error -# (ZE_RESULT_ERROR_INVALID_NULL_POINTER) and can crash the driver inside the -# append -- which then emits no _exit event -- so this runs at ENTRY, before the -# (possibly fatal) call, reading the input pointers directly from defi. Null-ness -# is a static property of the arguments, so unlike the OOB/UAF checks it needs no -# deferral to execute time. `endpoints` is an ordered role -> pointer map; a fill -# passes only the destination, so a fill's (absent) source is never flagged. -# Reports every null endpoint found. + check_copy_endpoint_oob(state, ctx, allocations, params[:dst], size, api, 'destination') + check_copy_endpoint_oob(state, ctx, allocations, params[:src], size, api, 'source') +end + +# Check if the copy is from/to a nullptr def check_null_copy_ptr(state, ctx, api, endpoints) endpoints.each do |role, ptr| if ptr.nil? || ptr == 0 @@ -609,19 +393,15 @@ def check_null_copy_ptr(state, ctx, api, endpoints) end end -# ADDED: a successful allocation may reuse an address previously freed. Drop any -# freed record whose former range overlaps the new allocation so it is not -# mistaken for a still-dangling pointer. Call from the alloc callbacks. +# Deletes the address with a new allocation +# An address might be reused after a free. In this case, we need to update the validator's state as well. def mark_reallocated(state, ctx, ctx_handle, handle, size) - # CHANGED: only scan the freed registry of the context this allocation belongs - # to -- a reused address in one context says nothing about another context. freed = state.freed_memory_allocations(ctx, ctx_handle) return if freed.empty? freed.delete_if { |_addr, m| ranges_overlap?(m.base, m.size, handle, size) } end -# ADDED: like find_allocation_containing, but over the freed-allocation registry -# -- finds a released allocation whose (former) range still contains ptr. +# Finds the freed allocation that contains the ptr def find_freed_allocation_containing(freed, ptr) freed.each_value.find { |m| m.base && m.base <= ptr && ptr < m.base + m.size } end @@ -647,10 +427,7 @@ def check_uaf_endpoint(state, ctx, live, freed, ptr, api, role) "freed#{mem.freed_by ? " by #{mem.freed_by}" : ""}; use-after-free") end -# ADDED: deferred use-after-free check for a recorded copy/fill op. Runs from the -# scheduler at the point the copy actually executes (alongside check_oob_copy), -# so a pointer freed before the copy's turn is caught, while a destination only -# allocated later is not falsely flagged. +# Performs the actual checks for when an API uses a memory that has been freed def check_use_after_free(state, ctx, params) api = params[:api] || 'zeCommandListAppendMemoryCopy' # CHANGED: resolve both maps within the copy's own context (see check_oob_copy). @@ -661,28 +438,14 @@ def check_use_after_free(state, ctx, params) check_uaf_endpoint(state, ctx, live, freed, params[:src], api, 'source') end -# ADDED: append-time UAF check that RESPECTS the op's own wait events. A copy -# does not touch memory until its wait events are signaled: an immediate append -# executes only once its waits are satisfied, and a recorded (regular-list) -# append executes later still. So render a verdict at append entry only when the -# waits are already satisfied (empty waits count as satisfied). If the waits are -# still unmet, the copy has not accessed memory yet -- leave it to the deferred -# scheduler (run_deferred_op), which re-checks at the exact point the copy -# becomes runnable. This keeps the crash-safety benefit for the common -# no-wait / ready case while staying event-aware for gated copies. +# calls the check_use_after_free only if the wait events have been satisfied def check_use_after_free_on_append(state, ctx, params, waits) return unless state.waits_satisfied?(ctx, waits) check_use_after_free(state, ctx, params) end -# ADDED: deferred validation of a zeCommandListAppendMemoryRangesBarrier's memory -# ranges. Runs from the scheduler when the barrier executes (its waits satisfied), -# so it sees the memory model as it stands at that point -- mirroring the copy -# checks. For each range, if its base was already zeMemFree'd we report a -# use-after-free (the barrier references memory that is gone); a base that matches -# no live allocation is left alone, since it may be IPC/untracked memory (see -# check_uaf_endpoint's rationale). A range base of nullptr is skipped. -def check_ranges_barrier(state, ctx, params) +# Checks for uaf on memory ranges barrier +def check_uaf_ranges_barrier(state, ctx, params) api = params[:api] || 'zeCommandListAppendMemoryRangesBarrier' live = state.memory_allocations(ctx, params[:ctx_handle]) freed = state.freed_memory_allocations(ctx, params[:ctx_handle]) @@ -692,7 +455,7 @@ def check_ranges_barrier(state, ctx, params) end end -# ADDED: true if [a, a+asize) and [b, b+bsize) overlap. +# returns true if [a, a+asize) and [b, b+bsize) overlap. def ranges_overlap?(a, asize, b, bsize) return false unless a && b && asize && bsize a < b + bsize && b < a + asize diff --git a/backends/ze/ze_validator_function_entry_exit_callbacks.rb b/backends/ze/ze_validator_function_entry_exit_callbacks.rb index 1ae1feed..69a53fe6 100644 --- a/backends/ze/ze_validator_function_entry_exit_callbacks.rb +++ b/backends/ze/ze_validator_function_entry_exit_callbacks.rb @@ -2,51 +2,7 @@ require 'ze_validator_zemodel' require 'ze_library' -# ============================================================================= -# THE DISPATCH TABLES -- where each Level Zero API is taught to the validator. -# -# This file is a big flat registry. For every API the validator cares about, -# it registers a lambda in one of three global hashes, keyed by API name. -# StateObject#on_entry / #on_exit look the current API up in these and call -# whatever it finds; an API with no entry here is simply ignored. -# -# $upon_entry[api] runs when the call STARTS -# $on_successful_exit[api] runs when it RETURNS SUCCESSFULLY -# $on_erroneous_exit[api] runs when it RETURNS AN ERROR -# -# WHICH TABLE DOES A GIVEN THING BELONG IN? -# ----------------------------------------- -# $on_successful_exit is the default, and holds most MODEL UPDATES: -# * Create calls -- the new handle only exists in the exit payload, and a -# failed create produced no object to record. -# * Destroy calls -- a failed destroy did not destroy anything. -# * Recording appended operations -- only an append that succeeded will -# actually execute later. -# -# $upon_entry holds checks that CANNOT wait for the exit, for two reasons: -# * The call may kill the process (a bad kernel launch, a copy from freed -# memory). If it does, the tracer never writes an _exit event, so an -# exit-time check would silently never run. Several callbacks below say -# exactly this in their comment. -# * The check needs pre-call state -- e.g. zeMemFree must inspect what is -# still in flight BEFORE the buffer is released. -# -# $on_erroneous_exit is for the rarer case where a FAILURE is itself -# informative: the driver rejecting a copy is a good moment to point out that -# the copy was out of bounds, and a failed free must undo the model change that -# was applied optimistically at entry. -# -# READING THE LAMBDAS -# ------------------- -# Every lambda takes |state, ctx, defi| -- see the argument conventions at the -# top of ze_validator_entry_exit_helpers.rb. The recurring idiom -# -# handle = defi['hFoo'] # in an ENTRY callback -# handle = state.find_param(ctx, 'hFoo') # in an EXIT callback -# -# is not inconsistency: at exit, defi holds only outputs, so inputs must be read -# from the saved entry payload via find_param. -# ============================================================================= + $upon_entry = {} #called to modify program state on entry $on_successful_exit = {} #called upon seeing exit functions with a successful return code @@ -302,7 +258,7 @@ # stream for the deferred scheduler and deadlock detection. It additionally names # memory ranges whose coherency it guarantees; those ranges are snapshotted and # validated against the allocation model when the barrier executes (see -# record_ranges_barrier_op / check_ranges_barrier). +# record_ranges_barrier_op / check_uaf_ranges_barrier). $on_successful_exit['zeCommandListAppendMemoryRangesBarrier'] = lambda { |state, ctx, defi| record_ranges_barrier_op(state, ctx) } @@ -389,7 +345,7 @@ # ADDED: a list with a compute kernel launch must not go to a copy-only queue check_copy_only_queue_submission(state,ctx,command_queue,known_command_lists[command_list_handle]) # ADDED: events used by the list must come from an event pool on the queue's context - check_event_pool_queue_context_match(state,ctx,command_queue,known_command_lists[command_list_handle]) + check_event_pool_list_context_match(state,ctx,known_command_lists[command_list_handle]) end else # CHANGED: was raise_internal_error, which aborted the whole validator on one diff --git a/backends/ze/ze_validator_state_object.rb b/backends/ze/ze_validator_state_object.rb index 1ef092cf..8a5296ba 100644 --- a/backends/ze/ze_validator_state_object.rb +++ b/backends/ze/ze_validator_state_object.rb @@ -530,7 +530,7 @@ def run_deferred_op(unit) check_use_after_free(self, context, op.params) end #a memory-ranges barrier references memory freed before its turn is a UAF - check_ranges_barrier(self, context, op.params) if op.kind == :ranges_barrier + check_uaf_ranges_barrier(self, context, op.params) if op.kind == :ranges_barrier #a reset takes effect before this op signals its own completion event reset_event(context, op.params[:reset_handle]) if op.kind == :reset signaled = false From b4d0b0b7e828af6f02b97e1767d5f4163abd5945 Mon Sep 17 00:00:00 2001 From: Junhyung Shim Date: Thu, 13 Aug 2026 19:37:00 +0000 Subject: [PATCH 3/4] Improved comments, refactored variable names, and added safeguards for detections, --- backends/ze/ze_validator.in | 165 +---- .../ze/ze_validator_entry_exit_helpers.rb | 266 +++------ ...validator_function_entry_exit_callbacks.rb | 564 +++++++----------- backends/ze/ze_validator_state_object.rb | 472 +++------------ backends/ze/ze_validator_zemodel.rb | 471 +++------------ 5 files changed, 469 insertions(+), 1469 deletions(-) diff --git a/backends/ze/ze_validator.in b/backends/ze/ze_validator.in index eaafa1d8..5e91ca94 100644 --- a/backends/ze/ze_validator.in +++ b/backends/ze/ze_validator.in @@ -1,105 +1,24 @@ #!/usr/bin/env ruby # coding: utf-8 -# ============================================================================= -# ze_validator -- a correctness checker for Intel Level Zero ("ze") programs. -# -# WHAT THIS IS -# ------------ -# THAPI (Tracing Heterogeneous APIs) ships a tracer that intercepts every call -# an application makes into the Level Zero runtime and writes it to an LTTng -# trace on disk (a "CTF" trace). This script reads such a trace back and -# replays it against a software model of what the Level Zero runtime would have -# been doing, looking for API misuse the runtime itself does not diagnose: -# leaks, use-after-free, deadlocks, cross-context handle mixing, and so on. -# -# It never runs on the GPU and never touches the application. It is a pure -# post-mortem analysis of a recorded trace. -# -# THE FIVE FILES THAT MAKE UP THE VALIDATOR -# ----------------------------------------- -# ze_validator.in <-- YOU ARE HERE -# Executable entry point. Parses CLI options, wires up the babeltrace2 -# graph that decodes the trace, and hands each decoded event to the -# StateObject. ".in" means autoconf preprocesses it at build time, -# substituting @prefix@ below; the installed file is named `ze_validator`. -# -# ze_validator_zemodel.rb -# Pure data model. Plain Ruby classes mirroring Level Zero objects -# (Device, Context, CommandList, Event, Fence, Memory, ...) plus the -# bookkeeping types used for deferred execution (RecordedOp, DeferredUnit). -# No checking logic lives here -- just state. -# -# ze_validator_state_object.rb -# The engine. Holds the whole model (per host / per process / per thread), -# drives the trace-consumption loop, runs the deferred-execution -# scheduler, and owns every error-reporting method (print_usage_error etc). -# -# ze_validator_function_entry_exit_callbacks.rb -# The dispatch tables. Three global hashes mapping a ze API name to a -# lambda: $upon_entry, $on_successful_exit, $on_erroneous_exit. This is -# where "what does zeMemAllocDevice do to the model" is written. -# -# ze_validator_entry_exit_helpers.rb -# The check library. The `check_*` functions those callbacks call -- -# out-of-bounds copies, use-after-free, deadlock cycles, context matching. -# -# HOW A TRACE BECOMES A DIAGNOSTIC (the data flow) -# ------------------------------------------------ -# trace on disk -# -> babeltrace2 graph (built in build_and_run_graph below) -# -> StateObject#consume receives one CTF event at a time -# -> event name is matched against /:(z.*)_(entry|exit)/ so -# "lttng_ust_ze:zeMemAllocDevice_entry" yields api="zeMemAllocDevice", -# phase="entry" -# -> StateObject#on_entry / #on_exit look the API up in the dispatch tables -# -> the callback mutates the model and/or calls a check_* helper -# -> a violated check prints "Level Zero Error: ..." to stderr -# -> at end of trace StateObject#check_issues reports leaks and deadlocks -# -# WHY EVERY API APPEARS TWICE (entry and exit) -# -------------------------------------------- -# The tracer emits one event just BEFORE the call enters the driver (_entry, -# carrying the input arguments) and one just AFTER it returns (_exit, carrying -# the return code and any output pointers). This matters constantly in the -# callback code: -# * Output handles (the thing a Create call produced) only exist at _exit. -# * If the driver CRASHES inside the call, the _exit event is never written -- -# so any check that could be the thing that crashes must run at _entry, or -# it will silently never fire. Several callbacks carry a comment saying -# exactly this. -# -# TYPICAL USE -# ----------- -# iprof -t -- ./my_app # record a trace with the THAPI tracer -# ze_validator ~/lttng-traces/... # replay it through this validator -# ============================================================================= - -# Where the installed data files live (ze_thread_safety.yaml, ze_deprecated.json, -# ze_device_property.json) and where the ze_library Ruby bindings are found. -# @prefix@ is replaced by autoconf with the configure --prefix at build time. DATADIR = File.join("@prefix@", "share") BINDIR = File.join("@prefix@", "bin") $:.unshift(DATADIR) if File.directory?(DATADIR) require 'optparse' -require 'babeltrace2' # Ruby bindings for babeltrace2, the CTF trace reader +require 'babeltrace2' require 'find' -require 'ze_library' # generated FFI bindings: ZE::ZEResult, ZE::ZE*Desc structs +require 'ze_library' require 'pp' require 'set' -require 'ze_validator_zemodel' # the object model (ZEModel::*) -require 'ze_validator_function_entry_exit_callbacks' # populates $upon_entry / $on_*_exit -require 'ze_validator_state_object' # the StateObject engine +require 'ze_validator_zemodel' +require 'ze_validator_function_entry_exit_callbacks' +require 'ze_validator_state_object' require 'yaml' # Don't complain about broken pipe -# (restores the default kill-on-SIGPIPE so piping our output into e.g. `head` -# terminates quietly instead of raising Errno::EPIPE out of a puts) Signal.trap('SIGPIPE', 'SYSTEM_DEFAULT') -# Runs the ze_device_property helper binary to (re)generate ze_device_property.json -# in DATADIR, where StateObject reads it. Best-effort: if the binary is missing or -# fails (e.g. no accessible GPU on this node), the validator continues without the -# device topology data rather than aborting. +# Runs the ze_device_property helper to (re)generate ze_device_property.json. +# Best-effort: the validator continues without the device topology data. def generate_device_properties json_path = File.join(DATADIR, 'ze_device_property.json') binary = File.join(BINDIR, 'ze_device_property') @@ -115,12 +34,6 @@ rescue SystemCallError => e "continuing without device properties." end -# Command-line defaults. All checking categories are ON unless explicitly -# disabled, so a plain `ze_validator ` gives the most thorough report. -# live - read a live LTTng session instead of a trace directory -# device_agnostic - report portability hazards (hardcoded ordinals etc.) -# performance - report API usage that costs performance -# gen_device_properties - shell out to the ze_device_property helper first $options = { live: false, device_agnostic: true, performance: true, gen_device_properties: true } @@ -151,46 +64,15 @@ OptionParser.new do |opts| end.parse! -# Builds and runs the babeltrace2 processing graph that decodes the trace and -# feeds it to the validator, then triggers the end-of-trace reporting pass. -# -# babeltrace2 works as a dataflow graph of components connected port-to-port: -# -# [source: trace_0] --\ -# [source: trace_1] ----> [filter: muxer] --> [sink: our StateObject] -# [source: trace_2] --/ -# -# * SOURCES decode one CTF trace directory each into a stream of messages. -# A traced run produces one trace per process (and per node on a cluster), -# hence potentially many sources. -# * The MUXER merges those streams into a single stream ordered by timestamp. -# This is essential: the validator's model assumes it sees events in the -# order they really happened, across all processes and threads. -# * The SINK is us -- sink_object.consume is a lambda invoked with each batch -# of messages (see StateObject#consume). -# -# `source_location` is the list of paths given on the command line. def build_and_run_graph( source_location, sink_object ) # build graph and set up source graph = BT2::BTGraph.new - # Look up the three component classes we need from babeltrace2's plugins. - # ctf.fs - read a CTF trace from the filesystem - # ctf.lttng-live - attach to a running LTTng session over the network - # utils.muxer - timestamp-order-merge several streams into one ctf_fs = BT2::BTPlugin.find('ctf').get_source_component_class_by_name('fs') ctf_lttng_live = BT2::BTPlugin.find("ctf").get_source_component_class_by_name("lttng-live") utils_muxer = BT2::BTPlugin.find('utils').get_filter_component_class_by_name('muxer') if !$options[:live] - # Offline mode: the user hands us a directory that may contain many traces - # nested at arbitrary depth (LTTng lays out one subdirectory per process, - # per node, per UST channel). Walk it and work out which directories are - # actually readable CTF traces, in four steps: - # 1. recursively enumerate every path, keeping only files - # 2. keep the ones literally named "metadata" -- every CTF trace directory - # contains exactly one such file describing its event layout - # 3. take that file's parent directory: that IS the trace directory trace_locations = Find.find(*source_location).reject do |path| FileTest.directory?(path) @@ -204,16 +86,10 @@ def build_and_run_graph( source_location, sink_object ) qe.query.value['weight'] > 0.5 end else - # Live mode: the arguments are LTTng relay-daemon URLs, not paths, so there - # is nothing on disk to search -- pass them through untouched. trace_locations = source_location end raise 'Could not find lttng trace' if trace_locations.size == 0 - # Add one source component per trace. Each gets a unique name ("trace_0", - # "trace_1", ...) because babeltrace requires component names to be distinct. - # In live mode, "session-not-found-action" => "end" makes the component finish - # cleanly rather than hang forever when the named session does not exist. if !$options[:live] comp_sources = trace_locations.each_with_index.collect { |trace_location, i| graph.add_component(ctf_fs, "trace_#{i}", params: {"inputs" => [ trace_location ] }) } else @@ -223,59 +99,32 @@ def build_and_run_graph( source_location, sink_object ) # Muxer comp_muxer = graph.add_component(utils_muxer, 'mux') - # The sink is where our code plugs in: `sink_object.consume` returns a lambda - # that babeltrace calls with an iterator over each batch of decoded messages. - # That lambda is the top of the whole validation pipeline. sink = graph.add_simple_sink('babeltrace_thapi', sink_object.consume) # Sources to muxer - # A single trace may expose several output ports (one per CTF stream, i.e. - # roughly per traced thread), so flat_map over all sources' ports and wire - # port i of the collected list into muxer input port i. The muxer grows its - # input ports on demand, which is why we can index it freely. comp_sources.flat_map(&:output_ports).each_with_index do |op, i| ip = comp_muxer.input_port(i) graph.connect_ports(op, ip) end # Chain the rest - # Only one link is left (muxer -> sink). It is written as a generic - # each_cons(2) pairwise chain so extra filter components could be spliced into - # the array without changing this code. [comp_muxer, sink].flatten.each_cons(2) do |_out, _in| op = _out.output_port(0) ip = _in.input_port(0) graph.connect_ports(op, ip) end - # Pull the whole trace through the graph. This call does not return until - # every event has been consumed, so by the time it finishes the model - # reflects the entire run. graph.run - # End-of-trace pass: drain any still-pending deferred command lists, report - # deadlocks, and report every object that was created but never destroyed. - # Errors found DURING the run were already printed as they were discovered; - # these are the ones only visible once you know nothing more is coming. sink_object.check_issues() end # only executive this code if we launch this as the main # script. if it's just included with "require" we just want access to the functions. -# (Ruby's equivalent of C's `int main` guard: $0 is the script that was invoked, -# __FILE__ is this file, so they match only when run directly. Tests can then -# `require` this file to reuse build_and_run_graph without starting an analysis.) if __FILE__ == $0 - # Refresh ze_device_property.json first, so StateObject picks up the real - # command-queue-group topology of this machine when it is constructed below. generate_device_properties if $options[:gen_device_properties] - # One StateObject holds the entire model for the whole run and acts as the - # graph's sink. sink_obj = StateObject.new(device_agnostic: $options[:device_agnostic], performance: $options[:performance]) - # Whatever is left in ARGV after OptionParser#parse! are the trace paths. - # Deduplicate: feeding the same trace twice would double-apply every event and - # corrupt the model (e.g. a second create for an already-live handle). ARGV.uniq! source_location = ARGV build_and_run_graph(source_location, sink_obj) diff --git a/backends/ze/ze_validator_entry_exit_helpers.rb b/backends/ze/ze_validator_entry_exit_helpers.rb index fb812087..e63a066c 100644 --- a/backends/ze/ze_validator_entry_exit_helpers.rb +++ b/backends/ze/ze_validator_entry_exit_helpers.rb @@ -3,10 +3,9 @@ -# A command queue is created with an (ordinal, index) -# pair -- which engine group, and which queue within that group. Asking oob index segfaults +# Checks for oob index. A command queue is created with an (ordinal, index) +# pair -- which engine group, and which queue within that group. def check_valid_index_for_ordinal(state,ctx,queue_handle,ordinal,index) - #puts "entered" if state.device_properties command_queue_prop = state.device_properties["devices"][0]["command_queue_groups"] command_queue_prop.each do |prop| @@ -21,7 +20,7 @@ def check_valid_index_for_ordinal(state,ctx,queue_handle,ordinal,index) # Checking whether the application ever called zeDeviceGetCommandQueueGroupProperties # before calling command queue/list create. Not calling it implies hardcoded ordinals -def check_group_property_queued(state, ctx, defi, device) +def check_group_property_queued(state, ctx, payload, device) if !(device.cmd_queue_group_properties_queried) && state.print_tracker["check_group_property"] == 0 state.print_tracker["check_group_property"] = 1 state.print_usage_error(ctx,"command queue group wasn't queried. Hardcoded group properties may break the code on different devices") @@ -38,7 +37,7 @@ def copy_only_ordinals(state) end # checks whether a command list attached to a copy-only engine receives a kernel -def check_valid_ordinal(state, ctx, defi, cqg_ordinal) +def check_valid_ordinal(state, ctx, payload, cqg_ordinal) copy_only_ords = copy_only_ordinals(state) if copy_only_ords.include?(cqg_ordinal) && state.print_tracker["check_valid_ordinal"] == 0 state.print_tracker["check_valid_ordinal"] = 1 @@ -56,6 +55,7 @@ def check_valid_ordinal(state, ctx, defi, cqg_ordinal) #list of compute launches COMPUTE_LAUNCH_APIS = ['zeCommandListAppendLaunchKernel', 'zeCommandListAppendLaunchCooperativeKernel'].freeze + def command_list_has_kernel_launch?(cmd_list) cmd_list && cmd_list.ops.any? { |op| op.kind == :launch && COMPUTE_LAUNCH_APIS.include?(op.api) } end @@ -75,11 +75,11 @@ def check_copy_only_queue_submission(state, ctx, queue, cmd_list) end # Checks whether the kernel module's context matches that of the command list's. -def check_kernel_list_context_match(state, ctx, defi) +def check_kernel_list_context_match(state, ctx, payload) command_lists = state.find_objects(ctx, 'command_list') kernels = state.find_objects(ctx, 'kernel') - cmd_list = command_lists[defi['hCommandList']] - kernel = kernels[defi['hKernel']] + cmd_list = command_lists[payload['hCommandList']] + kernel = kernels[payload['hKernel']] return unless cmd_list && cmd_list.context && kernel mod = kernel.module return unless mod && mod.context @@ -94,28 +94,26 @@ def check_kernel_list_context_match(state, ctx, defi) "(context #{state.get_handle_str(cmd_list.context.handle)})") end -# USAGE CHECK: the handle passed to a kernel launch was never produced by a -# zeKernelCreate we saw -- an uninitialized, stale, or wrong variable. -def check_kernel_created(state, ctx, defi) +# Checks if the kernel was created +def check_kernel_created(state, ctx, payload) kernels = state.find_objects(ctx, 'kernel') - kernel_handle = defi['hKernel'] + kernel_handle = payload['hKernel'] unless kernels[kernel_handle] state.print_usage_error(ctx, "kernel: #{state.get_handle_str(kernel_handle)} wasn't created. Consider calling zeKernelCreate") end end #Checks for using fence without reset -def check_fence_misuse(state, ctx, defi) - fence_handle = defi['hFence'] +def check_fence_misuse(state, ctx, payload) + fence_handle = payload['hFence'] fence = get_fence(state,ctx,fence_handle) if fence && (fence.status == fence.signaled || fence.status == fence.in_use) state.print_usage_error(ctx, "Used fence: #{state.get_handle_str(fence_handle)} twice without resetting it") end end -# Check whether the queue handed to ExecuteCommandLists was never created (or -# was already destroyed). -def check_valid_command_queue(state,ctx,defi, cmd_queues, cmd_queue_ptr) +# Check whether the queue handed to ExecuteCommandLists was never created (or was already destroyed). +def check_valid_command_queue(state,ctx,payload, cmd_queues, cmd_queue_ptr) cmd_queue = cmd_queues[cmd_queue_ptr] unless cmd_queue state.print_usage_error(ctx, "Invalid commandQueue (#{state.get_handle_str(cmd_queue_ptr)}) was handed to zeCommandQueueExecuteCommandLists") @@ -123,11 +121,10 @@ def check_valid_command_queue(state,ctx,defi, cmd_queues, cmd_queue_ptr) end -# Checks for three things: submitting nothing at all, submitting a handle that was never created, -# or submitting an IMMEDIATE list -- immediate lists carry their own implicit -# queue and execute at append time, so passing one to a queue is invalid. -def check_valid_command_lists(state, ctx, defi) - command_lists = defi['phCommandLists_vals'] +# Checks for submitting nothing, submitting a handle that was never created, or +# submitting an immediate list, which carries its own queue. +def check_valid_command_lists(state, ctx, payload) + command_lists = payload['phCommandLists_vals'] known_command_lists = state.find_objects(ctx, 'command_list') if command_lists.nil? || command_lists.empty? state.print_usage_error(ctx, "No valid commandlist was chosen at zeCommandQueueExecuteCommandLists") @@ -163,14 +160,11 @@ def wait_event_handles(state, ctx) handles.reject { |h| h.nil? || h == 0 } end -# Record one op onto its command list. A regular (non-immediate) list +# Record one op onto a command list def record_op(state, ctx, cmd_list_handle, op) cmd_list = state.find_objects(ctx, 'command_list')[cmd_list_handle] return unless cmd_list if cmd_list.immediate - # An immediate list never reaches zeCommandQueueExecuteCommandLists, so - # check its events' context here (against the list's own context) before the op - # is scheduled. check_event_pool_immediate_list_context_match(state, ctx, cmd_list, op) state.enqueue_immediate_op(ctx, op, cmd_list_handle) else @@ -179,8 +173,6 @@ def record_op(state, ctx, cmd_list_handle, op) end # Record a memory-copy op (zeCommandListAppendMemoryCopy / MemoryFill). -# recording is necessary because the per-call context is -# gone by the time the op is replayed. def record_copy_op(state, ctx, api, dst_key, src_key) cmd_list_handle = state.find_param(ctx, 'hCommandList') op = ZEModel::RecordedOp.new(:copy, @@ -210,9 +202,9 @@ def record_ranges_barrier_op(state, ctx) end # Check if a command list was closed before launching anything on it (called at the execute command lists, for non-immediate command queues) -def check_command_list_closed(state, ctx, defi) - command_queue_handle = defi['hCommandQueue'] - command_lists = defi['phCommandLists_vals'] || [] +def check_command_list_closed(state, ctx, payload) + command_queue_handle = payload['hCommandQueue'] + command_lists = payload['phCommandLists_vals'] || [] known_command_lists = state.find_objects(ctx, 'command_list') command_lists.each do |command_list_handle| cmd_list = known_command_lists[command_list_handle] @@ -228,8 +220,8 @@ def check_command_list_closed(state, ctx, defi) # check if the command list reset is valid or not. # Invalid calls: reset on destroyed lists, reset on immeidate lists, and reset on command lists that are already exeucting. -def check_command_list_reset(state, ctx, defi) - handle = defi['hCommandList'] +def check_command_list_reset(state, ctx, payload) + handle = payload['hCommandList'] cmd_list = state.find_objects(ctx, 'command_list')[handle] if cmd_list.status == ZEModel::CommandList.class_variable_get(:@@DESTROYED) @@ -262,7 +254,7 @@ def check_command_list_reset(state, ctx, defi) end # checks whether zeKernelCreate was given a null module handle. -def check_valid_module(state,ctx,defi) +def check_valid_module(state,ctx,payload) module_handle = state.find_param(ctx, 'hModule') if !module_handle || module_handle == 0 state.print_usage_error(ctx, "Improper hModule was handed") @@ -271,7 +263,7 @@ def check_valid_module(state,ctx,defi) # Checks if the fence's queue and the command list is on the same context. -def check_list_and_fence_have_matching_context(state,ctx,defi,cmd_list,fence) +def check_list_and_fence_have_matching_context(state,ctx,payload,cmd_list,fence) if fence unless cmd_list && fence.command_queue && cmd_list.context == fence.command_queue.context @@ -284,7 +276,7 @@ def check_list_and_fence_have_matching_context(state,ctx,defi,cmd_list,fence) # Checks for context between queue and the fence. # Stronger than a context match, as it checks for the matching of the queue. -def check_fence_and_queue_compatibility(state,ctx,defi,cmd_queue,fence) +def check_fence_and_queue_compatibility(state,ctx,payload,cmd_queue,fence) if fence unless cmd_queue && cmd_queue == fence.command_queue queue_handle = cmd_queue ? state.get_handle_str(cmd_queue.handle) : "nullptr" @@ -295,8 +287,8 @@ def check_fence_and_queue_compatibility(state,ctx,defi,cmd_queue,fence) end end -# Check the contxt bettween the queue and the list -def check_list_and_queue_have_matching_context(state,ctx,defi,cmd_list, cmd_queue) +# Check the context between the queue and the list +def check_list_and_queue_have_matching_context(state,ctx,payload,cmd_list, cmd_queue) unless cmd_queue && cmd_list && cmd_list.context == cmd_queue.context queue_handle = cmd_queue ? state.get_handle_str(cmd_queue.handle) : "nullptr" list_handle = cmd_list ? state.get_handle_str(cmd_list.handle) : "nullptr" @@ -358,21 +350,25 @@ def check_event_pool_immediate_list_context_match(state, ctx, cmd_list, op) #Find the memory allocation containing the ptr +#O(N) per lookup def find_allocation_containing(allocations, ptr) allocations.each_value.find { |m| m.base && m.base <= ptr && ptr < m.base + m.size } end -# Check whether the copy's endpoints have enough space to support the requested size +# Check whether the copy's endpoints have enough space to support the requested size +# Deduped so an append checked at entry is not reported again when it executes. def check_copy_endpoint_oob(state, ctx, allocations, ptr, size, api, role) return if ptr.nil? || ptr == 0 || size.nil? mem = allocations[ptr] || find_allocation_containing(allocations, ptr) return unless mem offset = ptr - mem.base available = mem.size - offset - if available < size - state.print_usage_error(ctx, "#{api}: #{role} memory #{state.get_handle_str(ptr)} only has #{available} " \ - "bytes available from this offset but the copy needs #{size} bytes") - end + return unless available < size + key = "oob-#{api}-#{role}-#{state.get_handle_str(ptr)}-#{size}" + return unless state.print_tracker[key] == 0 + state.print_tracker[key] = 1 + state.print_usage_error(ctx, "#{api}: #{role} memory #{state.get_handle_str(ptr)} only has #{available} " \ + "bytes available from this offset but the copy needs #{size} bytes") end # Performs the oob check for copy for both endpoints (src and dst) @@ -406,18 +402,12 @@ def find_freed_allocation_containing(freed, ptr) freed.each_value.find { |m| m.base && m.base <= ptr && ptr < m.base + m.size } end -# ADDED: use-after-free check for one endpoint (dst/src) of a copy/fill. If the -# pointer does NOT resolve to a live allocation but DOES fall inside an -# allocation that was already zeMemFree'd, report a use-after-free. A pointer -# that matches neither is left alone (unknown / untraced -- nothing to assert). +# Checks for use-after-free on an address def check_uaf_endpoint(state, ctx, live, freed, ptr, api, role) return if ptr.nil? || ptr == 0 - #still live (exact base or an offset within a live allocation) -> fine return if live[ptr] || find_allocation_containing(live, ptr) mem = freed[ptr] || find_freed_allocation_containing(freed, ptr) return unless mem - #dedup: the same freed pointer can be seen both at append-entry and again at - #deferred execute time -- report it once per (api, pointer). key = "uaf-#{api}-#{state.get_handle_str(ptr)}" return unless state.print_tracker[key] == 0 state.print_tracker[key] = 1 @@ -427,10 +417,9 @@ def check_uaf_endpoint(state, ctx, live, freed, ptr, api, role) "freed#{mem.freed_by ? " by #{mem.freed_by}" : ""}; use-after-free") end -# Performs the actual checks for when an API uses a memory that has been freed +# Checks for when an API uses a memory that has been freed def check_use_after_free(state, ctx, params) api = params[:api] || 'zeCommandListAppendMemoryCopy' - # CHANGED: resolve both maps within the copy's own context (see check_oob_copy). live = state.memory_allocations(ctx, params[:ctx_handle]) freed = state.freed_memory_allocations(ctx, params[:ctx_handle]) return if freed.empty? @@ -440,8 +429,17 @@ def check_use_after_free(state, ctx, params) # calls the check_use_after_free only if the wait events have been satisfied def check_use_after_free_on_append(state, ctx, params, waits) - return unless state.waits_satisfied?(ctx, waits) - check_use_after_free(state, ctx, params) + if state.waits_satisfied?(ctx, waits) + check_use_after_free(state, ctx, params) + end +end + +# Calls check_oob_copy at append time, so an append that crashes the driver (and +# so emits no _exit) is still checked. Gated on the waits like the uaf check. +def check_oob_copy_on_append(state, ctx, params, waits) + if state.waits_satisfied?(ctx, waits) + check_oob_copy(state, ctx, params) + end end # Checks for uaf on memory ranges barrier @@ -461,16 +459,9 @@ def ranges_overlap?(a, asize, b, bsize) a < b + bsize && b < a + asize end -# ADDED: free-while-in-flight check. Called from zeMemFree BEFORE the allocation -# is removed. If any copy/fill op still pending in an in-flight deferred command -# list references (overlaps) the allocation being freed, the device may still -# read/write it after the free -- report it. mem is the ZEModel::Memory about to -# be freed. +# Checks for whether memory was deleted during execution of a command list def check_free_in_flight(state, ctx, mem) return unless mem - # ADDED: the buffer being freed belongs to one context; only an in-flight copy - # in that SAME context can alias it. Comparing across contexts would be a false - # positive now that addresses may repeat between contexts. mem_ctx_handle = mem.context ? mem.context.handle : nil state.each_inflight_copy_op(ctx) do |unit, op| p = op.params @@ -486,38 +477,14 @@ def check_free_in_flight(state, ctx, mem) end end -def check_ptrs_have_same_context(state,ctx,params) - # CHANGED: resolve within the copy's context sub-map. NOTE: this is currently a - # stub (empty body) and unregistered. With allocations now keyed by context, - # both endpoints found in one sub-map necessarily share a context by - # construction, so a genuine cross-context-copy check would instead have to - # search every context's sub-map for each pointer -- left for when this is implemented. - allocations = state.memory_allocations(ctx, params[:ctx_handle]) - if allocations[params[:dst]] && allocations[params[:src]] && (allocations[params[:dst]].context != allocations[params[:src]].context) - - end -end -# ADDED: search a single context sub-map for the allocation matching ptr -- an -# exact base hit first, then an allocation whose [base, base+size) range contains -# ptr (offset copy). Returns the Memory or nil. +# Finds the memory object in the validator that matches the ptr, or the object that contains the ptr def find_memory_in_submap(submap, ptr) submap[ptr] || find_allocation_containing(submap, ptr) end -# ADDED: locate the KNOWN allocation for ptr across this process's per-context -# allocation sub-maps, preferring the command list's own context. Returns -# [memory, ctx_handle], or [nil, nil] if ptr matches no tracked allocation. -# Preferring the list's context keeps the check false-positive-free under address -# aliasing: L0 addresses are unique only within a context, so the same numeric -# address can exist in several contexts. If ptr resolves in the list's own context -# we return that (a correct, in-context copy) and stop; only if it resolves solely -# in a foreign context do we surface a mismatch. -# -# NOTE: IPC-imported memory is intentionally NOT handled here -- the validator does -# not model zeMemOpenIpcHandle, so such pointers are simply "not found" and skipped -# (no callback registers them). Only pointers we positively tracked are considered. -def find_known_memory_preferring_context(state, ctx, ptr, prefer_ctx_handle) +# Returns [memory, ctx_handle] for ptr, preferring the passed context (usually command list's context). +def find_known_memory(state, ctx, ptr, prefer_ctx_handle) return [nil, nil] if ptr.nil? || ptr == 0 all_maps = state.get_process(ctx).memory_allocations if prefer_ctx_handle && all_maps.key?(prefer_ctx_handle) @@ -532,19 +499,12 @@ def find_known_memory_preferring_context(state, ctx, ptr, prefer_ctx_handle) [nil, nil] end -# ADDED: report one endpoint (dst/src) of a copy/fill only when its pointer -# resolves to a KNOWN allocation on a DIFFERENT context than the command list. The -# spec requires the command list and the copied memory to share a context -# (zeCommandListAppendMemory{Copy,Fill}: "the command list and events were created, -# and the memory was allocated, on the same context"). No false positives: a ptr -# found in the list's own context is accepted, and a ptr found in NO tracked -# context -- system/malloc host memory, an untracked/IPC allocation, or memory from -# before tracing started -- is skipped (nothing is asserted about unknown pointers, -# mirroring the OOB/UAF checks). Deduped once per (command list, endpoint, pointer). +# Checks that one copy/fill endpoint was allocated on the command list's +# context. Untracked pointers are skipped; deduped per (list, endpoint, ptr). def check_ptr_endpoint_list_context(state, ctx, list_ctx_handle, list_handle, ptr, api, role) return if ptr.nil? || ptr == 0 - return if list_ctx_handle.nil? # unknown command list context (mid-stream) -> skip - mem, found_ctx = find_known_memory_preferring_context(state, ctx, ptr, list_ctx_handle) + return if list_ctx_handle.nil? # unknown command list context -> skip + mem, found_ctx = find_known_memory(state, ctx, ptr, list_ctx_handle) return unless mem # unknown pointer -> skip (no false alarm) return if found_ctx == list_ctx_handle # correctly in the list's context -> fine key = "ptr-list-ctx-#{state.get_handle_str(list_handle)}-#{role}-#{state.get_handle_str(ptr)}" @@ -557,11 +517,8 @@ def check_ptr_endpoint_list_context(state, ctx, list_ctx_handle, list_handle, pt "the command list and copied memory must share a context") end -# ADDED: append-entry check that a copy/fill's KNOWN memory endpoints were -# allocated on the same context as the command list. Runs at ENTRY (a cross-context -# copy can be rejected inside the append, which then emits no _exit event), reading -# the input pointers directly from defi. Context is a static property of the -# pointer and the list, so unlike OOB/UAF this needs no deferral to execute time. +# Checks a copy/fill's endpoints against the command list's context. Runs at +# entry: a cross-context copy can be rejected inside the append. def check_copy_ptr_list_context(state, ctx, api, list_handle, endpoints) list_ctx_handle = cmd_list_ctx_handle(state, ctx, list_handle) endpoints.each do |role, ptr| @@ -569,18 +526,11 @@ def check_copy_ptr_list_context(state, ctx, api, list_handle, endpoints) end end -# ADDED: detect misuse of an event that is signaled while already signaled, with -# no intervening reset. Mirrors the fence double-signal check. Two shapes: -# * reuse-no-reset -- the prior signal WAS observed by the host (e.g. it -# synchronized on the event) and the event is reused as a -# signal target without a reset first. -# * double-signal -- the prior signal was never observed; two signalers target -# the same event with no consumer between them. -# Called just before an op applies its own signal, in execution order, so any -# intervening reset/observe has already been recorded. `who` names the signaler. +# Checks for an event signaled while already signaled with no reset between: +# reuse-no-reset if the host observed the prior signal, double-signal if not. def check_event_signal_reuse(state, ctx, handle, who) ev = state.event_by_handle(ctx, handle) - return unless ev && ev.signaled? + return unless ev && ev.signaled if ev.observed state.print_usage_error(ctx, "event #{state.get_handle_str(handle)} was reused as a signal target by #{who} " \ "without calling zeEventHostReset/zeCommandListAppendEventReset after it was " \ @@ -592,47 +542,20 @@ def check_event_signal_reuse(state, ctx, handle, who) end end -# ADDED: report wait-events that were never signaled by end of trace -- a -# deferred op that could never complete (missing signal or deadlock). +# Reports wait-events never signaled by end of trace, i.e. a deferred op that +# could never complete. def report_unsignaled_waits(state, ctx, waits) (waits || []).each do |h| ev = state.event_by_handle(ctx, h) - next unless ev && !ev.signaled? + next unless ev && !ev.signaled state.print_usage_error(ctx, "event #{state.get_handle_str(h)} was never signaled; a deferred command list " \ "operation could not complete (possible deadlock or missing signal)") end end -# --------------------------------------------------------------------------- -# DEADLOCK DETECTION. -# -# GPU work is ordered by events, and it is easy to write an event graph that -# can never make progress. The classic shape: -# -# command list A: wait(evB) ... signal(evA) -# command list B: wait(evA) ... signal(evB) -# -# Neither can start, because each is waiting for something the other only -# produces after it has itself started. On real hardware this manifests as an -# application that simply hangs, with no error from the runtime. -# -# The validator sees this for free from its deferred-execution machinery: a -# DeferredUnit that is still parked when the trace ends could never proceed. -# The two functions below classify why: -# check_circular_deadlock - a cycle ACROSS units (the shape above) -# check_in_order_self_deadlock - a cycle WITHIN one in-order list -# --------------------------------------------------------------------------- - -# ADDED: detect a circular event dependency (deadlock) among the deferred units -# still stuck at end of trace, and report the FIRST cycle found. Builds a -# wait-for graph -- unit U points to unit V when U is blocked on an event that -# only V can still signal (it is in V's pending_signals) -- then searches for one -# cycle. A cycle means every unit on it waits for an event another unit on the -# cycle only signals after finishing, so none can ever start (e.g. clA waits -# evB/signals evA while clB waits evA/signals evB). -# -# Only the first cycle is reported: cycles overlap and share units, so emitting -# every one found would restate a single underlying bug many times over. + +# Checks for a circular event dependency across the units still stuck at end of +# trace, reporting the first cycle found since cycles overlap and share units. def check_circular_deadlock(state, units) stuck = units.select { |u| u.blocked_on && !u.blocked_on.empty? } return if stuck.empty? @@ -649,14 +572,8 @@ def check_circular_deadlock(state, units) end end - # DFS; stop at the first cycle and report only that one - # Textbook cycle detection by depth-first search with two marker sets: - # on_path - units on the current DFS branch. Reaching one again closes a - # cycle, and the cycle is the tail of `path` from that unit on. - # visited - units fully explored on some earlier branch; no need to redo - # them, which keeps this linear in the size of the graph. - # The lambda returns true once a cycle is found, so the recursion and the - # outer loop both unwind immediately. + # DFS; stop at the first cycle and report only that one. on_path holds the + # current branch (reaching one again closes a cycle), visited the rest. path = [] on_path = {} visited = {} @@ -684,15 +601,10 @@ def check_circular_deadlock(state, units) report_deadlock_cycle(state, found) if found end -# ADDED: report one deadlock cycle, naming each unit AND the specific command -# (op) it is stuck on -- e.g. "command_list 0x..::zeCommandListAppendMemoryCopy". -# The blocked command is the unit's current_op (the cursor is parked on it and -# blocked_on holds exactly that op's unsatisfied waits), so the chain reads -# :: -> :: -> ... back to the first. +# Labels one node of a deadlock cycle as "::". def deadlock_node_label(state, unit) op = unit.current_op - # op.api is set for every op that can carry waits (copy/launch/barrier/wait); - # fall back to the op kind for anything else so the label is never blank. + #fall back to the op kind so the label is never blank api = op ? (op.api || op.kind.to_s) : 'unknown' waits = unit.blocked_on.map { |h| state.get_handle_str(h) }.join(', ') "#{unit.label}::#{api} (waiting on event #{waits})" @@ -706,17 +618,8 @@ def report_deadlock_cycle(state, cycle) state.print_deadlock_error(ctx, "circular event dependency among command list operations; none can start: #{desc}") end -# ADDED: detect an intra-list deadlock in an IN-ORDER command list. Such a list -# runs its ops strictly in append order (op N+1 cannot start until op N -# completes), so if the op the unit is parked on waits on an event that only a -# LATER op in the SAME list will signal, that later op can never be reached -- -# the list deadlocks on itself. The cross-list detector cannot see this because -# it drops self-edges. Run at end-of-trace (flush): a unit still parked here was -# never rescued by an external host signal, so the wait is genuinely unmet. -# -# unit.pending_signals holds exactly the events signaled by ops at/after the -# cursor, so blocked_on & pending_signals = waits only a later op in this list -# owes -- the self-deadlock condition -- with no extra bookkeeping. +# Checks for an in-order list parked on an event only a later op in the same +# list signals. The cross-list detector misses this since it drops self-edges. def check_in_order_self_deadlock(state, units) units.each do |unit| next unless unit.in_order @@ -730,9 +633,7 @@ def check_in_order_self_deadlock(state, units) end end -# ADDED: report one intra-list self-deadlock in the op-level arrow format: -# :: (waits on event 0xE) -> -# :: (signals event 0xE later in the same in-order list) +# Reports one intra-list self-deadlock as -> . def report_in_order_self_deadlock(state, unit, ev, signaling_op) waiting = unit.current_op waiting_api = waiting ? (waiting.api || waiting.kind.to_s) : 'unknown' @@ -745,14 +646,9 @@ def report_in_order_self_deadlock(state, unit, ev, signaling_op) "command in the same list signals: #{desc}") end -# USAGE CHECK: every Level Zero descriptor struct begins with an `stype` field -# naming its own type (ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC and so on). Setting -# it wrong -- usually by copy-pasting the initialization of a different -# descriptor -- does not fail today: current drivers ignore it. But the field -# exists so the runtime can version and extend structs, so a wrong stype is a -# latent bug that a future driver is entitled to reject. Reported once per -# expected stype. -def check_struct_stype_misuse(state,ctx,defi,expected_stype, observed_stype) +# Checks a descriptor's stype. Current drivers ignore a wrong one, but it is a +# latent bug a future driver may reject. Reported once per expected stype. +def check_struct_stype_misuse(state,ctx,payload,expected_stype, observed_stype) if expected_stype != observed_stype && state.print_tracker[expected_stype] == 0 state.print_tracker[expected_stype] = 1 state.print_usage_error(ctx,"\nExpected stype of #{expected_stype}\nbut #{observed_stype} was observed.") diff --git a/backends/ze/ze_validator_function_entry_exit_callbacks.rb b/backends/ze/ze_validator_function_entry_exit_callbacks.rb index 69a53fe6..1f5320e7 100644 --- a/backends/ze/ze_validator_function_entry_exit_callbacks.rb +++ b/backends/ze/ze_validator_function_entry_exit_callbacks.rb @@ -8,64 +8,25 @@ $on_successful_exit = {} #called upon seeing exit functions with a successful return code $on_erroneous_exit = {} #called upon seeing exit functions with a non-successful return code -# --- Memory residency ------------------------------------------------------- -# Device allocations must be resident in GPU memory to be usable. These two APIs -# move them in and out explicitly; the model just tracks the flag. - -$on_successful_exit["zeContextEvictMemory"] = lambda{|state, ctx, defi| - mem_addr = state.find_param(ctx,"ptr") - # CHANGED: scope the lookup to the evicting context (hContext is a param of - # this API), so an address live in another context is not touched by mistake. - memory_allocations = state.memory_allocations(ctx, state.find_param(ctx, 'hContext')) - if memory_allocations[mem_addr] - mem = memory_allocations[mem_addr] - mem.resident = false - end -} - -#Check whether that memory is accessible by device? -$on_successful_exit["zeContextMakeMemoryResident"] = lambda {|state, ctx, defi| - mem_addr = state.find_param(ctx,"ptr") - # CHANGED: scope the lookup to this context (see zeContextEvictMemory). - memory_allocations = state.memory_allocations(ctx, state.find_param(ctx, 'hContext')) - if memory_allocations[mem_addr] - mem = memory_allocations[mem_addr] - mem.resident = true #Does the driver automatically evict memory if the virtual mem exceeds the physical mem? - end -} - - -# --- Device introspection --------------------------------------------------- -# These two record only that the application ASKED. The portability checks care -# about the question being asked at all: code that never queries the device but -# still passes ordinals and group indices is working from hardcoded assumptions -# that will not survive a hardware change. - -$on_successful_exit["zeDeviceGetProperties"] = lambda{|state, ctx, defi| +#these two record only that the app asked, for the portability checks +$on_successful_exit["zeDeviceGetProperties"] = lambda{|state, ctx, payload| device_ptr = state.find_param(ctx,'hDevice') devices = state.find_objects(ctx, 'device') devices[device_ptr].property_fetched = true } - -$on_successful_exit["zeDeviceGetCommandQueueGroupProperties"] = lambda{|state, ctx, defi| +# Mark that the queue group property was queried. +$on_successful_exit["zeDeviceGetCommandQueueGroupProperties"] = lambda{|state, ctx, payload| device_ptr = state.find_param(ctx,'hDevice') devices = state.find_objects(ctx, 'device') devices[device_ptr].cmd_queue_group_properties_queried = true } -# --- Kernel launches -------------------------------------------------------- -# Note the split that recurs for every append API below: VALIDATION at entry -# (because the call may crash), RECORDING at exit (because only a successful -# append will ever execute). - -# CHANGED: validation moved to ENTRY. Launching a kernel on a copy-only ordinal -# can crash the process, so the append may emit no _exit event -- checking at -# exit (the old code, which also had a `stat.find_param` typo that would NameError) -# would miss it. Entry callbacks may read input params directly from defi. -$upon_entry["zeCommandListAppendLaunchKernel"] = lambda { |state, ctx, defi| +# For every append API below: validation at entry (the call may crash), +# recording at exit (only a successful append will ever execute). +$upon_entry["zeCommandListAppendLaunchKernel"] = lambda { |state, ctx, payload| #Retrieve the compute ordinal from the command list command_lists = state.find_objects(ctx, 'command_list') - cmd_list = command_lists[defi['hCommandList']] + cmd_list = command_lists[payload['hCommandList']] cqg_ordinal = 0 #a normal list carries the ordinal in desc; an immediate list in altdesc if cmd_list && cmd_list.desc @@ -74,15 +35,13 @@ cqg_ordinal = cmd_list.altdesc[:ordinal] end #both checks must run even if the launch later aborts - check_valid_ordinal(state, ctx, defi, cqg_ordinal) - check_kernel_created(state, ctx, defi) + check_valid_ordinal(state, ctx, payload, cqg_ordinal) + check_kernel_created(state, ctx, payload) #the kernel's module must be on the same context as the command list - check_kernel_list_context_match(state, ctx, defi) + check_kernel_list_context_match(state, ctx, payload) } -# CHANGED: op-recording (for deferred execution ordering) stays at EXIT, since -# only a launch that successfully appended actually executes later on the queue. -$on_successful_exit["zeCommandListAppendLaunchKernel"] = lambda { |state, ctx, defi| +$on_successful_exit["zeCommandListAppendLaunchKernel"] = lambda { |state, ctx, payload| record_op(state, ctx, state.find_param(ctx, 'hCommandList'), ZEModel::RecordedOp.new(:launch, signal: state.find_param(ctx, 'hSignalEvent'), @@ -90,20 +49,13 @@ api: 'zeCommandListAppendLaunchKernel')) } -# ADDED: zeCommandListReset returns a command list to its initial, empty, -# appendable state so it can be reused without destroy+recreate. Misuse checks run -# at ENTRY (a reset can be rejected/crash without emitting an _exit -- e.g. on an -# immediate list) reading the input handle from defi. -$upon_entry["zeCommandListReset"] = lambda { |state, ctx, defi| - check_command_list_reset(state, ctx, defi) +$upon_entry["zeCommandListReset"] = lambda { |state, ctx, payload| + check_command_list_reset(state, ctx, payload) } -# ADDED: on success the list is empty and open again. Clear the recorded ops so a -# later close/execute replays only ops appended after the reset (in-flight -# executions from before are unaffected -- enqueue_deferred_execution snapshotted -# a dup of the ops at submit time), and return the status to INITIALIZED so the -# closed-before-execute check applies to the reused list. -$on_successful_exit["zeCommandListReset"] = lambda { |state, ctx, defi| +# on success the list is empty and open again, so clear the recorded ops. +# In-flight executions are unaffected: they snapshotted the ops at submit time. +$on_successful_exit["zeCommandListReset"] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') cmd_list = command_lists[state.find_param(ctx, 'hCommandList')] return unless cmd_list @@ -111,29 +63,23 @@ cmd_list.status = ZEModel::CommandList.class_variable_get(:@@INITIALIZED) } -#when command queue is executed, the associated fence's status is set to IN_USE -# Closing finalizes the list: no more appends, and it may now be submitted. -# check_command_list_closed later verifies this happened before any submission. -$on_successful_exit["zeCommandListClose"] = lambda { |state, ctx, defi| +#check_command_list_closed later verifies this happened before any submission +$on_successful_exit["zeCommandListClose"] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') command_list_handle = state.find_param(ctx,"hCommandList") cmd_list = command_lists[command_list_handle] cmd_list.status = ZEModel::CommandList.class_variable_get(:@@CLOSED) } -# CHANGED: validation at ENTRY (a cooperative launch can likewise abort without -# an exit). Also fixes the `stte` typo (undefined -> NameError) and nil-guards -# the command list. -$upon_entry["zeCommandListAppendLaunchCooperativeKernel"] = lambda { |state, ctx, defi| +$upon_entry["zeCommandListAppendLaunchCooperativeKernel"] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') - cmd_list = command_lists[defi['hCommandList']] - check_group_property_queued(state,ctx,defi,cmd_list.device) if cmd_list + cmd_list = command_lists[payload['hCommandList']] + check_group_property_queued(state,ctx,payload,cmd_list.device) if cmd_list #the kernel's module must be on the same context as the command list - check_kernel_list_context_match(state, ctx, defi) + check_kernel_list_context_match(state, ctx, payload) } -# CHANGED: recording at EXIT. -$on_successful_exit["zeCommandListAppendLaunchCooperativeKernel"] = lambda { |state, ctx, defi| +$on_successful_exit["zeCommandListAppendLaunchCooperativeKernel"] = lambda { |state, ctx, payload| record_op(state, ctx, state.find_param(ctx, 'hCommandList'), ZEModel::RecordedOp.new(:launch, signal: state.find_param(ctx, 'hSignalEvent'), @@ -141,60 +87,50 @@ api: 'zeCommandListAppendLaunchCooperativeKernel')) } -# ---- ADDED: copy / event ops recorded onto the command list for deferred replay -# Each records the op in list order. For copies the out-of-bounds check is -# deferred until the op's wait-events are satisfied (see the scheduler in -# ze_validator_state_object.rb), so it runs against the memory state at the point -# the copy actually executes rather than at append or execute time. Exit -# callbacks read input params via find_param (defi holds only exit output). - -# ADDED: use-after-free check at ENTRY. A copy/fill whose pointer was already -# freed can crash the driver inside the append, so no _exit event is emitted and -# an exit-only check would miss it (the trace shows only the entry, then a crash). -# Entry callbacks read input params directly from defi. This runs the same UAF -# check against the freed registry before the (possibly fatal) append. -$upon_entry['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, defi| +# Copy/event ops are recorded in list order for deferred replay. The +# out-of-bounds check waits until the op's wait-events are satisfied. +$upon_entry['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, payload| + params = { api: 'zeCommandListAppendMemoryCopy', + ctx_handle: cmd_list_ctx_handle(state, ctx, payload['hCommandList']), + dst: payload['dstptr'], src: payload['srcptr'], size: payload['size'] } + waits = wait_event_handles(state, ctx) check_null_copy_ptr(state, ctx, 'zeCommandListAppendMemoryCopy', - { 'destination' => defi['dstptr'], 'source' => defi['srcptr'] }) - check_use_after_free_on_append(state, ctx, - { api: 'zeCommandListAppendMemoryCopy', - ctx_handle: cmd_list_ctx_handle(state, ctx, defi['hCommandList']), - dst: defi['dstptr'], src: defi['srcptr'], size: defi['size'] }, - wait_event_handles(state, ctx)) + { 'destination' => payload['dstptr'], 'source' => payload['srcptr'] }) + check_use_after_free_on_append(state, ctx, params, waits) + #an out-of-bounds copy can crash the driver, which emits no _exit + check_oob_copy_on_append(state, ctx, params, waits) # known memory endpoints must be allocated on the command list's context - check_copy_ptr_list_context(state, ctx, 'zeCommandListAppendMemoryCopy', defi['hCommandList'], - { 'destination' => defi['dstptr'], 'source' => defi['srcptr'] }) + check_copy_ptr_list_context(state, ctx, 'zeCommandListAppendMemoryCopy', payload['hCommandList'], + { 'destination' => payload['dstptr'], 'source' => payload['srcptr'] }) } -$upon_entry['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, defi| +$upon_entry['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, payload| + params = { api: 'zeCommandListAppendMemoryFill', + ctx_handle: cmd_list_ctx_handle(state, ctx, payload['hCommandList']), + dst: payload['ptr'], src: nil, size: payload['size'] } + waits = wait_event_handles(state, ctx) check_null_copy_ptr(state, ctx, 'zeCommandListAppendMemoryFill', - { 'destination' => defi['ptr'] }) - check_use_after_free_on_append(state, ctx, - { api: 'zeCommandListAppendMemoryFill', - ctx_handle: cmd_list_ctx_handle(state, ctx, defi['hCommandList']), - dst: defi['ptr'], src: nil, size: defi['size'] }, - wait_event_handles(state, ctx)) + { 'destination' => payload['ptr'] }) + check_use_after_free_on_append(state, ctx, params, waits) + #an out-of-bounds fill can crash the driver, which emits no _exit + check_oob_copy_on_append(state, ctx, params, waits) # known memory endpoint must be allocated on the command list's context - check_copy_ptr_list_context(state, ctx, 'zeCommandListAppendMemoryFill', defi['hCommandList'], - { 'destination' => defi['ptr'] }) + check_copy_ptr_list_context(state, ctx, 'zeCommandListAppendMemoryFill', payload['hCommandList'], + { 'destination' => payload['ptr'] }) } -$on_successful_exit['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, defi| +$on_successful_exit['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, payload| record_copy_op(state, ctx, 'zeCommandListAppendMemoryCopy', 'dstptr', 'srcptr') } -$on_successful_exit['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, defi| +$on_successful_exit['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, payload| #a fill only touches the destination; model it as a copy with no source record_copy_op(state, ctx, 'zeCommandListAppendMemoryFill', 'ptr', nil) } -# ADDED: a copy append that FAILED is never recorded and never executed, so the -# deferred check would never see it. But a copy whose size exceeds its -# destination/source allocation is out-of-bounds regardless of the error code -- -# and a failing append is exactly where the driver rejects such a copy (e.g. -# ZE_RESULT_ERROR_OUT_OF_DEVICE_MEMORY). Check it now, against the current memory -# state (the pointers are already allocated at append time). -$on_erroneous_exit['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, defi| +# A failed append is never recorded, so the deferred check would never see it, +# but the copy is out-of-bounds regardless of the error code. Check it here. +$on_erroneous_exit['zeCommandListAppendMemoryCopy'] = lambda { |state, ctx, payload| params = { api: 'zeCommandListAppendMemoryCopy', ctx_handle: cmd_list_ctx_handle(state, ctx, state.find_param(ctx, 'hCommandList')), dst: state.find_param(ctx, 'dstptr'), @@ -204,7 +140,7 @@ check_use_after_free(state, ctx, params) } -$on_erroneous_exit['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, defi| +$on_erroneous_exit['zeCommandListAppendMemoryFill'] = lambda { |state, ctx, payload| params = { api: 'zeCommandListAppendMemoryFill', ctx_handle: cmd_list_ctx_handle(state, ctx, state.find_param(ctx, 'hCommandList')), dst: state.find_param(ctx, 'ptr'), @@ -214,9 +150,9 @@ check_use_after_free(state, ctx, params) } -$on_successful_exit['zeCommandListAppendMemoryCopyRegion'] = lambda { |state, ctx, defi| - #region copies carry 2D/3D extents, so `size` is not a flat byte count; we only - #record ordering + event effects and skip the flat OOB comparison for now +# region copies carry 2D/3D extents, so `size` is not a flat byte count; we only +# record ordering + event effects and skip the flat OOB comparison +$on_successful_exit['zeCommandListAppendMemoryCopyRegion'] = lambda { |state, ctx, payload| record_op(state, ctx, state.find_param(ctx, 'hCommandList'), ZEModel::RecordedOp.new(:launch, signal: state.find_param(ctx, 'hSignalEvent'), @@ -225,27 +161,27 @@ } #A device-side signal: the event is signaled when this op executes (after waits). -$on_successful_exit['zeCommandListAppendSignalEvent'] = lambda { |state, ctx, defi| +$on_successful_exit['zeCommandListAppendSignalEvent'] = lambda { |state, ctx, payload| record_op(state, ctx, state.find_param(ctx, 'hCommandList'), ZEModel::RecordedOp.new(:signal, signal: state.find_param(ctx, 'hEvent'), api: 'zeCommandListAppendSignalEvent')) } #A device-side wait: this op blocks the list until phEvents are signaled. -$on_successful_exit['zeCommandListAppendWaitOnEvents'] = lambda { |state, ctx, defi| +$on_successful_exit['zeCommandListAppendWaitOnEvents'] = lambda { |state, ctx, payload| record_op(state, ctx, state.find_param(ctx, 'hCommandList'), ZEModel::RecordedOp.new(:wait, waits: wait_event_handles(state, ctx), api: 'zeCommandListAppendWaitOnEvents')) } #A device-side reset: returns the event to unsignaled when this op executes. -$on_successful_exit['zeCommandListAppendEventReset'] = lambda { |state, ctx, defi| +$on_successful_exit['zeCommandListAppendEventReset'] = lambda { |state, ctx, payload| record_op(state, ctx, state.find_param(ctx, 'hCommandList'), ZEModel::RecordedOp.new(:reset, params: { reset_handle: state.find_param(ctx, 'hEvent') })) } #A barrier waits on its events and signals its completion event. -$on_successful_exit['zeCommandListAppendBarrier'] = lambda { |state, ctx, defi| +$on_successful_exit['zeCommandListAppendBarrier'] = lambda { |state, ctx, payload| record_op(state, ctx, state.find_param(ctx, 'hCommandList'), ZEModel::RecordedOp.new(:barrier, signal: state.find_param(ctx, 'hSignalEvent'), @@ -253,83 +189,62 @@ api: 'zeCommandListAppendBarrier')) } -# ADDED: a memory-ranges barrier has the same event semantics as a plain barrier -# (waits on its events, signals its completion event), so record it in the op -# stream for the deferred scheduler and deadlock detection. It additionally names -# memory ranges whose coherency it guarantees; those ranges are snapshotted and -# validated against the allocation model when the barrier executes (see -# record_ranges_barrier_op / check_uaf_ranges_barrier). -$on_successful_exit['zeCommandListAppendMemoryRangesBarrier'] = lambda { |state, ctx, defi| +# Same event semantics as a plain barrier, plus the memory ranges it names, +# which are validated when the barrier executes (check_uaf_ranges_barrier). +$on_successful_exit['zeCommandListAppendMemoryRangesBarrier'] = lambda { |state, ctx, payload| record_ranges_barrier_op(state, ctx) } -# ADDED: host-side event operations, effective immediately (in trace order). -#Signaling an already-signaled event without a reset is the same misuse we catch -#on device ops. -$on_successful_exit['zeEventHostSignal'] = lambda { |state, ctx, defi| +#host-side event operations, effective immediately in trace order +$on_successful_exit['zeEventHostSignal'] = lambda { |state, ctx, payload| handle = state.find_param(ctx, 'hEvent') check_event_signal_reuse(state, ctx, handle, 'zeEventHostSignal') state.signal_event(ctx, handle, 'zeEventHostSignal') } -$on_successful_exit['zeEventHostReset'] = lambda { |state, ctx, defi| +$on_successful_exit['zeEventHostReset'] = lambda { |state, ctx, payload| state.reset_event(ctx, state.find_param(ctx, 'hEvent')) } -#The host waited until the event was signaled and observed it. This does NOT -#signal the event; it records that the signaled state was consumed, so a later -#signal without a reset reads as reuse-without-reset, not a double-signal. -$on_successful_exit['zeEventHostSynchronize'] = lambda { |state, ctx, defi| +#does not signal the event, only records that the signaled state was consumed +$on_successful_exit['zeEventHostSynchronize'] = lambda { |state, ctx, payload| state.observe_event(ctx, state.find_param(ctx, 'hEvent')) } #A successful status query also observes the signaled state. -$on_successful_exit['zeEventQueryStatus'] = lambda { |state, ctx, defi| +$on_successful_exit['zeEventQueryStatus'] = lambda { |state, ctx, payload| state.observe_event(ctx, state.find_param(ctx, 'hEvent')) } -# ADDED: device-wide host synchronization points. The host waited for all -#submitted work, so every currently-signaled event has been consumed. -#zeCommandQueueSynchronize covers regular queues; zeCommandListHostSynchronize is -#the immediate-command-list analogue (an immediate list is its own implicit -#queue) -- giving immediate lists the same event-observation semantics. -$on_successful_exit['zeCommandQueueSynchronize'] = lambda { |state, ctx, defi| +#the host waited for all submitted work, so every signaled event was consumed +$on_successful_exit['zeCommandQueueSynchronize'] = lambda { |state, ctx, payload| state.observe_all_signaled_events(ctx) } -$on_successful_exit['zeCommandListHostSynchronize'] = lambda { |state, ctx, defi| +$on_successful_exit['zeCommandListHostSynchronize'] = lambda { |state, ctx, payload| state.observe_all_signaled_events(ctx) } -# --- Submission ------------------------------------------------------------- -# zeCommandQueueExecuteCommandLists is the busiest callback in the file, because -# submission is where most of the "these objects must belong together" rules -# finally become checkable: the queue, the lists, their events, and the fence -# are only brought into contact here. -# -# It is also ASYNCHRONOUS -- it returns as soon as the work is queued, long -# before the GPU runs it. Hence the split: validation at entry (below), and at -# exit the lists become deferred units to be replayed as their events fire. -$upon_entry["zeCommandQueueExecuteCommandLists"] = lambda { |state, ctx, defi| +# Submission is where the queue, the lists, their events and the fence are +# first brought together, so most "must belong together" rules are checked here. +$upon_entry["zeCommandQueueExecuteCommandLists"] = lambda { |state, ctx, payload| command_queues = state.find_objects(ctx, 'command_queue') - command_queue_handle = defi['hCommandQueue'] + command_queue_handle = payload['hCommandQueue'] command_queue = command_queues[command_queue_handle] #check if any command list is null - check_valid_command_lists(state,ctx,defi) - check_valid_command_queue(state,ctx,defi,command_queues,command_queue_handle) + check_valid_command_lists(state,ctx,payload) + check_valid_command_queue(state,ctx,payload,command_queues,command_queue_handle) #Check if command list was closed before executing it on the queue #ignore if it is the first execute call - check_command_list_closed(state, ctx, defi) - check_fence_misuse(state,ctx,defi) + check_command_list_closed(state, ctx, payload) + check_fence_misuse(state,ctx,payload) known_command_lists = state.find_objects(ctx, 'command_list') - # CHANGED: was `state.find_objects(ctx, 'phCommandLists_vals')` -- that is not - # an object store; the submitted list handles are the array payload itself. - command_list_handles = defi['phCommandLists_vals'] || [] + command_list_handles = payload['phCommandLists_vals'] || [] fences = state.find_objects(ctx, 'fence') - fence_handle = defi['hFence'] + fence_handle = payload['hFence'] fence = fences[fence_handle] if fence @@ -337,29 +252,25 @@ end if command_queue - check_group_property_queued(state,ctx,defi,command_queue.device) - check_fence_and_queue_compatibility(state,ctx,defi,command_queue,fence) + check_group_property_queued(state,ctx,payload,command_queue.device) + check_fence_and_queue_compatibility(state,ctx,payload,command_queue,fence) command_list_handles.each do |command_list_handle| - check_list_and_queue_have_matching_context(state,ctx,defi,known_command_lists[command_list_handle],command_queue) - check_list_and_fence_have_matching_context(state,ctx,defi,known_command_lists[command_list_handle],fence) - # ADDED: a list with a compute kernel launch must not go to a copy-only queue + check_list_and_queue_have_matching_context(state,ctx,payload,known_command_lists[command_list_handle],command_queue) + check_list_and_fence_have_matching_context(state,ctx,payload,known_command_lists[command_list_handle],fence) + #a list with a compute kernel launch must not go to a copy-only queue check_copy_only_queue_submission(state,ctx,command_queue,known_command_lists[command_list_handle]) - # ADDED: events used by the list must come from an event pool on the queue's context + #events used by the list must come from a pool on the queue's context check_event_pool_list_context_match(state,ctx,known_command_lists[command_list_handle]) end else - # CHANGED: was raise_internal_error, which aborted the whole validator on one - # unknown queue handle (common if tracing started after zeCommandQueueCreate). - # Report and continue so the deferred execution below still runs. + #report and continue so the deferred execution below still runs state.print_usage_error(ctx, "command queue #{state.get_handle_str(command_queue_handle)} was not found ") end } -# CHANGED: execute is asynchronous. On success, the submitted lists' recorded ops -# become deferred execution units (each list its own unit). Their memory copies -# are checked for out-of-bounds when their wait-events are signaled, not here (see -# the scheduler in ze_validator_state_object.rb). Previously this was empty. -$on_successful_exit["zeCommandQueueExecuteCommandLists"] = lambda { |state, ctx, defi| +# Execute is asynchronous, so each submitted list becomes a deferred unit and +# its copies are checked when their wait-events are signaled, not here. +$on_successful_exit["zeCommandQueueExecuteCommandLists"] = lambda { |state, ctx, payload| known_command_lists = state.find_objects(ctx, 'command_list') command_list_handles = state.find_param(ctx, 'phCommandLists_vals') || [] command_lists = command_list_handles.map { |h| known_command_lists[h] } @@ -367,9 +278,7 @@ } #When a fence signals the host, set the fence's status to signaled -$on_successful_exit["zeFenceHostSynchronize"] = lambda { |state, ctx, defi| - # CHANGED: was `get_fence(state,ctx,defi)` -- get_fence expects a handle, not - # defi. Read the handle from the entry params and pass it. +$on_successful_exit["zeFenceHostSynchronize"] = lambda { |state, ctx, payload| fence_handle = state.find_param(ctx,"hFence") fence = get_fence(state, ctx, fence_handle) if fence @@ -381,52 +290,29 @@ #should a double reset be considered as a usage error? #Also, a fence can be shared throughout the threads and is modeled correctly (if you are wondering about whether the model treats fence associated with different thread-id differently). -$upon_entry["zeFenceReset"] = lambda { |state, ctx, defi| - # CHANGED: was ZEModel::Fence.get_fence(...) and @@INITIALIZED -- Fence has no - # such class method or class variable (that method was removed from the model; - # @@INITIALIZED belongs to CommandList). get_fence is a top-level helper, and a - # reset fence returns to the not_signaled instance state. This is an entry - # callback, so input params are available directly in defi. - curr_fence = get_fence(state, ctx, defi['hFence']) +$upon_entry["zeFenceReset"] = lambda { |state, ctx, payload| + curr_fence = get_fence(state, ctx, payload['hFence']) return unless curr_fence curr_fence.status = curr_fence.not_signaled } -# ============================================================================ -# OBJECT LIFECYCLE CALLBACKS. -# -# The remainder of the file is largely mechanical and follows one pattern per -# object type: -# -# CREATE -> build the ZEModel object, file it in the process table AND in -# its parent's table, and validate the descriptor's stype -# DESTROY -> remove it from both tables, and report any children that should -# have been destroyed first -# -# The two-table bookkeeping is what makes leak reporting possible: the process -# table drives the end-of-trace sweep, while the parent's table lets a destroy -# notice that, say, an event pool still holds live events. -# -# The `{ ... }` block passed to Hash#delete is Ruby's "key not found" handler -- -# it fires when a destroy names a handle the model never recorded. -# ============================================================================ +# Object lifecycle callbacks. Create files the object in the process table and +# its parent's; destroy removes both and reports children still alive. #Set the driver for the current context -# zeDriverGet reports all installed drivers at once, so this registers each -# handle it returns (skipping any already known -- the app may call it twice). -$on_successful_exit['zeDriverGet'] = lambda { |state, ctx, defi| +$on_successful_exit['zeDriverGet'] = lambda { |state, ctx, payload| drivers = state.get_process(ctx).drivers - defi['phDrivers_vals'].each { |h| + payload['phDrivers_vals'].each { |h| drivers[h] = ZEModel::Driver.new(h) unless drivers[h] } } -#Set device -$on_successful_exit['zeDeviceGet'] = lambda { |state, ctx, defi| +#Create device objects +$on_successful_exit['zeDeviceGet'] = lambda { |state, ctx, payload| devices = state.find_objects(ctx, 'device') driver = state.find_object(ctx, 'driver', 'hDriver') if driver - defi['phDevices_vals'].each { |h| + payload['phDevices_vals'].each { |h| unless devices[h] devices[h] = ZEModel::Device.new(h) driver.devices.push devices[h] @@ -436,11 +322,11 @@ } - -$on_successful_exit['zeDeviceGetSubDevices'] = lambda { |state, ctx, defi| +#Create subdevice objects, with device as a parent +$on_successful_exit['zeDeviceGetSubDevices'] = lambda { |state, ctx, payload| devices = state.find_objects(ctx, 'device') device = state.find_object(ctx, 'device', 'hDevice') - defi['phSubdevices_vals'].each { |h| + payload['phSubdevices_vals'].each { |h| unless devices[h] devices[h] = ZEModel::SubDevice.new(h, device) device.sub_devices.push devices[h] @@ -448,17 +334,19 @@ } } -$on_successful_exit['zeContextCreate'] = lambda { |state, ctx, defi| +#Create ze context objects +$on_successful_exit['zeContextCreate'] = lambda { |state, ctx, payload| contexts = state.find_objects(ctx, 'context') driver = state.find_object(ctx, 'driver', 'hDriver') desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZEContextDesc) - handle = defi['phContext_val'] + handle = payload['phContext_val'] contexts[handle] = ZEModel::Context.new(handle, driver, desc) - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_CONTEXT_DESC,desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_CONTEXT_DESC,desc[:stype]) } -$on_successful_exit['zeContextCreateEx'] = lambda { |state, ctx, defi| +#Experimental API, it practically serves the same purpose as zeContextCreate +$on_successful_exit['zeContextCreateEx'] = lambda { |state, ctx, payload| contexts = state.find_objects(ctx, 'context') devices = state.find_objects(ctx, 'device') driver = state.find_object(ctx, 'driver', 'hDriver') @@ -466,18 +354,20 @@ desc = state.to_struct(desc_val, ZE::ZEContextDesc) devs = state.find_param(ctx, 'phDevices_vals').collect { |h| devices[h] } devs = nil unless state.find_param(ctx, 'phDevices') != 0 - handle = defi['phContext_val'] + handle = payload['phContext_val'] contexts[handle] = ZEModel::Context.new(handle, driver, desc, devs) } -$on_successful_exit['zeContextDestroy'] = lambda { |state, ctx, defi| +# Releases the corresponding ze context object +$on_successful_exit['zeContextDestroy'] = lambda { |state, ctx, payload| contexts = state.find_objects(ctx, 'context') contexts.delete(state.find_param(ctx, 'hContext')) { |h| raise_internal_error(ctx, "context #{state.get_handle_str(h)} does not exist") } } -$on_successful_exit['zeEventPoolCreate'] = lambda { |state, ctx, defi| +# Creates an event pool +$on_successful_exit['zeEventPoolCreate'] = lambda { |state, ctx, payload| context = state.find_object(ctx, 'context', 'hContext') devices = state.find_objects(ctx, 'device') event_pools = state.find_objects(ctx, 'event_pool') @@ -485,15 +375,15 @@ desc = state.to_struct(desc_val, ZE::ZEEventPoolDesc) devs = state.find_param(ctx, 'phDevices_vals').collect { |h| devices[h] } devs = nil unless state.find_param(ctx, 'phDevices') != 0 - handle = defi['phEventPool_val'] + handle = payload['phEventPool_val'] event_pools[handle] = ZEModel::EventPool.new(handle, context, desc, devs) context.event_pools[handle] = event_pools[handle] - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_EVENT_POOL_DESC,desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_EVENT_POOL_DESC,desc[:stype]) } -# Destroying a pool while events carved out of it are still alive leaves those -# events dangling, so each survivor is reported. -$on_successful_exit['zeEventPoolDestroy'] = lambda { |state, ctx, defi| +# Destroys the given event pool +# Destroying a pool while events carved out of it are still alive leads to leaks +$on_successful_exit['zeEventPoolDestroy'] = lambda { |state, ctx, payload| event_pools = state.find_objects(ctx, 'event_pool') handle = state.find_param(ctx, 'hEventPool') event_pool = event_pools.delete(handle) { @@ -507,26 +397,25 @@ } } -# Events are carved out of a pool's fixed set of slots: desc[:index] picks one. -# Set#delete? returns nil when the index was not free, which means two live -# events claim the same slot -- they would then alias each other's signal state. -$on_successful_exit['zeEventCreate'] = lambda { |state, ctx, defi| + +#Create an event +$on_successful_exit['zeEventCreate'] = lambda { |state, ctx, payload| events = state.find_objects(ctx, 'event') event_pool = state.find_object(ctx, 'event_pool', 'hEventPool') desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZEEventDesc) - handle = defi['phEvent_val'] + handle = payload['phEvent_val'] events[handle] = ZEModel::Event.new(handle, event_pool, desc) + # "delete?" returns nil when desc[:index] was not free. if !event_pool.indices.delete?(desc[:index]) state.print_usage_error(ctx, "event_pool #{state.get_handle_str(event_pool.handle)} index #{desc[:index]} is already used") end event_pool.events[handle] = events[handle] - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_EVENT_DESC,desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_EVENT_DESC,desc[:stype]) } -# Releasing an event returns its slot to the pool. Set#add? returning nil means -# the slot was already free -- a double destroy of the same index. -$on_successful_exit['zeEventDestroy'] = lambda { |state, ctx, defi| +# Destroys an event +$on_successful_exit['zeEventDestroy'] = lambda { |state, ctx, payload| events = state.find_objects(ctx, 'event') handle = state.find_param(ctx, 'hEvent') event = events.delete(handle) { @@ -541,29 +430,30 @@ end } -$on_successful_exit['zeCommandQueueCreate'] = lambda { |state, ctx, defi| +# Creates a command queue +$on_successful_exit['zeCommandQueueCreate'] = lambda { |state, ctx, payload| command_queues = state.find_objects(ctx, 'command_queue') context = state.find_object(ctx, 'context', 'hContext') device = state.find_object(ctx, 'device', 'hDevice') desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZECommandQueueDesc) - handle = defi['phCommandQueue_val'] + handle = payload['phCommandQueue_val'] command_queues[handle] = ZEModel::CommandQueue.new(handle, context, device, desc) context.command_queues[handle] = command_queues[handle] - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC,desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC,desc[:stype]) } -# A queue creation that FAILED is the likely symptom of an (ordinal, index) pair -# the device does not have, so this is the moment to check the index against the -# real topology and explain the failure. -$on_erroneous_exit['zeCommandQueueCreate'] = lambda { |state, ctx, defi| +# A failed creation is likely an (ordinal, index) the device does not have, so +# check the index against the real topology to explain the failure. +$on_erroneous_exit['zeCommandQueueCreate'] = lambda { |state, ctx, payload| desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZECommandQueueDesc) handle = state.find_param(ctx, 'phCommandQueue') check_valid_index_for_ordinal(state,ctx,handle,desc[:ordinal],desc[:index]) } -$on_successful_exit['zeCommandQueueDestroy'] = lambda { |state, ctx, defi| +# Destroys the given command queue +$on_successful_exit['zeCommandQueueDestroy'] = lambda { |state, ctx, payload| command_queues = state.find_objects(ctx, 'command_queue') handle = state.find_param(ctx, 'hCommandQueue') command_queue = command_queues.delete(handle) { @@ -577,19 +467,21 @@ } } -$on_successful_exit['zeFenceCreate'] = lambda { |state, ctx, defi| +# Creates a fence +$on_successful_exit['zeFenceCreate'] = lambda { |state, ctx, payload| fences = state.find_objects(ctx, 'fence') command_queue = state.find_object(ctx, 'command_queue', 'hCommandQueue') desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZEFenceDesc) - handle = defi['phFence_val'] + handle = payload['phFence_val'] fence = ZEModel::Fence.new(handle, command_queue, desc) fences[handle] = fence command_queue.fences[handle] = fence - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_FENCE_DESC,desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_FENCE_DESC,desc[:stype]) } -$on_successful_exit['zeFenceDestroy'] = lambda { |state, ctx, defi| +# Destroys the fence +$on_successful_exit['zeFenceDestroy'] = lambda { |state, ctx, payload| fences = state.find_objects(ctx, 'fence') handle = state.find_param(ctx, 'hFence') fence = fences.delete(handle) { @@ -601,49 +493,46 @@ } } -$on_successful_exit['zeCommandListCreate'] = lambda { |state, ctx, defi| +# Creates the command list +$on_successful_exit['zeCommandListCreate'] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') context = state.find_object(ctx, 'context', 'hContext') device = state.find_object(ctx, 'device', 'hDevice') desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZECommandListDesc) - handle = defi['phCommandList_val'] + handle = payload['phCommandList_val'] command_lists[handle] = ZEModel::CommandList.new(handle, context, device, desc, nil) - # ADDED: remember whether this list is in-order. desc[:flags] decodes (via the - # FFI zebitmask) to an array of symbols; IN_ORDER means appended ops run strictly - # in order, enabling the intra-list self-deadlock check. + #in-order enables the intra-list self-deadlock check command_lists[handle].in_order = !!(desc && desc[:flags].respond_to?(:include?) && desc[:flags].include?(:ZE_COMMAND_LIST_FLAG_IN_ORDER)) context.command_lists[handle] = command_lists[handle] - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_COMMAND_LIST_DESC,desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_COMMAND_LIST_DESC,desc[:stype]) } -$on_successful_exit['zeCommandListCreateImmediate'] = lambda { |state, ctx, defi| +# Creates an immediate command list +$on_successful_exit['zeCommandListCreateImmediate'] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') context = state.find_object(ctx, 'context', 'hContext') device = state.find_object(ctx, 'device', 'hDevice') altdesc_val = state.find_param(ctx, 'altdesc_val') altdesc = state.to_struct(altdesc_val, ZE::ZECommandQueueDesc) - handle = defi['phCommandList_val'] - check_group_property_queued(state,ctx,defi,device) + handle = payload['phCommandList_val'] + check_group_property_queued(state,ctx,payload,device) command_lists[handle] = ZEModel::CommandList.new(handle, context, device, nil, altdesc) command_lists[handle].immediate = true #immdediate command lists cannot be passed to the execute command lists command_lists[handle].associated_ordinal = altdesc[:ordinal] - # ADDED: immediate lists carry the queue desc (altdesc); its IN_ORDER flag is the - # queue-level one. Immediate appends still CAN deadlock among themselves (e.g. - # op1 waits A/signals B while op2 waits B/signals A) -- but each append is its - # own single-op DeferredUnit, so such a cycle is a CROSS-unit cycle already - # caught by check_circular_deadlock, not the single-unit case - # check_in_order_self_deadlock handles. Recorded here for consistency. + #each immediate append is its own single-op unit, so a cycle between them is + #caught by check_circular_deadlock; recorded here for consistency command_lists[handle].in_order = !!(altdesc && altdesc[:flags].respond_to?(:include?) && altdesc[:flags].include?(:ZE_COMMAND_QUEUE_FLAG_IN_ORDER)) context.command_lists[handle] = command_lists[handle] #immediate command list does not take in the list descriptor as an input - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC,altdesc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC,altdesc[:stype]) } -$on_successful_exit['zeCommandListDestroy'] = lambda { |state, ctx, defi| +# Destroys the command list +$on_successful_exit['zeCommandListDestroy'] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') handle = state.find_param(ctx, 'hCommandList') command_list = command_lists.delete(handle) { @@ -654,17 +543,18 @@ } } -$on_successful_exit['zeModuleCreate'] = lambda { |state, ctx, defi| +# Destroys the module +$on_successful_exit['zeModuleCreate'] = lambda { |state, ctx, payload| modules = state.find_objects(ctx, 'module') context = state.find_object(ctx, 'context', 'hContext') device = state.find_object(ctx, 'device', 'hDevice') desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZEModuleDesc) - handle = defi['phModule_val'] + handle = payload['phModule_val'] mod = ZEModel::Module.new(handle, context, device, desc) modules[handle] = mod context.modules[handle] = mod - build_log_handle = defi['phBuildLog_val'] + build_log_handle = payload['phBuildLog_val'] if build_log_handle != 0 module_build_logs = state.find_objects(ctx, 'module_build_log') build_log = ZEModel::Module::BuildLog.new(build_log_handle, mod) @@ -672,13 +562,14 @@ context.module_build_logs[build_log_handle] = build_log modules[handle].build_log = build_log end - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_MODULE_DESC,desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_MODULE_DESC,desc[:stype]) } -$on_erroneous_exit['zeModuleCreate'] = lambda { |state, ctx, defi| - build_log_handle = defi['phBuildLog_val'] +# Runs diagnoistics on why the module crete failed +$on_erroneous_exit['zeModuleCreate'] = lambda { |state, ctx, payload| + build_log_handle = payload['phBuildLog_val'] if build_log_handle != 0 module_build_logs = state.find_objects(ctx, 'module_build_log') build_log = ZEModel::Module::BuildLog.new(build_log_handle) @@ -687,7 +578,8 @@ end } -$on_successful_exit['zeModuleDestroy'] = lambda { |state, ctx, defi| +# Destroys the module. Kernel must be destroyed first. +$on_successful_exit['zeModuleDestroy'] = lambda { |state, ctx, payload| modules = state.find_objects(ctx, 'module') handle = state.find_param(ctx, 'hModule') mod = modules.delete(handle) { @@ -701,8 +593,9 @@ } } -$on_erroneous_exit['zeModuleDynamicLink'] = $on_successful_exit['zeModuleDynamicLink'] = lambda { |state, ctx, defi| - build_log_handle = defi['phLinkLog_val'] +# Appends build log at dynamic link +$on_erroneous_exit['zeModuleDynamicLink'] = $on_successful_exit['zeModuleDynamicLink'] = lambda { |state, ctx, payload| + build_log_handle = payload['phLinkLog_val'] if build_log_handle != 0 module_build_logs = state.find_objects(ctx, 'module_build_log') build_log = ZEModel::Module::BuildLog.new(build_log_handle) @@ -711,7 +604,8 @@ end } -$on_successful_exit['zeModuleBuildLogDestroy'] = lambda { |state, ctx, defi| +# Destroys the build log +$on_successful_exit['zeModuleBuildLogDestroy'] = lambda { |state, ctx, payload| module_build_logs = state.find_objects(ctx, 'module_build_log') handle = state.find_param(ctx, 'hModuleBuildLog') module_build_log = module_build_logs.delete(handle) { @@ -725,24 +619,27 @@ end } -$upon_entry['zeKernelCreate'] = lambda {|state, ctx, defi| - check_valid_module(state,ctx, defi) +# upon entering, check if a valid module was passed +$upon_entry['zeKernelCreate'] = lambda {|state, ctx, payload| + check_valid_module(state,ctx, payload) } -$on_successful_exit['zeKernelCreate'] = lambda { |state, ctx, defi| +# Creates the kernel object +$on_successful_exit['zeKernelCreate'] = lambda { |state, ctx, payload| kernels = state.find_objects(ctx, 'kernel') mod = state.find_object(ctx, 'module', 'hModule') desc_val = state.find_param(ctx, 'desc_val') desc = state.to_struct(desc_val, ZE::ZEKernelDesc) - handle = defi['phKernel_val'] + handle = payload['phKernel_val'] kernelName = state.find_param(ctx, 'desc__pKernelName_val') kernel = ZEModel::Kernel.new(handle, mod, desc, kernelName) kernels[handle] = kernel mod.kernels[handle] = kernel - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_KERNEL_DESC, desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_KERNEL_DESC, desc[:stype]) } -$on_successful_exit['zeKernelDestroy'] = lambda { |state, ctx, defi| +# Destroys the kernel +$on_successful_exit['zeKernelDestroy'] = lambda { |state, ctx, payload| kernels = state.find_objects(ctx, 'kernel') handle = state.find_param(ctx, 'hKernel') kernel = kernels.delete(handle) { @@ -754,90 +651,63 @@ } } -# REMOVED: a second, broken $on_successful_exit['zeCommandListAppendMemoryCopy'] -# used to live here. It called add_api_call_to_cmd_list (undefined locals) and, -# being defined later, would have overridden the recorder above. The single -# recorder near the other append callbacks now handles this API. - -# ============================================================================ -# MEMORY ALLOCATION AND FREEING. -# -# Three allocation flavors, one free. Each allocator does the same four things: -# 1. resolve the Level Zero context -- allocations are keyed by it, since an -# address is only guaranteed unique WITHIN a context -# 2. call mark_reallocated, because the driver may hand back an address that -# was freed earlier; without this the stale freed-record would make the -# fresh allocation look like a dangling pointer -# 3. build the Memory object and file it in the process's per-context map -# (and, for device memory, on the owning Device as well) -# 4. validate the descriptor's stype -# ============================================================================ - -#The implementation of this (zeMemAllocDevice) function must be thread-safe -$on_successful_exit['zeMemAllocDevice'] = lambda { |state, ctx, defi| +# Each allocator keys the allocation by its Level Zero context, then calls +# mark_reallocated since the driver may hand back an address that was freed. + +# Allocs device memory +$on_successful_exit['zeMemAllocDevice'] = lambda { |state, ctx, payload| # memory is associated with devices - ctx_handle = state.find_param(ctx, 'hContext') # ADDED: key allocations by context + ctx_handle = state.find_param(ctx, 'hContext') memory_allocations = state.memory_allocations(ctx, ctx_handle) context = state.find_object(ctx, 'context', 'hContext') device = state.find_object(ctx, 'device','hDevice') size = state.find_param(ctx,"size") device_desc_val = state.find_param(ctx,"device_desc_val") - handle = defi['pptr_val'] - mark_reallocated(state, ctx, ctx_handle, handle, size) # ADDED: address may reuse a freed range in this context + handle = payload['pptr_val'] + mark_reallocated(state, ctx, ctx_handle, handle, size) memory_allocation = ZEModel::Memory.new(handle, context, size, device, "device") memory_allocations[handle] = memory_allocation - device.memory_allocations[ctx_handle][handle] = memory_allocation # CHANGED: per-context device sub-map + device.memory_allocations[ctx_handle][handle] = memory_allocation device_desc = state.to_struct(device_desc_val, ZE::ZEDeviceMemAllocDesc) - check_struct_stype_misuse(state,ctx,defi,:ZE_STRUCTURE_TYPE_DEVICE_MEM_ALLOC_DESC, device_desc[:stype]) + check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_DEVICE_MEM_ALLOC_DESC, device_desc[:stype]) } - -$on_successful_exit['zeMemAllocShared'] = lambda { |state, ctx, defi| - ctx_handle = state.find_param(ctx, 'hContext') # ADDED: key allocations by context +# Allocs shared memory +$on_successful_exit['zeMemAllocShared'] = lambda { |state, ctx, payload| + ctx_handle = state.find_param(ctx, 'hContext') memory_allocations = state.memory_allocations(ctx, ctx_handle) # finds the device and context objects associated with the params context = state.find_object(ctx, 'context', 'hContext') device = state.find_object(ctx, 'device','hDevice') - # Passing nullptr as the device handle does not associate the shared allocation with any device. - # For allocations with no associated device, ownership of the allocation is shared between the - # host and all devices supporting cross-device shared access capabilities. - # TODO: should add in code to add this mme allocation to all devices with that property + # A nullptr device handle shares ownership between the host and all devices + # supporting cross-device shared access. size = state.find_param(ctx,"size") - handle = defi['pptr_val'] - mark_reallocated(state, ctx, ctx_handle, handle, size) # ADDED: address may reuse a freed range in this context + handle = payload['pptr_val'] + mark_reallocated(state, ctx, ctx_handle, handle, size) memory_allocation = ZEModel::Memory.new(handle, context, size, device) memory_allocations[handle] = memory_allocation - device.memory_allocations[ctx_handle][handle] = memory_allocation if device # CHANGED: per-context device sub-map + device.memory_allocations[ctx_handle][handle] = memory_allocation if device } -$on_successful_exit['zeMemAllocHost'] = lambda { |state, ctx, defi| +# Allocs Host memory +$on_successful_exit['zeMemAllocHost'] = lambda { |state, ctx, payload| # Host allocations are accessible by the host and all devices within the driver’s context. - # TODO: add this memory allocation to all devices in the context - ctx_handle = state.find_param(ctx, 'hContext') # ADDED: key allocations by context + ctx_handle = state.find_param(ctx, 'hContext') memory_allocations = state.memory_allocations(ctx, ctx_handle) context = state.find_object(ctx, 'context', 'hContext') size = state.find_param(ctx,"size") - handle = defi['pptr_val'] - mark_reallocated(state, ctx, ctx_handle, handle, size) # ADDED: address may reuse a freed range in this context + handle = payload['pptr_val'] + mark_reallocated(state, ctx, ctx_handle, handle, size) memory_allocation = ZEModel::Memory.new(handle, context, size, nil, "host") memory_allocations[handle] = memory_allocation } -# CHANGED: apply the free at ENTRY, not exit. On the program timeline the app -# relinquishes the buffer at the call to zeMemFree; nothing after that call may -# touch it. Applying the free at _exit is wrong when zeMemFree BLOCKS until the -# buffer is idle: a gated copy that reads the buffer can be released (by another -# thread signaling its wait event) and executed BETWEEN this call's _entry and -# _exit, so at _exit the copy has already drained (nothing looks in-flight) and, -# while the copy ran, the model had not yet marked the buffer freed (no UAF). -# Doing it at entry lets check_free_in_flight see the still-parked copy, and -# marks the buffer freed before that copy is later replayed, so the deferred UAF -# check fires too. If the free actually fails, the erroneous-exit handler below -# restores the allocation. -$upon_entry['zeMemFree'] = lambda { |state, ctx, defi| - ctx_handle = defi['hContext'] # ADDED: allocations are keyed by freeing context +# The free is applied at entry: zeMemFree may block until the buffer is idle, so +# by _exit a gated copy could have drained and the in-flight check would miss it. +$upon_entry['zeMemFree'] = lambda { |state, ctx, payload| + ctx_handle = payload['hContext'] memory_allocations = state.memory_allocations(ctx, ctx_handle) - handle = defi['ptr'] + handle = payload['ptr'] memory_allocation = memory_allocations[handle] next unless memory_allocation # flag if this buffer is still referenced by a copy/fill that has been @@ -845,24 +715,22 @@ check_free_in_flight(state, ctx, memory_allocation) memory_allocations.delete(handle) owned = memory_allocation.owned_by - owned.memory_allocations[ctx_handle].delete(handle) if owned # CHANGED: per-context device sub-map + owned.memory_allocations[ctx_handle].delete(handle) if owned # keep the freed allocation in this context's freed registry so a later # copy/fill/kernel referencing this address is caught as use-after-free memory_allocation.freed_by = state.get_api_context(ctx) state.freed_memory_allocations(ctx, ctx_handle)[handle] = memory_allocation } -# ADDED: the free was applied at entry; if the driver reported failure, the -# buffer is actually still alive -- move it back from the freed registry to the -# live set so it is not falsely flagged as use-after-free later. -$on_erroneous_exit['zeMemFree'] = lambda { |state, ctx, defi| - ctx_handle = state.find_param(ctx, 'hContext') # ADDED: same context the entry freed under +# the free was applied at entry, so restore the allocation if it actually failed +$on_erroneous_exit['zeMemFree'] = lambda { |state, ctx, payload| + ctx_handle = state.find_param(ctx, 'hContext') handle = state.find_param(ctx, "ptr") mem = state.freed_memory_allocations(ctx, ctx_handle).delete(handle) if mem mem.freed_by = nil state.memory_allocations(ctx, ctx_handle)[handle] = mem owned = mem.owned_by - owned.memory_allocations[ctx_handle][handle] = mem if owned # CHANGED: per-context device sub-map + owned.memory_allocations[ctx_handle][handle] = mem if owned end } \ No newline at end of file diff --git a/backends/ze/ze_validator_state_object.rb b/backends/ze/ze_validator_state_object.rb index 8a5296ba..b5f265ec 100644 --- a/backends/ze/ze_validator_state_object.rb +++ b/backends/ze/ze_validator_state_object.rb @@ -7,99 +7,28 @@ require 'yaml' require 'json' -# ============================================================================= -# StateObject -- the validator's engine. -# -# One instance exists for an entire run. It plays three roles: -# -# 1. THE BABELTRACE SINK. #consume returns the lambda the trace graph calls -# with each batch of decoded events. That lambda is the entry point for -# everything below. -# -# 2. THE MODEL OWNER. @state holds the whole ZEModel tree -# (hostname -> Node -> pid -> Process -> objects). Every find_* / get_* -# helper here is a path into that tree, and the callbacks in -# ze_validator_function_entry_exit_callbacks.rb reach the model only -# through these methods. -# -# 3. THE REPORTER AND SCHEDULER. All print_* methods (the only place -# diagnostics are emitted) live here, as does the deferred-execution -# scheduler that replays asynchronous GPU work at the right moment. -# -# THE LIFE OF ONE TRACE EVENT -# --------------------------- -# consume -# -> match name against /:(z.*)_(entry|exit)/ -> api name + entry|exit -# -> build `context` = {hostname, vpid, vtid, api}, the "who/where" tuple -# threaded through literally every method in the validator -# -> check_initialization (was zeInit called first?) -# -> print_deprecation_warning -# -> on_entry / on_exit -# - lock/unlock shared objects for the thread-safety check -# - run the API's callback -# - push/pop the thread's call stack -# -> pump_deferred (a signal may have unblocked pending GPU work) -# ...and once the trace is exhausted, ze_validator.in calls #check_issues. -# -# TWO VOCABULARY NOTES THAT UNLOCK THE REST OF THE CODE -# ----------------------------------------------------- -# `context` (lowercase, often `ctx`) -- the TRACE context hash above. It says -# which host/process/thread/API is currently executing. It is NOT a -# Level Zero context. -# `ctx_handle` / ZEModel::Context -- the LEVEL ZERO context, the isolation -# domain objects belong to. -# The two are unrelated and appear side by side constantly; keeping them -# distinct is the single biggest hurdle when reading this file. -# -# `defi` -- the decoded event payload ("definition"). At _entry it holds the -# call's input arguments; at _exit it holds the return code and output -# values. This asymmetry is why exit callbacks call find_param (which -# reads the saved _entry payload) to get at inputs. -# ============================================================================= class StateObject - # hostname -> ZEModel::Node. The root of the entire model. attr_reader :state - # Parsed ze_thread_safety.yaml: api name -> objects that must not be touched - # concurrently during that call. attr_reader :ze_thread_safety - # Callback lists built from the YAML above, run around every traced call. attr_reader :lock_shared_object_on_entry attr_reader :unlock_shared_object_on_exit - # DEDUPLICATION TABLE. A GPU program is a loop: the same mistake in the same - # line will recur thousands of times and would bury the report. Checks build a - # key describing the specific violation, print only when its counter is 0, and - # then set it to 1. Seeing `state.print_tracker[key] == 0` guarding a - # print_usage_error is this idiom, and it is used all over the check library. attr_accessor :print_tracker - attr_accessor :device_agnostic # --disable-dagn turns this off - attr_accessor :performance # --disable-performance turns this off + attr_accessor :device_agnostic + attr_accessor :performance attr_accessor :memory_in_transit - # Real command-queue-group topology of this machine, or nil when unavailable. attr_reader :device_properties + def initialize(**opts) - # api name -> [deprecated_since_version, replacement_api]. Loaded from a - # data file so new deprecations need no code change. @deprecated = JSON.parse(File.read(File.join(DATADIR, 'ze_deprecated.json'))) - # Per-device command queue group topology (ordinal -> engine type + numQueues), - # generated on a real device by the ze_device_property helper binary and - # installed alongside the other data files. Loaded if present; validation that - # does not depend on it still runs when the file is absent. @device_properties = load_device_properties - #for supressing redundant error outputs - # Any key not yet seen reads as 0, so checks can test-and-set without - # initializing keys first. @print_tracker = Hash.new { |h, k| h[k] = 0 } - # Append a third slot to every entry: "has this warning been printed?". @deprecated.each do |api, (version, replacement)| @deprecated[api] = [version, replacement, false] end @performance = opts[:performance] @device_agnostic = opts[:device_agnostic] - # The model root. Nodes, and in turn processes and threads, spring into - # existence the first time an event mentions them -- so no discovery pass is - # needed before consuming the trace. @state = Hash.new { |h, k| h[k] = ZEModel::Node.new(k) } @ze_thread_safety = YAML::load_file(File.join(DATADIR, 'ze_thread_safety.yaml')) @lock_shared_object_on_entry = Hash.new { |h, k| h[k] = [] } @@ -107,43 +36,15 @@ def initialize(**opts) @init_called = Hash.new { |h, k| h[k] = false } #pid : init called status @memory_in_transit = Hash.new {|h,k| h[k] = []} #pid : [[mem, (src|dst)]] list of memories being transferred @printed_init_error = false - # zeCommandQueueExecuteCommandLists is asynchronous, so we do not check a - # list's memory copies at execute time (the destination may only be allocated - # later by another unit that then signals a wait-event). Instead each - # submitted list becomes a ZEModel::DeferredUnit here and is advanced by - # pump_deferred as events get signaled -- NO Ruby threads/fibers; it is a - # plain cursor-based worklist driven by the single trace-consumption loop. @deferred_units = [] - # ADDED: bumped whenever an event transitions to signaled, so pump_deferred - # knows something may have become unblocked and is worth another sweep. @signal_epoch = 0 - # ------------------------------------------------------------------ - # Build the thread-safety callbacks from ze_thread_safety.yaml. - # - # The YAML maps each API to the objects it must hold exclusively, as - # [param_name, object_type] pairs -- e.g. for zeCommandListAppendBarrier, - # ['hCommandList', 'command_list'] means "the command list named by the - # hCommandList argument". So `o.first` is the parameter name to read the - # handle from, and `o.last` is which model table to look it up in. - # - # For every such pair we synthesize two lambdas: one that locks the object - # when the call starts, one that unlocks it when the call returns. Because - # trace events are timestamp-ordered, finding an object already locked means - # two calls genuinely overlapped -- see ZEModel::Object#lock. - # - # A parameter may name an ARRAY of handles (e.g. a list of command lists), - # hence the kind_of? Array branch in each lambda. - # ------------------------------------------------------------------ @ze_thread_safety.each { |api, objects| objects.each { |o| - @lock_shared_object_on_entry[api].push( lambda { |state, ctx, defi| - #at entry the input args are in defi directly - handle = defi[o.first] + @lock_shared_object_on_entry[api].push( lambda { |state, ctx, payload| + #at entry the input args are in payload directly + handle = payload[o.first] if handle.kind_of? Array handle.each { |h| - # CHANGED: nil-guard -- find_object may - # return nil for an unknown handle (e.g. - # tracing started mid-stream); do not crash obj = state.find_object(ctx, o.last, h) obj.lock(state, ctx) if obj } @@ -152,13 +53,12 @@ def initialize(**opts) obj.lock(state, ctx) if obj end }) - @unlock_shared_object_on_exit[api].push( lambda { |state, ctx, defi| - #at exit defi holds only outputs, so the input + @unlock_shared_object_on_exit[api].push( lambda { |state, ctx, payload| + #at exit payload holds only outputs, so the input #handle comes from the saved entry payload handle = state.find_param(ctx, o.first) if handle.kind_of? Array handle.each { |h| - # CHANGED: nil-guard as above obj = state.find_object(ctx, o.last, h) obj.unlock(ctx) if obj } @@ -172,8 +72,8 @@ def initialize(**opts) end - # Reads ze_device_property.json from DATADIR. Returns the parsed hash, or nil - # if the file is missing or unparseable so the validator degrades gracefully. + # Returns the parsed ze_device_property.json, or nil if it is missing or + # unparseable so the validator degrades gracefully. def load_device_properties path = File.join(DATADIR, 'ze_device_property.json') return nil unless File.file?(path) @@ -183,9 +83,8 @@ def load_device_properties nil end - # Look up a command queue group by ordinal. Without a device index it returns - # the matching group from the first device (sufficient for homogeneous nodes). - # Returns a hash like {"ordinal"=>1, "type"=>"copy", "numQueues"=>8} or nil. + # Returns a map e.g.,{"ordinal"=>1, "type"=>"copy", "numQueues"=>8} or nil. Without a + # device index it uses the first device. def command_queue_group(ordinal, device_index: nil) return nil unless @device_properties devices = @device_properties['devices'] || [] @@ -198,13 +97,6 @@ def command_queue_group(ordinal, device_index: nil) end - # -------------------------------------------------------------------------- - # Model navigation. Each of these walks the trace context down the model tree - # (hostname -> Node -> pid -> Process -> tid -> Thread). Every level - # auto-creates on first access, so these never return nil for a new - # host/process/thread. - # -------------------------------------------------------------------------- - # The innermost API call currently executing on this thread, or nil. def get_last_entry(context) @state[context['hostname']].processes[context['vpid']].threads[context['vtid']].last_entry @@ -218,10 +110,8 @@ def get_process(context) @state[context['hostname']].processes[context['vpid']] end - # Sanity check run at every _exit: the call we are returning from must be the - # one on top of this thread's stack. A mismatch means the model has lost sync - # with the trace (missing or reordered events), so it aborts loudly rather - # than producing nonsense diagnostics from a corrupt state. + # Checks that the call we return from is on top of this thread's stack. A + # mismatch means the model lost sync with the trace, so it aborts. def check_last_entry(context) last_entry = get_last_entry(context) unless last_entry && last_entry.name == context['api'] @@ -230,37 +120,29 @@ def check_last_entry(context) end - # CHANGED: push a new call frame instead of overwriting a single slot, so a - # traced API that calls another traced API on the same thread nests correctly. - def set_last_entry(state, context, defi) - get_thread(context).call_stack.push(ZEModel::ApiCall.new(context['api'], defi)) + # Pushes a call frame, so a traced API calling another traced API on the same + # thread nests correctly. + def set_last_entry(state, context, payload) + get_thread(context).call_stack.push(ZEModel::ApiCall.new(context['api'], payload)) end - # CHANGED: pop the innermost frame on return, exposing the caller's frame (if - # any) rather than clearing everything. + # Pops the innermost frame on return, exposing the caller's frame. def reset_last_entry(context) get_thread(context).call_stack.pop end - # Did the call succeed? The exit payload carries the raw integer return code; - # the generated FFI enum turns it back into a symbol to compare against - # ZE_RESULT_SUCCESS. This decides whether on_exit runs the success or the - # error callback for the API. - def validate_result(defi) - ZE::ZEResult.from_native(defi["zeResult"], nil) == :ZE_RESULT_SUCCESS + # Decides whether on_exit runs the success or the error callback. + def validate_result(payload) + ZE::ZEResult.from_native(payload["zeResult"], nil) == :ZE_RESULT_SUCCESS end - # -------------------------------------------------------------------------- - # Message formatting. Handles are printed zero-padded to 16 hex digits so - # columns line up and the same object is textually identical everywhere -- - # which also makes these strings safe to use as print_tracker dedup keys. - # -------------------------------------------------------------------------- + # Zero-padded so the same object reads identically everywhere, which also + # makes these strings safe as print_tracker dedup keys. def get_handle_str(handle) '0x%016x' % handle end - # "hostname - pid": the right granularity for process-wide findings such as - # leaks and deadlocks, which no single API call is responsible for. + # "hostname - pid", for process-wide findings such as leaks and deadlocks. def get_proc_context_str(context) "#{context['hostname']} - #{context['vpid']}" end @@ -270,14 +152,12 @@ def get_api_context(context) "#{context['vtid']} in #{context['api']}" end - # Fully qualified "hostname - pid - tid in zeSomeApi", used for findings - # attributable to one call. + # "hostname - pid - tid in zeSomeApi", for findings attributable to one call. def get_context_str(context) "#{get_proc_context_str(context)} - #{get_api_context(context)}" end - # Warn once per deprecated API actually used. The third slot of the - # @deprecated entry is the already-printed flag. + # Warns once per deprecated API actually used. def print_deprecation_warning(old_api) if @deprecated.include?(old_api) and @deprecated[old_api][2] deprecated_since = @deprecated[old_api][0] @@ -290,21 +170,6 @@ def print_deprecation_warning(old_api) end end - # -------------------------------------------------------------------------- - # DIAGNOSTIC CHANNELS. Every finding the validator reports goes through one of - # these; nothing else prints. Each writes to stderr with a distinct - # "Level Zero :" prefix so a report can be grepped or filtered by - # severity class. Findings are printed as they are discovered, so their order - # follows the trace timeline. - # - # Portability Error - works here, may break on other/newer hardware - # Performance Issue - correct but slow API usage - # Usage Error - violates the Level Zero specification - # Crash Error - a call that never returned (no _exit event in trace) - # Memory Error - use-after-free, freeing memory still in use - # Deadlock - circular event dependency; work can never proceed - # Leak - object created but never destroyed - # -------------------------------------------------------------------------- def print_portability_error(context,str) $stderr.puts "Level Zero Portability Error: on #{get_context_str(context)}: #{str}\n\n" end @@ -319,24 +184,15 @@ def print_crash_error(context, str) $stderr.puts "Level Zero Crash Error: on #{get_context_str(context)}: #{str}\n\n" end - # ADDED: reporting channel for memory-safety violations (use-after-free, - # freeing memory still in use by in-flight device work). def print_memory_error(context, str) $stderr.puts "Level Zero Memory Error: on #{get_context_str(context)}: #{str}\n\n" end - # ADDED: reporting channel for circular event dependency (deadlock). Uses the - # process-level context because a deadlock spans multiple command lists/threads - # rather than a single api call. def print_deadlock_error(context, str) $stderr.puts "Level Zero Deadlock: on #{get_proc_context_str(context)}: #{str}\n\n" end - - # Reported from check_issues at end of trace for every object still alive. - # memtypestr distinguishes device/host/shared for memory allocations; it is - # empty for handle-based objects. def print_leak_error(context, type, handle, memtypestr="") if memtypestr.empty? $stderr.puts "Level Zero Leak: on #{get_proc_context_str(context)}: #{type} #{get_handle_str(handle)}\n\n" @@ -345,16 +201,13 @@ def print_leak_error(context, type, handle, memtypestr="") end end - # NOT a finding about the traced program -- this aborts the validator itself. - # Used when the model reaches a state that should be impossible (e.g. deleting - # an object that was never created), which means the validator's own - # bookkeeping is wrong and any further output would be untrustworthy. + # Not a finding about the traced program: the validator's own bookkeeping is + # wrong, so further output would be untrustworthy. def raise_internal_error(context, str) raise "Invalid state #{get_context_str(context)}: #{str}" end - # Reported from ZEModel::Object#lock when two calls hold the same object at - # once. Deduped per (object, other holder) so a racing loop reports once. + # Deduped per (object, other holder) so a racing loop reports once. def print_race_condition(context, other_context, type, handle) if @print_tracker["#{type}-#{get_handle_str(handle)}-#{get_api_context(other_context)}"] == 0 @print_tracker["#{type}-#{get_handle_str(handle)}-#{get_api_context(other_context)}"] = 1 @@ -362,62 +215,41 @@ def print_race_condition(context, other_context, type, handle) end end - # Passed as the block to Hash#delete by the destroy callbacks, so it fires - # when a destroy names a handle the model never recorded. `sub_context` says - # which secondary table was being cleaned up (e.g. the owning context's list). + # Passed as the block to Hash#delete, so it fires when a destroy names a + # handle the model never recorded. def object_not_found(context, type, handle, sub_context = nil) raise_internal_error(context, "event_pool #{get_handle_str(handle)} not found#{sub_context ? " in #{sub_context}" : ""}") end - # -------------------------------------------------------------------------- - # Lookup helpers used constantly by the callbacks and checks. - # -------------------------------------------------------------------------- - - # Read one INPUT argument of the call currently executing on this thread. This - # works at _exit too -- the exit payload has only outputs, but the entry - # payload is still on the thread's call stack, which is exactly what this - # reaches into. + # Reads one input argument of the call executing on this thread. Works at + # _exit too, since the entry payload is still on the call stack. def find_param(context, name) get_last_entry(context).params[name] end - # The whole handle->object table for a type, e.g. find_objects(ctx, - # 'command_list') returns the process's @command_lists. + # The whole handle -> object table for a type. def find_objects(context, type) get_process(context).instance_variable_get("@#{type}s") end - # Resolve a single object. `handle` may be either the integer handle itself - # or, as a convenience, the NAME of the parameter carrying it -- so - # find_object(ctx, 'context', 'hContext') reads hContext from the current call - # and looks the resulting handle up in one step. + # `handle` may be the handle itself or the name of the param carrying it. def find_object(context, type, handle) handle = find_param(context, handle) if handle.kind_of? String find_objects(context, type)[handle] end - # ADDED: the live-allocation sub-map for one Level Zero context (address -> - # Memory). Allocations are keyed by context handle because the L0 unified - # virtual address space only guarantees non-aliasing addresses within a - # context (see Process#memory_allocations). ctx_handle nil falls back to a - # single shared bucket so a trace that started mid-stream -- where the owning - # context is unknown -- still tracks something rather than crashing. + # The live allocations of one Level Zero context (address -> Memory). def memory_allocations(context, ctx_handle) get_process(context).memory_allocations[ctx_handle] end - # ADDED: per-context registry of allocations that have been zeMemFree'd but - # kept for use-after-free detection (address -> freed ZEModel::Memory). - # CHANGED: now scoped by context handle, mirroring the live map above. + # The freed allocations of one Level Zero context, for use-after-free checks. def freed_memory_allocations(context, ctx_handle) get_process(context).freed_memory_allocations[ctx_handle] end - # ADDED: yield [unit, op] for every copy/fill op still pending (at or after the - # cursor) in an in-flight deferred unit belonging to this process. Used by the - # free-in-flight check to see whether a buffer being freed is still referenced - # by device work that has been submitted but not yet completed. Deferred units - # do not carry a process id, so we match on the unit's context host+pid. + # Yields [unit, op] for every copy op still pending in this process, so a free + # can tell whether the buffer is still referenced by submitted work. def each_inflight_copy_op(context) @deferred_units.each do |unit| next unless unit.context['hostname'] == context['hostname'] && @@ -429,13 +261,7 @@ def each_inflight_copy_op(context) end end - # ADDED: true if command list `handle` still has an in-flight deferred - # execution in this process -- a prior zeCommandQueueExecuteCommandLists whose - # ops have not all drained yet. Used by zeCommandListReset, which must not run - # while the list is still executing (undefined behavior in Level Zero). Matches - # on the unit's originating list handle and the process it belongs to (deferred - # units carry no pid, so we compare context host+pid, mirroring - # each_inflight_copy_op). + # True if a prior submission of this command list has not drained yet. def command_list_in_flight?(context, handle) @deferred_units.any? do |unit| unit.cmd_list_handle == handle && @@ -445,42 +271,19 @@ def command_list_in_flight?(context, handle) end end - # Decode a raw descriptor blob from the trace into a typed FFI struct. - # The tracer captures structs like ze_command_queue_desc_t as opaque bytes; - # this copies them into native memory and reinterprets them as `klass` (from - # the generated ze_library bindings) so callbacks can read desc[:ordinal], - # desc[:stype], and so on. Returns nil for an empty blob (a null descriptor - # pointer), which every caller must be prepared for. + # Decodes a raw descriptor blob from the trace into a typed FFI struct, or nil + # for a null descriptor. def to_struct(memory, klass) memory.size > 0 ? klass.new(FFI::MemoryPointer.from_string(memory)) : nil end - # ============================================================================ - # ADDED: Event semantics + non-concurrent deferred-execution scheduler. - # - # zeCommandQueueExecuteCommandLists is asynchronous. Checking a list's memory - # copies against the memory model at execute time gives false positives, - # because a copy's destination may only be allocated by another unit that - # signals a wait-event later. So each submitted command list is turned into a - # ZEModel::DeferredUnit and its ops are replayed only as their wait-events - # actually become signaled. - # - # There are NO Ruby threads or fibers. Each unit keeps an integer cursor into - # its op list. pump_deferred repeatedly sweeps all units, advancing any unit - # whose current op has all wait-events satisfied, and loops until a full sweep - # makes no progress. A unit left parked on an unsatisfied op is simply waiting - # for a future event (which arrives as later trace events are consumed). - # ============================================================================ - - # ADDED: look up an Event model object by raw handle. nil for a null/unknown - # handle (nothing to track). + # Returns the Event for a handle, nil for a null or unknown one. def event_by_handle(context, handle) return nil if handle.nil? || handle == 0 find_objects(context, 'event')[handle] end - # ADDED: signal an event and note progress so pump_deferred re-sweeps. `by` - # records who signaled it, for diagnostics. + # Signals an event and notes progress so pump_deferred sweeps again. def signal_event(context, handle, by = nil) ev = event_by_handle(context, handle) if ev @@ -489,38 +292,30 @@ def signal_event(context, handle, by = nil) end ev end - - # ADDED: return an event to the unsignaled state. + + # Reset's the given handle's event def reset_event(context, handle) event_by_handle(context, handle)&.reset end - # ADDED: record that the host observed an event's signaled state. + # Records that the host observed an event's signaled state. def observe_event(context, handle) event_by_handle(context, handle)&.observe end - # ADDED: a device-wide host synchronization (zeCommandQueueSynchronize, or for - # immediate lists zeCommandListHostSynchronize) means the host waited for all - # submitted work -- so every currently-signaled event has been consumed. Mark - # them observed so a later signal without a reset reads as reuse-without-reset - # rather than a concurrent double-signal. + # A device-wide host synchronization means every signaled event was consumed. def observe_all_signaled_events(context) - find_objects(context, 'event').each_value { |ev| ev.observe if ev.signaled? } + find_objects(context, 'event').each_value { |ev| ev.observe if ev.signaled } end - # ADDED: true once every wait handle is signaled (or is null/unknown, which we - # treat as satisfied: we cannot track it, and any unit may signal an event, so - # we must not invent a deadlock). + # True once every wait handle is signaled. Untracked handles count as + # satisfied, so we never invent a deadlock for one. def waits_satisfied?(context, waits) return true if waits.nil? || waits.empty? - waits.all? { |h| ev = event_by_handle(context, h); ev.nil? || ev.signaled? } + waits.all? { |h| ev = event_by_handle(context, h); ev.nil? || ev.signaled } end - # ADDED: execute one op of a unit (the op is known to be runnable). Runs the - # deferred checks, then applies the op's reset/signal side effects, and - # advances the cursor. Returns true if it signaled an event (progress that may - # unblock other units). + # Runs the op the cursor points at, applying its deferred checks and signal. def run_deferred_op(unit) context = unit.context op = unit.current_op @@ -547,20 +342,8 @@ def run_deferred_op(unit) signaled end - # ADDED: advance every deferred unit as far as its wait-events allow. Sweeps - # repeatedly until a whole pass makes no progress (completed an op or signaled - # an event), then drops finished units. Units still parked on an unmet wait - # stay queued for a future event or the end-of-trace flush. - # - # Why a repeat-until-quiet loop rather than one pass: advancing unit A can - # signal an event that unblocks unit B, which may already have been visited - # earlier in the same sweep. Iterating until a full pass changes nothing - # reaches the fixed point regardless of the order units happen to sit in. - # Termination is guaranteed because every unit of progress advances some - # cursor, and cursors only move forward over finite op lists. - # - # Called after every trace event (see #consume), so deferred work advances in - # lockstep with the real program's event signals. + # Advances every unit as far as its wait-events allow, sweeping until a whole + # pass makes no progress since one unit's signal can unblock another. def pump_deferred progress = true while progress @@ -575,7 +358,7 @@ def pump_deferred #park the unit on this op and record what it is blocked on so the #deadlock detector can see the wait-for edges unit.blocked_on = op.waits.reject { |h| - ev = event_by_handle(unit.context, h); ev.nil? || ev.signaled? + ev = event_by_handle(unit.context, h); ev.nil? || ev.signaled } break end @@ -585,20 +368,15 @@ def pump_deferred end end - # ADDED: register a command list's ops as a deferred unit and pump. `ops` is a - # snapshot (dup) taken by the caller so a later reset+re-append on the same - # list cannot mutate an in-flight execution. + # Registers a command list's ops as a deferred unit and pumps. def run_deferred_list(context, ops, label, in_order: false, cmd_list_handle: nil) @deferred_units << ZEModel::DeferredUnit.new(ops, context, label, in_order: in_order, cmd_list_handle: cmd_list_handle) pump_deferred end - # ADDED: deferred execution of the lists submitted to - # zeCommandQueueExecuteCommandLists. Each list becomes its OWN unit: lists in - # one submit are ordered only by events, not by list order, so a circular - # event dependency across two lists in one submit is a real deadlock we must be - # able to see. + # Each submitted list becomes its own unit: lists in one submit are ordered by + # events, not by list order, so a cycle between two of them is a real deadlock. def enqueue_deferred_execution(context, command_lists) command_lists.each do |cl| next unless cl @@ -607,27 +385,20 @@ def enqueue_deferred_execution(context, command_lists) end end - # ADDED: immediate command lists execute each op as it is appended, so we - # schedule the single op immediately. It still honors wait-events and goes - # through the same machinery, giving immediate lists the same OOB-copy and - # event-reuse checks as regular lists. + # Immediate lists execute each op as it is appended, but still go through the + # same machinery so they get the same checks. def enqueue_immediate_op(context, op, handle = nil) label = handle ? "immediate command list (#{get_handle_str(handle)})" \ : 'immediate command list' run_deferred_list(context, [op], label) end - # ADDED: end-of-trace drain. First pump normally in case ordering left work - # runnable. Whatever is still parked cannot progress on its own -- report any - # circular event dependency (deadlock) among the stuck units, then force each - # remaining unit's blocked op (reporting the never-signaled wait) so the - # deferred checks still run against the final memory state. + # End-of-trace drain: reports deadlocks among whatever is still stuck, then + # forces each remaining op so its deferred checks run against the final state. def flush_deferred pump_deferred return if @deferred_units.empty? check_circular_deadlock(self, @deferred_units) - #ADDED: an in-order list where an earlier op waits on an event only a later op - #in the SAME list signals is a self-deadlock the cross-list check cannot see check_in_order_self_deadlock(self, @deferred_units) until @deferred_units.empty? unit = @deferred_units.first @@ -640,24 +411,17 @@ def flush_deferred end end - # END-OF-TRACE REPORTING PASS, called once by ze_validator.in after the whole - # trace has been consumed. Findings here are the ones that are only knowable - # when you know nothing further is coming: - # 1. deferred work that never completed -> deadlocks (via flush_deferred) - # 2. API calls with an _entry but no _exit -> the process crashed inside them - # 3. objects still present in the model -> leaks - # Everything else was already reported inline as the trace was replayed. + + # Checks for the issues only visible at end of trace: deadlocks, calls that + # never returned, and objects that were never destroyed. def check_issues() - #ADDED: drain deferred command-list executions (and detect deadlocks) before - #reporting leaks/crashes + #drain deferred command-list executions before reporting leaks/crashes flush_deferred crash = false @state.each { |hostname, node| node.processes.each { |pid, process| process.threads.each { |tid, thread| - # CHANGED: iterate the whole call stack instead of a single slot. Any - # frame still on the stack is a traced call that never returned (a - # crash); a clean run pops every frame back to empty. + #any frame still on the stack is a call that never returned thread.call_stack.each { |frame| ctx = {'hostname' => hostname, 'vpid'=> pid, 'vtid' => tid, 'api' => frame.name} print_crash_error(ctx, 'command did not finish execution') @@ -667,7 +431,6 @@ def check_issues() } } - #if !crash || true unless crash && false @state.each { |hostname, node| node.processes.each { |pid, process| @@ -681,18 +444,12 @@ def check_issues() 'module_build_log', 'kernel', ].each { |t| - #objects that were created will be deleted upon successful exits. - #So, only the ones that didn't get deleted will be reported process.objects(t).each { |h, c| - print_leak_error(ctx, t, h) #it prints the type as well + print_leak_error(ctx, t, h) } } - # CHANGED: memory_allocation is now nested by context handle - # (ctx_handle -> {address -> Memory}), so iterate one level deeper. - # Any allocation still live at end of trace, in any context, is a leak. process.objects('memory_allocation').each { |_ctx_handle, allocs| allocs.each { |h, c| - #puts "mem alloc type = #{c.instance_variable_get(:@memtypestr)}" print_leak_error(ctx, 'memory_allocation', h, c.instance_variable_get(:@memtypestr)) } } @@ -702,113 +459,78 @@ def check_issues() end - # The Level Zero spec requires zeInit (or zeInitDrivers) before any other API - # call. Initialization is per-process, so the flag is keyed by pid. `m` is the - # regex match from #consume, so m[1] is the API name. - # Reported at most once per run to avoid one missing init producing an error - # for every subsequent call in the trace. - def check_initialization(context,m) - if ZEModel::INIT_API_NAMES.include?(m[1]) + # Checks that zeInit or zeInitDrivers came before any other API call. Keyed by + # pid, and reported once per run. + def check_initialization(context) + if ZEModel::INIT_API_NAMES.include?(context['api']) @init_called[context['vpid']] = true end if !@init_called[context['vpid']] && !@printed_init_error - self.print_usage_error(context, "zeInit or zeDriversInit wasn't called before #{m[1]}") + self.print_usage_error(context, "zeInit or zeDriversInit wasn't called before #{context['api']}") @printed_init_error = true end end - # Handle a call ENTRY, in order: - # 1. push the call (with its input args) onto the thread's stack, so - # find_param can reach those args later, including from the exit callback - # 2. take the thread-safety locks this API requires - # 3. run the API's $upon_entry callback, if any - # - # Checks live at entry either because they need the input arguments in their - # pre-call state, or -- more often -- because the call being validated might - # crash the process, in which case no _exit event is ever written and an - # exit-time check would silently never run. - def on_entry(m,hostname, context,defi) - set_last_entry(self, context, defi) #sets the per-thread callstack of the APIs + # Pushes the call frame, takes the thread-safety locks and runs the API's + # entry callback. Checks run here when the call itself might crash. + def on_entry(m,hostname, context,payload) + set_last_entry(self, context, payload) #sets the per-thread callstack of the APIs @lock_shared_object_on_entry[m[1]].each { |l| - l.call(self, context, defi) + l.call(self, context, payload) } #modifies the satate based on entry fields. Needed because some fields are easier to access it from the entry l = $upon_entry[m[1]] - l.call(self,context,defi) if l - end - - # Handle a call EXIT, the mirror image of on_entry: - # 1. release the thread-safety locks (in reverse order -- lock ordering - # discipline, so nested acquisitions unwind as a stack) - # 2. run the success OR the error callback, depending on the return code - # 3. verify and pop the thread's call stack - # - # Most model MUTATION happens here rather than at entry, because a call that - # failed must not be allowed to change the model (a failed Create produced no - # object), and because output handles only exist once the call has returned. - def on_exit(m,hostname,context,defi) + l.call(self,context,payload) if l + end + + def on_exit(m,hostname,context,payload) #unlock the shared object if the api name matches the predefined in ze_thread_safety.yaml @unlock_shared_object_on_exit[m[1]].reverse_each { |l| - l.call(self, context, defi) + l.call(self, context, payload) } #check if the return code indicates successful return from the API call - if validate_result(defi) + if validate_result(payload) l = $on_successful_exit[m[1]] #This might be a problem for tracking erroneous exits. - l.call(self, context, defi) if l + l.call(self, context, payload) if l else - #puts "failed: #{m[1]}" l = $on_erroneous_exit[m[1]] - l.call(self, context, defi) if l + l.call(self, context, payload) if l end check_last_entry(context) #When we return from _exit, we need to see what we saw in _entry for the current thread_id reset_last_entry(context) #Reset the callstack for current thread_id end - # THE MAIN LOOP. Returns the lambda babeltrace invokes with each batch of - # decoded messages; it is installed as the graph's sink in ze_validator.in. - # Everything the validator does happens somewhere inside this callback. - # - # Note the Ruby endless-method syntax (`def consume = lambda {...}`): calling - # `consume` returns the lambda rather than running it. + # The main loop: returns the lambda called with each batch of decoded + # messages. def consume = lambda { |iterator, _| iterator.next_messages.each do |m| - #the stream also carries stream-begin/end and packet messages; only - #actual trace events are of interest here next unless m.type == :BT_MESSAGE_TYPE_EVENT e = m.event - #Event names look like "lttng_ust_ze:zeMemAllocDevice_entry". This - #splits out the API name (m[1]) and the phase (m[2]). Non-matching - #events -- other backends sharing the trace, tracer-internal events -- - #fall through the `if` and are ignored. + #splits "lttng_ust_ze:zeMemAllocDevice_entry" into the API name (m[1]) + #and the phase (m[2]); anything else is ignored m = e.name.match(/:(z.*)_(entry|exit)/) if m - #hostname is trace-level metadata (recorded once per trace), while - #vpid/vtid come from LTTng's per-event common context. Merging them - #with the API name produces the `context` tuple that identifies - #"who is calling what", threaded through the entire validator. hostname = e.stream.trace.get_environment_entry_value_by_name('hostname').value context = e.get_common_context_field.value #the event's own fields: input args at _entry, results at _exit - defi = e.payload_field.value + payload = e.payload_field.value context['hostname'] = hostname context['api'] = m[1] #zeDriversInit or zeInit must be the first one to be called before any api calls - check_initialization(context,m) + check_initialization(context) #print the known deprecated APIs print_deprecation_warning(m[1]) if @deprecated[m[1]] if m[2] == 'entry' - on_entry(m, hostname, context, defi) + on_entry(m, hostname, context, payload) elsif m[2] == 'exit' - #puts "#{m[1]}" - on_exit(m, hostname, context, defi) + on_exit(m, hostname, context, payload) end - #ADDED: this event may have signaled something a deferred command list - #was waiting on, so advance the deferred worklist now + #this event may have signaled something a deferred command list waits on pump_deferred end end diff --git a/backends/ze/ze_validator_zemodel.rb b/backends/ze/ze_validator_zemodel.rb index 9fc0c859..100d05f1 100644 --- a/backends/ze/ze_validator_zemodel.rb +++ b/backends/ze/ze_validator_zemodel.rb @@ -1,82 +1,15 @@ require 'set' -# ============================================================================= -# ZEModel -- the data model the validator replays a trace into. -# -# Every class here is a plain Ruby mirror of a Level Zero concept. As the -# validator walks the trace, a `zeXxxCreate` event constructs one of these and -# files it in a lookup table; a `zeXxxDestroy` removes it. Anything still -# present when the trace ends is, by definition, leaked. -# -# LEVEL ZERO IN ONE PARAGRAPH (for readers new to the API) -# -------------------------------------------------------- -# Level Zero is Intel's low-level GPU compute API (the layer SYCL/OpenMP sit on -# top of). The object hierarchy is roughly: -# -# Driver one per installed GPU runtime -# └─ Device a physical GPU (may expose SubDevices, e.g. tiles) -# Context an isolation domain: memory and objects belong to exactly -# one context and may not be mixed across contexts -# ├─ Memory allocations (device / host / shared) -# ├─ Module a compiled GPU binary -# │ └─ Kernel one entry point within a module -# ├─ CommandList a recorded sequence of GPU operations ("append" to build -# │ it, "close" to finalize it) -# ├─ CommandQueue where a closed command list is submitted to actually run -# │ └─ Fence host-visible "this submission finished" signal -# └─ EventPool preallocated slots for Events -# └─ Event fine-grained GPU/host synchronization token -# -# Work is ASYNCHRONOUS: submitting a command list returns immediately, and the -# operations inside it run later, ordered by Events. That asynchrony is the -# reason for RecordedOp and DeferredUnit near the bottom of this file -- the -# validator cannot check a memory copy at the moment it is appended, because -# the copy has not happened yet. -# -# HANDLES -# ------- -# Level Zero identifies every object by an opaque pointer-sized "handle" -# (ze_command_list_handle_t and friends). In the trace these arrive as plain -# integers, and the validator uses them as hash keys throughout. When you see a -# bare `handle` in this codebase, it is that integer. -# -# WHERE THESE OBJECTS LIVE -# ------------------------ -# Node (one per hostname) -# └─ Process (one per pid) <- the main container; see Process below -# ├─ threads (per tid, each with a call stack) -# └─ one hash per object type: @devices, @contexts, @command_lists, ... -# StateObject#find_objects(ctx, 'command_list') is how the rest of the code -# reaches those hashes. -# -# A NOTE ON THE "ADDED:" / "CHANGED:" COMMENTS -# -------------------------------------------- -# Comments marked ADDED/CHANGED/REMOVED record deliberate deviations from an -# earlier version of the model and explain WHY the change was necessary. They -# are kept because the reasoning (e.g. why memory is keyed by context) is not -# recoverable from the code alone. -# ============================================================================= module ZEModel #One of these APIs must be called before any other calls - # The Level Zero spec requires the runtime to be initialized before any other - # entry point is used. Calling anything else first is undefined behavior, so - # StateObject#check_initialization watches for one of these appearing first. INIT_API_NAMES = ['zeInit', 'zeInitDrivers'] #This defines the object in which most ze objects (command list, command queue) extend form - # Common base class for every tracked Level Zero object. It provides two - # things every object needs: - # * @handle -- the integer identity from the trace - # * a lock -- NOT a real mutex. It is a marker used to detect data races: - # see #lock below. class Object attr_reader :handle attr_accessor :status # returns what object the caller is # e.g., 'Device' will return device - # Each subclass sets `@typename` at class level (a class instance variable); - # this reader exposes it for error messages, e.g. "concurrent access to - # command_list 0x...". def self.typename @typename end @@ -86,21 +19,10 @@ def self.typename def initialize(handle) @handle = handle @lock = nil - #@status = -1 end - # THREAD-SAFETY CHECK. - # The Level Zero spec documents, per API, which objects may NOT be touched - # concurrently from two threads. ze_thread_safety.yaml encodes that table, - # and StateObject turns each entry into a pair of callbacks that lock the - # object on _entry and unlock it on _exit. - # - # Because the validator processes events one at a time (never truly in - # parallel), @lock is not a real mutex -- it is simply "which API call - # currently has this object open". Since the trace is timestamp-ordered, - # finding the object ALREADY locked when a second call tries to lock it - # means two calls overlapped in wall-clock time on the real machine: a - # genuine data race. `ctx` identifies the locking call (thread id + API). + # Reports a race: the trace is timestamp-ordered, so finding the object + # already locked means two calls really overlapped. def lock(state,ctx) if @lock state.print_race_condition(ctx, @lock, self.class.typename, @handle) @@ -109,10 +31,8 @@ def lock(state,ctx) end end - # Release only if this same call is the holder. The guard matters for - # nested/overlapping calls: a thread that failed to acquire the lock above - # (and was reported as a race) must not then steal the real holder's lock - # by unlocking on its own way out. + # Releases only if this same call is the holder, so a call that lost the + # race above does not steal the real holder's lock on its way out. def unlock(ctx) if @lock == ctx @lock = nil @@ -120,10 +40,6 @@ def unlock(ctx) end end - # 'A < B' means A inherits from B - # The driver is an interface that serves between the host and the devices - # Created by zeDriverGet, which reports every Level Zero driver installed. - # Holds the Devices discovered under it via zeDeviceGet. class Driver < Object @typename = 'driver' attr_reader :devices @@ -135,46 +51,28 @@ def initialize(handle) end - # Device is mostly GPU - # A physical accelerator, created by zeDeviceGet. Besides identity, the model - # tracks two "did the application ask about me before assuming things?" flags - # used by the portability checks -- hardcoding device characteristics instead - # of querying them is the classic way GPU code breaks on the next generation - # of hardware. class Device < Object @typename = 'device' attr_reader :properties #delete attr_reader :sub_devices - # CHANGED: nested by Level Zero context handle -- ctx_handle -> {addr -> Memory} - # -- for the same reason as Process#memory_allocations: an address is only - # guaranteed unique within a context, and one device can back allocations in - # several contexts. Auto-vivifies an empty sub-map per context. + # ctx_handle -> {addr -> Memory}, keyed by context like Process#memory_allocations attr_accessor :memory_allocations - # set by zeDeviceGetProperties -- the app asked what this device is + # the "did the app query this before assuming it?" flags the portability + # checks look at (see check_group_property_queued) attr_accessor :property_fetched - # set by zeDeviceGetCommandQueueGroupProperties -- the app asked which - # engines (compute / copy) this device has and how many queues each - # supports. If it never asks but still passes an ordinal, it is guessing: - # see check_group_property_queued. attr_accessor :cmd_queue_group_properties_queried - #attr_accessor :p2p_list def initialize(handle) super @sub_devices = [] @memory_allocations = Hash.new { |h, k| h[k] = {} } @property_fetched = false - #This @cmd_queue_group_properties_queried = false end end - # A tile/slice of a Device exposed by zeDeviceGetSubDevices. It behaves like a - # Device in every respect (hence the inheritance) but remembers its parent. - # Note the unusual ordering below: @parent is assigned BEFORE calling super, - # because Device#initialize is what actually sets up the shared state. class SubDevice < Device attr_reader :parent def initialize(handle, parent) @@ -184,36 +82,17 @@ def initialize(handle, parent) end #create memory object so that device, shared, host mem allocs can be differentiated - # One GPU-visible allocation. Level Zero has three flavors, distinguished by - # @memtypestr because they have different accessibility and residency rules: - # "device" - lives in GPU memory; only that device can touch it - # "host" - lives in host memory; every device in the context can touch it - # "shared" - migrates between host and device on demand - # - # Unlike the handle-based objects, memory is identified by its ADDRESS, and - # the validator must reason about ranges rather than exact matches: a copy - # into `base + 64` is a legitimate use of the allocation starting at `base`. - # Hence @base and @size are the fields most checks actually consult. class Memory < Object @typename = 'memory_allocation' - attr_reader :context # the Context this allocation belongs to - attr_reader :size # length in bytes + attr_reader :context + attr_reader :size attr_reader :owned_by # the Device for a device allocation; nil for host - # Whether the allocation is currently resident in device memory. Toggled by - # zeContextMakeMemoryResident / zeContextEvictMemory. Device allocations - # start non-resident; host/shared memory is always reachable. - attr_accessor :resident attr_accessor :memtypestr # "device" | "host" | "shared" - # Start address. Equal to the handle -- for memory the "handle" IS the - # pointer -- but kept under a descriptive name because range arithmetic - # (base <= ptr < base + size) reads far better than handle arithmetic. attr_accessor :base - # ADDED: api-context string of the zeMemFree that released this allocation, - # or nil while live. A freed allocation is moved to the process-level - # @freed_memory_allocations registry (kept, not discarded) so a later copy/ - # fill/kernel that still references its address range can be reported as a - # use-after-free instead of silently passing (unknown pointer). + # the zeMemFree that released this allocation, nil while live. Freed + # allocations are kept so a later reference is caught as use-after-free. attr_accessor :freed_by + def initialize(handle, context, size, owned_by, memtypestr="shared") super(handle) @context = context @@ -221,56 +100,16 @@ def initialize(handle, context, size, owned_by, memtypestr="shared") @owned_by = owned_by @memtypestr = memtypestr @base = handle - @freed_by = nil # ADDED - #puts "size = #{size}, handle = #{handle}, handle+size=#{handle + size}" - #for device memory. - # Device allocations must be explicitly made resident before the GPU can - # use them; host and shared memory is always accessible, so it starts - # resident and stays that way. - if memtypestr == "device" - @resident = false - else - @resident = true - end + @freed_by = nil end end - # No need to create DeviceMemory class. Just create Memory with the specified type (device,shared,host) - # class DeviceMemory < Object - # @typename = 'memory_allocation_device' - # attr_reader :context - # attr_reader :size - # attr_reader :device - - # def initialize(handle, context, size, device) - # super(handle,context,size,device) - # @context = context - # @size = size - # @device = device - # end - # end - - - - - - # THE CENTRAL ISOLATION BOUNDARY. - # Nearly every "you cannot mix these" rule in Level Zero is really "these two - # objects were created on different contexts". A command list, the events it - # uses, the queue it is submitted to, and the memory it copies must all belong - # to one context. A large share of the validator's checks are variations on - # comparing two Context objects, and the per-context nesting of the memory - # maps (see Process below) exists for the same reason. - # - # The child collections below mirror what the context owns, so destroying a - # context can report anything still alive inside it. class Context < Object @typename = 'context' attr_reader :driver - attr_reader :desc # the ze_context_desc_t passed to zeContextCreate - attr_reader :devices # nil means "all devices of the driver" - + attr_reader :desc + attr_reader :devices attr_reader :event_pools attr_reader :command_queues attr_reader :command_lists @@ -291,22 +130,14 @@ def initialize(handle, driver, desc, devices = nil) end end - # A fixed-size block of event slots, allocated up front by zeEventPoolCreate. - # Events are not individually allocated: the pool reserves `desc[:count]` - # slots, and each zeEventCreate claims one by index. Two live events may not - # share an index, so the model tracks which indices are still free. - # - # An event pool is also how an Event acquires a context (an Event has no - # context of its own), which several checks rely on. class EventPool < Object @typename = 'event_pool' attr_reader :context attr_reader :desc attr_reader :devices - attr_reader :events # handle -> Event, for leak reporting on destroy - # The set of slot indices NOT yet in use. Starts as {0, 1, ..., count-1}; - # zeEventCreate removes one (double use = error) and zeEventDestroy puts it - # back (double free = error). + attr_reader :events + # slot indices not yet in use: zeEventCreate removes one (double use = + # error), zeEventDestroy puts it back (double free = error) attr_reader :indices def initialize(handle, context, desc, devices = nil) @@ -319,25 +150,14 @@ def initialize(handle, context, desc, devices = nil) end end - # A synchronization token. An Event is a one-bit flag the GPU or host can - # SIGNAL, that other work can WAIT on, and that must be RESET before it is - # reused. Getting that lifecycle wrong is a major source of GPU hangs, so the - # model tracks more than just the bit -- see the accessors below. class Event < Object @typename = 'event' attr_reader :event_pool attr_reader :desc attr_accessor :signaled - # ADDED: richer event state so we can model Level Zero event semantics. - # signaled_by - api-context string of whoever last signaled this event - # (used only for diagnostic messages). - # observed - whether the host has observed the signaled state since the - # last signal (via zeEventHostSynchronize / a successful - # zeEventQueryStatus / a device-wide synchronize). This lets - # us tell a genuine concurrent double-signal (signaled but - # never consumed) from a reuse-without-reset (signaled, - # consumed by the host, then signaled again with no reset). - attr_reader :signaled_by + attr_reader :signaled_by # who last signaled it, for diagnostics + # whether the host observed the signaled state since the last signal. Tells + # a concurrent double-signal (never consumed) from a reuse-without-reset. attr_reader :observed def initialize(handle, event_pool, desc) @@ -350,44 +170,31 @@ def initialize(handle, event_pool, desc) @observed = false end - # ADDED: move the event to the signaled state. `by` records who signaled it - # (for messages). A fresh signal has not yet been observed by the host. + # `by` records who signaled it, for messages def signal(by = nil) @signaled = true @signaled_by = by @observed = false end - # ADDED: zeEventHostReset / zeCommandListAppendEventReset return the event to - # the unsignaled state so it can be reused as a dependency again. def reset @signaled = false @signaled_by = nil @observed = false end - # ADDED: record that the host observed the signaled state. Distinguishes a - # later reuse-without-reset from a concurrent double-signal. def observe @observed = true end - - def signaled? - @signaled - end end - # Where closed command lists are submitted for execution. A queue is bound at - # creation to one engine of one device, identified by desc[:ordinal] (which - # command queue GROUP -- compute, copy, or both) and desc[:index] (which - # queue within that group). Submitting compute work to a copy-only ordinal, or - # using an index beyond the group's numQueues, is checked against the real - # device topology in ze_device_property.json. class CommandQueue < Object @typename = 'command_queue' attr_reader :context attr_reader :device - attr_reader :desc # ze_command_queue_desc_t: carries :ordinal and :index + # :ordinal and :index are checked against the topology in + # ze_device_property.json + attr_reader :desc attr_reader :fences def initialize(handle, context, device, desc) @@ -400,24 +207,13 @@ def initialize(handle, context, device, desc) end end - # A coarse, host-visible completion signal for ONE submission to a queue. - # Where an Event synchronizes individual operations, a Fence answers "has this - # whole zeCommandQueueExecuteCommandLists finished?". - # - # A fence must be reset before it can be reused. The three-state lifecycle: - # not_signaled -> in_use (submitted with this fence) - # -> signaled (zeFenceHostSynchronize observed completion) - # -> not_signaled again (zeFenceReset) - # Reusing a fence that is still in_use or already signaled is the misuse - # check_fence_misuse reports. - # - # NOTE: not_signaled/in_use/signaled are per-instance constants exposed as - # readers, so comparisons read `fence.status == fence.signaled`. class Fence < Object @typename = 'fence' - attr_reader :command_queue # the queue this fence was created for + attr_reader :command_queue attr_reader :desc - attr_accessor :status # one of the three values below + # not_signaled -> in_use -> signaled -> not_signaled (zeFenceReset). Compared + # as `fence.status == fence.signaled`; see check_fence_misuse. + attr_accessor :status attr_reader :not_signaled attr_reader :in_use attr_reader :signaled @@ -435,47 +231,21 @@ def initialize(handle, command_queue, desc) end - # A recorded sequence of GPU operations -- the core object of the API. - # - # NORMAL LIFECYCLE: - # zeCommandListCreate -> zeCommandListAppendXxx (many) -> - # zeCommandListClose -> zeCommandQueueExecuteCommandLists - # Appending only RECORDS an operation; nothing runs until the closed list is - # submitted to a queue. Executing a list that was never closed is an error - # (check_command_list_closed). - # - # IMMEDIATE LISTS are the exception: created by zeCommandListCreateImmediate, - # they carry their own implicit queue and execute each operation the moment it - # is appended. They are never closed and must never be passed to - # ExecuteCommandLists. Because they take a QUEUE descriptor rather than a list - # descriptor, the model stores that in @altdesc and leaves @desc nil -- which - # is why so much code here branches on `desc ? ... : altdesc`. class CommandList < Object @typename = 'command_list' attr_reader :context attr_reader :device - attr_reader :desc # ze_command_list_desc_t (nil for immediate lists) - attr_reader :altdesc # ze_command_queue_desc_t (immediate lists only) + attr_reader :desc # nil for immediate lists + attr_reader :altdesc # queue descriptor, immediate lists only attr_accessor :associated_command_queue - attr_accessor :immediate # true for zeCommandListCreateImmediate lists + attr_accessor :immediate attr_accessor :associated_ordinal - # ADDED: true when the list was created with ZE_COMMAND_LIST_FLAG_IN_ORDER - # (or, for immediate lists, ZE_COMMAND_QUEUE_FLAG_IN_ORDER). In-order lists - # execute their appended ops strictly in append order -- op N+1 will not - # start until op N completes -- so an earlier op that waits on an event only - # a later op in the SAME list signals can never complete (an intra-list - # deadlock the cross-list detector cannot see). See check_in_order_self_deadlock. + # enables check_in_order_self_deadlock: in an in-order list an op waiting on + # an event only a later op in the same list signals can never complete attr_accessor :in_order - # ADDED: ordered list of RecordedOp appended to this command list. It is - # replayed when the list is executed on a queue, so that checks depending on - # event completion (out-of-bounds copy, event-signal reuse) run at the point - # the op would actually execute -- not at append or execute time. + # RecordedOps in append order, replayed when the list is executed so the + # deferred checks run at the point the op would actually execute attr_accessor :ops - # Lifecycle states, compared via - # ZEModel::CommandList.class_variable_get(:@@INITIALIZED) at the call sites. - # INITIALIZED - open for appending (fresh, or just reset) - # CLOSED - finalized by zeCommandListClose, ready to submit - # DESTROYED - zeCommandListDestroy was called; any further use is a bug @@INITIALIZED = 0 #created or being properly recycled @@CLOSED = 1 @@DESTROYED = 2 @@ -490,67 +260,25 @@ def initialize(handle, context, device, desc, altdesc) @status = @@INITIALIZED @immediate = false @associated_ordinal = 0 - @in_order = false # ADDED + @in_order = false @api_calls = [] - @ops = [] # ADDED + @ops = [] end - # An immediate list has no list descriptor (it was given a queue descriptor - # instead), so a nil desc identifies it. NOTE: the @immediate flag set by - # the zeCommandListCreateImmediate callback is what most call sites actually - # test; this predicate derives the same fact from the descriptor. + # An immediate list is given a queue descriptor instead of a list one, so a + # nil desc identifies it. def immediate? return !desc end end - # =========================================================================== - # DEFERRED EXECUTION -- why RecordedOp and DeferredUnit exist. - # - # THE PROBLEM. Consider this perfectly valid program: - # - # append a copy into buffer X onto list A, gated on event E - # submit list A <- returns immediately, copy has NOT run - # ... later ... - # allocate buffer X - # signal event E <- only NOW does the copy actually run - # - # If the validator checked the copy's bounds when it was APPENDED, or even - # when the list was SUBMITTED, buffer X would not exist yet and it would - # report a bogus error. The check is only meaningful at the moment the copy - # really executes -- which the trace tells us only indirectly, via events. - # - # THE SOLUTION. Appending records a RecordedOp instead of checking. Submitting - # turns the list's recorded ops into a DeferredUnit: a queued, half-executed - # program with a cursor. As the trace goes on and events get signaled, the - # scheduler in StateObject (pump_deferred) walks each unit's cursor forward - # over every op whose wait-events are now satisfied, running the deferred - # checks at that point -- the correct point on the program timeline. - # - # NO THREADS, NO FIBERS. Execution state is just an integer index. A unit that - # cannot advance is simply left parked, and retried after the next signal. - # Anything still parked when the trace ends could never have completed, which - # is exactly the definition of a deadlock -- so the same data structure that - # defers the memory checks also powers the deadlock detector. - # =========================================================================== - - # ADDED: A single operation recorded when it is appended to a command list. It - # snapshots everything the deferred checks need, because the trace's per-call - # context (find_param) is gone by the time the op is replayed at execute time. - # kind - :copy, :wait, :signal, :reset, :barrier, :ranges_barrier, :launch - # signal - handle of the completion event this op signals (nil/0 if none) - # waits - event handles that must be signaled before this op can execute - # params - kind-specific data (copy: api/dst/src/size; reset: reset_handle; - # ranges_barrier: api/ctx_handle/ranges, where ranges is an array of - # {base:, size:} for each memory range the barrier covers) - # api - the ZE API that appended this op (for diagnostics) class RecordedOp - attr_reader :kind # :copy | :wait | :signal | :reset | :barrier | - # :ranges_barrier | :launch + # :copy, :wait, :signal, :reset, :barrier, :ranges_barrier or :launch + attr_reader :kind attr_reader :signal # event this op signals on completion (nil if none) attr_reader :waits # events that must be signaled before this op may run - attr_reader :params # kind-specific snapshot (see class comment above) - attr_reader :api # originating ze API name, for diagnostics + attr_reader :params + attr_reader :api def initialize(kind, signal: 0, waits: [], params: {}, api: nil) @kind = kind @@ -563,36 +291,18 @@ def initialize(kind, signal: 0, waits: [], params: {}, api: nil) end end - # ADDED: One deferred-execution unit -- a single submitted command list whose - # recorded ops are replayed cooperatively by the (non-concurrent) scheduler in - # StateObject. Instead of a Ruby Fiber, execution state is an explicit integer - # cursor into `ops`: the scheduler advances the cursor past every op whose - # waits are satisfied, and leaves it parked on the first op that is still - # blocked. `blocked_on` / `pending_signals` are the metadata the deadlock - # detector uses to build a wait-for graph across units. - # ops - snapshot (dup) of the list's ops for this execution - # context - trace context captured at submit time - # label - human label for messages (e.g. "command_list 0x..") - # cursor - index of the next op to execute - # blocked_on - event handles the current op is waiting for (or []) - # pending_signals - events this unit may still signal before it finishes class DeferredUnit - attr_reader :ops # the ops to replay, in order - attr_reader :context # trace context (host/pid/tid/api) captured at submit - attr_reader :label # e.g. "command_list (0x00007f...)", used in messages + attr_reader :ops # snapshot of the list's ops for this execution + attr_reader :context # trace context captured at submit time + attr_reader :label # e.g. "command_list (0x00007f...)", for messages attr_accessor :cursor # index of the next op to run; == ops.size means done attr_accessor :blocked_on # events the current op is still waiting for - # Events this unit has not signaled yet. Read by the deadlock detector: if - # unit U is blocked on an event that appears only in unit V's - # pending_signals, then U is waiting on V -- an edge in the wait-for graph. + # Events this unit has not signaled yet. If unit U is blocked on an event + # only in V's pending_signals, U waits on V: an edge in the wait-for graph. attr_accessor :pending_signals - # ADDED: whether the originating command list is in-order (see CommandList#in_order). - # The intra-list self-deadlock check only applies to in-order units. attr_reader :in_order - # ADDED: handle of the command list this unit was submitted from (nil if - # unknown/immediate without a handle). Lets checks scoped to a specific list -- - # e.g. resetting a list while a prior submission is still in-flight -- find the - # deferred units that belong to it without matching on the label string. + # the command list this unit came from, so list-scoped checks can find their + # units without matching on the label string attr_reader :cmd_list_handle def initialize(ops, context, label, in_order: false, cmd_list_handle: nil) @@ -601,8 +311,8 @@ def initialize(ops, context, label, in_order: false, cmd_list_handle: nil) @label = label @cursor = 0 @blocked_on = [] - @in_order = in_order # ADDED - @cmd_list_handle = cmd_list_handle # ADDED + @in_order = in_order + @cmd_list_handle = cmd_list_handle #every event this unit will eventually signal, for the wait-for graph @pending_signals = ops.map { |op| op.signal }.compact end @@ -618,22 +328,12 @@ def current_op end end - # A compiled GPU binary loaded into a context (zeModuleCreate takes SPIR-V or - # native code). Kernels are the individual entry points inside it. - # - # A Kernel has no context of its own, so "which context does this kernel - # belong to?" is answered by kernel.module.context -- the indirection - # check_kernel_list_context_match relies on. class Module < Object @typename = 'module' - # The compiler diagnostics object optionally produced alongside a module. - # It is a separately destroyable handle, so failing to destroy it is its own - # leak; @module may be nil when the build FAILED (no module was produced, - # but the log describing why still exists). class BuildLog < Object @typename = 'module_build_log' - attr_reader :module + attr_reader :module # nil when the build failed and produced no module def initialize(handle, mod = nil) super(handle) @@ -656,14 +356,11 @@ def initialize(handle, context, device, desc) end end - # One entry point within a Module, created by zeKernelCreate. @name is the - # source-level function name, kept so diagnostics can say which kernel - # misbehaved instead of only printing a handle. class Kernel < Object @typename = 'kernel' - attr_reader :module # owning Module -- also how the kernel's context is found + attr_reader :module # also how the kernel's context is found attr_reader :desc - attr_reader :name # human-readable kernel name from the descriptor + attr_reader :name # kernel name from the descriptor, for diagnostics def initialize(handle, mod, desc, name) super(handle) @@ -673,13 +370,9 @@ def initialize(handle, mod, desc, name) end end - # One in-flight API call: pushed on a thread's stack at _entry and popped at - # _exit. @params holds the entry payload -- the call's INPUT arguments -- which - # is why exit callbacks reach back through find_param to read inputs that the - # exit event itself does not carry. class ApiCall - attr_reader :name # e.g. "zeCommandListAppendMemoryCopy" - attr_reader :params # the decoded _entry payload + attr_reader :name + attr_reader :params # the _entry payload, i.e. the call's input arguments def initialize(name, params) @name = name @@ -687,15 +380,10 @@ def initialize(name, params) end end - # One OS thread of the traced process, identified by its LTTng vtid. class Thread attr_reader :vtid - # CHANGED: was a single `last_entry` slot, which broke when a traced API - # internally calls another traced API on the same thread (e.g. - # zelLoaderDriverCheck calls zeInit): the inner entry clobbered the outer - # frame and the outer _exit then failed check_last_entry. Modeling it as a - # stack lets nested calls push/pop correctly; the top of stack is the - # currently-executing call. + # a stack, not a single slot: a traced API may call another traced API on + # the same thread (e.g. zelLoaderDriverCheck calls zeInit) attr_reader :call_stack def initialize(vtid) @@ -703,20 +391,12 @@ def initialize(vtid) @call_stack = [] end - # the innermost in-flight ApiCall, or nil if the thread has none. - # Kept as `last_entry` so existing callers (find_param, etc.) are unchanged. + # the innermost in-flight ApiCall, or nil if the thread has none def last_entry @call_stack.last end end - # One traced process. This is the main container: every Level Zero handle is - # only meaningful within the process that created it, so all the object tables - # live here rather than globally. - # - # The tables are reached generically -- #objects('command_list') and - # StateObject#find_objects both resolve the name to the matching @...s ivar -- - # which is what lets the leak reporter loop over object types by name. class Process attr_reader :vpid # LTTng virtual pid attr_reader :threads # tid -> Thread (auto-created on first sight) @@ -731,16 +411,10 @@ class Process attr_reader :command_lists attr_reader :modules attr_reader :module_build_logs - # ADDED: address -> freed Memory objects (kept after zeMemFree) so a later - # reference to a released address can be flagged as use-after-free. + # allocations kept after zeMemFree, for use-after-free detection attr_reader :freed_memory_allocations - # ADDED: both memory maps are nested by Level Zero context handle -- - # { ctx_handle => { address => Memory } } -- because the L0 unified virtual - # address space only guarantees non-aliasing addresses WITHIN a context. - # Two live allocations in different contexts may share a numeric address, so - # a flat address-keyed map would let the second overwrite the first. Each - # inner sub-map has the same shape as the old flat map, so code that already - # holds a sub-map (allocations[ptr], .each_value, .delete) is unchanged. + # { ctx_handle => { address => Memory } }: addresses are only guaranteed + # non-aliasing within a context, so a flat map would lose one of two. attr_reader :memory_allocations def initialize(vpid) @@ -759,26 +433,17 @@ def initialize(vpid) @modules = {} @module_build_logs = {} @kernels = {} - # CHANGED: nested by context handle -- ctx_handle -> { address -> Memory }. - # Auto-vivify an empty sub-map on first use of a context so callers never - # get nil for a context that has not allocated yet. @memory_allocations = Hash.new { |h, k| h[k] = {} } - @freed_memory_allocations = Hash.new { |h, k| h[k] = {} } # ADDED: ctx -> {addr -> freed Memory} - #@initCalled = false + @freed_memory_allocations = Hash.new { |h, k| h[k] = {} } end - # Generic accessor: objects('command_list') returns @command_lists. The - # singular type names come from the model's typename strings, so a caller - # can iterate ['context', 'fence', 'command_list', ...] and reach each table - # without a case statement -- see StateObject#check_issues. + # objects('command_list') returns @command_lists, so callers can iterate + # object types by name (see StateObject#check_issues). def objects(type) instance_variable_get(:"@#{type}s") end end - # One machine in the trace, keyed by hostname. A multi-node MPI run produces - # one trace per node; they are merged into a single timestamp-ordered stream, - # so the top of the model is keyed by hostname to keep them apart. class Node attr_reader :name # hostname attr_reader :processes # pid -> Process (auto-created on first sight) @@ -789,4 +454,4 @@ def initialize(name) end end -end \ No newline at end of file +end From c7c90087cd0b0d8a205ad76919a07b5d6b1375a8 Mon Sep 17 00:00:00 2001 From: Junhyung Shim Date: Thu, 13 Aug 2026 19:46:16 +0000 Subject: [PATCH 4/4] Removed unnecessary comments, refactored variable names, and added safeguards for the detections --- ...validator_function_entry_exit_callbacks.rb | 55 +++++++++---------- backends/ze/ze_validator_state_object.rb | 7 +-- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/backends/ze/ze_validator_function_entry_exit_callbacks.rb b/backends/ze/ze_validator_function_entry_exit_callbacks.rb index 1f5320e7..6034698a 100644 --- a/backends/ze/ze_validator_function_entry_exit_callbacks.rb +++ b/backends/ze/ze_validator_function_entry_exit_callbacks.rb @@ -226,7 +226,7 @@ } # Submission is where the queue, the lists, their events and the fence are -# first brought together, so most "must belong together" rules are checked here. +# So the same context checkings between those objects are called here. $upon_entry["zeCommandQueueExecuteCommandLists"] = lambda { |state, ctx, payload| command_queues = state.find_objects(ctx, 'command_queue') command_queue_handle = payload['hCommandQueue'] @@ -296,9 +296,6 @@ curr_fence.status = curr_fence.not_signaled } -# Object lifecycle callbacks. Create files the object in the process table and -# its parent's; destroy removes both and reports children still alive. - #Set the driver for the current context $on_successful_exit['zeDriverGet'] = lambda { |state, ctx, payload| drivers = state.get_process(ctx).drivers @@ -307,7 +304,7 @@ } } -#Create device objects + $on_successful_exit['zeDeviceGet'] = lambda { |state, ctx, payload| devices = state.find_objects(ctx, 'device') driver = state.find_object(ctx, 'driver', 'hDriver') @@ -322,7 +319,7 @@ } -#Create subdevice objects, with device as a parent + $on_successful_exit['zeDeviceGetSubDevices'] = lambda { |state, ctx, payload| devices = state.find_objects(ctx, 'device') device = state.find_object(ctx, 'device', 'hDevice') @@ -334,7 +331,7 @@ } } -#Create ze context objects + $on_successful_exit['zeContextCreate'] = lambda { |state, ctx, payload| contexts = state.find_objects(ctx, 'context') driver = state.find_object(ctx, 'driver', 'hDriver') @@ -358,7 +355,7 @@ contexts[handle] = ZEModel::Context.new(handle, driver, desc, devs) } -# Releases the corresponding ze context object + $on_successful_exit['zeContextDestroy'] = lambda { |state, ctx, payload| contexts = state.find_objects(ctx, 'context') contexts.delete(state.find_param(ctx, 'hContext')) { |h| @@ -366,7 +363,7 @@ } } -# Creates an event pool + $on_successful_exit['zeEventPoolCreate'] = lambda { |state, ctx, payload| context = state.find_object(ctx, 'context', 'hContext') devices = state.find_objects(ctx, 'device') @@ -381,8 +378,8 @@ check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_EVENT_POOL_DESC,desc[:stype]) } -# Destroys the given event pool -# Destroying a pool while events carved out of it are still alive leads to leaks + +# Destroying a pool while events are being used should not occur $on_successful_exit['zeEventPoolDestroy'] = lambda { |state, ctx, payload| event_pools = state.find_objects(ctx, 'event_pool') handle = state.find_param(ctx, 'hEventPool') @@ -398,7 +395,7 @@ } -#Create an event + $on_successful_exit['zeEventCreate'] = lambda { |state, ctx, payload| events = state.find_objects(ctx, 'event') event_pool = state.find_object(ctx, 'event_pool', 'hEventPool') @@ -414,7 +411,7 @@ check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_EVENT_DESC,desc[:stype]) } -# Destroys an event + $on_successful_exit['zeEventDestroy'] = lambda { |state, ctx, payload| events = state.find_objects(ctx, 'event') handle = state.find_param(ctx, 'hEvent') @@ -430,7 +427,7 @@ end } -# Creates a command queue + $on_successful_exit['zeCommandQueueCreate'] = lambda { |state, ctx, payload| command_queues = state.find_objects(ctx, 'command_queue') context = state.find_object(ctx, 'context', 'hContext') @@ -452,7 +449,7 @@ check_valid_index_for_ordinal(state,ctx,handle,desc[:ordinal],desc[:index]) } -# Destroys the given command queue + $on_successful_exit['zeCommandQueueDestroy'] = lambda { |state, ctx, payload| command_queues = state.find_objects(ctx, 'command_queue') handle = state.find_param(ctx, 'hCommandQueue') @@ -467,7 +464,7 @@ } } -# Creates a fence + $on_successful_exit['zeFenceCreate'] = lambda { |state, ctx, payload| fences = state.find_objects(ctx, 'fence') command_queue = state.find_object(ctx, 'command_queue', 'hCommandQueue') @@ -480,7 +477,7 @@ check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_FENCE_DESC,desc[:stype]) } -# Destroys the fence + $on_successful_exit['zeFenceDestroy'] = lambda { |state, ctx, payload| fences = state.find_objects(ctx, 'fence') handle = state.find_param(ctx, 'hFence') @@ -493,7 +490,7 @@ } } -# Creates the command list + $on_successful_exit['zeCommandListCreate'] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') context = state.find_object(ctx, 'context', 'hContext') @@ -509,7 +506,7 @@ check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_COMMAND_LIST_DESC,desc[:stype]) } -# Creates an immediate command list + $on_successful_exit['zeCommandListCreateImmediate'] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') context = state.find_object(ctx, 'context', 'hContext') @@ -531,7 +528,7 @@ check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_COMMAND_QUEUE_DESC,altdesc[:stype]) } -# Destroys the command list + $on_successful_exit['zeCommandListDestroy'] = lambda { |state, ctx, payload| command_lists = state.find_objects(ctx, 'command_list') handle = state.find_param(ctx, 'hCommandList') @@ -543,7 +540,7 @@ } } -# Destroys the module + $on_successful_exit['zeModuleCreate'] = lambda { |state, ctx, payload| modules = state.find_objects(ctx, 'module') context = state.find_object(ctx, 'context', 'hContext') @@ -578,7 +575,7 @@ end } -# Destroys the module. Kernel must be destroyed first. +# Kernel must be destroyed first before module $on_successful_exit['zeModuleDestroy'] = lambda { |state, ctx, payload| modules = state.find_objects(ctx, 'module') handle = state.find_param(ctx, 'hModule') @@ -593,7 +590,7 @@ } } -# Appends build log at dynamic link + $on_erroneous_exit['zeModuleDynamicLink'] = $on_successful_exit['zeModuleDynamicLink'] = lambda { |state, ctx, payload| build_log_handle = payload['phLinkLog_val'] if build_log_handle != 0 @@ -604,7 +601,7 @@ end } -# Destroys the build log + $on_successful_exit['zeModuleBuildLogDestroy'] = lambda { |state, ctx, payload| module_build_logs = state.find_objects(ctx, 'module_build_log') handle = state.find_param(ctx, 'hModuleBuildLog') @@ -624,7 +621,7 @@ check_valid_module(state,ctx, payload) } -# Creates the kernel object + $on_successful_exit['zeKernelCreate'] = lambda { |state, ctx, payload| kernels = state.find_objects(ctx, 'kernel') mod = state.find_object(ctx, 'module', 'hModule') @@ -638,7 +635,7 @@ check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_KERNEL_DESC, desc[:stype]) } -# Destroys the kernel + $on_successful_exit['zeKernelDestroy'] = lambda { |state, ctx, payload| kernels = state.find_objects(ctx, 'kernel') handle = state.find_param(ctx, 'hKernel') @@ -654,7 +651,7 @@ # Each allocator keys the allocation by its Level Zero context, then calls # mark_reallocated since the driver may hand back an address that was freed. -# Allocs device memory + $on_successful_exit['zeMemAllocDevice'] = lambda { |state, ctx, payload| # memory is associated with devices ctx_handle = state.find_param(ctx, 'hContext') @@ -672,7 +669,7 @@ check_struct_stype_misuse(state,ctx,payload,:ZE_STRUCTURE_TYPE_DEVICE_MEM_ALLOC_DESC, device_desc[:stype]) } -# Allocs shared memory + $on_successful_exit['zeMemAllocShared'] = lambda { |state, ctx, payload| ctx_handle = state.find_param(ctx, 'hContext') memory_allocations = state.memory_allocations(ctx, ctx_handle) @@ -689,7 +686,7 @@ device.memory_allocations[ctx_handle][handle] = memory_allocation if device } -# Allocs Host memory + $on_successful_exit['zeMemAllocHost'] = lambda { |state, ctx, payload| # Host allocations are accessible by the host and all devices within the driver’s context. ctx_handle = state.find_param(ctx, 'hContext') diff --git a/backends/ze/ze_validator_state_object.rb b/backends/ze/ze_validator_state_object.rb index b5f265ec..7eae5a88 100644 --- a/backends/ze/ze_validator_state_object.rb +++ b/backends/ze/ze_validator_state_object.rb @@ -522,7 +522,6 @@ def consume = lambda { |iterator, _| context['api'] = m[1] #zeDriversInit or zeInit must be the first one to be called before any api calls check_initialization(context) - #print the known deprecated APIs print_deprecation_warning(m[1]) if @deprecated[m[1]] if m[2] == 'entry' @@ -530,10 +529,10 @@ def consume = lambda { |iterator, _| elsif m[2] == 'exit' on_exit(m, hostname, context, payload) end - #this event may have signaled something a deferred command list waits on - pump_deferred + # Runs the blocked commands, if the wait-event(s) are satisfied + pump_deferred end end } -end #end of StateObject +end