CUDA 设备代码中的 long double:为什么不能把它当作扩展精度

在主机 C++ 代码中,long double 通常表示精度和范围不低于 double 的浮点类型。但把同一个类型写进 CUDA 设备函数时,nvcc 可能给出警告:

'long double' is treated as 'double' in device code

这里最重要的结论不是“GPU 上的 long double 究竟如何布局”,而是:CUDA C++ 明确不支持在设备代码中使用 long double。 编译器为了继续编译而采取的降级行为,只是实现细节,不是可以依赖的语言契约。

因此,工程上的处理原则很直接:设备端接口和运算统一使用受支持的 float 或 double;如果精度仍然不足,再从算法、数据表示或软件多倍精度实现中寻找方案,而不是忽略警告。

先把三个概念拆开

讨论实验结果前,需要把类型大小、精度和支持状态分开。

sizeof 不等于有效精度

sizeof(long double) 由主机平台 ABI 决定,并不直接说明对象中的每一位都参与数值表示。常见情况包括:

  • x86-64 System V ABI 中,80 位扩展精度值经常占用 16 字节存储,其中包含填充;

  • Windows 上的 MSVC 通常让 long double 与 double 采用相同表示;

  • 其他架构还可能使用不同格式。

因此,“对象占 16 字节”不能推出“它提供 128 位浮点精度”,更不能据此判断设备端是否支持相同格式。

主机代码和设备代码由不同后端处理

一个 .cu 文件实际上跨越两套执行环境:

  • 主机代码由主机 C++ 编译器和对应 ABI 处理;

  • 设备代码由 CUDA 编译器前端与 GPU 后端处理。

同一个类型名出现在两边,不代表主机和设备支持相同的表示与运算。CUDA 文档在 C/C++ Language Restrictions 中明确列出:设备代码不支持 long double。

编译通过不等于受支持

警告中的 “treated as double” 表示当前编译器可能把设备端运算降为双精度。它只能说明这个工具链版本选择了怎样继续编译,不能保证:

  • 设备端保留主机 long double 的范围或精度;

  • 对象的全部存储字节具有可移植含义;

  • 主机和设备之间直接复制 long double 后仍保持同一数值;

  • 后续 CUDA 版本继续采用完全相同的降级策略。

为什么原始字节实验容易误导

一种常见实验是打印 long double 的每个字节,再与 double 的对象表示比较。这可以记录某次编译和运行的结果,却无法证明跨平台、跨工具链的语言保证,而且实验本身还容易引入新的误判。

char 可能导致符号扩展

下面的写法会先把 char 提升为 int,再交给要求 unsigned int 的 %x。当平台默认把 char 视为有符号类型时,0xcd 还会在提升过程中发生符号扩展:

const char byte = static_cast<char>(0xcd);
std::printf("%08x\n", byte); // 类型不匹配,常见现象是输出 ffffffcd

观察对象表示时应从一开始就使用 unsigned char 或 std::byte,再把字节值转换为 %x 所要求的 unsigned int:

const unsigned char byte = 0xcd;
std::printf("%02x\n", static_cast<unsigned int>(byte));

填充字节不是数值证据

即使对象后半部分恰好全为零,也只能说明本次运行时这些存储字节的状态。填充字节可能根本不参与数值表示,不能被解释为“扩展精度的高位”。

设备与主机复制可能制造 ABI 陷阱

如果设备端实际按 double 运算,而接口仍保留主机侧 long double 的大小和对齐特征,cudaMemcpy 只会原样复制字节,不会替你完成数值格式转换。随后再把这些字节解释为主机原生的 long double,得到的结果没有可移植保证。

分别验证主机表示和设备计算

如果目的是观察主机对象表示,应先把数值复制到无符号字节数组中,避免别名规则、符号扩展和格式化参数带来的干扰:

#include <array>
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <type_traits>

template <typename T>
void dump_bytes(const T& value) {
    static_assert(std::is_trivially_copyable_v<T>);
    std::array<unsigned char, sizeof(T)> bytes{};
    std::memcpy(bytes.data(), &value, sizeof(T));

    for (unsigned char byte : bytes) {
        std::printf("%02x ", static_cast<unsigned int>(byte));
    }
    std::putchar('\n');
}

int main() {
    const double d = 1.3;
    const long double ld = 1.3L;

    std::printf("sizeof(double)      = %zu\n", sizeof(d));
    std::printf("sizeof(long double) = %zu\n", sizeof(ld));
    dump_bytes(d);
    dump_bytes(ld);
}

这段代码只回答“当前主机 ABI 如何存储这个对象”,不能据此推导 GPU 的数值能力。

设备端实验应该把接口、存储和运算统一为 CUDA 支持的 double,并检查每一步运行时调用:

#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

#define CUDA_CHECK(call)                                                   \
    do {                                                                   \
        const cudaError_t error = (call);                                  \
        if (error != cudaSuccess) {                                        \
            std::fprintf(stderr, "%s:%d: %s\n",                          \
                         __FILE__, __LINE__, cudaGetErrorString(error));    \
            std::exit(EXIT_FAILURE);                                       \
        }                                                                  \
    } while (false)

__global__ void write_value(double* output) {
    *output = 1.3;
}

int main() {
    double* device_value = nullptr;
    double host_value = 0.0;

    CUDA_CHECK(cudaMalloc(&device_value, sizeof(double)));
    write_value<<<1, 1>>>(device_value);
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());
    CUDA_CHECK(cudaMemcpy(&host_value, device_value, sizeof(double),
                          cudaMemcpyDeviceToHost));
    CUDA_CHECK(cudaFree(device_value));

    std::printf("%.17g\n", host_value);
}

这个版本没有让不受支持的类型穿过 kernel 边界,也不会把一套 ABI 下的对象表示误认为另一套数值格式。主机对象布局与设备计算能力被拆成两个问题后,实验结论才不会互相污染。

如果确实需要更高精度

CUDA 设备代码的原生浮点选择主要是 float 和 double。当 double 仍无法满足要求时,先判断真正受限的是误差、动态范围还是某个局部步骤:

  1. 只是累计误差过大:尝试重新排序计算、成对求和、Kahan 求和或缩放数据;

  2. 需要更大动态范围:考虑对数域、分段缩放或重写数值表示;

  3. 确实需要多倍精度:评估经过验证的软件多精度实现,并接受明显的性能成本;

  4. 只有少量高精度步骤:把这些步骤留在 CPU,GPU 继续执行大规模 float / double 工作。

不要只把变量类型改成 long double,然后忽略编译器警告。类型名仍然存在,不代表设备端真的获得了额外精度。

正确验证数值行为

比“逐字节猜格式”更可靠的验证方式是:

  • 明确记录 CUDA Toolkit、主机编译器、GPU 型号和计算能力;

  • 使用可计算误差界的输入,并比较主机参考结果;

  • 同时测试绝对误差、相对误差、极端值、次正规数、无穷和 NaN;

  • 查看生成的 PTX 或 SASS,确认实际使用的指令精度;

  • 把实验结论限定在测试过的工具链与硬件范围内。

NVIDIA 的 Floating Point and IEEE 754 文档进一步解释了 GPU 浮点运算、舍入和融合乘加等行为。

总结

设备代码里的 long double 不是一种“占 16 字节、低 8 字节按 double 使用”的可靠数据格式;这种现象至多是特定主机 ABI 与编译器降级策略共同产生的观察结果。可移植的工程结论只有一条:CUDA kernel 和设备函数使用受支持的 float 或 double;需要更高精度时,选择目标明确的数值算法或软件实现,并用误差测试验证,而不是依赖未受支持类型的偶然行为。