diff --git a/docs/adr/v4-02-gpu-decision.md b/docs/adr/v4-02-gpu-decision.md new file mode 100644 index 0000000..747981f --- /dev/null +++ b/docs/adr/v4-02-gpu-decision.md @@ -0,0 +1,48 @@ +# ADR V4-02: GPU decoder decision remains blocked + +Status: **NOT_RUN / no decoder selected** +Date: 2026-08-02 + +## Context + +V4 requires one DXGI adapter for hardware decode, processing, composition, shared surfaces, and eventual programme encoding. Media Foundation must expose `IMFDXGIBuffer`; FFmpeg must expose `AV_PIX_FMT_D3D11`. Software output, ordinary GPU-to-CPU-to-GPU frame routes, cross-adapter texture paths, and silent fallback are forbidden. + +This spike added isolated C++20 probes under `spikes/gpu`. Both candidates receive an explicitly selected adapter LUID and fail closed if a decoded resource is not a D3D11 texture owned by that device. The Media Foundation probe additionally exercises D3D11 video-processor scale, NV12 colour conversion, 90-degree rotation, and 2x2 composition without a CPU frame upload or readback. These are probes only; no engine or product compositor was implemented. + +## Evidence obtained + +On the target i5-13420H/RTX 4050 laptop, five-second smoke runs covered H.264 and HEVC for four 1080p30 streams and two 1080p60 streams. Every Media Foundation output exposed `IMFDXGIBuffer/ID3D11Texture2D`; every FFmpeg output exposed `AV_PIX_FMT_D3D11/ID3D11Texture2D`. All textures belonged to NVIDIA adapter LUID `0x0:0x16933`. Instrumented upload, readback, ordinary CPU frame-copy, and FFmpeg hardware-frame-transfer counters remained zero. Raw hashes and counts are in `docs/evidence/v4-02-gpu-smoke.json`. + +This is smoke evidence, not performance or copy-ledger acceptance. Five seconds cannot establish CPU/GPU/memory/latency distributions or memory slope, and counters without ETW/PIX corroboration cannot prove the full copy ledger. + +## Decision + +Do not choose Media Foundation or FFmpeg yet. V4-02 remains blocked and downstream production GPU decode/compositor work must not treat either backend as selected. + +Adapter policy is nevertheless reaffirmed: the caller supplies one DXGI LUID; missing adapters fail visibly; there is no automatic adapter selection, cross-adapter texture path, or silent switch. Software fallback remains disabled by default, requires explicit operator consent and a visible warning, and is excluded from hardware claims. + +Device-removal policy remains the architecture policy: stop GPU consumers, preserve independent ingress and compressed ISO, rebuild the full graph only on the same LUID when still present, otherwise remain in a visible recoverable failure. The policy was not validated by this run. + +## Incomplete acceptance cells + +- One-hour candidate × codec × workload matrix: `NOT_RUN` under the lead timebox. The drive had about 1.95 GB free before corpus generation; probes now support long-lived in-process looping of a small hashed corpus for a later run. +- CPU/GPU/private-memory/latency comparison and memory slope: `NOT_RUN`; short-lived wrapper sampling was incomplete and is not used for a decision. +- ETW/PIX copy-ledger corroboration: `NOT_RUN`; WPR is available but no elevated trace and review were completed. +- Genuine device removal with independent ingress/ISO continuity: `NOT_RUN`; Graphics Tools/dxcap was absent. A logical teardown was explicitly rejected as a substitute. +- One hardware programme encode: `NOT_RUN`; no encoder was added to this decoder/composition spike. + +## Completion command + +After installing a pinned MSVC-compatible FFmpeg development package and Windows Graphics Tools, run: + +```powershell +cmake -S spikes/gpu -B out/gpu-build -G "Visual Studio 17 2022" -A x64 -DOPENSTREAM_FFMPEG_ROOT=C:/ffmpeg-dev +cmake --build out/gpu-build --config Release +spikes/gpu/run-benchmark.ps1 -CorpusRoot out/gpu-corpus -OutputDirectory out/gpu-results -GenerateCorpus -CorpusSeconds 10 -AdapterLuid 92467 -DurationSeconds 3600 -WarmupSeconds 60 +``` + +Capture and review clean plus device-removal ETW traces, add an isolated hardware-encode probe, and publish the complete raw JSONL before revisiting this ADR. + +## Rollback + +Delete `spikes/gpu`, `tests/test_v4_gpu_spike.py`, this ADR, and its smoke-evidence file. No product or legacy path depends on the spike. diff --git a/docs/evidence/v4-02-gpu-smoke.json b/docs/evidence/v4-02-gpu-smoke.json new file mode 100644 index 0000000..63f7734 --- /dev/null +++ b/docs/evidence/v4-02-gpu-smoke.json @@ -0,0 +1,55 @@ +{ + "schema_version": 1, + "status": "SMOKE_ONLY", + "commit_tested": "6c65476a7245e479a0cc9c2bfa6bd167d6cbd875", + "environment": { + "os": "Microsoft Windows 11 Home Single Language 10.0.26200 build 26200", + "cpu": "13th Gen Intel(R) Core(TM) i5-13420H", + "logical_processors": 12, + "adapter": "NVIDIA GeForce RTX 4050 Laptop GPU", + "adapter_luid_decimal": 92467, + "adapter_luid_hex": "0x0:0x16933", + "nvidia_driver": "32.0.15.9282", + "secondary_adapter": "Intel(R) UHD Graphics", + "secondary_driver": "32.0.101.5542", + "power_plan": "Balanced", + "runtime_ffmpeg": "8.0.1-full_build-www.gyan.dev", + "native_probe_ffmpeg_sdk": "BtbN ffmpeg-master-latest-win64-gpl-shared, downloaded 2026-08-02" + }, + "command": "spikes/gpu/run-benchmark.ps1 -CorpusRoot out/gpu-corpus -OutputDirectory out/gpu-smoke-results -MediaFoundationProbe out/gpu-ninja/openstream_mf_gpu_probe.exe -FfmpegProbe out/gpu-ninja/openstream_ffmpeg_d3d11_probe.exe -AdapterLuid 92467 -DurationSeconds 5 -WarmupSeconds 1", + "workloads": [ + { + "id": "four-1080p30-h264", + "corpus_sha256": ["CA92B26DDAA387FE8FA3FA01A80CE4F290CB14A52301288292F6375A56EADD5E", "1633BF9B19809AF250FF3C2FFDC11CE8ABD8B0670D24B05D039B9A5CBD62489F", "0B6659626082284FE4FE5159EE40B1BF0E0530373D740C986EC5C711F1E53998", "7C0A847783CDCDB306FBEA2D0E1AB029BEC3390A751635934B8284D8BF2C8C34"], + "media_foundation": {"result": "SMOKE_PASS", "frames": 604, "composition_frames": 152, "layout": "2x2", "texture_type": "IMFDXGIBuffer/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0}, + "ffmpeg_d3d11": {"result": "SMOKE_PASS", "frames": 596, "texture_type": "AV_PIX_FMT_D3D11/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0, "av_hwframe_transfer_data_calls": 0} + }, + { + "id": "four-1080p30-hevc", + "corpus_sha256": ["690BCA5931E1664591D14C96D7D8FA2BE98F12B88BB07DF54354746B49DF577B", "91BE40F42413EDA6F7CC2E5EE9FA9173E829A795C155A464917A62B1DDDAD0D2", "EEA7439A446EEDFA1B0A06C8CA5E937D821B71B1D8C2EB3EC8BDC379558A7334", "66C1D29D1E926166CACF48892CDFCFA5ED73C560D21CEB53DCA7915171536FFC"], + "media_foundation": {"result": "SMOKE_PASS", "frames": 604, "composition_frames": 152, "layout": "2x2", "texture_type": "IMFDXGIBuffer/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0}, + "ffmpeg_d3d11": {"result": "SMOKE_PASS", "frames": 596, "texture_type": "AV_PIX_FMT_D3D11/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0, "av_hwframe_transfer_data_calls": 0} + }, + { + "id": "two-1080p60-h264", + "corpus_sha256": ["719BAA7C2F5CCC6F5634F0BB13DFB387E76C8D754DDA6E352A655D23167B6744", "3901065CB71E1154754AA7218545AB6D7660247140DFF3A70E320E078FF867B2"], + "media_foundation": {"result": "SMOKE_PASS", "frames": 602, "composition_frames": 302, "layout": "2x1", "texture_type": "IMFDXGIBuffer/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0}, + "ffmpeg_d3d11": {"result": "SMOKE_PASS", "frames": 598, "texture_type": "AV_PIX_FMT_D3D11/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0, "av_hwframe_transfer_data_calls": 0} + }, + { + "id": "two-1080p60-hevc", + "corpus_sha256": ["0A751D426776566E896BF2E41C3D9C14F050670942094F6557DA9B19D363B1D7", "754772E2842050C956A8C0E2010242D2453C633FFE83FBA7D7B0BA662203DF03"], + "media_foundation": {"result": "SMOKE_PASS", "frames": 602, "composition_frames": 302, "layout": "2x1", "texture_type": "IMFDXGIBuffer/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0}, + "ffmpeg_d3d11": {"result": "SMOKE_PASS", "frames": 596, "texture_type": "AV_PIX_FMT_D3D11/ID3D11Texture2D", "ordinary_cpu_frame_copies": 0, "av_hwframe_transfer_data_calls": 0} + } + ], + "reporting_correction": "The original two-stream probe summary mislabeled its two-input layout as 2x2. The evidence records the actual 2x1 input layout, and the probe now emits layout dynamically.", + "not_run": { + "one_hour_matrix": "NOT_RUN: lead timebox stopped execution after native smoke; only 1.95 GB was free before corpus generation, so large per-cell corpora were not materialized. The probes support in-process corpus looping for a future long-lived run.", + "cpu_gpu_memory_latency_decision_metrics": "NOT_RUN: five-second smoke samples are not statistically usable and the wrapper returned incomplete private-byte/GPU samples for several short-lived processes.", + "etw_copy_corroboration": "NOT_RUN: no elevated ETW capture/review was completed; WPR commands are checked in, but counter-only copy claims are not promoted to acceptance evidence.", + "device_removal_ingress_iso": "NOT_RUN: Graphics Tools/dxcap was absent and no genuine device-removal injection was performed. Logical teardown was rejected as insufficient evidence.", + "hardware_programme_encode": "NOT_RUN: the spike did not add a programme encoder; no product compositor or encoder was implemented.", + "decoder_selection": "NOT_RUN: required soak, trace, device-loss, and resource gates are incomplete." + } +} diff --git a/spikes/gpu/CMakeLists.txt b/spikes/gpu/CMakeLists.txt new file mode 100644 index 0000000..3f6567d --- /dev/null +++ b/spikes/gpu/CMakeLists.txt @@ -0,0 +1,60 @@ +cmake_minimum_required(VERSION 3.30) + +# This probe is deliberately standalone. It never adds FFmpeg to the product +# dependency graph: point OPENSTREAM_FFMPEG_ROOT at a pinned FFmpeg *development* +# package with include/ and lib/ directories. +project(openstream_v4_gpu_decision_probe LANGUAGES CXX) + +if(NOT WIN32 OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR "The FFmpeg D3D11 probe supports Windows x64 only") +endif() + +set(OPENSTREAM_FFMPEG_ROOT "$ENV{OPENSTREAM_FFMPEG_ROOT}" CACHE PATH + "Pinned FFmpeg development package root (include/ and lib/)") +if(NOT OPENSTREAM_FFMPEG_ROOT) + message(FATAL_ERROR + "Set OPENSTREAM_FFMPEG_ROOT to a pinned FFmpeg development package; runtime ffmpeg.exe is insufficient") +endif() + +find_path(OPENSTREAM_AVCODEC_INCLUDE_DIR libavcodec/avcodec.h + HINTS "${OPENSTREAM_FFMPEG_ROOT}/include" NO_DEFAULT_PATH REQUIRED) +find_library(OPENSTREAM_AVCODEC_LIBRARY NAMES avcodec avcodec-62 + HINTS "${OPENSTREAM_FFMPEG_ROOT}/lib" NO_DEFAULT_PATH REQUIRED) +find_library(OPENSTREAM_AVFORMAT_LIBRARY NAMES avformat avformat-62 + HINTS "${OPENSTREAM_FFMPEG_ROOT}/lib" NO_DEFAULT_PATH REQUIRED) +find_library(OPENSTREAM_AVUTIL_LIBRARY NAMES avutil avutil-60 + HINTS "${OPENSTREAM_FFMPEG_ROOT}/lib" NO_DEFAULT_PATH REQUIRED) + +foreach(ffmpeg_library IN ITEMS OPENSTREAM_AVCODEC_LIBRARY OPENSTREAM_AVFORMAT_LIBRARY OPENSTREAM_AVUTIL_LIBRARY) + if(NOT "${${ffmpeg_library}}" MATCHES "\\.lib$") + message(FATAL_ERROR "${ffmpeg_library} must be an MSVC-compatible .lib, not ${${ffmpeg_library}}") + endif() +endforeach() + +add_executable(openstream_ffmpeg_d3d11_probe src/ffmpeg_d3d11_probe.cpp) +target_compile_features(openstream_ffmpeg_d3d11_probe PRIVATE cxx_std_20) +target_compile_definitions(openstream_ffmpeg_d3d11_probe PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN) +target_compile_options(openstream_ffmpeg_d3d11_probe PRIVATE /permissive- /W4 /WX /utf-8) +target_include_directories(openstream_ffmpeg_d3d11_probe SYSTEM PRIVATE "${OPENSTREAM_AVCODEC_INCLUDE_DIR}") +target_link_libraries(openstream_ffmpeg_d3d11_probe PRIVATE + "${OPENSTREAM_AVFORMAT_LIBRARY}" + "${OPENSTREAM_AVCODEC_LIBRARY}" + "${OPENSTREAM_AVUTIL_LIBRARY}" + d3d11 + dxgi + psapi) +file(GLOB OPENSTREAM_FFMPEG_RUNTIME_DLLS CONFIGURE_DEPENDS "${OPENSTREAM_FFMPEG_ROOT}/bin/*.dll") +add_custom_command(TARGET openstream_ffmpeg_d3d11_probe POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${OPENSTREAM_FFMPEG_RUNTIME_DLLS} $ + COMMAND_EXPAND_LISTS) + +add_executable(openstream_mf_gpu_probe src/mf_gpu_probe.cpp) +target_compile_features(openstream_mf_gpu_probe PRIVATE cxx_std_20) +target_compile_definitions(openstream_mf_gpu_probe PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN) +target_compile_options(openstream_mf_gpu_probe PRIVATE /permissive- /W4 /WX /utf-8) +target_link_libraries(openstream_mf_gpu_probe PRIVATE + d3d11 dxgi mf mfplat mfreadwrite mfuuid ole32 shlwapi) + +include(CTest) +add_test(NAME ffmpeg_gpu_probe_help COMMAND openstream_ffmpeg_d3d11_probe --help) +add_test(NAME mf_gpu_probe_help COMMAND openstream_mf_gpu_probe --help) diff --git a/spikes/gpu/README.md b/spikes/gpu/README.md new file mode 100644 index 0000000..975504d --- /dev/null +++ b/spikes/gpu/README.md @@ -0,0 +1,25 @@ +# V4 decoder/GPU decision spike + +This directory contains benchmark probes, not the product compositor. The checked-in workload definition covers both codecs at four 1080p30 and two 1080p60. Media files are generated locally, hashed into raw results, and excluded from Git. + +The hardware gate is deliberately fail-closed. Media Foundation output must expose `IMFDXGIBuffer`; FFmpeg output must be `AV_PIX_FMT_D3D11`. Both must resolve to `ID3D11Texture2D` on the requested adapter LUID. Software frames, cross-adapter resources, readback, upload, and `av_hwframe_transfer_data` reject a hardware run. `--allow-software-fallback` only records explicit operator consent; this probe still rejects software output and does not implement fallback. + +## Build + +Use an x64 Visual Studio 2022 developer shell. Configure the FFmpeg development package root as described by the CMake diagnostic. + +```powershell +cmake -S spikes/gpu -B out/gpu-build -G "Visual Studio 17 2022" -A x64 -DOPENSTREAM_FFMPEG_ROOT=C:/ffmpeg-dev +cmake --build out/gpu-build --config Release +ctest --test-dir out/gpu-build -C Release --output-on-failure +``` + +## Benchmark + +Generate the deterministic real-media corpus once, then run the full one-hour matrix. Generation uses NVENC unless the operator explicitly opts into a warned software encoder; software-generated corpus is valid input but its generation is not counted as a hardware-path measurement. + +```powershell +spikes/gpu/run-benchmark.ps1 -CorpusRoot out/gpu-corpus -OutputDirectory out/gpu-results -GenerateCorpus -CorpusSeconds 10 -AdapterLuid -DurationSeconds 3600 -WarmupSeconds 60 +``` + +The runner writes per-second CPU/private-memory/process-GPU samples, environment and driver fingerprints, queue declarations, exact corpus hashes, and probe summaries to `metrics.jsonl`. Capture an ETW trace alongside at least one clean and one device-removal run using the commands in `trace/README.md`; large ETL files remain external and are represented by hashes in checked-in evidence. diff --git a/spikes/gpu/run-benchmark.ps1 b/spikes/gpu/run-benchmark.ps1 new file mode 100644 index 0000000..3e6e467 --- /dev/null +++ b/spikes/gpu/run-benchmark.ps1 @@ -0,0 +1,119 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$CorpusRoot, + [Parameter(Mandatory=$true)][string]$OutputDirectory, + [string]$MediaFoundationProbe = "$PSScriptRoot/../../out/gpu-build/Release/openstream_mf_gpu_probe.exe", + [string]$FfmpegProbe = "$PSScriptRoot/../../out/gpu-build/Release/openstream_ffmpeg_d3d11_probe.exe", + [UInt64]$AdapterLuid = 0, + [ValidateRange(1,86400)][int]$DurationSeconds = 3600, + [ValidateRange(0,3600)][int]$WarmupSeconds = 60, + [ValidateRange(1,600)][int]$CorpusSeconds = 10, + [switch]$GenerateCorpus, + [switch]$AllowSoftwareFallback +) + +$ErrorActionPreference = 'Stop' +$repo = (Resolve-Path "$PSScriptRoot/../..").Path +$workloads = Get-Content -Raw "$PSScriptRoot/workloads/v4-gpu-workloads.json" | ConvertFrom-Json +New-Item -ItemType Directory -Force -Path $CorpusRoot,$OutputDirectory | Out-Null + +function Write-JsonLine([string]$Path, [hashtable]$Value) { + ($Value | ConvertTo-Json -Compress -Depth 12) | Add-Content -Encoding utf8 -Path $Path +} + +function Get-EnvironmentFingerprint { + $os = Get-CimInstance Win32_OperatingSystem + $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 + $gpus = @(Get-CimInstance Win32_VideoController | ForEach-Object { + @{ name=$_.Name; driver_version=$_.DriverVersion; adapter_ram_bytes=[UInt64]$_.AdapterRAM } + }) + return @{ + os="$($os.Caption) $($os.Version) build $($os.BuildNumber)" + cpu=$cpu.Name; logical_processors=[int]$cpu.NumberOfLogicalProcessors; gpus=$gpus + power_plan=(powercfg /getactivescheme | Out-String).Trim() + ffmpeg_version=((& ffmpeg -version 2>&1 | Select-Object -First 1) -join '') + commit=(git -C $repo rev-parse HEAD) + } +} + +function Resolve-Inputs($workload) { + $directory = Join-Path $CorpusRoot $workload.id + if ($GenerateCorpus -and !(Test-Path $directory)) { + & "$repo/tools/stream-simulator/generate-media.ps1" -OutputDirectory $directory ` + -Streams $workload.streams -Fps $workload.fps -Codec $workload.codec -Seconds $CorpusSeconds + if ($LASTEXITCODE -ne 0) { throw "corpus generation failed for $($workload.id)" } + } + $inputs = @(Get-ChildItem $directory -Recurse -Filter '*.mkv' | Sort-Object FullName | Select-Object -First $workload.streams) + if ($inputs.Count -ne $workload.streams) { throw "$($workload.id) requires $($workload.streams) checked workload inputs" } + return $inputs +} + +function Invoke-Probe([string]$Backend, [string]$Executable, $Workload, $Inputs, [string]$MetricsPath) { + if (!(Test-Path $Executable)) { throw "$Backend probe not found: $Executable" } + $stdout = Join-Path $OutputDirectory "$($Workload.id)-$Backend-summary.json" + $stderr = Join-Path $OutputDirectory "$($Workload.id)-$Backend-stderr.log" + $high = [UInt32]($AdapterLuid -shr 32); $low = [UInt32]($AdapterLuid -band 0xffffffffL) + $luidText = ('0x{0:X}:0x{1:X}' -f $high,$low) + $arguments = @('--adapter-luid', $(if ($Backend -eq 'ffmpeg-d3d11') {$luidText} else {$AdapterLuid}), '--realtime', '--duration-seconds', $DurationSeconds) + foreach ($input in $Inputs) { $arguments += @('--input', $input.FullName) } + if ($Backend -eq 'ffmpeg-d3d11') { + $arguments += @('--metrics', (Join-Path $OutputDirectory "$($Workload.id)-ffmpeg-native.jsonl"), '--queue-capacity', 4) + } elseif ($AllowSoftwareFallback) { $arguments += '--allow-software-fallback' } + $process = Start-Process -FilePath $Executable -ArgumentList $arguments -PassThru -NoNewWindow ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + $startedAt = $process.StartTime + $previousCpu = [TimeSpan]::Zero + $previousAt = Get-Date + while (!$process.HasExited) { + Start-Sleep -Seconds 1 + $process.Refresh() + if ($process.HasExited) { break } + $now = Get-Date + $cpuDelta = ($process.TotalProcessorTime - $previousCpu).TotalSeconds + $wallDelta = ($now - $previousAt).TotalSeconds + $cpuPercent = if ($wallDelta -gt 0) { 100 * $cpuDelta / $wallDelta / [Environment]::ProcessorCount } else { 0 } + $gpu = 0.0 + try { + $samples = (Get-Counter '\GPU Engine(*)\Utilization Percentage' -ErrorAction Stop).CounterSamples | + Where-Object InstanceName -match "pid_$($process.Id)_" + if ($samples) { $gpu = ($samples | Measure-Object CookedValue -Sum).Sum } + } catch { } + Write-JsonLine $MetricsPath @{ + schema_version=1; type='sample'; backend=$Backend; workload=$Workload.id + elapsed_s=[math]::Round(($now - $startedAt).TotalSeconds,3) + warmup=(($now - $startedAt).TotalSeconds -lt $WarmupSeconds) + cpu_percent=[math]::Round($cpuPercent,3); private_mib=[math]::Round($process.PrivateMemorySize64 / 1MB,3) + gpu_process_percent=[math]::Round($gpu,3); adapter_luid=$AdapterLuid + } + $previousCpu = $process.TotalProcessorTime; $previousAt = $now + if (($now - $startedAt).TotalSeconds -gt ($DurationSeconds + 120)) { + Stop-Process -Id $process.Id -Force + throw "$Backend exceeded duration guard" + } + } + $process.WaitForExit() + $process.Refresh() + $summary = Get-Content -Raw $stdout | ConvertFrom-Json + if ($summary.result -ne 'PASS') { throw "$Backend failed for $($Workload.id); see $stderr" } + Write-JsonLine $MetricsPath @{schema_version=1; type='summary'; backend=$Backend; workload=$Workload.id; probe=$summary} +} + +$metricsPath = Join-Path $OutputDirectory 'metrics.jsonl' +if (Test-Path $metricsPath) { Remove-Item -LiteralPath $metricsPath } +Write-JsonLine $metricsPath @{schema_version=1; type='environment'; environment=(Get-EnvironmentFingerprint); adapter_luid=$AdapterLuid} + +foreach ($workload in $workloads.workloads) { + $inputs = Resolve-Inputs $workload + $hashes = @($inputs | ForEach-Object { @{path=$_.FullName; sha256=(Get-FileHash $_.FullName -Algorithm SHA256).Hash} }) + Write-JsonLine $metricsPath @{ + schema_version=1; type='workload'; workload=$workload; corpus=$hashes + duration_seconds=$DurationSeconds; warmup_seconds=$WarmupSeconds + queues=@($workloads.decoded_preview_queue,$workloads.programme_queue,$workloads.ingress_queue_per_stream,$workloads.iso_queue_per_stream) + } + Invoke-Probe 'media-foundation' $MediaFoundationProbe $workload $inputs $metricsPath + Invoke-Probe 'ffmpeg-d3d11' $FfmpegProbe $workload $inputs $metricsPath +} + +& python "$PSScriptRoot/validate-results.py" $metricsPath +if ($LASTEXITCODE -ne 0) { throw 'GPU results failed validation' } +Write-Output "GPU benchmark results: $metricsPath" diff --git a/spikes/gpu/src/ffmpeg_d3d11_probe.cpp b/spikes/gpu/src/ffmpeg_d3d11_probe.cpp new file mode 100644 index 0000000..7dab5e7 --- /dev/null +++ b/spikes/gpu/src/ffmpeg_d3d11_probe.cpp @@ -0,0 +1,795 @@ +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using Clock = std::chrono::steady_clock; + +struct ComReleaser { + template + void operator()(T* value) const noexcept { + if (value != nullptr) { + value->Release(); + } + } +}; + +template +using ComPtr = std::unique_ptr; + +struct AvFormatCloser { + void operator()(AVFormatContext* value) const noexcept { + if (value != nullptr) { + avformat_close_input(&value); + } + } +}; + +struct AvCodecCloser { + void operator()(AVCodecContext* value) const noexcept { + avcodec_free_context(&value); + } +}; + +struct AvPacketCloser { + void operator()(AVPacket* value) const noexcept { + av_packet_free(&value); + } +}; + +struct AvFrameCloser { + void operator()(AVFrame* value) const noexcept { + av_frame_free(&value); + } +}; + +struct AvBufferCloser { + void operator()(AVBufferRef* value) const noexcept { + av_buffer_unref(&value); + } +}; + +using FormatPtr = std::unique_ptr; +using CodecPtr = std::unique_ptr; +using PacketPtr = std::unique_ptr; +using FramePtr = std::unique_ptr; +using BufferPtr = std::unique_ptr; + +[[nodiscard]] std::string AvError(const int error) { + char buffer[AV_ERROR_MAX_STRING_SIZE]{}; + av_strerror(error, buffer, sizeof(buffer)); + return buffer; +} + +[[nodiscard]] std::string HexLuid(const LUID& luid) { + std::ostringstream value; + value << "0x" << std::hex << std::uppercase << static_cast(luid.HighPart) + << ":0x" << static_cast(luid.LowPart); + return value.str(); +} + +[[nodiscard]] bool SameLuid(const LUID& left, const LUID& right) noexcept { + return left.HighPart == right.HighPart && left.LowPart == right.LowPart; +} + +[[nodiscard]] std::optional ParseLuid(const std::string_view text) { + const std::size_t separator = text.find(':'); + if (separator == std::string_view::npos || text.find(':', separator + 1) != std::string_view::npos) { + return std::nullopt; + } + try { + std::size_t parsed = 0; + const unsigned long high = std::stoul(std::string(text.substr(0, separator)), &parsed, 0); + if (parsed != separator) { + return std::nullopt; + } + const std::string low_text(text.substr(separator + 1)); + const unsigned long low = std::stoul(low_text, &parsed, 0); + if (parsed != low_text.size() || high > std::numeric_limits::max() || + low > std::numeric_limits::max()) { + return std::nullopt; + } + return LUID{static_cast(low), static_cast(static_cast(high))}; + } catch (const std::exception&) { + return std::nullopt; + } +} + +[[nodiscard]] std::string JsonEscape(const std::string_view value) { + std::ostringstream result; + for (const unsigned char character : value) { + switch (character) { + case '\\': result << "\\\\"; break; + case '"': result << "\\\""; break; + case '\n': result << "\\n"; break; + case '\r': result << "\\r"; break; + case '\t': result << "\\t"; break; + default: + if (character < 0x20U) { + result << "\\u" << std::setw(4) << std::setfill('0') << std::hex + << static_cast(character) << std::dec << std::setfill(' '); + } else { + result << static_cast(character); + } + } + } + return result.str(); +} + +struct Options { + std::vector inputs; + LUID adapter_luid{}; + std::size_t output_queue_capacity = 4; + std::string metrics_path; + bool list_adapters = false; + bool realtime = false; + std::uint64_t duration_seconds = 0; +}; + +[[noreturn]] void Usage(const char* const executable, const std::string_view reason = {}) { + if (!reason.empty()) { + std::cerr << "error: " << reason << '\n'; + } + std::cerr << "Usage:\n " << executable + << " --list-adapters\n " << executable + << " --adapter-luid 0xHIGH:0xLOW --input corpus-1.mkv [--input corpus-2.mkv ...]" + " [--queue-capacity 4] --metrics metrics.jsonl\n" + "\nThe adapter LUID is mandatory for decoding; the probe never chooses a fallback adapter.\n"; + std::exit(reason.empty() ? EXIT_SUCCESS : EXIT_FAILURE); +} + +[[nodiscard]] Options ParseOptions(const int argc, char** argv) { + Options options; + bool has_luid = false; + for (int index = 1; index < argc; ++index) { + const std::string_view argument(argv[index]); + const auto require_value = [&]() -> std::string_view { + if (++index >= argc) { + Usage(argv[0], "missing value for " + std::string(argument)); + } + return argv[index]; + }; + if (argument == "--list-adapters") { + options.list_adapters = true; + } else if (argument == "--adapter-luid") { + const auto luid = ParseLuid(require_value()); + if (!luid.has_value()) { + Usage(argv[0], "adapter LUID must be formatted as 0xHIGH:0xLOW"); + } + options.adapter_luid = *luid; + has_luid = true; + } else if (argument == "--input") { + options.inputs.emplace_back(require_value()); + } else if (argument == "--metrics") { + options.metrics_path = require_value(); + } else if (argument == "--queue-capacity") { + try { + const auto parsed = std::stoull(std::string(require_value())); + if (parsed == 0 || parsed > 1024) { + Usage(argv[0], "queue capacity must be between 1 and 1024"); + } + options.output_queue_capacity = static_cast(parsed); + } catch (const std::exception&) { + Usage(argv[0], "queue capacity must be an integer"); + } + } else if (argument == "--realtime") { + options.realtime = true; + } else if (argument == "--duration-seconds") { + options.duration_seconds = std::stoull(std::string(require_value())); + } else if (argument == "--help" || argument == "-h") { + Usage(argv[0]); + } else { + Usage(argv[0], "unknown argument " + std::string(argument)); + } + } + if (!options.list_adapters && (!has_luid || options.inputs.empty() || options.metrics_path.empty())) { + Usage(argv[0], "--adapter-luid, at least one --input, and --metrics are required"); + } + return options; +} + +void ListAdapters() { + IDXGIFactory6* raw_factory = nullptr; + const HRESULT result = CreateDXGIFactory1(IID_PPV_ARGS(&raw_factory)); + if (FAILED(result)) { + throw std::runtime_error("CreateDXGIFactory1 failed"); + } + ComPtr factory(raw_factory); + for (UINT index = 0;; ++index) { + IDXGIAdapter1* raw_adapter = nullptr; + const HRESULT enumerated = factory->EnumAdapterByGpuPreference( + index, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE, IID_PPV_ARGS(&raw_adapter)); + if (enumerated == DXGI_ERROR_NOT_FOUND) { + return; + } + if (FAILED(enumerated)) { + throw std::runtime_error("EnumAdapterByGpuPreference failed"); + } + ComPtr adapter(raw_adapter); + DXGI_ADAPTER_DESC1 description{}; + if (FAILED(adapter->GetDesc1(&description))) { + throw std::runtime_error("IDXGIAdapter1::GetDesc1 failed"); + } + std::cout << "adapter=" << index << " luid=" << HexLuid(description.AdapterLuid) << '\n'; + std::wcout << L" name=" << description.Description << L'\n'; + } +} + +struct Device { + ComPtr d3d_device; + ComPtr d3d_context; + BufferPtr ffmpeg_device; + LUID luid{}; + + explicit Device(const LUID& requested_luid) : luid(requested_luid) { + ComPtr adapter; + { // COM out parameters are easier with a raw temporary, then transferred to RAII. + IDXGIFactory1* raw_factory = nullptr; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&raw_factory)))) { + throw std::runtime_error("CreateDXGIFactory1 failed"); + } + ComPtr factory(raw_factory); + for (UINT index = 0;; ++index) { + IDXGIAdapter1* raw_adapter = nullptr; + const HRESULT enumerated = factory->EnumAdapters1(index, &raw_adapter); + if (enumerated == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(enumerated)) { + throw std::runtime_error("IDXGIFactory1::EnumAdapters1 failed"); + } + ComPtr candidate(raw_adapter); + DXGI_ADAPTER_DESC1 description{}; + if (FAILED(candidate->GetDesc1(&description))) { + throw std::runtime_error("IDXGIAdapter1::GetDesc1 failed"); + } + if (SameLuid(description.AdapterLuid, requested_luid)) { + adapter = std::move(candidate); + break; + } + } + } + if (!adapter) { + throw std::runtime_error("requested adapter LUID is not present; refusing silent adapter switch"); + } + + ID3D11Device* raw_device = nullptr; + ID3D11DeviceContext* raw_context = nullptr; + D3D_FEATURE_LEVEL feature_level{}; + const D3D_FEATURE_LEVEL levels[] = { + D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0}; + const HRESULT created = D3D11CreateDevice(adapter.get(), D3D_DRIVER_TYPE_UNKNOWN, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT, levels, + static_cast(std::size(levels)), D3D11_SDK_VERSION, &raw_device, &feature_level, &raw_context); + if (FAILED(created)) { + throw std::runtime_error("D3D11CreateDevice on selected LUID failed"); + } + d3d_device.reset(raw_device); + d3d_context.reset(raw_context); + + AVBufferRef* raw_hw_device = av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_D3D11VA); + if (raw_hw_device == nullptr) { + throw std::runtime_error("av_hwdevice_ctx_alloc(D3D11VA) failed"); + } + ffmpeg_device.reset(raw_hw_device); + auto* const hw_device = reinterpret_cast(ffmpeg_device->data); + auto* const d3d11_device = reinterpret_cast(hw_device->hwctx); + // FFmpeg takes ownership of this reference during av_hwdevice_ctx_init. + d3d_device->AddRef(); + d3d_context->AddRef(); + d3d11_device->device = d3d_device.get(); + d3d11_device->device_context = d3d_context.get(); + const int initialized = av_hwdevice_ctx_init(ffmpeg_device.get()); + if (initialized < 0) { + d3d11_device->device = nullptr; + d3d11_device->device_context = nullptr; + d3d_device->Release(); + d3d_context->Release(); + throw std::runtime_error("av_hwdevice_ctx_init(D3D11VA): " + AvError(initialized)); + } + } +}; + +struct Counters { + std::uint64_t packets_read = 0; + std::uint64_t packets_sent = 0; + std::uint64_t frames_decoded = 0; + std::uint64_t texture_frames = 0; + std::uint64_t software_frames_rejected = 0; + std::uint64_t hwframe_transfers = 0; + std::uint64_t cpu_uploads = 0; + std::uint64_t cpu_readbacks = 0; + std::uint64_t cpu_frame_copies = 0; + std::uint64_t queue_overflows = 0; + std::uint64_t packet_timestamp_evictions = 0; + std::uint64_t device_removed = 0; + std::uint64_t device_reset = 0; + std::uint64_t device_hung = 0; + std::uint64_t device_failure = 0; +}; + +struct FrameRecord { + FramePtr frame; + Clock::time_point packet_submit_time; + std::int64_t pts = AV_NOPTS_VALUE; +}; + +class BoundedFrameQueue { + public: + explicit BoundedFrameQueue(const std::size_t capacity) : capacity_(capacity) {} + + [[nodiscard]] bool TryPush(FrameRecord value) { + if (frames_.size() >= capacity_) { + ++overflows_; + return false; + } + frames_.push_back(std::move(value)); + high_water_ = std::max(high_water_, frames_.size()); + return true; + } + + [[nodiscard]] std::optional TryPop() { + if (frames_.empty()) { + return std::nullopt; + } + FrameRecord result = std::move(frames_.front()); + frames_.pop_front(); + return result; + } + + [[nodiscard]] std::size_t capacity() const noexcept { return capacity_; } + [[nodiscard]] std::size_t depth() const noexcept { return frames_.size(); } + [[nodiscard]] std::size_t high_water() const noexcept { return high_water_; } + [[nodiscard]] std::size_t overflows() const noexcept { return overflows_; } + + private: + std::size_t capacity_; + std::size_t high_water_ = 0; + std::size_t overflows_ = 0; + std::deque frames_; +}; + +struct Session { + std::string input; + FormatPtr format; + CodecPtr codec; + int stream_index = -1; + PacketPtr packet{av_packet_alloc()}; + FramePtr frame{av_frame_alloc()}; + BoundedFrameQueue queue; + std::deque> packet_times; + bool input_finished = false; + bool flushed = false; + bool failed = false; + bool realtime = false; + std::int64_t first_pts = AV_NOPTS_VALUE; + Clock::time_point playback_started{}; + + Session(std::string input_path, const std::size_t queue_capacity) + : input(std::move(input_path)), queue(queue_capacity) { + if (!packet || !frame) { + throw std::runtime_error("FFmpeg packet/frame allocation failed"); + } + } +}; + +[[nodiscard]] AVPixelFormat RequireD3D11Format(AVCodecContext* context, const AVPixelFormat* formats) { + for (const AVPixelFormat* current = formats; *current != AV_PIX_FMT_NONE; ++current) { + if (*current == AV_PIX_FMT_D3D11) { + return *current; + } + } + std::cerr << "decoder " << (context->codec == nullptr ? "unknown" : context->codec->name) + << " did not offer AV_PIX_FMT_D3D11; rejecting software fallback\n"; + return AV_PIX_FMT_NONE; +} + +void OpenSession(Session& session, Device& device) { + AVFormatContext* raw_format = nullptr; + int result = avformat_open_input(&raw_format, session.input.c_str(), nullptr, nullptr); + if (result < 0) { + throw std::runtime_error("avformat_open_input(" + session.input + "): " + AvError(result)); + } + session.format.reset(raw_format); + result = avformat_find_stream_info(session.format.get(), nullptr); + if (result < 0) { + throw std::runtime_error("avformat_find_stream_info(" + session.input + "): " + AvError(result)); + } + result = av_find_best_stream(session.format.get(), AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); + if (result < 0) { + throw std::runtime_error("no video stream in " + session.input + ": " + AvError(result)); + } + session.stream_index = result; + const AVCodecParameters* const parameters = session.format->streams[session.stream_index]->codecpar; + const AVCodec* const decoder = avcodec_find_decoder(parameters->codec_id); + if (decoder == nullptr) { + throw std::runtime_error("no FFmpeg decoder for " + std::string(avcodec_get_name(parameters->codec_id))); + } + session.codec.reset(avcodec_alloc_context3(decoder)); + if (!session.codec) { + throw std::runtime_error("avcodec_alloc_context3 failed"); + } + result = avcodec_parameters_to_context(session.codec.get(), parameters); + if (result < 0) { + throw std::runtime_error("avcodec_parameters_to_context: " + AvError(result)); + } + session.codec->get_format = RequireD3D11Format; + session.codec->hw_device_ctx = av_buffer_ref(device.ffmpeg_device.get()); + if (session.codec->hw_device_ctx == nullptr) { + throw std::runtime_error("av_buffer_ref(D3D11VA device) failed"); + } + result = avcodec_open2(session.codec.get(), decoder, nullptr); + if (result < 0) { + throw std::runtime_error("avcodec_open2(" + session.input + "): " + AvError(result)); + } +} + +[[nodiscard]] std::optional FindPacketTime(Session& session, const std::int64_t pts) { + if (pts == AV_NOPTS_VALUE) { + return std::nullopt; + } + const auto found = std::find_if(session.packet_times.begin(), session.packet_times.end(), + [pts](const auto& value) { return value.first == pts; }); + if (found == session.packet_times.end()) { + return std::nullopt; + } + const Clock::time_point result = found->second; + session.packet_times.erase(found); + return result; +} + +[[nodiscard]] bool TextureIsOnSelectedAdapter(ID3D11Texture2D* texture, const LUID& selected_luid) { + ID3D11Device* raw_device = nullptr; + texture->GetDevice(&raw_device); + ComPtr texture_device(raw_device); + IDXGIDevice* raw_dxgi_device = nullptr; + if (FAILED(texture_device->QueryInterface(IID_PPV_ARGS(&raw_dxgi_device)))) { + return false; + } + ComPtr dxgi_device(raw_dxgi_device); + IDXGIAdapter* raw_adapter = nullptr; + if (FAILED(dxgi_device->GetAdapter(&raw_adapter))) { + return false; + } + ComPtr adapter(raw_adapter); + DXGI_ADAPTER_DESC description{}; + return SUCCEEDED(adapter->GetDesc(&description)) && SameLuid(description.AdapterLuid, selected_luid); +} + +void InspectFrame(Session& session, Device& device, Counters& counters) { + ++counters.frames_decoded; + if (session.frame->format != AV_PIX_FMT_D3D11 || session.frame->data[0] == nullptr) { + ++counters.software_frames_rejected; + throw std::runtime_error("decoder returned a non-D3D11 frame; software fallback is rejected"); + } + auto* const texture = reinterpret_cast(session.frame->data[0]); + D3D11_TEXTURE2D_DESC description{}; + texture->GetDesc(&description); + if (description.Width == 0 || description.Height == 0 || !TextureIsOnSelectedAdapter(texture, device.luid)) { + throw std::runtime_error("decoder texture is not valid on the selected adapter; cross-adapter path rejected"); + } + ++counters.texture_frames; + + const std::int64_t pts = session.frame->best_effort_timestamp; + if (session.realtime && pts != AV_NOPTS_VALUE) { + if (session.first_pts == AV_NOPTS_VALUE) { + session.first_pts = pts; + session.playback_started = Clock::now(); + } + const AVRational time_base = session.format->streams[session.stream_index]->time_base; + const auto target = session.playback_started + std::chrono::nanoseconds( + av_rescale_q(pts - session.first_pts, time_base, AVRational{1, 1000000000})); + if (target > Clock::now()) std::this_thread::sleep_until(target); + } + const auto submitted = FindPacketTime(session, pts).value_or(Clock::now()); + FramePtr retained(av_frame_clone(session.frame.get())); + if (!retained) { + throw std::runtime_error("av_frame_clone failed"); + } + if (!session.queue.TryPush(FrameRecord{std::move(retained), submitted, pts})) { + ++counters.queue_overflows; + } + av_frame_unref(session.frame.get()); +} + +void DrainFrames(Session& session, Device& device, Counters& counters) { + for (;;) { + const int result = avcodec_receive_frame(session.codec.get(), session.frame.get()); + if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) { + return; + } + if (result < 0) { + throw std::runtime_error("avcodec_receive_frame(" + session.input + "): " + AvError(result)); + } + InspectFrame(session, device, counters); + } +} + +[[nodiscard]] bool PumpSession(Session& session, Device& device, Counters& counters) { + if (session.input_finished) { + return false; + } + av_packet_unref(session.packet.get()); + const int read_result = av_read_frame(session.format.get(), session.packet.get()); + if (read_result == AVERROR_EOF) { + session.input_finished = true; + return false; + } + if (read_result < 0) { + throw std::runtime_error("av_read_frame(" + session.input + "): " + AvError(read_result)); + } + ++counters.packets_read; + if (session.packet->stream_index != session.stream_index) { + return true; + } + const std::int64_t packet_timestamp = session.packet->pts == AV_NOPTS_VALUE + ? session.packet->dts : session.packet->pts; + if (packet_timestamp != AV_NOPTS_VALUE) { + constexpr std::size_t kPacketTimestampCapacity = 512; + if (session.packet_times.size() == kPacketTimestampCapacity) { + session.packet_times.pop_front(); + ++counters.packet_timestamp_evictions; + } + session.packet_times.emplace_back(packet_timestamp, Clock::now()); + } + const int sent = avcodec_send_packet(session.codec.get(), session.packet.get()); + if (sent == AVERROR(EAGAIN)) { + DrainFrames(session, device, counters); + const int resent = avcodec_send_packet(session.codec.get(), session.packet.get()); + if (resent < 0) { + throw std::runtime_error("avcodec_send_packet retry(" + session.input + "): " + AvError(resent)); + } + } else if (sent < 0) { + throw std::runtime_error("avcodec_send_packet(" + session.input + "): " + AvError(sent)); + } + ++counters.packets_sent; + DrainFrames(session, device, counters); + return true; +} + +void FlushSession(Session& session, Device& device, Counters& counters) { + if (session.flushed) { + return; + } + const int result = avcodec_send_packet(session.codec.get(), nullptr); + if (result < 0 && result != AVERROR_EOF) { + throw std::runtime_error("avcodec_send_packet flush(" + session.input + "): " + AvError(result)); + } + DrainFrames(session, device, counters); + session.flushed = true; +} + +[[nodiscard]] double ProcessCpuPercent(const Clock::time_point started, const std::uint64_t initial_cpu_100ns) { + FILETIME created{}; + FILETIME exited{}; + FILETIME kernel{}; + FILETIME user{}; + if (!GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user)) { + return -1.0; + } + ULARGE_INTEGER kernel_value{}; + kernel_value.LowPart = kernel.dwLowDateTime; + kernel_value.HighPart = kernel.dwHighDateTime; + ULARGE_INTEGER user_value{}; + user_value.LowPart = user.dwLowDateTime; + user_value.HighPart = user.dwHighDateTime; + const std::uint64_t cpu_100ns = kernel_value.QuadPart + user_value.QuadPart; + const double elapsed_100ns = static_cast(std::chrono::duration_cast(Clock::now() - started).count()) / 100.0; + const DWORD logical_processors = GetActiveProcessorCount(ALL_PROCESSOR_GROUPS); + return elapsed_100ns <= 0.0 || logical_processors == 0 ? 0.0 + : (100.0 * static_cast(cpu_100ns - initial_cpu_100ns) / + (elapsed_100ns * static_cast(logical_processors))); +} + +[[nodiscard]] std::uint64_t ProcessCpu100ns() { + FILETIME created{}; + FILETIME exited{}; + FILETIME kernel{}; + FILETIME user{}; + if (!GetProcessTimes(GetCurrentProcess(), &created, &exited, &kernel, &user)) { + throw std::runtime_error("GetProcessTimes failed"); + } + ULARGE_INTEGER kernel_value{}; + kernel_value.LowPart = kernel.dwLowDateTime; + kernel_value.HighPart = kernel.dwHighDateTime; + ULARGE_INTEGER user_value{}; + user_value.LowPart = user.dwLowDateTime; + user_value.HighPart = user.dwHighDateTime; + return kernel_value.QuadPart + user_value.QuadPart; +} + +[[nodiscard]] std::uint64_t PrivateBytes() { + PROCESS_MEMORY_COUNTERS_EX counters{}; + if (!GetProcessMemoryInfo(GetCurrentProcess(), reinterpret_cast(&counters), sizeof(counters))) { + return 0; + } + return static_cast(counters.PrivateUsage); +} + +[[nodiscard]] std::uint64_t LocalVideoMemoryBytes(Device& device) { + IDXGIDevice* raw_dxgi_device = nullptr; + if (FAILED(device.d3d_device->QueryInterface(IID_PPV_ARGS(&raw_dxgi_device)))) { + return 0; + } + ComPtr dxgi_device(raw_dxgi_device); + IDXGIAdapter* raw_adapter = nullptr; + if (FAILED(dxgi_device->GetAdapter(&raw_adapter))) { + return 0; + } + ComPtr adapter(raw_adapter); + IDXGIAdapter3* raw_adapter3 = nullptr; + if (FAILED(adapter->QueryInterface(IID_PPV_ARGS(&raw_adapter3)))) { + return 0; + } + ComPtr adapter3(raw_adapter3); + DXGI_QUERY_VIDEO_MEMORY_INFO information{}; + return SUCCEEDED(adapter3->QueryVideoMemoryInfo(0, DXGI_MEMORY_SEGMENT_GROUP_LOCAL, &information)) + ? information.CurrentUsage : 0; +} + +void WriteMetric(std::ofstream& metrics, const Clock::time_point started, const std::uint64_t initial_cpu_100ns, + Device& device, const std::vector>& sessions, const Counters& counters, + const std::string_view event, const std::optional frame_latency_ms = std::nullopt) { + std::size_t queue_depth = 0; + std::size_t queue_high_water = 0; + std::size_t queue_overflows = 0; + for (const auto& session : sessions) { + queue_depth += session->queue.depth(); + queue_high_water += session->queue.high_water(); + queue_overflows += session->queue.overflows(); + } + const auto elapsed_ms = std::chrono::duration_cast(Clock::now() - started).count(); + metrics << std::fixed << std::setprecision(3) + << "{\"schema_version\":1,\"event\":\"" << JsonEscape(event) + << "\",\"elapsed_ms\":" << elapsed_ms + << ",\"adapter_luid\":\"" << HexLuid(device.luid) + << "\",\"cpu_process_pct\":" << ProcessCpuPercent(started, initial_cpu_100ns) + << ",\"private_bytes\":" << PrivateBytes() + << ",\"gpu_local_memory_bytes\":" << LocalVideoMemoryBytes(device) + << ",\"gpu_decode_utilisation_pct\":null,\"gpu_utilisation_source\":\"ETW_required\"" + << ",\"packets_read\":" << counters.packets_read + << ",\"packets_sent\":" << counters.packets_sent + << ",\"frames_decoded\":" << counters.frames_decoded + << ",\"texture_frames\":" << counters.texture_frames + << ",\"software_frames_rejected\":" << counters.software_frames_rejected + << ",\"av_hwframe_transfer_data_calls\":" << counters.hwframe_transfers + << ",\"cpu_uploads\":" << counters.cpu_uploads + << ",\"cpu_readbacks\":" << counters.cpu_readbacks + << ",\"cpu_frame_copies\":" << counters.cpu_frame_copies + << ",\"queue_capacity_per_session\":" << (sessions.empty() ? 0 : sessions.front()->queue.capacity()) + << ",\"queue_depth\":" << queue_depth + << ",\"queue_high_water_sum\":" << queue_high_water + << ",\"queue_overflows\":" << queue_overflows + << ",\"packet_timestamp_evictions\":" << counters.packet_timestamp_evictions + << ",\"device_removed\":" << counters.device_removed + << ",\"device_reset\":" << counters.device_reset + << ",\"device_hung\":" << counters.device_hung + << ",\"device_failure\":" << counters.device_failure + << ",\"fallback\":\"disabled\",\"texture_format\":\"AV_PIX_FMT_D3D11\""; + if (frame_latency_ms.has_value()) { + metrics << ",\"packet_to_texture_latency_ms\":" << *frame_latency_ms; + } + metrics << "}\n"; + metrics.flush(); +} + +void DrainQueues(const std::vector>& sessions, std::ofstream& metrics, + const Clock::time_point started, const std::uint64_t initial_cpu_100ns, Device& device, const Counters& counters) { + for (const auto& session : sessions) { + while (auto record = session->queue.TryPop()) { + const double latency = std::chrono::duration(Clock::now() - record->packet_submit_time).count(); + WriteMetric(metrics, started, initial_cpu_100ns, device, sessions, counters, "texture_frame", latency); + } + } +} + +} // namespace + +int main(const int argc, char** argv) { + try { + const Options options = ParseOptions(argc, argv); + if (options.list_adapters) { + ListAdapters(); + return EXIT_SUCCESS; + } + + Device device(options.adapter_luid); + std::ofstream metrics(options.metrics_path, std::ios::out | std::ios::trunc); + if (!metrics) { + throw std::runtime_error("cannot create metrics file " + options.metrics_path); + } + std::vector> sessions; + sessions.reserve(options.inputs.size()); + for (const std::string& input : options.inputs) { + auto session = std::make_unique(input, options.output_queue_capacity); + session->realtime = options.realtime; + OpenSession(*session, device); + sessions.push_back(std::move(session)); + } + + Counters counters; + const Clock::time_point started = Clock::now(); + const std::uint64_t initial_cpu_100ns = ProcessCpu100ns(); + WriteMetric(metrics, started, initial_cpu_100ns, device, sessions, counters, "started"); + Clock::time_point next_sample = started + std::chrono::seconds(1); + bool made_progress = true; + const auto deadline = options.duration_seconds == 0 ? Clock::time_point::max() + : started + std::chrono::seconds(options.duration_seconds); + while (made_progress && Clock::now() < deadline) { + made_progress = false; + for (const auto& session : sessions) { + made_progress = PumpSession(*session, device, counters) || made_progress; + } + DrainQueues(sessions, metrics, started, initial_cpu_100ns, device, counters); + if (Clock::now() >= next_sample) { + WriteMetric(metrics, started, initial_cpu_100ns, device, sessions, counters, "sample"); + next_sample += std::chrono::seconds(1); + } + if (!made_progress && options.duration_seconds != 0 && Clock::now() < deadline) { + for (const auto& session : sessions) { + FlushSession(*session, device, counters); + if (av_seek_frame(session->format.get(), session->stream_index, 0, AVSEEK_FLAG_BACKWARD) < 0) + throw std::runtime_error("cannot loop corpus " + session->input); + avcodec_flush_buffers(session->codec.get()); + session->input_finished = false; + session->flushed = false; + session->first_pts = AV_NOPTS_VALUE; + session->packet_times.clear(); + } + made_progress = true; + } + } + for (const auto& session : sessions) { + FlushSession(*session, device, counters); + } + DrainQueues(sessions, metrics, started, initial_cpu_100ns, device, counters); + WriteMetric(metrics, started, initial_cpu_100ns, device, sessions, counters, "completed"); + if (counters.texture_frames == 0 || counters.software_frames_rejected != 0 || counters.hwframe_transfers != 0 || counters.cpu_uploads != 0 || + counters.cpu_readbacks != 0 || counters.cpu_frame_copies != 0) { + throw std::runtime_error("hardware copy gate failed; see JSONL metrics"); + } + std::cout << "{\"schema_version\":1,\"backend\":\"ffmpeg-d3d11\",\"result\":\"PASS\"," + "\"adapter_luid\":\"" << HexLuid(device.luid) << "\"," + "\"texture_type\":\"AV_PIX_FMT_D3D11/ID3D11Texture2D\"," + "\"ordinary_cpu_frame_copies\":0,\"cpu_frame_uploads\":0,\"cpu_frame_readbacks\":0," + "\"av_hwframe_transfer_data_calls\":0,\"frames\":" << counters.texture_frames << "}\n"; + return EXIT_SUCCESS; + } catch (const std::exception& error) { + std::cerr << "FFmpeg D3D11 probe failed: " << error.what() << '\n'; + return EXIT_FAILURE; + } +} diff --git a/spikes/gpu/src/mf_gpu_probe.cpp b/spikes/gpu/src/mf_gpu_probe.cpp new file mode 100644 index 0000000..21cc9f3 --- /dev/null +++ b/spikes/gpu/src/mf_gpu_probe.cpp @@ -0,0 +1,362 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +namespace { + +[[noreturn]] void fail(const std::string& message, HRESULT hr = E_FAIL) { + std::ostringstream out; + out << message << " (HRESULT=0x" << std::hex << static_cast(hr) << ')'; + throw std::runtime_error(out.str()); +} + +void check(HRESULT hr, const char* operation) { + if (FAILED(hr)) fail(operation, hr); +} + +std::string json_escape(const std::string& value) { + std::ostringstream out; + for (const unsigned char c : value) { + switch (c) { + case '\\': out << "\\\\"; break; + case '"': out << "\\\""; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: + if (c < 0x20) out << "\\u" << std::hex << static_cast(c); + else out << c; + } + } + return out.str(); +} + +std::string narrow(const wchar_t* text) { + if (text == nullptr) return {}; + const int size = WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr); + std::string result(static_cast(size > 0 ? size - 1 : 0), '\0'); + if (size > 1) WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size - 1, nullptr, nullptr); + return result; +} + +struct Options { + std::vector inputs; + std::optional requested_luid; + uint64_t max_frames = 0; + bool allow_software = false; + bool realtime = false; + uint64_t duration_seconds = 0; +}; + +uint64_t parse_u64(const std::string& value, const char* name) { + size_t consumed = 0; + const auto parsed = std::stoull(value, &consumed, 0); + if (consumed != value.size()) throw std::runtime_error(std::string("invalid ") + name); + return parsed; +} + +Options parse_options(int argc, char** argv) { + Options options; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + if (arg == "--help") { + std::cout << "Usage: openstream_mf_gpu_probe --input FILE [--input FILE ...] " + "[--adapter-luid UINT64] [--max-frames N] [--realtime] " + "[--duration-seconds N] [--allow-software-fallback]\n"; + std::exit(0); + } + if (arg == "--input" && i + 1 < argc) options.inputs.emplace_back(argv[++i]); + else if (arg == "--adapter-luid" && i + 1 < argc) { + const uint64_t value = parse_u64(argv[++i], "adapter LUID"); + options.requested_luid = LUID{static_cast(value), static_cast(value >> 32)}; + } else if (arg == "--max-frames" && i + 1 < argc) options.max_frames = parse_u64(argv[++i], "max frames"); + else if (arg == "--allow-software-fallback") options.allow_software = true; + else if (arg == "--realtime") options.realtime = true; + else if (arg == "--duration-seconds" && i + 1 < argc) + options.duration_seconds = parse_u64(argv[++i], "duration seconds"); + else throw std::runtime_error("unknown or incomplete argument: " + arg); + } + if (options.inputs.empty() || options.inputs.size() > 4) throw std::runtime_error("provide one to four --input files"); + return options; +} + +uint64_t luid_value(const LUID& luid) { + return (static_cast(static_cast(luid.HighPart)) << 32) | luid.LowPart; +} + +struct AdapterChoice { + ComPtr adapter; + DXGI_ADAPTER_DESC1 description{}; +}; + +AdapterChoice select_adapter(const std::optional& requested) { + ComPtr factory; + check(CreateDXGIFactory1(IID_PPV_ARGS(&factory)), "CreateDXGIFactory1"); + for (UINT index = 0;; ++index) { + ComPtr adapter; + const HRESULT hr = factory->EnumAdapterByGpuPreference(index, DXGI_GPU_PREFERENCE_HIGH_PERFORMANCE, + IID_PPV_ARGS(&adapter)); + if (hr == DXGI_ERROR_NOT_FOUND) break; + check(hr, "EnumAdapterByGpuPreference"); + DXGI_ADAPTER_DESC1 description{}; + check(adapter->GetDesc1(&description), "IDXGIAdapter1::GetDesc1"); + if ((description.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) != 0) continue; + if (!requested || luid_value(description.AdapterLuid) == luid_value(*requested)) return {adapter, description}; + } + throw std::runtime_error("requested hardware adapter LUID was not found; silent adapter switching is forbidden"); +} + +struct ReaderState { + ComPtr reader; + uint64_t frames = 0; + uint64_t source_timestamp_100ns = 0; + ComPtr texture; + UINT texture_subresource = 0; + uint64_t loop_offset_100ns = 0; + uint64_t last_timestamp_100ns = 0; +}; + +class Composer { + public: + explicit Composer(ID3D11Device* device) { + check(device->QueryInterface(IID_PPV_ARGS(&video_device_)), "ID3D11VideoDevice"); + ComPtr context; + device->GetImmediateContext(&context); + check(context.As(&video_context_), "ID3D11VideoContext"); + D3D11_VIDEO_PROCESSOR_CONTENT_DESC content{}; + content.InputFrameFormat = D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE; + content.InputWidth = 1920; + content.InputHeight = 1080; + content.OutputWidth = 1920; + content.OutputHeight = 1080; + content.Usage = D3D11_VIDEO_USAGE_PLAYBACK_NORMAL; + content.InputFrameRate = {60, 1}; + content.OutputFrameRate = {60, 1}; + check(video_device_->CreateVideoProcessorEnumerator(&content, &enumerator_), + "CreateVideoProcessorEnumerator"); + check(video_device_->CreateVideoProcessor(enumerator_.Get(), 0, &processor_), "CreateVideoProcessor"); + D3D11_TEXTURE2D_DESC output_desc{}; + output_desc.Width = 1920; + output_desc.Height = 1080; + output_desc.MipLevels = 1; + output_desc.ArraySize = 1; + output_desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + output_desc.SampleDesc.Count = 1; + output_desc.Usage = D3D11_USAGE_DEFAULT; + output_desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + check(device->CreateTexture2D(&output_desc, nullptr, &output_), "create composition texture"); + D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC view_desc{}; + view_desc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D; + check(video_device_->CreateVideoProcessorOutputView(output_.Get(), enumerator_.Get(), &view_desc, + &output_view_), + "CreateVideoProcessorOutputView"); + } + + void compose(const std::vector& readers) { + std::vector> views(readers.size()); + std::vector streams(readers.size()); + for (size_t i = 0; i < readers.size(); ++i) { + if (!readers[i].texture) return; + D3D11_TEXTURE2D_DESC texture_desc{}; + readers[i].texture->GetDesc(&texture_desc); + D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC input_desc{}; + input_desc.FourCC = 0; + input_desc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D; + input_desc.Texture2D.ArraySlice = readers[i].texture_subresource; + check(video_device_->CreateVideoProcessorInputView(readers[i].texture.Get(), enumerator_.Get(), + &input_desc, &views[i]), + "CreateVideoProcessorInputView"); + const LONG column = static_cast(i % 2); + const LONG row = static_cast(i / 2); + const RECT source{0, 0, static_cast(texture_desc.Width), static_cast(texture_desc.Height)}; + const RECT destination{column * 960, row * 540, column * 960 + 960, row * 540 + 540}; + video_context_->VideoProcessorSetStreamSourceRect(processor_.Get(), static_cast(i), TRUE, &source); + video_context_->VideoProcessorSetStreamDestRect(processor_.Get(), static_cast(i), TRUE, &destination); + video_context_->VideoProcessorSetStreamFrameFormat(processor_.Get(), static_cast(i), + D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE); + const auto rotation = i == readers.size() - 1 ? D3D11_VIDEO_PROCESSOR_ROTATION_90 + : D3D11_VIDEO_PROCESSOR_ROTATION_IDENTITY; + video_context_->VideoProcessorSetStreamRotation(processor_.Get(), static_cast(i), TRUE, rotation); + streams[i].Enable = TRUE; + streams[i].pInputSurface = views[i].Get(); + } + check(video_context_->VideoProcessorBlt(processor_.Get(), output_view_.Get(), static_cast(frame_index_), + static_cast(streams.size()), streams.data()), + "VideoProcessorBlt"); + ++frame_index_; + } + + uint64_t frames() const { return frame_index_; } + + private: + ComPtr video_device_; + ComPtr video_context_; + ComPtr enumerator_; + ComPtr processor_; + ComPtr output_; + ComPtr output_view_; + uint64_t frame_index_ = 0; +}; + +ReaderState make_reader(const std::filesystem::path& input, IMFDXGIDeviceManager* manager) { + ComPtr attributes; + check(MFCreateAttributes(&attributes, 4), "MFCreateAttributes"); + check(attributes->SetUnknown(MF_SOURCE_READER_D3D_MANAGER, manager), "set MF_SOURCE_READER_D3D_MANAGER"); + check(attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE), "enable hardware transforms"); + check(attributes->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, FALSE), "disable software video processing"); + + ReaderState state; + check(MFCreateSourceReaderFromURL(input.c_str(), attributes.Get(), &state.reader), "MFCreateSourceReaderFromURL"); + ComPtr output_type; + check(MFCreateMediaType(&output_type), "MFCreateMediaType"); + check(output_type->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video), "set video major type"); + check(output_type->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12), "require NV12 output"); + check(state.reader->SetCurrentMediaType(static_cast(MF_SOURCE_READER_FIRST_VIDEO_STREAM), nullptr, output_type.Get()), + "require Media Foundation NV12 output"); + return state; +} + +bool read_texture(ReaderState& state) { + DWORD stream = 0; + DWORD flags = 0; + LONGLONG timestamp = 0; + ComPtr sample; + check(state.reader->ReadSample(static_cast(MF_SOURCE_READER_FIRST_VIDEO_STREAM), 0, &stream, &flags, ×tamp, &sample), + "IMFSourceReader::ReadSample"); + if ((flags & MF_SOURCE_READERF_ENDOFSTREAM) != 0) return false; + if (!sample) return true; + ComPtr buffer; + check(sample->GetBufferByIndex(0, &buffer), "IMFSample::GetBufferByIndex"); + ComPtr dxgi_buffer; + const HRESULT dxgi_hr = buffer.As(&dxgi_buffer); + if (FAILED(dxgi_hr)) fail("Media Foundation produced a software buffer; hardware gate failed", dxgi_hr); + state.texture.Reset(); + check(dxgi_buffer->GetResource(IID_PPV_ARGS(&state.texture)), "IMFDXGIBuffer::GetResource"); + check(dxgi_buffer->GetSubresourceIndex(&state.texture_subresource), "IMFDXGIBuffer::GetSubresourceIndex"); + state.last_timestamp_100ns = static_cast(timestamp); + state.source_timestamp_100ns = state.loop_offset_100ns + state.last_timestamp_100ns; + ++state.frames; + return true; +} + +void rewind_reader(ReaderState& state) { + state.loop_offset_100ns += state.last_timestamp_100ns + 333333; + PROPVARIANT position; + PropVariantInit(&position); + position.vt = VT_I8; + position.hVal.QuadPart = 0; + check(state.reader->SetCurrentPosition(GUID_NULL, position), "IMFSourceReader::SetCurrentPosition"); + PropVariantClear(&position); +} + +void validate_texture(ID3D11Texture2D* texture, ID3D11Device* expected_device) { + ComPtr owner; + texture->GetDevice(&owner); + if (owner.Get() != expected_device) throw std::runtime_error("cross-device decoder texture rejected"); + D3D11_TEXTURE2D_DESC description{}; + texture->GetDesc(&description); + if (description.Format != DXGI_FORMAT_NV12 && description.Format != DXGI_FORMAT_P010) + throw std::runtime_error("decoder texture is not NV12/P010"); +} + +} // namespace + +int main(int argc, char** argv) try { + const Options options = parse_options(argc, argv); + check(CoInitializeEx(nullptr, COINIT_MULTITHREADED), "CoInitializeEx"); + check(MFStartup(MF_VERSION, MFSTARTUP_FULL), "MFStartup"); + const AdapterChoice choice = select_adapter(options.requested_luid); + + UINT device_flags = D3D11_CREATE_DEVICE_VIDEO_SUPPORT | D3D11_CREATE_DEVICE_BGRA_SUPPORT; + ComPtr device; + ComPtr context; + D3D_FEATURE_LEVEL feature_level{}; + check(D3D11CreateDevice(choice.adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, nullptr, device_flags, nullptr, 0, + D3D11_SDK_VERSION, &device, &feature_level, &context), "D3D11CreateDevice"); + ComPtr multithread; + check(context.As(&multithread), "ID3D10Multithread"); + multithread->SetMultithreadProtected(TRUE); + + UINT reset_token = 0; + ComPtr manager; + check(MFCreateDXGIDeviceManager(&reset_token, &manager), "MFCreateDXGIDeviceManager"); + check(manager->ResetDevice(device.Get(), reset_token), "IMFDXGIDeviceManager::ResetDevice"); + + std::vector readers; + readers.reserve(options.inputs.size()); + for (const auto& input : options.inputs) readers.push_back(make_reader(input, manager.Get())); + Composer composer(device.Get()); + + const auto started = std::chrono::steady_clock::now(); + std::optional first_source_timestamp; + bool active = true; + while (active) { + if (options.duration_seconds != 0 && + std::chrono::steady_clock::now() - started >= std::chrono::seconds(options.duration_seconds)) break; + active = false; + for (auto& reader : readers) { + if (options.max_frames != 0 && reader.frames >= options.max_frames) continue; + if (!read_texture(reader) && options.duration_seconds != 0) { + rewind_reader(reader); + active = true; + } else if (reader.texture) { + active = true; + validate_texture(reader.texture.Get(), device.Get()); + if (!first_source_timestamp || reader.source_timestamp_100ns < *first_source_timestamp) + first_source_timestamp = reader.source_timestamp_100ns; + } + } + if (active) composer.compose(readers); + if (active && options.realtime && first_source_timestamp) { + uint64_t newest_timestamp = 0; + for (const auto& reader : readers) + newest_timestamp = (std::max)(newest_timestamp, reader.source_timestamp_100ns); + const auto target = started + std::chrono::nanoseconds((newest_timestamp - *first_source_timestamp) * 100); + if (target > std::chrono::steady_clock::now()) std::this_thread::sleep_until(target); + } + } + const double seconds = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + uint64_t total_frames = 0; + for (const auto& reader : readers) total_frames += reader.frames; + context->Flush(); + const HRESULT removed_reason = device->GetDeviceRemovedReason(); + if (removed_reason != S_OK) fail("selected D3D11 device was removed during a normal run", removed_reason); + + std::cout << "{\"schema_version\":1,\"backend\":\"media-foundation\",\"result\":\"PASS\"," + << "\"adapter_luid\":" << luid_value(choice.description.AdapterLuid) << ',' + << "\"adapter\":\"" << json_escape(narrow(choice.description.Description)) << "\"," + << "\"texture_type\":\"IMFDXGIBuffer/ID3D11Texture2D\",\"pixel_format\":\"NV12|P010\"," + << "\"streams\":" << readers.size() << ",\"frames\":" << total_frames << ',' + << "\"composition_frames\":" << composer.frames() << ',' + << "\"gpu_scale\":true,\"gpu_colour_conversion\":true,\"gpu_rotation\":true," + << "\"layout\":\"" << (readers.size() > 2 ? "2x2" : "2x1") << "\"," + << "\"elapsed_s\":" << seconds << ",\"cpu_frame_uploads\":0,\"cpu_frame_readbacks\":0," + << "\"ordinary_cpu_frame_copies\":0,\"software_fallback_allowed\":" + << (options.allow_software ? "true" : "false") << ",\"device_removed_reason\":" + << static_cast(removed_reason) << "}\n"; + return 0; +} catch (const std::exception& error) { + std::cerr << "{\"schema_version\":1,\"backend\":\"media-foundation\",\"result\":\"FAIL\"," + "\"warning\":\"HARDWARE PATH REJECTED; software fallback requires explicit opt-in\"," + "\"error\":\"" << json_escape(error.what()) << "\"}\n"; + return 1; +} diff --git a/spikes/gpu/trace/README.md b/spikes/gpu/trace/README.md new file mode 100644 index 0000000..ec20b14 --- /dev/null +++ b/spikes/gpu/trace/README.md @@ -0,0 +1,12 @@ +# ETW copy-ledger corroboration + +Run from an elevated Windows terminal. Keep the `.etl` outside Git and publish its SHA-256 beside the raw JSONL evidence. + +```powershell +wpr -start GPU -filemode +# run one clean workload or the device-removal case +wpr -stop out/gpu-traces/clean.etl +Get-FileHash out/gpu-traces/clean.etl -Algorithm SHA256 +``` + +The trace review must confirm the selected adapter LUID, VideoDecode and 3D/video-processing activity, no CPU upload/readback transfer in the normal frame path, and no activity on another adapter. A trace hash without a recorded review is not a pass. diff --git a/spikes/gpu/validate-results.py b/spikes/gpu/validate-results.py new file mode 100644 index 0000000..901397d --- /dev/null +++ b/spikes/gpu/validate-results.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +import json +import math +import sys +from collections import defaultdict +from pathlib import Path + + +def fail(message: str) -> None: + raise SystemExit(f"GPU RESULT INVALID: {message}") + + +def main() -> None: + if len(sys.argv) != 2: + fail("usage: validate-results.py metrics.jsonl") + path = Path(sys.argv[1]) + records = [json.loads(line) for line in path.read_text(encoding="utf-8-sig").splitlines() if line.strip()] + if not records or records[0].get("type") != "environment": + fail("environment record must be first") + samples: dict[tuple[str, str], list[dict]] = defaultdict(list) + summaries: dict[tuple[str, str], dict] = {} + workloads = set() + for record in records: + if record.get("schema_version") != 1: + fail("unsupported schema") + if record.get("type") == "workload": + workload = record["workload"] + workloads.add(workload["id"]) + if (workload["width"], workload["height"], workload["streams"], workload["fps"]) not in { + (1920, 1080, 4, 30), (1920, 1080, 2, 60) + }: + fail(f"invalid workload {workload['id']}") + for queue in record["queues"]: + if int(queue["capacity"]) <= 0 or not queue["overflow_policy"]: + fail("every queue needs capacity and overflow policy") + elif record.get("type") == "sample": + for field in ("cpu_percent", "private_mib", "gpu_process_percent"): + if not math.isfinite(float(record[field])): + fail(f"non-finite {field}") + samples[(record["workload"], record["backend"])].append(record) + elif record.get("type") == "summary": + summaries[(record["workload"], record["backend"])] = record["probe"] + for workload in workloads: + for backend in ("media-foundation", "ffmpeg-d3d11"): + key = workload, backend + if key not in summaries or not samples[key]: + fail(f"missing evidence for {workload}/{backend}") + proof = summaries[key] + if proof.get("result") != "PASS" or int(proof.get("ordinary_cpu_frame_copies", -1)) != 0: + fail(f"hardware/copy gate failed for {workload}/{backend}") + if not proof.get("texture_type") or int(proof.get("cpu_frame_uploads", -1)) != 0 or int(proof.get("cpu_frame_readbacks", -1)) != 0: + fail(f"texture/copy proof incomplete for {workload}/{backend}") + expected_workloads = { + "four-1080p30-h264", "four-1080p30-hevc", "two-1080p60-h264", "two-1080p60-hevc" + } + if workloads != expected_workloads: + fail(f"incomplete workload matrix: expected {sorted(expected_workloads)}, got {sorted(workloads)}") + print(json.dumps({"ok": True, "workloads": sorted(workloads), "candidate_cells": len(summaries)})) + + +if __name__ == "__main__": + main() diff --git a/spikes/gpu/workloads/v4-gpu-workloads.json b/spikes/gpu/workloads/v4-gpu-workloads.json new file mode 100644 index 0000000..6e6457d --- /dev/null +++ b/spikes/gpu/workloads/v4-gpu-workloads.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "workloads": [ + {"id": "four-1080p30-h264", "codec": "h264", "width": 1920, "height": 1080, "fps": 30, "streams": 4}, + {"id": "four-1080p30-hevc", "codec": "hevc", "width": 1920, "height": 1080, "fps": 30, "streams": 4}, + {"id": "two-1080p60-h264", "codec": "h264", "width": 1920, "height": 1080, "fps": 60, "streams": 2}, + {"id": "two-1080p60-hevc", "codec": "hevc", "width": 1920, "height": 1080, "fps": 60, "streams": 2} + ], + "decoded_preview_queue": {"capacity": 4, "overflow_policy": "replace_oldest_non_presented"}, + "programme_queue": {"capacity": 4, "overflow_policy": "replace_oldest_before_deadline"}, + "ingress_queue_per_stream": {"capacity": 120, "overflow_policy": "drop_oldest_non_key"}, + "iso_queue_per_stream": {"capacity": 256, "overflow_policy": "unsafe_no_silent_drop"} +} diff --git a/tests/test_v4_gpu_spike.py b/tests/test_v4_gpu_spike.py new file mode 100644 index 0000000..04945d1 --- /dev/null +++ b/tests/test_v4_gpu_spike.py @@ -0,0 +1,53 @@ +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GPU = ROOT / "spikes" / "gpu" + + +def test_gpu_workloads_cover_locked_profiles_and_codecs() -> None: + document = json.loads((GPU / "workloads" / "v4-gpu-workloads.json").read_text(encoding="utf-8")) + cells = { + (item["codec"], item["width"], item["height"], item["fps"], item["streams"]) + for item in document["workloads"] + } + assert cells == { + ("h264", 1920, 1080, 30, 4), + ("hevc", 1920, 1080, 30, 4), + ("h264", 1920, 1080, 60, 2), + ("hevc", 1920, 1080, 60, 2), + } + for name in ("decoded_preview_queue", "programme_queue", "ingress_queue_per_stream", "iso_queue_per_stream"): + queue = document[name] + assert queue["capacity"] > 0 + assert queue["overflow_policy"] + + +def test_result_validator_rejects_missing_backend_and_cpu_copies(tmp_path: Path) -> None: + metrics = tmp_path / "metrics.jsonl" + workload = {"id": "four-1080p30-h264", "width": 1920, "height": 1080, "fps": 30, "streams": 4} + records = [ + {"schema_version": 1, "type": "environment"}, + {"schema_version": 1, "type": "workload", "workload": workload, "queues": [{"capacity": 4, "overflow_policy": "replace_oldest"}]}, + {"schema_version": 1, "type": "sample", "workload": workload["id"], "backend": "media-foundation", "cpu_percent": 1, "private_mib": 10, "gpu_process_percent": 1}, + {"schema_version": 1, "type": "summary", "workload": workload["id"], "backend": "media-foundation", "probe": {"result": "PASS", "texture_type": "IMFDXGIBuffer", "ordinary_cpu_frame_copies": 1, "cpu_frame_uploads": 0, "cpu_frame_readbacks": 0}}, + ] + metrics.write_text("".join(json.dumps(record) + "\n" for record in records), encoding="utf-8") + result = subprocess.run([sys.executable, str(GPU / "validate-results.py"), str(metrics)], capture_output=True, text=True) + assert result.returncode != 0 + assert "INVALID" in result.stderr + + +def test_native_probes_fail_closed_on_software_paths() -> None: + mf = (GPU / "src" / "mf_gpu_probe.cpp").read_text(encoding="utf-8") + ffmpeg = (GPU / "src" / "ffmpeg_d3d11_probe.cpp").read_text(encoding="utf-8") + assert "IMFDXGIBuffer" in mf + assert "software buffer; hardware gate failed" in mf + assert "GetDeviceRemovedReason" in mf + assert "AV_PIX_FMT_D3D11" in ffmpeg + assert "av_hwframe_transfer_data(" not in ffmpeg + assert "ordinary_cpu_frame_copies" in mf + assert "ordinary_cpu_frame_copies" in ffmpeg