问题类型
视频串流 / NVENC 首帧卡死 / 10-bit 4:4:4 色度错位。
问题描述
启用 nvenc_cuda_array_input,通过 Moonlight 请求 HEVC、HDR 和 YUV 4:4:4 后,客户端连接成功并收到音频,但始终没有视频,随后提示无画面并退出。Sunshine 在会话清理时触发 watchdog。
在同一 GPU 和驱动上,用独立的 D3D11 → CUDA → NVENC 复现器将问题分成了两部分:
- 当前 CUDA Array 的注册
pitch = width * 3 * 2 会让首帧 nvEncLockBitstream() 在异步模式下不返回。
- 仅将 pitch 改成
width * 2 后可以得到码流,但保留 CU_AD_FORMAT_UINT16_PLANAR_444(0x5e)仍有 U/V 错位。改用原生 CU_AD_FORMAT_YUV444_16BIT_SEMIPLANAR(0xb5,Y + 交织 UV)后,合成测试图案的完整三帧码流与 device-pointer 对照逐字节相同。
这份报告补充了 #871 相关现象的 API 级复现和画面校验结果。上述结论已在下列环境验证,尚不推断所有 GPU/驱动上的行为一致。
环境
| 项目 |
值 |
| 主机系统 |
Windows 11 专业工作站版 25H2,OS build 26200.9278 |
| Sunshine |
Foundation Sunshine v2026.906.141628.杂鱼 |
| 主机 GPU / 驱动 |
NVIDIA GeForce RTX 5090 / 616.86 |
| 编码接口 |
NVENC SDK 13.1,D3D11/CUDA interop |
| 捕获后端 |
VDD |
| 客户端 |
Windows,Moonlight V+ 6.4.0.0,RTX 5070 / 616.86 |
| 串流请求 |
2560 × 1440,180 fps,100 Mbps,HEVC,HDR,YUV 4:4:4 |
| 相关编码配置 |
CUDA Array enabled,P7,VBR,full-resolution two-pass,forced three-strip split encoding |
仅列出复现需要的设置;未附完整配置、设备标识或原始系统日志。
复现步骤
- 在支持 HEVC 10-bit 4:4:4 的 NVIDIA 环境中开启 Sunshine 的「10-bit 4:4:4 CUDA Array」。
- Moonlight 指定 HEVC,开启 HDR 和 YUV 4:4:4,以以上参数连接桌面串流。
- Sunshine 实际会话启用了 block-linear CUDA Array 并创建 HEVC 10-bit 4:4:4 编码器;客户端收到音频,但没有视频。
- 客户端等待约 11 秒后退出,Sunshine 清理会话超过 10 秒后触发 watchdog。
关闭 Array 的原有 device-pointer 路径可以工作。本报告的修复目标是保留 Array、10-bit、4:4:4、HDR 和分割编码。
故障定位:首帧究竟停在哪里
原版应用日志中与问题直接有关的固定错误消息如下。为避免泄露环境信息,此处仅摘录消息正文:
Hang detected! Session failed to terminate in 10 seconds.
Windows 记录到的异常为 0x80000003,结合 watchdog 路径判断,这是会话无法结束后主动触发的断点异常,不能据此认定是最初发生了访问越界。
独立复现器在每个 CUDA/NVENC API 调用前后立即刷新日志,输入完全由程序生成,不依赖桌面内容、Moonlight、网络或虚拟显示器。原版布局与 pitch 的结果是:
nvEncRegisterResource(...) -> NV_ENC_SUCCESS
nvEncMapInputResource(...) -> NV_ENC_SUCCESS
nvEncEncodePicture(...) -> NV_ENC_SUCCESS
WaitForSingleObject(encodeCompletionEvent, 5000) -> WAIT_OBJECT_0
nvEncLockBitstream(..., doNotWait = 1) -> 未返回
上面是调用结果的归纳,并非整段原始日志。外部测试包装器在 12 秒超时后只终止它启动的复现器进程。切换为同步编码、doNotWait = 0 的对照中,首帧锁定返回 NV_ENC_ERR_INVALID_PARAM (8)。
因此客户端未收到视频可以在不使用客户端和网络的条件下独立复现,停点位于主机 NVENC 首帧取码流阶段。驱动为何没有在注册时拒绝该输入、异步锁定为何没有及时报错,仍需要 NVIDIA/维护者进一步确认;本报告不将未观察到的驱动内部机制当作事实。
根因一:注册 pitch 把三个颜色平面重复算进行跨度
涉及 src/nvenc/win/impl/nvenc_d3d11_on_cuda.cpp 的 create_and_register_cuda_array_input():
// 当前:width * 3 * 2
encoder_params.width * planar_yuv_plane_count * planar_yuv_bytes_per_sample
// 在已验证的原生多平面 CUDA Array 路径中:一行 Y 的字节宽度
encoder_params.width * planar_yuv_bytes_per_sample
10-bit 样本使用 16-bit 容器,所以一行 Y 为 width * 2 字节。三个逻辑颜色分量不能再作为注册行跨度的倍数。以 2560 像素宽为例,当前传入 15360,已验证可用的值为 5120。
×6 的来源是源码固定的 3(Y/U/V)×2(每样本两字节),与三条 NVENC 分割带无关。三条带通过 NV_ENC_INITIALIZE_PARAMS::splitEncodeMode 独立设置;关闭分割编码的小分辨率对照仍能复现原问题,保留三条带的 1440p 对照在完整修正后可以正常编码。
提交 acce1db6 / PR #910 将该表达式改为 W*3*2。作为另一份可核对的实现,FFmpeg 的 CUARRAY 编码支持提交 对 CUDA Array 使用第一颜色分量每行的字节宽度作为注册 pitch。
根因二:只改 pitch 仍不足以修好原生 Array 的 UV 布局
原实现分配 CU_AD_FORMAT_UINT16_PLANAR_444(0x5e),取得三个 Array plane,然后分别拷入 Y、U、V。修正 pitch 后,编码 API 能返回,但有变化色度的全帧测试揭示了 U/V 错位。
这里已额外做过每个 CUDA plane 的 CPU readback:上传值与读回值完全一致,mismatch = 0、maxError = 0。因此不能用“拷贝 API 成功”或“几个样本读回正确”来排除编码器读取布局的问题。
本环境验证成功的输入为:
CUDA_ARRAY3D_DESCRIPTOR:
Width = W, Height = H
Format = CU_AD_FORMAT_YUV444_16BIT_SEMIPLANAR (0xb5)
NumChannels = 3
Flags = CUDA_ARRAY3D_SURFACE_LDST | CUDA_ARRAY3D_VIDEO_ENCODE_DECODE
plane 0: Y,W × H,每样本 uint16
plane 1: UV,W × H,每像素 U:uint16 + V:uint16
NV_ENC_REGISTER_RESOURCE:
resourceType = NV_ENC_INPUT_RESOURCE_TYPE_CUDAARRAY
bufferFormat = NV_ENC_BUFFER_FORMAT_YUV444_10BIT
width = W, height = H, pitch = W * 2
两个存储平面仍包含完整分辨率的 Y、U、V,未进行 4:2:0/4:2:2 色度抽样。UV plane 每行实际写入 W * 4 字节;NVENC 注册时的 pitch 仍是 Y 行字节宽度 W * 2。NumChannels = 3 是逻辑颜色通道数,不等于三个独立 Array plane。
FFmpeg 的 NVDEC 原生 opaque Array 格式选择 对 16-bit 4:4:4 使用同一原生半平面格式。这为实验提供了线索;它不替代针对 NVENC 的上述实际验证。
对照实验与画面校验
| 输入路径 |
观察结果 |
三平面 0x5e,pitch 6W,异步非阻塞锁定 |
首帧 nvEncLockBitstream 不返回 |
三平面 0x5e,pitch 6W,同步锁定 |
返回错误 8 |
三平面 0x5e,pitch 3W |
小图可输出但图像损坏;1440p 错误 |
三平面 0x5e,pitch 2W |
有码流,但变化 U/V 图案错位 |
三平面 0x5e,pitch 2W,调整分配/注册高度及 padding |
未解决变化色度图案错位;常量色度可能掩盖错误 |
原生半平面 0xb5,pitch 2W,CPU 交织上传对照 |
全帧正确,三帧码流与 device-pointer 对照完全一致 |
原生半平面 0xb5,pitch 2W,D3D11 输入 + GPU PTX 交织 UV |
全帧正确,三帧码流与 device-pointer 对照完全一致 |
最终 GPU 复现器保留 2560×1440、180 fps、P7、VBR 100/150 Mbps、full-resolution two-pass、forced three strips、async、pic.inputPitch = 0,并在 NVENC 调用前 pop CUDA context。它生成三帧有变化 Y/U/V 的合成图案,经 D3D11 R16 纹理、CUDA 拷贝和 GPU 交织后编码。
- CUDA Array 全量样本 readback:所有样本一致。
- 输出格式:HEVC Rext、2560×1440、
yuv444p10le、三帧。
- 完整三帧文件:255845 字节;GPU Array 路径与 device-pointer 路径逐字节一致。
- 两份文件的 SHA256 均为
10A48E1433D2E32C34E25FEFDF3F3229E6B03BBC8D025A42A02E545B72733F0C(合成测试码流摘要,不是设备标识)。
- 首帧逐像素对比源图案:最大 10-bit 样本绝对误差 Y=1、U=2、V=2;绝对误差大于 8 的像素数为 0。该误差符合本次有损编码结果。
- 三帧均成功编码,资源正常释放。
码流完全一致仅是此固定输入、配置和驱动的对照结果;没有由此推断所有内容或其他 GPU 都会产生相同码流。
修补设计
补丁仅修改 nvenc_d3d11_on_cuda.h/.cpp,同时修正注册 pitch 和存储布局:
- 保留现有捕获/颜色转换着色器及堆叠 Y/U/V 的
DXGI_FORMAT_R16_UINT D3D11 纹理。
- 将 Y 直接拷入原生 Array plane 0。
- 将原纹理中相邻堆叠的 U、V 拷至一个 pitched CUDA scratch buffer。
- 在 GPU 上运行嵌入 PTX,将每个 U/V 对打包为
U | (V << 16) 写入原生 UV plane。
- 同步 CUDA stream,再解除 D3D11 映射并交给 NVENC。
- 新增 scratch、surface object 和 CUDA module 随编码器实例分配/释放,资源创建失败沿用既有 device-pointer 回退。
实际构建的补丁还在原生 Array 成功注册之后加入一条验证日志,记录 native 16-bit 4:4:4 CUDA Array layout Y+UV, luma pitch= 及数值。它便于区分原生修补路径和既有 pointer 回退,不打印设备标识。
该方案不增加 nvcc 构建依赖;生产逐帧路径没有 CPU 像素读回或 CPU 色度交织。额外 scratch 约为 4 * W * H 字节加 pitch 对齐,1440p 约 14.1 MiB。PTX 使用 6.0 / sm_52,由驱动 JIT 编译。跨 GPU/驱动兼容性和性能仍值得扩大验证。
完整应用修补及端到端验证
已对完整应用实施源码修补并完成实际串流验证,不再仅依赖独立探针。
构建与部署
- 以原版 tag 对应提交
ece35eb30c3af260c87182825f24d0d3c9918f5f 为基线,按项目官方 Windows MSYS2 UCRT64 构建路线编译完整 sunshine target。
- 使用 GCC 16.2.0、Release/static linking;所有 NVENC API 兼容层编译通过。修补版版本为
2026.906.141628.cuda-array-fix。
- 可执行文件的 DLL 导入集合与原版相同;只替换 Sunshine 可执行文件,保留原 GUI、Web assets、驱动及配置。配置文件内容校验确认未变,CUDA Array、HDR、4:4:4、P7、VBR、全分辨率双遍、三条分割编码带仍保持原设置。
- 为确认真实路径,实际成功注册原生 Array 后增加了以下固定日志:
NvEnc: native 16-bit 4:4:4 CUDA Array layout Y+UV, luma pitch=5120
NvEnc: using block-linear CUDA array input
NvEnc: created encoder v1301 HEVC P7 async yuv444 10-bit vbr quality=auto two-pass rfi
上述标记在两轮真实会话中均出现,确认使用修补后的原生 CUDA Array,没有将 device-pointer 回退当成成功。
实际客户端验证
两轮均协商 2560x1440x180 (format 0x800);Moonlight 的 VIDEO_FORMAT_H265_REXT10_444 = 0x0800 明确定义为 HEVC RExt 4:4:4 10-bit。两轮均启用 D3D11VA 渲染器,并记录 stream HDR / display HDR enabled。
| 测试 |
实际视频与统计 |
退出验证 |
| 第一轮持续串流 |
收到首个视频包约 400ms;正常视频阶段超过 3 分钟;接收/解码约 162.5 FPS,渲染约 157.6 FPS;解码约 0.24ms,主机编码约 3.2ms |
此轮后续测试自动化误关 Qt 主窗,影响客户端正常清理,因此不把它作为正常客户端退出证据;结束该测试子进程后,主机约 47ms 完成编码器清理 |
| 第二轮重新连接 |
首个视频包约 300ms;实际视频约 47 秒;接收/解码约 169.8 FPS,渲染约 161.9 FPS;解码约 0.24ms,主机编码约 3.3ms |
准确关闭本轮 SDL 串流窗口后,客户端完成所有流的 Stopping/Cleaning up 并退出;主机约 50ms 完成异步编码器清理 |
这些帧率是此次桌面内容与网络环境下的观测值,串流请求为 180fps;不把桌面实际帧率写成满帧率性能保证。第一轮测试工具的退出控制问题与正常串流阶段数据分开记录,第二轮重新验证了标准退出。
部署后的 Sunshine 进程在两轮测试间保持运行,没有新的 Hang detected、fatal 或 Windows Sunshine 应用崩溃事件。原版的首帧无视频与会话退出卡死,在以上修补后实际会话中未再出现。
与既有记录的关系
- Issue #871 有实际启用 Array 后花屏、后续无法连接的反馈,检查时仍开放。本报告提供新 GPU 上的独立复现、首帧 API 停点和两部分修正;不能替代该报告者硬件的复测。
- PR #910 将 Array 改为 opt-in,同时引入上述
W*3*2;默认 4:4:4 可用不能证明 Array 路径可用。
- PR #1000 让启动 probe 绕开 Array,并改善上下文生命周期。其说明明确实际会话 Array 行为不变。因此启动编码器探测通过、或 Issue #998 的空闲 CPU 问题改善,不能作为本次真实 Array 串流已经正常的证据。
建议维护者复核
请优先复核多平面 CUDA Array 注册 pitch 的含义,以及 NV_ENC_BUFFER_FORMAT_YUV444_10BIT 在原生 Array 输入模式下要求的实际 UV 存储布局。建议回归测试同时包含变化亮度和变化色度,覆盖“有输出”之外的完整图像正确性,并在至少一款旧架构 NVIDIA GPU 上复测。仅将 6W 改为 2W 不足以修复本环境中的全部问题。
实际构建的完整源码补丁(相对仓库路径)
diff --git a/src/nvenc/win/impl/nvenc_d3d11_on_cuda.cpp b/src/nvenc/win/impl/nvenc_d3d11_on_cuda.cpp
index 959a1a3..ea89dce 100644
--- a/src/nvenc/win/impl/nvenc_d3d11_on_cuda.cpp
+++ b/src/nvenc/win/impl/nvenc_d3d11_on_cuda.cpp
@@ -15,6 +15,39 @@
namespace NVENC_NAMESPACE {
#else
namespace nvenc {
+#endif
+
+#if NVENCAPI_MAJOR_VERSION * 100 + NVENCAPI_MINOR_VERSION >= 1301
+ // Interleave two stacked 16-bit chroma planes into the native NVENC UV array.
+ // Embedded PTX avoids adding an nvcc dependency. Samples remain bit-exact;
+ // this changes their storage layout without chroma subsampling or conversion.
+ static constexpr char cuda_interleave_chroma_ptx[] = R"ptx(
+.version 6.0
+.target sm_52
+.address_size 64
+.visible .entry pack_uv(
+ .param .u64 src, .param .u64 pitch, .param .u64 dstsurf,
+ .param .u32 width, .param .u32 height)
+{
+ .reg .pred %p<3>;
+ .reg .b32 %r<20>;
+ .reg .b64 %rd<20>;
+ ld.param.u64 %rd1,[src]; ld.param.u64 %rd2,[pitch]; ld.param.u64 %rd3,[dstsurf];
+ ld.param.u32 %r1,[width]; ld.param.u32 %r2,[height];
+ mov.u32 %r3,%ctaid.x; mov.u32 %r4,%ntid.x; mov.u32 %r5,%tid.x;
+ mad.lo.u32 %r6,%r3,%r4,%r5;
+ mov.u32 %r7,%ctaid.y; mov.u32 %r8,%ntid.y; mov.u32 %r9,%tid.y;
+ mad.lo.u32 %r10,%r7,%r8,%r9;
+ setp.ge.u32 %p1,%r6,%r1; setp.ge.u32 %p2,%r10,%r2; or.pred %p1,%p1,%p2; @%p1 bra DONE;
+ cvt.u64.u32 %rd4,%r10; mul.lo.u64 %rd5,%rd4,%rd2;
+ mul.wide.u32 %rd6,%r6,2; add.u64 %rd7,%rd1,%rd5; add.u64 %rd8,%rd7,%rd6;
+ cvt.u64.u32 %rd9,%r2; mul.lo.u64 %rd10,%rd9,%rd2; add.u64 %rd11,%rd8,%rd10;
+ ld.global.u16 %r11,[%rd8]; ld.global.u16 %r12,[%rd11]; shl.b32 %r13,%r12,16; or.b32 %r14,%r11,%r13;
+ shl.b32 %r15,%r6,2;
+ sust.b.2d.b32.trap [%rd3,{%r15,%r10}],%r14;
+ DONE: ret;
+}
+)ptx";
#endif
nvenc_d3d11_on_cuda::nvenc_d3d11_on_cuda(ID3D11Device *d3d_device, shared_dll dll):
@@ -135,7 +168,14 @@ namespace nvenc {
#if NVENCAPI_MAJOR_VERSION * 100 + NVENCAPI_MINOR_VERSION >= 1301
else if (!load_function(functions.cuArray3DCreate, "cuArray3DCreate_v2") ||
!load_function(functions.cuArrayDestroy, "cuArrayDestroy") ||
- !load_function(functions.cuArrayGetPlane, "cuArrayGetPlane")) {
+ !load_function(functions.cuArrayGetPlane, "cuArrayGetPlane") ||
+ !load_function(functions.cuSurfObjectCreate, "cuSurfObjectCreate") ||
+ !load_function(functions.cuSurfObjectDestroy, "cuSurfObjectDestroy") ||
+ !load_function(functions.cuModuleLoadData, "cuModuleLoadData") ||
+ !load_function(functions.cuModuleGetFunction, "cuModuleGetFunction") ||
+ !load_function(functions.cuModuleUnload, "cuModuleUnload") ||
+ !load_function(functions.cuLaunchKernel, "cuLaunchKernel") ||
+ !load_function(functions.cuStreamSynchronize, "cuStreamSynchronize")) {
BOOST_LOG(info) << "NvEnc: CUDA array input functions unavailable, using CUDA device pointer input";
functions.cuArray3DCreate = nullptr;
functions.cuArrayDestroy = nullptr;
@@ -318,7 +358,9 @@ namespace nvenc {
CUDA_ARRAY3D_DESCRIPTOR array_descriptor = {};
array_descriptor.Width = encoder_params.width;
array_descriptor.Height = encoder_params.height;
- array_descriptor.Format = CU_AD_FORMAT_UINT16_PLANAR_444;
+ // NVDEC's native 16-bit 4:4:4 layout has a Y plane and a two-channel UV
+ // plane. Registering generic three-plane CUDA storage can corrupt chroma.
+ array_descriptor.Format = CU_AD_FORMAT_YUV444_16BIT_SEMIPLANAR;
array_descriptor.NumChannels = planar_yuv_plane_count;
array_descriptor.Flags = CUDA_ARRAY3D_SURFACE_LDST | CUDA_ARRAY3D_VIDEO_ENCODE_DECODE;
@@ -328,7 +370,7 @@ namespace nvenc {
return false;
}
- for (std::uint32_t plane = 0; plane < planar_yuv_plane_count; ++plane) {
+ for (std::uint32_t plane = 0; plane < 2; ++plane) {
if (cuda_failed(cuda_functions.cuArrayGetPlane(&cuda_array_planes[plane], cuda_array_surface, plane))) {
BOOST_LOG(warning) << "NvEnc: cuArrayGetPlane() failed for NVENC input plane " << plane << ": error " << last_cuda_error;
destroy_cuda_array_input();
@@ -337,24 +379,86 @@ namespace nvenc {
}
}
- // NVENC wants the byte width of the whole allocation. The header spells this
- // as `CUDA_ARRAY3D_DESCRIPTOR::Width * NumChannels`, which only works out to
- // bytes for 8-bit formats; UINT16_PLANAR_444 is 2 bytes per sample across 3
- // planes, so the row stride is width * 3 * 2.
+ if (!create_cuda_chroma_converter()) {
+ destroy_cuda_array_input();
+ return false;
+ }
+
+ // Multi-planar array registration uses the byte width of one luma row.
+ // NumChannels is the logical channel count, not an additional pitch factor.
if (!register_cuda_input(
NV_ENC_INPUT_RESOURCE_TYPE_CUDAARRAY,
cuda_array_surface,
- encoder_params.width * planar_yuv_plane_count * planar_yuv_bytes_per_sample)) {
+ encoder_params.width * planar_yuv_bytes_per_sample)) {
BOOST_LOG(warning) << "NvEnc: CUDA array registration failed: " << last_nvenc_error_string;
destroy_cuda_array_input();
return false;
}
+ BOOST_LOG(info) << "NvEnc: native 16-bit 4:4:4 CUDA Array layout Y+UV, luma pitch="
+ << encoder_params.width * planar_yuv_bytes_per_sample;
+ return true;
+ }
+
+ bool
+ nvenc_d3d11_on_cuda::create_cuda_chroma_converter() {
+ // The caller holds the CUDA context and cleans up any partial allocation.
+ if (!cuda_chroma_scratch &&
+ cuda_failed(cuda_functions.cuMemAllocPitch(
+ &cuda_chroma_scratch, &cuda_chroma_scratch_pitch,
+ encoder_params.width * planar_yuv_bytes_per_sample,
+ encoder_params.height * 2, 16))) {
+ BOOST_LOG(warning) << "NvEnc: chroma scratch allocation failed: error " << last_cuda_error;
+ return false;
+ }
+
+ if (!cuda_chroma_surface) {
+ CUDA_RESOURCE_DESC resource_desc = {};
+ resource_desc.resType = CU_RESOURCE_TYPE_ARRAY;
+ resource_desc.res.array.hArray = cuda_array_planes[1];
+ if (cuda_failed(cuda_functions.cuSurfObjectCreate(&cuda_chroma_surface, &resource_desc))) {
+ BOOST_LOG(warning) << "NvEnc: chroma surface creation failed: error " << last_cuda_error;
+ return false;
+ }
+ }
+
+ if (!cuda_chroma_module &&
+ cuda_failed(cuda_functions.cuModuleLoadData(&cuda_chroma_module, cuda_interleave_chroma_ptx))) {
+ BOOST_LOG(warning) << "NvEnc: chroma PTX loading failed: error " << last_cuda_error;
+ return false;
+ }
+ if (!cuda_chroma_kernel &&
+ cuda_failed(cuda_functions.cuModuleGetFunction(&cuda_chroma_kernel, cuda_chroma_module, "pack_uv"))) {
+ BOOST_LOG(warning) << "NvEnc: chroma kernel lookup failed: error " << last_cuda_error;
+ return false;
+ }
return true;
}
void
nvenc_d3d11_on_cuda::destroy_cuda_array_input() {
+ // Per-frame stream synchronization completes every launch before cleanup.
+ // Surface objects must be destroyed before their underlying CUDA arrays.
+ if (cuda_chroma_surface) {
+ if (cuda_failed(cuda_functions.cuSurfObjectDestroy(cuda_chroma_surface))) {
+ BOOST_LOG(error) << "NvEnc: chroma surface destruction failed: error " << last_cuda_error;
+ }
+ cuda_chroma_surface = 0;
+ }
+ if (cuda_chroma_scratch) {
+ if (cuda_failed(cuda_functions.cuMemFree(cuda_chroma_scratch))) {
+ BOOST_LOG(error) << "NvEnc: chroma scratch release failed: error " << last_cuda_error;
+ }
+ cuda_chroma_scratch = 0;
+ cuda_chroma_scratch_pitch = 0;
+ }
+ if (cuda_chroma_module) {
+ if (cuda_failed(cuda_functions.cuModuleUnload(cuda_chroma_module))) {
+ BOOST_LOG(error) << "NvEnc: chroma module unload failed: error " << last_cuda_error;
+ }
+ cuda_chroma_module = nullptr;
+ cuda_chroma_kernel = nullptr;
+ }
if (cuda_array_surface) {
if (cuda_failed(cuda_functions.cuArrayDestroy(cuda_array_surface))) {
BOOST_LOG(error) << "NvEnc: cuArrayDestroy() failed: error " << last_cuda_error;
@@ -450,16 +554,47 @@ namespace nvenc {
#if NVENCAPI_MAJOR_VERSION * 100 + NVENCAPI_MINOR_VERSION >= 1301
if (cuda_array_surface) {
copy_params.dstMemoryType = CU_MEMORYTYPE_ARRAY;
+ copy_params.dstArray = cuda_array_planes[0];
copy_params.Height = encoder_params.height;
+ if (cuda_failed(cuda_functions.cuMemcpy2D(©_params))) {
+ BOOST_LOG(error) << "NvEnc: luma copy to CUDA array failed: error " << last_cuda_error;
+ return false;
+ }
- for (std::uint32_t plane = 0; plane < planar_yuv_plane_count; ++plane) {
- copy_params.srcY = encoder_params.height * plane;
- copy_params.dstArray = cuda_array_planes[plane];
- if (cuda_failed(cuda_functions.cuMemcpy2D(©_params))) {
- BOOST_LOG(error) << "NvEnc: cuMemcpy2D() to CUDA array plane " << plane
- << " failed: error " << last_cuda_error;
- return false;
- }
+ // Keep the existing D3D11 R16 texture and its Y/U/V stacked layout. Copy
+ // U and V together to pitched GPU scratch, then interleave into native UV.
+ // This avoids surface-load restrictions on the mapped D3D11 resource.
+ CUDA_MEMCPY2D chroma_copy = {};
+ chroma_copy.srcMemoryType = CU_MEMORYTYPE_ARRAY;
+ chroma_copy.srcArray = input_texture_array;
+ chroma_copy.srcY = encoder_params.height;
+ chroma_copy.dstMemoryType = CU_MEMORYTYPE_DEVICE;
+ chroma_copy.dstDevice = cuda_chroma_scratch;
+ chroma_copy.dstPitch = cuda_chroma_scratch_pitch;
+ chroma_copy.WidthInBytes = encoder_params.width * planar_yuv_bytes_per_sample;
+ chroma_copy.Height = encoder_params.height * 2;
+ if (cuda_failed(cuda_functions.cuMemcpy2D(&chroma_copy))) {
+ BOOST_LOG(error) << "NvEnc: chroma copy to CUDA scratch failed: error " << last_cuda_error;
+ return false;
+ }
+
+ std::uint32_t width = encoder_params.width;
+ std::uint32_t height = encoder_params.height;
+ void *kernel_params[] = {
+ &cuda_chroma_scratch, &cuda_chroma_scratch_pitch,
+ &cuda_chroma_surface, &width, &height
+ };
+ if (cuda_failed(cuda_functions.cuLaunchKernel(
+ cuda_chroma_kernel, (width + 31) / 32, (height + 7) / 8, 1,
+ 32, 8, 1, 0, nullptr, kernel_params, nullptr))) {
+ BOOST_LOG(error) << "NvEnc: chroma interleave launch failed: error " << last_cuda_error;
+ return false;
+ }
+ // Complete writes before unmapping the D3D resource and giving NVENC the
+ // array. Use the same default stream as the existing CUDA interop copies.
+ if (cuda_failed(cuda_functions.cuStreamSynchronize(nullptr))) {
+ BOOST_LOG(error) << "NvEnc: chroma interleave synchronization failed: error " << last_cuda_error;
+ return false;
}
}
else
diff --git a/src/nvenc/win/impl/nvenc_d3d11_on_cuda.h b/src/nvenc/win/impl/nvenc_d3d11_on_cuda.h
index 17a8591..d0cb3ca 100644
--- a/src/nvenc/win/impl/nvenc_d3d11_on_cuda.h
+++ b/src/nvenc/win/impl/nvenc_d3d11_on_cuda.h
@@ -37,6 +37,14 @@ namespace nvenc {
tcuArray3DCreate *cuArray3DCreate;
tcuArrayDestroy *cuArrayDestroy;
tcuArrayGetPlane *cuArrayGetPlane;
+ // CUsurfObject is a 64-bit handle; the bundled CUDA shim omits these APIs.
+ CUresult(CUDAAPI *cuSurfObjectCreate)(unsigned long long *, const CUDA_RESOURCE_DESC *);
+ CUresult(CUDAAPI *cuSurfObjectDestroy)(unsigned long long);
+ tcuModuleLoadData *cuModuleLoadData;
+ tcuModuleGetFunction *cuModuleGetFunction;
+ tcuModuleUnload *cuModuleUnload;
+ tcuLaunchKernel *cuLaunchKernel;
+ tcuStreamSynchronize *cuStreamSynchronize;
#endif
shared_dll dll;
};
@@ -88,6 +96,9 @@ namespace nvenc {
bool
create_and_register_cuda_array_input();
+ bool
+ create_cuda_chroma_converter();
+
void
destroy_cuda_array_input();
#endif
@@ -141,7 +152,13 @@ namespace nvenc {
size_t cuda_surface_pitch = 0;
#if NVENCAPI_MAJOR_VERSION * 100 + NVENCAPI_MINOR_VERSION >= 1301
CUarray cuda_array_surface = nullptr;
- CUarray cuda_array_planes[planar_yuv_plane_count] = {};
+ // The native video array contains Y and interleaved UV, without subsampling.
+ CUarray cuda_array_planes[2] = {};
+ CUdeviceptr cuda_chroma_scratch = 0;
+ size_t cuda_chroma_scratch_pitch = 0;
+ unsigned long long cuda_chroma_surface = 0;
+ CUmodule cuda_chroma_module = nullptr;
+ CUfunction cuda_chroma_kernel = nullptr;
#endif
};
}
问题类型
视频串流 / NVENC 首帧卡死 / 10-bit 4:4:4 色度错位。
问题描述
启用
nvenc_cuda_array_input,通过 Moonlight 请求 HEVC、HDR 和 YUV 4:4:4 后,客户端连接成功并收到音频,但始终没有视频,随后提示无画面并退出。Sunshine 在会话清理时触发 watchdog。在同一 GPU 和驱动上,用独立的 D3D11 → CUDA → NVENC 复现器将问题分成了两部分:
pitch = width * 3 * 2会让首帧nvEncLockBitstream()在异步模式下不返回。width * 2后可以得到码流,但保留CU_AD_FORMAT_UINT16_PLANAR_444(0x5e)仍有 U/V 错位。改用原生CU_AD_FORMAT_YUV444_16BIT_SEMIPLANAR(0xb5,Y + 交织 UV)后,合成测试图案的完整三帧码流与 device-pointer 对照逐字节相同。这份报告补充了 #871 相关现象的 API 级复现和画面校验结果。上述结论已在下列环境验证,尚不推断所有 GPU/驱动上的行为一致。
环境
v2026.906.141628.杂鱼仅列出复现需要的设置;未附完整配置、设备标识或原始系统日志。
复现步骤
关闭 Array 的原有 device-pointer 路径可以工作。本报告的修复目标是保留 Array、10-bit、4:4:4、HDR 和分割编码。
故障定位:首帧究竟停在哪里
原版应用日志中与问题直接有关的固定错误消息如下。为避免泄露环境信息,此处仅摘录消息正文:
Windows 记录到的异常为
0x80000003,结合 watchdog 路径判断,这是会话无法结束后主动触发的断点异常,不能据此认定是最初发生了访问越界。独立复现器在每个 CUDA/NVENC API 调用前后立即刷新日志,输入完全由程序生成,不依赖桌面内容、Moonlight、网络或虚拟显示器。原版布局与 pitch 的结果是:
上面是调用结果的归纳,并非整段原始日志。外部测试包装器在 12 秒超时后只终止它启动的复现器进程。切换为同步编码、
doNotWait = 0的对照中,首帧锁定返回NV_ENC_ERR_INVALID_PARAM (8)。因此客户端未收到视频可以在不使用客户端和网络的条件下独立复现,停点位于主机 NVENC 首帧取码流阶段。驱动为何没有在注册时拒绝该输入、异步锁定为何没有及时报错,仍需要 NVIDIA/维护者进一步确认;本报告不将未观察到的驱动内部机制当作事实。
根因一:注册 pitch 把三个颜色平面重复算进行跨度
涉及
src/nvenc/win/impl/nvenc_d3d11_on_cuda.cpp的create_and_register_cuda_array_input():10-bit 样本使用 16-bit 容器,所以一行 Y 为
width * 2字节。三个逻辑颜色分量不能再作为注册行跨度的倍数。以 2560 像素宽为例,当前传入 15360,已验证可用的值为 5120。×6的来源是源码固定的3(Y/U/V)×2(每样本两字节),与三条 NVENC 分割带无关。三条带通过NV_ENC_INITIALIZE_PARAMS::splitEncodeMode独立设置;关闭分割编码的小分辨率对照仍能复现原问题,保留三条带的 1440p 对照在完整修正后可以正常编码。提交 acce1db6 / PR #910 将该表达式改为
W*3*2。作为另一份可核对的实现,FFmpeg 的 CUARRAY 编码支持提交 对 CUDA Array 使用第一颜色分量每行的字节宽度作为注册 pitch。根因二:只改 pitch 仍不足以修好原生 Array 的 UV 布局
原实现分配
CU_AD_FORMAT_UINT16_PLANAR_444(0x5e),取得三个 Array plane,然后分别拷入 Y、U、V。修正 pitch 后,编码 API 能返回,但有变化色度的全帧测试揭示了 U/V 错位。这里已额外做过每个 CUDA plane 的 CPU readback:上传值与读回值完全一致,
mismatch = 0、maxError = 0。因此不能用“拷贝 API 成功”或“几个样本读回正确”来排除编码器读取布局的问题。本环境验证成功的输入为:
两个存储平面仍包含完整分辨率的 Y、U、V,未进行 4:2:0/4:2:2 色度抽样。UV plane 每行实际写入
W * 4字节;NVENC 注册时的 pitch 仍是 Y 行字节宽度W * 2。NumChannels = 3是逻辑颜色通道数,不等于三个独立 Array plane。FFmpeg 的 NVDEC 原生 opaque Array 格式选择 对 16-bit 4:4:4 使用同一原生半平面格式。这为实验提供了线索;它不替代针对 NVENC 的上述实际验证。
对照实验与画面校验
0x5e,pitch6W,异步非阻塞锁定nvEncLockBitstream不返回0x5e,pitch6W,同步锁定0x5e,pitch3W0x5e,pitch2W0x5e,pitch2W,调整分配/注册高度及 padding0xb5,pitch2W,CPU 交织上传对照0xb5,pitch2W,D3D11 输入 + GPU PTX 交织 UV最终 GPU 复现器保留 2560×1440、180 fps、P7、VBR 100/150 Mbps、full-resolution two-pass、forced three strips、async、
pic.inputPitch = 0,并在 NVENC 调用前 pop CUDA context。它生成三帧有变化 Y/U/V 的合成图案,经 D3D11 R16 纹理、CUDA 拷贝和 GPU 交织后编码。yuv444p10le、三帧。10A48E1433D2E32C34E25FEFDF3F3229E6B03BBC8D025A42A02E545B72733F0C(合成测试码流摘要,不是设备标识)。码流完全一致仅是此固定输入、配置和驱动的对照结果;没有由此推断所有内容或其他 GPU 都会产生相同码流。
修补设计
补丁仅修改
nvenc_d3d11_on_cuda.h/.cpp,同时修正注册 pitch 和存储布局:DXGI_FORMAT_R16_UINTD3D11 纹理。U | (V << 16)写入原生 UV plane。实际构建的补丁还在原生 Array 成功注册之后加入一条验证日志,记录
native 16-bit 4:4:4 CUDA Array layout Y+UV, luma pitch=及数值。它便于区分原生修补路径和既有 pointer 回退,不打印设备标识。该方案不增加 nvcc 构建依赖;生产逐帧路径没有 CPU 像素读回或 CPU 色度交织。额外 scratch 约为
4 * W * H字节加 pitch 对齐,1440p 约 14.1 MiB。PTX 使用 6.0 /sm_52,由驱动 JIT 编译。跨 GPU/驱动兼容性和性能仍值得扩大验证。完整应用修补及端到端验证
已对完整应用实施源码修补并完成实际串流验证,不再仅依赖独立探针。
构建与部署
ece35eb30c3af260c87182825f24d0d3c9918f5f为基线,按项目官方 Windows MSYS2 UCRT64 构建路线编译完整sunshinetarget。2026.906.141628.cuda-array-fix。上述标记在两轮真实会话中均出现,确认使用修补后的原生 CUDA Array,没有将 device-pointer 回退当成成功。
实际客户端验证
两轮均协商
2560x1440x180 (format 0x800);Moonlight 的VIDEO_FORMAT_H265_REXT10_444 = 0x0800明确定义为 HEVC RExt 4:4:4 10-bit。两轮均启用 D3D11VA 渲染器,并记录 stream HDR / display HDR enabled。这些帧率是此次桌面内容与网络环境下的观测值,串流请求为 180fps;不把桌面实际帧率写成满帧率性能保证。第一轮测试工具的退出控制问题与正常串流阶段数据分开记录,第二轮重新验证了标准退出。
部署后的 Sunshine 进程在两轮测试间保持运行,没有新的
Hang detected、fatal 或 Windows Sunshine 应用崩溃事件。原版的首帧无视频与会话退出卡死,在以上修补后实际会话中未再出现。与既有记录的关系
W*3*2;默认 4:4:4 可用不能证明 Array 路径可用。建议维护者复核
请优先复核多平面 CUDA Array 注册 pitch 的含义,以及
NV_ENC_BUFFER_FORMAT_YUV444_10BIT在原生 Array 输入模式下要求的实际 UV 存储布局。建议回归测试同时包含变化亮度和变化色度,覆盖“有输出”之外的完整图像正确性,并在至少一款旧架构 NVIDIA GPU 上复测。仅将6W改为2W不足以修复本环境中的全部问题。实际构建的完整源码补丁(相对仓库路径)