From ea87f1a1dfcb22f70ecc6be0925b0bc9c2eb345c Mon Sep 17 00:00:00 2001 From: hxy21211319 <2249818804@qq.com> Date: Fri, 18 Sep 2026 14:44:30 +0800 Subject: [PATCH 1/2] feat: add MXFP8 and NVFP4 quantization project --- .../.gitignore" | 32 + .../CMakeLists.txt" | 50 ++ .../CMakePresets.json" | 60 ++ .../Conclusion.md" | 413 ++++++++++ .../README.md" | 334 ++++++++ .../apps/CMakeLists.txt" | 7 + .../apps/main.cpp" | 411 ++++++++++ .../benchmarks/CMakeLists.txt" | 7 + .../benchmarks/benchmark_main.cpp" | 449 +++++++++++ .../configs/README.md" | 31 + .../configs/mxfp8_block_nearest_bf16.toml" | 6 + .../configs/mxfp8_block_nearest_fp16.toml" | 6 + .../configs/mxfp8_block_nearest_fp32.toml" | 6 + .../configs/mxfp8_block_stochastic_bf16.toml" | 7 + .../configs/mxfp8_block_stochastic_fp16.toml" | 7 + .../configs/mxfp8_block_stochastic_fp32.toml" | 7 + .../configs/mxfp8_tensor_nearest_bf16.toml" | 6 + .../configs/mxfp8_tensor_nearest_fp16.toml" | 6 + .../configs/mxfp8_tensor_nearest_fp32.toml" | 6 + .../mxfp8_tensor_stochastic_bf16.toml" | 7 + .../mxfp8_tensor_stochastic_fp16.toml" | 7 + .../mxfp8_tensor_stochastic_fp32.toml" | 7 + .../configs/nvfp4_block_nearest_bf16.toml" | 6 + .../configs/nvfp4_block_nearest_fp16.toml" | 6 + .../configs/nvfp4_block_nearest_fp32.toml" | 6 + .../configs/nvfp4_block_stochastic_bf16.toml" | 7 + .../configs/nvfp4_block_stochastic_fp16.toml" | 7 + .../configs/nvfp4_block_stochastic_fp32.toml" | 7 + .../docs/app.md" | 111 +++ .../docs/configuration.md" | 225 ++++++ .../docs/experiments.md" | 218 +++++ .../docs/file_format.md" | 242 ++++++ .../docs/format_codecs.md" | 401 ++++++++++ .../docs/format_spec.md" | 377 +++++++++ .../docs/kernels.md" | 303 +++++++ .../docs/tests.md" | 186 +++++ .../include/quant_dequant/config.hpp" | 89 +++ .../include/quant_dequant/metrics.hpp" | 263 +++++++ .../include/quant_dequant/quantize.hpp" | 184 +++++ .../include/quant_dequant/quantized_io.hpp" | 66 ++ .../quant_dequant/quantized_tensor.hpp" | 264 +++++++ .../include/quant_dequant/tensor_io.hpp" | 91 +++ .../include/quant_dequant/types.hpp" | 537 +++++++++++++ .../include/quant_dequant/version.hpp" | 14 + .../scripts/generate_tensor.py" | 289 +++++++ .../scripts/run_benchmark_suite.py" | 446 +++++++++++ .../scripts/run_e2e_suite.py" | 687 ++++++++++++++++ .../src/CMakeLists.txt" | 43 + .../src/common/cuda_stream.cu" | 48 ++ .../src/common/cuda_stream.cuh" | 64 ++ .../src/common/cuda_timer.cu" | 84 ++ .../src/common/cuda_timer.cuh" | 96 +++ .../src/config/config_parser.cpp" | 605 ++++++++++++++ .../src/cuda/mxfp8_dequantize.cu" | 204 +++++ .../src/cuda/mxfp8_dequantize.cuh" | 31 + .../src/cuda/mxfp8_quantize.cu" | 679 ++++++++++++++++ .../src/cuda/mxfp8_quantize.cuh" | 75 ++ .../src/cuda/nvfp4_dequantize.cu" | 211 +++++ .../src/cuda/nvfp4_dequantize.cuh" | 31 + .../src/cuda/nvfp4_quantize.cu" | 584 ++++++++++++++ .../src/cuda/nvfp4_quantize.cuh" | 54 ++ .../src/formats/fp32_utils.cuh" | 331 ++++++++ .../src/formats/mxfp8_codec.cuh" | 480 +++++++++++ .../src/formats/nvfp4_codec.cuh" | 449 +++++++++++ .../src/io/quantized_io.cpp" | 742 ++++++++++++++++++ .../src/io/tensor_io.cpp" | 685 ++++++++++++++++ .../src/metrics/metrics.cpp" | 505 ++++++++++++ .../src/pipeline/dequantize.cpp" | 368 +++++++++ .../src/pipeline/device_quantized_tensor.cuh" | 347 ++++++++ .../src/pipeline/pipeline_detail.hpp" | 87 ++ .../src/pipeline/quantize.cpp" | 598 ++++++++++++++ .../src/quant_dequant.cpp" | 9 + .../src/reference/mxfp8_reference.cpp" | 365 +++++++++ .../src/reference/nvfp4_reference.cpp" | 329 ++++++++ .../src/reference/reference_detail.hpp" | 72 ++ .../src/reference/reference_dispatch.cpp" | 80 ++ 76 files changed, 15160 insertions(+) create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/.gitignore" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakeLists.txt" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakePresets.json" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/Conclusion.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/README.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/CMakeLists.txt" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/main.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/CMakeLists.txt" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/benchmark_main.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/README.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_bf16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp32.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_bf16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp32.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_bf16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp32.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_bf16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp32.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_bf16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp32.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_bf16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp16.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp32.toml" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/app.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/configuration.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/experiments.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/file_format.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_codecs.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_spec.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/kernels.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/tests.md" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/config.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/metrics.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantize.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_io.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_tensor.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/tensor_io.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/types.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/version.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/generate_tensor.py" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_benchmark_suite.py" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_e2e_suite.py" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/CMakeLists.txt" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/config/config_parser.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/fp32_utils.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/mxfp8_codec.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/nvfp4_codec.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/quantized_io.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/tensor_io.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/metrics/metrics.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/dequantize.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/device_quantized_tensor.cuh" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/pipeline_detail.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/quantize.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/quant_dequant.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/mxfp8_reference.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/nvfp4_reference.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_detail.hpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_dispatch.cpp" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/.gitignore" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/.gitignore" new file mode 100644 index 00000000..1d8d005d --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/.gitignore" @@ -0,0 +1,32 @@ +# CMake 与本地构建目录 +/build/ +/cmake-build-*/ +/CMakeCache.txt +/CMakeFiles/ +/CTestTestfile.cmake +/Testing/ +/compile_commands.json +/CMakeUserPresets.json + +# 程序运行产生的文件 +/outputs/ +/logs/ +*.log +*.jsonl +*.csv + +# Python 实验脚本的解释器缓存 +__pycache__/ +*.pyc + +# 大型或临时二进制数据;小型测试样例可显式保留在 tests/data/。 +*.bin +!tests/data/ +!tests/data/**/*.bin + +# CUDA 分析和编译中间产物 +*.ptx +*.cubin +*.fatbin +*.nsys-rep +*.ncu-rep diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakeLists.txt" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakeLists.txt" new file mode 100644 index 00000000..8ab7e772 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakeLists.txt" @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.22) + +project(quant_dequant LANGUAGES CXX CUDA) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# CMake 3.22 尚未定义 CUDA20 对应的 NVCC 编译选项。host 侧 .cpp 继续 +# 使用 C++20;.cu 文件使用 CUDA 12.1 与 CMake 3.22 均明确支持的 C++17。 +# 当前 codec 和 kernel 约束在 C++17 可用特性内,以便两种编译路径一致。 +set(CMAKE_CUDA_STANDARD 17) +set(CMAKE_CUDA_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +option(QUANT_DEQUANT_BUILD_TESTS "构建正确性测试" ON) +option(QUANT_DEQUANT_BUILD_APPS "构建命令行程序" ON) +option(QUANT_DEQUANT_BUILD_BENCHMARKS "构建性能评测程序" ON) +set(QUANT_DEQUANT_CUDA_ARCHITECTURES "" CACHE STRING + "目标 CUDA 架构,例如 RTX 4060 使用 89;留空时由 CMake 决定") + +if(QUANT_DEQUANT_CUDA_ARCHITECTURES) + set(CMAKE_CUDA_ARCHITECTURES "${QUANT_DEQUANT_CUDA_ARCHITECTURES}" CACHE STRING + "CUDA 架构列表" FORCE) +endif() + +include(CTest) +find_package(CUDAToolkit REQUIRED) + +function(quant_dequant_enable_warnings target_name) + target_compile_options(${target_name} + PRIVATE + $<$:-Wall -Wextra -Wpedantic> + ) +endfunction() + +# src 只生成可复用核心库;应用、测试和性能评测各自链接该库。 +add_subdirectory(src) + +if(QUANT_DEQUANT_BUILD_APPS) + add_subdirectory(apps) +endif() + +if(BUILD_TESTING AND QUANT_DEQUANT_BUILD_TESTS) + add_subdirectory(tests) +endif() + +if(QUANT_DEQUANT_BUILD_BENCHMARKS) + add_subdirectory(benchmarks) +endif() diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakePresets.json" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakePresets.json" new file mode 100644 index 00000000..be993817 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/CMakePresets.json" @@ -0,0 +1,60 @@ +{ + "version": 3, + "cmakeMinimumRequired": { + "major": 3, + "minor": 22, + "patch": 0 + }, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Unix Makefiles", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "QUANT_DEQUANT_BUILD_APPS": "ON", + "QUANT_DEQUANT_BUILD_BENCHMARKS": "ON", + "QUANT_DEQUANT_BUILD_TESTS": "ON", + "QUANT_DEQUANT_CUDA_ARCHITECTURES": "89" + } + }, + { + "name": "rtx4060-release", + "displayName": "RTX 4060 Release", + "description": "为 NVIDIA RTX 4060(compute capability 8.9)构建 Release 版本。", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "rtx4060-debug", + "displayName": "RTX 4060 Debug", + "description": "为 NVIDIA RTX 4060(compute capability 8.9)构建 Debug 版本。", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + } + ], + "buildPresets": [ + { + "name": "rtx4060-release", + "configurePreset": "rtx4060-release" + }, + { + "name": "rtx4060-debug", + "configurePreset": "rtx4060-debug" + } + ], + "testPresets": [ + { + "name": "rtx4060-release", + "configurePreset": "rtx4060-release" + }, + { + "name": "rtx4060-debug", + "configurePreset": "rtx4060-debug" + } + ] +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/Conclusion.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/Conclusion.md" new file mode 100644 index 00000000..0406261b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/Conclusion.md" @@ -0,0 +1,413 @@ +# CUDA MXFP8 / NVFP4 量化与反量化项目总结 + +## 项目目标与完成范围 + +本项目实现了一个完整的 CUDA 低精度浮点软件模拟程序。它从 QDTENSOR 文件读取 +FP16 或 FP32 的 row-major 矩阵,根据配置量化为 MXFP8 或 NVFP4,按真实 bit 宽度写入 +QDWGT 文件,再由 CUDA kernel 反量化为 FP16、BF16 或 FP32,并输出误差、压缩率、 +kernel 时间与有效带宽 JSON 日志。 + +实现覆盖以下完整数据路径: + +```mermaid +flowchart LR + A["QDTENSOR 输入
FP16 或 FP32"] --> B["读取并扩展为 host FP32"] + B --> C["CUDA 量化
MXFP8 或 NVFP4"] + C --> D["QDWGT
payload 与 scale"] + D --> E["读回并校验 QDWGT"] + E --> F["CUDA 反量化
FP32 暂存"] + F --> G["QDTENSOR 输出
FP16、BF16 或 FP32"] + G --> H["读回输出并计算指标"] + H --> I["JSON 运行报告"] +``` + +其中 CPU reference、二进制 I/O、CUDA 量化/反量化、独立单元测试以及批量实验脚本均已 +实现。MXFP8 的 `tensor` scale 是为作业配置要求增加的项目扩展;严格 OCP +MXFP8 基线路径为 `block_size = 32` 的 block scaling。NVFP4 固定为每 16 元素局部 +scale 加整张张量全局 scale,不支持 tensor-only mode。 + +更细的数值规范、文件规范和 kernel 源码导读分别见 +[docs/format_spec.md](docs/format_spec.md)、[docs/file_format.md](docs/file_format.md) 与 +[docs/kernels.md](docs/kernels.md)。 + +## 低精度格式与数值语义 + +### 共同量化模型 + +低精度浮点的 payload 仍是浮点 codebook,而不是 INT code;但它的指数和尾数位数很少, +不能直接覆盖一般 FP32 张量的动态范围。因此先用正 scale 将一组原始值映射到 payload +可表示范围,再进行浮点编码: + +$$ +z_i = x_i / s,qquad p_i = Q(z_i),qquad +\hat{x}_i = \operatorname{decode}(p_i) \cdot s. +$$ + +这里 $x_i$ 是原始 FP32 值,$z_i$ 是归一化值,$p_i$ 是实际保存的低精度 bit pattern, +$\hat{x}_i$ 是反量化结果。若 payload 最大有限幅值为 $q_{\max}$,当前组的 +$amax = \max_i |x_i|$,避免最大元素饱和至少要求: + +$$ +s \ge amax / q_{\max}. +$$ + +在不饱和的 scale 中,较小的 scale 通常更好:它会把小元素推向 payload codebook 的 +normal 区域,而不是 subnormal 或零附近。block scale 的价值就是避免一个异常大的元素 +用全局 scale 牺牲整张张量中其他区域的分辨率。 + +元素编码支持两种舍入策略: + +- `nearest`:round-to-nearest, ties-to-even(RNE,最近值、平局取偶)。 +- `stochastic`:在相邻可表示值 $l$、$h$ 之间,以 + $(a-l)/(h-l)$ 的概率选择较大的 $h$;随机数由 `stochastic_seed` 和全局线性下标的 + 无状态 SplitMix64 映射决定,因此 CPU 与 GPU 可逐元素复现。 + +输入必须是有限 FP16/FP32 值。遇到 NaN 或 $\pm\infty$ 时,公共接口会失败,不产生 +可用的量化文件;这避免把未定义的私有 sentinel 混入 NVFP4 数据。 + +### MXFP8:E4M3 payload 与 E8M0 scale + +MXFP8 的元素 payload 固定为 E4M3,一个字节的 layout 为 `S EEEE MMM`:1 bit 符号、 +4 bit 指数、3 bit 尾数,指数 bias 为 7。其有限值定义为: + +$$ +p=(-1)^S 2^{E-7}(1+M/8),\quad E>0, +$$ + +$$ +p=(-1)^S 2^{-6}(M/8),\quad E=0. +$$ + +第二式是 subnormal 区域:`E = 0` 时没有隐含 leading one。E4M3 的最小 subnormal 为 +$2^{-9}$,最小 normal 为 $2^{-6}$,最大有限值为 `448`(`0x7e`);`0x7f` 和 +`0xff` 是 NaN。该 E4M3 变体没有 Inf 编码,有限输入溢出时饱和到 $\pm448$。 + +每个严格 MXFP8 block 有 32 个 E4M3 元素和一个 E8M0 scale。E8M0 是一个只有 8 bit +指数、没有尾数的浮点 scale: + +$$ +s_b = 2^{e_b-127},qquad e_b\in[0,254]. +$$ + +因为 E4M3 最大有限值为 448,block 最大幅值 $a_b$ 的理想连续 scale 为 +$a_b / 448$。但 E8M0 只能表示 2 的幂,因此实际选择不小于该值的最小 E8M0: + +$$ +s_b = 2^{\lceil\log_2(a_b/448)\rceil}. +$$ + +实际实现还会把 E8M0 指数 clamp 到可编码范围。向上取整保证 +$a_b/s_b\le448$,不会把 block 最大元素饱和;未触及 clamp 时还有 +$224 < a_b/s_b\le448$,即最大元素会落入 E4M3 有限范围的上半区。全零 block 写最小 +有限 E8M0 scale $2^{-127}$,所有 payload 规范化为正零 `0x00`,因此反量化仍严格为零。 + +MXFP8 的反量化公式为: + +$$ +\hat{x}_i = \operatorname{decode}_{\mathrm{E4M3}}(p_i)\cdot s_{b(i)}. +$$ + +`block` 模式按行每 32 个元素一个 $s_b$;项目扩展的 `tensor` 模式则只计算一个整张 +张量的 $amax$,写入唯一的 E8M0 $s_0$,所有元素共享它。 + +### NVFP4:E2M1 payload、E4M3 local scale 与 FP32 global scale + +NVFP4 的元素为 4 bit E2M1 nibble,layout 是 `S EE M`。指数 bias 为 1,正数幅值 +codebook 是: + +| `EE M` | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 幅值 | 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | + +其中 code 1 是 subnormal,最大有限幅值仅为 6;E2M1 没有 NaN 或 Inf。仅靠一个 +E2M1 scale 无法同时覆盖整张张量与保留局部精度,所以 NVFP4 采用两层 scale: + +$$ +\hat{x}_i = \operatorname{decode}_{\mathrm{E2M1}}(q_i) +\cdot \operatorname{decode}_{\mathrm{E4M3}}(l_{b(i)}) \cdot s_g. +$$ + +这里 $q_i$ 是 E2M1 nibble,$l_b$ 是每 16 个 rowwise 元素一个的 E4M3 local-scale +code,$s_g$ 是整张张量唯一的 FP32 global scale。global scale 使用解码方向保存,不存 +倒数: + +$$ +a_g=\max_i |x_i|,\qquad +s_g=\begin{cases} +a_g/(448\times6), & a_g>0,\\ +1, & a_g=0. +\end{cases} +$$ + +$448\times6=2688$ 来自两层可表示幅值的乘积:E4M3 local scale 最大为 448,E2M1 +payload 最大为 6。因此 $2688s_g$ 是 NVFP4 可由给定 global scale 重构的最大幅值, +选择 $s_g=a_g/2688$ 会恰好让全局最大值落在该边界。随后对每个 block: + +$$ +a_b=\max_{i\in b}|x_i|,qquad +l_b=Q_{\mathrm{E4M3}}\left(a_b/(6s_g)\right),qquad +q_i=Q_{\mathrm{E2M1}}\left(x_i/(\operatorname{decode}(l_b)s_g)\right). +$$ + +local scale 也以 RNE 量化到 E4M3。它的作用是把不同 block 的幅值重新对齐到 E2M1 的 +`0.5...6` 范围;global scale 则把整个张量压入 E4M3 local-scale 的 `0...448` 范围。 +这也是 NVFP4 不接受 `scale_mode = tensor` 的原因:拿掉每 16 元素 local scale 后, +它已不再是 NVFP4 的双层缩放格式。 + +## 真实 bit 宽度与文件布局 + +矩阵按 row-major 保存,设形状为 $R\times C$、元素数为 $N=RC$。block 不跨行;每行 +block 数为 $\lceil C/B\rceil$,所以 block scale 总数为 +$R\lceil C/B\rceil$。这会让最后不足 $B$ 的尾 block 获得独立 scale,但 payload 中不 +写补零元素。 + +| 格式 | payload 布局 | scale 布局 | 每元素核心存储 | +| --- | --- | --- | ---: | +| MXFP8 block | 线性第 $n$ 个元素就是 `payload[n]` 的一个 E4M3 byte。 | 每行每 32 元素一个 E8M0 byte。 | 8 bit + 每 32 元素 8 bit scale。 | +| MXFP8 tensor | 同上。 | 整张张量仅一个 E8M0 byte。 | 约 8 bit。 | +| NVFP4 block | `n` 为偶数写低 nibble,奇数写高 nibble;一个 `uint8_t` 保存两个 E2M1。 | 每行每 16 元素一个 E4M3 byte,另有一个 FP32 global scale。 | 4 bit + 每 16 元素 8 bit local scale。 | + +NVFP4 的物理打包关系为: + +$$ +\operatorname{payload}[\lfloor n/2\rfloor] = +\begin{cases} +(\operatorname{byte}\mathbin{\&}\mathtt{0xf0})\mathbin{|}q_n, & n\text{ 为偶数},\\ +(\operatorname{byte}\mathbin{\&}\mathtt{0x0f})\mathbin{|}(q_n\ll4), & n\text{ 为奇数}. +\end{cases} +$$ + +若 $N$ 为奇数,最后一个物理 byte 的高 nibble 强制写零,并在 QDWGT header 的 flag 中 +记录该事实。这既满足“两个 4 bit 元素每字节”的要求,也使读取器能检查尾 nibble 是否 +未初始化。 + +文件容器不直接序列化 C++ struct,而是按 little-endian 固定 offset 逐字段编码: + +- QDTENSOR 使用 64-byte header,保存 shape、物理 dtype 和 `tensor_role`;输入只允许 + FP16/FP32,反量化输出允许 FP16/BF16/FP32。 +- QDWGT 使用 128-byte header,保存 format、scale mode、rounding、来源 dtype、真实 + payload 位宽、block size、各 section offset/size、NVFP4 global scale 和随机种子。 +- QDWGT 的 payload 从 offset 128 开始,local scale section 对齐到 8 bytes。读取器会 + 检查 magic、版本、字段组合、section 边界、payload/scale 长度及 NVFP4 尾 nibble。 + +这种布局使量化文件可脱离当前配置独立读取,也避免 C++ ABI padding、宿主端字节序和 +struct 对齐改变文件格式。 + +## 实现思路与优化过程 + +### 分层设计与正确性基线 + +项目将“数值规则”和“并行调度”刻意分离: + +- `src/formats/` 实现无状态、`__host__ __device__` 可调用的 FP32 bit helper、E4M3、 + E8M0、E2M1、舍入和 nibble pack/unpack。它不依赖 tensor 形状、文件和 CUDA grid。 +- `src/reference/` 用同一套 codec 写普通 C++ 的完整 CPU reference,输出统一的 + `QuantizedTensor`。它是 GPU payload、scale 和反量化数值的逐项比较基线。 +- `src/cuda/` 只处理线程映射、规约、访存和 kernel launch;格式转换必须复用 + `formats/` 的函数,避免 CPU/GPU 各维护一份边界规则。 +- `src/pipeline/` 以 `thrust::device_vector` 管理 device 所有权,负责 H2D、格式分派、 + kernel launch 与 D2H;host 的 `QuantizedTensor` 专门拥有最终 payload/scale,便于统一 + 文件写出和 CPU/GPU 结果交接。 +- `src/io/` 固定 QDTENSOR/QDWGT 字节布局;`src/metrics/` 独立计算误差、压缩率、 + Event 时间对应的逻辑有效带宽和 JSON。 + +开发顺序先建立 codec 与 CPU reference,再构造 QDWGT I/O,最后实现 CUDA。这样遇到 +错误时可以首先判断是单元素编码、矩阵 scale、文件写读,还是 GPU 并行映射的问题,而 +不是只面对一个总误差结果。 + +### MXFP8 CUDA 量化与反量化 + +MXFP8 block size 恰为 32,因此 block mode 的自然映射是**一个 warp 处理一个量化 +block**:lane $l$ 读取 block 中列偏移 $l$ 的 FP32,使用 `__shfl_down_sync()` 规约 +warp 内 $amax$,lane 0 计算并写唯一 E8M0 scale,再通过 shuffle 广播该 scale;每个有效 +lane 独立写一个 E4M3 payload byte。尾 block 的无效 lane 不访存,只向规约贡献零。 + +一个 CTA 固定为 256 threads,即 8 个 warp,同时处理 8 个逻辑 block。launcher 查询 +SM 数和 occupancy,以不超过 `SM × min(4, 可驻留 CTA/SM)` 的 CTA 数发射 persistent +grid;每个 warp 在 kernel 内通过 grid-stride 循环领取后续 block。它减少了海量小 CTA +的调度开销,并保留了对大矩阵的持续并行度。 + +MXFP8 tensor mode 无法由各 CTA 在一次 kernel 内安全共享最终 $amax$,因此采用三段式: + +1. 第一阶段 persistent reduction 使用 `float4` 合并读取和 grid-stride 循环,每个 CTA + 写出一个 `partial_amax` 与 `has_nonfinite` 标记。 +2. 第二阶段只启动一个 CTA,规约所有 partial,直接将唯一 E8M0 scale 写入 + `local_scales[0]`。 +3. 第三阶段独立编码 kernel 读取这个 scale,并行产生全部 E4M3 payload。 + +反量化更直接:一维 grid-stride 中每线程解一个或多个 payload。tensor mode 始终读 +`scale[0]`;block mode 通过 row、column 计算 rowwise scale index。之后调用同一 E4M3 +decode 并乘 scale。 + +### NVFP4 CUDA 量化与反量化 + +NVFP4 的 global scale 依赖全张量最大值,因此量化必先走与 MXFP8 tensor mode 类似的 +两阶段规约:partial global $amax$,再由单 CTA 写一个 FP32 `global_scale`。之后分两步 +处理局部量化: + +1. local-scale kernel 使用 `cooperative_groups::thread_block_tile<16>`;一个 16-lane + tile 处理一个 rowwise local block,规约 $a_b$ 后由 tile leader 写一字节 E4M3 local + scale。 +2. packed-encode kernel 使用 `thread_block_tile<32>`。偶数 lane 唯一拥有一个 payload + byte:它编码自身的 low nibble、从相邻奇数 lane 取得 high nibble,再进行一次 8-bit + store。 + +将 local-scale 与 encode 分为两个 kernel 是有意的优化/正确性取舍,而不是遗漏融合: +local scale 必须先对整个 16 元素 block 的 $amax$ 完成规约;并且 16 元素 rowwise block +与两个线性元素一个 byte 的物理 payload 边界并不总重合。例如奇数列矩阵中,行末和下一 +行开头可能共用一个 packed byte,却属于不同 local block。若让各 tile 独立 read-modify- +write 同一 byte,会产生竞争。独立 packed-encode kernel 改为让偶数 lane 成为唯一 byte +writer,同时保留连续、合并的读写。 + +NVFP4 反量化采取“一个线程一个物理 payload byte”:线程加载一次 byte,分别解低/高 +nibble,并为两个线性元素各自推导 rowwise local-scale index。这个细节正确覆盖奇数列时 +跨行共用的 byte,避免错误地假设两个 nibble 必定在同一 local block。 + +### 开发中确认的边界问题 + +- **rowwise block 与线性 packing 是两种不同分组。** scale 不跨行,但 NVFP4 payload + 按线性元素两两打包;测试专门使用 35 列和奇数元素数覆盖尾 block、跨行 byte 和尾 + nibble。 +- **tensor $amax$ 不能在多个 CTA 内直接同步。** 先写 partial workspace、再用单 CTA + finalize 是正确的跨 CTA 规约方式;workspace 由 pipeline 在三段 kernel 完成前持续持有。 +- **非有限值不能只做浮点 max。** NaN 在普通 max 中可能被掩盖,因此规约状态同时保存 + `amax` 与 `has_nonfinite`;D2H 后发现 sentinel 即拒绝量化结果。 +- **文件大小和逻辑压缩率不同。** QDWGT 固定 header 与 8-byte section 对齐会降低实际 + 落盘压缩率,尤其在小张量上更明显;报告同时保留逻辑和 on-disk 两种定义。 +- **误差必须在输出落盘后计算。** 反量化 kernel 内部先得到 FP32;但目标为 FP16/BF16 + 时,写 QDTENSOR 还会再窄化一次。app 写出后重新读取输出文件,再和输入扩展后的 FP32 + 比较,因此指标反映用户真正获得的结果。 + +## 最终误差、压缩率与性能结果 + +### 验证方法 + +CTest 将 codec、配置、I/O、CPU reference、device 数据结构和 CUDA integration 拆成 +独立可执行文件。当前共 18 个测试条目,覆盖: + +- E4M3/E8M0/E2M1 的边界、RNE、stochastic、饱和、规范化零和 host/device 一致性; +- QDTENSOR/QDWGT round-trip、header 字段、损坏文件与真实 NVFP4 nibble 布局; +- MXFP8 block/tensor、NVFP4 block 的 CPU reference 与 CUDA payload、local scale、 + global scale、反量化数值逐项对照; +- 35 列 tail block、NVFP4 跨行 packed byte、persistent grid-stride 第二轮工作、三种 + 输出 dtype 以及 CUDA Event profile API。 + +### 最终误差指标 + +以下具体表格选取 **FP32 输入、FP32 输出、RNE、`1024 × 1025`**,以排除 FP16/BF16 +输出窄化以及随机舍入带来的额外变量。GPU 为 NVIDIA RTX 4060(SM89),构建 preset 为 +`rtx4060-release`。本表只保留最终数值误差;性能数据统一使用下一节的同进程 benchmark, +不混入本节。 + +| 格式 / scale | 分布 | max abs | MAE | MSE | +| --- | --- | ---: | ---: | ---: | +| MXFP8 block | uniform | 0.031250 | 0.010417 | 0.000186 | +| MXFP8 block | normal | 0.248401 | 0.017952 | 0.000703 | +| MXFP8 block | outlier | 0.242721 | 0.017784 | 0.000696 | +| MXFP8 tensor | uniform | 0.031250 | 0.010417 | 0.000186 | +| MXFP8 tensor | normal | 0.248401 | 0.017952 | 0.000703 | +| MXFP8 tensor | outlier | 0.242721 | 0.017784 | 0.000696 | +| NVFP4 block | uniform | 0.166666 | 0.044308 | 0.003447 | +| NVFP4 block | normal | 0.593036 | 0.071406 | 0.009053 | +| NVFP4 block | outlier | 1.333329 | 0.149060 | 0.084099 | + +该表的数值趋势符合格式预期: + +- MXFP8 的 3-bit 尾数与更大的 E4M3 codebook,使 MAE 明显低于仅有 4 bit E2M1 + payload 的 NVFP4;在 normal 输入上,代表性 RNE MAE 为 $0.01795$ 对 $0.07141$。 +- NVFP4 的最大优势是存储。它为每个元素支付 4 bit payload 和每 16 元素 1 byte local + scale,仍显著小于 MXFP8 的 8 bit payload。 +- 在本次特定输入里,MXFP8 block/tensor 的误差恰好非常接近;这不是一般性保证。更大、 + 更强的局部动态范围差异或更多异常值时,block scale 通常会更有利于小值精度。 + +### 最终压缩率 + +对本次 $N=1024\times1025=1,049,600$ 的 FP32 输入,逻辑量化字节数和压缩率为: + +| 格式 / scale | payload bytes | local scale bytes | global scale bytes | 逻辑压缩率 | 落盘压缩率 | +| --- | ---: | ---: | ---: | ---: | ---: | +| MXFP8 block | 1,049,600 | 33,792 | 0 | 3.8752× | 3.8748× | +| MXFP8 tensor | 1,049,600 | 1 | 0 | 4.0000× | 3.9995× | +| NVFP4 block | 524,800 | 66,560 | 4 | 7.0995× | 7.0980× | + +MXFP8 block 比理想 4× 略小,因为每 32 个元素多保存一个 E8M0 byte;NVFP4 比理想 8× +略小,因为每 16 个元素保存一个 E4M3 local scale,另有 4-byte global scale。落盘压缩率 +再低一点,是 QDWGT 128-byte header 和 section 对齐的真实成本。若输入本来是 FP16, +分母只有 2 bytes/element,表中压缩率会大约减半;这不是格式变差,而是原始输入已经 +占用更少字节。 + +### 最终性能指标与分析 + +最终性能结论以 `outputs/benchmark_suite/summary.md` 的同进程 benchmark 为准。该实验在 +RTX 4060(SM89)上对一个 `4096 × 4097` 的 FP32 normal 输入执行,每个真实 TOML 都采用 +`warmups = 10`、`repeats = 30`。CUDA Event 位于 H2D 后、D2H 前,故只统计格式专用 +kernel;min/mean/median/p95/max 都由 30 个正式样本计算。 + +| 配置 | quant mean ms | quant median ms | quant p95 ms | quant GB/s | dequant mean ms | dequant median ms | dequant p95 ms | dequant GB/s | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| MXFP8 block + RNE | 1.161 | 1.071 | 1.456 | 72.717 | 0.695 | 0.577 | 1.229 | 121.425 | +| MXFP8 block + stochastic | 1.551 | 1.471 | 1.977 | 54.448 | 0.665 | 0.598 | 1.002 | 127.065 | +| MXFP8 tensor + RNE | 0.909 | 0.886 | 0.984 | 92.334 | 0.501 | 0.489 | 0.547 | 167.540 | +| MXFP8 tensor + stochastic | 1.272 | 1.222 | 1.613 | 65.947 | 0.535 | 0.535 | 0.629 | 156.770 | +| NVFP4 block + RNE | 2.311 | 2.272 | 2.595 | 33.131 | 0.610 | 0.606 | 0.634 | 125.574 | +| NVFP4 block + stochastic | 2.450 | 2.424 | 2.700 | 31.247 | 0.608 | 0.606 | 0.620 | 125.942 | + +这组数据给出以下结论: + +- **MXFP8 tensor + RNE 是当前实现的最快路径。** quant median 为 `0.886 ms`, + dequant median 为 `0.489 ms`。它只保存一个 E8M0 scale;尽管量化需要 + partial/finalize/encode 三段 kernel,仍避免了每个 32 元素 block 的独立 scale 规约与 + 写入,因而在本实现和该 shape 下优于 MXFP8 block。 +- **stochastic rounding 的成本主要体现在量化。** 相比相同 scale mode 的 RNE,MXFP8 + block 的 quant mean 从 `1.161 ms` 升至 `1.551 ms`,MXFP8 tensor 从 `0.909 ms` 升至 + `1.272 ms`。额外开销来自每元素 SplitMix64 无状态随机数、相邻 code 边界与概率分支。 + NVFP4 的增幅较小(`2.311 ms` 到 `2.450 ms`),因为 global reduction、local-scale 和 + packed encode 已是主要成本,随机舍入在总时间中的占比更低。 +- **NVFP4 quant 最重,但其 dequant 稳定。** NVFP4 量化需要 global 两阶段规约、 + 16-lane local-scale kernel 和 32-lane packed-encode kernel,因此 quant median 为 + `2.272--2.424 ms`,约为 MXFP8 tensor + RNE 的 2.6--2.7 倍。反量化则由一个线程读一个 + packed byte、同时解两个 E2M1 nibble;median 稳定在约 `0.606 ms`,p95 仅约 + `0.620--0.634 ms`。 +- **MXFP8 block 的反量化存在尾部离群样本。** RNE 的 median 是 `0.577 ms`,但 p95 为 + `1.229 ms`、max 为 `1.275 ms`;stochastic 也出现较弱的尾部波动。该现象更像短 kernel + 的 GPU 时钟/桌面调度干扰,而不是数值错误。最终提交应同时报告 median、p95 与 mean, + 不应只选择最小值。 +- 表中的 GB/s 是按项目“逻辑读写字节 / CUDA Event 时间”定义的**有效带宽**,不是显存 + 理论峰值。量化通常低于反量化,是因为 $amax$ 规约、scale 编码、浮点转换与随机数生成 + 增加了计算和同步成本。 + +当前 benchmark 已复用进程、输入和配置,但每轮仍经由公共 pipeline 创建 stream/device +buffer。它适合作为本项目当前 kernel 实现的可复现实验口径;若追求极限吞吐,下一步应为 +pipeline 增加可复用的 persistent device-buffer 会话。 + +## 软件模拟、GPU 架构与第三方依赖边界 + +| 组成 | 实现性质 | 说明 | +| --- | --- | --- | +| E4M3、E8M0、E2M1 编解码、RNE/SR、scale 与 nibble pack/unpack | **软件模拟** | 使用普通 FP32 运算和显式 bit 操作实现;没有调用原生 FP8/FP4 tensor-core 指令。 | +| CPU reference | **软件模拟** | 普通 C++ 循环调用与 GPU 共用的 codec,是数值真值基线。 | +| QDTENSOR/QDWGT I/O、header 校验、JSON metrics | **软件实现** | 使用标准 C++,文件字段显式 little-endian 序列化。 | +| GPU quant/dequant | **CUDA 通用 kernel** | 依赖 CUDA runtime、warp shuffle、shared memory、CUDA Event;数值格式本身仍由软件 codec 模拟。 | +| CTA/warp 映射、persistent grid、occupancy 限制 | **GPU 架构感知优化** | 利用 NVIDIA 32-lane warp 的执行模型;当前 preset 编译目标为 RTX 4060 的 SM89。 | +| `thrust::device_vector` | **CUDA Toolkit 的 Thrust 库** | 负责 device buffer 的 RAII 所有权与 host/device 数据转移;kernel 接收 raw device pointer。 | +| `cooperative_groups::thread_block_tile<16/32>` | **CUDA Toolkit 库接口** | 用于 NVFP4 的 16-lane local-scale tile 和 32-lane packed-store warp 协作。 | + +项目不依赖 TensorRT、Transformer Engine、cuDNN、cuBLAS 或 Blackwell 专用硬件。资料中的 +OCP MX 与 NVIDIA NVFP4 规范用于确定软件数值语义;RTX 4060 没有在本项目中被假定拥有 +原生 NVFP4/MXFP8 指令。由于线程映射依赖 32-lane NVIDIA warp,代码不是可直接搬到非 +CUDA GPU 平台的通用实现;但格式 codec 的数学规则不依赖 SM89。 + +## 可继续提升的方向 + +- 扩展独立 benchmark:复用已分配的 device buffer,在同一进程内多轮 warmup/repeat, + 输出 p50/p95、吞吐与矩阵尺寸曲线。 +- 评估 persistent grid 的 CTA/SM 系数、CTA 大小和 vector load 宽度,而不是把当前 + `SM × 4` 启发式当作所有 shape/GPU 的最优值。 +- 为 NVFP4 研究更高效的 global reduction 与 local-scale/encode 调度,例如评估安全的 + fusion 边界、异步拷贝和不同的 byte-owner 映射;必须保持奇数列跨行 byte 的无竞争语义。 +- 扩充数值实验:多个 stochastic seed 的均值/方差、更多异常值比例、不同 block size 的 + 敏感性,以及相对误差/分位数误差图,而不是只看 MAE/MSE。 +- 增加可选 E5M2、OCP MXFP4 或更多输入格式,并以新 QDWGT version 扩展 header;不能在 + version 1 中改变已有枚举、payload nibble 顺序或 scale 解码方向。 +- 若目标硬件将来提供原生 FP8/FP4 指令,可新增独立硬件加速 backend,并继续保留本项目 + 的软件 reference 作为位级/数值对照,不混淆两条实现路径。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/README.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/README.md" new file mode 100644 index 00000000..f5b72873 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/README.md" @@ -0,0 +1,334 @@ +# MXFP8 / NVFP4 量化与反量化 + +本项目在普通 CUDA GPU 上软件模拟 MXFP8 和 NVFP4 的量化、真实位宽打包、文件保存与 CUDA 反量化。输入为 FP32 或 FP16 的行主序矩阵,输出低精度权重文件、指定精度的反量化矩阵,以及误差和性能日志。 + +普通 FP8 不属于基础验收范围;可将其作为调试基线、误差对照或扩展功能。 + +## 功能范围 + +- 读取 FP32 / FP16 输入矩阵文件,并保留行主序布局;第一版不接受 BF16 输入。 +- 按配置进行 per-tensor 或 block-wise scaling,支持 `rounding = nearest` 和 `rounding = stochastic`。 +- 编码并打包 MXFP8 和 NVFP4 数据:MXFP8 每个元素占 1 字节;NVFP4 每个字节必须容纳两个 4-bit 元素。 +- 保存量化 payload、scale 数组、格式和形状等必要元数据。 +- 用 CUDA kernel 将量化数据解包、反量化为 FP16、BF16 或 FP32。 +- 类型约束:输入 QDTENSOR 只允许 FP16 / FP32;反量化输出 QDTENSOR 允许 FP16 / BF16 / FP32。QDWGT 的 `source_dtype` 因此也只可能是 FP16 / FP32。 +- 对随机、正态分布和含异常值矩阵统计最大绝对误差、MAE、MSE、压缩率和 kernel 性能。 + +## 工程结构 + +```text +黄新颖/ +├── CMakeLists.txt +├── CMakePresets.json +├── .gitignore +├── README.md +├── Conclusion.md # 项目总结:格式、kernel、实验结果与 profiling 记录 +├── include/ +│ └── quant_dequant/ +│ ├── types.hpp # 已实现:枚举、TensorDesc、三类方向配置等基础类型 +│ ├── config.hpp # 已实现:量化、反量化、app 三种配置解析接口 +│ ├── tensor_io.hpp # 已实现:输入与反量化输出 QDTENSOR 的读取、写入接口 +│ ├── quantized_tensor.hpp # 已实现:统一 host 量化结果、格式特有不变量校验 +│ ├── quantized_io.hpp # 已实现:QDWGT v1 量化权重文件读写接口 +│ ├── quantize.hpp # 已实现:CPU reference 与 CUDA pipeline 的量化、反量化公共接口 +│ └── metrics.hpp # 已实现:误差、压缩率、带宽与 JSON 运行报告接口 +├── src/ +│ ├── CMakeLists.txt # 只列出已存在的 .cpp/.cu,构建 quant_dequant_core +│ ├── quant_dequant.cpp # 已实现:当前版本信息等轻量库入口 +│ ├── config/ +│ │ └── config_parser.cpp # 已实现:key-value 配置解析与分方向校验 +│ ├── io/ +│ │ ├── tensor_io.cpp # 已实现:FP32 / FP16 输入和反量化输出 +│ │ └── quantized_io.cpp # 已实现:QDWGT header、payload、scale 读写 +│ ├── formats/ +│ │ ├── fp32_utils.cuh # 已实现:IEEE FP32 位操作、指数、RNE 公共工具 +│ │ ├── mxfp8_codec.cuh # 已实现:E4M3/E8M0 的 host/device 编码规则 +│ │ └── nvfp4_codec.cuh # 已实现:E2M1、双层 scale、nibble 打包的 host/device 规则 +│ ├── reference/ +│ │ ├── reference_detail.hpp # 已实现骨架:格式专用 CPU reference 内部接口 +│ │ ├── reference_dispatch.cpp # 已实现骨架:按 format 转发 CPU reference 调用 +│ │ ├── mxfp8_reference.cpp # 已实现:MXFP8 CPU 量化、反量化数值循环 +│ │ └── nvfp4_reference.cpp # 已实现:NVFP4 CPU 量化、反量化、双层 scale 与 nibble 打包/解包 +│ ├── cuda/ +│ │ ├── mxfp8_quantize.cuh # 已实现:MXFP8 block/tensor-scale CUDA launcher 私有声明 +│ │ ├── mxfp8_quantize.cu # 已实现:block-scale warp kernel 与 tensor-scale 两阶段 amax/编码 kernel +│ │ ├── mxfp8_dequantize.cuh # 已实现:MXFP8 反量化 launcher 私有声明 +│ │ ├── mxfp8_dequantize.cu # 已实现:MXFP8 E4M3/E8M0 解码、tensor/block scale 索引 kernel +│ │ ├── nvfp4_quantize.cuh # 已实现:NVFP4 量化 launcher 私有声明 +│ │ ├── nvfp4_quantize.cu # 已实现:全局规约、16-lane local scale、32-lane packed store kernel +│ │ ├── nvfp4_dequantize.cuh # 已实现:NVFP4 反量化 launcher 私有声明 +│ │ └── nvfp4_dequantize.cu # 已实现:NVFP4 packed load、解包与反量化 kernel +│ ├── pipeline/ +│ │ ├── device_quantized_tensor.cuh # 已实现:pipeline 私有 Thrust quantize 输入、量化结果、tensor 规约工作区与反量化 FP32 输出对象 +│ │ ├── pipeline_detail.hpp # 已实现:MXFP8/NVFP4 CUDA 格式专用内部入口声明 +│ │ ├── quantize.cpp # 已实现:MXFP8/NVFP4 的预检、H2D、kernel 分派、D2H 与 QuantizedTensor 构造 +│ │ └── dequantize.cpp # 已实现:MXFP8/NVFP4 H2D、反量化 launcher 与 D2H +│ ├── common/ +│ │ ├── cuda_stream.cuh # 已实现:non-blocking CUDA stream 的 RAII 所有权 +│ │ ├── cuda_stream.cu # 已实现:stream 创建、同步与析构 +│ │ ├── cuda_timer.cuh # 已实现:绑定指定 stream 的 CUDA Event RAII kernel 计时器 +│ │ └── cuda_timer.cu # 已实现:Event 创建、状态机、同步与 elapsed time +│ └── metrics/ +│ └── metrics.cpp # 已实现:误差、压缩率、带宽与 JSON 报告实现 +├── apps/ +│ ├── CMakeLists.txt +│ └── main.cpp # 已实现:完整 QDTENSOR→QDWGT→QDTENSOR→JSON 命令行编排 +├── configs/ +│ ├── mxfp8_{block,tensor}_{nearest,stochastic}_{fp16,bf16,fp32}.toml # 已实现:12 个 MXFP8 实验配置 +│ └── nvfp4_block_{nearest,stochastic}_{fp16,bf16,fp32}.toml # 已实现:6 个 NVFP4 实验配置 +├── tests/ +│ ├── CMakeLists.txt # 已实现:为每个测试模块构建独立 CTest 可执行文件并配置标签 +│ ├── test_entry_main.cpp # 已实现:由 CMake 绑定单个 run_*_tests() 的可复用入口 +│ ├── test_config.cpp # 已实现:配置解析、默认值和非法配置测试 +│ ├── test_cuda_dispatch.cpp # 已实现:CUDA pipeline 参数校验和 MXFP8/NVFP4 格式分派测试 +│ ├── test_device_quantized_tensor.cu # 已实现:device buffer 长度、格式不变量和 move-only 语义测试 +│ ├── test_tensor_io.cpp # 已实现:QDTENSOR 的 FP16/FP32 I/O 与损坏文件测试 +│ ├── test_mxfp8_codec.cu # 已实现:-0 规范化与 host/device codec 一致性测试 +│ ├── test_quantized_tensor.cpp # 已实现:MXFP8/NVFP4 payload、scale 元数据不变量测试 +│ ├── test_reference_dispatch.cpp # 已实现:CPU reference 输入校验与格式转发骨架测试 +│ ├── test_mxfp8_reference.cpp # 已实现:CPU block/tensor quantize-dequantize 与文件 round-trip 测试 +│ ├── test_mxfp8_cuda.cu # 已实现:GPU tensor/block 量化→反量化、CUDA/CPU payload/scale/数值与 NaN 哨兵对照 +│ ├── test_mxfp8_dequantize_cuda.cu # 已实现:CUDA/CPU tensor/block 反量化和三种输出类型对照 +│ ├── test_quantized_io.cpp # 已实现:QDWGT round-trip、header 与损坏文件测试 +│ ├── test_nvfp4_codec.cu # 已实现:E2M1、E4M3 scale、nibble 顺序与 host/device 一致性测试 +│ ├── test_nvfp4_reference.cpp # 已实现:NVFP4 CPU 双层 scale、跨行 packed byte 与非有限输入测试 +│ ├── test_nvfp4_cuda.cu # 已实现:NVFP4 CUDA/CPU payload、scale 与 global scale 对照 +│ ├── test_nvfp4_dequantize_cuda.cu # 已实现:NVFP4 CUDA/CPU packed 反量化和三种输出类型对照 +│ └── test_end_to_end.cu # 后续:文件 -> 量化 -> 文件 -> 反量化的端到端测试 +├── benchmarks/ +│ ├── CMakeLists.txt # 构建 quant_dequant_bench 可执行文件 +│ └── benchmark_main.cpp # 独立 warmup 和 CUDA Event 计时 +├── docs/ +│ ├── format_spec.md # 位表示、缩放和舍入的唯一规范 +│ ├── format_codecs.md # `src/formats` 位编码实现原理与源码导读 +│ ├── kernels.md # `src/cuda` 量化/反量化 kernel 的调度与规约设计 +│ ├── file_format.md # 二进制 header 与 payload 布局 +│ ├── configuration.md # 配置对象、字段、解析与读取接口 +│ ├── app.md # 完整命令行、运行流程、输出与指标边界 +│ ├── experiments.md # 矩阵生成、端到端 suite 与实验结果汇总 +│ ├── tests.md # CTest 条目、标签、运行与新增测试约定 +└── scripts/ + ├── generate_tensor.py # 已实现:uniform/normal/outlier 的 FP16/FP32 QDTENSOR 生成器 + ├── run_e2e_suite.py # 已实现:真实 app 批量运行、JSON/产物校验与 Markdown 汇总 + └── run_benchmark_suite.py # 已实现:真实 TOML 的同进程 CUDA warmup/repeat 性能遍历 +``` + +其中 `src/` 只构建 `quant_dequant_core` 静态库;`apps/`、`tests/` 与 `benchmarks/` 各自构建可执行文件并链接该库。树中的“后续”表示已经确定的目标文件名,但当前**不能**写入 `src/CMakeLists.txt`,否则 CMake 会因找不到源文件而失败。 + +各层只承担一种职责: + +- `formats/` 是无状态的数值和 bit primitive。它只回答“一个 float 如何成为 E4M3”或“一个 nibble 如何解码”,不知道矩阵形状、文件和 CUDA grid。 +- `formats/fp32_utils.cuh` 是格式层的内部公共基座,不是对外 API。它集中处理 FP32 的符号、NaN、subnormal、指数和 RNE,避免 MXFP8、NVFP4 各自复制不同的边界语义;`mxfp8_codec.cuh` 提供 E4M3/E8M0 规则,`nvfp4_codec.cuh` 在其 E4M3 local scale 基础上提供 E2M1、NVFP4 双层 scale 与 nibble 打包规则。 +- `reference/` 用同一套 codec 在 host 上实现完整矩阵流程,是 CUDA 结果的真值来源;MXFP8 与 NVFP4 的量化、反量化都已完成。NVFP4 量化先求全局 amax 与 FP32 `global_scale`,再写每个 rowwise 16 元素 block 的 E4M3 local scale,最后按线性相邻元素打包 E2M1 nibble;反量化逐个 physical payload byte 解包,并让 low/high nibble 分别推导自己的 rowwise local-scale 下标。reference 是普通 `.cpp`,它调用仅含 `__host__ __device__` 数值原语的格式头文件,但不发射 CUDA kernel。 +- `cuda/` 只负责线程映射、block reduction、合并访存和 packed load/store。格式公式必须调用 `formats/`,不能在 kernel 内另写一份。`mxfp8_quantize.cuh` 声明 block-scale 与 tensor-scale launcher;block-scale kernel 使用一 warp 对应一组 32 元素、一个 CTA 对应 8 个 warp 的映射。其网格大小取“逻辑所需 CTA 数”和 `SM 数 × min(4, occupancy 上限)` 的较小值,warp 再用 grid-stride 继续处理后续量化 block;因此是固定网格的 persistent kernel(持久化 kernel)调度。tensor-scale kernel 参考 V7 reduction:第一阶段以 `SM 数 × 4` CTA、`float4` 合并加载和 grid-stride 生成 partial amax/NaN-Inf 标记;第二阶段仅一个 CTA 用同一 warp/shared-memory 规约结构合并 partial,并由 thread 0 直接写唯一的 E8M0 `scale[0]`;第三阶段启动独立编码 kernel。`mxfp8_dequantize.cu` 则以一维 grid-stride 让每个线程解码一个或多个 E4M3 payload;tensor 模式读取唯一的 scale 0,block 模式按 `row * blocks_per_row + column / 32` 读取 E8M0 scale。真正的 `__global__` kernel 只定义在 `.cu` 文件中。 +- `pipeline/` 是唯一了解 Thrust device buffer 和 CUDA launcher 的编排层。公共 API `quantize_cuda()` 和 `dequantize_cuda()` 仍接收 `HostTensor` 或 `QuantizedTensor`,先完成 host 侧校验,再按 MXFP8/NVFP4 转发到 `pipeline_detail.hpp` 中的格式专用入口。MXFP8 block-scale 量化已构造 `DeviceQuantizationInput`、执行 H2D、分配 `DeviceQuantizedTensor`、调用 persistent kernel,并以 Thrust device iterator 回传 scale 和 payload;tensor-scale 额外分配只在本次调用内存活的 `DeviceTensorQuantizationWorkspace`,使第一、二阶段规约和编码 kernel 完成前 partial 数组始终有效。NVFP4 量化复用这一工作区先做全局两阶段 amax reduction 并写一个 FP32 `global_scale`,随后以 `thread_block_tile<16>` 写 E4M3 local scale,再以 `thread_block_tile<32>` 让偶数 lane 独占写 packed E2M1 byte;D2H 检查 global/local scale 后构造统一 host `QuantizedTensor`。两种反量化都复用 `DeviceQuantizedTensor` 作为 H2D 的低精度输入,以及 `DeviceDequantizationOutput` 保存 device FP32 结果;NVFP4 kernel 以一个线程读取一个 packed byte,分别解包并解码两个 nibble,从而正确覆盖奇数列产生的跨行 byte。`quantize.cpp`、`dequantize.cpp` 不定义 kernel,因而保留 `.cpp` 后缀;但它们实例化 `thrust::device_vector` 的 CUDA 后端模板,CMake 必须将这两个源文件交给 NVCC 编译。这里使用 `thrust::device_vector`,但不再额外设计一个泛用 GPU Tensor 类。 +- `io/` 只处理磁盘字节布局。`tensor_io.cpp` 面向 QDTENSOR;`quantized_io.cpp` 面向 QDWGT,且只接收已完成量化的 `QuantizedTensor`。 +- `quantized_tensor.hpp` 不是通用张量封装,只是量化结果的 host 侧所有权对象:描述信息、真实位宽 payload、local scale,以及 NVFP4 的 `global_scale`。 +- `metrics.hpp` / `metrics.cpp` 是不依赖 CUDA 的指标层:以 FP32 数组计算误差、从描述和量化结果推导逻辑字节数/压缩率、按约定公式换算有效带宽,并序列化 `RunReport` JSON。它不读取文件或发射 kernel;app 在完成 I/O 后把产物大小和最终数值交给它。 +- `common/cuda_stream.cuh` / `.cu` 拥有每次 profile pipeline 调用独占的 non-blocking CUDA stream;它负责将 H2D、event、格式专用 kernel 与 D2H 串在同一条有序队列中。 +- `common/cuda_timer.cuh` / `.cu` 是绑定该 stream 的 CUDA Event RAII(资源获取即初始化)计时器。`quantize_cuda_profiled()` 和 `dequantize_cuda_profiled()` 都在 H2D 后记录 start event、在最后一个 kernel 后记录 stop event、随后才执行 D2H,因此返回的 `kernel_ms` 只包含该次调用的 device kernel 时间,不包含内存传输、分配、文件 I/O 或 CUDA context 初始化。 + +```mermaid +flowchart TD + APP["apps/main.cpp"] --> API["include 公共接口"] + API --> PIPE["pipeline:Thrust、数据传输、格式分派"] + PIPE --> CUDA["cuda:格式专用 kernel"] + CUDA --> FORMAT["formats:共享 codec"] + REF["reference:CPU 真值"] --> FORMAT + PIPE --> QIO["io/quantized_io.cpp"] + TIO["io/tensor_io.cpp"] --> PIPE + TEST["tests"] --> REF + TEST --> CUDA + TEST --> TIO + TEST --> QIO +``` + +这个依赖方向的关键是:`formats/` 永远不依赖 CUDA kernel、Thrust 或文件 I/O;而测试可分别定位 codec、量化流程和文件布局的问题。 + +## 实现顺序 + +当前已经完成基础类型、配置解析、普通张量 I/O,以及 `test_config.cpp`、`test_tensor_io.cpp`。接下来的文件按以下顺序创建最稳妥: + +1. `src/formats/fp32_utils.cuh`、`src/formats/mxfp8_codec.cuh` 与 `tests/test_mxfp8_codec.cu` 已完成;后续扩展测试向量,覆盖 E4M3、E8M0、RNE、饱和和 stochastic rounding 的全部位级边界。 +2. `include/quant_dequant/quantized_tensor.hpp` 已完成;接着实现 `src/reference/mxfp8_reference.cu`,用该统一 host 结果类型得到不依赖 GPU 的完整 MXFP8 block/tensor 参考结果。 +3. `include/quant_dequant/quantized_io.hpp`、`src/io/quantized_io.cpp` 与 `tests/test_quantized_io.cpp` 已完成:QDWGT v1 使用逐字段 little-endian 序列化,覆盖实际 round-trip、section 对齐和损坏文件拒绝。 +4. `include/quant_dequant/quantize.hpp`、`src/reference/reference_dispatch.cpp`、`src/reference/reference_detail.hpp` 与 `tests/test_reference_dispatch.cpp` 已完成 CPU reference 公共接口与格式分派。 +5. `src/reference/mxfp8_reference.cpp` 与 `tests/test_mxfp8_reference.cpp` 已完成:以 `QuantizedTensor` 产出完整 MXFP8 CPU block/tensor 参考结果,并验证 payload、E8M0 scale、反量化值和文件 round-trip。 +6. `src/pipeline/pipeline_detail.hpp`、`src/pipeline/quantize.cpp`、`src/pipeline/dequantize.cpp` 与 `tests/test_cuda_dispatch.cpp` 已完成 CUDA pipeline 的格式分派与数据传输边界。MXFP8 tensor/block-scale 量化均已完成预检、H2D、`DeviceQuantizationInput` / `DeviceQuantizedTensor` 构造、kernel 分派、D2H 和 `QuantizedTensor` 交付;tensor 模式还拥有调用期临时 `DeviceTensorQuantizationWorkspace`。反量化已完成 QDWGT 的 H2D、`DeviceDequantizationOutput` 分配、kernel、D2H 和 `HostTensor` 交付。 +7. `src/cuda/mxfp8_quantize.cuh`、`src/cuda/mxfp8_quantize.cu` 与 `tests/test_mxfp8_cuda.cu` 已完成 MXFP8 的两种 CUDA 量化路径。block-scale 使用 warp-per-32-element persistent 映射;tensor-scale 使用 V7 风格 `float4` grid-stride partial reduction、单 CTA final reduction/scale 写入和独立编码 kernel。测试在有 GPU 时以 nearest 和 stochastic rounding 分别逐字节对比 CPU reference 的 payload/E8M0 scale,再把 GPU 量化 D2H 返回的同一个 `QuantizedTensor` 直接传入 GPU 反量化,并对比 CPU reference 的最终 FP32 数值;同时覆盖 tail block、两种模式的 grid-stride 循环和 NaN 哨兵。`src/cuda/mxfp8_dequantize.cu` 与 `tests/test_mxfp8_dequantize_cuda.cu` 已完成 CUDA 反量化:对 tensor/block scale 模式和 FP16/BF16/FP32 输出描述分别与 CPU reference 比较。 +8. NVFP4 的 CPU reference、CUDA quantize/dequantize kernel、pipeline 与 CPU/GPU 对照测试已经完成。反量化以一个线程读取一个 packed byte,low/high nibble 分别按自己的 row/column 推导 local-scale,覆盖跨行 byte、尾 nibble 和 FP16/BF16/FP32 输出描述。 +9. CUDA profile pipeline 已完成:`quantize_cuda_profiled()` 与 `dequantize_cuda_profiled()` 返回功能结果和纯 kernel Event 时间;`CudaStream`、`CudaEventTimer` 以及 profile 集成测试覆盖其资源生命周期、计时状态机和 MXFP8 tensor-scale 的 CPU/GPU 端到端一致性。 +10. `apps/main.cpp` 已完成完整流程:读取完整配置和输入 QDTENSOR、CUDA profile 量化、写并读回真实 bit-width QDWGT、CUDA profile 反量化、写目标 FP16/BF16/FP32 QDTENSOR、读回实际输出计算误差,并将产物大小、压缩率、kernel 时间和带宽写为 JSON。命令行和报告边界见 [`docs/app.md`](docs/app.md)。 +11. `configs/`、`scripts/generate_tensor.py` 与 `scripts/run_e2e_suite.py` 已完成实验层自动化:18 份真实 TOML 覆盖 MXFP8 的 block/tensor、nearest/stochastic、三种输出类型,以及 NVFP4 的 block、nearest/stochastic、三种输出类型;脚本再遍历 uniform、normal、outlier 和 FP16/FP32 输入,实际调用 release app、验证每个 QDWGT/QDTENSOR/JSON 的字段和文件大小,并输出均值/中位数汇总。详见 [`docs/experiments.md`](docs/experiments.md)。 + +## 配置文件 + +配置对象的职责边界、全部字段、文本语法、三种 loader 与错误处理见 +[`docs/configuration.md`](docs/configuration.md)。本节只保留项目级概览。 + +配置文件采用简单的 `key = value` 文本格式,例如 `configs/nvfp4_block_nearest_fp16.toml`: + +```toml +format = "nvfp4" +block_size = 16 +scale_mode = "block" +output_type = "fp16" +rounding = "nearest" +target_gpu = "RTX 4060" +``` + +| 字段 | 可选值 | 含义 | +| --- | --- | --- | +| `format` | `mxfp8` / `nvfp4` | 目标低精度格式。 | +| `block_size` | 正整数 | `mxfp8` 默认 32,`nvfp4` 默认 16;block 模式下每组连续元素共享一组缩放信息。 | +| `scale_mode` | MXFP8:`tensor` / `block`;NVFP4:仅 `block` | 全张量共享 scale,或每个 block 单独保存 scale;NVFP4 必须保留每 16 元素的 local scale。 | +| `output_type` | `fp16` / `bf16` / `fp32` | CUDA 反量化输出数据类型。 | +| `rounding` | `nearest` / `stochastic` | 编码时的舍入策略;随机舍入应支持确定性随机种子,方便复现测试。 | +| `stochastic_seed` | 可选非负整数,默认 `0` | 仅在 `rounding = "stochastic"` 时使用;`nearest` 模式下只能省略或写 `0`。 | +| `target_gpu` | GPU 型号字符串 | 仅记录在报告和日志中,不改变数值语义。 | + +配置文本保持作业要求的平面 `key = value` 形式,但程序内部不会把全部字段强行 +传给每个数值接口: + +- `QuantizationConfig` 只含 `format`、`block_size`、`scale_mode`、`rounding` 和可选 `stochastic_seed`。量化 CPU reference、CUDA quantize 和单独量化命令只接收它。 +- `DequantizationConfig` 只含 `output_type`。同一份 QDWGT 可以分别反量化为 FP16、BF16 或 FP32,因此反量化接口只接收它。 +- `AppConfig` 只供 `apps/main.cpp` 编排完整流程使用,组合前两者及仅用于日志的 `ReportConfig::target_gpu`;它不能作为量化或反量化 kernel/pipeline 的参数。 + +对应 loader 分别为 `load_quantization_config()`、`load_dequantization_config()` 和 +`load_app_config()`。前两个允许各自的最小单方向配置:单独量化文件不要求 +`output_type` 或 `target_gpu`;单独反量化文件只需 `output_type`。完整 app 配置 +则要求表中的所有必填字段。包含额外已知字段的完整配置也可被单方向 loader +投影使用。 + +`scale_mode = "block"` 时,block 按行划分:每一行独立按连续的 `1 × block_size` 元素分组,MXFP8 使用 `block_size = 32`,NVFP4 使用 `block_size = 16`。一行末尾不足整块时逻辑补零,payload 只保存原始元素;scale 数量为 `num_rows * ceil(num_cols / block_size)`。精确的编码、scale、舍入和打包规则以 [`docs/format_spec.md`](docs/format_spec.md) 为准;若要按源码理解 E4M3、E8M0、E2M1 和相关 helper,可阅读 [`docs/format_codecs.md`](docs/format_codecs.md)。 + +## 输入和输出文件 + +### 输入张量 + +输入张量文件由 header 和连续 data 组成: + +```text +[header] +num_rows: int64 +num_cols: int64 +dtype: string # fp32 或 fp16 + +[data] +values: dtype[num_rows * num_cols] # 行主序 +``` + +输入文件的 `tensor_role` 必须为 `input`,且 `dtype` 只能是 FP16 或 FP32。读取后程序会把两者统一扩展为 host FP32,供 scale 计算、量化编码和误差统计使用;原始物理类型仍保留在 QDWGT 的 `source_dtype` 中。BF16 只支持作为反量化输出文件的类型。 + +### 量化参数 + +完整 app 的量化参数来自上节的配置文件。程序启动时必须校验:格式与 block size 的组合是否合法、输入 dtype 是否受支持,以及输出 dtype 是否受支持;单方向工具则只校验自己所需的那一组配置。 + +### 低精度权重 + +低精度权重文件是二进制文件,应至少包含: + +- magic 和版本号,用于识别文件格式。 +- `num_rows`、`num_cols`、原始元素数量与原始输入 dtype。 +- `format`、`block_size`、`scale_mode`、`rounding`。 +- payload 字节数、scale 数量、scale 的存储类型,以及 NVFP4 所需的全局 scale 元数据。 +- 紧随 header 的 packed data 和 scale 数组。 + +NVFP4 的 payload 大小必须为 `ceil(num_elements / 2)` 字节。偶数线性下标写入低 4 bit、奇数线性下标写入高 4 bit;不足两个元素时,未使用的半字节清零。反量化 kernel 必须用 packed load 读取字节后解出两个元素,不能将每个 4-bit 元素以一个 `uint8_t` 保存。 + +MXFP8 的 payload 为每元素 8 bit;scale 的数量为 1(tensor 模式)或 `num_rows * ceil(num_cols / block_size)`(block 模式)。NVFP4 仅支持 block 模式,必须按格式规范保存每个 16 元素 block 的局部 scale 与一个全局 scale;配置和 QDWGT header 中的 `NVFP4 + tensor` 会被拒绝。 + +### 反量化张量与日志 + +- 反量化张量按行主序保存,dtype 由 `output_type` 决定,可为 FP16、BF16 或 FP32;其 `tensor_role` 必须为 `dequantized_output`。建议复用输入张量的 header 结构,使输出文件可独立读取。 +- 日志建议使用 JSON 或 CSV,每次运行一条记录,包含矩阵形状、配置、目标 GPU、误差和性能数据。 +- 必须输出:最大绝对误差、MAE、MSE、压缩率、量化 kernel 时间、反量化 kernel 时间和有效内存带宽(GB/s)。 + +## 执行流程 + +```mermaid +flowchart LR + A["FP32 / FP16 矩阵文件"] --> B["读取并校验配置"] + B --> C["CUDA 计算 scale 并量化"] + C --> D["打包 payload 与保存 scale"] + D --> E["低精度权重文件"] + D --> F["CUDA packed load、解包与反量化"] + F --> G["FP16 / BF16 / FP32 输出文件"] + F --> H["误差统计与性能日志"] +``` + +误差统计以原始输入 FP32 值为参考;FP16 输入先转换为 FP32 参考值再计算误差。性能统计应在 warmup 后用 CUDA Event 分别测量量化与反量化 kernel,避免把文件 I/O、内存分配和首次 CUDA 初始化混入 kernel 时间。 + +## 格式实现约束 + +- **MXFP8**:实现指定 MXFP8 元素编码、block scaling、打包存储和 CUDA 反量化;默认 block size 为 32。 +- **NVFP4**:实现 NVFP4 数值编码、元素局部缩放、全局缩放、4-bit 打包/解包和 CUDA 反量化;默认 block size 为 16。 +- **scale_mode**:MXFP8 支持 `tensor` 与 `block`;NVFP4 严格只支持 `block`,以保留其全局 scale 加 16 元素局部 scale 的分层规则。格式特有的 scale 规则统一记录在 `docs/format_spec.md`。 +- **边界数据**:全零、极小值、极大值、NaN、Inf、非整 block 长度和奇数个 NVFP4 元素必须有明确且可测试的处理规则。 + +MXFP8 和 NVFP4 的精确 codebook、scale 数据类型、饱和规则以及 NaN/Inf 语义必须在开发前写入 `docs/format_spec.md`。这些是数值正确性的依据,不能仅依赖 kernel 代码中的隐含实现。 + +输入张量、量化权重、反量化张量与 JSON 日志的固定 header、section offset、字节序和校验规则见 [`docs/file_format.md`](docs/file_format.md)。实现 I/O 时必须逐字段序列化,不能直接写入 C++ struct。 + +## 验证与性能评测 + +测试应分为格式单元测试、文件 I/O 测试和端到端 CUDA 测试: + +- 用已知 bit pattern 验证编码、解码以及 NVFP4 高低半字节顺序。 +- 与 CPU 参考实现逐元素比对 CUDA 的量化结果、scale 和反量化结果。 +- 分别对随机矩阵、正态分布矩阵和含异常值矩阵输出误差统计。 +- 覆盖 MXFP8 的 tensor/block 模式、NVFP4 的 block 模式及其 tensor 非法组合、FP16/FP32 输入、三种输出类型、尾 block 与奇数长度 payload。 +- benchmark 与正确性测试分离;报告中标明 `target_gpu`、CUDA 版本、矩阵形状、warmup 次数和重复次数。 + +## 构建与运行 + +在本目录下使用 `CMakePresets.json` 独立构建,不依赖仓库根目录的 `CMakeLists.txt`。`rtx4060-release` 与 `rtx4060-debug` 均会将 `QUANT_DEQUANT_CUDA_ARCHITECTURES` 固定为 `89`,即 NVIDIA RTX 4060 的 compute capability。 + +项目的 `.cpp` 使用 C++20。受 CMake 3.22 对 CUDA20 编译选项支持不足的限制,`.cu` 暂使用 CUDA C++17;因此 kernel 与 `__host__ __device__` codec 不应依赖 C++20 独有语法。 + +```shell +cd "02_quant_dequant/黄新颖" +cmake --preset rtx4060-release +cmake --build --preset rtx4060-release -j +ctest --preset rtx4060-release --output-on-failure + +./build/rtx4060-release/apps/quant_dequant \ + --input data/input_fp32.bin \ + --config configs/nvfp4_block_nearest_fp16.toml \ + --quantized-output outputs/weights_nvfp4.bin \ + --dequantized-output outputs/dequant_fp16.bin \ + --report outputs/run_nvfp4.json +``` + +### 按模块运行测试 + +测试不再汇总为单个 `quant_dequant_tests` 进程。每个 `test_*.cpp` / `test_*.cu` +源文件各自生成一个独立 CTest 条目,因此失败时能直接定位到配置、I/O、codec、 +CPU reference 或某条 CUDA pipeline。先列出当前条目: + +```shell +ctest --preset rtx4060-release -N +``` + +常用筛选方式如下: + +```shell +# 只运行一个 NVFP4 CUDA 反量化测试。 +ctest --preset rtx4060-release \ + -R '^quant_dequant_nvfp4_dequantize_cuda_tests$' --output-on-failure + +# 运行全部带 cuda 标签的测试;无 CUDA device/driver 时,数值对照测试会明确跳过。 +ctest --preset rtx4060-release -L cuda --output-on-failure + +# 只运行不需要实际 CUDA device 的 unit 测试。 +ctest --preset rtx4060-release -L unit --output-on-failure + +# 仅筛选 NVFP4 测试;可与 -L cuda 联合使用。 +ctest --preset rtx4060-release -L nvfp4 --output-on-failure +``` + +CTest 标签为 `unit`、`integration`、`cuda`、`config`、`io`、`codec`、`reference`、 +`model`、`metrics`、`common`、`profile`、`mxfp8` 与 `nvfp4`。标签可组合筛选;例如 +`-L cuda -L nvfp4` 只保留同时匹配两个正则表达式的测试。 + +`build/`、运行产生的 `outputs/`、日志和大规模二进制测试数据应由 `.gitignore` 排除;配置样例、格式文档、测试代码和小型可复现实例应提交到 Git。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/CMakeLists.txt" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/CMakeLists.txt" new file mode 100644 index 00000000..2e04dcb5 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/CMakeLists.txt" @@ -0,0 +1,7 @@ +add_executable(quant_dequant + main.cpp +) + +target_link_libraries(quant_dequant PRIVATE quant_dequant::core) + +quant_dequant_enable_warnings(quant_dequant) diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/main.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/main.cpp" new file mode 100644 index 00000000..2b19dd15 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/apps/main.cpp" @@ -0,0 +1,411 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/config.hpp" +#include "quant_dequant/metrics.hpp" +#include "quant_dequant/quantize.hpp" +#include "quant_dequant/quantized_io.hpp" +#include "quant_dequant/tensor_io.hpp" +#include "quant_dequant/version.hpp" + +namespace { + +/** @brief `quant_dequant` 完整运行所需的全部命令行文件路径。 */ +struct AppOptions { + /** 输入用途的 QDTENSOR 文件,物理 dtype 为 FP16 或 FP32。 */ + std::filesystem::path input_path{}; + + /** 包含量化、反量化与报告字段的完整 key-value 配置文件。 */ + std::filesystem::path config_path{}; + + /** 要创建或覆盖的真实 bit-width QDWGT 量化权重文件。 */ + std::filesystem::path quantized_output_path{}; + + /** 要创建或覆盖的 FP16/BF16/FP32 QDTENSOR 反量化输出文件。 */ + std::filesystem::path dequantized_output_path{}; + + /** 要创建或覆盖的误差、压缩率和 CUDA 性能 JSON 报告。 */ + std::filesystem::path report_path{}; +}; + +/** @brief 表示 app 命令行、输出路径或编排过程的调用方错误。 */ +class AppError final : public std::runtime_error { +public: + /** + * @brief 构造带有面向命令行用户诊断的 app 错误。 + * + * @param detail 失败的具体原因。 + */ + explicit AppError(std::string detail) : std::runtime_error(std::move(detail)) {} +}; + +/** + * @brief 打印完整流程 app 的命令行用法。 + * + * @param stream 要写入 usage 的标准输出或错误流。 + */ +void print_usage(std::ostream& stream) { + stream << "用法:quant_dequant \\\n --input \\\n --config \\\n --quantized-output \\\n --dequantized-output \\\n --report \n\n" + << "完整流程:读取 FP16/FP32 QDTENSOR → CUDA 量化并写 QDWGT → " + "CUDA 反量化并写 FP16/BF16/FP32 QDTENSOR → 写 JSON 指标报告。\n"; +} + +/** + * @brief 读取一个需要紧随值的命令行选项。 + * + * @param argc 命令行参数数量。 + * @param argv 原始命令行参数数组。 + * @param index 当前选项在 argv 中的下标;成功时会前移到 value 下标。 + * @param option_name 当前选项名,用于诊断。 + * @return 非空的文件路径 value。 + * @throws AppError 选项缺值或 value 为空时抛出。 + */ +[[nodiscard]] std::filesystem::path consume_path_argument( + const int argc, + char* const argv[], + int* const index, + const std::string_view option_name) { + if (*index + 1 >= argc) { + throw AppError{"选项 \"" + std::string{option_name} + "\" 缺少路径 value。"}; + } + + ++(*index); + const std::string_view value{argv[*index]}; + if (value.empty() || value.starts_with("--")) { + throw AppError{"选项 \"" + std::string{option_name} + "\" 缺少有效路径 value。"}; + } + + return std::filesystem::path{value}; +} + +/** + * @brief 赋值一个只能出现一次的路径选项。 + * + * @param destination 尚未设置的目标路径槽位。 + * @param value 当前解析到的路径值。 + * @param option_name 当前选项名,用于重复选项诊断。 + * @throws AppError 同一选项出现多次时抛出。 + */ +void assign_unique_path(std::filesystem::path* const destination, + std::filesystem::path value, + const std::string_view option_name) { + if (!destination->empty()) { + throw AppError{"选项 \"" + std::string{option_name} + "\" 不能重复出现。"}; + } + *destination = std::move(value); +} + +/** + * @brief 解析完整流程所需的五个文件路径选项。 + * + * @param argc 命令行参数数量。 + * @param argv 原始命令行参数数组。 + * @return 已通过语法和必填字段检查的 app 选项。 + * @throws AppError 未知选项、重复选项或缺少必填选项时抛出。 + */ +[[nodiscard]] AppOptions parse_options(const int argc, char* const argv[]) { + AppOptions options{}; + + for (int index = 1; index < argc; ++index) { + const std::string_view option{argv[index]}; + if (option == "--input") { + assign_unique_path( + &options.input_path, + consume_path_argument(argc, argv, &index, option), option); + } else if (option == "--config") { + assign_unique_path( + &options.config_path, + consume_path_argument(argc, argv, &index, option), option); + } else if (option == "--quantized-output") { + assign_unique_path( + &options.quantized_output_path, + consume_path_argument(argc, argv, &index, option), option); + } else if (option == "--dequantized-output") { + assign_unique_path( + &options.dequantized_output_path, + consume_path_argument(argc, argv, &index, option), option); + } else if (option == "--report") { + assign_unique_path( + &options.report_path, + consume_path_argument(argc, argv, &index, option), option); + } else { + throw AppError{"不支持的命令行选项:\"" + std::string{option} + "\"。"}; + } + } + + if (options.input_path.empty() || options.config_path.empty() || + options.quantized_output_path.empty() || + options.dequantized_output_path.empty() || options.report_path.empty()) { + throw AppError{"完整流程必须提供 --input、--config、--quantized-output、" + "--dequantized-output 和 --report。"}; + } + + return options; +} + +/** + * @brief 将路径转换为可比较的绝对规范化词法路径。 + * + * 此处不解析符号链接,因为输出路径在运行前可不存在;它主要防止用户直接把同一 + * 相对/绝对路径同时传给输入和输出,导致 app 覆盖自己的输入或配置文件。 + * + * @param path 原始用户路径。 + * @return 绝对且按词法归一化后的路径。 + * @throws AppError 无法取得绝对路径时抛出。 + */ +[[nodiscard]] std::filesystem::path normalized_path(const std::filesystem::path& path) { + std::error_code error_code{}; + const std::filesystem::path absolute_path = std::filesystem::absolute(path, error_code); + if (error_code) { + throw AppError{"无法规范化路径 \"" + path.string() + "\":" + + error_code.message()}; + } + return absolute_path.lexically_normal(); +} + +/** + * @brief 拒绝两项逻辑上不能使用相同路径的 app 输入/输出。 + * + * @param first_path 第一条路径。 + * @param first_name 第一条路径的命令行选项名。 + * @param second_path 第二条路径。 + * @param second_name 第二条路径的命令行选项名。 + * @throws AppError 两条规范化路径相同,可能造成覆盖时抛出。 + */ +void require_distinct_paths(const std::filesystem::path& first_path, + const std::string_view first_name, + const std::filesystem::path& second_path, + const std::string_view second_name) { + if (normalized_path(first_path) == normalized_path(second_path)) { + throw AppError{"选项 \"" + std::string{first_name} + "\" 与 \"" + + std::string{second_name} + "\" 不能指向同一个文件。"}; + } +} + +/** + * @brief 验证输入、配置与三个输出文件不会直接相互覆盖。 + * + * @param options 已解析的完整 app 文件路径。 + * @throws AppError 任意冲突路径存在时抛出。 + */ +void validate_path_separation(const AppOptions& options) { + require_distinct_paths(options.input_path, "--input", options.config_path, "--config"); + require_distinct_paths( + options.input_path, "--input", options.quantized_output_path, + "--quantized-output"); + require_distinct_paths( + options.input_path, "--input", options.dequantized_output_path, + "--dequantized-output"); + require_distinct_paths(options.input_path, "--input", options.report_path, "--report"); + require_distinct_paths( + options.config_path, "--config", options.quantized_output_path, + "--quantized-output"); + require_distinct_paths( + options.config_path, "--config", options.dequantized_output_path, + "--dequantized-output"); + require_distinct_paths(options.config_path, "--config", options.report_path, "--report"); + require_distinct_paths( + options.quantized_output_path, "--quantized-output", + options.dequantized_output_path, "--dequantized-output"); + require_distinct_paths( + options.quantized_output_path, "--quantized-output", options.report_path, + "--report"); + require_distinct_paths( + options.dequantized_output_path, "--dequantized-output", options.report_path, + "--report"); +} + +/** + * @brief 在写输出前创建其父目录。 + * + * @param output_path 即将由 app 创建或覆盖的文件路径。 + * @throws AppError 父路径存在但不是目录,或创建目录失败时抛出。 + */ +void ensure_output_parent_directory(const std::filesystem::path& output_path) { + const std::filesystem::path parent_path = output_path.parent_path(); + if (parent_path.empty()) { + return; + } + + std::error_code error_code{}; + const bool created = std::filesystem::create_directories(parent_path, error_code); + if (error_code) { + throw AppError{"无法创建输出目录 \"" + parent_path.string() + "\":" + + error_code.message()}; + } + if (!created && !std::filesystem::is_directory(parent_path, error_code)) { + throw AppError{"输出父路径不是可用目录:\"" + parent_path.string() + "\"。"}; + } + if (error_code) { + throw AppError{"无法检查输出目录 \"" + parent_path.string() + "\":" + + error_code.message()}; + } +} + +/** + * @brief 安全取得一个已写入产物的完整文件字节数。 + * + * @param file_path 已由对应 I/O 写入器成功写出的文件。 + * @param artifact_name 用于错误诊断的产物名称。 + * @return 可由 metrics 层使用的 uint64 文件总字节数。 + * @throws AppError 查询失败或字节数无法以 uint64 表示时抛出。 + */ +[[nodiscard]] std::uint64_t get_file_size(const std::filesystem::path& file_path, + const std::string_view artifact_name) { + std::error_code error_code{}; + const std::uintmax_t bytes = std::filesystem::file_size(file_path, error_code); + if (error_code || bytes > std::numeric_limits::max()) { + throw AppError{"无法取得 " + std::string{artifact_name} + " 文件大小:\"" + + file_path.string() + "\"。"}; + } + return static_cast(bytes); +} + +/** + * @brief 向终端输出一个 JSON 风格的可选数值。 + * + * @param stream 目标终端流。 + * @param value 可选性能数值;无值时输出 `null`。 + */ +void print_optional_number(std::ostream& stream, const std::optional& value) { + if (value.has_value()) { + stream << *value; + } else { + stream << "null"; + } +} + +/** + * @brief 在终端输出一次完整运行的关键结果,详细稳定记录以 JSON 为准。 + * + * @param options 本次运行的文件路径。 + * @param report 已成功写入 JSON 的结构化报告。 + */ +void print_summary(const AppOptions& options, const quant_dequant::RunReport& report) { + std::cout << "完整 CUDA 量化/反量化流程完成。\n" + << " QDWGT:" << options.quantized_output_path << '\n' + << " 反量化 QDTENSOR:" << options.dequantized_output_path << '\n' + << " JSON 报告:" << options.report_path << '\n' + << " 误差:max_abs=" << report.error.max_abs + << ", mae=" << report.error.mae << ", mse=" << report.error.mse << '\n' + << " 压缩率:logical=" << report.compression.logical_compression_ratio + << ", on_disk=" << report.compression.on_disk_compression_ratio << '\n' + << " CUDA kernel:quant="; + print_optional_number(std::cout, report.performance.quant_kernel_ms); + std::cout << " ms, dequant="; + print_optional_number(std::cout, report.performance.dequant_kernel_ms); + std::cout << " ms\n 有效带宽:quant="; + print_optional_number( + std::cout, report.performance.quant_effective_bandwidth_gbps); + std::cout << " GB/s, dequant="; + print_optional_number( + std::cout, report.performance.dequant_effective_bandwidth_gbps); + std::cout << " GB/s\n"; +} + +/** + * @brief 执行一次从 QDTENSOR 输入到 QDWGT、反量化 QDTENSOR 与 JSON 报告的完整流程。 + * + * @param options 已完成命令行解析和路径隔离校验的输入/输出路径。 + * @throws ConfigError、TensorIoError、QuantizedIoError、CudaPipelineError、MetricsError + * 或 AppError 任一阶段不能继续时抛出。 + */ +void run_full_pipeline(const AppOptions& options) { + // 配置和原始 QDTENSOR 在产生任何输出前读取/验证;这样格式、dtype、NVFP4 + // block-mode 等用户错误不会留下部分输出文件。 + const quant_dequant::AppConfig config = + quant_dequant::load_app_config(options.config_path); + const quant_dequant::HostTensor input = + quant_dequant::read_input_tensor(options.input_path); + + ensure_output_parent_directory(options.quantized_output_path); + ensure_output_parent_directory(options.dequantized_output_path); + ensure_output_parent_directory(options.report_path); + + // profile API 在同一条独占 stream 上完成 H2D、kernel 与 D2H;返回的 Event + // 时间仅覆盖量化 kernel。QDWGT 写入器保留 payload 的真实 bit-width:NVFP4 + // 每个字节保存两个 E2M1 nibble。 + const quant_dequant::ProfiledQuantizationResult profiled_quantized = + quant_dequant::quantize_cuda_profiled(input, config.quantization); + quant_dequant::write_quantized_tensor( + options.quantized_output_path, profiled_quantized.tensor); + + // 完整 app 不直接把内存中的量化对象交给反量化,而是先经 QDWGT writer/read + // 边界。由此验证实际保存的 packed payload、local scale、NVFP4 global scale + // 和 header 能独立重建反量化所需的 QuantizedTensor。 + const quant_dequant::QuantizedTensor stored_quantized = + quant_dequant::read_quantized_tensor(options.quantized_output_path); + + // 反量化仍在 CUDA kernel 中完成;输出 HostTensor 的 values 统一是 FP32, + // desc.dtype 则让 writer 按配置真正写成 FP16、BF16 或 FP32 payload。 + const quant_dequant::ProfiledDequantizationResult profiled_dequantized = + quant_dequant::dequantize_cuda_profiled( + stored_quantized, config.dequantization); + quant_dequant::write_dequantized_tensor( + options.dequantized_output_path, profiled_dequantized.tensor); + + // 重新读取实际落盘的输出。对于 FP16/BF16,这一步将文件中的窄化值扩展为 + // FP32;故误差统计精确包含物理输出 dtype 的舍入误差,而非只统计 kernel + // 在 FP32 暂存值上的误差。 + const quant_dequant::HostTensor stored_dequantized = + quant_dequant::read_dequantized_tensor(options.dequantized_output_path); + + const std::uint64_t quantized_file_bytes = + get_file_size(options.quantized_output_path, "QDWGT"); + const std::uint64_t dequantized_file_bytes = + get_file_size(options.dequantized_output_path, "反量化 QDTENSOR"); + const quant_dequant::ArtifactMetrics artifacts = + quant_dequant::make_artifact_metrics( + input.desc, stored_quantized, stored_dequantized.desc, + quantized_file_bytes, dequantized_file_bytes); + const quant_dequant::RunReport report{ + .input_desc = input.desc, + .quantization = config.quantization, + .dequantization = config.dequantization, + .target_gpu = config.report.target_gpu, + .artifacts = artifacts, + .error = quant_dequant::compute_error_metrics( + input.values, stored_dequantized.values), + .compression = quant_dequant::compute_compression_metrics(artifacts), + .performance = quant_dequant::make_kernel_performance( + artifacts, stored_quantized, stored_dequantized.desc, + profiled_quantized.kernel_ms, profiled_dequantized.kernel_ms), + }; + quant_dequant::write_run_report_json(options.report_path, report); + print_summary(options, report); +} + +} // namespace + +/** + * @brief 执行 CUDA 低精度量化项目的完整命令行流程。 + * + * @param argc 命令行参数数量。 + * @param argv 原始命令行参数数组。 + * @return 正常完成或显示帮助时返回 0;参数、I/O、CUDA 或指标错误时返回 1。 + */ +int main(const int argc, char* argv[]) { + if (argc == 2 && std::string_view{argv[1]} == "--help") { + print_usage(std::cout); + return 0; + } + + try { + const AppOptions options = parse_options(argc, argv); + validate_path_separation(options); + run_full_pipeline(options); + return 0; + } catch (const std::exception& error) { + std::cerr << "quant_dequant 运行失败:" << error.what() << '\n'; + print_usage(std::cerr); + return 1; + } +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/CMakeLists.txt" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/CMakeLists.txt" new file mode 100644 index 00000000..16657ecd --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/CMakeLists.txt" @@ -0,0 +1,7 @@ +add_executable(quant_dequant_bench + benchmark_main.cpp +) + +target_link_libraries(quant_dequant_bench PRIVATE quant_dequant::core) + +quant_dequant_enable_warnings(quant_dequant_bench) diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/benchmark_main.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/benchmark_main.cpp" new file mode 100644 index 00000000..616f8bf5 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/benchmarks/benchmark_main.cpp" @@ -0,0 +1,449 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/config.hpp" +#include "quant_dequant/metrics.hpp" +#include "quant_dequant/quantize.hpp" +#include "quant_dequant/tensor_io.hpp" +#include "quant_dequant/types.hpp" + +namespace { + +/** @brief benchmark 命令行解析或运行前参数校验失败时使用的异常。 */ +class BenchmarkError final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +/** @brief 同一类 kernel 时间样本的汇总统计。 */ +struct TimingStatistics { + /** 全部正式 repeat 中的最小 kernel 时间,单位毫秒。 */ + double min_ms{0.0}; + + /** 全部正式 repeat 的算术均值,单位毫秒。 */ + double mean_ms{0.0}; + + /** 排序后中位数;偶数样本取两个中间值的平均,单位毫秒。 */ + double median_ms{0.0}; + + /** nearest-rank 定义的 p95,单位毫秒。 */ + double p95_ms{0.0}; + + /** 全部正式 repeat 中的最大 kernel 时间,单位毫秒。 */ + double max_ms{0.0}; +}; + +/** @brief benchmark 所需的明确命令行参数。 */ +struct BenchmarkArguments { + /** 一次读取后在整个 warmup/repeat 生命周期中复用的输入 QDTENSOR。 */ + std::filesystem::path input_path{}; + + /** 完整 AppConfig 文件;量化与反量化子配置从中分别投影。 */ + std::filesystem::path config_path{}; + + /** 可选 JSON 结果路径;省略时只向 stdout 写 JSON。 */ + std::optional output_path{}; + + /** 不计入统计的同进程 warmup 次数。 */ + std::uint64_t num_warmups{10U}; + + /** 计入统计的同进程正式 repeat 次数。 */ + std::uint64_t num_repeats{30U}; +}; + +/** + * @brief 将非负整数字符串解析为 uint64_t。 + * + * @param text 不含前缀或空白的十进制文本。 + * @param option_name 用于错误信息的命令行选项名。 + * @return 解析成功的无符号整数。 + * @throws BenchmarkError 文本为空、含非数字或超出 uint64_t 范围时抛出。 + */ +[[nodiscard]] std::uint64_t parse_unsigned_integer( + const std::string_view text, + const std::string_view option_name) { + std::uint64_t value = 0U; + const auto [end, error] = std::from_chars( + text.data(), text.data() + text.size(), value); + if (error != std::errc{} || end != text.data() + text.size()) { + throw BenchmarkError("选项 " + std::string{option_name} + + " 必须是非负十进制整数。"); + } + return value; +} + +/** + * @brief 解析 benchmark 命令行。 + * + * @param argc 进程参数个数。 + * @param argv 进程参数数组。 + * @return 已校验必填路径及 warmup/repeat 约束的参数对象。 + * @throws BenchmarkError 未知选项、重复选项、缺少选项值或必填项时抛出。 + */ +[[nodiscard]] BenchmarkArguments parse_arguments(const int argc, char* argv[]) { + BenchmarkArguments arguments{}; + bool has_input = false; + bool has_config = false; + + for (int index = 1; index < argc; ++index) { + const std::string_view option{argv[index]}; + if (option == "--help") { + std::cout + << "用法:quant_dequant_bench --input " + "--config [--warmups ] [--repeats ] " + "[--output ]\n"; + std::exit(0); + } + if (index + 1 >= argc) { + throw BenchmarkError("选项 " + std::string{option} + " 缺少值。"); + } + + const std::string_view value{argv[++index]}; + if (option == "--input") { + if (has_input) { + throw BenchmarkError("--input 不能重复指定。"); + } + arguments.input_path = std::filesystem::path{value}; + has_input = true; + } else if (option == "--config") { + if (has_config) { + throw BenchmarkError("--config 不能重复指定。"); + } + arguments.config_path = std::filesystem::path{value}; + has_config = true; + } else if (option == "--output") { + if (arguments.output_path.has_value()) { + throw BenchmarkError("--output 不能重复指定。"); + } + arguments.output_path = std::filesystem::path{value}; + } else if (option == "--warmups") { + arguments.num_warmups = parse_unsigned_integer(value, option); + } else if (option == "--repeats") { + arguments.num_repeats = parse_unsigned_integer(value, option); + } else { + throw BenchmarkError("未知选项:" + std::string{option}); + } + } + + if (!has_input || !has_config) { + throw BenchmarkError("--input 和 --config 都是必填项;使用 --help 查看用法。"); + } + if (arguments.num_repeats == 0U) { + throw BenchmarkError("--repeats 必须大于 0。"); + } + return arguments; +} + +/** + * @brief 计算一组非空、有限且非负的毫秒样本的统计量。 + * + * @param samples 仅包含正式 repeat 的 CUDA Event 时间,单位毫秒。 + * @return min、mean、median、p95、max 五项稳定统计。 + * @throws BenchmarkError 样本为空或包含无效时间时抛出。 + */ +[[nodiscard]] TimingStatistics compute_timing_statistics( + const std::vector& samples) { + if (samples.empty()) { + throw BenchmarkError("无法对空的时间样本计算统计量。"); + } + + std::vector sorted_samples = samples; + double sum_ms = 0.0; + for (const double sample_ms : sorted_samples) { + if (!std::isfinite(sample_ms) || sample_ms < 0.0) { + throw BenchmarkError("CUDA Event 返回了非有限或负的 kernel 时间。"); + } + sum_ms += sample_ms; + } + std::sort(sorted_samples.begin(), sorted_samples.end()); + + const std::size_t count = sorted_samples.size(); + const std::size_t upper_middle = count / 2U; + const double median_ms = (count % 2U == 0U) + ? (sorted_samples[upper_middle - 1U] + + sorted_samples[upper_middle]) / + 2.0 + : sorted_samples[upper_middle]; + // nearest-rank p95:第 ceil(0.95 * N) 个 1-based 样本。 + const std::size_t p95_index = (95U * count + 99U) / 100U - 1U; + + return TimingStatistics{ + .min_ms = sorted_samples.front(), + .mean_ms = sum_ms / static_cast(count), + .median_ms = median_ms, + .p95_ms = sorted_samples[p95_index], + .max_ms = sorted_samples.back(), + }; +} + +/** + * @brief 将 UTF-8 字节串转为可嵌入 JSON 的最小 string literal。 + * + * @param value 待转义的文本,例如 target_gpu。 + * @return 已含双引号的 JSON string literal。 + */ +[[nodiscard]] std::string json_string(const std::string_view value) { + std::ostringstream stream{}; + stream << '"'; + for (const unsigned char character : value) { + switch (character) { + case '"': + stream << "\\\""; + break; + case '\\': + stream << "\\\\"; + break; + case '\b': + stream << "\\b"; + break; + case '\f': + stream << "\\f"; + break; + case '\n': + stream << "\\n"; + break; + case '\r': + stream << "\\r"; + break; + case '\t': + stream << "\\t"; + break; + default: + if (character < 0x20U) { + stream << "\\u00" << std::hex << std::setw(2) + << std::setfill('0') + << static_cast(character) << std::dec + << std::setfill(' '); + } else { + stream << static_cast(character); + } + break; + } + } + stream << '"'; + return stream.str(); +} + +/** + * @brief 将 kernel 统计及其由均值换算的有效带宽写入 JSON。 + * + * @param stream 目标 JSON 输出流。 + * @param statistics 已完成统计的时间对象。 + * @param logical_bytes 当前阶段按项目约定的逻辑读写字节数。 + */ +void write_timing_json( + std::ostream& stream, + const TimingStatistics& statistics, + const std::uint64_t logical_bytes) { + stream << std::setprecision(12) + << "{\n" + << " \"min_ms\": " << statistics.min_ms << ",\n" + << " \"mean_ms\": " << statistics.mean_ms << ",\n" + << " \"median_ms\": " << statistics.median_ms << ",\n" + << " \"p95_ms\": " << statistics.p95_ms << ",\n" + << " \"max_ms\": " << statistics.max_ms << ",\n" + << " \"effective_bandwidth_gbps_from_mean\": "; + if (statistics.mean_ms == 0.0) { + stream << "null\n"; + } else { + stream << quant_dequant::compute_effective_bandwidth_gbps( + logical_bytes, statistics.mean_ms) + << '\n'; + } + stream << " }"; +} + +/** + * @brief 组装 benchmark 的自描述 JSON,不写文件也不访问 CUDA。 + * + * @param input_desc 原始输入描述。 + * @param app_config 完整配置,用于记录格式和输出 dtype。 + * @param num_warmups 未纳入统计的同进程 warmup 数。 + * @param num_repeats 纳入统计的正式样本数。 + * @param quant_statistics 量化 kernel 时间统计。 + * @param dequant_statistics 反量化 kernel 时间统计。 + * @param quant_logical_bytes 量化阶段的逻辑读写字节数。 + * @param dequant_logical_bytes 反量化阶段的逻辑读写字节数。 + * @return 以换行结尾的 UTF-8 JSON 文本。 + */ +[[nodiscard]] std::string make_benchmark_json( + const quant_dequant::TensorDesc& input_desc, + const quant_dequant::AppConfig& app_config, + const std::uint64_t num_warmups, + const std::uint64_t num_repeats, + const TimingStatistics& quant_statistics, + const TimingStatistics& dequant_statistics, + const std::uint64_t quant_logical_bytes, + const std::uint64_t dequant_logical_bytes) { + std::ostringstream stream{}; + stream << "{\n" + << " \"schema_version\": 1,\n" + << " \"input\": {\n" + << " \"rows\": " << input_desc.num_rows << ",\n" + << " \"cols\": " << input_desc.num_cols << ",\n" + << " \"dtype\": " << json_string(quant_dequant::to_string(input_desc.dtype)) + << "\n },\n" + << " \"config\": {\n" + << " \"format\": " + << json_string(quant_dequant::to_string(app_config.quantization.format)) + << ",\n" + << " \"block_size\": " << app_config.quantization.block_size << ",\n" + << " \"scale_mode\": " + << json_string(quant_dequant::to_string(app_config.quantization.scale_mode)) + << ",\n" + << " \"rounding\": " + << json_string(quant_dequant::to_string(app_config.quantization.rounding)) + << ",\n" + << " \"output_type\": " + << json_string(quant_dequant::to_string(app_config.dequantization.output_type)) + << ",\n" + << " \"target_gpu\": " + << json_string(app_config.report.target_gpu) << "\n" + << " },\n" + << " \"benchmark\": {\n" + << " \"warmups\": " << num_warmups << ",\n" + << " \"repeats\": " << num_repeats << ",\n" + << " \"timing_scope\": \"CUDA Event;仅格式专用 kernel,不含 H2D、D2H、分配、文件 I/O 或 context 初始化\"\n" + << " },\n" + << " \"performance\": {\n" + << " \"quant\": "; + write_timing_json(stream, quant_statistics, quant_logical_bytes); + stream << ",\n \"dequant\": "; + write_timing_json(stream, dequant_statistics, dequant_logical_bytes); + stream << "\n }\n}\n"; + return stream.str(); +} + +/** + * @brief 创建 output 路径的父目录,并写入完整 JSON。 + * + * @param output_path 要创建或覆盖的 JSON 文件路径。 + * @param json_text 已完成序列化的 JSON 文本。 + * @throws BenchmarkError 无法创建目录或写入完整文件时抛出。 + */ +void write_json_file( + const std::filesystem::path& output_path, + const std::string_view json_text) { + const std::filesystem::path parent_path = output_path.parent_path(); + if (!parent_path.empty()) { + std::error_code error{}; + std::filesystem::create_directories(parent_path, error); + if (error) { + throw BenchmarkError("无法创建 benchmark 输出目录:" + + parent_path.string() + ":" + error.message()); + } + } + + std::ofstream stream{output_path, std::ios::binary | std::ios::trunc}; + if (!stream) { + throw BenchmarkError("无法写入 benchmark JSON:" + output_path.string()); + } + stream.write(json_text.data(), static_cast(json_text.size())); + if (!stream) { + throw BenchmarkError("写入 benchmark JSON 时失败:" + output_path.string()); + } +} + +/** + * @brief 在同一进程中完成 CUDA warmup 和重复量化/反量化测量。 + * + * 输入 QDTENSOR 与配置只读取一次。每轮调用 profile pipeline;该 pipeline 的 + * CUDA Event 位于 H2D 后、D2H 前,故收集的时间严格只覆盖格式专用 kernel。warmup + * 轮完全不进入统计,用于让 CUDA context、模块加载、GPU 时钟和缓存状态趋于稳定。 + * + * @param arguments 已校验的 benchmark 命令行参数。 + * @return 成功时返回 0。 + */ +int run_benchmark(const BenchmarkArguments& arguments) { + const quant_dequant::HostTensor input = + quant_dequant::read_input_tensor(arguments.input_path); + const quant_dequant::AppConfig app_config = + quant_dequant::load_app_config(arguments.config_path); + + // warmup 与正式测量都走 quantize -> dequantize 数据依赖,保证 MXFP8 tensor + // reduction、NVFP4 global/local scale 与 pack/unpack 等完整格式路径都被执行。 + for (std::uint64_t iteration = 0U; + iteration < arguments.num_warmups; + ++iteration) { + const auto quantized = quant_dequant::quantize_cuda_profiled( + input, app_config.quantization); + [[maybe_unused]] const auto dequantized = + quant_dequant::dequantize_cuda_profiled( + quantized.tensor, app_config.dequantization); + } + + std::vector quant_samples_ms{}; + std::vector dequant_samples_ms{}; + quant_samples_ms.reserve(static_cast(arguments.num_repeats)); + dequant_samples_ms.reserve(static_cast(arguments.num_repeats)); + + // 最后一轮的量化布局与前面各轮相同;它仅用于推导报告中的逻辑读写字节数。 + std::optional last_quantized{}; + std::optional last_dequantized{}; + for (std::uint64_t iteration = 0U; + iteration < arguments.num_repeats; + ++iteration) { + const auto quantized = quant_dequant::quantize_cuda_profiled( + input, app_config.quantization); + const auto dequantized = quant_dequant::dequantize_cuda_profiled( + quantized.tensor, app_config.dequantization); + + quant_samples_ms.push_back(quantized.kernel_ms); + dequant_samples_ms.push_back(dequantized.kernel_ms); + last_quantized = quantized.tensor; + last_dequantized = dequantized.tensor; + } + + const TimingStatistics quant_statistics = + compute_timing_statistics(quant_samples_ms); + const TimingStatistics dequant_statistics = + compute_timing_statistics(dequant_samples_ms); + const std::uint64_t input_bytes = + quant_dequant::compute_input_payload_bytes(input.desc); + const std::uint64_t quantized_bytes = + quant_dequant::compute_logical_quantized_bytes(*last_quantized); + const std::uint64_t output_bytes = + quant_dequant::compute_dequantized_payload_bytes(last_dequantized->desc); + const std::uint64_t quant_logical_bytes = input_bytes + quantized_bytes; + const std::uint64_t dequant_logical_bytes = quantized_bytes + output_bytes; + const std::string json_text = make_benchmark_json( + input.desc, + app_config, + arguments.num_warmups, + arguments.num_repeats, + quant_statistics, + dequant_statistics, + quant_logical_bytes, + dequant_logical_bytes); + + if (arguments.output_path.has_value()) { + write_json_file(*arguments.output_path, json_text); + } + std::cout << json_text; + return 0; +} + +} // namespace + +int main(int argc, char* argv[]) { + try { + return run_benchmark(parse_arguments(argc, argv)); + } catch (const std::exception& error) { + std::cerr << "quant_dequant_bench 失败:" << error.what() << '\n'; + return 1; + } +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/README.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/README.md" new file mode 100644 index 00000000..053caf29 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/README.md" @@ -0,0 +1,31 @@ +# 实验配置矩阵 + +本目录中的每个 `.toml` 都是可以直接传给 `quant_dequant --config` 的完整 app 配置。 +它们不是模板,也不会由脚本临时生成;`scripts/run_e2e_suite.py` 默认扫描全部 `.toml` +并逐一运行。 + +| 格式 | scale mode | rounding | 输出 dtype | 文件数 | +| --- | --- | --- | --- | ---: | +| MXFP8 | `block`、`tensor` | `nearest`、`stochastic` | `fp16`、`bf16`、`fp32` | 12 | +| NVFP4 | 仅 `block` | `nearest`、`stochastic` | `fp16`、`bf16`、`fp32` | 6 | + +命名固定为: + +```text +mxfp8___.toml +nvfp4_block__.toml +``` + +`nearest` 配置省略 `stochastic_seed`,因此 loader 使用默认值 0;`stochastic` 配置都 +显式使用 `stochastic_seed = 20260917`,使同一输入与配置可以复现完全相同的编码结果。 + +例如,只跑 MXFP8 的 tensor scale、随机舍入和 BF16 输出: + +```shell +python3 scripts/run_e2e_suite.py \ + --config-names mxfp8_tensor_stochastic_bf16 \ + --output-dir outputs/mxfp8_tensor_stochastic_bf16 +``` + +完整字段语义及单方向/完整 app loader 的区别见 +[`../docs/configuration.md`](../docs/configuration.md)。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_bf16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_bf16.toml" new file mode 100644 index 00000000..87499cb8 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_bf16.toml" @@ -0,0 +1,6 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "nearest" +output_type = "bf16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp16.toml" new file mode 100644 index 00000000..f1137f1b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp16.toml" @@ -0,0 +1,6 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "nearest" +output_type = "fp16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp32.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp32.toml" new file mode 100644 index 00000000..7e23d8cf --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_nearest_fp32.toml" @@ -0,0 +1,6 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "nearest" +output_type = "fp32" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_bf16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_bf16.toml" new file mode 100644 index 00000000..5b92a66c --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_bf16.toml" @@ -0,0 +1,7 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "bf16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp16.toml" new file mode 100644 index 00000000..5c665b9f --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp16.toml" @@ -0,0 +1,7 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "fp16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp32.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp32.toml" new file mode 100644 index 00000000..468d9231 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_block_stochastic_fp32.toml" @@ -0,0 +1,7 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "fp32" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_bf16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_bf16.toml" new file mode 100644 index 00000000..cfb0735a --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_bf16.toml" @@ -0,0 +1,6 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "tensor" +rounding = "nearest" +output_type = "bf16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp16.toml" new file mode 100644 index 00000000..399a32a4 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp16.toml" @@ -0,0 +1,6 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "tensor" +rounding = "nearest" +output_type = "fp16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp32.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp32.toml" new file mode 100644 index 00000000..1c5baddb --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_nearest_fp32.toml" @@ -0,0 +1,6 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "tensor" +rounding = "nearest" +output_type = "fp32" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_bf16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_bf16.toml" new file mode 100644 index 00000000..6ca9a2e0 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_bf16.toml" @@ -0,0 +1,7 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "tensor" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "bf16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp16.toml" new file mode 100644 index 00000000..6c90d140 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp16.toml" @@ -0,0 +1,7 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "tensor" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "fp16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp32.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp32.toml" new file mode 100644 index 00000000..782032dd --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/mxfp8_tensor_stochastic_fp32.toml" @@ -0,0 +1,7 @@ +format = "mxfp8" +block_size = 32 +scale_mode = "tensor" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "fp32" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_bf16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_bf16.toml" new file mode 100644 index 00000000..b18244bb --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_bf16.toml" @@ -0,0 +1,6 @@ +format = "nvfp4" +block_size = 16 +scale_mode = "block" +rounding = "nearest" +output_type = "bf16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp16.toml" new file mode 100644 index 00000000..032bd017 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp16.toml" @@ -0,0 +1,6 @@ +format = "nvfp4" +block_size = 16 +scale_mode = "block" +rounding = "nearest" +output_type = "fp16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp32.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp32.toml" new file mode 100644 index 00000000..4f7b1815 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_nearest_fp32.toml" @@ -0,0 +1,6 @@ +format = "nvfp4" +block_size = 16 +scale_mode = "block" +rounding = "nearest" +output_type = "fp32" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_bf16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_bf16.toml" new file mode 100644 index 00000000..52dd58ca --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_bf16.toml" @@ -0,0 +1,7 @@ +format = "nvfp4" +block_size = 16 +scale_mode = "block" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "bf16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp16.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp16.toml" new file mode 100644 index 00000000..e825d625 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp16.toml" @@ -0,0 +1,7 @@ +format = "nvfp4" +block_size = 16 +scale_mode = "block" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "fp16" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp32.toml" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp32.toml" new file mode 100644 index 00000000..1e11a6a7 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/configs/nvfp4_block_stochastic_fp32.toml" @@ -0,0 +1,7 @@ +format = "nvfp4" +block_size = 16 +scale_mode = "block" +rounding = "stochastic" +stochastic_seed = 20260917 +output_type = "fp32" +target_gpu = "RTX 4060" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/app.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/app.md" new file mode 100644 index 00000000..6a609535 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/app.md" @@ -0,0 +1,111 @@ +# 完整命令行程序:QDTENSOR → QDWGT → QDTENSOR → JSON + +`quant_dequant` 是项目的端到端 CUDA 程序。它不实现新的数值规则;它只把已经 +独立验证的配置、QDTENSOR I/O、CUDA pipeline、QDWGT I/O 和 metrics 按一次实验 +运行的顺序编排起来。 + +数值格式见 [format_spec.md](format_spec.md),文件字节布局见 +[file_format.md](file_format.md),配置字段见 [configuration.md](configuration.md)。 + +## 命令行 + +构建 RTX 4060 的 release 版本后,执行文件位于 `build/rtx4060-release/apps/`: + +```shell +cd "02_quant_dequant/黄新颖" +cmake --preset rtx4060-release +cmake --build --preset rtx4060-release -j + +./build/rtx4060-release/apps/quant_dequant \ + --input data/input_fp32.qdtensor \ + --config configs/mxfp8_block_nearest_fp16.toml \ + --quantized-output outputs/weights.qdwgt \ + --dequantized-output outputs/dequantized.qdtensor \ + --report outputs/run_report.json +``` + +全部五个路径选项都是必填项: + +| 选项 | 含义 | 输入/输出约束 | +| --- | --- | --- | +| `--input` | 原始 QDTENSOR 矩阵。 | 文件的 `tensor_role` 必须为 `input`,dtype 只能为 FP16 或 FP32。 | +| `--config` | 完整 app 的 `key = value` 配置。 | 必须包含量化字段、`output_type` 和 `target_gpu`。 | +| `--quantized-output` | 量化后的 QDWGT 文件。 | 创建或覆盖;NVFP4 payload 真实按两个 FP4 元素每字节打包。 | +| `--dequantized-output` | CUDA 反量化后的 QDTENSOR 文件。 | 创建或覆盖;物理 dtype 由 `output_type` 指定。 | +| `--report` | 误差与性能的 UTF-8 JSON 报告。 | 创建或覆盖。 | + +程序会自动创建输出文件的父目录。为了防止意外覆盖,输入、配置和三个输出路径 +两两不能是同一个规范化路径。使用 `--help` 可查看该用法。 + +完整 app 配置示例: + +```toml +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "nearest" +output_type = "bf16" +target_gpu = "RTX 4060" +``` + +NVFP4 时把 `format` 改为 `nvfp4`、`block_size` 改为 `16`,并保持 +`scale_mode = "block"`;NVFP4 不接受 tensor mode。 + +## 一次运行的控制流与数据流 + +```mermaid +flowchart LR + IN["输入 QDTENSOR
FP16 或 FP32"] --> READ["read_input_tensor"] + CFG["完整配置文件"] --> PARSE["load_app_config"] + READ --> Q["quantize_cuda_profiled"] + PARSE --> Q + Q --> WQ["write_quantized_tensor
QDWGT"] + WQ --> RQ["read_quantized_tensor"] + RQ --> D["dequantize_cuda_profiled"] + PARSE --> D + D --> WD["write_dequantized_tensor
输出 QDTENSOR"] + WD --> RD["read_dequantized_tensor"] + READ --> METRIC["误差、压缩率与带宽"] + Q --> METRIC + D --> METRIC + RD --> METRIC + METRIC --> JSON["write_run_report_json"] +``` + +量化和反量化各自使用 profile API。每一阶段内部均采用独占 non-blocking CUDA +stream,提交顺序是:H2D、start event、该阶段全部 kernel、stop event、D2H。 +因此 JSON 中的 kernel 时间只统计 device kernel,不包含拷贝、内存分配、文件 I/O +或 CUDA context 初始化。 + +## 输出与误差定义 + +app 依次写入 QDWGT、**读回 QDWGT 后再 CUDA 反量化**、写入反量化 QDTENSOR 和 JSON +报告。QDWGT 与 QDTENSOR 写入完成后才查询真实文件大小,因此压缩率中的 +`on_disk_compression_ratio` 包含固定 header 和 QDWGT local-scale 对齐 padding。 + +误差比较的两侧是: + +- 参考值:输入 QDTENSOR 被读取并统一扩展后的 FP32 值; +- 实际值:反量化 QDTENSOR **写入后重新读取**并扩展到 FP32 的值。 + +第二项尤其重要。当 `output_type = fp16` 或 `bf16` 时,CUDA 反量化 kernel 的 FP32 +暂存结果在写文件时还会进行一次物理 dtype 窄化。app 读回该文件,使 `max_abs`、 +`mae`、`mse` 包含这一次真实落盘误差。 + +报告字段和带宽公式以 [file_format.md 的“误差与性能日志”](file_format.md#误差与性能日志) +为准。若极短 kernel 被 CUDA Event 的有限分辨率量为 `0.0 ms`,时间字段仍如实写 +`0.0`,但带宽字段写 `null`,因为有限字节数除以零没有定义。 + +## 失败行为 + +任一阶段失败时程序在标准错误输出中文诊断并返回非零: + +- 配置字段、格式/block/scale 组合不合法; +- 输入 QDTENSOR 的 role、dtype、header 或 payload 不合法; +- CUDA runtime、stream、kernel 或 D2H 失败,或输入含 NaN/Inf; +- 输出文件无法写入,或者写出后读回的 QDTENSOR 不合法; +- 指标、压缩率或报告 JSON 无法构造。 + +配置和输入文件会在创建输出目录、覆盖任何输出前先完成读取和校验。不过一旦开始 +写 QDWGT,后续阶段失败时已完成的输出会保留,方便定位哪一阶段失败;下次使用同一 +显式输出路径运行会覆盖它们。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/configuration.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/configuration.md" new file mode 100644 index 00000000..690c8663 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/configuration.md" @@ -0,0 +1,225 @@ +# 配置模块:方向配置、完整 app 配置与解析规则 + +本项目的配置不是“把一次程序运行涉及的全部参数塞进一个结构体”。量化、反量化和报告属于三个不同的职责:量化不需要知道反量化输出类型,单独反量化也不应被迫填写量化格式。因此,配置模块把**文本配置文件的形式**和**程序内部传递的配置对象**分开设计。 + +本页说明配置字段、三个配置对象、文本解析规则和读取接口。MXFP8、NVFP4 的数值含义见 [format_spec.md](format_spec.md);QDTENSOR、QDWGT 的磁盘格式见 [file_format.md](file_format.md)。 + +## 模块位置与职责 + +| 位置 | 职责 | +| --- | --- | +| `include/quant_dequant/types.hpp` | 定义 `QuantizationConfig`、`DequantizationConfig`、`ReportConfig`、`AppConfig` 和相关枚举。它们是无 I/O、可在 CPU/CUDA pipeline 间传递的普通值对象。 | +| `include/quant_dequant/config.hpp` | 对外提供三个配置读取接口和 `ConfigError`。app、测试或未来独立工具只应依赖这里。 | +| `src/config/config_parser.cpp` | 实现 `key = value` 文本解析、字段去重、值转换和按方向进行的语义校验。内部的 `ParsedConfigDocument` 不属于公共 API。 | + +当前模块**只读取和校验配置**,没有 `write_config(...)` 接口。这是有意的:配置文件通常由用户、实验脚本或命令行生成;程序运行后应把实际配置写入 JSON 性能日志,而不是覆盖用户给定的原始配置。 + +```mermaid +flowchart TD + FILE["平面 key = value 配置文件"] --> PARSE["parse_config_document
语法解析与字段记录"] + PARSE --> Q["QuantizationConfig
量化方向"] + PARSE --> D["DequantizationConfig
反量化方向"] + PARSE --> A["ReportConfig
报告元数据"] + Q --> APP["AppConfig
仅供 app 编排"] + D --> APP + A --> APP + Q --> QUANT["CPU reference / CUDA quantize"] + D --> DEQUANT["CPU reference / CUDA dequantize"] +``` + +`AppConfig` 是三个较小配置的组合,不是数值接口的“万能参数”。例如 `quantize_reference()` 只接收 `QuantizationConfig`;它不知道也不需要知道 `output_type` 或 `target_gpu`。 + +## 三类配置对象 + +### `QuantizationConfig` + +它描述“原始浮点张量如何编码为低精度 payload 与 scale”。当前成员如下: + +| 成员 | 类型 | 含义 | 不变量 | +| --- | --- | --- | --- | +| `format` | `QuantFormat` | 目标格式,`kMxfp8` 或 `kNvfp4`。 | 不允许 `kUnknown`。 | +| `block_size` | `uint32_t` | 格式原生 block 的元素数。 | MXFP8 必须是 `32`,NVFP4 必须是 `16`;MXFP8 的 tensor mode 仍保留该固有值。 | +| `scale_mode` | `ScaleMode` | scale 的共享范围。 | MXFP8 可为 `kBlock` 或 `kTensor`;NVFP4 只能为 `kBlock`。 | +| `rounding` | `RoundingMode` | 元素编码的舍入方式。 | 必须是 `kNearest` 或 `kStochastic`。 | +| `stochastic_seed` | `uint64_t` | stochastic rounding 的确定性种子。 | `nearest` 时必须为 `0`;`stochastic` 时允许任意 `uint64_t`,包括 `0`。 | + +这里不保存输入 dtype。输入是 FP16 还是 FP32 由 `tensor_role = input` 的 +QDTENSOR header 描述,读取后统一转换为 `HostTensor::values` 中的 FP32;这样同一量化配置可复用于 FP16 和 FP32 输入。第一版不接受 BF16 输入;BF16 仅可由反量化阶段作为输出文件的物理类型使用。 + +### `DequantizationConfig` + +它只描述“低精度量化结果应该以何种物理类型写成反量化输出”。 + +| 成员 | 类型 | 含义 | 合法值 | +| --- | --- | --- | --- | +| `output_type` | `DType` | 反量化后的 QDTENSOR payload dtype。 | `kFloat16`、`kBFloat16`、`kFloat32`。 | + +同一份 QDWGT 可以多次读取,并分别输出 FP16、BF16、FP32。因此 `output_type` 不放入 `QuantizedTensor`,也不写成低精度权重自身的数值语义。 + +### `ReportConfig` 与 `AppConfig` + +`ReportConfig` 当前只有 `target_gpu`,它是非空字符串,仅用于报告、日志与实验可复现性说明,不改变量化或反量化的数值结果。 + +`AppConfig` 将以下对象组合起来: + +```cpp +struct AppConfig { + QuantizationConfig quantization; + DequantizationConfig dequantization; + ReportConfig report; +}; +``` + +它仅供 `apps/main.cpp` 这类“从输入到量化、反量化、日志的一次完整运行”使用。数值层应提取其中的 `quantization` 或 `dequantization` 成员,而不是接收整个 `AppConfig`。 + +## 配置文件字段 + +配置文件采用平面 `key = value` 形式。值可写成不带引号的标量,也可用双引号包裹;示例中保留引号,便于看清字符串字段。 + +| 文本字段 | 对应成员 | 可写值 | 含义与限制 | +| --- | --- | --- | --- | +| `format` | `QuantizationConfig::format` | `"mxfp8"`、`"nvfp4"` | 选择目标低精度格式。大小写敏感。 | +| `block_size` | `QuantizationConfig::block_size` | 十进制无符号整数 | MXFP8 只能写 `32`;NVFP4 只能写 `16`。不是“建议默认值”,不匹配即报错。 | +| `scale_mode` | `QuantizationConfig::scale_mode` | MXFP8:`"block"`、`"tensor"`;NVFP4:仅 `"block"` | `block` 表示每行分别分 block 保存 local scale;`tensor` 表示整张矩阵只保存一个 local scale,且仅适用于 MXFP8。 | +| `rounding` | `QuantizationConfig::rounding` | `"nearest"`、`"stochastic"` | 控制元素编码的舍入;scale 自身的编码规则不由该字段改变。 | +| `stochastic_seed` | `QuantizationConfig::stochastic_seed` | 十进制无符号整数,可省略 | 省略时为 `0`。`nearest` 时只能省略或显式写 `0`。 | +| `output_type` | `DequantizationConfig::output_type` | `"fp16"`、`"bf16"`、`"fp32"` | 只决定反量化输出文件的物理 dtype。 | +| `target_gpu` | `ReportConfig::target_gpu` | 非空字符串 | 用于报告说明,例如 `"RTX 4060"`;不参与计算,也不影响 CUDA 架构编译选项。 | + +`scale_mode = "block"` 时,block 一律按 row-major 矩阵的**每一行**独立划分;不会跨越行边界。对 `R × C` 矩阵,scale 数量为 $R \times \lceil C / \mathrm{block\_size} \rceil$。MXFP8 使用 32 元素 block,NVFP4 使用 16 元素 block。 + +## 三种最小配置文件 + +### 仅量化 + +适用于将 QDTENSOR 输入写成 QDWGT 的工具或 CPU/CUDA quantize pipeline: + +```toml +# mxfp8_quantize.toml +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "nearest" +``` + +若使用随机舍入,显式提供种子以保证测试可复现: + +```toml +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "stochastic" +stochastic_seed = 20260913 +``` + +### 仅反量化 + +适用于已经存在 QDWGT 文件、只希望输出某种精度张量的工具: + +```toml +# dequantize_fp32.toml +output_type = "fp32" +``` + +不需要 `format`,因为格式、block size、scale mode 和 payload 布局都由 QDWGT header 决定。 + +### 完整 app + +完整 app 在一次运行中连续执行量化、写出低精度权重、反量化和报告,因此需要两侧配置及报告字段: + +```toml +# app_mxfp8_block.toml +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "nearest" +output_type = "fp16" +target_gpu = "RTX 4060" +``` + +同一个完整文件也可以传给单方向 loader。量化 loader 会读取并校验量化字段、忽略其中已知的 `output_type` 和 `target_gpu`;反量化 loader 则只取 `output_type`。这使实验只需维护一份完整配置,同时又不让单方向 API 承担无关字段。 + +额外已知字段仍会经过基本的文本和值域解析,例如 `format = "fp8"` 依然会被拒绝;但 `load_dequantization_config()` 不会施加“format 与 block_size 必须匹配”这类量化方向的跨字段校验。若要验证整份完整配置,应该调用 `load_app_config()`;若要验证量化配置,应调用 `load_quantization_config()`。 + +## 读取接口与必填字段 + +公共头文件是 `include/quant_dequant/config.hpp`。 + +| 接口 | 返回类型 | 必填字段 | 允许出现但不返回的已知字段 | +| --- | --- | --- | --- | +| `load_quantization_config(path)` | `QuantizationConfig` | `format`、`block_size`、`scale_mode`、`rounding` | `stochastic_seed`、`output_type`、`target_gpu`。 | +| `load_dequantization_config(path)` | `DequantizationConfig` | `output_type` | 量化字段、`stochastic_seed`、`target_gpu`。 | +| `load_app_config(path)` | `AppConfig` | 量化必填字段、`output_type`、`target_gpu` | `stochastic_seed`。 | + +调用方式如下: + +```cpp +#include + +#include "quant_dequant/config.hpp" + +const std::filesystem::path config_path{"configs/app_mxfp8_block.toml"}; +const quant_dequant::AppConfig app_config = + quant_dequant::load_app_config(config_path); + +// 数值层只取自己真正需要的方向配置。 +const quant_dequant::QuantizationConfig quantization = + app_config.quantization; +const quant_dequant::DequantizationConfig dequantization = + app_config.dequantization; +``` + +## 语法与读取流程 + +解析器并不依赖 TOML 库;虽然示例文件通常使用 `.toml` 扩展名,当前支持的是一个刻意受限的平面 `key = value` 子集。 + +1. 逐行读取。首行存在 UTF-8 BOM 时会移除;空行会跳过。 +2. 删除**引号外**的 `#` 行尾注释,再去除 key 和 value 两端的 ASCII 空白。 +3. 在引号外寻找 `=` 分隔符,取得 key 与标量 value。 +4. 将七个已知 key 转换到内部对象,同时记录每个字段第一次出现的行号。 +5. 根据调用的 public loader,检查该方向的必填字段和跨字段不变量。 + +例如,下列字符串中的 `#` 是 `target_gpu` 的内容,不是注释: + +```toml +target_gpu = "实验机 #2" +``` + +当前语法边界如下: + +- key 不能为空,也不能包含空白。 +- 每行必须含有引号外的 `=`;value 不可为空。 +- 双引号只能包裹整个 value,必须成对出现;第一版不支持转义序列或字符串内部再出现双引号。 +- 未加引号的 value 不能含双引号。 +- 整数字段只能是范围内的十进制无符号整数;不接受负数、十六进制或小数。 +- 所有已知字段在一份文件中最多出现一次,即使当前 loader 不使用该字段;重复字段会报出首次出现的行号。 +- 未知字段会被拒绝,避免拼写错误悄悄改变实验含义。 + +## 校验失败与 `ConfigError` + +任一读取接口遇到文件、语法或语义错误都会抛出 `ConfigError`。异常继承自 `std::runtime_error`,同时保存: + +- `filePath()`:出错配置文件的路径; +- `lineNumber()`:从 1 开始的行号。缺少必填字段等不对应单行的错误返回 `0`; +- `what()`:包含路径、可用时的行号和中文诊断信息。 + +常见错误包括: + +| 配置片段 | 错误原因 | +| --- | --- | +| `format = "fp8"` | 当前只支持 `mxfp8` 和 `nvfp4`。 | +| `format = "mxfp8"` 与 `block_size = 16` | format 和 block size 不匹配。 | +| `format = "nvfp4"` 与 `scale_mode = "tensor"` | NVFP4 使用 FP32 global scale 加每个 16 元素 block 的 E4M3 local scale,只支持 block mode。 | +| `rounding = "nearest"` 与 `stochastic_seed = 1` | nearest 没有随机过程,seed 必须是 0。 | +| `output_type = "int8"` | 反量化输出仅支持 FP16、BF16、FP32。 | +| `target_gpu = ""` | 完整 app 的报告字段不能是空字符串。 | +| 同一字段写两次 | 配置语义不明确,解析器拒绝重复 key。 | + +## 配置模块与其他模块的边界 + +- 配置模块不读取 QDTENSOR 或 QDWGT;这属于 `tensor_io` 与 `quantized_io`。 +- `QuantizationConfig` 不承载矩阵形状、输入 dtype 或 quantized payload;这些数据分别属于 `TensorDesc`、`HostTensor` 与 `QuantizedTensor`。输入 dtype 由 QDTENSOR header 提供,且第一版只能是 FP16 或 FP32。 +- `DequantizationConfig::output_type` 不改变 QDWGT 的解释方式,只决定反量化后的 QDTENSOR 如何落盘;合法输出类型是 FP16、BF16 或 FP32。 +- `target_gpu` 是报告元数据;实际 CUDA 架构由 CMake preset 的 `QUANT_DEQUANT_CUDA_ARCHITECTURES` 控制。当前 RTX 4060 preset 使用 `89`。 + +这条边界使单独的 CPU reference、未来 CUDA pipeline、文件 I/O 与 app 编排都能重用同一份方向配置,而不会互相引入不必要的依赖。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/experiments.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/experiments.md" new file mode 100644 index 00000000..091bcc99 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/experiments.md" @@ -0,0 +1,218 @@ +# 生成实验矩阵与端到端批量验收 + +本项目把“生成数据”“运行完整 app”“位级单元测试”分成三个层次: + +- `scripts/generate_tensor.py`:生成合法的输入用途 QDTENSOR; +- `scripts/run_e2e_suite.py`:批量调用真实 CUDA app,检查产物与 JSON; +- `tests/`:通过 CTest 验证 codec、I/O、CPU reference 与 CUDA kernel 的细粒度正确性。 + +脚本不是 CTest 的替代品。它们覆盖真实命令行、文件路径、QDWGT 写读、反量化输出 +写读和 JSON 指标边界;位编码或单元素舍入错误仍应优先在 CTest 中定位。 + +## 生成单份输入矩阵 + +生成器只使用 Python 标准库,不需要 NumPy、CUDA 或 GPU。它写出的文件固定为 +QDTENSOR v1、little-endian、`tensor_role = input`,物理输入 dtype 可为 FP16 或 +FP32。 + +```shell +cd "02_quant_dequant/黄新颖" + +# 均匀分布 FP32,适合基本量化路径。 +python3 scripts/generate_tensor.py \ + --output outputs/inputs/uniform_512x513_fp32.qdtensor \ + --rows 512 --cols 513 --dtype fp32 \ + --distribution uniform --seed 20260917 + +# 标准正态分布 FP16,生成器会先采样再真实窄化为 binary16 payload。 +python3 scripts/generate_tensor.py \ + --output outputs/inputs/normal_512x513_fp16.qdtensor \ + --rows 512 --cols 513 --dtype fp16 \ + --distribution normal --seed 20260918 + +# 稀疏异常值:99% 附近为 N(0, 1),约 1% 元素为 ±32。 +python3 scripts/generate_tensor.py \ + --output outputs/inputs/outlier_512x513_fp32.qdtensor \ + --rows 512 --cols 513 --dtype fp32 \ + --distribution outlier --seed 20260919 \ + --outlier-probability 0.01 --outlier-magnitude 32 +``` + +生成成功时 stdout 输出一行 JSON,其中包含 shape、dtype、seed、元素数和真实文件 +字节数。相同的全部参数必定产生逐字节相同的输入文件。 + +### 三种分布的含义 + +| `--distribution` | 生成规则 | 主要观察点 | +| --- | --- | --- | +| `uniform` | 默认从 $[-1, 1]$ 均匀采样。 | 基本 scale、pack/unpack 与平均误差。 | +| `normal` | 默认从 $mathcal{N}(0, 1)$ 采样。 | 常规近零数值与格式 subnormal/normal 区域。 | +| `outlier` | 基础为 $mathcal{N}(0, 1)$,默认 1% 以概率替换为 $pm32$。 | 异常值如何拉大 block/tensor 的 scale,并影响小值误差。 | + +`--uniform-low`、`--uniform-high`、`--normal-mean`、`--normal-stddev`、 +`--outlier-probability` 和 `--outlier-magnitude` 都可调整。所有参数和值在写入前会 +检查有限性;若 FP16 无法表示某个生成值,脚本会明确失败而不是静默写入 Inf。 + +## 批量端到端 suite + +先构建 release app。性能数据应该来自 release,而不是带调试信息的 debug: + +```shell +cmake --preset rtx4060-release +cmake --build --preset rtx4060-release -j +``` + +最小 smoke suite 使用较小矩阵、一个真实 TOML、一个输入 dtype、一种分布和一轮正式 +运行,不做 warmup;适合确认命令行和 GPU 环境可用: + +```shell +python3 scripts/run_e2e_suite.py \ + --rows 128 --cols 129 \ + --input-dtypes fp32 \ + --distributions uniform \ + --config-names mxfp8_block_nearest_fp16 \ + --warmups 0 --repeats 1 \ + --output-dir outputs/e2e_smoke +``` + +`configs/` 中提交的是 **真实 app 配置**,而不是脚本临时写出的配置: + +- MXFP8:block/tensor、nearest/stochastic、FP16/BF16/FP32 输出,共 12 份; +- NVFP4:只允许 block、nearest/stochastic、FP16/BF16/FP32 输出,共 6 份; +- stochastic TOML 均显式写入 `stochastic_seed = 20260917`;nearest TOML 使用默认的 + seed 0。 + +默认 suite 使用三种分布、两种物理输入 dtype、全部 18 份配置、`1024 × 1025` 矩阵、 +零次额外功能运行和一次正式运行,因此会进行 +$3 \times 2 \times 18 \times (0 + 1) = 108$ 次完整 app 调用: + +```shell +python3 scripts/run_e2e_suite.py \ + --output-dir outputs/e2e_release +``` + +它会创建如下结构: + +```text +outputs/e2e_release/ +├── inputs/ # 六份固定 seed 的 QDTENSOR:3 分布 × 2 输入 dtype +├── runs/ +│ └── //// +│ ├── weights.qdwgt +│ ├── dequantized.qdtensor +│ └── report.json +└── summary.md # 正式 repeat 的均值、中位数和误差汇总 +``` + +若只希望跑某一格式或某一输入类型,可以缩小组合: + +```shell +# 仅 MXFP8 tensor/stochastic/BF16,FP32 正态输入;五轮正式重复用于观察波动。 +python3 scripts/run_e2e_suite.py \ + --config-names mxfp8_tensor_stochastic_bf16 \ + --input-dtypes fp32 \ + --distributions normal \ + --rows 2048 --cols 2049 \ + --warmups 2 --repeats 5 \ + --output-dir outputs/mxfp8_bf16_release + +# 仅针对异常值,检查 NVFP4 分层缩放的两种舍入和全部反量化输出类型。 +python3 scripts/run_e2e_suite.py \ + --formats nvfp4 \ + --distributions outlier \ + --input-dtypes fp16 fp32 \ + --output-dir outputs/nvfp4_outlier_release +``` + +## suite 校验的内容 + +每次 app 调用成功后,脚本读取 `report.json`,并与命令行规格、真实文件系统状态做 +交叉验证: + +- 输入 shape、输入 dtype、format、block size、scale mode、rounding、stochastic seed、 + 输出 dtype 和 `target_gpu` 必须与本次真实 TOML 一致; +- MXFP8 payload 必须是一个元素一字节,NVFP4 必须是两个元素一字节; +- local scale 数量必须等于 + $\mathrm{rows} \times \lceil \mathrm{cols}/\mathrm{block\_size} \rceil$; +- NVFP4 的 `global_scale_bytes` 必须为 4,MXFP8 必须为 0; +- JSON 声明的 QDWGT、反量化 QDTENSOR 文件大小必须等于实际文件大小; +- 压缩率、逻辑字节数、误差、kernel 时间与带宽必须满足 metrics 的公式和有限性约束; +- CUDA Event 得到 `0.0 ms` 时,带宽必须为 `null`;正时间时带宽必须为有限正数。 + +因此 suite 既能发现 app 失败,也能发现“app 虽然退出为 0、但产物/报告字段不自洽”的 +问题。 + +## 性能解释与独立 benchmark + +`summary.md` 汇总的是 app 返回的 CUDA Event kernel 时间,H2D、D2H、文件 I/O、 +host 分配、CUDA context 初始化均不在其中。默认全量 108 次运行的主要用途是功能覆盖: +它会确认真实 QDTENSOR、QDWGT、反量化文件和 JSON 报告彼此自洽。 + +`run_e2e_suite.py` 的一次 warmup/repeat 都会启动一个新的 `quant_dequant` 进程。因此 +它的 `--warmups` 只能表示“额外的端到端功能验证次数”,**不能**预热下一次 repeat 的 +CUDA stream、上下文或 device allocation。脚本在 `--warmups > 0` 时会打印这一提醒; +默认仍保持 `warmups = 0`、`repeats = 1`,避免将全配置功能验收误解为性能 benchmark。 + +正式 kernel 性能使用 `benchmarks/quant_dequant_bench`。它在**同一个进程**中只读取一次 +QDTENSOR 与 TOML,先执行不计入统计的 warmup,再执行正式 repeat,并报告 quant/dequant +的 min、mean、median、p95、max 及由 mean 推导的逻辑有效带宽: + +```shell +cd "02_quant_dequant/黄新颖" +cmake --build --preset rtx4060-release --target quant_dequant_bench -j + +# 建议先用较大且列数不整除 block_size 的 shape,例如 4096 x 4097。 +./build/rtx4060-release/benchmarks/quant_dequant_bench \ + --input outputs/inputs/normal_4096x4097_fp32.qdtensor \ + --config configs/mxfp8_block_nearest_fp32.toml \ + --warmups 10 --repeats 30 \ + --output outputs/benchmarks/mxfp8_block_normal_fp32.json +``` + +benchmark 每轮仍通过公共 profile pipeline 执行完整的 H2D、CUDA kernel 和 D2H,因而 +数值路径与 app 一致;其 CUDA Event 仍只测 kernel。当前 pipeline 会在每轮管理自己的 +stream 和 `thrust::device_vector`,但这些分配、拷贝和 I/O 都位于 Event 区间外。于是该 +工具消除了“每次性能样本都重启进程”的问题,却不把结果伪装成“已复用 device buffer 的 +极限吞吐”。后者可作为下一阶段专项优化。 + +### 批量性能配置遍历 + +`scripts/run_benchmark_suite.py` 负责自动生成一份输入、扫描 `configs/` 中真实 TOML、 +逐项调用 `quant_dequant_bench`,并校验每个 JSON 的输入、配置、warmup/repeat、分位数 +顺序与带宽字段。它不会写 QDWGT/QDTENSOR 中间产物;文件级正确性仍由 +`run_e2e_suite.py` 负责。 + +默认性能组合选择 `4096 × 4097`、FP32 normal 输入、`warmups = 10`、`repeats = 30`, +并只筛选 `output_type = fp32` 的六份 TOML。原因是当前反量化 kernel 先统一产生 FP32, +FP16/BF16 的物理窄化在随后 QDTENSOR I/O 层完成;把三种输出 TOML 都作为 kernel +benchmark 样本会造成重复工作。默认六份分别覆盖 MXFP8 的 block/tensor、nearest/ +stochastic,以及 NVFP4 的 block、nearest/stochastic。 + +```shell +cd "02_quant_dequant/黄新颖" +cmake --build --preset rtx4060-release --target quant_dequant_bench -j + +# 默认性能组合:6 份真实 configs/ TOML。 +python3 scripts/run_benchmark_suite.py \ + --output-dir outputs/benchmark_suite + +# 若要逐项性能记录全部 18 份提交的配置文件: +python3 scripts/run_benchmark_suite.py \ + --output-dtypes fp16 bf16 fp32 \ + --output-dir outputs/benchmark_suite_all_configs +``` + +输出结构为: + +```text +outputs/benchmark_suite/ +├── inputs/ +│ └── normal_fp32_4096x4097_seed20260917.qdtensor +├── runs/ +│ └── /benchmark.json +└── summary.md +``` + +若要从空的 `outputs/` 重新执行完整验收,先运行 `run_e2e_suite.py` 生成/验证全部文件 +路径,再运行上述 performance suite。前者默认 108 次完整 app 调用,后者默认 6 个 +同进程 benchmark;二者的报告不可混为同一统计口径。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/file_format.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/file_format.md" new file mode 100644 index 00000000..16c3eece --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/file_format.md" @@ -0,0 +1,242 @@ +# 二进制文件与日志格式 + +本文固定本项目输入张量、低精度权重、反量化张量和运行日志的物理格式。它与 [format_spec.md](format_spec.md) 分工如下:前者定义 MXFP8/NVFP4 的数值语义,本文定义这些数值如何写入文件。 + +第一版格式的目标是:文件可以独立读取、字段不依赖 C/C++ struct 的内存对齐、NVFP4 真正按两个元素每字节存储,并可在读取时检测常见的损坏或配置不匹配。 + +## 通用规则 + +- 文件格式版本为 `1`。所有多字节整数与 FP16/BF16/FP32 bit pattern 均为 **little-endian**。 +- 写入器必须逐字段序列化,不能直接对含 padding 的 C/C++ struct 使用 `fwrite`。读取器也必须按本文 offset 逐字段解析。 +- 本项目只写入 IEEE 754 二进制浮点 bit pattern。第一版不接受大端文件;header 的 `byte_order` 不是 `1` 时,读取器必须报错。 +- `num_rows`、`num_cols` 和 `element_count` 均为无符号 64 位整数,且必须满足 `element_count = num_rows * num_cols`,乘法不得溢出。 +- 张量 payload 均按 row-major 连续排列,不写入每行 padding。线性下标为 `n = row * num_cols + col`。 +- 所有保留字段、对齐 padding 都必须写零;第一版读取器忽略其数值,但必须检查已知 section 不重叠且位于文件范围内。 + +## 枚举编码 + +header 只存紧凑的枚举值;对应的配置文本字符串由程序在读写边界转换。 + +| 字段 | 数值 | 含义 | +| --- | ---: | --- | +| `byte_order` | `1` | little-endian。 | +| `dtype` | `1` | FP16。 | +| `dtype` | `2` | BF16。 | +| `dtype` | `3` | FP32。 | +| `tensor_role` | `0` | 输入张量。 | +| `tensor_role` | `1` | 反量化输出张量。 | +| `format` | `1` | MXFP8-E4M3 + E8M0。 | +| `format` | `2` | NVFP4-E2M1 + E4M3 + FP32 global scale。 | +| `scale_mode` | `1` | `tensor`;仅 MXFP8 合法。 | +| `scale_mode` | `2` | `block`。 | +| `rounding` | `1` | `nearest`(RNE)。 | +| `rounding` | `2` | `stochastic`。 | +| `local_scale_type` | `1` | E8M0,一个字节一个 scale。 | +| `local_scale_type` | `2` | E4M3,一个字节一个 scale。 | +| `scale_layout` | `1` | rowwise block 顺序;tensor 模式也使用此值。 | + +## 普通张量文件:`QDTENSOR` + +输入张量和反量化张量使用同一种容器。文件开头的 **64 字节固定 header** 的 `0--7` 字节必须为 ASCII `QDTENSOR`;header 之后是连续的元素数据。 + +### header(64 字节) + +| Offset | 大小 | 字段 | 规则 | +| ---: | ---: | --- | --- | +| `0` | 8 | `magic` | ASCII `QDTENSOR`。 | +| `8` | 2 | `version` | `uint16`,值为 `1`。 | +| `10` | 2 | `header_bytes` | `uint16`,值为 `64`。 | +| `12` | 1 | `byte_order` | 值为 `1`。 | +| `13` | 1 | `dtype` | 由 `tensor_role` 决定,见下方约束表。 | +| `14` | 1 | `tensor_role` | 输入为 `0`,反量化输出为 `1`。 | +| `15` | 1 | `reserved0` | 写零。 | +| `16` | 8 | `num_rows` | `uint64`。 | +| `24` | 8 | `num_cols` | `uint64`。 | +| `32` | 8 | `element_count` | `uint64`,必须等于行数乘列数。 | +| `40` | 8 | `data_offset` | `uint64`;版本 1 固定为 `64`。 | +| `48` | 8 | `data_bytes` | `uint64`,必须等于元素数乘 dtype 字节数。 | +| `56` | 8 | `reserved1` | 写零。 | + +数据区从 `data_offset` 开始,存储 `element_count` 个连续元素:FP16 与 BF16 每元素 2 字节,FP32 每元素 4 字节。反量化输出文件必须把 `tensor_role` 写为 `1`,但其 payload 仍是纯 row-major 数值数组;解释该文件不需要知道原量化格式。 + +### `tensor_role` 与 `dtype` 约束 + +虽然 `dtype` 枚举定义了 FP16、BF16 和 FP32 三种编码,**具体允许哪一种由 +`tensor_role` 决定**。这是 QDTENSOR v1 的方向性约束,不是配置文件中的可选项。 + +| `tensor_role` | 合法 `dtype` | 用途 | +| --- | --- | --- | +| `input = 0` | FP16、FP32 | 待量化的原始矩阵。读取后会扩展为 host FP32,供 scale 计算和元素编码使用。BF16 输入不属于第一版需求,读取器必须拒绝。 | +| `dequantized_output = 1` | FP16、BF16、FP32 | 已反量化的输出矩阵。反量化计算先得到 host FP32,写入器再按目标 `dtype` 转换为对应物理 payload。 | + +QDWGT header 中的 `source_dtype` 记录其来源输入文件的物理类型,因此同样**只允许 +FP16 或 FP32**;它不记录 `output_type`。`output_type` 属于反量化操作,可让同一份 +QDWGT 分别写出 FP16、BF16 或 FP32 的 QDTENSOR 输出。 + +### 例子 + +一个形状为 `2 × 3` 的 FP32 输入张量:`data_offset = 64`、`data_bytes = 24`,文件总大小为 `88` 字节。payload 顺序为 `x[0,0]`、`x[0,1]`、`x[0,2]`、`x[1,0]`、`x[1,1]`、`x[1,2]`。 + +## 低精度权重文件:`QDWGT` + +量化输出使用独立容器。其 **128 字节固定 header** 的 `0--7` 字节为 ASCII `QDWGT`,后面补 3 个 `0x00` 到 8 字节;header 之后依次是 packed payload、可选零 padding 与局部 scale 数组。 + +文件的 section 排列是: + +```text +0 128 payload_end local_scale_offset ++-------------------------+-------------------+-----------------+------------------+ +| 固定 header(128 字节) | packed payload | 零填充(0--7 B) | local scale 数组 | ++-------------------------+-------------------+-----------------+------------------+ +``` + +`payload_offset`、`payload_bytes` 与 `local_scale_offset` 始终以 header 中记录的数值为准。版本 1 的写入器固定 `payload_offset = 128`,并将 local scale section 向上对齐到 8 字节;使用 offset 而非隐式推导,是为了让后续版本可以扩展 header 或增加 section。 + +### header(128 字节) + +| Offset | 大小 | 字段 | 规则 | +| ---: | ---: | --- | --- | +| `0` | 8 | `magic` | ASCII `QDWGT`,后随 3 个零字节。 | +| `8` | 2 | `version` | `uint16`,值为 `1`。 | +| `10` | 2 | `header_bytes` | `uint16`,值为 `128`。 | +| `12` | 1 | `byte_order` | 值为 `1`。 | +| `13` | 1 | `format` | `1` 为 MXFP8,`2` 为 NVFP4。 | +| `14` | 1 | `scale_mode` | `1` 为 tensor(仅 MXFP8 合法),`2` 为 block。 | +| `15` | 1 | `rounding` | `1` 为 nearest,`2` 为 stochastic。 | +| `16` | 1 | `source_dtype` | 原输入的 FP16 或 FP32。 | +| `17` | 1 | `payload_element_bits` | MXFP8 为 `8`,NVFP4 为 `4`。 | +| `18` | 1 | `local_scale_type` | MXFP8 为 E8M0,NVFP4 为 E4M3。 | +| `19` | 1 | `scale_layout` | 版本 1 固定为 `1`,rowwise。 | +| `20` | 4 | `block_size` | `uint32`;MXFP8 为 32,NVFP4 为 16。 | +| `24` | 8 | `num_rows` | `uint64`。 | +| `32` | 8 | `num_cols` | `uint64`。 | +| `40` | 8 | `element_count` | `uint64`,必须等于行数乘列数。 | +| `48` | 8 | `payload_offset` | `uint64`;版本 1 写入器为 `128`。 | +| `56` | 8 | `payload_bytes` | `uint64`,见下文。 | +| `64` | 8 | `local_scale_offset` | `uint64`,tensor/block 均存在局部 scale section。 | +| `72` | 8 | `local_scale_count` | `uint64`,见下文。 | +| `80` | 8 | `local_scale_bytes` | `uint64`;版本 1 必须等于 `local_scale_count`。 | +| `88` | 4 | `global_scale` | IEEE 754 `float32` decode scale;仅 NVFP4 使用。MXFP8 必须写 `1.0f`。 | +| `92` | 4 | `flags` | bit 0 表示最后 payload 字节的高 nibble 已清零;其余 bit 为 0。MXFP8 的 `flags` 必须为 0。 | +| `96` | 8 | `stochastic_seed` | `uint64`;`rounding = nearest` 时写 0。 | +| `104` | 24 | `reserved` | 全部写零。 | + +### payload 与 scale section + +MXFP8 的 `payload_bytes` 等于 `element_count`;第 `n` 个字节就是线性元素 `n` 的 E4M3 编码。 + +NVFP4 的 `payload_bytes` 等于 `ceil(element_count / 2)`。线性偶数下标在低 nibble,奇数下标在高 nibble: + +$$ +\operatorname{byte}[n/2] = +\begin{cases} +(\operatorname{byte}[n/2]\ \&\ 0xf0)\ |\ q_n, & n\text{ 为偶数} \\ +(\operatorname{byte}[n/2]\ \&\ 0x0f)\ |\ (q_n \ll 4), & n\text{ 为奇数}. +\end{cases} +$$ + +元素总数为奇数时,最后一个字节的高 nibble 必须为零,且 `flags & 1` 必须为 `1`。元素总数为偶数时,`flags & 1` 必须为 `0`。这使读取器可以检测尾 nibble 未初始化的问题。 + +局部 scale section 直接存储 one-byte scale code,顺序与 rowwise block 顺序一致。第 `b` 个 scale 对应的 block 为: + +```text +row = b / ceil(num_cols / block_size) +block_in_row = b % ceil(num_cols / block_size) +col_begin = block_in_row * block_size +``` + +scale 个数规则如下: + +| 格式与模式 | `local_scale_count` | scale 内容 | +| --- | ---: | --- | +| MXFP8 + `block` | `num_rows * ceil(num_cols / 32)` | 每个 block 一个 E8M0 字节。 | +| MXFP8 + `tensor` | `1` | 整张张量一个 E8M0 字节。 | +| NVFP4 + `block` | `num_rows * ceil(num_cols / 16)` | 每个 block 一个 E4M3 字节。 | + +NVFP4 的 `global_scale` 是 `format_spec.md` 中定义的 FP32 **解码方向** scale;MXFP8 没有这一层 scale,因此固定写入 `1.0f`。`format = NVFP4` 与 `scale_mode = tensor` 是非法 header 组合,读取器必须在使用任何 section 前拒绝它。其余量化语义,包括 E8M0 的 NaN 和 E4M3 的饱和,均以 [format_spec.md](format_spec.md) 为准。 + +### 写入与读取校验 + +写入量化权重时必须校验: + +- `format`、`payload_element_bits`、`local_scale_type` 和 `block_size` 的组合符合格式规范; +- `format = NVFP4` 时 `scale_mode` 必须为 `block`; +- `payload_bytes` 与 `element_count` 的关系正确; +- `local_scale_bytes = local_scale_count`,且 scale count 与 `scale_mode` 一致; +- `payload_offset >= header_bytes`,各 section 不重叠,`local_scale_offset` 满足 8 字节对齐; +- NVFP4 的 `global_scale` 为有限正数;MXFP8 的值为 `1.0f`; +- NVFP4 尾 nibble 与 `flags` 一致。 + +读取器不得根据本机 struct 的 `sizeof`、当前配置文件或文件名推断格式;必须只相信通过校验的 header。若版本号、magic、字段组合或文件长度不正确,应返回带原因的错误,不应启动 CUDA kernel。 + +## 误差与性能日志 + +日志使用 UTF-8 JSON 文件,每次命令执行覆盖指定的 `--report` 路径并写入一个 JSON object。JSON 比 CSV 更适合同时记录嵌套配置、文件大小和多项性能指标;后续实验汇总脚本可读取多个 JSON 文件。 + +必填结构如下。数值必须以 JSON number 写入,不能把浮点数转成字符串。 + +```json +{ + "schema_version": 1, + "input": { + "rows": 1024, + "cols": 1024, + "dtype": "fp32" + }, + "config": { + "format": "mxfp8", + "block_size": 32, + "scale_mode": "block", + "rounding": "nearest", + "stochastic_seed": 0, + "output_type": "fp16", + "target_gpu": "RTX 4060" + }, + "artifacts": { + "quantized_file_bytes": 1081472, + "dequantized_file_bytes": 2097216, + "payload_bytes": 1048576, + "local_scale_bytes": 32768, + "global_scale_bytes": 0 + }, + "error": { + "max_abs": 0.0, + "mae": 0.0, + "mse": 0.0 + }, + "compression": { + "input_payload_bytes": 4194304, + "logical_quantized_bytes": 1081344, + "logical_compression_ratio": 3.8788, + "on_disk_compression_ratio": 3.8783 + }, + "performance": { + "quant_kernel_ms": null, + "dequant_kernel_ms": null, + "quant_effective_bandwidth_gbps": null, + "dequant_effective_bandwidth_gbps": null + } +} +``` + +`max_abs`、`mae` 与 `mse` 均以原始输入转换得到的 FP32 值为参考。`logical_quantized_bytes` 只计低精度 payload、局部 scale 和 NVFP4 的 4 字节 global scale,不包括 header 和对齐 padding;`on_disk_compression_ratio` 则用完整量化文件大小计算。因此: + +$$ +\operatorname{logical\_compression\_ratio} += \frac{\operatorname{input\_payload\_bytes}} + {\operatorname{logical\_quantized\_bytes}}. +$$ + +有效带宽使用十进制 GB/s,并且只统计 CUDA kernel 的逻辑读写量,不包含文件 I/O、CPU/GPU 拷贝、内存分配、warmup 或 CUDA 初始化: + +- 量化的逻辑字节数 = 输入 payload + 量化 payload + 局部 scale + NVFP4 global scale。 +- 反量化的逻辑字节数 = 量化 payload + 局部 scale + NVFP4 global scale + 反量化输出 payload。 +- 带宽 = 逻辑字节数 / kernel 时间;当时间以毫秒记录时,`GB/s = bytes / (ms * 1e6)`。 + +若量化与反量化未实际执行 CUDA kernel(例如前期 CPU reference 测试),对应时间与带宽字段写 `null`,不能伪造为 `0.0`。若 CUDA Event 因有限分辨率将一个极短 kernel 测为 `0.0 ms`,时间字段保留 `0.0`,但对应带宽字段写 `null`,避免除以零伪造无限带宽。 + +## 格式演进 + +- 增加字段、压缩方式或新的 format profile 时,必须提升 `version`,并保留旧版本读取逻辑或明确拒绝旧版本。 +- 不能在不升版本的情况下改变枚举值、header offset、payload nibble 顺序、scale 顺序或 `global_scale` 的解码方向。 +- 写入器只产生本文定义的版本 1;读取器可以先只支持版本 1,并在遇到其他版本时明确报错。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_codecs.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_codecs.md" new file mode 100644 index 00000000..c5a3aecf --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_codecs.md" @@ -0,0 +1,401 @@ +# `src/formats` 编码器原理与源码导读 + +`src/formats/` 是项目的**单元素数值编码层**。它不负责遍历矩阵、分配 GPU +内存、计算 CUDA grid,也不负责 QDWGT 文件读写;它只回答三个问题: + +1. 一个 FP32 数如何编码为 E4M3 或 E2M1 的 bit pattern; +2. 一个低精度 bit pattern 如何恢复为 FP32; +3. scale 如何编码、解码,以及如何与元素编码组合。 + +这三个头文件都是 header-only,并把关键函数标为 `__host__ __device__`。因此 +CPU reference 和 CUDA kernel 调用的是同一套规则,才能逐字节比较 payload 与 scale。 + +| 源文件 | 角色 | 主要内容 | +| --- | --- | --- | +| `fp32_utils.cuh` | 公共基础 | FP32 按位读取、指数分析、精确 2 的幂、RNE 辅助与随机数收窄。 | +| `mxfp8_codec.cuh` | MXFP8 codec | E4M3 元素、E8M0 scale、MXFP8 单元素量化/反量化、SplitMix64 随机数。 | +| `nvfp4_codec.cuh` | NVFP4 codec | E2M1 nibble、双层 scale、NVFP4 单元素量化/反量化、两个 nibble 的打包。 | + +数值格式的项目级规范、block 布局和 I/O 约定见 +[format_spec.md](format_spec.md);本文着重解释源码中每条数值路径为何成立。 + +## 读 codec 前需要的三个概念 + +### 位字段、bias 与隐含 1 + +浮点编码通常把一个值拆成符号位、指数位和 fraction(尾数存储位)。以一般形式的 +`S E...E M...M` 为例,`S` 决定正负,`E` 是编码后的指数,`M` 是 fraction。 + +- **biased exponent(偏置指数)**:指数位是无符号整数,不能直接存负指数。因此保存 + $E=e+\mathrm{bias}$;$e$ 才是数学上的 unbiased exponent(真实指数)。 +- **normal(规格化数)**:当指数位不为零,二进制有效数在 $[1,2)$,最高的 `1` 对所有 + normal 数都相同,因此不存它。数值形如 $(-1)^S(1+M/2^m)2^e$。 +- **subnormal(非规格化数)**:指数位为零时,隐含的 `1` 被取消,数值从零开始均匀排布。 + 这样能填补零和最小 normal 之间的空隙,但精度低于 normal 数。 + +这里的 `M` 既可能指“存储的无符号整数”,也可能泛指 fraction field;代码会用 +`mantissa` 表示它。**magnitude code(幅值码)** 则是去掉符号位后剩下的所有 bit: +它只描述非负幅值,最后再拼回正负号。 + +### 为什么先缩放再编码 + +低精度 FP 格式不是 INT quantization 的 `round(x / s) + z`。它仍然保留指数和 +fraction,元素编码器本身就是一张非均匀的浮点数表。scale 的作用是把一个 block 的 +动态范围移动到该表能容纳的范围: + +$$ +z_i = x_i / s, \qquad p_i = Q_{\mathrm{lowp}}(z_i), \qquad +\hat{x}_i = \operatorname{decode}(p_i)\,s. +$$ + +这里 $z_i$ 是缩放后的 FP32 临时值,$p_i$ 是真正写入 payload 的低精度 code;两者 +不是同一种东西。FP8/FP4 的量化依旧需要 scale,是因为单个低精度浮点数的指数范围远小于 +FP32,且有限个 fraction bit 决定了相对精度。 + +### 两种舍入目标不同 + +- `nearest` 调用 RNE(round-to-nearest, ties-to-even):选择距离最近的可表示值;正好 + 在中点时选择最低有效 bit 为偶数的 code。它通常降低单次误差。 +- `stochastic` 先找相邻下界 $l$ 和上界 $h$,再以 + $P(h)=(|x|-l)/(h-l)$ 的概率选上界。它的期望值等于原值,长期统计偏差较小,但某一 + 次实验的 MAE/MSE 往往大于 RNE。 + +两个模式对精确可表示值、零和已超出最大值的饱和输入都不需要随机数。 + +## `fp32_utils.cuh`:让 host 与 device 以同一方式理解 FP32 + +### 重要接口:输入、输出与用途 + +| 接口 | 输入 | 输出 | 用途 | +| --- | --- | --- | --- | +| `float_to_bits(value)` | 一个 FP32 `value` | 同 bit pattern 的 `uint32_t` | 无损读取 FP32 的符号、指数和 fraction。 | +| `bits_to_float(bits)` | 一个 IEEE FP32 bit pattern | 同 bit pattern 的 FP32 | 从构造出的 bit 恢复 FP32,例如固定 NaN、`-0` 和 $2^{-127}$。 | +| `floor_log2_positive(x)` | 正且有限的 FP32 | 整数 $\lfloor\log_2(x)\rfloor$ | 找 normal 编码所需的真实指数。 | +| `power_of_two(e)` | 整数指数 `e` | 精确的 FP32 $2^e$ | 让 codec 不依赖近似的 `powf`,并正确覆盖 $2^{-127}$。 | +| `round_to_nearest_even_nonnegative(x)` | 范围受调用方限制的非负有限 FP32 | RNE 后的 `uint32_t` | 对 subnormal 单位或 mantissa 格子做整数舍入。 | +| `clamp_uniform_random(u)` | 候选随机 FP32 | $[0,1)$ 内的 FP32 | 为 stochastic rounding 修正 NaN、负数和 `1.0F`。 | + +### FP32 的真实 bit 布局 + +IEEE 754 binary32 的 32 bit 为 `S EEEEEEEE M...M`:1 个符号位、8 个指数位、23 个 +fraction 位,bias 为 127。代码以 `uint32_t` 掩码读取它们: + +| 常量 | bit pattern | 含义 | +| --- | --- | --- | +| `kSignMask` | `0x80000000` | bit 31;也能区分 `+0` 和 `-0`。 | +| `kExponentMask` | `0x7f800000` | bits `[30:23]`;全 1 表示 NaN 或 Inf。 | +| `kFractionMask` | `0x007fffff` | bits `[22:0]`。 | +| `kCanonicalQuietNaNBits` | `0x7fc00000` | 项目固定的 quiet NaN,保证 CPU/GPU 一致。 | + +`float_to_bits()` 和 `bits_to_float()` 是**按位重解释**,不是数值转换:在 device 使用 +CUDA intrinsic,在 host 用 `memcpy` 避免 strict-aliasing 未定义行为。因此 NaN payload +和 `-0` 的符号都不会丢失。 + +`is_finite()` 不调用不同平台可能实现不同的数学库,而是直接检查指数是否全 1。 +`absolute_value()` 只是清除符号位;这比调用 `fabsf` 更明确地保留 NaN 的其他 bit。 + +### 为什么 `floor_log2_positive()` 要特别处理 subnormal + +normal FP32 的真实指数就是 `exponent_field - 127`。但 subnormal 的 exponent field +为零,不能机械地得到 $-127$:它的值是 +$\mathrm{fraction}\times2^{-149}$。因此函数找到 fraction 的最高置位 bit:若该 bit +位于 `k`,则 $\lfloor\log_2(x)\rfloor=k-149$。 + +例如最小 FP32 subnormal 的 fraction 是 1,因此得到 $0-149=-149$;值为 +$2^{-127}$ 的 fraction 最高位是 22,因此得到 $22-149=-127$。 + +`power_of_two(exponent)` 是反方向的精确构造。普通 $2^e$ 可直接写 exponent field, +但 $2^{-127}$ 是 FP32 subnormal,必须特别构造 `0x00400000`;这正是 E8M0 最小 +scale 解码时需要的边界。 + +### RNE 和随机数的公共辅助函数 + +`round_to_nearest_even_nonnegative()` 将一个小的非负数拆成整数部分和小数部分: + +- 小数大于 `0.5`,向上; +- 小数小于 `0.5`,向下; +- 恰好为 `0.5`,仅当截断结果是奇数时向上,使结果为偶数。 + +它用于“以最小 subnormal 为单位”的整数舍入和 mantissa 舍入,不是任意范围 FP32 的 +通用整数转换器。 + +`clamp_uniform_random()` 则把调用者给出的候选随机数收窄至 $[0,1)$。`1.0F` 会映射为 +`kLargestBelowOne = 1-2^{-24}`,所以 stochastic rounding 不会意外以概率 1 选择上界。 + +## `mxfp8_codec.cuh`:E4M3、E8M0 与 MXFP8 + +### 重要接口:输入、输出与用途 + +| 接口 | 输入 | 输出 | 用途 | +| --- | --- | --- | --- | +| `decode_e4m3(encoded)` | 完整 E4M3 byte `S EEEE MMM` | FP32 | 解释一项 MXFP8 payload。 | +| `find_e4m3_lower_magnitude_code(magnitude)` | 有限 $[0,448)$ 的非负 FP32 幅值 | 不含符号的 7-bit `EEEE MMM` code | 找到不超过输入的下界,供 RNE/SR 选择相邻端点。 | +| `encode_e4m3(value, rounding, u)` | FP32、舍入模式、SR 随机数 | 完整 E4M3 byte | 将缩放后的元素编码,并处理 NaN、零和饱和。 | +| `decode_e8m0(encoded)` | 一个 E8M0 scale byte | 正 FP32 scale 或 NaN | 将 scale code 解释为 $2^{e-127}$。 | +| `encode_e8m0_round_up(scale)` | 非负 FP32 所需 scale | E8M0 scale byte | 向上取 2 的幂,避免 MXFP8 最大元素溢出。 | +| `compute_mxfp8_scale_code(amax)` | 一个 block 或整张张量的非负 `amax` | E8M0 scale byte | 把范围 $amax$ 转为可直接使用的 MXFP8 scale。 | +| `encode_mxfp8_element(value, scale, rounding, u)` | 原始 FP32 元素、E8M0 scale byte | E4M3 payload byte | 组合“除 scale + E4M3 编码”。 | +| `decode_mxfp8_element(payload, scale)` | E4M3 payload、E8M0 scale byte | FP32 | 组合“解码 E4M3 + 乘 scale”。 | + +### E4M3 元素表 + +E4M3 payload 是完整的一个 byte,布局为 `S EEEE MMM`,指数 bias 为 7。 + +| 条件 | 解码公式 | 关键点 | +| --- | --- | --- | +| `E = 0, M = 0` | 带符号零 | decoder 保留输入文件中的 `-0`。 | +| `E = 0, M > 0` | $(-1)^S M \times 2^{-9}$ | subnormal,间距固定为 $2^{-9}$。 | +| `1 ≤ E ≤ 15` 且不是 NaN | $(-1)^S(1+M/8)2^{E-7}$ | normal,具有隐含 `1`。 | +| `E = 15, M = 7` | NaN | `0x7f` 与 `0xff` 均是 NaN。 | + +E4M3 不保留 Inf:`E=15,M=6` 是最大有限值 `448`,code 为 `0x7e`;再大的有限值和 +`±Inf` 都饱和到同符号的 `±448`。最小 normal 是 $2^{-6}$,最小 subnormal 是 +$2^{-9}$。后者来自 $M=1$ 时的 $1\times2^{-9}$,而不是 bias 直接计算出的值。 + +### `decode_e4m3()`:bit field 到 FP32 + +该函数先拆出 `negative`、`exponent_field` 和 `mantissa`: + +1. 若 `E=15,M=7`,返回固定的 `fp32::canonical_quiet_nan()`;FP32 有确定的 NaN + 表示,后续浮点运算也能正常传播它。 +2. 若 `E=0,M=0`,用 `signed_zero(negative)` 恢复 `+0` 或 `-0`。 +3. 若 `E=0`,计算 `M * kE4M3MinSubnormal`。 +4. 否则计算 `(8 + M) * 2^(E - 10)`。它与 + $(1+M/8)2^{E-7}$ 完全等价,只是整数形式适合代码。 + +### `find_e4m3_lower_magnitude_code()`:为何先找下界 + +**输入**是一个已取绝对值的 FP32 `magnitude`;调用方保证它有限且落在 $[0,448)$。 +**输出**是一个不带符号位的 `uint8_t`,范围为 `0x00` 到 `0x7e`。将它传给 +`decode_e4m3()` 后,得到的值一定不大于输入幅值,且它是满足该条件的最大 E4M3 值。 + +**用途**不是直接完成量化,而是先定位相邻端点。RNE 需要该下界和下一 code 的中点; +stochastic rounding 需要它们之间的距离来计算选上界概率。调用者最后才把原输入的符号位 +拼到这个 magnitude code 上。 + +编码器需要知道输入夹在哪两个相邻值之间。例如 1.30 的相邻 E4M3 值是 1.25 和 1.375。 +RNE 要比较它们的中点;stochastic 要计算选上界的概率。因此 helper 返回**不超过输入的 +最大幅值码**,再由 `lower_code + 1` 获得上界。 + +- 若幅值小于 $2^{-6}$,它在 subnormal 区:最小步长为 $2^{-9}$,所以 + `magnitude * 512` 就是“有多少个 subnormal 单位”。`float -> uint32_t` 对非负数截断, + 等价于向下取整,得到最大的 $M$ 使 $M \times 2^{-9}\leq |x|$。 +- 否则先用 `floor_log2_positive()` 得到真实指数 $e$,然后算 + `normalized = magnitude / 2^e`。它一定在 $[1,2)$;`(normalized - 1) * 8` 是 + 三个 mantissa bit 对应的 8 个区间编号,截断后得到下界 `M`。 +- 最后把 `E=e+7` 左移 3 bit,并与 `M` 按位或,得到不含符号的 `EEEE MMM`。 + +这个函数只接收 $[0,448)$ 的有限非负数,所以不会返回 NaN code `0x7f`。 + +### `encode_e4m3_rne_sat()`:RNE 编码完整流程 + +1. 先从原 FP32 取符号位;对 NaN 返回 canonical `0x7f`,对 Inf 返回同符号的最大有限 + code。 +2. 取幅值。`+0` 和 `-0` 都写为 `0x00`,使项目自己的量化输出只有一种零的 bit + pattern;decoder 仍兼容外部文件中的 `0x80`。 +3. 幅值不小于 448 时饱和到 `0x7e` 或 `0xfe`。 +4. 对 subnormal 区,计算 `magnitude * 512` 并 RNE 到整数单位。结果 8 会自然变成 + `E=1,M=0`,即最小 normal,不会错误留在 subnormal。 +5. 对 normal 区,将 $[1,2)$ 的 fraction 乘 8 并 RNE。若 mantissa 从 7 进位为 8, + 清零 mantissa 并给真实指数加一。 +6. 最后检查指数进位不能落入 E4M3 的 NaN 码 `E=15,M=7`;这种有限溢出仍饱和在 + `E=15,M=6`。 + +### `encode_e4m3_stochastic_sat()`:与 RNE 的差别 + +前面的 NaN、零和饱和处理相同;它只改变“严格夹在两个有限 codebook 值之间”的情况。 +假设下界为 $l$、上界为 $h$,则计算 +$p=(|x|-l)/(h-l)$。随机数小于 $p$ 选择上界,否则选择下界。因此 +$\mathbb{E}[Q(x)]=x$;符号位最后原样拼回。 + +`encode_e4m3()` 只是根据 `RoundingMode` 分派到上面两个函数;`kUnknown` 的安全回退为 +RNE,但上层配置校验本来就应拒绝未知模式。 + +### E8M0:为什么 scale 只能是 2 的幂 + +E8M0 的 byte 不含符号和 mantissa,只有 8 bit 编码指数。有限 code $e\in[0,254]$ 解码为 +$2^{e-127}$,`0xff` 是 NaN;它没有零也没有 Inf。 + +这意味着 scale 只能是 2 的整数次幂。好处是它的表示不额外引入 mantissa 误差,而且 +乘除 scale 在二进制浮点中通常只是指数移动;代价是范围会向上取整,不能恰好贴住 +block 的 `amax`。 + +`encode_e8m0_round_up(required_scale)` 的目标不是“最接近”,而是 +$\lceil\log_2(\mathrm{required\_scale})\rceil$。它先使用 FP32 bit 分析判断输入是否 +恰是 2 的幂:是则不加一,不是则在 `floor_log2` 后加一;再加 bias 127 并夹在 `[0,254]`。 +向上取整保证 scale 不会太小。 + +`compute_mxfp8_scale_code(amax)` 把这个规则接到格式范围上: + +$$ +s=\operatorname{E8M0\_UP}(amax / 448). +$$ + +于是 $amax/s\leq448$,最大元素不会在 E4M3 编码时因 scale 偏小而溢出。`amax=0` 时 +写 `0x00`,它表示最小有限 scale $2^{-127}$;不是数学零,但零 payload 乘它后仍为零。 + +### MXFP8 单元素组合接口 + +`encode_mxfp8_element(value, scale_code, rounding, u)` 先 `decode_e8m0(scale_code)` 得到 +scale,再编码 $z=value/s$ 为 E4M3。若 scale code 是 E8M0 NaN,则返回 E4M3 canonical +NaN,避免继续做无意义除法。 + +`decode_mxfp8_element(payload, scale_code)` 正好反向执行: + +$$ +\hat{x}=\operatorname{decode\_e4m3}(payload) +\times\operatorname{decode\_e8m0}(scale\_code). +$$ + +在 block mode 中上层为每个 rowwise 32 元素 block 传不同的 `scale_code`;在 tensor mode +中全部元素传同一个 code。codec 本身不关心 scale 的索引来自哪里。 + +### 可复现的 stochastic 随机数 + +`mxfp8_stochastic_uniform_for_element(seed, linear_index)` 使用无状态 SplitMix64: +输入只由配置 seed 和 row-major 全局下标决定,再取混合结果高 24 bit 乘 $2^{-24}$。 + +因此 CPU 即便按顺序循环、GPU 即便以 persistent grid-stride 顺序处理,两边同一个元素都 +得到同一个 $u\in[0,1)$。NVFP4 的 +`nvfp4_stochastic_uniform_for_element()` 直接复用这个映射,保证两个格式的随机规则一致。 + +## `nvfp4_codec.cuh`:E2M1 与双层缩放 + +### 重要接口:输入、输出与用途 + +| 接口 | 输入 | 输出 | 用途 | +| --- | --- | --- | --- | +| `decode_e2m1(encoded)` | 低 4 bit 的 E2M1 nibble | FP32 | 解码一个 NVFP4 payload 元素。 | +| `find_e2m1_lower_magnitude_code(magnitude)` | 有限 $[0,6)$ 的非负 FP32 幅值 | 不含符号的 3-bit `EE M` code | 找 E2M1 下界,供两种舍入复用。 | +| `encode_e2m1(value, rounding, u)` | 已缩放 FP32、舍入模式、SR 随机数 | 高 4 bit 为零的 E2M1 nibble | 对单元素进行 FP4 数值编码。 | +| `compute_nvfp4_global_scale(tensor_amax)` | 整张张量的非负 `amax` | FP32 global decode scale | 建立全张量范围的粗粒度缩放。 | +| `compute_nvfp4_local_scale_code(block_amax, global_scale)` | 16 元素 block 的 `amax`、global scale | E4M3 local-scale byte | 让该 block 的 E2M1 范围尽量用满。 | +| `encode_nvfp4_element(value, local, global, rounding, u)` | 原元素、E4M3 local code、FP32 global scale | E2M1 nibble | 组合两层缩放与 E2M1 编码。 | +| `decode_nvfp4_element(payload, local, global)` | E2M1 nibble、E4M3 local code、FP32 global scale | FP32 | 组合三项,得到反量化结果。 | +| `pack_e2m1_nibbles(low, high)` | 两个 E2M1 nibble | 一个 `uint8_t` | 将两个 4-bit 元素真正压缩为一个字节。 | + +### E2M1 nibble 的完整 codebook + +一个 E2M1 payload 是 4 bit `S EE M`,其中 `S` 是 bit 3,`EE` 是两位指数,`M` 是一位 +mantissa,bias 为 1。去掉符号后的所有八种 code 都是有限值: + +| 幅值码 `EE M` | 数值 | 分类 | +| --- | ---: | --- | +| `000` | 0 | 零 | +| `001` | 0.5 | subnormal | +| `010` | 1 | 最小 normal | +| `011` | 1.5 | normal | +| `100` | 2 | normal | +| `101` | 3 | normal | +| `110` | 4 | normal | +| `111` | 6 | 最大有限值 | + +当 `EE=0`,没有隐含 1,`M=1` 得到 $0.5$;当 `EE>0`,公式为 +$(1+M/2)2^{EE-1}$。E2M1 没有 NaN/Inf 保留码,所以完整量化 pipeline 在进入 codec +之前拒绝原始 NaN/Inf。 + +`decode_e2m1()` 先以 `decode_e2m1_magnitude()` 查上述 codebook,再恢复符号;它保留 +外部 nibble 中的 `-0`。`find_e2m1_lower_magnitude_code()` 直接按表中边界比较。这里只有 +8 个值,查表式分支比套用通用指数公式更清晰,也能准确处理 0.5 到 1 的边界。 + +### E2M1 的 RNE 与 stochastic 编码 + +`encode_e2m1_rne_sat()` 与 E4M3 的总体策略相同,但相邻间距不再规则: + +- 非零有限幅值大于等于 6 时饱和为同符号 `±6`; +- 在下界/上界间算中点;正好中点时检查 `lower_code & 1` 来实现 ties-to-even; +- NaN 在这个孤立的单元素接口中规范化为正零,因为 E2M1 没有 NaN code;完整 pipeline + 会更早拒绝它; +- `-0` 写为正零,保持项目输出唯一。 + +`encode_e2m1_stochastic_sat()` 使用同一对边界,但以距离比例随机选择。`encode_e2m1()` +按配置作分派,语义与 E4M3 的外层分派一致。 + +### 为什么 NVFP4 需要 global + local 两层 scale + +E2M1 最大值只有 6,若只靠每个 block 的 E4M3 scale,scale 自身在全张量范围很大时会被 +压缩得不够精细。NVFP4 先用整张矩阵的 `amax` 建立 FP32 global decode scale: + +$$ +s_g=amax_{tensor}/(448\times6)=amax_{tensor}/2688. +$$ + +随后每个 rowwise 16 元素 block 再计算 E4M3 local scale: + +$$ +s_b=Q_{\mathrm{E4M3,RNE}}\left(amax_{block}/(6s_g)\right). +$$ + +元素实际使用的 combined scale 是 $s_b s_g$。于是 global scale 处理整张张量的粗范围, +local scale 让每个 16 元素 block 再贴近自身范围,E2M1 payload 最终编码 +$z_i=x_i/(s_b s_g)$。 + +代码将 `global_scale` 保存为**解码方向**的 $s_g$,而非它的倒数。这使反量化直接是: + +$$ +\hat{x}_i=\operatorname{decode\_e2m1}(q_i) +\times\operatorname{decode\_e4m3}(s_b)\times s_g. +$$ + +`compute_nvfp4_global_scale()` 对全零张量返回 `1.0F`,避免后续除零; +`compute_nvfp4_local_scale_code()` 始终用 E4M3 RNE 编码 local scale,**不受元素 +`rounding` 配置影响**。这是因为 `rounding` 的实验维度只应改变 E2M1 payload,不应让 +scale 同时随机变化。 + +若 block 的 local scale 解码为零(全零 block 或 E4M3 scale 下溢), +`encode_nvfp4_element()` 会返回正零 nibble,避免除零并遵循项目的全零约定。 + +### 4 bit 真实打包 + +CPU 和 CUDA 都不以一个 `uint8_t` 存一个 E2M1 元素。两个逻辑 nibble 必须合到一个物理 +byte: + +```text +线性元素下标: 2k 2k + 1 +逻辑 nibble: low high +物理 byte: bits [3:0] bits [7:4] +``` + +`pack_e2m1_nibbles(low, high)` 做掩码与左移,得到 +`(low & 0x0f) | ((high & 0x0f) << 4)`;两个 `unpack_*` 函数是精确反操作。 +元素总数为奇数时,最后一个 byte 的 high nibble 传零并保留为零。这个约定对跨行配对也 +成立:配对依据是 row-major 的**全局线性下标**,不是“同一行的相邻列”。 + +## 接口到量化流程的对应关系 + +| 格式 | 上层先计算 | 每元素调用 | 每元素反量化 | +| --- | --- | --- | --- | +| MXFP8 block | 每个 32 元素 block 的 `compute_mxfp8_scale_code()` | `encode_mxfp8_element()` | `decode_mxfp8_element()` | +| MXFP8 tensor | 全矩阵一次 `compute_mxfp8_scale_code()` | `encode_mxfp8_element()` | `decode_mxfp8_element()` | +| NVFP4 | 一次 `compute_nvfp4_global_scale()`,每 16 元素 block 一次 `compute_nvfp4_local_scale_code()` | `encode_nvfp4_element()` 后两两 `pack_e2m1_nibbles()` | unpack 后 `decode_nvfp4_element()` | + +这也解释了 CUDA kernel 的职责边界:kernel 负责并行 `amax` 规约、计算正确的 block +或 tensor scale 下标、读写 payload;codec 负责每个元素的数值语义。若要排查“payload +与 CPU reference 不一致”,先检查传入 codec 的 scale、线性下标随机数和元素值,再检查 +codec 本身。 + +## 实现中的边界策略速查 + +| 情况 | E4M3 | E8M0 | E2M1 | 完整 pipeline | +| --- | --- | --- | --- | --- | +| `+0` / `-0` 写入 | 统一 `0x00` | scale 零写 `0x00`,解码为 $2^{-127}$ | 统一 `0x0` | 全零 block payload 都为正零。 | +| 外部文件中的 `-0` | decoder 保留 | 不适用 | decoder 保留 | 合法,可读回。 | +| 有限溢出 | 饱和到 `±448` | 向上取整后夹到最大有限 scale | 饱和到 `±6` | 正常量化输入先要求有限。 | +| NaN | canonical `0x7f` | `0xff` | 无 code,单元素 fallback 为 0 | 原始输入直接报错。 | +| Inf | 饱和到 `±448` | `0xff` | 饱和到 `±6` | 原始输入直接报错。 | + +这里“单元素 codec 的 fallback”不等于端到端文件语义。codec 必须对任何 bit 输入有稳定 +行为,便于 host/device 测试;真正的量化接口会在 scale 规约前拒绝非有限输入,避免把 +这些 fallback 写入正常 QDWGT 产物。 + +## 建议的源码阅读顺序 + +1. 先读 `fp32_utils.cuh` 的 `float_to_bits()`、`floor_log2_positive()` 和 + `power_of_two()`,理解为什么 subnormal 需要单独处理。 +2. 读 `decode_e4m3()`,用 E4M3 的 normal/subnormal 公式手算几个 code。 +3. 读 `find_e4m3_lower_magnitude_code()`、`encode_e4m3_rne_sat()` 与 + `encode_e4m3_stochastic_sat()`,把“下界、上界、RNE/SR”串起来。 +4. 读 E8M0 与 `compute_mxfp8_scale_code()`,再读 MXFP8 的两项组合接口。 +5. 读 E2M1 codebook 与 `encode_e2m1_*()`;它更小,适合验证对 subnormal 与中点的理解。 +6. 最后读 NVFP4 的 global/local scale 和 nibble pack/unpack,再进入 + `src/reference/` 与 `src/cuda/` 看矩阵级调度。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_spec.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_spec.md" new file mode 100644 index 00000000..348e7439 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/format_spec.md" @@ -0,0 +1,377 @@ +# MXFP8 与 NVFP4 数值格式规范 + +本文是本项目量化、反量化、测试和二进制文件字段的**唯一数值语义来源**。实现必须遵循本文的位编码、scale 含义和舍入约定;如果以后需要修改这些规则,必须同时更新本文、测试向量和文件版本号。 + +若希望从代码角度理解每个 bit-field helper、RNE/SR 编码分支和 nibble 打包,请阅读 +[format_codecs.md](format_codecs.md)。该文档是本规范的源码导读,不改变本文的数值约定。 +CUDA kernel 如何并行计算这些 scale、编码 payload 与解包反量化,见 +[kernels.md](kernels.md)。 + +## 范围与资料来源 + +- **MXFP8** 采用 [OCP Microscaling Formats (MX) Specification v1.0](https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf) 的 MXFP8 语义。本项目基线固定为 **E4M3 元素 + E8M0 scale + 32 元素 block**。OCP 也定义 E5M2 变体,但题目的配置文件没有元素格式字段,因此 E5M2 不纳入第一版。 +- **NVFP4** 采用 NVIDIA [Transformer Engine NVFP4 文档](https://docs.nvidia.com/deeplearning/transformer-engine/user-guide/features/low_precision_training/nvfp4/nvfp4.html) 的一维双层缩放语义:**E2M1 元素 + 每 16 元素一个 E4M3 局部 scale + 整张张量一个 FP32 全局 scale**。它不是 OCP 的 MXFP4:后者使用 32 元素 block 和 E8M0 scale。 +- MXFP8 的 scale 计算采用 NVIDIA [TensorRT MX-compliant dynamic quantization](https://docs.nvidia.com/deeplearning/tensorrt/10.x.x/inference-library/work-quantized-types.html#mx-compliant-dynamic-quantization) 的 `round_up_to_e8m0(amax / 448)`。这是 OCP 建议算法允许的实现选择,能保证 block 最大值不会因 scale 偏小而溢出。 +- 本项目在 RTX 4060(SM89)上使用普通 CUDA kernel 软件模拟格式;不依赖 Blackwell 的原生 MXFP8/NVFP4 指令。 + +## 共同约定 + +### 输入、布局和 block + +- 输入 FP16 或 FP32 时,先转换为 FP32 进行 scale 计算和编码;反量化再转换到配置指定的 FP16、BF16 或 FP32。 +- 基线布局是 **rowwise**:对形状为 `R × C` 的 row-major 矩阵,每一行独立分为连续的 `1 × block_size` block。MXFP8 的 `block_size = 32`,NVFP4 的 `block_size = 16`。 +- 一行末尾不足一个 block 时,逻辑上以零补齐以便定义 block,但 payload 只保存原始 `R × C` 个元素。补零不参与有效元素输出,也不改变 `amax`。 +- 因此 block 模式下 scale 数量是 `R * ceil(C / block_size)`,而不是跨行展平后的 `ceil(R * C / block_size)`。 +- 本项目第一版只接受**有限值**输入。输入含 NaN 或 `±Inf` 时,量化接口必须返回错误,不写任何输出文件。这样可避免 NVFP4 无法表示 NaN/Inf 时引入未定义的私有 sentinel。 + +### 通用舍入 + +配置字段 `rounding` 只控制**元素**量化;scale 的量化规则在各格式章节单独规定。 + +- `nearest`:round-to-nearest, ties-to-even(RNE,最近值且中点取偶)。这是 OCP MX 对 FP8/FP4 转换要求支持的基线舍入。 +- `stochastic`:设非负幅值 `a` 位于相邻可表示幅值 `l <= a <= h` 之间。以概率 `(a - l) / (h - l)` 选择 `h`,以剩余概率选择 `l`;精确命中、`l == h` 和饱和边界的结果不依赖随机数。最后恢复原符号。 +- 超过最大有限值时,两种模式都饱和到最大有限值;小于最小 subnormal 的幅值在 `nearest` 下为零,在 `stochastic` 下可按零与最小 subnormal 的距离随机选择。 + +### stochastic 的确定性随机数映射 + +`stochastic_seed` 不是任意 PRNG 实现的提示,而是本项目数值语义的一部分。对 row-major 全局线性下标为 `n` 的元素,CPU reference 与未来 CUDA kernel 必须使用下列无状态映射生成 $u_n\in[0,1)$。无状态表示每个元素只依赖 seed 和自身下标;前一个元素是否恰好命中可表示值,不会改变后一个元素的随机数。 + +所有 `uint64_t` 加法和乘法按模 $2^{64}$ 回绕。令 $\gamma=\texttt{0x9e3779b97f4a7c15}$,先计算 `state = stochastic_seed + gamma * (n + 1)`,再执行: + +```cpp +std::uint64_t splitmix64(std::uint64_t state) { + state += 0x9e3779b97f4a7c15ULL; + state = (state ^ (state >> 30U)) * 0xbf58476d1ce4e5b9ULL; + state = (state ^ (state >> 27U)) * 0x94d049bb133111ebULL; + return state ^ (state >> 31U); +} + +const std::uint64_t random_bits = splitmix64(state); +const std::uint32_t random_mantissa = + static_cast(random_bits >> 40U); +const float uniform_random = + static_cast(random_mantissa) * 0x1.0p-24F; +``` + +`random_mantissa` 取 `random_bits` 的高 24 bit,范围为 $[0,2^{24}-1]$,因此 `uniform_random` 严格位于 $[0,1-2^{-24}]$,不会等于 `1.0F`。`nearest` 模式不使用该映射;`stochastic` 模式将该值传给格式特有的元素编码器。即使实现提前计算了某个 $u_n$,由于没有可变 PRNG 状态,也不影响其他元素。 + +### 为什么低精度浮点仍然需要 scale + +FP8/FP4 的元素不是 INT 量化里的整数 code,但它们仍只有很小的有限数值范围。例如 +E4M3 的有限幅值范围是从 $2^{-9}$ 到 $448$,E2M1 的有限幅值范围则只有从 $0.5$ 到 +$6$。如果直接把原始 FP32 输入送进这些格式: + +- 当输入幅值大于格式最大值时,编码只能饱和,较大的数被压成同一个最大值; +- 当输入幅值远小于格式的最小 normal/subnormal 时,编码会进入 subnormal,甚至变为零; +- 一个张量的不同区域通常有不同量级。让一个很大的异常值决定所有小值的尺度,会使小值 + 被推到低精度格式最稀疏的区域。 + +因此量化不是直接计算 $p_i=Q(x_i)$,而是先为一组元素选正 scale: + +$$ +z_i=x_i/s,\qquad p_i=Q(z_i),\qquad \hat{x}_i=\operatorname{decode}(p_i)s. +$$ + +scale 的数学任务是把这组数据的有效范围平移到低精度 codebook 的有效范围。若元素格式 +最大有限幅值为 $q_{\max}$,而当前组的 $amax$ 是 +$a=\max_i|x_i|$,不饱和的必要条件是: + +$$ +\frac{a}{s}\le q_{\max} +\qquad\Longleftrightarrow\qquad +s\ge\frac{a}{q_{\max}}. +$$ + +在满足覆盖范围的 scale 中,通常希望选尽可能小的那个:scale 越小,归一化后的 +$z_i$ 越大,小元素越不容易落入 subnormal/零附近;scale 过大则会浪费低精度格式顶部的 +许多可表示值。block scale 的意义正是在每个小块各自选接近该下界的 scale,而非让整张 +张量的全局最大值主导所有块。 + +以 E4M3 为例,若整个张量有 $amax=256$,但某个 32 元素 block 的 $amax=1$: + +- tensor scale 的最小 E8M0 选择是 $s_t=1$;该 block 的 $x=0.01$ 会变为 + $z_t=0.01$,已落在 E4M3 的 subnormal 区; +- block scale 的 E8M0 选择是 $s_b=2^{-8}$;同一个元素变为 $z_b=2.56$,落在 + normal 区,拥有更好的相对分辨率。 + +代价是 block mode 需要额外存储 scale,并在量化时做更多次 $amax$ 规约;这就是 +压缩率、kernel 工作量与数值精度之间的基本取舍。 + +## MXFP8-E4M3 + +### 表示形式 + +一个 MXFP8 block 由 1 个 E8M0 scale 字节和 32 个 E4M3 元素字节组成。反量化语义为: + +$$ +\hat{x}_i = p_i \times s_b +$$ + +其中 $p_i$ 是 E4M3 私有元素,$s_b$ 是该 block 的 E8M0 scale。 + +| 字段 | 位宽 | 规则 | +| --- | ---: | --- | +| E4M3 符号 `S` | 1 | bit 7。 | +| E4M3 指数 `E` | 4 | bits `[6:3]`,bias 为 7。 | +| E4M3 尾数 `M` | 3 | bits `[2:0]`。 | +| E8M0 指数 `e` | 8 | 一个无符号字节,bias 为 127。 | + +对 E4M3 的普通编码:当 `E > 0` 时, + +$$ +p=(-1)^S\,2^{E-7}\,(1+M/8) +$$ + +当 `E = 0` 时为 subnormal: + +$$ +p=(-1)^S\,2^{-6}\,(M/8) +$$ + +E4M3 关键值:最小 subnormal 为 $2^{-9}$,最小 normal 为 $2^{-6}$,最大有限值为 `448`(位模式 `0x7e`),`E=15, M=7` 为 NaN。E4M3 没有 Inf 编码;对有限输入溢出使用饱和规则。 + +项目写入策略会把任意有限 `+0` 或 `-0` 元素编码为 canonical 正零 `0x00`,无论该元素所在 block 是否全零;这使量化器的输出唯一。解码器仍接受外部 QDWGT 中合法的 `0x80`,并将其还原为 FP32 `-0`。因此“写入时规范化”和“读取时保留 bit pattern”是两个不同的规则。 + +E8M0 的有限 scale 为: + +$$ +s_b = 2^{e-127},\quad e\in[0,254] +$$ + +`e=255` 是 NaN,E8M0 没有零或 Inf。解码损坏文件时,若 E8M0 为 NaN,则按 OCP MX 语义将该 block 的所有输出视为 NaN;量化器不会为有限输入产生该编码。 + +### block scale 计算 + +对第 `b` 个有效 block,先计算: + +$$ +a_b=\max_i |x_i|, \qquad r_b = a_b / 448 +$$ + +然后按向上取整到 E8M0 的幂次: + +$$ +e_b=\operatorname{clamp}(\lceil\log_2(r_b)\rceil+127,0,254), +$$ + +$$ +s_b=2^{e_b-127}. +$$ + +#### 为什么 MXFP8 选择 E8M0 向上取整的 block scale + +E4M3 的最大有限幅值是 $448$。对当前 block,若其实际 decode scale 是 $s_b$,最大 +归一化幅值为 $a_b/s_b$。为了保证最大元素不因 E4M3 上溢而饱和,必须满足: + +$$ +\frac{a_b}{s_b}\le448 +\qquad\Longleftrightarrow\qquad +s_b\ge\frac{a_b}{448}. +$$ + +理想的连续 scale 是 $a_b/448$,但 E8M0 没有 mantissa,只能表示 $2$ 的整数次幂。 +所以项目采用不小于理想值的最小 E8M0 幂: + +$$ +s_b=2^{\lceil\log_2(a_b/448)\rceil}. +$$ + +这就是向上取整到 E8M0,并在实际编码时加入 E8M0 可表示指数范围的 clamp。若没有触及 +clamp 且 $a_b>0$,它还给出一个很有用的范围: + +$$ +224 < \frac{a_b}{s_b}\le448. +$$ + +也就是说,block 中最大元素一定落在 E4M3 顶部一半的有限动态范围,而不会溢出;这比 +向下取整安全得多。若错误地选择 $s_b448$,最大元素必然饱和, +并破坏该 block 的数值关系。 + +E8M0 只能取 2 的幂也带来一个性质:对仍位于 E4M3 normal 区的元素,乘除 $s_b$ 在 +二进制语义上主要是指数移动,不再额外引入一个任意 mantissa 的 scale 近似误差。代价是 +scale 最多可能比理想连续值大接近 2 倍,因此 block 内远小于 $a_b$ 的元素仍可能进入 +subnormal;这正是把 block 设为 32 而不是整张张量的原因。 + +$a_b = 0$ 时写入 $e_b = 0$,即最小有限 E8M0 scale $2^{-127}$,并把所有元素编码为正零。由于所有私有元素均为零,反量化结果仍为零。 + +当 $a_b>0$ 但理论指数小于 E8M0 的最小指数时,上式中的 clamp 同样令 $e_b=0$。这仍表示 $2^{-127}$,而不是零:E8M0 没有零编码。有限输入过小导致 `a_b / 448` 在 FP32 中下溢为零时,当前实现也按这一最小有限 scale 处理。 + +等价实现可以直接检查 FP32 的指数和 mantissa:对 $r_b$ 提取指数;若 mantissa 非零则指数加一。这正是 NVIDIA 对 E8M0 向上取整的描述。 + +### 编码、解码与 scale_mode + +**严格 MXFP8 block 模式**: + +1. 按上节得到每个 `1 × 32` block 的 E8M0 $s_b$。 +2. 计算 $z_i = x_i / s_b$。 +3. 按配置的元素舍入,将 $z_i$ 转为 E4M3;有限溢出饱和到 `±448`。 +4. 反量化为 $\operatorname{decode\_e4m3}(p_i) \times s_b$。 + +`scale_mode = "tensor"` 是为了满足题目配置加入的**项目扩展**,不再是严格 OCP MXFP8:在整张 `R × C` 矩阵上计算一个 `amax`,按同一公式只生成一个 E8M0 scale,所有元素共享它。文件 header 必须保存 `scale_mode`,读取方不得把 tensor 模式数据当作 OCP block 模式数据。 + +### 可用于单元测试的向量 + +一个 block 含有 `{0, 1, -1, 448}`,其余元素为零时: + +- `amax = 448`,所以 $s_b = 1$,E8M0 字节为 `0x7f`。 +- 对应 E4M3 字节依次为 `0x00`、`0x38`、`0xb8`、`0x7e`。 + +当前 CPU reference 的端到端测试还覆盖一个 `1 × 33` 矩阵:前 32 个元素中前四项为 `{0, 1, -1, 448}`、其余为零,尾 block 的唯一元素为 `-896`。两个 local scale 依次为 `0x7f`($1$)和 `0x80`($2$);第 33 个 E4M3 payload 为 `0xfe`($-448$)。该向量同时验证 rowwise 尾 block、scale 索引和 QDWGT 文件 round-trip。 + +## NVFP4-E2M1 + +### 表示形式 + +NVFP4 的一个元素由 E2M1 nibble、局部 E4M3 scale 和全局 FP32 scale 共同解释: + +$$ +\hat{x}_i = q_i \times s_b \times s_g +$$ + +其中 $q_i$ 是 E2M1,$s_b$ 是该 `1 × 16` block 的 E4M3 局部 decode scale,$s_g$ 是整张张量的 FP32 全局 decode scale。这个公式与 NVIDIA Transformer Engine 文档一致。 + +E2M1 的 bit layout 为 `S EE M`:符号位 `S` 为 bit 3,指数 `EE` 为 bits `[2:1]`,尾数 `M` 为 bit 0,指数 bias 为 1。其正数幅值 codebook 如下: + +| `EE M` | 幅值 | 说明 | +| --- | ---: | --- | +| `000` | 0 | 零;符号位可形成 `+0`、`-0`。 | +| `001` | 0.5 | subnormal,也是最小正数。 | +| `010` | 1 | normal。 | +| `011` | 1.5 | normal。 | +| `100` | 2 | normal。 | +| `101` | 3 | normal。 | +| `110` | 4 | normal。 | +| `111` | 6 | 最大有限值。 | + +因此 E2M1 不存在 Inf 或 NaN 编码,最大幅值 $q_{\max} = 6$。局部 scale 使用与上文相同的 OCP E4M3 编码:正的有限 E4M3 scale 范围是 `0` 至 `448`,并以一个字节存储。 + +### 双层 scale 计算 + +本项目存储和使用的是**解码方向**的 $s_g$,不存倒数: + +$$ +a_g=\max_i |x_i|, +\qquad +s_g= +\begin{cases} +a_g/(448\times6), & a_g>0 \\ +1, & a_g=0 +\end{cases} +$$ + +对每一个 16 元素 block: + +$$ +a_b=\max_i|x_i|, +\qquad +r_b=a_b/(6s_g), +\qquad +s_b=\operatorname{E4M3\_RNE\_SAT}(r_b). +$$ + +`E4M3_RNE_SAT` 表示 E4M3 scale 始终用 RNE 转换、超过 `448` 时饱和;它不受元素 `rounding` 配置影响。量化元素时: + +$$ +z_i=x_i/(s_b s_g), +\qquad +q_i=\operatorname{E2M1}_{\text{rounding}}(z_i). +$$ + +#### 为什么 NVFP4 还需要 FP32 global scale + +NVFP4 的元素 E2M1 最大幅值只有 $6$;一个 local scale 虽可用 E4M3 表示,最大有限 +值也只有 $448$。因此,对于给定 FP32 global decode scale $s_g$,某个 block 可覆盖的 +最大原始幅值至多是: + +$$ +q_{\max}\times s_{b,\max}\times s_g +=6\times448\times s_g +=2688s_g. +$$ + +令整张张量的最大幅值为 $a_g$。要让任意 block 的理想 local-scale 目标不超出 E4M3 +的最大值,必须至少有 $2688s_g\ge a_g$。项目正好选择最小满足该覆盖条件的 FP32 +global scale: + +$$ +s_g=\frac{a_g}{2688}. +$$ + +这一步的作用不是把所有元素直接压进 E2M1;如果只使用这个 global scale,E2M1 单独的 +可覆盖范围仍仅为 $6s_g=a_g/448$,绝大多数大元素依然会饱和。它先把整张张量的粗粒度 +动态范围映射到“E4M3 local scale 最大可达 448”的坐标系。 + +对第 $b$ 个 block 的 $a_b$,代入上述 $s_g$ 后,理想 local scale 为: + +$$ +s_b^\ast=\frac{a_b}{6s_g} +=448\frac{a_b}{a_g}. +$$ + +因为 $0\le a_b\le a_g$,必有 $0\le s_b^\ast\le448$。这正是 global scale 的关键数学 +效果:无论各 block 的绝对量级多大,local scale 的理想值都会落入 E4M3 的有限范围。 +随后 E4M3 用 RNE 将 $s_b^\ast$ 编码为实际 local scale $s_b$,再令 +$z_i=x_i/(s_b s_g)$。若没有 local-scale 的 E4M3 舍入误差,block 最大元素会满足 +$a_b/(s_b^\ast s_g)=6$,恰好用满 E2M1 的正最大值。 + +E4M3 local scale 是离散的,RNE 后的 $s_b$ 可能略大或略小于 $s_b^\ast$;因此它优化的是 +局部范围匹配,而不是像 MXFP8 E8M0 向上取整那样严格保证每个元素绝不饱和。实际编码器 +仍对 E2M1 的超范围值执行饱和。这是 NVFP4 用更小 4-bit payload 换取更高压缩率时的 +固有误差来源之一。 + +如果没有 global scale,而直接要求 E4M3 local scale 吸收原始范围,则任一 +$a_b>6\times448=2688$ 的 block 都需要超过 E4M3 最大值的 local scale,必然发生 scale +饱和。即使没有这么大的 block,global + local 的分工仍然有价值:$s_g$ 表示整张张量的 +粗粒度幅值,$s_b$ 则把每个 16 元素 block 拉回 E2M1 最有效的 $[-6,6]$ 附近。 + +若 $a_b = 0$,局部 E4M3 scale 写为正零,16 个 nibble 都写为零。若一个非零 block 的 $s_b$ 因 E4M3 下溢而成为零,则该 block 的所有元素量化为零;这是有限 E4M3 scale 分辨率导致的预期结果。 + +计算时可以使用编码方向的倒数 $g_{\mathrm{encode}} = 1 / s_g = 2688 / a_g$ 避免除法,但文件中的 `global_scale` 字段必须始终保存上述解码方向的 $s_g$。这一命名约定避免与部分 NVIDIA API 中使用的“编码 scale”混淆。 + +NVFP4 在本项目中严格采用 block scaling:一个 FP32 $s_g$ 加上每个 `1 × 16` block 的一个 E4M3 $s_b$。因此 `format = "nvfp4"` 时 `scale_mode` 必须为 `"block"`;`"tensor"` 会在配置读取、`QuantizedTensor` 元数据校验和 QDWGT 文件读取时被拒绝。这既符合 NVIDIA 的标准 NVFP4 recipe,也避免将分层缩放错误地退化成单个 local scale。 + +### 4-bit 打包 + +题目要求真实 4-bit 存储;本项目的 payload 约定为线性 row-major 下标 `n = row * C + col`: + +$$ +payload[n/2] = +\begin{cases} +(payload[n/2]\ \&\ 0xf0)\ |\ q_n, & n\text{ 为偶数} \\ +(payload[n/2]\ \&\ 0x0f)\ |\ (q_n \ll 4), & n\text{ 为奇数}. +\end{cases} +$$ + +- 偶数元素在低 nibble,奇数元素在高 nibble。 +- payload 字节数为 `ceil(R * C / 2)`;如果元素总数为奇数,最后一个字节的高 nibble 必须清零。 +- CUDA kernel 必须以字节为单位执行 packed load/store,并通过掩码和移位读取两个 E2M1 元素,不能用一个 `uint8_t` 保存一个 nibble。 +- NVIDIA 的硬件 GEMM 还要求 scale swizzle 和部分对齐填充;本项目是软件模拟且只做量化/反量化,因此 payload 与 scale 按上述朴素 row-major 顺序保存,不实施硬件 swizzle。 + +### 可用于单元测试的向量 + +假设整张张量与当前 block 的 `amax = 6`: + +- $s_g = 6 / 2688 = 1 / 448$。 +- $s_b = \operatorname{E4M3}(448)$,字节为 `0x7e`。 +- `+6` 的 E2M1 nibble 为 `0x7`,`-6` 为 `0xf`。 +- row-major 连续元素 `{+6, -6}` 的 packed 字节为 `0xf7`,反量化分别为 `+6`、`-6`。 + +## 配置、文件和兼容性要求 + +| 配置 | MXFP8 | NVFP4 | +| --- | --- | --- | +| `format` | `mxfp8`,基线为 E4M3 + E8M0。 | `nvfp4`,E2M1 + E4M3 + FP32 global scale。 | +| `block_size` | 配置和 header 均必须为 32;block 模式按此大小分组。 | 配置和 header 均必须为 16;按此大小分组。 | +| `scale_mode=block` | 严格 OCP MXFP8。 | NVIDIA 一维 NVFP4 recipe。 | +| `scale_mode=tensor` | 单 E8M0 scale 的项目扩展。 | **不支持**;配置和 QDWGT header 均会被拒绝。 | +| `rounding` | 控制 E4M3 元素转换。 | 控制 E2M1 元素转换。 | + +低精度权重文件的 header 至少要写入:`format`(QDWGT v1 的一个字节:`1 = MXFP8`、`2 = NVFP4`)、`scale_mode`、矩阵形状、block size、rounding、payload 字节数、局部 scale 个数与类型,以及 NVFP4 的 FP32 `global_scale`。`mxfp8_e4m3`、`nvfp4_e2m1` 只是本文对数值 profile 的描述,不是 header 中保存的字符串。物理 header 字节布局、枚举值与 section 顺序以 [file_format.md](file_format.md) 为准。 + +## 实现和测试边界 + +- MXFP8 CPU reference 的**量化与反量化方向**已经实现:量化支持 `block`、`tensor`、`nearest`、`stochastic`,会构造 `QuantizedTensor` 并可写入 QDWGT;反量化按相同的 tensor 或 rowwise block scale 索引解码为 host FP32,并由 `DequantizationConfig` 指定后续 QDTENSOR 的 FP16、BF16 或 FP32 物理输出类型。 +- MXFP8 CUDA 的 block/tensor 量化和反量化已实现,并在有 CUDA device 时与 CPU reference 对照 payload、E8M0 scale 和 FP32 反量化结果;测试覆盖 tail block、grid-stride 路径、nearest、stochastic 和非有限输入拒绝。QDWGT I/O 还覆盖了 `NVFP4 + tensor` header 的拒绝。 +- NVFP4 的 CPU reference 与 CUDA 量化/反量化已经实现:量化先用两阶段规约得到 FP32 global scale,再用 16-lane tile 计算每个 E4M3 local scale,最后用连续 32 元素 warp 让偶数 lane 独占写 packed E2M1 byte。反量化不需要规约,一个线程读取一个 physical payload byte,解包 low/high nibble 后分别用自己的 `(row, column)` 推导 rowwise local-scale 下标,因此能正确处理奇数列的跨行 byte 与奇数元素的尾 nibble。CPU 测试覆盖 E2M1 codebook、双层 scale、跨行 byte 配对、stochastic 可复现性、反量化与非有限输入拒绝;有 CUDA device 时,测试逐元素对照 CPU/CUDA 量化与反量化结果,覆盖三种输出 dtype、persistent grid-stride、尾 block 和 `NVFP4 + tensor` 非法组合。 +- CPU reference 和 CUDA kernel 必须调用或复制同一套数学规则;对 `nearest`,两者需要做到 payload 与 scale 位级一致;对 `stochastic`,在相同随机种子与随机数映射下也需要一致。 +- RTX 4060 仅用于软件模拟和性能评测。它不支持 Blackwell 的原生 MXFP8/NVFP4 Tensor Core 路径,因此报告不得把本项目性能称作硬件格式指令性能。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/kernels.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/kernels.md" new file mode 100644 index 00000000..19fdf16a --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/kernels.md" @@ -0,0 +1,303 @@ +# MXFP8 与 NVFP4 CUDA kernel 设计 + +本文解释 `src/cuda/` 中量化和反量化 kernel 的**执行映射、数据流与设计原因**。 +它关注“一个 CTA、warp、lane 到底处理什么”和“为什么某些 scale 必须走两阶段规约”; +E4M3、E8M0、E2M1 的单元素数值规则见 [format_codecs.md](format_codecs.md), +文件布局见 [file_format.md](file_format.md)。 + +## 源文件与入口 + +| 文件 | host launcher | device kernel | 输入与输出 | 用途 | +| --- | --- | --- | --- | --- | +| `mxfp8_quantize.cu` | `launch_mxfp8_block_quantize()`、`launch_mxfp8_tensor_quantize()` | block 量化、partial amax、finalize、tensor 编码 | FP32 → E4M3 payload + E8M0 scale | 按 `scale_mode` 分别实现 MXFP8 两条量化路径。 | +| `mxfp8_dequantize.cu` | `launch_mxfp8_dequantize()` | `mxfp8DequantizeKernel` | E4M3 + E8M0 → FP32 | 统一处理 MXFP8 block/tensor 两种 scale 索引。 | +| `nvfp4_quantize.cu` | `launch_nvfp4_block_quantize()` | global partial/finalize、local scale、packed encode | FP32 → packed E2M1 + E4M3 local + FP32 global | 实现严格 NVFP4 的双层 scale 与真实 4-bit 写入。 | +| `nvfp4_dequantize.cu` | `launch_nvfp4_dequantize()` | `nvfp4DequantizeKernel` | packed E2M1 + 两层 scale → FP32 | 每线程解一个物理 payload byte。 | + +所有 launcher 都接收 `DeviceQuantizationInput`、`DeviceQuantizedTensor` 或 +`DeviceDequantizationOutput`。它们由 `src/pipeline/` 使用 `thrust::device_vector` +拥有 device 内存;launcher 只用 `thrust::raw_pointer_cast()` 取得非拥有的 raw device +pointer,因此不会在 kernel 发射点重复进行 H2D/D2H。 + +## 共同执行模型 + +### 线性下标、rowwise block 与 scale 下标 + +对 row-major 的 $R\times C$ 矩阵,元素 $(r,c)$ 的全局线性下标是 +$n=rC+c$。block mode 不跨行分块: + +$$ +\mathrm{blocks\_per\_row}=\left\lceil\frac{C}{B}\right\rceil, +\qquad +\mathrm{scale\_index}=r\cdot\mathrm{blocks\_per\_row}+\left\lfloor\frac{c}{B}\right\rfloor. +$$ + +MXFP8 使用 $B=32$;NVFP4 使用 $B=16$。尾 block 的无效 lane 不读 global memory, +只把数值零贡献给 `amax` 规约,因此不会改变有效元素的结果。 + +### CTA、warp 与持久化 grid + +量化 kernel 的 CTA 都固定为 256 线程,即 8 个 warp。反量化 kernel 也使用 256 线程。 +大张量不会为每个逻辑工作项发射一个 CTA,而使用 **persistent grid(持久化网格)**: + +- launcher 查询当前 GPU 的 SM 数,目标为每 SM 最多 4 个常驻 CTA; +- MXFP8 block 与 NVFP4 local-scale 路径额外用 occupancy API 取 + `min(4, 实际可驻留 CTA/SM)`,避免超过寄存器或架构资源上限; +- 小问题只发射足够覆盖首轮工作量的 CTA;大问题发射有限 CTA,kernel 内用 grid-stride + 循环持续处理后续元素、block 或 payload byte。 + +这降低了大量小 CTA 的调度开销,同时让所有 SM 有持续工作。`SM × 4` 是启发式,不是 +对任何 GPU 都最优的常数。 + +### CUDA stream 与计时边界 + +`quantize_*_cuda_profiled()` 和 `dequantize_*_cuda_profiled()` 每次调用创建独占的 +non-blocking stream。执行顺序为: + +```text +H2D → CUDA Event start → 本文列出的全部 kernel → CUDA Event stop → D2H +``` + +同一 stream 中不同 kernel 按发射顺序执行:例如 finalize kernel 写出的 scale,对后续 +encode kernel 已可见,不需要 host 同步。Event 时间只覆盖 kernel,不包括 device 分配、 +H2D、D2H、文件 I/O 与 CUDA context 初始化。 + +### 非有限输入的联合规约 + +`amax` 不能只规约 `float max`:NaN 可能被最大值逻辑静默忽略。因此 tensor/global 规约 +使用 `TensorAmaxState`: + +```text +{ amax: 当前有限元素的最大绝对值, + has_nonfinite: 是否见过 NaN、+Inf 或 -Inf } +``` + +每个 warp 用 `__shfl_down_sync()` 规约两项;一个 CTA 的 8 个 warp 各由 lane 0 写一项 +shared memory,warp 0 再规约这 8 项。最终 `has_nonfinite != 0` 时写入格式的 NaN 哨兵, +pipeline 在 D2H 后将其转为量化失败。正常 API 不会为非有限输入生成可用 QDWGT。 + +## 为什么 tensor scale 必须两阶段规约 + +一个普通 CUDA kernel 内可以同步同一 CTA 的线程,却不能同步不同 CTA。若 tensor mode +的多个 CTA 各自得到 `partial_amax`,它们不能在同一发射中安全地决定同一个最终 scale。 +因此路径必须分为: + +```mermaid +flowchart LR + A["输入 FP32"] --> P["阶段一:每 CTA
grid-stride 读取并写 partial amax"] + P --> W["partial_amax
partial_nonfinite 工作区"] + W --> F["阶段二:单 CTA 规约全部 partial
并直接写最终 scale"] + F --> S["唯一 scale"] + S --> E["阶段三:并行编码 payload"] +``` + +阶段一采用 `float4` 加载:连续线程读取连续的 16-byte 向量,减少循环次数并维持 warp +合并访存。不能整除 4 的最后 1–3 项改为标量 grid-stride 加载,避免越界读取。 + +第二阶段只有一个 256-thread CTA。每个线程以 block-stride 读取多个 partial,随后复用 +warp-shuffle + shared-memory CTA 规约;thread 0 直接调用格式 codec 写最终 scale。这样 +不必再额外发射“只写 scale”的第三个规约 kernel。 + +`DeviceTensorQuantizationWorkspace` 持有 `partial_amax` 与 `partial_nonfinite` 两个 +device 数组,其长度等于第一阶段的 persistent CTA 数。workspace 的生命周期覆盖全部 +规约与编码 kernel。 + +## MXFP8 量化 + +MXFP8 payload 是每元素一个 E4M3 byte,scale 是 E8M0 byte。它支持题目要求的 +`scale_mode = block` 与项目扩展的 `scale_mode = tensor`,但两者的 kernel 路径不同。 + +### block mode:一个 warp 完成一个 32 元素 block + +`mxfp8BlockQuantizeKernel` 的映射恰好匹配 MXFP8 block 大小:一个 warp 的 32 个 lane +对应一个 rowwise 32 元素量化 block。 + +| 对象 | 映射 | +| --- | --- | +| CTA | 256 threads = 8 warp,同时处理 8 个逻辑 MXFP8 block。 | +| warp | 一个 32 元素 rowwise quantization block。 | +| lane `l` | block 中列偏移 `l` 的一个元素。 | +| `local_scales[quant_block]` | 该 warp 处理 block 的唯一 E8M0 scale。 | +| `payload[linear_index]` | 有效 lane 写自己的 E4M3 byte。 | + +第一个 block 编号为: + +$$ +\mathrm{quant\_block}=\mathrm{blockIdx.x}\times8+\mathrm{warp\_id}. +$$ + +完成后同一 warp 以步长 `gridDim.x × 8` 继续领取后续 block。其一轮工作如下: + +1. 每个有效 lane 加载一个 FP32;尾 block 中无效 lane 使用 `0.0F` 且不访问输入。 +2. `__ballot_sync()` 汇总非有限 lane。若存在,lane 0 写 `0xff` E8M0 NaN scale 并让整个 + warp 进入下一 block;不会写有意义的 payload。 +3. 所有 lane 使用 `__shfl_down_sync()` 做树形最大值规约;lane 0 得到该 block 的 `amax`。 +4. lane 0 调用 `compute_mxfp8_scale_code(amax)`,写 E8M0 scale;再用 `__shfl_sync()` + 广播给全 warp。 +5. 每个有效 lane 按自己的全局线性下标产生 stochastic 随机数(RNE 时为 0),调用 + `encode_mxfp8_element()`,独占写一个 payload byte。 + +规约、scale 写入、广播与 E4M3 编码都在一个 warp 内完成,不需要 shared memory、CTA +同步或跨 CTA 通信。这是 block mode 能用一个融合 kernel 的根本原因。 + +### tensor mode:两阶段 global amax 加独立编码 + +`mxfp8TensorAmaxPartialKernel`、`mxfp8TensorScaleFinalizeKernel` 和 +`mxfp8TensorEncodeKernel` 依次执行: + +| 阶段 | kernel | 每个 CTA/线程处理 | 写入 | +| --- | --- | --- | --- | +| 1 | `mxfp8TensorAmaxPartialKernel` | 每线程 grid-stride 遍历多个 `float4` 与尾部标量元素 | 一项 `partial_amax[blockIdx.x]` 与非有限标记。 | +| 2 | `mxfp8TensorScaleFinalizeKernel` | 一个 CTA 的线程分担全部 partial | `local_scales[0]` 的唯一 E8M0 scale。 | +| 3 | `mxfp8TensorEncodeKernel` | 每线程 grid-stride 遍历多个线性元素 | 每元素一个 E4M3 payload byte。 | + +阶段 2 在同一个 kernel 内完成最终规约**并**写 `local_scales[0]`。阶段 3 的每个 CTA +只需读取该下标 0 的 scale,不再有 scale 索引分支。若阶段 2 写 `0xff` 非有限哨兵,阶段 +3 直接返回,不产生表面上可用的 payload。 + +因此 MXFP8 的 tensor mode 不可能像 block mode 那样把 scale 与编码融合:在进入编码前, +所有 CTA 都必须看见同一份全局 amax 的结果。 + +## MXFP8 反量化 + +`mxfp8DequantizeKernel` 使用一维 persistent grid-stride 映射;一个线程处理一个或多个 +逻辑元素。它不需要规约,也没有元素间写冲突。 + +对每个 `linear_index`,kernel 恢复 $(row,column)$,再选择 scale: + +$$ +\mathrm{scale\_index}= +\begin{cases} +0, & \mathrm{tensor\ mode}, \\ +row\cdot\mathrm{blocks\_per\_row}+\lfloor column/32\rfloor, +& \mathrm{block\ mode}. +\end{cases} +$$ + +随后调用 `decode_mxfp8_element(payload[index], local_scales[scale_index])`。相邻线程读取 +连续 payload 与写连续 FP32 output;block mode 虽多了一次 scale 索引计算,元素级解码 +公式完全相同,所以两种 mode 可以共用一个 kernel。 + +## NVFP4 量化 + +NVFP4 只支持 `scale_mode = block`,但它仍然需要整张张量的 FP32 `global_scale`,再为 +每个 rowwise 16 元素 block 计算 E4M3 `local_scale`。所以它的完整量化流程是四个 kernel: + +```mermaid +flowchart LR + A["输入 FP32"] --> P["阶段一:global amax
partial reduction"] + P --> F["阶段二:单 CTA finalize
写 FP32 global scale"] + F --> L["阶段三:每 16 元素 block
写 E4M3 local scale"] + L --> E["阶段四:每 warp 编码 32 元素
偶数 lane 写 packed byte"] + E --> O["packed E2M1 payload"] +``` + +### global scale:仍是两阶段规约 + +`nvfp4TensorAmaxPartialKernel` 与 `nvfp4GlobalScaleFinalizeKernel` 的规约结构和 MXFP8 +tensor mode 相同:`float4` grid-stride 加载、warp shuffle、8 项 shared memory、warp 0 +finalize。区别只在最后写入: + +$$ +global\_scale=\frac{amax_{tensor}}{448\times6}. +$$ + +finalize 的 thread 0 调用 `compute_nvfp4_global_scale()`,写出一个 FP32 decode-direction +`global_scale[0]`。即使 NVFP4 不接受 tensor mode,也无法省掉这两阶段:它的 local scale +必须以全张量范围为参考。 + +### local scale:16-lane Cooperative Group tile + +`nvfp4LocalScaleKernel` 每 CTA 有 256 线程。它用 +`cooperative_groups::tiled_partition<16>()` 切成 16 个 `thread_block_tile<16>`;因此每个 +物理 warp 同时容纳两个独立的 NVFP4 16 元素 block。 + +| 对象 | 映射 | +| --- | --- | +| CTA | 16 个 16-lane tile,同时首轮处理 16 个逻辑 NVFP4 block。 | +| tile | 一个 rowwise 16 元素 block。 | +| tile lane | block 内一个元素,或尾 block 的无效零贡献。 | +| `local_scales[quant_block]` | tile lane 0 写的 E4M3 local scale。 | + +每个 tile 以 `tile.shfl_down()` 做 16-lane `amax` 和非有限标记规约;tile lane 0 调用 +`compute_nvfp4_local_scale_code(block_amax, global_scale[0])`。使用 Cooperative Groups 的 +好处是 16-lane 子组的同步与 shuffle 语义写在类型中,不必手写 half-warp mask。 + +该 kernel 与 packed encode 分开,原因不仅是 scale 必须先可见:一个物理 payload byte +可能装两个相邻元素,而在奇数列矩阵中这两个元素还可能跨行。若让两个 16 元素 block +边计算 scale 边以不同线程写 nibble,会产生同一 byte 的 read-modify-write 竞争。 + +### packed encode:一个 warp 处理 32 个连续元素 + +`nvfp4PackedEncodeKernel` 重新使用 `thread_block_tile<32>`。每 warp 处理连续 32 个**线性** +元素,而不是一行内固定 32 列;这样跨行时 payload 配对仍遵守 row-major 线性布局。 + +1. lane `l` 的元素下标是 `first_linear_index + l`,恢复各自的 `(row,column)`,以 + `row × blocks_per_row + column / 16` 读取所属 local scale。 +2. 每个有效 lane 调用 `encode_nvfp4_element()`,得到自己的 E2M1 nibble。尾 warp 无效 + lane 初始化为零 code。 +3. 所有 lane 参加 `warp.shfl()`。偶数 lane 从紧随的奇数 lane 取得 partner nibble; + 奇数 lane 虽不写,也必须参加这次 collective 操作。 +4. 只有偶数 lane 写 + `payload[linear_index / 2] = pack_e2m1_nibbles(own_code, partner_code)`。 + +这样一个 payload byte 永远只有一个写者,没有 `atomic`,也没有两个线程对同一 byte 的 +read-modify-write 竞争。最后一个逻辑元素无 partner 时,无效奇数 lane 提供零,最终 high +nibble 自然清零。 + +## NVFP4 反量化 + +`nvfp4DequantizeKernel` 选择“一个线程对应一个**物理 payload byte**”,而不是一个线程 +对应一个 nibble: + +- thread 读取 `payload[payload_index]` 一次; +- low nibble 对应 `even_index = 2 × payload_index`,必定有效; +- high nibble 对应 `odd_index = even_index + 1`,仅当小于 `element_count` 时解码; +- 两个逻辑元素分别恢复自己的 `(row,column)` 与 local-scale 下标,再乘同一个 + `global_scale[0]`。 + +最后一点必须分开计算。若列数为奇数,一个 byte 的 even/odd 元素会分属相邻两行,甚至 +落在不同的 16 元素 block;它们共享 payload byte,却不一定共享 `local_scale`。每线程 +独占 byte 既避免重复读取,也自然保证最后 padding high nibble 不会写到输出。 + +## 访存、同步和正确性边界 + +| 场景 | 访存/同步选择 | 原因 | +| --- | --- | --- | +| tensor/global `amax` 第一阶段 | 连续 `float4` load + grid-stride | 合并访存,固定 CTA 数也能覆盖大张量。 | +| CTA 级规约 | warp shuffle → 每 warp 一项 shared memory → warp 0 | 大部分规约不需要 shared memory;只用 8 项 shared memory 跨 warp 汇合。 | +| MXFP8 block amax | 仅 warp shuffle | 32 元素 block 恰为一个完整 warp,不需要 CTA 同步。 | +| NVFP4 local amax | `thread_block_tile<16>` shuffle | 16 元素 block 小于 warp,CG 明确表达两个 half-warp 子组。 | +| NVFP4 packed store | 偶数 lane 独占整字节 store | 两个 nibble 共享 byte,避免写竞争。 | +| 反量化 | 一维 grid-stride,无 shared memory | 每个元素或 byte 相互独立,无规约、无跨线程通信。 | + +所有 launcher 在发射后调用 `cudaGetLastError()` 检查参数与 launch 错误;它不等待 +kernel 完成。异步执行期错误会在同一 stream 的 D2H 或显式 `stream.synchronize()` 时上报, +从而不在每一次 kernel 后插入不必要的全局同步。 + +## 路径选择速查 + +| 格式与 mode | 是否需全局两阶段规约 | 是否需 block-local 规约 | 量化 kernel 数 | 反量化线程粒度 | +| --- | ---: | ---: | ---: | --- | +| MXFP8 block | 否 | 是,warp 内 32-lane | 1 | 一个线程一个 E4M3 元素。 | +| MXFP8 tensor | 是,写唯一 E8M0 scale | 否 | 3 | 一个线程一个 E4M3 元素。 | +| NVFP4 block | 是,写 FP32 global scale | 是,16-lane tile 写 E4M3 local scale | 4 | 一个线程一个 packed byte、最多两个 E2M1 元素。 | +| NVFP4 tensor | 不支持 | 不适用 | 0 | 不适用。 | + +在所有路径中,实际 FP16/BF16/FP32 输出的窄化不放在反量化 kernel 内:kernel 和 +`HostTensor::values` 始终使用 FP32,之后由 QDTENSOR I/O 按 `output_type` 写为目标物理 +dtype。这使同一个 CUDA 解码 kernel 能服务三种输出类型,也让误差统计可以明确包含最终 +落盘窄化造成的误差。 + +## 建议的源码阅读顺序 + +1. 读 `mxfp8BlockQuantizeKernel`,理解最简单的“一个 warp = 一个 32 元素 block”融合路径。 +2. 读 `TensorAmaxState`、`warp_reduce_tensor_amax()` 和 `block_reduce_tensor_amax()`,再读 + MXFP8 tensor mode 的三段 kernel。 +3. 读 `mxfp8DequantizeKernel`,理解反量化为何只差一个 scale 索引公式。 +4. 读 NVFP4 的 global reduction;它和 MXFP8 tensor mode 结构相同,但最终写 FP32 global + scale。 +5. 读 `nvfp4LocalScaleKernel` 的 16-lane tile,然后读 `nvfp4PackedEncodeKernel` 的 + 32-lane packing,重点关注“为什么偶数 lane 才写 byte”。 +6. 最后读 `nvfp4DequantizeKernel`,用奇数列、奇数元素数的例子验证它为何分别计算两个 + nibble 的 local-scale 下标。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/tests.md" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/tests.md" new file mode 100644 index 00000000..78d59033 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/docs/tests.md" @@ -0,0 +1,186 @@ +# 测试体系:CTest 条目、标签与新增测试约定 + +本项目使用 **CMake + CTest** 管理测试,但不依赖 GoogleTest 等第三方框架。每个 +`test_*.cpp` 或 `test_*.cu` 文件定义一个 `run_*_tests()` 函数;CMake 为它构建一 +个独立可执行文件并注册一个独立 CTest 条目。这样失败报告的最小单位是一个模块, +而不是过去的单个聚合测试进程。 + +数值格式、scale 和舍入的真值规则见 [format_spec.md](format_spec.md),QDTENSOR / +QDWGT 的文件边界见 [file_format.md](file_format.md)。本文只说明“如何组织、运行和 +扩展测试”。 + +## 测试执行模型 + +```mermaid +flowchart LR + SRC["一个 test_*.cpp 或 test_*.cu
定义 run_*_tests()"] --> CMAKE["add_quant_dequant_test()
注册独立 target 与标签"] + ENTRY["test_entry_main.cpp
由编译定义选择入口函数"] --> EXE["一个测试可执行文件"] + CMAKE --> EXE + EXE --> CTEST["一个 CTest 条目"] + CTEST --> RESULT["独立通过、失败或跳过报告"] +``` + +`tests/test_entry_main.cpp` 不包含业务测试逻辑。CMake 为每个 target 定义 +`QUANT_DEQUANT_TEST_ENTRY=run_xxx_tests`,通用入口据此调用目标函数并把其退出码 +返回给 CTest。 + +这种设计有两个直接结果: + +- 一个测试失败不会掩盖其他模块的结果;CTest 输出会直接给出失败 target 名称。 +- 可以通过名称或标签只运行某一格式、某一层,尤其适合 CUDA kernel 的迭代调试。 + +代价是每个测试 target 都要单独链接一次 `quant_dequant::core`。对于当前以正确性 +和学习为主的小项目,这个编译时间代价换来了更清晰的隔离和诊断。 + +## 当前测试清单 + +| CTest 条目 | 源文件 | 标签 | 主要验证内容 | +| --- | --- | --- | --- | +| `quant_dequant_config_tests` | `test_config.cpp` | `unit`, `config` | key-value 配置解析、默认值、非法格式/scale 组合。 | +| `quant_dequant_tensor_io_tests` | `test_tensor_io.cpp` | `unit`, `io` | QDTENSOR 的 FP16/FP32 输入读取、FP16/BF16/FP32 输出写入与读回、role 隔离和损坏文件拒绝。 | +| `quant_dequant_quantized_io_tests` | `test_quantized_io.cpp` | `unit`, `io` | QDWGT round-trip、header、section 对齐与非法文件拒绝。 | +| `quant_dequant_quantized_tensor_tests` | `test_quantized_tensor.cpp` | `unit`, `model` | `QuantizedTensor` 的 payload、local scale、global scale 元数据不变量。 | +| `quant_dequant_metrics_tests` | `test_metrics.cpp` | `unit`, `metrics` | 误差、逻辑字节、压缩率、带宽、JSON null/转义、0 ms CUDA Event 边界与实际报告文件写入。 | +| `quant_dequant_reference_dispatch_tests` | `test_reference_dispatch.cpp` | `unit`, `reference` | CPU reference 的输入校验和 MXFP8/NVFP4 格式派发。 | +| `quant_dequant_mxfp8_reference_tests` | `test_mxfp8_reference.cpp` | `unit`, `reference`, `mxfp8` | MXFP8 CPU 量化、反量化、scale 与文件 round-trip。 | +| `quant_dequant_nvfp4_reference_tests` | `test_nvfp4_reference.cpp` | `unit`, `reference`, `nvfp4` | NVFP4 双层 scale、跨行 packed byte、CPU 反量化。 | +| `quant_dequant_mxfp8_codec_tests` | `test_mxfp8_codec.cu` | `unit`, `codec`, `mxfp8`, `cuda` | E4M3/E8M0 位编码、边界与 host/device codec 一致性。 | +| `quant_dequant_nvfp4_codec_tests` | `test_nvfp4_codec.cu` | `unit`, `codec`, `nvfp4`, `cuda` | E2M1 codebook、E4M3 local scale、nibble 打包与解包。 | +| `quant_dequant_cuda_dispatch_tests` | `test_cuda_dispatch.cpp` | `integration`, `cuda` | CUDA pipeline 公共入口的格式派发与异常边界。 | +| `quant_dequant_device_quantized_tensor_tests` | `test_device_quantized_tensor.cu` | `integration`, `cuda` | Thrust device buffer 的长度、格式不变量和 move-only 生命周期。 | +| `quant_dequant_cuda_timer_tests` | `test_cuda_timer.cu` | `integration`, `cuda`, `common` | non-blocking `CudaStream` 的 RAII 生命周期,以及绑定该 stream 的 CUDA Event 计时器状态机、复用和无 GPU 跳过语义。 | +| `quant_dequant_cuda_profile_tests` | `test_cuda_profile.cu` | `integration`, `cuda`, `profile`, `mxfp8` | `*_cuda_profiled()` 的 H2D → event → kernel → D2H 时序、`kernel_ms` 有效性和 MXFP8 tensor-scale CPU reference 端到端对照。 | +| `quant_dequant_mxfp8_cuda_tests` | `test_mxfp8_cuda.cu` | `integration`, `cuda`, `mxfp8` | MXFP8 CUDA 量化与 CPU reference 的 payload/scale 对照。 | +| `quant_dequant_mxfp8_dequantize_cuda_tests` | `test_mxfp8_dequantize_cuda.cu` | `integration`, `cuda`, `mxfp8` | MXFP8 CUDA 反量化与 CPU reference 的数值、输出 dtype 对照。 | +| `quant_dequant_nvfp4_cuda_tests` | `test_nvfp4_cuda.cu` | `integration`, `cuda`, `nvfp4` | NVFP4 CUDA 量化的 packed payload、local/global scale 对照。 | +| `quant_dequant_nvfp4_dequantize_cuda_tests` | `test_nvfp4_dequantize_cuda.cu` | `integration`, `cuda`, `nvfp4` | NVFP4 packed load/解包、跨行 nibble scale 索引和三种输出 dtype 对照。 | + +## 构建与运行 + +以下命令均在 `02_quant_dequant/黄新颖` 目录执行。RTX 4060 使用 SM89,因此选择 +`rtx4060-debug` 或 `rtx4060-release` preset。 + +```shell +cmake --preset rtx4060-debug +cmake --build --preset rtx4060-debug -j + +# 运行全部 CTest 条目;失败时打印对应进程的输出。 +ctest --preset rtx4060-debug --output-on-failure +``` + +先查看可用条目和编号: + +```shell +ctest --preset rtx4060-debug -N +``` + +### 按名称运行 + +`-R` 接收正则表达式。使用首尾锚点可确保只命中一个完整测试名: + +```shell +ctest --preset rtx4060-debug \ + -R '^quant_dequant_nvfp4_dequantize_cuda_tests$' \ + --output-on-failure +``` + +### 按标签运行 + +`-L` 以正则表达式匹配标签。多次提供 `-L` 时,CTest 只保留同时匹配所有表达式的 +测试,因此可以准确取交集: + +```shell +# 所有确实会进入 CUDA pipeline 或 device codec 的测试。 +ctest --preset rtx4060-debug -L cuda --output-on-failure + +# 只运行 NVFP4 的 CUDA 测试。 +ctest --preset rtx4060-debug -L cuda -L nvfp4 --output-on-failure + +# 不需要实际 CUDA device 的 host unit 测试。 +ctest --preset rtx4060-debug -L unit --output-on-failure + +# 仅运行文件格式 I/O 测试。 +ctest --preset rtx4060-debug -L io --output-on-failure +``` + +当前标签含义如下: + +| 标签 | 含义 | +| --- | --- | +| `unit` | 单模块正确性测试;通常不需要实际 GPU。 | +| `integration` | 跨越 public API、Thrust device buffer 或 pipeline 的测试。 | +| `cuda` | 编译 CUDA device 代码或测试 CUDA pipeline 的测试。 | +| `config`、`io`、`codec`、`reference`、`model`、`metrics`、`common`、`profile` | 按实现层筛选。 | +| `mxfp8`、`nvfp4` | 按低精度格式筛选。 | + +## CUDA 可用性与跳过语义 + +项目必须能在没有 NVIDIA driver 或没有 GPU 的普通开发/CI 环境中执行 host 测试。 +因此 CUDA 测试自身先调用 `cudaGetDeviceCount()`: + +- 返回 `cudaErrorNoDevice`、`cudaErrorInsufficientDriver` 或 device 数量为零时, + 测试打印“跳过”说明并以退出码 `0` 结束。 +- 其他 CUDA runtime 错误视为真正失败,测试返回非零。 +- 在 RTX 4060 上,CUDA 对照测试会实际分配 device buffer、发射 kernel,并将结果 + 与 CPU reference 比较。 + +这表示“CTest 通过”至少说明 host 路径和 CUDA 编译/链接正确;只有在可用 GPU 上 +通过 `cuda` 标签测试,才能说明实际 kernel 数值路径也已经执行并通过。 + +## CUDA kernel 计时边界 + +`quantize_cuda_profiled()` 与 `dequantize_cuda_profiled()` 为一次调用创建独占的 +non-blocking `CudaStream`。操作提交顺序固定如下: + +```text +H2D 拷贝 → start event → 量化或反量化的全部 kernel → stop event → D2H 拷贝 → 同步 host +``` + +同一条 stream 保证 start event 先等待 H2D、stop event 后等待全部 kernel;读取 +elapsed time 时同步的是 stop event,因而 `kernel_ms` 是两个 event 间的 device 时间。 +它不包括 H2D/D2H、Thrust 容器分配、CPU 配置校验、QDTENSOR/QDWGT 文件 I/O,也不包括 +首次使用 CUDA 时的 context 初始化。极短 kernel 可能因 CUDA Event 分辨率得到 `0.0 ms`, +这是合法计时值;实际性能报告宜对足够大的矩阵或多次迭代取统计量。 + +## 新增测试的约定 + +新增一个功能时,优先让测试文件与被测模块对应。当前 `metrics` 已遵循这一规则, +由 `tests/test_metrics.cpp` 单独覆盖;未来的新模块也不要把断言塞入无关的 I/O 或 +reference 测试中。 + +1. 在测试源中定义一个返回进程退出码的函数: + + ```cpp + /** @brief 运行 metrics 模块的正确性测试。 */ + int run_metrics_tests() { + // 成功返回 0;失败打印可定位诊断并返回非零。 + return 0; + } + ``` + +2. 在 `tests/CMakeLists.txt` 中注册一个独立 target;标签应同时表达层级和格式: + + ```cmake + add_quant_dequant_test(quant_dequant_metrics_tests + test_metrics.cpp run_metrics_tests "unit;metrics") + ``` + +3. 数值格式 kernel 必须与 CPU reference 对照。测试至少覆盖典型值、零、最大有限 + 值、尾 block 和异常输入;NVFP4 还要覆盖奇数元素导致的 tail nibble 与奇数列 + 导致的跨行 packed byte。 + +4. 测试所需的小型二进制样例可以放在 `tests/data/` 并提交 Git;运行生成的大型 + `.bin`、日志和临时输出应写到构建目录或 `outputs/`,继续由 `.gitignore` 排除。 + +## 诊断顺序 + +出现失败时,建议按下面的范围逐步缩小: + +1. `ctest -R '^具体失败测试名$' --output-on-failure` 复现单项。 +2. 位编码、scale、nibble 问题先跑 `codec`,再跑对应的 `reference`。 +3. CPU reference 已正确而 CUDA 对照失败时,再运行 `-L cuda -L mxfp8` 或 + `-L cuda -L nvfp4`,检查 H2D、kernel 索引、D2H 和 CPU/GPU 比较诊断。 +4. 文件问题先运行 `-L io`;不要用 CUDA 测试来排查 QDTENSOR/QDWGT header。 + +这样的顺序从无状态数值规则逐步扩展到矩阵流程、文件边界和 device 执行,能避免 +一次调试同时混入格式、I/O 与 CUDA 三类问题。 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/config.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/config.hpp" new file mode 100644 index 00000000..513b3e8b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/config.hpp" @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include + +#include "quant_dequant/types.hpp" + +namespace quant_dequant { + +/** + * @brief 表示量化配置文件的读取、语法或语义校验错误。 + * + * 异常消息已经包含文件路径和可用时的行号;调用方也可以通过访问器取得 + * 结构化信息,用于命令行程序或测试中的额外诊断。 + */ +class ConfigError final : public std::runtime_error { +public: + /** + * @brief 构造一个配置错误。 + * + * @param file_path 发生错误的配置文件路径。 + * @param line_number 发生错误的 1-based 行号;值为 0 表示错误不对应某一行。 + * @param detail 面向用户的具体错误说明。 + */ + ConfigError(std::filesystem::path file_path, std::size_t line_number, + std::string detail); + + /** + * @brief 返回发生错误的配置文件路径。 + * + * @return 不拥有文件系统资源的常量路径引用。 + */ + [[nodiscard]] const std::filesystem::path& filePath() const noexcept; + + /** + * @brief 返回发生错误的 1-based 行号。 + * + * @return 行号;0 表示错误不对应单独的一行。 + */ + [[nodiscard]] std::size_t lineNumber() const noexcept; + +private: + std::filesystem::path mFilePath; + std::size_t mLineNumber{0U}; +}; + +/** + * @brief 读取单方向量化所需的 `key = value` 配置。 + * + * 必填 key 为 `format`、`block_size`、`scale_mode` 和 `rounding`; + * `stochastic_seed` 可选,省略时为 0。文件可额外包含完整 app 配置所需的 + * `output_type` 与 `target_gpu`,但本函数只返回量化阶段的配置。 + * + * @param config_path 输入配置文件路径。 + * @return 已完成语法和量化语义校验的 QuantizationConfig。 + * @throws ConfigError 文件无法打开,或配置内容不符合项目约定时抛出。 + */ +[[nodiscard]] QuantizationConfig load_quantization_config( + const std::filesystem::path& config_path); + +/** + * @brief 读取单方向反量化所需的 `key = value` 配置。 + * + * 必填 key 为 `output_type`。文件可额外包含量化或报告字段,但本函数只返回 + * 反量化输出类型,因此反量化工具不需要伪造 format、scale 或 target GPU。 + * + * @param config_path 输入配置文件路径。 + * @return 已完成语法和反量化语义校验的 DequantizationConfig。 + * @throws ConfigError 文件无法打开,或配置内容不符合项目约定时抛出。 + */ +[[nodiscard]] DequantizationConfig load_dequantization_config( + const std::filesystem::path& config_path); + +/** + * @brief 读取 `apps/main.cpp` 完整流程所需的 `key = value` 配置。 + * + * 必填 key 为 `format`、`block_size`、`scale_mode`、`rounding`、`output_type` + * 和 `target_gpu`;`stochastic_seed` 可选。这个聚合对象只应用于 app 编排, + * 后续必须把其子配置分别传给量化、反量化和日志模块。 + * + * @param config_path 输入配置文件路径。 + * @return 已完成完整流程语义校验的 AppConfig。 + * @throws ConfigError 文件无法打开,或配置内容不符合项目约定时抛出。 + */ +[[nodiscard]] AppConfig load_app_config(const std::filesystem::path& config_path); + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/metrics.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/metrics.hpp" new file mode 100644 index 00000000..7d85fb2f --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/metrics.hpp" @@ -0,0 +1,263 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/quantized_tensor.hpp" +#include "quant_dequant/tensor_io.hpp" +#include "quant_dequant/types.hpp" + +namespace quant_dequant { + +/** @brief 表示指标计算或运行报告构造/写入无法继续时的错误。 */ +class MetricsError final : public std::runtime_error { +public: + /** + * @brief 构造带有面向调用方诊断信息的指标错误。 + * + * @param detail 错误的具体原因。 + */ + explicit MetricsError(std::string detail); +}; + +/** @brief 原始 FP32 参考值和最终反量化值之间的误差统计。 */ +struct ErrorMetrics { + /** 全部元素绝对误差中的最大值。 */ + double max_abs{0.0}; + + /** 全部元素绝对误差的算术平均值。 */ + double mae{0.0}; + + /** 全部元素平方误差的算术平均值。 */ + double mse{0.0}; +}; + +/** @brief 写入产物和逻辑量化布局的字节数。 */ +struct ArtifactMetrics { + /** 原始 QDTENSOR 数值 payload 的字节数,不包含 64-byte header。 */ + std::uint64_t input_payload_bytes{0U}; + + /** 实际写出的完整 QDWGT 文件字节数,包含 header 和 section 对齐 padding。 */ + std::uint64_t quantized_file_bytes{0U}; + + /** 实际写出的完整反量化 QDTENSOR 文件字节数,包含 header。 */ + std::uint64_t dequantized_file_bytes{0U}; + + /** 低精度 payload 的真实物理字节数;NVFP4 已按两个 nibble 每字节计算。 */ + std::uint64_t payload_bytes{0U}; + + /** local-scale 数组的物理字节数。当前 MXFP8/NVFP4 都是一项一字节。 */ + std::uint64_t local_scale_bytes{0U}; + + /** NVFP4 为 4,MXFP8 为 0 的 global-scale 逻辑字节数。 */ + std::uint64_t global_scale_bytes{0U}; +}; + +/** @brief 低精度逻辑布局和实际 QDWGT 文件得到的压缩率。 */ +struct CompressionMetrics { + /** payload、local scale 与 NVFP4 global scale 的逻辑总字节数。 */ + std::uint64_t logical_quantized_bytes{0U}; + + /** input payload / logical quantized bytes,不包含任何文件 header 或 padding。 */ + double logical_compression_ratio{0.0}; + + /** input payload / complete QDWGT file bytes,反映实际落盘空间占用。 */ + double on_disk_compression_ratio{0.0}; +}; + +/** @brief CUDA kernel 计时及按项目逻辑读写量定义的有效带宽。 */ +struct KernelPerformance { + /** 量化阶段所有 kernel 的合计时间;CPU/reference 路径为 null。 */ + std::optional quant_kernel_ms{}; + + /** 反量化阶段所有 kernel 的合计时间;CPU/reference 路径为 null。 */ + std::optional dequant_kernel_ms{}; + + /** + * @brief 量化逻辑读写字节数除以 quant_kernel_ms,单位 GB/s。 + * + * 无计时或 CUDA Event 分辨率将极短 kernel 量为 0 ms 时为 null;后者的带宽 + * 无法定义为有限数,但 `quant_kernel_ms = 0.0` 仍会如实保留。 + */ + std::optional quant_effective_bandwidth_gbps{}; + + /** + * @brief 反量化逻辑读写字节数除以 dequant_kernel_ms,单位 GB/s。 + * + * 无计时或 CUDA Event 分辨率将极短 kernel 量为 0 ms 时为 null。 + */ + std::optional dequant_effective_bandwidth_gbps{}; +}; + +/** + * @brief 一次完整 app 运行需要写入 JSON 的所有稳定报告字段。 + * + * 该对象只保存值和元数据,不保存输入/输出大数组,也不持有 CUDA event、stream 或 + * 文件句柄。app 在量化、反量化、写文件后构造它,再交给 `write_run_report_json()`。 + */ +struct RunReport { + /** 原始输入 QDTENSOR 的形状与物理输入 dtype。 */ + TensorDesc input_desc{}; + + /** 本次量化使用的格式、block、scale 和舍入配置。 */ + QuantizationConfig quantization{}; + + /** 本次反量化写出 QDTENSOR 的目标物理 dtype。 */ + DequantizationConfig dequantization{}; + + /** 仅用于实验记录的 GPU 名称。 */ + std::string target_gpu{}; + + /** 输入、输出文件和低精度布局的字节统计。 */ + ArtifactMetrics artifacts{}; + + /** 原始输入和最终物理输出之间的误差。 */ + ErrorMetrics error{}; + + /** 逻辑布局与真实文件大小得到的压缩率。 */ + CompressionMetrics compression{}; + + /** CUDA kernel 计时和带宽;CPU/reference 路径允许所有字段为 null。 */ + KernelPerformance performance{}; +}; + +/** + * @brief 计算两个等长、有限 FP32 数组的最大绝对误差、MAE 与 MSE。 + * + * 调用方应传入最终会写入 QDTENSOR 的数值:当 `output_type` 为 FP16/BF16 时, + * 必须先完成与实际文件写入一致的窄化再扩展,避免漏掉物理输出类型带来的误差。 + * + * @param reference 原始输入扩展得到的 row-major FP32 参考值。 + * @param actual 最终反量化输出对应的 row-major FP32 值。 + * @return 使用 double 累积得到的三项误差统计。 + * @throws MetricsError 数组为空、长度不同或含 NaN/Inf 时抛出。 + */ +[[nodiscard]] ErrorMetrics compute_error_metrics( + std::span reference, + std::span actual); + +/** + * @brief 推导给定输入描述的 QDTENSOR 数值 payload 字节数。 + * + * @param input_desc 合法的 FP16 或 FP32 输入描述。 + * @return 不含 QDTENSOR header 的原始输入 payload 字节数。 + * @throws MetricsError 描述、dtype 或字节数溢出不合法时抛出。 + */ +[[nodiscard]] std::uint64_t compute_input_payload_bytes( + const TensorDesc& input_desc); + +/** + * @brief 推导目标反量化 QDTENSOR 数值 payload 的字节数。 + * + * @param output_desc 合法的 FP16、BF16 或 FP32 输出描述。 + * @return 不含 QDTENSOR header 的输出 payload 字节数。 + * @throws MetricsError 描述、dtype 或字节数溢出不合法时抛出。 + */ +[[nodiscard]] std::uint64_t compute_dequantized_payload_bytes( + const TensorDesc& output_desc); + +/** + * @brief 从自洽 QuantizedTensor 推导量化逻辑存储字节数。 + * + * 结果固定为 `payload + local_scales + (NVFP4 ? 4 : 0)`,不包含 QDWGT 128-byte + * header 或 section padding,适用于逻辑压缩率和 kernel 逻辑读写量。 + * + * @param quantized 已完成量化的 host 低精度张量。 + * @return 低精度逻辑存储的总字节数。 + * @throws MetricsError quantized 不自洽或加法溢出时抛出。 + */ +[[nodiscard]] std::uint64_t compute_logical_quantized_bytes( + const QuantizedTensor& quantized); + +/** + * @brief 构造量化文件布局及实际输出文件大小的字节统计。 + * + * @param input_desc 原始 QDTENSOR 输入描述。 + * @param quantized 已完成量化的低精度结果。 + * @param dequantized_output_desc 最终写出 QDTENSOR 的输出描述。 + * @param quantized_file_bytes 实际 QDWGT 文件总字节数。 + * @param dequantized_file_bytes 实际输出 QDTENSOR 文件总字节数。 + * @return 可直接写入 `RunReport::artifacts` 的统计结构。 + * @throws MetricsError 描述、量化结果或计算出的字节数不合法时抛出。 + */ +[[nodiscard]] ArtifactMetrics make_artifact_metrics( + const TensorDesc& input_desc, + const QuantizedTensor& quantized, + const TensorDesc& dequantized_output_desc, + std::uint64_t quantized_file_bytes, + std::uint64_t dequantized_file_bytes); + +/** + * @brief 从产物字节统计计算逻辑压缩率和落盘压缩率。 + * + * @param artifacts 由 `make_artifact_metrics()` 产生的字节统计。 + * @return 与 QDWGT/QDTENSOR 文件布局一致的压缩率结构。 + * @throws MetricsError 输入、逻辑量化或 QDWGT 文件字节数为零时抛出。 + */ +[[nodiscard]] CompressionMetrics compute_compression_metrics( + const ArtifactMetrics& artifacts); + +/** + * @brief 用逻辑读写字节数和 kernel 时间计算十进制 GB/s。 + * + * @param logical_bytes 该阶段的逻辑读写字节总数。 + * @param kernel_ms CUDA Event 测得的 kernel 合计时间,单位毫秒。 + * @return `logical_bytes / (kernel_ms * 1e6)`,单位 GB/s。 + * @throws MetricsError logical_bytes 为零,或 kernel_ms 非有限/非正时抛出。 + */ +[[nodiscard]] double compute_effective_bandwidth_gbps( + std::uint64_t logical_bytes, + double kernel_ms); + +/** + * @brief 从可选 CUDA kernel 时间构造完整性能指标。 + * + * quantize 的逻辑读写量为输入 payload 加量化逻辑字节数;dequantize 的逻辑读写量 + * 为量化逻辑字节数加最终输出 payload。无 CUDA 计时时保持 null,不伪造 0.0。 + * + * @param artifacts 当前运行的产物字节统计。 + * @param quantized 已完成量化的低精度张量。 + * @param dequantized_output_desc 最终写出 QDTENSOR 的输出描述,用于精确推导 + * 反量化 kernel 的逻辑写入 payload 字节数。 + * @param quant_kernel_ms 量化 kernel 合计时间;CPU/reference 路径传 null。 + * @param dequant_kernel_ms 反量化 kernel 合计时间;CPU/reference 路径传 null。 + * 已提供的合法 0 ms CUDA Event 时间会保留为 `0.0`,但对应带宽为 null,避免 + * 除以零;正常的正时间会同时产生带宽。 + * + * @return 包含可选时间和可选带宽的性能结构。 + * @throws MetricsError 量化结果、字节数或已提供的时间不合法时抛出。 + */ +[[nodiscard]] KernelPerformance make_kernel_performance( + const ArtifactMetrics& artifacts, + const QuantizedTensor& quantized, + const TensorDesc& dequantized_output_desc, + std::optional quant_kernel_ms, + std::optional dequant_kernel_ms); + +/** + * @brief 将自洽 RunReport 序列化为稳定、带两个空格缩进的 JSON 文本。 + * + * 可选 kernel 时间和带宽会写为 JSON `null`,而不是数值 0。该函数不访问文件, + * 方便测试精确检查字段和让 app 自行决定写入位置。 + * + * @param report 完整 app 运行的报告对象。 + * @return 以换行结尾的 JSON 文本。 + * @throws MetricsError 报告字段违反本项目约定时抛出。 + */ +[[nodiscard]] std::string serialize_run_report_json(const RunReport& report); + +/** + * @brief 将 RunReport 以 JSON 写入指定路径。 + * + * @param output_path 要创建或覆盖的报告 JSON 文件。 + * @param report 待序列化的完整报告。 + * @throws MetricsError 路径无法写入或报告不合法时抛出。 + */ +void write_run_report_json(const std::filesystem::path& output_path, + const RunReport& report); + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantize.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantize.hpp" new file mode 100644 index 00000000..6298a1f2 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantize.hpp" @@ -0,0 +1,184 @@ +#pragma once + +#include +#include + +#include "quant_dequant/quantized_tensor.hpp" +#include "quant_dequant/tensor_io.hpp" +#include "quant_dequant/types.hpp" + +namespace quant_dequant { + +/** + * @brief 表示 CPU reference 量化或反量化流程无法继续时的错误。 + * + * 该异常用于参数不自洽、量化结果不自洽,以及某个格式的 reference 后端尚未 + * 实现等情况。它不表示 CUDA runtime 错误;CUDA pipeline 的接口使用独立的 + * `CudaPipelineError`。 + */ +class ReferenceError final : public std::runtime_error { +public: + /** + * @brief 构造带有具体诊断信息的 CPU reference 错误。 + * + * @param detail 面向调用方的错误说明。 + */ + explicit ReferenceError(std::string detail); +}; + +/** + * @brief 使用 CPU reference 量化一个 host FP32 张量。 + * + * 这是正确性基准接口,不发射 CUDA kernel,也不接触 Thrust device buffer。 + * 函数根据 `config.format` 转发到格式专用 reference 实现。MXFP8 支持 + * tensor/block scaling;NVFP4 支持严格的 block scaling、E4M3 local scale、 + * FP32 global scale 和真实 nibble 打包。 + * + * @param input 输入 host 张量;`values` 必须是 row-major FP32 数据,`desc.dtype` + * 必须记录原输入的 FP16 或 FP32 物理类型。 + * @param config 仅包含量化方向数值语义的配置。 + * @return 量化完成后的 host `QuantizedTensor`。 + * @throws ReferenceError 输入、配置非法或目标格式的 CPU reference 尚未实现时抛出。 + */ +[[nodiscard]] QuantizedTensor quantize_reference( + const HostTensor& input, + const QuantizationConfig& config); + +/** + * @brief 使用 CPU reference 反量化一个 host 侧低精度张量。 + * + * 函数根据 `input.desc.format` 转发到格式专用 reference 实现。返回的 + * `HostTensor::values` 始终是 FP32;`HostTensor::desc.dtype` 则设置为 + * `config.output_type`,供随后写 QDTENSOR 时选择 FP16、BF16 或 FP32 payload。 + * + * @param input 已通过 `QuantizedTensor::isConsistent()` 校验的量化结果。 + * @param config 仅包含反量化输出类型的配置。 + * @return 反量化后的 host FP32 张量及目标物理输出类型。 + * @throws ReferenceError 量化结果、配置非法或目标格式的 CPU reference 尚未实现时抛出。 + */ +[[nodiscard]] HostTensor dequantize_reference( + const QuantizedTensor& input, + const DequantizationConfig& config); + +/** + * @brief 表示 CUDA pipeline 在启动格式专用实现前无法继续时的错误。 + * + * 该异常用于 host 输入、量化描述或配置不自洽,以及某个 CUDA 格式路径尚未 + * 实现的情况。它与 `ReferenceError` 分开,使调用方能够区分 CPU reference 和 + * CUDA pipeline 后端的失败。未来 CUDA runtime API 与 kernel 的实际错误也会 + * 归一到这一异常类型,并保留底层诊断信息。 + */ +class CudaPipelineError final : public std::runtime_error { +public: + /** + * @brief 构造带有具体诊断信息的 CUDA pipeline 错误。 + * + * @param detail 面向调用方的错误说明。 + */ + explicit CudaPipelineError(std::string detail); +}; + +/** + * @brief CUDA 量化的持久化结果及仅 kernel 区间的 Event 计时。 + * + * `kernel_ms` 从同一条非阻塞 stream 上的 start/stop event 得到,不包含 host-device + * 拷贝、Thrust 分配、D2H、文件 I/O、CUDA context 初始化或 CPU 元数据校验。 + */ +struct ProfiledQuantizationResult { + /** 已完成 D2H、可直接传给 QDWGT 写入器的量化结果。 */ + QuantizedTensor tensor{}; + + /** 当前调用中所有量化 kernel 的合计时间,单位毫秒。 */ + double kernel_ms{0.0}; +}; + +/** + * @brief CUDA 反量化的 host 结果及仅 kernel 区间的 Event 计时。 + */ +struct ProfiledDequantizationResult { + /** 已完成 D2H 的统一 FP32 HostTensor;desc 记录目标物理输出类型。 */ + HostTensor tensor{}; + + /** 当前调用中所有反量化 kernel 的合计时间,单位毫秒。 */ + double kernel_ms{0.0}; +}; + +/** + * @brief 使用 CUDA pipeline 量化一个 host 侧输入张量。 + * + * 接口与 `quantize_reference()` 一样接收 `HostTensor`,避免调用方在 CPU 与 GPU + * 后端之间维护两套输入类型。MXFP8 block-scale 路径在一个 warp 内处理一个 + * 32 元素量化 block;tensor-scale 路径使用 V7 风格的固定 CTA `amax` partial + * reduction、单 CTA final reduction/scale 写入和独立编码 kernel。两条路径均已 + * 完成 H2D、device 所有权对象建立、kernel 发射和 D2H,返回值可直接交给 + * `write_quantized_tensor()` 序列化。 + * + * NVFP4 路径先做全局两阶段 amax reduction 写 FP32 global scale,再以 16-lane + * tile 写 E4M3 local scale,最后以 32-lane warp 写 packed E2M1 payload。两种 + * 格式的输入若含 NaN/Inf,公共接口都会拒绝该请求。 + * + * @param input 输入 host 张量;`values` 必须是 row-major FP32 数据,`desc.dtype` + * 必须记录原输入的 FP16 或 FP32 物理类型。 + * @param config 仅包含量化方向数值语义的配置。 + * @return 已完成的 host `QuantizedTensor`;支持 MXFP8 tensor/block 与 NVFP4 block。 + * @throws CudaPipelineError 输入、配置非法、CUDA runtime/kernel/D2H 失败、输入含 + * NaN/Inf 时抛出。 + */ +[[nodiscard]] QuantizedTensor quantize_cuda( + const HostTensor& input, + const QuantizationConfig& config); + +/** + * @brief 使用独占 CUDA stream 执行量化并返回纯 kernel Event 计时。 + * + * H2D 在 start event 前提交;格式专用路径的全部 kernel 都提交到同一条 stream; + * stop event 紧跟最后一个 kernel,随后才执行 D2H。因此本接口适合 app 报告和 + * benchmark,不会把内存传输、分配或文件操作误计为 kernel 时间。 + * + * @param input 输入 host FP32 张量,描述 dtype 为原始 FP16/FP32 物理类型。 + * @param config 合法的 MXFP8 或 NVFP4 量化配置。 + * @return 量化结果和该格式路径所有 kernel 的合计毫秒数。 + * @throws CudaPipelineError 输入、配置、stream、CUDA runtime、kernel 或 D2H 失败时抛出。 + */ +[[nodiscard]] ProfiledQuantizationResult quantize_cuda_profiled( + const HostTensor& input, + const QuantizationConfig& config); + +/** + * @brief 使用 CUDA pipeline 反量化一个 host 侧低精度张量。 + * + * MXFP8 路径会把 `QuantizedTensor` 的 E4M3 payload 与 E8M0 local scale 传输到 + * device,按 `input.desc.scale_mode` 计算 scale 下标;NVFP4 则传输 packed E2M1 + * payload、E4M3 local scale 与 FP32 global scale,由一个线程解包一个物理 byte。 + * 两条路径均发射格式专用反量化 kernel,并将结果回传为 FP32 `HostTensor::values`。 + * 返回描述中的 `dtype` 设为 `config.output_type`,由张量 I/O 层决定写出 FP16、 + * BF16 或 FP32 的物理 payload。 + * + * 支持 MXFP8 的 tensor/block-scale,以及 NVFP4 的严格 rowwise block-scale。 + * + * @param input 已通过 `QuantizedTensor::isConsistent()` 校验的量化结果。 + * @param config 仅包含反量化输出类型的配置。 + * @return 已完成的 host FP32 张量及目标物理输出类型。 + * @throws CudaPipelineError 量化结果、配置非法、CUDA runtime/kernel/D2H 失败, + * 时抛出。 + */ +[[nodiscard]] HostTensor dequantize_cuda( + const QuantizedTensor& input, + const DequantizationConfig& config); + +/** + * @brief 使用独占 CUDA stream 执行反量化并返回纯 kernel Event 计时。 + * + * QDWGT buffer 的 H2D 完成后才记录 start event;格式专用反量化 kernel 结束后立刻 + * 记录 stop event;FP32 输出的 D2H 位于计时区间之外。 + * + * @param input 已验证的 MXFP8 或 NVFP4 host 量化张量。 + * @param config 合法的反量化输出类型配置。 + * @return 反量化 HostTensor 和 kernel 毫秒数。 + * @throws CudaPipelineError 输入、配置、stream、CUDA runtime、kernel 或 D2H 失败时抛出。 + */ +[[nodiscard]] ProfiledDequantizationResult dequantize_cuda_profiled( + const QuantizedTensor& input, + const DequantizationConfig& config); + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_io.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_io.hpp" new file mode 100644 index 00000000..7519218f --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_io.hpp" @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +#include "quant_dequant/quantized_tensor.hpp" + +namespace quant_dequant { + +/** + * @brief 表示 QDWGT 量化权重文件读写过程中的错误。 + */ +class QuantizedIoError final : public std::runtime_error { +public: + /** + * @brief 构造一个量化权重 I/O 错误。 + * + * @param file_path 发生错误的 QDWGT 文件路径。 + * @param detail 面向用户的错误说明。 + */ + QuantizedIoError(std::filesystem::path file_path, std::string detail); + + /** + * @brief 返回发生错误的文件路径。 + * + * @return 不拥有资源的常量路径引用。 + */ + [[nodiscard]] const std::filesystem::path& filePath() const noexcept; + +private: + std::filesystem::path mFilePath; +}; + +/** + * @brief 将完整的 host 侧量化结果写为 QDWGT v1 文件。 + * + * 写入器使用固定的 128 字节 little-endian header,紧跟 packed payload,并将 + * local scale section 向上对齐到 8 字节。它不会直接写入 C++ struct,因此不会 + * 受编译器 padding 或 host ABI 影响。 + * + * MXFP8 在文件 header 的 `global_scale` 槽写固定 1.0F;NVFP4 则写入 + * `tensor.global_scale`。这与 `QuantizedTensor` 的内存语义保持分离。 + * + * @param output_path 要创建或覆盖的 QDWGT 文件路径。 + * @param tensor 已完成量化且必须满足 `QuantizedTensor::isConsistent()` 的结果。 + * @throws QuantizedIoError 输出路径无法写入或量化结果不自洽时抛出。 + */ +void write_quantized_tensor(const std::filesystem::path& output_path, + const QuantizedTensor& tensor); + +/** + * @brief 读取一个 QDWGT v1 文件,并构造统一的 host 侧量化结果。 + * + * 读取器按固定 offset 逐字段解析 little-endian header,不依赖本机 struct + * 的大小或任何当前配置文件。读取前会验证格式组合、shape、payload/scale + * 长度、section offset、NVFP4 flags、global scale 和文件总长度。 + * + * @param input_path 待读取的 QDWGT 文件路径。 + * @return 通过所有 QDWGT v1 校验的量化结果。 + * @throws QuantizedIoError 文件无法读取或不符合 QDWGT v1 格式时抛出。 + */ +[[nodiscard]] QuantizedTensor read_quantized_tensor( + const std::filesystem::path& input_path); + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_tensor.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_tensor.hpp" new file mode 100644 index 00000000..a02d3727 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/quantized_tensor.hpp" @@ -0,0 +1,264 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/types.hpp" + +namespace quant_dequant { + +/** + * @brief 描述一个已经完成量化、可保存为 QDWGT 文件的张量。 + * + * 这是 CPU reference 和 CUDA pipeline 在完成量化后共同交付的元数据。 + * 它刻意不持有 CUDA device 指针或 `thrust::device_vector`:那些对象只属于 + * pipeline 的短暂执行过程;本描述和 `QuantizedTensor` 则可安全地传给 I/O、 + * 测试和误差统计模块。 + * + * `source_desc.dtype` 记录量化前文件中的物理类型,且第一版仅允许 FP16 或 + * FP32。反量化目标类型不属于量化结果本身,应从 + * `DequantizationConfig::output_type` 或反量化 API 的参数取得。 + */ +struct QuantizedTensorDesc { + /** 原始 row-major 矩阵的形状和物理输入类型。 */ + TensorDesc source_desc{}; + + /** MXFP8 或 NVFP4,决定 payload 和 local scale 的解释规则。 */ + QuantFormat format{QuantFormat::kUnknown}; + + /** MXFP8 可共享整张张量或每个 rowwise block 的 scale;NVFP4 仅允许后者。 */ + ScaleMode scale_mode{ScaleMode::kUnknown}; + + /** 产生 payload 时使用的舍入规则,写入文件以支持可复现实验。 */ + RoundingMode rounding{RoundingMode::kUnknown}; + + /** + * @brief stochastic rounding 的确定性种子。 + * + * nearest 模式没有随机过程,必须为 0;stochastic 模式允许任意 + * `uint64_t` 值(包括 0)。 + */ + std::uint64_t stochastic_seed{0U}; + + /** + * @brief 格式的原生 block size。 + * + * MXFP8 固定为 32,NVFP4 固定为 16。MXFP8 即使 `scale_mode` 为 tensor, + * 也保留该值,避免同一格式出现多种不兼容的 header 解释;NVFP4 不支持 + * tensor mode。 + */ + std::uint32_t block_size{0U}; + + /** 第一版仅支持按行切分、按行优先排列的局部 scale。 */ + ScaleLayout scale_layout{ScaleLayout::kRowwise}; + + /** + * @brief 返回逻辑元素个数。 + * + * @return `num_rows * num_cols`;零维或乘法溢出时返回空值。 + */ + [[nodiscard]] constexpr std::optional elementCount() const noexcept { + return source_desc.elementCount(); + } + + /** + * @brief 返回最终 packed payload 的精确字节数。 + * + * MXFP8 的每个 E4M3 code 占一个字节;NVFP4 的两个 E2M1 nibble + * 共用一个字节,因此奇数个元素时最后一个字节的高 nibble 保留为零。 + * + * @return 合法形状和已知格式时返回 payload 字节数;否则返回空值。 + */ + [[nodiscard]] constexpr std::optional expectedPayloadBytes() const noexcept { + const auto element_count = elementCount(); + if (!element_count.has_value()) { + return std::nullopt; + } + + switch (format) { + case QuantFormat::kMxfp8: + return element_count; + + case QuantFormat::kNvfp4: + // 用除法和余数计算 ceil(n / 2),避免先做 n + 1 的上溢风险。 + return (*element_count / 2U) + (*element_count % 2U); + + case QuantFormat::kUnknown: + return std::nullopt; + } + + return std::nullopt; + } + + /** + * @brief 返回 rowwise block 模式下一行所需的 local scale 数量。 + * + * @return block 模式下返回 `ceil(num_cols / block_size)`;格式、形状或 + * block size 非法时返回空值。 + */ + [[nodiscard]] constexpr std::optional blocksPerRow() const noexcept { + if (scale_mode != ScaleMode::kBlock || + !is_valid_scale_mode(format, scale_mode) || + !is_valid_block_size(format, block_size) || + !source_desc.elementCount().has_value()) { + return std::nullopt; + } + + const std::uint64_t block_size_u64 = static_cast(block_size); + return (source_desc.num_cols / block_size_u64) + + (source_desc.num_cols % block_size_u64 == 0U ? 0U : 1U); + } + + /** + * @brief 返回 local scale 字节数组中应有的元素数量。 + * + * 两种已支持格式的 local scale 都恰好编码为一个字节:MXFP8 是 E8M0, + * NVFP4 是 E4M3。因此 scale 数量也就是 scale 字节数。具体编码类型由 + * `local_scale_type(format)` 推导,不在此结构中重复保存。 + * + * @return tensor 模式返回 1;block 模式返回 + * `num_rows * ceil(num_cols / block_size)`;非法元数据或溢出时返回空值。 + */ + [[nodiscard]] constexpr std::optional expectedLocalScaleCount() const noexcept { + if (!is_valid_block_size(format, block_size) || + !is_valid_scale_mode(format, scale_mode) || + !source_desc.elementCount().has_value()) { + return std::nullopt; + } + + if (scale_mode == ScaleMode::kTensor) { + return 1U; + } + + const auto blocks_per_row = blocksPerRow(); + if (!blocks_per_row.has_value()) { + return std::nullopt; + } + + constexpr std::uint64_t kMaxValue = std::numeric_limits::max(); + if (source_desc.num_rows > kMaxValue / *blocks_per_row) { + return std::nullopt; + } + + return source_desc.num_rows * *blocks_per_row; + } + + /** + * @brief 判断该格式在内存语义上是否需要 `global_scale`。 + * + * NVFP4 的反量化公式包含一个解码方向的 FP32 global scale;MXFP8 不使用 + * 它。QDWGT 文件中 MXFP8 的相应 header 槽仍写固定的 1.0F,属于文件 + * 布局哨兵,而不是本对象的一项语义数据。 + * + * @return 仅 NVFP4 返回 true。 + */ + [[nodiscard]] constexpr bool usesGlobalScale() const noexcept { + return format == QuantFormat::kNvfp4; + } + + /** + * @brief 验证不依赖 payload 内容的量化元数据。 + * + * @return 格式、输入描述、scale 组织和舍入元数据均符合第一版规范时返回 + * true。 + */ + [[nodiscard]] constexpr bool isMetadataValid() const noexcept { + const bool valid_format = format == QuantFormat::kMxfp8 || + format == QuantFormat::kNvfp4; + const bool valid_rounding = rounding == RoundingMode::kNearest || + rounding == RoundingMode::kStochastic; + + return valid_format && is_valid_scale_mode(format, scale_mode) && valid_rounding && + source_desc.isValid() && is_supported_input_dtype(source_desc.dtype) && + is_valid_block_size(format, block_size) && + scale_layout == ScaleLayout::kRowwise && + (rounding != RoundingMode::kNearest || stochastic_seed == 0U) && + expectedPayloadBytes().has_value() && + expectedLocalScaleCount().has_value(); + } +}; + +/** + * @brief CPU reference 与 CUDA quantize pipeline 的统一 host 侧量化结果。 + * + * 所有 vector 都拥有 host 内存。CUDA 路径在 device 上完成计算并 D2H 拷回后, + * 构造同一个对象;CPU reference 则直接构造它。因此后续 QDWGT I/O、逐字节 + * CPU/CUDA 对照和日志模块都只需处理一种结果类型。 + * + * `payload` 的存储约定由 `desc.format` 决定: + * - MXFP8:一个 E4M3 code 一个字节; + * - NVFP4:偶数线性下标在低 nibble,奇数线性下标在高 nibble。 + * + * `local_scales` 每项均为一个编码字节,但 MXFP8 解释为 E8M0,NVFP4 解释为 + * E4M3。`global_scale` 只在 NVFP4 中存在,且应为有限正的 FP32 值。 + */ +struct QuantizedTensor { + /** 与所有后续数组共同解释的不可缺少元数据。 */ + QuantizedTensorDesc desc{}; + + /** 按 row-major 线性顺序存储的真实位宽 packed 元素 code。 */ + std::vector payload{}; + + /** 按 `desc.scale_layout` 排列的格式特有局部 scale code。 */ + std::vector local_scales{}; + + /** + * @brief NVFP4 的解码方向 FP32 global scale;MXFP8 必须为 `std::nullopt`。 + * + * 这样在内存模型中区分“不存在 global scale”的 MXFP8 与“数值恰好等于 + * 1.0F”的 NVFP4。序列化时由 `quantized_io` 将 MXFP8 的物理 header 槽写为 + * 固定 1.0F。 + */ + std::optional global_scale{}; + + /** + * @brief 验证元数据、数组长度和格式特有的内存不变量。 + * + * 本函数验证容器可以安全地被后续量化文件写入器和反量化器解释,但不会 + * 判断每一个 E4M3/E2M1 code 是否数值“正常”:NaN code 仍是可表示的低 + * 精度 bit pattern,应由格式 codec 决定它的数值语义。 + * + * @return 完整对象自洽时返回 true。 + */ + [[nodiscard]] bool isConsistent() const noexcept { + if (!desc.isMetadataValid()) { + return false; + } + + const auto expected_payload_bytes = desc.expectedPayloadBytes(); + const auto expected_local_scale_count = desc.expectedLocalScaleCount(); + constexpr std::uint64_t kMaxSize = + static_cast(std::numeric_limits::max()); + + if (!expected_payload_bytes.has_value() || + !expected_local_scale_count.has_value() || + *expected_payload_bytes > kMaxSize || + *expected_local_scale_count > kMaxSize || + payload.size() != static_cast(*expected_payload_bytes) || + local_scales.size() != static_cast(*expected_local_scale_count)) { + return false; + } + + if (desc.format == QuantFormat::kMxfp8) { + return !global_scale.has_value(); + } + + if (!global_scale.has_value() || !std::isfinite(*global_scale) || + *global_scale <= 0.0F) { + return false; + } + + const auto element_count = desc.elementCount(); + const bool has_odd_element_count = element_count.has_value() && + (*element_count % 2U != 0U); + // 奇数个 NVFP4 元素时,末尾字节的高 nibble 不属于任何逻辑元素。 + return !has_odd_element_count || + ((payload.back() & static_cast(0xF0U)) == 0U); + } +}; + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/tensor_io.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/tensor_io.hpp" new file mode 100644 index 00000000..85fabe80 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/tensor_io.hpp" @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include + +#include "quant_dequant/types.hpp" + +namespace quant_dequant { + +/** + * @brief 表示 QDTENSOR 文件读取或写入过程中的错误。 + */ +class TensorIoError final : public std::runtime_error { +public: + /** + * @brief 构造一个张量 I/O 错误。 + * + * @param file_path 发生错误的张量文件路径。 + * @param detail 面向用户的错误说明。 + */ + TensorIoError(std::filesystem::path file_path, std::string detail); + + /** + * @brief 返回发生错误的文件路径。 + * + * @return 不拥有资源的常量路径引用。 + */ + [[nodiscard]] const std::filesystem::path& filePath() const noexcept; + +private: + std::filesystem::path mFilePath; +}; +/** + * @brief 保存 host 侧 FP32 数据及其 QDTENSOR 序列化描述的张量。 + * + * 这不是 device tensor 或通用数值抽象;它只服务于 QDTENSOR I/O 与量化 + * pipeline 的 host 端边界。`values` 始终是 row-major 的 FP32 数据; + * `desc.dtype` 则表示其对应 QDTENSOR payload 的物理 dtype:读取输入时它是 + * 文件原始 dtype,写反量化输出时它是将要写出的 FP16、BF16 或 FP32 dtype。 + */ +struct HostTensor { + /** row-major 形状与 QDTENSOR payload 的物理 dtype。 */ + TensorDesc desc{}; + + /** 按 row-major 顺序保存的 host FP32 元素。 */ + std::vector values{}; +}; + +/** + * @brief 读取一个输入用途的 QDTENSOR 文件,并统一转换为 host FP32。 + * + * 文件必须满足 `tensor_role = input`,且 dtype 只能是 FP16 或 FP32。 + * 函数会验证 magic、版本、字节序、header 字段、payload 长度和文件总长度。 + * + * @param input_path 输入 QDTENSOR 文件路径。 + * @return `desc` 保留输入文件物理 dtype、元素已转换为 FP32 的 host 张量。 + * @throws TensorIoError 文件无法读取或不符合 QDTENSOR v1 格式时抛出。 + */ +[[nodiscard]] HostTensor read_input_tensor(const std::filesystem::path& input_path); + +/** + * @brief 读取一个反量化输出用途的 QDTENSOR 文件,并统一转换为 host FP32。 + * + * 文件必须满足 `tensor_role = dequantized_output`,dtype 可以是 FP16、BF16 或 + * FP32。该接口既可用于读取既有反量化结果,也可使完整 app 在写出文件后重新 + * 读取实际物理 payload,再据此统计包含 FP16/BF16 窄化误差的最终误差。 + * + * @param output_path 反量化输出 QDTENSOR 文件路径。 + * @return `desc` 保留输出文件物理 dtype、元素已扩展为 FP32 的 host 张量。 + * @throws TensorIoError 文件无法读取或不符合 QDTENSOR v1 输出格式时抛出。 + */ +[[nodiscard]] HostTensor read_dequantized_tensor( + const std::filesystem::path& output_path); + +/** + * @brief 将 FP32 host 数据写为反量化输出用途的 QDTENSOR 文件。 + * + * `tensor.desc.dtype` 决定文件 payload 的物理类型,可以是 FP16、BF16 + * 或 FP32;`tensor.values` 始终视为 FP32 输入,并按目标 dtype 转换。采用 + * `HostTensor` 使该接口与 `write_quantized_tensor()` 一样,以一个完整的 + * 自描述 host 对象作为写入单位。 + * + * @param output_path 要创建或覆盖的输出文件路径。 + * @param tensor 要写出的 host 张量;`values.size()` 必须等于 `desc` 的元素数。 + * @throws TensorIoError 路径无法写入、描述非法或张量长度不匹配时抛出。 + */ +void write_dequantized_tensor(const std::filesystem::path& output_path, + const HostTensor& tensor); +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/types.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/types.hpp" new file mode 100644 index 00000000..653f0b12 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/types.hpp" @@ -0,0 +1,537 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace quant_dequant +{ + +/** + * @brief MXFP8 rowwise block 的固定元素数量。 + */ +inline constexpr std::uint32_t kMxfp8BlockSize = 32U; + +/** + * @brief NVFP4 rowwise block 的固定元素数量。 + */ +inline constexpr std::uint32_t kNvfp4BlockSize = 16U; + +/** + * @brief 文件格式版本 1 使用的小端字节序标记。 + */ +inline constexpr std::uint8_t kLittleEndianByteOrder = 1U; + + +/** + * @brief 普通张量元素的数据类型。 + * + * 枚举值与 `docs/file_format.md` 中 `QDTENSOR` header 的 `dtype` + * 字段一一对应,不能在不提升文件格式版本的情况下修改。 + */ + +enum class DType:std::uint8_t{ + /** 未初始化或非法值。 */ + kUnknown = 0U, + + /** IEEE 754 binary16。 */ + kFloat16 = 1U, + + /** bfloat16。 */ + kBFloat16 = 2U, + + /** IEEE 754 binary32。 */ + kFloat32 = 3U, +}; +/** + * @brief 低精度量化格式。 + * + * 枚举值与 `QDWGT` header 的 `format` 字段一一对应。 + */ +enum class QuantFormat : std::uint8_t { + /** 未初始化或非法值。 */ + kUnknown = 0U, + + /** MXFP8:E4M3 元素和 E8M0 局部 scale。 */ + kMxfp8 = 1U, + + /** NVFP4:E2M1 元素、E4M3 局部 scale 和 FP32 全局 scale。 */ + kNvfp4 = 2U, +}; +/** + * @brief scale 的共享粒度。 + */ +enum class ScaleMode : std::uint8_t { + /** 未初始化或非法值。 */ + kUnknown = 0U, + + /** 整张矩阵共享 scale。 */ + kTensor = 1U, + + /** 每个 rowwise block 独立保存 scale。 */ + kBlock = 2U, +}; +/** + * @brief 元素编码时使用的舍入策略。 + */ +enum class RoundingMode : std::uint8_t { + /** 未初始化或非法值。 */ + kUnknown = 0U, + + /** Round-to-nearest, ties-to-even。 */ + kNearest = 1U, + + /** 按相邻可表示值距离进行概率选择。 */ + kStochastic = 2U, +}; +/** + * @brief `QDTENSOR` 文件的张量用途。 + */ +enum class TensorRole : std::uint8_t { + /** 输入张量。 */ + kInput = 0U, + + /** CUDA 反量化后的输出张量。 */ + kDequantizedOutput = 1U, +}; +/** + * @brief 量化权重文件中局部 scale 的编码格式。 + */ +enum class LocalScaleType : std::uint8_t { + /** 未初始化或非法值。 */ + kUnknown = 0U, + + /** MXFP8 使用的 E8M0 scale。 */ + kE8M0 = 1U, + + /** NVFP4 使用的 E4M3 scale。 */ + kE4M3 = 2U, +}; +/** + * @brief 局部 scale 的线性排列方式。 + */ +enum class ScaleLayout : std::uint8_t { + /** 未初始化或非法值。 */ + kUnknown = 0U, + + /** + * @brief 每行独立分 block,随后按行优先顺序排列 scale。 + */ + kRowwise = 1U, +}; +/** + * @brief 返回 dtype 的单个元素字节数。 + * + * @param dtype 要查询的元素类型。 + * @return 合法类型返回对应字节数;未知类型返回 0。 + */ +[[nodiscard]] constexpr std::size_t dtype_size_bytes(const DType dtype) noexcept { + switch (dtype) { + case DType::kFloat16: + case DType::kBFloat16: + return 2U; + + case DType::kFloat32: + return 4U; + + case DType::kUnknown: + return 0U; + } + + return 0U; +} +/** + * @brief 判断 dtype 是否可作为输入张量类型。 + * + * @param dtype 要检查的类型。 + * @return 仅 FP16 和 FP32 返回 true。 + */ +[[nodiscard]] constexpr bool is_supported_input_dtype(const DType dtype) noexcept { + return dtype == DType::kFloat16 || dtype == DType::kFloat32; +} +/** + * @brief 判断 dtype 是否可作为反量化输出类型。 + * + * @param dtype 要检查的类型。 + * @return FP16、BF16、FP32 返回 true。 + */ +[[nodiscard]] constexpr bool is_supported_output_dtype(const DType dtype) noexcept { + return dtype == DType::kFloat16 || dtype == DType::kBFloat16 || + dtype == DType::kFloat32; +} +/** + * @brief 返回量化格式的默认且唯一合法 block size。 + * + * @param format 量化格式。 + * @return MXFP8 返回 32,NVFP4 返回 16;未知格式返回 0。 + */ +[[nodiscard]] constexpr std::uint32_t default_block_size( + const QuantFormat format) noexcept { + switch (format) { + case QuantFormat::kMxfp8: + return kMxfp8BlockSize; + + case QuantFormat::kNvfp4: + return kNvfp4BlockSize; + + case QuantFormat::kUnknown: + return 0U; + } + + return 0U; +} +/** + * @brief 判断 block size 是否符合当前量化格式。 + * + * MXFP8 的 tensor mode 也保留原生 block size,以避免同一种格式出现多种 + * 不兼容解释。严格 NVFP4 只允许 block mode。 + * + * @param format 量化格式。 + * @param block_size 待校验的 block size。 + * @return block size 与格式匹配时返回 true。 + */ +[[nodiscard]] constexpr bool is_valid_block_size(const QuantFormat format, + const std::uint32_t block_size) noexcept { + return block_size != 0U && block_size == default_block_size(format); +} + +/** + * @brief 判断量化格式与 scale 共享粒度是否是受支持的组合。 + * + * MXFP8 支持 tensor 与 rowwise block 两种 scale 组织。NVFP4 遵循标准的 + * 分层 block scaling:一个 FP32 global scale 加上每个 16 元素 block 的 + * E4M3 local scale,因此不接受 tensor mode。 + * + * @param format 待检查的低精度格式。 + * @param scale_mode 待检查的 scale 共享粒度。 + * @return 该格式可使用该 scale 模式时返回 true。 + */ +[[nodiscard]] constexpr bool is_valid_scale_mode(const QuantFormat format, + const ScaleMode scale_mode) noexcept { + switch (format) { + case QuantFormat::kMxfp8: + return scale_mode == ScaleMode::kTensor || + scale_mode == ScaleMode::kBlock; + + case QuantFormat::kNvfp4: + return scale_mode == ScaleMode::kBlock; + + case QuantFormat::kUnknown: + return false; + } + + return false; +} +/** + * @brief 返回量化 payload 中一个逻辑元素占用的 bit 数。 + * + * @param format 量化格式。 + * @return MXFP8 返回 8,NVFP4 返回 4;未知格式返回 0。 + */ +[[nodiscard]] constexpr std::uint8_t payload_element_bits( + const QuantFormat format) noexcept { + switch (format) { + case QuantFormat::kMxfp8: + return 8U; + + case QuantFormat::kNvfp4: + return 4U; + + case QuantFormat::kUnknown: + return 0U; + } + + return 0U; +} +/** + * @brief 返回量化格式所使用的局部 scale 类型。 + * + * @param format 量化格式。 + * @return MXFP8 返回 E8M0,NVFP4 返回 E4M3;未知格式返回 Unknown。 + */ +[[nodiscard]] constexpr LocalScaleType local_scale_type( + const QuantFormat format) noexcept { + switch (format) { + case QuantFormat::kMxfp8: + return LocalScaleType::kE8M0; + + case QuantFormat::kNvfp4: + return LocalScaleType::kE4M3; + + case QuantFormat::kUnknown: + return LocalScaleType::kUnknown; + } + + return LocalScaleType::kUnknown; +} +/** + * @brief 将 dtype 转换为配置、日志中使用的稳定文本。 + * + * @param dtype 待转换的类型。 + * @return 不拥有内存的静态字符串视图。 + */ +[[nodiscard]] constexpr std::string_view to_string(const DType dtype) noexcept { + switch (dtype) { + case DType::kFloat16: + return "fp16"; + + case DType::kBFloat16: + return "bf16"; + + case DType::kFloat32: + return "fp32"; + + case DType::kUnknown: + return "unknown"; + } + + return "unknown"; +} + +/** + * @brief 将量化格式转换为配置、日志中使用的稳定文本。 + * + * @param format 待转换的量化格式。 + * @return 不拥有内存的静态字符串视图。 + */ +[[nodiscard]] constexpr std::string_view to_string(const QuantFormat format) noexcept { + switch (format) { + case QuantFormat::kMxfp8: + return "mxfp8"; + + case QuantFormat::kNvfp4: + return "nvfp4"; + + case QuantFormat::kUnknown: + return "unknown"; + } + + return "unknown"; +} + +/** + * @brief 将 scale mode 转换为配置、日志中使用的稳定文本。 + * + * @param scale_mode 待转换的 scale mode。 + * @return 不拥有内存的静态字符串视图。 + */ +[[nodiscard]] constexpr std::string_view to_string(const ScaleMode scale_mode) noexcept { + switch (scale_mode) { + case ScaleMode::kTensor: + return "tensor"; + + case ScaleMode::kBlock: + return "block"; + + case ScaleMode::kUnknown: + return "unknown"; + } + + return "unknown"; +} + +/** + * @brief 将舍入模式转换为配置、日志中使用的稳定文本。 + * + * @param rounding_mode 待转换的舍入模式。 + * @return 不拥有内存的静态字符串视图。 + */ +[[nodiscard]] constexpr std::string_view to_string( + const RoundingMode rounding_mode) noexcept { + switch (rounding_mode) { + case RoundingMode::kNearest: + return "nearest"; + + case RoundingMode::kStochastic: + return "stochastic"; + + case RoundingMode::kUnknown: + return "unknown"; + } + + return "unknown"; +} +/** + * @brief 描述一个不拥有数据的 row-major 二维张量。 + * + * 该结构只保存元数据;host 端元素由 `std::vector` 持有, + * device 端元素由 `thrust::device_vector` 持有。 + */ +struct TensorDesc { + /** 矩阵行数,必须大于 0。 */ + std::uint64_t num_rows{0U}; + + /** 矩阵列数,必须大于 0。 */ + std::uint64_t num_cols{0U}; + + /** 文件中的物理元素类型。 */ + DType dtype{DType::kUnknown}; + + /** + * @brief 返回元素总数,并检测乘法溢出。 + * + * @return 合法形状时返回 `num_rows * num_cols`;零维或溢出时返回空值。 + */ + [[nodiscard]] constexpr std::optional elementCount() const noexcept { + if (num_rows == 0U || num_cols == 0U) { + return std::nullopt; + } + + constexpr std::uint64_t kMaxValue = + std::numeric_limits::max(); + + if (num_rows > kMaxValue / num_cols) { + return std::nullopt; + } + + return num_rows * num_cols; + } + + /** + * @brief 返回连续 row-major payload 所需字节数,并检测溢出。 + * + * @return 合法 dtype 和形状时返回字节数;否则返回空值。 + */ + [[nodiscard]] constexpr std::optional dataBytes() const noexcept { + const auto element_count = elementCount(); + const std::size_t element_size = dtype_size_bytes(dtype); + + if (!element_count.has_value() || element_size == 0U) { + return std::nullopt; + } + + constexpr std::uint64_t kMaxValue = + std::numeric_limits::max(); + const auto element_size_u64 = static_cast(element_size); + + if (*element_count > kMaxValue / element_size_u64) { + return std::nullopt; + } + + return *element_count * element_size_u64; + } + + /** + * @brief 判断张量描述是否可用于普通张量文件。 + * + * @return 形状、dtype 与 payload 字节数都合法时返回 true。 + */ + [[nodiscard]] constexpr bool isValid() const noexcept { + return elementCount().has_value() && dataBytes().has_value(); + } +}; + + +/** + * @brief 仅描述“原始浮点张量如何量化”为低精度 code 的数值配置。 + * + * 此结构不包含输入 dtype:输入 dtype 从 QDTENSOR header 取得;也不包含 + * 反量化输出类型和日志信息。因此 CPU reference、CUDA quantize kernel 与 + * 单独的量化命令都可以只依赖它。 + */ +struct QuantizationConfig { + /** 目标低精度格式。 */ + QuantFormat format{QuantFormat::kUnknown}; + + /** 格式指定的原生 block size。 */ + std::uint32_t block_size{0U}; + + /** MXFP8 支持整张张量或每个 rowwise block 共享 scale;NVFP4 仅支持 block。 */ + ScaleMode scale_mode{ScaleMode::kUnknown}; + + /** 元素编码时使用的舍入模式。 */ + RoundingMode rounding{RoundingMode::kUnknown}; + + /** stochastic rounding 的确定性随机种子;nearest 模式下必须为 0。 */ + std::uint64_t stochastic_seed{0U}; + + /** + * @brief 判断量化阶段所需的数值参数是否自洽。 + * + * @return 格式、block size、scale mode、舍入模式及 seed 均合法时返回 true。 + */ + [[nodiscard]] constexpr bool isValid() const noexcept { + const bool valid_format = format == QuantFormat::kMxfp8 || + format == QuantFormat::kNvfp4; + const bool valid_rounding = rounding == RoundingMode::kNearest || + rounding == RoundingMode::kStochastic; + + return valid_format && is_valid_scale_mode(format, scale_mode) && valid_rounding && + is_valid_block_size(format, block_size) && + (rounding != RoundingMode::kNearest || stochastic_seed == 0U); + } +}; + +/** + * @brief 仅描述“低精度 code 如何反量化并输出”的数值配置。 + * + * 量化权重文件本身并不固化输出类型;同一份 QDWGT 可按此配置分别反量化为 + * FP16、BF16 或 FP32。 + */ +struct DequantizationConfig { + /** 反量化输出 QDTENSOR payload 的物理类型。 */ + DType output_type{DType::kUnknown}; + + /** + * @brief 判断反量化阶段的输出类型是否受支持。 + * + * @return FP16、BF16 或 FP32 时返回 true。 + */ + [[nodiscard]] constexpr bool isValid() const noexcept { + return is_supported_output_dtype(output_type); + } +}; + +/** + * @brief 描述一次 app 运行的报告元数据,不参与任何数值计算。 + */ +struct ReportConfig { + /** 用于日志和实验报告的目标 GPU 名称。 */ + std::string target_gpu{}; + + /** + * @brief 判断报告元数据是否可写入一次完整 app 运行的日志。 + * + * @return GPU 名称非空时返回 true。 + */ + [[nodiscard]] bool isValid() const noexcept { + return !target_gpu.empty(); + } +}; + +/** + * @brief `apps/main.cpp` 专用的完整流程配置。 + * + * 它只是配置文件与命令行 app 的编排对象:app 可以连续执行量化、写 QDWGT、 + * 反量化和日志记录。数值层绝不能把整个对象传给单方向接口;量化接口只接收 + * `QuantizationConfig`,反量化接口只接收 `DequantizationConfig`。 + */ +struct AppConfig { + /** 完整流程中量化阶段所需的参数。 */ + QuantizationConfig quantization{}; + + /** 完整流程中反量化阶段所需的参数。 */ + DequantizationConfig dequantization{}; + + /** 完整流程中日志和报告所需的非数值元数据。 */ + ReportConfig report{}; + + /** + * @brief 判断完整 app 流程所需的三类配置是否都合法。 + * + * @return 三个子配置均合法时返回 true。 + */ + [[nodiscard]] bool isValid() const noexcept { + return quantization.isValid() && dequantization.isValid() && + report.isValid(); + } +}; + +static_assert(static_cast(DType::kFloat16) == 1U); +static_assert(static_cast(DType::kBFloat16) == 2U); +static_assert(static_cast(DType::kFloat32) == 3U); +static_assert(static_cast(QuantFormat::kMxfp8) == 1U); +static_assert(static_cast(QuantFormat::kNvfp4) == 2U); +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/version.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/version.hpp" new file mode 100644 index 00000000..f6a8d2f3 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/include/quant_dequant/version.hpp" @@ -0,0 +1,14 @@ +#pragma once + +namespace quant_dequant { + +/** + * @brief 返回核心库的语义版本字符串。 + * + * 返回值指向静态存储期字符串,不需要调用方释放。 + * + * @return 核心库版本号。 + */ +[[nodiscard]] const char* version() noexcept; + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/generate_tensor.py" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/generate_tensor.py" new file mode 100644 index 00000000..fc88ab91 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/generate_tensor.py" @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""生成用于量化实验的确定性 QDTENSOR v1 输入矩阵。 + +脚本只写入 `tensor_role = input` 的 FP16/FP32 QDTENSOR,不依赖 NumPy 或 CUDA。 +因此它既可在没有 GPU 的机器上准备实验输入,也可作为端到端脚本的唯一数据源。 +""" + +from __future__ import annotations + +import argparse +import json +import math +from dataclasses import dataclass +from pathlib import Path +import random +import struct +from typing import Final + + +# QDTENSOR v1 的固定二进制约束;这些值必须与 docs/file_format.md 保持一致。 +_TENSOR_MAGIC: Final[bytes] = b"QDTENSOR" +_TENSOR_HEADER_BYTES: Final[int] = 64 +_TENSOR_FILE_VERSION: Final[int] = 1 +_LITTLE_ENDIAN_MARKER: Final[int] = 1 +_TENSOR_ROLE_INPUT: Final[int] = 0 +_DTYPE_TO_CODE: Final[dict[str, int]] = {"fp16": 1, "fp32": 3} +_DTYPE_TO_STRUCT_FORMAT: Final[dict[str, str]] = {"fp16": "e", "fp32": "f"} + + +@dataclass(frozen=True) +class TensorSpec: + """描述一个将要写成 QDTENSOR 输入文件的物理张量。 + + `dtype` 决定 payload 的真实二进制格式;生成阶段先在 Python float 中采样, + 再按该 dtype 窄化并写入。因此 FP16 输入也会真实体现输入物理精度的舍入。 + """ + + num_rows: int # row-major 矩阵行数。 + num_cols: int # row-major 矩阵列数。 + dtype: str # QDTENSOR input payload 的物理 dtype:fp16 或 fp32。 + + def element_count(self) -> int: + """返回未溢出的逻辑元素数,并检查文件格式要求的正形状。""" + if self.num_rows <= 0 or self.num_cols <= 0: + raise ValueError("rows 和 cols 必须都是正整数。") + if self.dtype not in _DTYPE_TO_CODE: + raise ValueError("输入 dtype 只能是 fp16 或 fp32。") + return self.num_rows * self.num_cols + + +@dataclass(frozen=True) +class DistributionSpec: + """描述一种可复现实验数据分布及其可选异常值规则。""" + + name: str # uniform、normal 或 outlier。 + seed: int # Python 独立随机数发生器的确定性种子。 + uniform_low: float # uniform 模式下的闭区间下界近似值。 + uniform_high: float # uniform 模式下的闭区间上界近似值。 + normal_mean: float # normal/outlier 基础正态分布的均值。 + normal_stddev: float # normal/outlier 基础正态分布的标准差。 + outlier_probability: float # outlier 模式中每元素替换为异常值的概率。 + outlier_magnitude: float # outlier 模式中异常值的绝对幅值。 + + def validate(self) -> None: + """验证各模式都会使用到的数值范围,拒绝 NaN、Inf 和无效概率。""" + finite_values = ( + self.uniform_low, + self.uniform_high, + self.normal_mean, + self.normal_stddev, + self.outlier_probability, + self.outlier_magnitude, + ) + if not all(math.isfinite(value) for value in finite_values): + raise ValueError("分布参数必须全部是有限浮点数。") + if self.name not in {"uniform", "normal", "outlier"}: + raise ValueError("distribution 只能是 uniform、normal 或 outlier。") + if self.uniform_low >= self.uniform_high: + raise ValueError("uniform-low 必须小于 uniform-high。") + if self.normal_stddev <= 0.0: + raise ValueError("normal-stddev 必须为正数。") + if not 0.0 <= self.outlier_probability <= 1.0: + raise ValueError("outlier-probability 必须位于 [0, 1]。") + if self.outlier_magnitude <= 0.0: + raise ValueError("outlier-magnitude 必须为正数。") + + +def _sample_value( + random_generator: random.Random, + distribution: DistributionSpec, +) -> float: + """从配置指定的分布采样一个有限 FP32 候选值。 + + outlier 模式先采样基础正态值,再按固定概率替换为正负对称的大幅值。 + 这样它既保留大多数常规元素,也能测试 block/tensor amax 被稀疏异常值主导时 + 的 scale 和误差行为。 + """ + if distribution.name == "uniform": + return random_generator.uniform( + distribution.uniform_low, + distribution.uniform_high, + ) + + base_value = random_generator.normalvariate( + distribution.normal_mean, + distribution.normal_stddev, + ) + if distribution.name == "normal": + return base_value + + # outlier 使用独立伯努利判断和随机符号,避免异常值总偏向正数或负数。 + if random_generator.random() < distribution.outlier_probability: + outlier_sign = -1.0 if random_generator.random() < 0.5 else 1.0 + return outlier_sign * distribution.outlier_magnitude + return base_value + + +def generate_values( + tensor: TensorSpec, + distribution: DistributionSpec, +) -> list[float]: + """按 row-major 线性顺序生成一个确定性浮点数组。 + + Args: + tensor: 输出矩阵形状和物理 dtype 描述。 + distribution: 数据分布及随机种子。 + + Returns: + 长度恰为 `rows * cols` 的有限 Python float 数组。 + """ + num_elements = tensor.element_count() + distribution.validate() + random_generator = random.Random(distribution.seed) + + # 预分配使大矩阵生成避免多次扩容;线性顺序就是 QDTENSOR 的 row-major 顺序。 + values: list[float] = [0.0] * num_elements + for element_index in range(num_elements): + sampled_value = _sample_value(random_generator, distribution) + if not math.isfinite(sampled_value): + raise ValueError(f"第 {element_index} 个采样值不是有限数。") + values[element_index] = sampled_value + return values + + +def _encode_payload(tensor: TensorSpec, values: list[float]) -> bytes: + """将采样值按目标输入 dtype 编码为 little-endian 连续 payload。""" + num_elements = tensor.element_count() + if len(values) != num_elements: + raise ValueError("values 长度与 TensorSpec 形状不一致。") + + element_format = _DTYPE_TO_STRUCT_FORMAT[tensor.dtype] + element_bytes = struct.calcsize(f"<{element_format}") + payload = bytearray(num_elements * element_bytes) + for element_index, value in enumerate(values): + try: + # struct 的 e/f 格式直接实现 IEEE binary16/binary32 的物理窄化。 + struct.pack_into( + f"<{element_format}", + payload, + element_index * element_bytes, + value, + ) + except OverflowError as error: + raise ValueError( + f"第 {element_index} 个值 {value} 无法表示为 {tensor.dtype}。" + ) from error + return bytes(payload) + + +def write_input_qdtensor( + output_path: Path, + tensor: TensorSpec, + values: list[float], +) -> None: + """将数据写为严格的 QDTENSOR v1 输入文件。 + + 副作用:必要时创建父目录,并覆盖 `output_path`。header 的所有整数和 payload + 均显式使用 little-endian,绝不依赖 Python 或运行主机的 struct 对齐。 + """ + payload = _encode_payload(tensor, values) + num_elements = tensor.element_count() + + header = bytearray(_TENSOR_HEADER_BYTES) + header[0:8] = _TENSOR_MAGIC + # offset 8--14 依次是版本、header 大小、字节序、dtype 和 input role。 + struct.pack_into( + " int: + """供 argparse 使用:解析并验证正整数。""" + parsed_value = int(value) + if parsed_value <= 0: + raise argparse.ArgumentTypeError("必须是正整数。") + return parsed_value + + +def _build_argument_parser() -> argparse.ArgumentParser: + """构造生成器 CLI 的参数定义。""" + parser = argparse.ArgumentParser( + description="生成确定性 FP16/FP32 QDTENSOR v1 输入矩阵。", + ) + parser.add_argument("--output", type=Path, required=True, help="输出 .qdtensor 路径。") + parser.add_argument("--rows", type=_positive_integer, required=True, help="矩阵行数。") + parser.add_argument("--cols", type=_positive_integer, required=True, help="矩阵列数。") + parser.add_argument("--dtype", choices=("fp16", "fp32"), default="fp32") + parser.add_argument( + "--distribution", + choices=("uniform", "normal", "outlier"), + required=True, + help="uniform、normal 或含稀疏异常值的 outlier。", + ) + parser.add_argument("--seed", type=int, default=20260917, help="确定性随机种子。") + parser.add_argument("--uniform-low", type=float, default=-1.0) + parser.add_argument("--uniform-high", type=float, default=1.0) + parser.add_argument("--normal-mean", type=float, default=0.0) + parser.add_argument("--normal-stddev", type=float, default=1.0) + parser.add_argument("--outlier-probability", type=float, default=0.01) + parser.add_argument("--outlier-magnitude", type=float, default=32.0) + return parser + + +def main() -> int: + """解析 CLI,生成矩阵,写入文件并输出可供脚本读取的 JSON 元数据。""" + arguments = _build_argument_parser().parse_args() + tensor = TensorSpec(arguments.rows, arguments.cols, arguments.dtype) + distribution = DistributionSpec( + name=arguments.distribution, + seed=arguments.seed, + uniform_low=arguments.uniform_low, + uniform_high=arguments.uniform_high, + normal_mean=arguments.normal_mean, + normal_stddev=arguments.normal_stddev, + outlier_probability=arguments.outlier_probability, + outlier_magnitude=arguments.outlier_magnitude, + ) + + try: + values = generate_values(tensor, distribution) + write_input_qdtensor(arguments.output, tensor, values) + except (OSError, ValueError) as error: + print(f"generate_tensor 失败:{error}") + return 1 + + # stdout 只输出一行 JSON,便于 run_e2e_suite.py 或人工脚本解析和记录。 + print( + json.dumps( + { + "output": str(arguments.output), + "rows": tensor.num_rows, + "cols": tensor.num_cols, + "dtype": tensor.dtype, + "distribution": distribution.name, + "seed": distribution.seed, + "element_count": tensor.element_count(), + "file_bytes": arguments.output.stat().st_size, + }, + ensure_ascii=False, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_benchmark_suite.py" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_benchmark_suite.py" new file mode 100644 index 00000000..c3018f4b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_benchmark_suite.py" @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +"""遍历 configs/ 中的真实 TOML,在同进程 CUDA benchmark 上生成性能汇总。 + +本脚本与 run_e2e_suite.py 分工明确:前者只负责性能实验,调用 +quant_dequant_bench 的同进程 warmup/repeat 循环;后者负责 QDTENSOR -> QDWGT -> +QDTENSOR 的文件级端到端验收。两者都使用提交到 configs/ 的真实配置文件,避免 +性能报告与功能验收使用两套数值语义。 +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +import math +from pathlib import Path +import statistics +import subprocess +import sys +from typing import Any, Final + +# 复用端到端脚本已经验证过的 TOML 解析与字段约束,保证两份 suite 对 configs/ 的 +# format、block_size、NVFP4 scale_mode 等判断完全一致。 +from run_e2e_suite import AppConfigFile +from run_e2e_suite import _DEFAULT_CONFIG_DIRECTORY +from run_e2e_suite import _load_app_config_file +from run_e2e_suite import _nonnegative_integer +from run_e2e_suite import _positive_integer + + +_PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parent.parent +_GENERATOR_PATH: Final[Path] = _PROJECT_ROOT / "scripts" / "generate_tensor.py" +_DEFAULT_BENCHMARK_EXECUTABLE: Final[Path] = ( + _PROJECT_ROOT / "build" / "rtx4060-release" / "benchmarks" / "quant_dequant_bench" +) +_DISTRIBUTIONS: Final[tuple[str, ...]] = ("uniform", "normal", "outlier") +_INPUT_DTYPES: Final[tuple[str, ...]] = ("fp16", "fp32") +_FORMATS: Final[tuple[str, ...]] = ("mxfp8", "nvfp4") +_SCALE_MODES: Final[tuple[str, ...]] = ("block", "tensor") +_ROUNDINGS: Final[tuple[str, ...]] = ("nearest", "stochastic") +_OUTPUT_DTYPES: Final[tuple[str, ...]] = ("fp16", "bf16", "fp32") + + +@dataclass(frozen=True) +class BenchmarkSuiteConfig: + """描述一次同进程 kernel 性能遍历的公共参数。""" + + executable_path: Path # 已构建的 quant_dequant_bench 可执行文件。 + config_directory: Path # 真实 TOML 所在目录,默认项目 configs/。 + output_directory: Path # 输入、每项 JSON 和汇总 Markdown 的根目录。 + num_rows: int # 基准输入矩阵行数。 + num_cols: int # 基准输入矩阵列数;默认故意不整除 32/16。 + input_dtype: str # 单份基准 QDTENSOR 的物理 FP16 或 FP32 dtype。 + distribution: str # uniform、normal 或 outlier。 + seed: int # 生成输入时使用的确定性随机种子。 + num_warmups: int # 每个 config 的不计入统计同进程 warmup 数。 + num_repeats: int # 每个 config 的正式同进程 repeat 数。 + app_configs: tuple[AppConfigFile, ...] # 经 CLI 筛选后实际执行的真实 TOML。 + + +@dataclass(frozen=True) +class TimingSummary: + """保存 benchmark JSON 中一阶段 kernel 的已验证统计结果。""" + + min_ms: float # 正式 repeat 的最小 CUDA Event 时间。 + mean_ms: float # 正式 repeat 的算术均值。 + median_ms: float # 正式 repeat 的中位数。 + p95_ms: float # nearest-rank p95。 + max_ms: float # 正式 repeat 的最大值。 + effective_bandwidth_gbps: float | None # 以 mean_ms 换算的逻辑有效带宽。 + + +@dataclass(frozen=True) +class BenchmarkResult: + """保存一份已交叉校验的 benchmark JSON,供汇总 Markdown 使用。""" + + config: AppConfigFile # 本项实际使用的 configs/ TOML。 + report_path: Path # 本项 benchmark 写出的 JSON 文件。 + quant: TimingSummary # 量化阶段统计。 + dequant: TimingSummary # 反量化阶段统计。 + + +def _run_command(command: list[str], description: str) -> subprocess.CompletedProcess[str]: + """在项目根目录运行命令,失败时保留完整 stdout/stderr。""" + completed_process = subprocess.run( + command, + cwd=_PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + ) + if completed_process.returncode != 0: + raise RuntimeError( + f"{description} 失败,退出码 {completed_process.returncode}。\n" + f"命令:{' '.join(command)}\n" + f"stdout:\n{completed_process.stdout}\n" + f"stderr:\n{completed_process.stderr}" + ) + return completed_process + + +def _discover_configs(arguments: argparse.Namespace) -> tuple[AppConfigFile, ...]: + """读取 configs/ 中真实 TOML,并按格式与输出类型筛选性能实验组合。""" + config_directory = arguments.config_dir.resolve() + if not config_directory.is_dir(): + raise RuntimeError(f"配置目录不存在:{config_directory}") + + requested_names = set(arguments.config_names) + config_files = sorted(config_directory.glob("*.toml")) + discovered_configs = tuple(_load_app_config_file(config_path) for config_path in config_files) + discovered_names = {config.name for config in discovered_configs} + unknown_names = requested_names.difference(discovered_names) + if unknown_names: + raise RuntimeError(f"--config-names 含不存在配置:{', '.join(sorted(unknown_names))}") + + selected_configs = tuple( + config + for config in discovered_configs + if (not requested_names or config.name in requested_names) + and config.quant_format in arguments.formats + and config.scale_mode in arguments.scale_modes + and config.rounding in arguments.roundings + and config.output_dtype in arguments.output_dtypes + ) + if not selected_configs: + raise RuntimeError("筛选后没有可执行的 benchmark TOML。") + return selected_configs + + +def _generate_input(suite: BenchmarkSuiteConfig) -> Path: + """生成本次 suite 共享的一份确定性 QDTENSOR 输入并检查生成器 JSON。""" + input_path = suite.output_directory / "inputs" / ( + f"{suite.distribution}_{suite.input_dtype}_" + f"{suite.num_rows}x{suite.num_cols}_seed{suite.seed}.qdtensor" + ) + completed_process = _run_command( + [ + sys.executable, + str(_GENERATOR_PATH), + "--output", + str(input_path), + "--rows", + str(suite.num_rows), + "--cols", + str(suite.num_cols), + "--dtype", + suite.input_dtype, + "--distribution", + suite.distribution, + "--seed", + str(suite.seed), + ], + "生成 benchmark 输入", + ) + try: + generator_report = json.loads(completed_process.stdout) + except json.JSONDecodeError as error: + raise RuntimeError("生成器没有输出合法 JSON。") from error + + expected_fields = ( + suite.num_rows, + suite.num_cols, + suite.input_dtype, + suite.distribution, + suite.seed, + ) + actual_fields = ( + generator_report.get("rows"), + generator_report.get("cols"), + generator_report.get("dtype"), + generator_report.get("distribution"), + generator_report.get("seed"), + ) + if actual_fields != expected_fields: + raise RuntimeError("生成器 JSON 与 benchmark 请求的 shape/dtype/分布/seed 不一致。") + if generator_report.get("file_bytes") != input_path.stat().st_size: + raise RuntimeError(f"生成器报告的文件大小与实际输入不一致:{input_path}") + return input_path + + +def _require_mapping(value: Any, field_name: str) -> dict[str, Any]: + """验证 JSON object 字段,避免后续 KeyError 给出不清晰诊断。""" + if not isinstance(value, dict): + raise RuntimeError(f"benchmark JSON 字段 {field_name} 必须是 object。") + return value + + +def _require_finite_nonnegative(value: Any, field_name: str) -> float: + """验证 JSON 数值是有限非负浮点数,并统一转换为 float。""" + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise RuntimeError(f"benchmark JSON 字段 {field_name} 必须是数值。") + numeric_value = float(value) + if not math.isfinite(numeric_value) or numeric_value < 0.0: + raise RuntimeError(f"benchmark JSON 字段 {field_name} 必须是有限非负数。") + return numeric_value + + +def _parse_timing_summary(stage: str, value: Any) -> TimingSummary: + """读取一阶段 benchmark 时间统计,并验证排序关系和可选带宽。""" + timing = _require_mapping(value, f"performance.{stage}") + min_ms = _require_finite_nonnegative(timing.get("min_ms"), f"{stage}.min_ms") + mean_ms = _require_finite_nonnegative(timing.get("mean_ms"), f"{stage}.mean_ms") + median_ms = _require_finite_nonnegative(timing.get("median_ms"), f"{stage}.median_ms") + p95_ms = _require_finite_nonnegative(timing.get("p95_ms"), f"{stage}.p95_ms") + max_ms = _require_finite_nonnegative(timing.get("max_ms"), f"{stage}.max_ms") + if not min_ms <= median_ms <= p95_ms <= max_ms: + raise RuntimeError(f"benchmark JSON 的 {stage} 时间分位数顺序不合法。") + bandwidth_value = timing.get("effective_bandwidth_gbps_from_mean") + if mean_ms == 0.0: + if bandwidth_value is not None: + raise RuntimeError(f"{stage} mean_ms 为 0 时带宽必须为 null。") + effective_bandwidth_gbps: float | None = None + else: + effective_bandwidth_gbps = _require_finite_nonnegative( + bandwidth_value, + f"{stage}.effective_bandwidth_gbps_from_mean", + ) + if effective_bandwidth_gbps <= 0.0: + raise RuntimeError(f"{stage} 正时间对应的有效带宽必须为正数。") + return TimingSummary( + min_ms=min_ms, + mean_ms=mean_ms, + median_ms=median_ms, + p95_ms=p95_ms, + max_ms=max_ms, + effective_bandwidth_gbps=effective_bandwidth_gbps, + ) + + +def _validate_benchmark_report( + report_path: Path, + suite: BenchmarkSuiteConfig, + app_config: AppConfigFile, +) -> BenchmarkResult: + """读取一份 benchmark JSON,并交叉验证输入、配置、循环次数与统计字段。""" + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"无法读取合法 benchmark JSON:{report_path}") from error + + input_info = _require_mapping(report.get("input"), "input") + config_info = _require_mapping(report.get("config"), "config") + benchmark_info = _require_mapping(report.get("benchmark"), "benchmark") + performance_info = _require_mapping(report.get("performance"), "performance") + if report.get("schema_version") != 1: + raise RuntimeError(f"{report_path} 的 schema_version 不是 1。") + if (input_info.get("rows"), input_info.get("cols"), input_info.get("dtype")) != ( + suite.num_rows, + suite.num_cols, + suite.input_dtype, + ): + raise RuntimeError(f"{report_path} 的输入 shape/dtype 与请求不一致。") + if ( + config_info.get("format"), + config_info.get("block_size"), + config_info.get("scale_mode"), + config_info.get("rounding"), + config_info.get("output_type"), + config_info.get("target_gpu"), + ) != ( + app_config.quant_format, + app_config.block_size, + app_config.scale_mode, + app_config.rounding, + app_config.output_dtype, + app_config.target_gpu, + ): + raise RuntimeError(f"{report_path} 的 config 与 {app_config.path} 不一致。") + if (benchmark_info.get("warmups"), benchmark_info.get("repeats")) != ( + suite.num_warmups, + suite.num_repeats, + ): + raise RuntimeError(f"{report_path} 的 warmups/repeats 与命令行不一致。") + timing_scope = benchmark_info.get("timing_scope") + if not isinstance(timing_scope, str) or "CUDA Event" not in timing_scope: + raise RuntimeError(f"{report_path} 缺少可识别的 CUDA Event 计时边界。") + + return BenchmarkResult( + config=app_config, + report_path=report_path, + quant=_parse_timing_summary("quant", performance_info.get("quant")), + dequant=_parse_timing_summary("dequant", performance_info.get("dequant")), + ) + + +def _run_benchmark_once( + suite: BenchmarkSuiteConfig, + input_path: Path, + app_config: AppConfigFile, +) -> BenchmarkResult: + """调用一次同进程 benchmark,并对写出的 JSON 做结构化校验。""" + report_path = suite.output_directory / "runs" / app_config.name / "benchmark.json" + _run_command( + [ + str(suite.executable_path), + "--input", + str(input_path), + "--config", + str(app_config.path), + "--warmups", + str(suite.num_warmups), + "--repeats", + str(suite.num_repeats), + "--output", + str(report_path), + ], + f"运行 {app_config.name} benchmark", + ) + return _validate_benchmark_report(report_path, suite, app_config) + + +def _format_optional(value: float | None, digits: int = 3) -> str: + """将可选性能数值转为 Markdown 表格单元格。""" + return "null" if value is None else f"{value:.{digits}f}" + + +def _write_summary(summary_path: Path, suite: BenchmarkSuiteConfig, results: list[BenchmarkResult]) -> None: + """写出便于提交/比较的 benchmark 汇总 Markdown,不覆盖单项原始 JSON。""" + summary_path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "# 同进程 CUDA kernel benchmark 汇总", + "", + "该表由 `quant_dequant_bench` 产生:每份 TOML 在同一个进程中读取一次输入和配置,", + "随后执行 warmup 与 repeat。CUDA Event 只统计格式专用 kernel,不含 H2D、D2H、", + "device 分配、文件 I/O 或 CUDA context 初始化。", + "", + f"- 输入:`{suite.num_rows} × {suite.num_cols}`、`{suite.input_dtype}`、" + f"`{suite.distribution}`、seed `{suite.seed}`。", + f"- 每项:`warmups = {suite.num_warmups}`、`repeats = {suite.num_repeats}`。", + f"- 配置数:{len(results)};每项原始 JSON 位于 `runs//benchmark.json`。", + "", + "| 配置 | quant mean ms | quant median ms | quant p95 ms | quant GB/s | dequant mean ms | dequant median ms | dequant p95 ms | dequant GB/s |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ] + for result in results: + lines.append( + "| " + f"{result.config.name} | {result.quant.mean_ms:.3f} | " + f"{result.quant.median_ms:.3f} | {result.quant.p95_ms:.3f} | " + f"{_format_optional(result.quant.effective_bandwidth_gbps)} | " + f"{result.dequant.mean_ms:.3f} | {result.dequant.median_ms:.3f} | " + f"{result.dequant.p95_ms:.3f} | " + f"{_format_optional(result.dequant.effective_bandwidth_gbps)} |" + ) + + quant_means = [result.quant.mean_ms for result in results] + dequant_means = [result.dequant.mean_ms for result in results] + lines.extend( + [ + "", + "## 跨配置观察", + "", + f"- quant mean 的范围是 `{min(quant_means):.3f}--{max(quant_means):.3f} ms`," + f"中位配置均值为 `{statistics.median(quant_means):.3f} ms`。", + f"- dequant mean 的范围是 `{min(dequant_means):.3f}--{max(dequant_means):.3f} ms`," + f"中位配置均值为 `{statistics.median(dequant_means):.3f} ms`。", + "- 不同格式、scale mode 与 rounding 的 kernel 数量不同;该表只比较固定 shape 下的" + "实际实现时间,不能直接等同于 GPU 硬件峰值带宽。", + "", + ] + ) + summary_path.write_text("\n".join(lines), encoding="utf-8") + + +def _build_argument_parser() -> argparse.ArgumentParser: + """构造性能 suite CLI;默认筛选出没有输出 dtype 重复的六份 FP32 TOML。""" + parser = argparse.ArgumentParser( + description="遍历 configs/,调用同进程 quant_dequant_bench 并汇总 kernel 性能。", + ) + parser.add_argument("--executable", type=Path, default=_DEFAULT_BENCHMARK_EXECUTABLE) + parser.add_argument("--config-dir", type=Path, default=_DEFAULT_CONFIG_DIRECTORY) + parser.add_argument("--config-names", nargs="*", default=()) + parser.add_argument( + "--output-dir", + type=Path, + default=_PROJECT_ROOT / "outputs" / "benchmark_suite", + ) + parser.add_argument("--rows", type=_positive_integer, default=4096) + parser.add_argument("--cols", type=_positive_integer, default=4097) + parser.add_argument("--input-dtype", choices=_INPUT_DTYPES, default="fp32") + parser.add_argument("--distribution", choices=_DISTRIBUTIONS, default="normal") + parser.add_argument("--seed", type=int, default=20260917) + parser.add_argument("--warmups", type=_nonnegative_integer, default=10) + parser.add_argument("--repeats", type=_positive_integer, default=30) + parser.add_argument("--formats", nargs="+", choices=_FORMATS, default=_FORMATS) + parser.add_argument("--scale-modes", nargs="+", choices=_SCALE_MODES, default=_SCALE_MODES) + parser.add_argument("--roundings", nargs="+", choices=_ROUNDINGS, default=_ROUNDINGS) + parser.add_argument( + "--output-dtypes", + nargs="+", + choices=_OUTPUT_DTYPES, + default=("fp32",), + help=( + "默认只测 fp32 输出,避免三种输出 TOML 重复测同一个 kernel;" + "若需完整 18 份 TOML,传 --output-dtypes fp16 bf16 fp32。" + ), + ) + return parser + + +def main() -> int: + """生成共享输入,执行所有筛选后的真实 TOML benchmark,并写汇总 Markdown。""" + arguments = _build_argument_parser().parse_args() + try: + app_configs = _discover_configs(arguments) + suite = BenchmarkSuiteConfig( + executable_path=arguments.executable.resolve(), + config_directory=arguments.config_dir.resolve(), + output_directory=arguments.output_dir.resolve(), + num_rows=arguments.rows, + num_cols=arguments.cols, + input_dtype=arguments.input_dtype, + distribution=arguments.distribution, + seed=arguments.seed, + num_warmups=arguments.warmups, + num_repeats=arguments.repeats, + app_configs=app_configs, + ) + if not suite.executable_path.is_file(): + raise RuntimeError(f"找不到 benchmark 可执行文件:{suite.executable_path}") + if not _GENERATOR_PATH.is_file(): + raise RuntimeError(f"找不到输入生成器:{_GENERATOR_PATH}") + + suite.output_directory.mkdir(parents=True, exist_ok=True) + input_path = _generate_input(suite) + results = [ + _run_benchmark_once(suite, input_path, app_config) + for app_config in suite.app_configs + ] + summary_path = suite.output_directory / "summary.md" + _write_summary(summary_path, suite, results) + except (OSError, RuntimeError, ValueError) as error: + print(f"run_benchmark_suite 失败:{error}") + return 1 + + print( + "同进程 benchmark 配置遍历通过:" + f"{len(results)} 份 TOML," + f"warmups={suite.num_warmups},repeats={suite.num_repeats}。" + ) + print(f"汇总报告:{summary_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_e2e_suite.py" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_e2e_suite.py" new file mode 100644 index 00000000..1d017709 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/scripts/run_e2e_suite.py" @@ -0,0 +1,687 @@ +#!/usr/bin/env python3 +"""遍历真实 TOML 配置,运行完整 CUDA app,并校验每次产物与 JSON 报告。 + +默认组合覆盖三种输入分布、FP16/FP32 输入以及 configs/ 中的全部合法 app 配置。 +脚本是端到端验收与实验工具;codec、单元素位编码和 CPU/GPU 逐元素对照仍由 CTest 负责。 +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +import math +from pathlib import Path +import statistics +import subprocess +import sys +from typing import Any, Final + + +_PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parent.parent +_GENERATOR_PATH: Final[Path] = _PROJECT_ROOT / "scripts" / "generate_tensor.py" +_DEFAULT_CONFIG_DIRECTORY: Final[Path] = _PROJECT_ROOT / "configs" +_FORMAT_TO_BLOCK_SIZE: Final[dict[str, int]] = {"mxfp8": 32, "nvfp4": 16} +_OUTPUT_DTYPE_BYTES: Final[dict[str, int]] = {"fp16": 2, "bf16": 2, "fp32": 4} + + +@dataclass(frozen=True) +class AppConfigFile: + """保存从一份真实 app TOML 读取且已通过脚本侧语义校验的字段。""" + + name: str # 配置文件 stem,同时用于稳定的输出目录名称。 + path: Path # 实际传递给 quant_dequant --config 的 TOML 路径。 + quant_format: str # mxfp8 或 nvfp4。 + block_size: int # 格式固定的 32 或 16。 + scale_mode: str # MXFP8 的 block/tensor,或 NVFP4 的 block。 + rounding: str # nearest 或 stochastic。 + stochastic_seed: int # nearest 固定为 0;stochastic 为配置中显式种子。 + output_dtype: str # fp16、bf16 或 fp32。 + target_gpu: str # JSON 中应原样记录的报告元数据。 + + +@dataclass(frozen=True) +class SuiteConfig: + """描述一次端到端组合遍历的公共运行参数。""" + + executable_path: Path # 已构建的 quant_dequant app。 + output_directory: Path # 本次输入、产物和汇总文件的根目录。 + num_rows: int # 每个生成矩阵的行数。 + num_cols: int # 每个生成矩阵的列数。 + input_dtypes: tuple[str, ...] # 本次要生成的 FP16/FP32 物理输入类型。 + distributions: tuple[str, ...] # uniform、normal、outlier 的子集。 + app_configs: tuple[AppConfigFile, ...] # 已按 CLI 过滤后的真实 TOML 配置。 + base_seed: int # 按 distribution/dtype 派生稳定 seed 的基础值。 + num_warmups: int # 每个组合不计入汇总的完整 app 调用次数。 + num_repeats: int # 每个组合计入汇总的完整 app 调用次数。 + + +@dataclass(frozen=True) +class RunResult: + """保存一轮通过报告和文件布局校验的正式 app 运行摘要。""" + + input_dtype: str # 本轮输入 QDTENSOR 的物理 dtype。 + distribution: str # 本轮输入分布。 + config_name: str # 真实 TOML 配置名称。 + repeat_index: int # 当前组合中的正式重复编号。 + report_path: Path # 已验证 report.json 的路径。 + quant_kernel_ms: float # CUDA Event 量化 kernel 时间。 + dequant_kernel_ms: float # CUDA Event 反量化 kernel 时间。 + max_abs: float # 最终物理输出文件的最大绝对误差。 + mae: float # 最终物理输出文件的平均绝对误差。 + mse: float # 最终物理输出文件的均方误差。 + + +def _positive_integer(value: str) -> int: + """供 argparse 使用:解析并验证正整数。""" + parsed_value = int(value) + if parsed_value <= 0: + raise argparse.ArgumentTypeError("必须是正整数。") + return parsed_value + + +def _nonnegative_integer(value: str) -> int: + """供 argparse 使用:解析并验证零或正整数。""" + parsed_value = int(value) + if parsed_value < 0: + raise argparse.ArgumentTypeError("必须是零或正整数。") + return parsed_value + + +def _run_command(command: list[str], description: str) -> subprocess.CompletedProcess[str]: + """执行子进程;失败时保留命令、stdout 和 stderr 以便定位。""" + completed_process = subprocess.run( + command, + cwd=_PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + ) + if completed_process.returncode != 0: + raise RuntimeError( + f"{description} 失败,退出码 {completed_process.returncode}。\n" + f"命令:{' '.join(command)}\n" + f"stdout:\n{completed_process.stdout}\n" + f"stderr:\n{completed_process.stderr}" + ) + return completed_process + + +def _parse_flat_config(config_path: Path) -> dict[str, str]: + """读取本项目受限的 `key = value` TOML 子集,供 suite 推导期望布局。 + + app 仍会调用 C++ `load_app_config()` 做权威语法和语义校验;这里仅解析项目 + 提交的样例配置,以便在读取 JSON 时知道本轮应出现的 format、scale 和 dtype。 + """ + fields: dict[str, str] = {} + try: + config_lines = config_path.read_text(encoding="utf-8").splitlines() + except OSError as error: + raise RuntimeError(f"无法读取配置文件:{config_path}") from error + + for line_number, raw_line in enumerate(config_lines, start=1): + # 提交的样例不在字符串中使用 #;保守删除注释即可避免把说明当作值。 + content = raw_line.split("#", maxsplit=1)[0].strip() + if not content: + continue + if "=" not in content: + raise RuntimeError(f"{config_path}:{line_number} 不是 key = value。") + key, raw_value = (part.strip() for part in content.split("=", maxsplit=1)) + if not key or not raw_value or key in fields: + raise RuntimeError(f"{config_path}:{line_number} 含有空或重复配置字段。") + # 项目配置允许整体双引号字符串;本脚本不需要支持复杂转义,因为 configs/ + # 中的样例固定使用简单标量。 + if raw_value.startswith('"') and raw_value.endswith('"') and len(raw_value) >= 2: + raw_value = raw_value[1:-1] + fields[key] = raw_value + return fields + + +def _load_app_config_file(config_path: Path) -> AppConfigFile: + """将一个真实 TOML 转为已验证的 AppConfigFile。""" + fields = _parse_flat_config(config_path) + required_fields = { + "format", + "block_size", + "scale_mode", + "rounding", + "output_type", + "target_gpu", + } + missing_fields = sorted(required_fields.difference(fields)) + if missing_fields: + raise RuntimeError(f"{config_path} 缺少字段:{', '.join(missing_fields)}") + + quant_format = fields["format"] + scale_mode = fields["scale_mode"] + rounding = fields["rounding"] + output_dtype = fields["output_type"] + target_gpu = fields["target_gpu"] + try: + block_size = int(fields["block_size"]) + stochastic_seed = int(fields.get("stochastic_seed", "0")) + except ValueError as error: + raise RuntimeError(f"{config_path} 的 block_size 或 stochastic_seed 不是整数。") from error + + if quant_format not in _FORMAT_TO_BLOCK_SIZE: + raise RuntimeError(f"{config_path} 的 format 不受支持:{quant_format}") + if block_size != _FORMAT_TO_BLOCK_SIZE[quant_format]: + raise RuntimeError(f"{config_path} 的 block_size 与 format 不匹配。") + if scale_mode not in {"block", "tensor"}: + raise RuntimeError(f"{config_path} 的 scale_mode 不受支持:{scale_mode}") + if quant_format == "nvfp4" and scale_mode != "block": + raise RuntimeError(f"{config_path}:NVFP4 只能使用 block scale。") + if rounding not in {"nearest", "stochastic"}: + raise RuntimeError(f"{config_path} 的 rounding 不受支持:{rounding}") + if rounding == "nearest" and stochastic_seed != 0: + raise RuntimeError(f"{config_path}:nearest 配置的 stochastic_seed 必须为 0。") + if rounding == "stochastic" and "stochastic_seed" not in fields: + raise RuntimeError(f"{config_path}:stochastic 配置必须显式提供 stochastic_seed。") + if output_dtype not in _OUTPUT_DTYPE_BYTES: + raise RuntimeError(f"{config_path} 的 output_type 不受支持:{output_dtype}") + if not target_gpu: + raise RuntimeError(f"{config_path} 的 target_gpu 不能为空。") + + return AppConfigFile( + name=config_path.stem, + path=config_path, + quant_format=quant_format, + block_size=block_size, + scale_mode=scale_mode, + rounding=rounding, + stochastic_seed=stochastic_seed, + output_dtype=output_dtype, + target_gpu=target_gpu, + ) + + +def _discover_and_filter_configs(arguments: argparse.Namespace) -> tuple[AppConfigFile, ...]: + """扫描 configs/ 中的 TOML,并按 format/scale/rounding/output 筛选组合。""" + config_directory = arguments.config_dir.resolve() + if not config_directory.is_dir(): + raise RuntimeError(f"配置目录不存在:{config_directory}") + + requested_names = set(arguments.config_names) + config_files = sorted(config_directory.glob("*.toml")) + discovered_configs = [_load_app_config_file(config_path) for config_path in config_files] + selected_configs = tuple( + config + for config in discovered_configs + if (not requested_names or config.name in requested_names) + and config.quant_format in arguments.formats + and config.scale_mode in arguments.scale_modes + and config.rounding in arguments.roundings + and config.output_dtype in arguments.output_dtypes + ) + unknown_names = requested_names.difference(config.name for config in discovered_configs) + if unknown_names: + raise RuntimeError(f"--config-names 含不存在配置:{', '.join(sorted(unknown_names))}") + if not selected_configs: + raise RuntimeError("筛选后没有任何可运行的 TOML 配置。") + return selected_configs + + +def _generate_input( + suite: SuiteConfig, + input_dtype: str, + distribution: str, + distribution_index: int, +) -> Path: + """调用生成器,写入一个 distribution/dtype 唯一且可复现的输入 QDTENSOR。""" + input_path = suite.output_directory / "inputs" / ( + f"{distribution}_{input_dtype}_{suite.num_rows}x{suite.num_cols}.qdtensor" + ) + # dtype 偏移避免 FP16 和 FP32 输入意外使用同一 random stream 标识。 + dtype_offset = 0 if input_dtype == "fp16" else 1 + derived_seed = suite.base_seed + distribution_index * 1009 + dtype_offset * 100003 + completed_process = _run_command( + [ + sys.executable, + str(_GENERATOR_PATH), + "--output", + str(input_path), + "--rows", + str(suite.num_rows), + "--cols", + str(suite.num_cols), + "--dtype", + input_dtype, + "--distribution", + distribution, + "--seed", + str(derived_seed), + ], + f"生成 {distribution}/{input_dtype} 输入", + ) + try: + generator_report = json.loads(completed_process.stdout) + except json.JSONDecodeError as error: + raise RuntimeError(f"生成器没有输出合法 JSON:{completed_process.stdout}") from error + if generator_report.get("file_bytes") != input_path.stat().st_size: + raise RuntimeError(f"生成器报告的文件大小与实际输入不一致:{input_path}") + return input_path + + +def _require_mapping(value: Any, field_name: str) -> dict[str, Any]: + """验证 JSON 字段是 object,给出比 KeyError 更清楚的报告错误。""" + if not isinstance(value, dict): + raise RuntimeError(f"报告字段 {field_name} 必须是 JSON object。") + return value + + +def _require_finite_nonnegative(value: Any, field_name: str) -> float: + """验证 JSON 数值为有限非负数。""" + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise RuntimeError(f"报告字段 {field_name} 必须是数值。") + numeric_value = float(value) + if not math.isfinite(numeric_value) or numeric_value < 0.0: + raise RuntimeError(f"报告字段 {field_name} 必须是有限非负数。") + return numeric_value + + +def _validate_optional_bandwidth(kernel_ms: float, bandwidth: Any, field_name: str) -> None: + """验证 CUDA Event 0 ms 对应 null 带宽,正时间对应有限正带宽。""" + if kernel_ms == 0.0: + if bandwidth is not None: + raise RuntimeError(f"{field_name} 的 kernel 时间为 0 ms 时带宽必须为 null。") + return + bandwidth_value = _require_finite_nonnegative(bandwidth, field_name) + if bandwidth_value <= 0.0: + raise RuntimeError(f"{field_name} 在正 kernel 时间下必须为正数。") + + +def _validate_report( + report_path: Path, + input_path: Path, + quantized_path: Path, + dequantized_path: Path, + suite: SuiteConfig, + input_dtype: str, + distribution: str, + app_config: AppConfigFile, +) -> RunResult: + """交叉检查一次 app JSON 与实际 QDTENSOR/QDWGT 文件布局。""" + try: + report = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"无法读取合法 JSON 报告:{report_path}") from error + + input_info = _require_mapping(report.get("input"), "input") + config_info = _require_mapping(report.get("config"), "config") + artifacts = _require_mapping(report.get("artifacts"), "artifacts") + error_info = _require_mapping(report.get("error"), "error") + compression = _require_mapping(report.get("compression"), "compression") + performance = _require_mapping(report.get("performance"), "performance") + + if report.get("schema_version") != 1: + raise RuntimeError(f"{report_path} 的 schema_version 不是 1。") + if (input_info.get("rows"), input_info.get("cols"), input_info.get("dtype")) != ( + suite.num_rows, + suite.num_cols, + input_dtype, + ): + raise RuntimeError(f"{report_path} 的输入形状或 dtype 与生成器不一致。") + # 该字段在报告 schema v1 的当前实现中用于记录随机舍入的可复现状态。缺失通常 + # 表明用户只更新了脚本、却仍在运行修改 metrics.cpp 前构建的旧 app。 + if "stochastic_seed" not in config_info: + raise RuntimeError( + f"{report_path} 缺少 config.stochastic_seed;" + "当前 app 可执行文件可能过旧,请重新构建所选 CMake preset。" + ) + expected_config_fields = ( + app_config.quant_format, + app_config.block_size, + app_config.scale_mode, + app_config.rounding, + app_config.stochastic_seed, + app_config.output_dtype, + app_config.target_gpu, + ) + reported_config_fields = ( + config_info.get("format"), + config_info.get("block_size"), + config_info.get("scale_mode"), + config_info.get("rounding"), + config_info.get("stochastic_seed"), + config_info.get("output_type"), + config_info.get("target_gpu"), + ) + if reported_config_fields != expected_config_fields: + raise RuntimeError(f"{report_path} 的 config 字段与 {app_config.path} 不一致。") + + num_elements = suite.num_rows * suite.num_cols + blocks_per_row = (suite.num_cols + app_config.block_size - 1) // app_config.block_size + expected_local_scale_bytes = ( + 1 + if app_config.quant_format == "mxfp8" and app_config.scale_mode == "tensor" + else suite.num_rows * blocks_per_row + ) + expected_payload_bytes = ( + num_elements + if app_config.quant_format == "mxfp8" + else (num_elements + 1) // 2 + ) + expected_global_scale_bytes = 0 if app_config.quant_format == "mxfp8" else 4 + expected_input_payload_bytes = num_elements * (2 if input_dtype == "fp16" else 4) + expected_dequantized_file_bytes = ( + 64 + num_elements * _OUTPUT_DTYPE_BYTES[app_config.output_dtype] + ) + expected_logical_bytes = ( + expected_payload_bytes + expected_local_scale_bytes + expected_global_scale_bytes + ) + + # 实际文件大小与 JSON 声明必须一致,避免只验证 app 内存中的中间对象。 + if artifacts.get("payload_bytes") != expected_payload_bytes: + raise RuntimeError(f"{report_path} 的 payload_bytes 不符合 {app_config.quant_format}。") + if artifacts.get("local_scale_bytes") != expected_local_scale_bytes: + raise RuntimeError(f"{report_path} 的 local_scale_bytes 不符合 scale_mode。") + if artifacts.get("global_scale_bytes") != expected_global_scale_bytes: + raise RuntimeError(f"{report_path} 的 global_scale_bytes 不符合格式。") + if artifacts.get("quantized_file_bytes") != quantized_path.stat().st_size: + raise RuntimeError(f"{report_path} 的 quantized_file_bytes 与真实 QDWGT 不一致。") + if artifacts.get("dequantized_file_bytes") != dequantized_path.stat().st_size: + raise RuntimeError(f"{report_path} 的 dequantized_file_bytes 与真实文件不一致。") + if dequantized_path.stat().st_size != expected_dequantized_file_bytes: + raise RuntimeError(f"反量化输出大小不符合 output_type:{dequantized_path}") + if input_path.stat().st_size != 64 + expected_input_payload_bytes: + raise RuntimeError(f"输入 QDTENSOR 大小不符合生成规格:{input_path}") + + if compression.get("input_payload_bytes") != expected_input_payload_bytes: + raise RuntimeError(f"{report_path} 的 input_payload_bytes 不正确。") + if compression.get("logical_quantized_bytes") != expected_logical_bytes: + raise RuntimeError(f"{report_path} 的 logical_quantized_bytes 不正确。") + logical_ratio = _require_finite_nonnegative( + compression.get("logical_compression_ratio"), + "compression.logical_compression_ratio", + ) + on_disk_ratio = _require_finite_nonnegative( + compression.get("on_disk_compression_ratio"), + "compression.on_disk_compression_ratio", + ) + if logical_ratio <= 0.0 or on_disk_ratio <= 0.0: + raise RuntimeError(f"{report_path} 的压缩率必须为正数。") + if not math.isclose( + logical_ratio, + expected_input_payload_bytes / expected_logical_bytes, + rel_tol=1.0e-12, + ): + raise RuntimeError(f"{report_path} 的 logical_compression_ratio 不正确。") + if not math.isclose( + on_disk_ratio, + expected_input_payload_bytes / quantized_path.stat().st_size, + rel_tol=1.0e-12, + ): + raise RuntimeError(f"{report_path} 的 on_disk_compression_ratio 不正确。") + + max_abs = _require_finite_nonnegative(error_info.get("max_abs"), "error.max_abs") + mae = _require_finite_nonnegative(error_info.get("mae"), "error.mae") + mse = _require_finite_nonnegative(error_info.get("mse"), "error.mse") + quant_kernel_ms = _require_finite_nonnegative( + performance.get("quant_kernel_ms"), + "performance.quant_kernel_ms", + ) + dequant_kernel_ms = _require_finite_nonnegative( + performance.get("dequant_kernel_ms"), + "performance.dequant_kernel_ms", + ) + _validate_optional_bandwidth( + quant_kernel_ms, + performance.get("quant_effective_bandwidth_gbps"), + "performance.quant_effective_bandwidth_gbps", + ) + _validate_optional_bandwidth( + dequant_kernel_ms, + performance.get("dequant_effective_bandwidth_gbps"), + "performance.dequant_effective_bandwidth_gbps", + ) + + return RunResult( + input_dtype=input_dtype, + distribution=distribution, + config_name=app_config.name, + repeat_index=0, + report_path=report_path, + quant_kernel_ms=quant_kernel_ms, + dequant_kernel_ms=dequant_kernel_ms, + max_abs=max_abs, + mae=mae, + mse=mse, + ) + + +def _run_app_once( + suite: SuiteConfig, + input_path: Path, + input_dtype: str, + distribution: str, + app_config: AppConfigFile, + phase: str, + index: int, +) -> RunResult: + """运行一次真实 app,随后验证 QDWGT、输出 QDTENSOR 与 JSON。""" + run_directory = ( + suite.output_directory + / "runs" + / input_dtype + / distribution + / app_config.name + / f"{phase}_{index:02d}" + ) + quantized_path = run_directory / "weights.qdwgt" + dequantized_path = run_directory / "dequantized.qdtensor" + report_path = run_directory / "report.json" + run_directory.mkdir(parents=True, exist_ok=True) + + _run_command( + [ + str(suite.executable_path), + "--input", + str(input_path), + "--config", + str(app_config.path), + "--quantized-output", + str(quantized_path), + "--dequantized-output", + str(dequantized_path), + "--report", + str(report_path), + ], + f"{input_dtype}/{distribution}/{app_config.name} {phase} {index}", + ) + validated_result = _validate_report( + report_path, + input_path, + quantized_path, + dequantized_path, + suite, + input_dtype, + distribution, + app_config, + ) + return RunResult( + input_dtype=validated_result.input_dtype, + distribution=validated_result.distribution, + config_name=validated_result.config_name, + repeat_index=index, + report_path=validated_result.report_path, + quant_kernel_ms=validated_result.quant_kernel_ms, + dequant_kernel_ms=validated_result.dequant_kernel_ms, + max_abs=validated_result.max_abs, + mae=validated_result.mae, + mse=validated_result.mse, + ) + + +def _write_summary(output_path: Path, results: list[RunResult]) -> None: + """将正式运行按输入 dtype、分布和真实配置聚合为 Markdown 表。""" + grouped_results: dict[tuple[str, str, str], list[RunResult]] = {} + for result in results: + group_key = (result.input_dtype, result.distribution, result.config_name) + grouped_results.setdefault(group_key, []).append(result) + + lines = [ + "# 端到端配置遍历汇总", + "", + "只汇总正式 repeat;warmup 仍会校验全部文件和 JSON,但不计入统计。", + "", + "| 输入 dtype | 分布 | 配置 | 重复次数 | quant 平均 ms | dequant 平均 ms | quant 中位 ms | dequant 中位 ms | max_abs | MAE | MSE |", + "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ] + for group_key, group in sorted(grouped_results.items()): + input_dtype, distribution, config_name = group_key + quant_times = [result.quant_kernel_ms for result in group] + dequant_times = [result.dequant_kernel_ms for result in group] + lines.append( + "| " + f"{input_dtype} | {distribution} | {config_name} | {len(group)} | " + f"{statistics.fmean(quant_times):.6f} | " + f"{statistics.fmean(dequant_times):.6f} | " + f"{statistics.median(quant_times):.6f} | " + f"{statistics.median(dequant_times):.6f} | " + f"{max(result.max_abs for result in group):.9g} | " + f"{max(result.mae for result in group):.9g} | " + f"{max(result.mse for result in group):.9g} |" + ) + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _build_argument_parser() -> argparse.ArgumentParser: + """定义组合遍历、筛选和重复次数的 CLI。""" + parser = argparse.ArgumentParser( + description="遍历 QDTENSOR 输入分布和 configs/ 中的量化配置,运行真实 CUDA app。", + ) + parser.add_argument( + "--executable", + type=Path, + default=_PROJECT_ROOT / "build" / "rtx4060-release" / "apps" / "quant_dequant", + help="已构建 app;默认是 rtx4060-release 的 quant_dequant。", + ) + parser.add_argument("--config-dir", type=Path, default=_DEFAULT_CONFIG_DIRECTORY) + parser.add_argument( + "--config-names", + nargs="*", + default=(), + help="可选:只运行这些 TOML stem,例如 mxfp8_tensor_stochastic_fp32。", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=_PROJECT_ROOT / "outputs" / "e2e_suite", + ) + parser.add_argument("--rows", type=_positive_integer, default=1024) + parser.add_argument("--cols", type=_positive_integer, default=1025) + parser.add_argument("--input-dtypes", nargs="+", choices=("fp16", "fp32"), default=("fp16", "fp32")) + parser.add_argument( + "--distributions", + nargs="+", + choices=("uniform", "normal", "outlier"), + default=("uniform", "normal", "outlier"), + ) + parser.add_argument("--formats", nargs="+", choices=("mxfp8", "nvfp4"), default=("mxfp8", "nvfp4")) + parser.add_argument("--scale-modes", nargs="+", choices=("block", "tensor"), default=("block", "tensor")) + parser.add_argument("--roundings", nargs="+", choices=("nearest", "stochastic"), default=("nearest", "stochastic")) + parser.add_argument("--output-dtypes", nargs="+", choices=("fp16", "bf16", "fp32"), default=("fp16", "bf16", "fp32")) + parser.add_argument("--seed", type=int, default=20260917) + parser.add_argument( + "--warmups", + type=_nonnegative_integer, + default=0, + help=( + "每个配置额外执行的功能验证次数;每次都是独立 app 进程," + "不构成同进程性能 warmup,正式性能请使用 quant_dequant_bench。" + ), + ) + parser.add_argument( + "--repeats", + type=_positive_integer, + default=1, + help=( + "每个配置的正式端到端功能运行次数;用于 JSON/产物交叉验证," + "不是专门 kernel benchmark 的 repeat。" + ), + ) + return parser + + +def main() -> int: + """执行输入生成、真实配置遍历、报告验证和最终汇总。""" + arguments = _build_argument_parser().parse_args() + try: + app_configs = _discover_and_filter_configs(arguments) + except RuntimeError as error: + print(f"run_e2e_suite 失败:{error}") + return 1 + + suite = SuiteConfig( + executable_path=arguments.executable.resolve(), + output_directory=arguments.output_dir.resolve(), + num_rows=arguments.rows, + num_cols=arguments.cols, + input_dtypes=tuple(arguments.input_dtypes), + distributions=tuple(arguments.distributions), + app_configs=app_configs, + base_seed=arguments.seed, + num_warmups=arguments.warmups, + num_repeats=arguments.repeats, + ) + if suite.num_warmups != 0: + print( + "注意:run_e2e_suite 的 warmup 会重新启动 app," + "不会预热后续 repeat 的 CUDA stream/device buffer;" + "性能测量请改用 quant_dequant_bench。" + ) + if not suite.executable_path.is_file(): + print(f"run_e2e_suite 失败:找不到 app 可执行文件:{suite.executable_path}") + return 1 + if not _GENERATOR_PATH.is_file(): + print(f"run_e2e_suite 失败:找不到生成器:{_GENERATOR_PATH}") + return 1 + + suite.output_directory.mkdir(parents=True, exist_ok=True) + results: list[RunResult] = [] + try: + for input_dtype in suite.input_dtypes: + for distribution_index, distribution in enumerate(suite.distributions): + input_path = _generate_input(suite, input_dtype, distribution, distribution_index) + for app_config in suite.app_configs: + for warmup_index in range(suite.num_warmups): + _run_app_once( + suite, + input_path, + input_dtype, + distribution, + app_config, + "warmup", + warmup_index, + ) + for repeat_index in range(suite.num_repeats): + results.append( + _run_app_once( + suite, + input_path, + input_dtype, + distribution, + app_config, + "repeat", + repeat_index, + ) + ) + except (OSError, RuntimeError, ValueError) as error: + print(f"run_e2e_suite 失败:{error}") + return 1 + + summary_path = suite.output_directory / "summary.md" + _write_summary(summary_path, results) + print( + "端到端配置遍历通过:" + f"{len(results)} 次正式运行," + f"{len(suite.app_configs)} 份 TOML," + f"{len(suite.input_dtypes)} 种输入 dtype," + f"{len(suite.distributions)} 种分布。" + ) + print(f"汇总报告:{summary_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/CMakeLists.txt" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/CMakeLists.txt" new file mode 100644 index 00000000..37415e77 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/CMakeLists.txt" @@ -0,0 +1,43 @@ +add_library(quant_dequant_core STATIC + quant_dequant.cpp + config/config_parser.cpp + io/tensor_io.cpp + io/quantized_io.cpp + metrics/metrics.cpp + reference/reference_dispatch.cpp + reference/mxfp8_reference.cpp + reference/nvfp4_reference.cpp + pipeline/quantize.cpp + pipeline/dequantize.cpp + cuda/mxfp8_quantize.cu + cuda/mxfp8_dequantize.cu + cuda/nvfp4_quantize.cu + cuda/nvfp4_dequantize.cu + common/cuda_stream.cu + common/cuda_timer.cu +) + +# 文件只承担 pipeline 编排,故保留 .cpp 后缀;但其中构造并写入 +# thrust::device_vector,会实例化 CUDA 后端模板,必须交由 NVCC 编译。 +# 它不定义任何 __global__ kernel;kernel 仍只位于 src/cuda/*.cu。 +set_source_files_properties(pipeline/quantize.cpp PROPERTIES LANGUAGE CUDA) +set_source_files_properties(pipeline/dequantize.cpp PROPERTIES LANGUAGE CUDA) + +add_library(quant_dequant::core ALIAS quant_dequant_core) + +target_compile_features(quant_dequant_core PUBLIC cxx_std_20) + +target_include_directories(quant_dequant_core + PUBLIC + $ + PRIVATE + ${PROJECT_SOURCE_DIR}/src +) + +target_link_libraries(quant_dequant_core PUBLIC CUDA::cudart) + +set_target_properties(quant_dequant_core PROPERTIES + CUDA_SEPARABLE_COMPILATION ON +) + +quant_dequant_enable_warnings(quant_dequant_core) diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cu" new file mode 100644 index 00000000..52cd555f --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cu" @@ -0,0 +1,48 @@ +#include "common/cuda_stream.cuh" + +#include +#include + +namespace quant_dequant::common { +namespace { + +/** + * @brief 检查 CUDA runtime 返回值并转换为 CudaStreamError。 + * + * @param status CUDA runtime API 状态。 + * @param operation 失败的 stream 操作名称。 + * @throws CudaStreamError status 不是 cudaSuccess 时抛出。 + */ +void check_cuda(const cudaError_t status, const char* const operation) { + if (status != cudaSuccess) { + throw CudaStreamError{ + "CUDA stream " + std::string{operation} + " 失败:" + + cudaGetErrorString(status)}; + } +} + +} // namespace + +CudaStreamError::CudaStreamError(std::string detail) + : std::runtime_error(std::move(detail)) {} + +CudaStream::CudaStream() { + check_cuda(cudaStreamCreateWithFlags(&mStream, cudaStreamNonBlocking), + "创建 non-blocking stream"); +} + +CudaStream::~CudaStream() { + if (mStream != nullptr) { + static_cast(cudaStreamDestroy(mStream)); + } +} + +cudaStream_t CudaStream::get() const noexcept { + return mStream; +} + +void CudaStream::synchronize() const { + check_cuda(cudaStreamSynchronize(mStream), "同步 stream"); +} + +} // namespace quant_dequant::common diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cuh" new file mode 100644 index 00000000..76bd0e53 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_stream.cuh" @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include +#include + +namespace quant_dequant::common { + +/** @brief 表示 CUDA stream 创建或同步失败时的错误。 */ +class CudaStreamError final : public std::runtime_error { +public: + /** + * @brief 构造带有 CUDA runtime 诊断的 stream 错误。 + * + * @param detail 失败操作及 CUDA 错误文本。 + */ + explicit CudaStreamError(std::string detail); +}; + +/** + * @brief 拥有一条 non-blocking CUDA stream 的 RAII 对象。 + * + * stream 用于串联一次 pipeline 调用的 H2D、CUDA Event、全部 kernel 和 D2H。 + * 它使用 `cudaStreamNonBlocking` 创建,因此不因 legacy default stream 与其他 + * 独立 stream 发生隐式双向等待;本对象不跨线程共享,也不暴露所有权转移。 + */ +class CudaStream final { +public: + /** + * @brief 创建一条 non-blocking CUDA stream。 + * + * @throws CudaStreamError 当前 device 无法创建 stream 时抛出。 + */ + CudaStream(); + + CudaStream(const CudaStream&) = delete; + CudaStream& operator=(const CudaStream&) = delete; + CudaStream(CudaStream&&) = delete; + CudaStream& operator=(CudaStream&&) = delete; + + /** @brief 销毁拥有的 CUDA stream;析构函数不抛出异常。 */ + ~CudaStream(); + + /** + * @brief 借出给 Thrust execution policy、event 和 kernel launch 使用的 stream。 + * + * @return 不拥有的有效 cudaStream_t;其生命周期由本对象保证。 + */ + [[nodiscard]] cudaStream_t get() const noexcept; + + /** + * @brief 等待当前 stream 中此前提交的全部工作完成。 + * + * @throws CudaStreamError CUDA runtime 同步失败时抛出。 + */ + void synchronize() const; + +private: + /** 由本对象独占拥有的 CUDA stream。 */ + cudaStream_t mStream{}; +}; + +} // namespace quant_dequant::common diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cu" new file mode 100644 index 00000000..f5a5b239 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cu" @@ -0,0 +1,84 @@ +#include "common/cuda_timer.cuh" + +#include +#include + +namespace quant_dequant::common { +namespace { + +/** + * @brief 将 CUDA runtime 状态转化为计时器异常。 + * + * @param status CUDA runtime API 的返回值。 + * @param operation 失败的 CUDA event 操作名称。 + * @throws CudaTimerError status 不是 cudaSuccess 时抛出。 + */ +void check_cuda(const cudaError_t status, const char* const operation) { + if (status != cudaSuccess) { + throw CudaTimerError{ + "CUDA Event 计时器 " + std::string{operation} + " 失败:" + + cudaGetErrorString(status)}; + } +} + +} // namespace + +CudaTimerError::CudaTimerError(std::string detail) + : std::runtime_error(std::move(detail)) {} + +CudaEventTimer::CudaEventTimer(const cudaStream_t stream) + : mStream(stream) { + check_cuda(cudaEventCreate(&mStartEvent), "创建 start event"); + try { + check_cuda(cudaEventCreate(&mStopEvent), "创建 stop event"); + } catch (...) { + // 构造函数第二个资源失败时主动回收第一个;析构函数尚不会运行。 + static_cast(cudaEventDestroy(mStartEvent)); + mStartEvent = nullptr; + throw; + } +} + +CudaEventTimer::~CudaEventTimer() { + // 析构函数不能抛异常。此处只回收对象拥有的 event;若 CUDA context 已被外部 + // 销毁,runtime 可能报错,但没有可靠的析构期恢复动作。 + if (mStartEvent != nullptr) { + static_cast(cudaEventDestroy(mStartEvent)); + } + if (mStopEvent != nullptr) { + static_cast(cudaEventDestroy(mStopEvent)); + } +} + +void CudaEventTimer::start() { + if (mState == State::kRunning) { + throw CudaTimerError{"CUDA Event 计时器不能在未 stop 的区间内再次 start。"}; + } + + check_cuda(cudaEventRecord(mStartEvent, mStream), "记录 start event"); + mState = State::kRunning; +} + +void CudaEventTimer::stop() { + if (mState != State::kRunning) { + throw CudaTimerError{"CUDA Event 计时器必须在 start 后才能 stop。"}; + } + + check_cuda(cudaEventRecord(mStopEvent, mStream), "记录 stop event"); + mState = State::kStopped; +} + +float CudaEventTimer::elapsedMilliseconds() const { + if (mState != State::kStopped) { + throw CudaTimerError{ + "CUDA Event 计时器必须在 stop 后才能读取 elapsed time。"}; + } + + check_cuda(cudaEventSynchronize(mStopEvent), "同步 stop event"); + float elapsed_ms = 0.0F; + check_cuda(cudaEventElapsedTime(&elapsed_ms, mStartEvent, mStopEvent), + "计算 elapsed time"); + return elapsed_ms; +} + +} // namespace quant_dequant::common diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cuh" new file mode 100644 index 00000000..62aa93a4 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/common/cuda_timer.cuh" @@ -0,0 +1,96 @@ +#pragma once + +#include + +#include +#include + +namespace quant_dequant::common { + +/** @brief 表示 CUDA Event 创建、记录或同步失败时的错误。 */ +class CudaTimerError final : public std::runtime_error { +public: + /** + * @brief 构造带有 CUDA runtime 诊断信息的计时器错误。 + * + * @param detail 失败操作及 CUDA 错误文本。 + */ + explicit CudaTimerError(std::string detail); +}; + +/** + * @brief 只测量指定 CUDA stream 中 kernel 区间的 move-only Event 计时器。 + * + * 调用顺序必须是 `start()`、发射一个或多个 kernel、`stop()`、 + * `elapsedMilliseconds()`。调用方应把 H2D、D2H、Thrust 分配和文件 I/O 放在 + * start/stop 区间之外;stop event 会在同一 stream 中等待此前 kernel 完成,因此 + * elapsed 只包含两个 event 之间的 device 工作时间。 + */ +class CudaEventTimer final { +public: + /** + * @brief 为指定 stream 创建一对 CUDA event。 + * + * @param stream 要测量的非拥有 CUDA stream;默认 stream 传 nullptr。 + * @throws CudaTimerError CUDA event 创建失败时抛出。 + */ + explicit CudaEventTimer(cudaStream_t stream = nullptr); + + CudaEventTimer(const CudaEventTimer&) = delete; + CudaEventTimer& operator=(const CudaEventTimer&) = delete; + CudaEventTimer(CudaEventTimer&&) = delete; + CudaEventTimer& operator=(CudaEventTimer&&) = delete; + + /** @brief 销毁内部 CUDA event;析构函数不抛出异常。 */ + ~CudaEventTimer(); + + /** + * @brief 在关联 stream 中记录开始 event。 + * + * 已完成一次 stop/elapsed 后可重新 start,计时器会覆盖旧区间。 + * + * @throws CudaTimerError event 记录失败或上一个区间尚未 stop 时抛出。 + */ + void start(); + + /** + * @brief 在关联 stream 中记录结束 event。 + * + * @throws CudaTimerError 尚未 start 或 event 记录失败时抛出。 + */ + void stop(); + + /** + * @brief 同步结束 event 并返回最近区间的毫秒数。 + * + * @return 有限且非负的 CUDA Event elapsed time,单位毫秒。 + * @throws CudaTimerError 尚未 stop、同步或 elapsed 计算失败时抛出。 + */ + [[nodiscard]] float elapsedMilliseconds() const; + +private: + enum class State { + /** 尚未记录开始 event,或已读取完前一次结果。 */ + kIdle, + + /** 已记录开始 event,尚未记录结束 event。 */ + kRunning, + + /** 已记录结束 event,可读取 elapsed time。 */ + kStopped, + }; + + /** 由对象拥有的开始 event。 */ + cudaEvent_t mStartEvent{}; + + /** 由对象拥有的结束 event。 */ + cudaEvent_t mStopEvent{}; + + /** 不拥有的、由调用方管理生命周期的计时 stream。 */ + cudaStream_t mStream{}; + + /** 用于验证 API 调用顺序的当前状态。 */ + State mState{State::kIdle}; +}; + +} // namespace quant_dequant::common diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/config/config_parser.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/config/config_parser.cpp" new file mode 100644 index 00000000..2ddf7b22 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/config/config_parser.cpp" @@ -0,0 +1,605 @@ +#include "quant_dequant/config.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quant_dequant { +namespace { + +/** + * @brief 删除字符串两端的 ASCII 空白字符,但不分配新内存。 + * + * @param value 待裁剪的字符串视图。 + * @return 指向原字符串有效子区间的视图。 + */ +[[nodiscard]] std::string_view trim_ascii_whitespace(std::string_view value) noexcept { + const auto is_whitespace = [](const unsigned char character) noexcept { + return std::isspace(character) != 0; + }; + + while (!value.empty() && is_whitespace(static_cast(value.front()))) { + value.remove_prefix(1U); + } + + while (!value.empty() && is_whitespace(static_cast(value.back()))) { + value.remove_suffix(1U); + } + + return value; +} + +/** + * @brief 找到引号外的行尾注释起点。 + * + * 当前配置格式不支持字符串转义;因此双引号只能成对出现。未闭合的引号 + * 会在后续值解析阶段报告为带行号的 ConfigError。 + * + * @param line 单行配置文本。 + * @return 删除引号外 `#` 注释后的字符串视图。 + */ +[[nodiscard]] std::string_view remove_trailing_comment(std::string_view line) noexcept { + bool inside_quotes = false; + + for (std::size_t index = 0U; index < line.size(); ++index) { + const char character = line[index]; + + if (character == '"') { + inside_quotes = !inside_quotes; + continue; + } + + if (character == '#' && !inside_quotes) { + return line.substr(0U, index); + } + } + + return line; +} + +/** + * @brief 找到引号外唯一有效的 key/value 分隔符。 + * + * @param line 已移除行尾注释的配置行。 + * @return 分隔符位置;不存在时返回空值。 + */ +[[nodiscard]] std::optional find_assignment_separator( + const std::string_view line) noexcept { + bool inside_quotes = false; + + for (std::size_t index = 0U; index < line.size(); ++index) { + const char character = line[index]; + + if (character == '"') { + inside_quotes = !inside_quotes; + continue; + } + + if (character == '=' && !inside_quotes) { + return index; + } + } + + return std::nullopt; +} + +/** + * @brief 为 ConfigError 生成统一的异常消息。 + * + * @param file_path 配置文件路径。 + * @param line_number 1-based 行号;0 表示无特定行。 + * @param detail 具体错误说明。 + * @return 可传给 std::runtime_error 的完整错误文本。 + */ +[[nodiscard]] std::string make_error_message(const std::filesystem::path& file_path, + const std::size_t line_number, + const std::string_view detail) { + std::string message{"配置文件 \""}; + message += file_path.string(); + message += "\""; + + if (line_number != 0U) { + message += " 第 "; + message += std::to_string(line_number); + message += " 行"; + } + + message += ":"; + message += detail; + return message; +} + +/** + * @brief 抛出带文件路径和行号的配置错误。 + * + * @param file_path 配置文件路径。 + * @param line_number 1-based 行号;0 表示无特定行。 + * @param detail 具体错误说明。 + */ +[[noreturn]] void throw_config_error(const std::filesystem::path& file_path, + const std::size_t line_number, + std::string detail) { + throw ConfigError(file_path, line_number, std::move(detail)); +} + +/** + * @brief 解析可带双引号的标量字符串。 + * + * 双引号用于保留 `#`、空格等普通字符;第一版不支持转义序列或嵌套引号。 + * + * @param value 已裁剪空白的原始 value。 + * @param file_path 配置文件路径,用于报错。 + * @param line_number 发生解析的 1-based 行号。 + * @return 去除外层双引号后的非拥有字符串视图。 + * @throws ConfigError value 为空、引号不配对或包含未支持的内嵌引号时抛出。 + */ +[[nodiscard]] std::string_view parse_scalar_value( + const std::string_view value, const std::filesystem::path& file_path, + const std::size_t line_number) { + if (value.empty()) { + throw_config_error(file_path, line_number, "key 缺少 value。"); + } + + const bool starts_with_quote = value.front() == '"'; + const bool ends_with_quote = value.back() == '"'; + + if (starts_with_quote != ends_with_quote) { + throw_config_error(file_path, line_number, "字符串 value 的双引号不配对。"); + } + + if (!starts_with_quote) { + if (value.find('"') != std::string_view::npos) { + throw_config_error(file_path, line_number, + "未加引号的 value 不能包含双引号。"); + } + return value; + } + + if (value.size() < 2U) { + throw_config_error(file_path, line_number, "字符串 value 不能为空的单个双引号。"); + } + + const std::string_view unquoted_value = value.substr(1U, value.size() - 2U); + if (unquoted_value.find('"') != std::string_view::npos) { + throw_config_error(file_path, line_number, + "第一版配置格式不支持字符串内的双引号或转义序列。"); + } + + return unquoted_value; +} + +/** + * @brief 将十进制无符号整数字符串转换为指定整数类型。 + * + * @tparam IntegerType 目标无符号整数类型。 + * @param value 已去除引号和两端空白的十进制字符串。 + * @param field_name 当前字段名,用于生成诊断。 + * @param file_path 配置文件路径。 + * @param line_number 发生解析的 1-based 行号。 + * @return 成功解析的整数。 + * @throws ConfigError value 非十进制整数、超出范围或为空时抛出。 + */ +template +[[nodiscard]] IntegerType parse_unsigned_integer( + const std::string_view value, const std::string_view field_name, + const std::filesystem::path& file_path, const std::size_t line_number) { + static_assert(std::is_unsigned_v); + + IntegerType result{}; + const auto [end_ptr, error_code] = + std::from_chars(value.data(), value.data() + value.size(), result, 10); + + if (error_code != std::errc{} || end_ptr != value.data() + value.size()) { + throw_config_error(file_path, line_number, + "字段 \"" + std::string{field_name} + + "\" 必须是范围内的十进制无符号整数。"); + } + + return result; +} + +/** + * @brief 将配置文本映射到 QuantFormat 枚举。 + * + * @param value 已去除引号的文本。 + * @param file_path 配置文件路径。 + * @param line_number 发生解析的 1-based 行号。 + * @return 合法的量化格式。 + */ +[[nodiscard]] QuantFormat parse_quant_format(const std::string_view value, + const std::filesystem::path& file_path, + const std::size_t line_number) { + if (value == "mxfp8") { + return QuantFormat::kMxfp8; + } + + if (value == "nvfp4") { + return QuantFormat::kNvfp4; + } + + throw_config_error(file_path, line_number, + "字段 \"format\" 只能是 \"mxfp8\" 或 \"nvfp4\"。"); +} + +/** + * @brief 将配置文本映射到 ScaleMode 枚举。 + * + * @param value 已去除引号的文本。 + * @param file_path 配置文件路径。 + * @param line_number 发生解析的 1-based 行号。 + * @return 合法的 scale mode。 + */ +[[nodiscard]] ScaleMode parse_scale_mode(const std::string_view value, + const std::filesystem::path& file_path, + const std::size_t line_number) { + if (value == "tensor") { + return ScaleMode::kTensor; + } + + if (value == "block") { + return ScaleMode::kBlock; + } + + throw_config_error(file_path, line_number, + "字段 \"scale_mode\" 只能是 \"tensor\" 或 \"block\"。"); +} + +/** + * @brief 将配置文本映射到反量化输出 DType。 + * + * @param value 已去除引号的文本。 + * @param file_path 配置文件路径。 + * @param line_number 发生解析的 1-based 行号。 + * @return 合法的反量化输出类型。 + */ +[[nodiscard]] DType parse_output_type(const std::string_view value, + const std::filesystem::path& file_path, + const std::size_t line_number) { + if (value == "fp16") { + return DType::kFloat16; + } + + if (value == "bf16") { + return DType::kBFloat16; + } + + if (value == "fp32") { + return DType::kFloat32; + } + + throw_config_error(file_path, line_number, + "字段 \"output_type\" 只能是 \"fp16\"、\"bf16\" 或 \"fp32\"。"); +} + +/** + * @brief 将配置文本映射到 RoundingMode 枚举。 + * + * @param value 已去除引号的文本。 + * @param file_path 配置文件路径。 + * @param line_number 发生解析的 1-based 行号。 + * @return 合法的舍入模式。 + */ +[[nodiscard]] RoundingMode parse_rounding_mode( + const std::string_view value, const std::filesystem::path& file_path, + const std::size_t line_number) { + if (value == "nearest") { + return RoundingMode::kNearest; + } + + if (value == "stochastic") { + return RoundingMode::kStochastic; + } + + throw_config_error(file_path, line_number, + "字段 \"rounding\" 只能是 \"nearest\" 或 \"stochastic\"。"); +} + +/** + * @brief 记录配置字段是否出现及其 1-based 行号。 + * + * 同一个平面 `key = value` 文件可被投影为量化、反量化或完整 app 配置; + * 因此“是否必填”不属于这个记录结构,而由调用方选择的配置入口决定。 + */ +struct ConfigFieldLines { + std::size_t format{0U}; + std::size_t block_size{0U}; + std::size_t scale_mode{0U}; + std::size_t output_type{0U}; + std::size_t rounding{0U}; + std::size_t target_gpu{0U}; + std::size_t stochastic_seed{0U}; +}; + +/** + * @brief 保存一次语法解析的全部已知字段及其出现位置。 + * + * 该类型只存在于 parser 内部。它允许文本文件只写量化字段或只写反量化字段, + * 再由不同的 public loader 施加各自的必填字段和语义约束。 + */ +struct ParsedConfigDocument { + QuantizationConfig quantization{}; + DequantizationConfig dequantization{}; + ReportConfig report{}; + ConfigFieldLines field_lines{}; +}; + +/** + * @brief 在字段首次出现时记录行号,重复时抛出异常。 + * + * @param line_slot 对应字段的行号槽位。 + * @param key 当前 key。 + * @param line_number 当前 1-based 行号。 + * @param file_path 配置文件路径。 + */ +void mark_field_seen(std::size_t& line_slot, const std::string_view key, + const std::size_t line_number, + const std::filesystem::path& file_path) { + if (line_slot != 0U) { + throw_config_error(file_path, line_number, + "字段 \"" + std::string{key} + "\" 重复;首次出现于第 " + + std::to_string(line_slot) + " 行。"); + } + + line_slot = line_number; +} + +/** + * @brief 确认一个按当前 loader 必填的字段确实出现在文件中。 + * + * @param line_number 对应字段的出现行号;0 表示字段未出现。 + * @param field_name 字段名称。 + * @param file_path 配置文件路径。 + */ +void require_field(const std::size_t line_number, + const std::string_view field_name, + const std::filesystem::path& file_path) { + if (line_number == 0U) { + throw_config_error(file_path, 0U, + "缺少必填字段 \"" + std::string{field_name} + "\"。"); + } +} + +/** + * @brief 验证单方向量化配置的必填字段及数值语义。 + * + * @param document 已完成语法解析的配置文档。 + * @param file_path 配置文件路径。 + */ +void validate_quantization_config(const ParsedConfigDocument& document, + const std::filesystem::path& file_path) { + const ConfigFieldLines& lines = document.field_lines; + const QuantizationConfig& config = document.quantization; + + require_field(lines.format, "format", file_path); + require_field(lines.block_size, "block_size", file_path); + require_field(lines.scale_mode, "scale_mode", file_path); + require_field(lines.rounding, "rounding", file_path); + + if (!is_valid_block_size(config.format, config.block_size)) { + throw_config_error(file_path, lines.block_size, + "字段 \"block_size\" 与 \"format\" 不匹配。"); + } + + if (!is_valid_scale_mode(config.format, config.scale_mode)) { + throw_config_error( + file_path, lines.scale_mode, + "NVFP4 仅支持 scale_mode = \"block\":每 16 个元素保存一个 E4M3 " + "local scale,并与 FP32 global_scale 共同构成分层缩放。"); + } + + if (config.rounding == RoundingMode::kNearest && + config.stochastic_seed != 0U) { + throw_config_error(file_path, lines.stochastic_seed, + "nearest 舍入不允许非零 \"stochastic_seed\"。"); + } + + if (!config.isValid()) { + throw_config_error(file_path, 0U, "量化配置未通过最终语义校验。"); + } +} + +/** + * @brief 验证单方向反量化配置的必填字段及数值语义。 + * + * @param document 已完成语法解析的配置文档。 + * @param file_path 配置文件路径。 + */ +void validate_dequantization_config(const ParsedConfigDocument& document, + const std::filesystem::path& file_path) { + require_field(document.field_lines.output_type, "output_type", file_path); + + if (!document.dequantization.isValid()) { + throw_config_error(file_path, document.field_lines.output_type, + "反量化输出类型不受支持。"); + } +} + +/** + * @brief 验证完整 app 配置额外需要的报告字段。 + * + * @param document 已完成语法解析的配置文档。 + * @param file_path 配置文件路径。 + */ +void validate_app_config(const ParsedConfigDocument& document, + const std::filesystem::path& file_path) { + validate_quantization_config(document, file_path); + validate_dequantization_config(document, file_path); + require_field(document.field_lines.target_gpu, "target_gpu", file_path); + + const AppConfig app_config{ + .quantization = document.quantization, + .dequantization = document.dequantization, + .report = document.report, + }; + if (!app_config.isValid()) { + throw_config_error(file_path, 0U, "完整 app 配置未通过最终语义校验。"); + } +} + +/** + * @brief 解析平面 `key = value` 文件中的全部已知字段,不预设运行方向。 + * + * 语法、标量类型、重复 key 与未知 key 始终在这里验证;量化、反量化和 app + * 三类 loader 各自的必填字段判断则延后到对应的语义校验函数。 + * + * @param config_path 待解析的配置文件路径。 + * @return 已完成语法解析的内部文档。 + * @throws ConfigError 文件无法打开、I/O 失败或文本语法非法时抛出。 + */ +[[nodiscard]] ParsedConfigDocument parse_config_document( + const std::filesystem::path& config_path) { + std::ifstream input_stream{config_path}; + if (!input_stream.is_open()) { + throw_config_error(config_path, 0U, "无法打开文件。"); + } + + ParsedConfigDocument document{}; + std::string line{}; + std::size_t line_number = 0U; + + while (std::getline(input_stream, line)) { + ++line_number; + + if (line_number == 1U && line.starts_with("\xEF\xBB\xBF")) { + line.erase(0U, 3U); + } + + const std::string_view uncommented_line = remove_trailing_comment(line); + const std::string_view content = trim_ascii_whitespace(uncommented_line); + if (content.empty()) { + continue; + } + + const auto separator_position = find_assignment_separator(content); + if (!separator_position.has_value()) { + throw_config_error(config_path, line_number, + "配置行必须使用 \"key = value\" 形式。"); + } + + const std::string_view key = + trim_ascii_whitespace(content.substr(0U, *separator_position)); + const std::string_view raw_value = + trim_ascii_whitespace(content.substr(*separator_position + 1U)); + if (key.empty()) { + throw_config_error(config_path, line_number, "配置行缺少 key。"); + } + + if (key.find_first_of(" \t\r\n") != std::string_view::npos) { + throw_config_error(config_path, line_number, "key 不能包含空白字符。"); + } + + const std::string_view value = + parse_scalar_value(raw_value, config_path, line_number); + ConfigFieldLines& field_lines = document.field_lines; + + if (key == "format") { + mark_field_seen(field_lines.format, key, line_number, config_path); + document.quantization.format = + parse_quant_format(value, config_path, line_number); + continue; + } + + if (key == "block_size") { + mark_field_seen(field_lines.block_size, key, line_number, config_path); + document.quantization.block_size = parse_unsigned_integer( + value, key, config_path, line_number); + continue; + } + + if (key == "scale_mode") { + mark_field_seen(field_lines.scale_mode, key, line_number, config_path); + document.quantization.scale_mode = + parse_scale_mode(value, config_path, line_number); + continue; + } + + if (key == "rounding") { + mark_field_seen(field_lines.rounding, key, line_number, config_path); + document.quantization.rounding = + parse_rounding_mode(value, config_path, line_number); + continue; + } + + if (key == "stochastic_seed") { + mark_field_seen(field_lines.stochastic_seed, key, line_number, config_path); + document.quantization.stochastic_seed = + parse_unsigned_integer(value, key, config_path, line_number); + continue; + } + + if (key == "output_type") { + mark_field_seen(field_lines.output_type, key, line_number, config_path); + document.dequantization.output_type = + parse_output_type(value, config_path, line_number); + continue; + } + + if (key == "target_gpu") { + mark_field_seen(field_lines.target_gpu, key, line_number, config_path); + if (value.empty()) { + throw_config_error(config_path, line_number, + "字段 \"target_gpu\" 不能为空字符串。"); + } + document.report.target_gpu = value; + continue; + } + + throw_config_error(config_path, line_number, + "未知配置字段 \"" + std::string{key} + "\"。"); + } + + if (!input_stream.eof()) { + throw_config_error(config_path, line_number, "读取文件时发生 I/O 错误。"); + } + + return document; +} + +} // namespace + +ConfigError::ConfigError(std::filesystem::path file_path, const std::size_t line_number, + std::string detail) + : std::runtime_error(make_error_message(file_path, line_number, detail)), + mFilePath(std::move(file_path)), + mLineNumber(line_number) {} + +const std::filesystem::path& ConfigError::filePath() const noexcept { + return mFilePath; +} + +std::size_t ConfigError::lineNumber() const noexcept { + return mLineNumber; +} + +QuantizationConfig load_quantization_config( + const std::filesystem::path& config_path) { + const ParsedConfigDocument document = parse_config_document(config_path); + validate_quantization_config(document, config_path); + return document.quantization; +} + +DequantizationConfig load_dequantization_config( + const std::filesystem::path& config_path) { + const ParsedConfigDocument document = parse_config_document(config_path); + validate_dequantization_config(document, config_path); + return document.dequantization; +} + +AppConfig load_app_config(const std::filesystem::path& config_path) { + const ParsedConfigDocument document = parse_config_document(config_path); + validate_app_config(document, config_path); + + return AppConfig{ + .quantization = document.quantization, + .dequantization = document.dequantization, + .report = document.report, + }; +} + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cu" new file mode 100644 index 00000000..ed6bce76 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cu" @@ -0,0 +1,204 @@ +#include "cuda/mxfp8_dequantize.cuh" + +#include + +#include +#include + +#include + +#include "formats/mxfp8_codec.cuh" +#include "pipeline/device_quantized_tensor.cuh" +#include "quant_dequant/quantize.hpp" + +namespace quant_dequant::cuda { +namespace { + +/** 单个反量化 CTA 使用的线程数量。 */ +inline constexpr int kThreadsPerCta = 256; + +/** 每个 SM 的目标常驻 CTA 数量,用于限制持久化一维网格规模。 */ +inline constexpr int kTargetCtasPerSm = 4; + +static_assert(kThreadsPerCta > 0); + +/** + * @brief 将 CUDA runtime 返回值转换为带阶段说明的 pipeline 异常。 + * + * @param status CUDA runtime API 的返回状态。 + * @param operation 失败的 CUDA 操作名称。 + * @throws CudaPipelineError status 不是 cudaSuccess 时抛出。 + */ +void check_cuda(const cudaError_t status, const char* const operation) { + if (status != cudaSuccess) { + throw CudaPipelineError{ + "mxfp8 CUDA dequantize " + std::string{operation} + + " 失败:" + cudaGetErrorString(status)}; + } +} + +/** + * @brief 验证 MXFP8 dequantize launcher 的输入量化张量与输出 FP32 buffer。 + * + * @param input 包含 E4M3 payload 与 E8M0 local scale 的只读 device 张量。 + * @param output 待写入的 device FP32 输出张量。 + * @throws CudaPipelineError 格式、形状、block 规则或 device buffer 不匹配时抛出。 + */ +void validate_mxfp8_dequantize_launch( + const pipeline::DeviceQuantizedTensor& input, + const pipeline::DeviceDequantizationOutput& output) { + if (!input.isConsistent() || !output.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA dequantize launcher 收到了不自洽的 device buffer。"}; + } + + if (input.desc.format != QuantFormat::kMxfp8 || + input.desc.block_size != kMxfp8BlockSize || + (input.desc.scale_mode != ScaleMode::kTensor && + input.desc.scale_mode != ScaleMode::kBlock) || + output.desc.num_rows != input.desc.source_desc.num_rows || + output.desc.num_cols != input.desc.source_desc.num_cols || + !is_supported_output_dtype(output.desc.dtype)) { + throw CudaPipelineError{ + "mxfp8 CUDA dequantize launcher 收到了不匹配的输入描述或输出描述。"}; + } +} + +/** + * @brief 以一维 grid-stride 映射解码完整 row-major MXFP8 张量。 + * + * 每个线程处理一个或多个线性元素下标。对于元素 `(row, column)`: + * + * - tensor-scale:读取唯一的 `local_scales[0]`; + * - block-scale:读取 `local_scales[row * blocks_per_row + column / 32]`。 + * + * 随后 `decode_mxfp8_element()` 完成 E4M3 与 E8M0 解码及乘法,结果写入 FP32 + * 输出。payload、scale 与 output 都按连续 row-major 线性布局访问,因此同一 + * warp 的相邻有效线程访问相邻地址,形成合并访存。 + * + * @param payload device 指针,指向连续 E4M3 byte payload,只读。 + * @param local_scales device 指针,指向 E8M0 scale code,只读。 + * @param output device 指针,指向连续 row-major FP32 结果,写入。 + * @param element_count 矩阵逻辑元素总数。 + * @param num_cols 矩阵列数,用于从线性下标恢复 row/column。 + * @param blocks_per_row block-scale 时每行 32 元素 block 数;tensor-scale 时为 0。 + * @param scale_mode 决定每个元素的 local scale 索引公式。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void mxfp8DequantizeKernel( + const std::uint8_t* __restrict__ payload, + const std::uint8_t* __restrict__ local_scales, + float* __restrict__ output, + const std::uint64_t element_count, + const std::uint64_t num_cols, + const std::uint64_t blocks_per_row, + const ScaleMode scale_mode) { + const std::uint64_t first_linear_index = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const std::uint64_t linear_stride = + static_cast(gridDim.x) * blockDim.x; + + // 大张量的逻辑线程数可超过持久化网格大小;每个线程按固定步长持续领取 + // 后续元素。没有跨元素依赖,因此既无需 shared memory 也无需同步。 + for (std::uint64_t linear_index = first_linear_index; + linear_index < element_count; + linear_index += linear_stride) { + const std::uint64_t row = linear_index / num_cols; + const std::uint64_t column = linear_index % num_cols; + const std::uint64_t scale_index = + scale_mode == ScaleMode::kTensor + ? 0U + : row * blocks_per_row + + column / static_cast(kMxfp8BlockSize); + + output[linear_index] = formats::decode_mxfp8_element( + payload[linear_index], local_scales[scale_index]); + } +} + +} // namespace + +void launch_mxfp8_dequantize( + const pipeline::DeviceQuantizedTensor& input, + pipeline::DeviceDequantizationOutput& output, + const cudaStream_t stream) { + validate_mxfp8_dequantize_launch(input, output); + + const auto element_count = input.desc.elementCount(); + if (!element_count.has_value() || *element_count == 0U) { + throw CudaPipelineError{ + "mxfp8 CUDA dequantize launcher 无法推导非空元素数量。"}; + } + + // block-scale 唯一需要的格式布局信息是每行 scale 数量;tensor-scale 的 + // 唯一 scale 固定在下标 0,因此显式传入 0,kernel 不会读取该值。 + std::uint64_t blocks_per_row = 0U; + if (input.desc.scale_mode == ScaleMode::kBlock) { + const auto derived_blocks_per_row = input.desc.blocksPerRow(); + if (!derived_blocks_per_row.has_value() || *derived_blocks_per_row == 0U) { + throw CudaPipelineError{ + "mxfp8 CUDA dequantize launcher 无法推导每行 block 数量。"}; + } + blocks_per_row = *derived_blocks_per_row; + } + + // 反量化无归约和跨 CTA 通信;网格只需覆盖足够线程后通过 grid-stride 完成 + // 全量元素。将 CTA 数限制到 SM * 4 能避免很大张量为每个小工作项发射 CTA。 + int device_id = 0; + check_cuda(cudaGetDevice(&device_id), "查询当前 device"); + + cudaDeviceProp device_properties{}; + check_cuda(cudaGetDeviceProperties(&device_properties, device_id), + "查询 device 属性"); + if (device_properties.multiProcessorCount <= 0 || + device_properties.maxGridSize[0] <= 0) { + throw CudaPipelineError{ + "mxfp8 CUDA dequantize launcher 未发现有效的 SM 或 grid.x 上限。"}; + } + + const std::uint64_t logical_cta_count = + *element_count / static_cast(kThreadsPerCta) + + (*element_count % static_cast(kThreadsPerCta) == 0U + ? 0U + : 1U); + const std::uint64_t resident_cta_count = + static_cast(device_properties.multiProcessorCount) * + static_cast(kTargetCtasPerSm); + const std::uint64_t desired_cta_count = + logical_cta_count < resident_cta_count + ? logical_cta_count + : resident_cta_count; + const std::uint64_t max_grid_x = + static_cast(device_properties.maxGridSize[0]); + const std::uint64_t launch_cta_count = + desired_cta_count < max_grid_x ? desired_cta_count : max_grid_x; + if (launch_cta_count == 0U) { + throw CudaPipelineError{ + "mxfp8 CUDA dequantize launcher 无法构造非空执行网格。"}; + } + + const dim3 grid{static_cast(launch_cta_count), 1U, 1U}; + const dim3 block{static_cast(kThreadsPerCta), 1U, 1U}; + + // DeviceQuantizedTensor / DeviceDequantizationOutput 管理实际所有权;这里 + // 只借用 raw pointer 传给 kernel,不触发额外 H2D/D2H 或隐式同步。 + const auto* const payload_ptr = + thrust::raw_pointer_cast(input.payload.data()); + const auto* const local_scales_ptr = + thrust::raw_pointer_cast(input.local_scales.data()); + auto* const output_ptr = thrust::raw_pointer_cast(output.values.data()); + + mxfp8DequantizeKernel<<>>( + payload_ptr, + local_scales_ptr, + output_ptr, + *element_count, + input.desc.source_desc.num_cols, + blocks_per_row, + input.desc.scale_mode); + + // 这里只检查 launch 参数错误;异步执行期错误将在 pipeline 的 D2H 时由 + // Thrust 传输操作报告。 + check_cuda(cudaGetLastError(), "发射 dequantize kernel"); +} + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cuh" new file mode 100644 index 00000000..270ab6d5 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_dequantize.cuh" @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace quant_dequant::pipeline { +struct DeviceDequantizationOutput; +struct DeviceQuantizedTensor; +} // namespace quant_dequant::pipeline + +namespace quant_dequant::cuda { + +/** + * @brief 启动 MXFP8 E4M3/E8M0 的 CUDA 反量化后端。 + * + * 每个线程解码一个 row-major E4M3 payload。tensor-scale 模式固定读取 + * `local_scales[0]`;block-scale 模式则由元素的 `(row, column)` 推导出其 + * rowwise 32 元素 block 的 E8M0 scale 下标。两种模式的元素级解码公式相同, + * 因此可以安全共享同一 kernel。 + * + * @param input 已完成 H2D 的 MXFP8 device payload 与 E8M0 local scale,只读。 + * @param output 已分配的 row-major device FP32 输出,kernel 写入每个元素。 + * @param stream 与本次 H2D、CUDA Event 和 D2H 共用的非拥有 stream。 + * @throws CudaPipelineError 描述、device buffer 或 CUDA runtime/kernel launch + * 不合法时抛出。 + */ +void launch_mxfp8_dequantize( + const pipeline::DeviceQuantizedTensor& input, + pipeline::DeviceDequantizationOutput& output, + cudaStream_t stream); + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cu" new file mode 100644 index 00000000..3bec6e20 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cu" @@ -0,0 +1,679 @@ +#include "cuda/mxfp8_quantize.cuh" + +#include + +#include +#include +#include +#include + +#include + +#include "formats/mxfp8_codec.cuh" +#include "quant_dequant/quantize.hpp" +#include "pipeline/device_quantized_tensor.cuh" + +namespace quant_dequant::cuda { +namespace { + +/** 单个 CUDA warp 中的固定线程数量。 */ +inline constexpr int kWarpSize = 32; + +/** 一个 CTA 同时处理的独立 MXFP8 量化 block 数量。 */ +inline constexpr int kWarpsPerCta = 8; + +/** block-scale kernel 的固定 CTA 线程数量。 */ +inline constexpr int kThreadsPerCta = kWarpSize * kWarpsPerCta; + +/** 每个 SM 的目标常驻 CTA 数量;实际值还受 occupancy 上限约束。 */ +inline constexpr int kTargetCtasPerSm = 4; + +static_assert(kMxfp8BlockSize == static_cast(kWarpSize)); +static_assert(kThreadsPerCta % kWarpSize == 0); + +/** + * @brief 将 CUDA runtime 返回值转换为带阶段说明的 pipeline 异常。 + * + * @param status CUDA runtime API 的返回状态。 + * @param operation 失败的 CUDA 操作名称。 + * @throws CudaPipelineError status 不是 cudaSuccess 时抛出。 + */ +void check_cuda(const cudaError_t status, const char* const operation) { + if (status != cudaSuccess) { + throw CudaPipelineError{ + "mxfp8 CUDA " + std::string{operation} + + " 失败:" + cudaGetErrorString(status)}; + } +} + +/** + * @brief 检查 block-scale launcher 收到的 device 对象和配置是否彼此匹配。 + * + * @param input 只读 device FP32 输入。 + * @param config 调用方传入的量化配置。 + * @param output 待写入的 MXFP8 device 输出。 + * @throws CudaPipelineError 任一对象不自洽或元数据不匹配时抛出。 + */ +void validate_mxfp8_block_launch( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + const pipeline::DeviceQuantizedTensor& output) { + if (!input.isConsistent() || !output.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA block-scale launcher 收到了不自洽的 device buffer。"}; + } + + if (!config.isValid() || config.format != QuantFormat::kMxfp8 || + config.scale_mode != ScaleMode::kBlock || + config.block_size != kMxfp8BlockSize || + output.desc.format != QuantFormat::kMxfp8 || + output.desc.scale_mode != ScaleMode::kBlock || + output.desc.block_size != kMxfp8BlockSize || + output.desc.rounding != config.rounding || + output.desc.stochastic_seed != config.stochastic_seed || + output.desc.source_desc.num_rows != input.desc.num_rows || + output.desc.source_desc.num_cols != input.desc.num_cols || + output.desc.source_desc.dtype != input.desc.dtype) { + throw CudaPipelineError{ + "mxfp8 CUDA block-scale launcher 收到了不匹配的输入、输出或配置。"}; + } +} + +/** + * @brief 保存一个规约线程当前看到的有限最大绝对值和非有限输入标记。 + * + * 单纯对 float 做最大值规约可能静默忽略 NaN;该状态把数值结果和 NaN/Inf + * 存在性一起向上规约,确保 tensor-scale 与 block-scale 都能拒绝非有限输入。 + */ +struct TensorAmaxState { + /** 当前线程或子组内所有有限元素的最大绝对值。 */ + float amax{0.0F}; + + /** 只要看到任意 NaN、+Inf 或 -Inf 就为非 0。 */ + std::uint32_t has_nonfinite{0U}; +}; + +/** + * @brief 将一个 FP32 输入值并入 tensor amax 规约状态。 + * + * @param state 当前线程持有的局部规约状态,原地更新。 + * @param value 本线程通过标量或 float4 读取到的一个输入元素。 + */ +__device__ __forceinline__ void accumulate_tensor_amax( + TensorAmaxState& state, + const float value) { + if (!formats::fp32::is_finite(value)) { + state.has_nonfinite = 1U; + return; + } + + const float magnitude = formats::fp32::absolute_value(value); + state.amax = state.amax > magnitude ? state.amax : magnitude; +} + +/** + * @brief 在一个 warp 内规约 amax 与非有限输入标记。 + * + * 所有调用 lane 必须处于活动状态。`amax` 使用最大值,`has_nonfinite` 使用 + * 逻辑或;因此 lane 0 返回整个 warp 的完整状态。 + * + * @param state 当前 lane 的局部规约状态。 + * @return 当前 lane 对应的 shuffle 规约中间状态;lane 0 为完整 warp 结果。 + */ +__device__ __forceinline__ TensorAmaxState warp_reduce_tensor_amax( + TensorAmaxState state) { + constexpr unsigned int kAllWarpLanes = 0xffffffffU; + + for (int offset = kWarpSize / 2; offset > 0; offset /= 2) { + const float neighbor_amax = + __shfl_down_sync(kAllWarpLanes, state.amax, offset); + const std::uint32_t neighbor_nonfinite = __shfl_down_sync( + kAllWarpLanes, state.has_nonfinite, offset); + state.amax = state.amax > neighbor_amax ? state.amax : neighbor_amax; + state.has_nonfinite |= neighbor_nonfinite; + } + + return state; +} + +/** + * @brief 组合 warp shuffle 和少量 shared memory,规约一个 256 线程 CTA。 + * + * 每个 warp 先通过 shuffle 产生一项结果,由 lane 0 写入 shared memory;CTA + * 的第一个 warp 再读取至多 8 项并做第二次 warp 规约。该结构直接借鉴给定 V7 + * reduction 的“warp reduce -> 每 warp 一项 shared -> warp 0 finalize”模式, + * 但把求和替换为 amax 与非有限标记的联合规约。 + * + * @param state 当前线程的局部规约状态。 + * @param shared_amax 长度至少为 8 的 shared 数组,保存每个 warp 的 amax。 + * @param shared_nonfinite 长度至少为 8 的 shared 数组,保存每个 warp 的标记。 + * @return thread 0 返回完整 CTA 结果;其他线程的返回值不应被使用。 + */ +__device__ __forceinline__ TensorAmaxState block_reduce_tensor_amax( + TensorAmaxState state, + float* const shared_amax, + std::uint32_t* const shared_nonfinite) { + const std::uint32_t lane_id = threadIdx.x % kWarpSize; + const std::uint32_t warp_id = threadIdx.x / kWarpSize; + + state = warp_reduce_tensor_amax(state); + if (lane_id == 0U) { + shared_amax[warp_id] = state.amax; + shared_nonfinite[warp_id] = state.has_nonfinite; + } + + // warp 0 只有在所有 warp 的 lane 0 均写完 partial 后才能读取 shared memory。 + __syncthreads(); + + if (warp_id == 0U) { + const std::uint32_t warp_count = + static_cast(blockDim.x / kWarpSize); + state = lane_id < warp_count + ? TensorAmaxState{ + shared_amax[lane_id], + shared_nonfinite[lane_id], + } + : TensorAmaxState{}; + state = warp_reduce_tensor_amax(state); + } + + return state; +} + +/** + * @brief 第一阶段:使用 V7 风格 float4 grid-stride 加载生成每 CTA 的 amax partial。 + * + * 每个 CTA 以 grid-stride 遍历多个连续 float4,寄存器中累计局部状态;随后采用 + * “warp shuffle + 8 项 shared memory + warp 0”规约,仅由 thread 0 写一项 + * `partial_amax[blockIdx.x]` 与一项 `partial_nonfinite[blockIdx.x]`。grid.x 固定 + * 为 `SM 数 * 4`,大输入由每个 CTA 的 grid-stride 循环继续处理。 + * + * @param input device 指针,指向连续 row-major FP32 输入,只读且至少 4-byte 对齐。 + * @param partial_amax device 指针,长度为 grid.x,每 CTA 写一项有限最大绝对值。 + * @param partial_nonfinite device 指针,长度为 grid.x,每 CTA 写一项非有限标记。 + * @param element_count 输入逻辑元素数。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void mxfp8TensorAmaxPartialKernel( + const float* __restrict__ input, + float* __restrict__ partial_amax, + std::uint32_t* __restrict__ partial_nonfinite, + const std::uint64_t element_count) { + __shared__ float shared_amax[kWarpsPerCta]; + __shared__ std::uint32_t shared_nonfinite[kWarpsPerCta]; + + TensorAmaxState state{}; + const auto* const input4 = reinterpret_cast(input); + const std::uint64_t vector_count = element_count / 4U; + const std::uint64_t first_vector_index = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const std::uint64_t vector_stride = + static_cast(gridDim.x) * blockDim.x; + + // float4 加载与 V7 一样让相邻线程读取相邻的 16-byte 向量;对于连续 FP32 + // device_vector,这同时减少循环次数并保持 warp 内合并全局内存访问。 + for (std::uint64_t vector_index = first_vector_index; + vector_index < vector_count; + vector_index += vector_stride) { + const float4 values = input4[vector_index]; + accumulate_tensor_amax(state, values.x); + accumulate_tensor_amax(state, values.y); + accumulate_tensor_amax(state, values.z); + accumulate_tensor_amax(state, values.w); + } + + // vector_count * 4 之后最多还有 3 个元素。它们仍以与 V7 相同的 grid-stride + // 映射由全部 CTA/线程共同处理,避免越过数组末尾的 float4 读取。 + const std::uint64_t tail_start = vector_count * 4U; + for (std::uint64_t linear_index = tail_start + first_vector_index; + linear_index < element_count; + linear_index += vector_stride) { + accumulate_tensor_amax(state, input[linear_index]); + } + + state = block_reduce_tensor_amax( + state, shared_amax, shared_nonfinite); + if (threadIdx.x == 0U) { + partial_amax[blockIdx.x] = state.amax; + partial_nonfinite[blockIdx.x] = state.has_nonfinite; + } +} + +/** + * @brief 第二阶段:单个 CTA 规约所有 partial,并直接写唯一的 E8M0 tensor scale。 + * + * 每个线程用 block-stride 遍历若干 partial,因此即使第一阶段 CTA 数量大于 256, + * 也无需第三层 reduction。完成 CTA 规约后 thread 0 直接调用 + * `compute_mxfp8_scale_code()` 写 `local_scales[0]`;这就是 tensor mode 省去 + * 独立 scale-finalize kernel 的关键。 + * + * @param partial_amax 第一阶段输出的有限最大绝对值数组,只读。 + * @param partial_nonfinite 第一阶段输出的非有限标记数组,只读。 + * @param local_scales device 指针,长度为 1;thread 0 写 E8M0 scale 或 0xff 哨兵。 + * @param partial_count 第一阶段 CTA 与 partial 数量。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void mxfp8TensorScaleFinalizeKernel( + const float* __restrict__ partial_amax, + const std::uint32_t* __restrict__ partial_nonfinite, + std::uint8_t* __restrict__ local_scales, + const std::uint64_t partial_count) { + __shared__ float shared_amax[kWarpsPerCta]; + __shared__ std::uint32_t shared_nonfinite[kWarpsPerCta]; + + TensorAmaxState state{}; + for (std::uint64_t partial_index = threadIdx.x; + partial_index < partial_count; + partial_index += blockDim.x) { + const float partial_value = partial_amax[partial_index]; + state.amax = state.amax > partial_value ? state.amax : partial_value; + state.has_nonfinite |= partial_nonfinite[partial_index]; + } + + state = block_reduce_tensor_amax( + state, shared_amax, shared_nonfinite); + if (threadIdx.x == 0U) { + local_scales[0] = state.has_nonfinite != 0U + ? formats::kE8M0NaNCode + : formats::compute_mxfp8_scale_code(state.amax); + } +} + +/** + * @brief 使用唯一的 tensor E8M0 scale 编码所有 row-major FP32 元素。 + * + * scale-finalize kernel 与本 kernel 是同一 default stream 上顺序发射的不同 + * kernel,因此所有 CTA 都能安全读取已经写完的 `local_scales[0]`。若上一阶段 + * 发现 NaN/Inf 而写入 0xff,本 kernel 不写 payload,D2H 会据此拒绝结果。 + * + * @param input device 指针,指向连续 row-major FP32 输入,只读。 + * @param payload device 指针,指向连续 E4M3 输出 payload,写入。 + * @param local_scales device 指针,长度为 1,读取唯一 E8M0 tensor scale。 + * @param element_count 输入和输出逻辑元素数。 + * @param rounding E4M3 编码时使用的舍入模式。 + * @param stochastic_seed stochastic rounding 的确定性种子。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void mxfp8TensorEncodeKernel( + const float* __restrict__ input, + std::uint8_t* __restrict__ payload, + const std::uint8_t* __restrict__ local_scales, + const std::uint64_t element_count, + const RoundingMode rounding, + const std::uint64_t stochastic_seed) { + const std::uint8_t scale_code = local_scales[0]; + if (formats::is_e8m0_nan(scale_code)) { + return; + } + + const std::uint64_t first_linear_index = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const std::uint64_t linear_stride = + static_cast(gridDim.x) * blockDim.x; + + for (std::uint64_t linear_index = first_linear_index; + linear_index < element_count; + linear_index += linear_stride) { + const float uniform_random = + rounding == RoundingMode::kStochastic + ? formats::mxfp8_stochastic_uniform_for_element( + stochastic_seed, linear_index) + : 0.0F; + payload[linear_index] = formats::encode_mxfp8_element( + input[linear_index], scale_code, rounding, uniform_random); + } +} + +/** + * @brief 以固定 CTA 网格持久处理 MXFP8 rowwise 32 元素量化 block。 + * + * 一个 warp 对应一个逻辑量化 block:lane 0--31 分别加载本 block 的一个元素, + * 通过 warp shuffle 规约 `amax`,由 lane 0 写 E8M0 scale,再编码各自的 E4M3 + * payload。一个 CTA 放置 8 个互不依赖的 warp;CTA 数量固定为不超过 + * `SM 数 * 目标 CTA/SM` 的值。每个 warp 随后以 grid-stride 继续处理更多量化 + * block,因此大矩阵不需要发射与量化 block 数相同数量的 CTA。 + * + * 尾 block 中超过实际列数的 lane 不访问内存、以 0 参与 amax 规约。发现 NaN + * 或 Inf 时,lane 0 写 E8M0 NaN code (`0xff`) 作为错误哨兵;后续 D2H 阶段必须 + * 扫描该哨兵并按与 CPU reference 一致的规则报告错误。 + * + * @param input device 指针,指向 row-major FP32 输入,只读。 + * @param payload device 指针,指向 row-major E4M3 输出 payload,写入。 + * @param local_scales device 指针,指向 rowwise E8M0 scale 数组,写入。 + * @param num_cols 输入矩阵列数。 + * @param blocks_per_row 每行的逻辑 32 元素量化 block 数。 + * @param total_blocks 全矩阵的逻辑量化 block 总数。 + * @param rounding E4M3 编码时使用的舍入模式。 + * @param stochastic_seed stochastic rounding 的确定性种子。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void mxfp8BlockQuantizeKernel( + const float* __restrict__ input, + std::uint8_t* __restrict__ payload, + std::uint8_t* __restrict__ local_scales, + const std::uint64_t num_cols, + const std::uint64_t blocks_per_row, + const std::uint64_t total_blocks, + const RoundingMode rounding, + const std::uint64_t stochastic_seed) { + constexpr unsigned int kAllWarpLanes = 0xffffffffU; + + const std::uint32_t lane_id = threadIdx.x % kWarpSize; + const std::uint32_t warp_id = threadIdx.x / kWarpSize; + const std::uint64_t first_block = + static_cast(blockIdx.x) * kWarpsPerCta + warp_id; + const std::uint64_t block_stride = + static_cast(gridDim.x) * kWarpsPerCta; + + // 固定数量 CTA 常驻时,每个 warp 以 grid-stride 反复领取同构的 32 元素 + // 工作项。这里无需 atomic work queue:每个量化 block 工作量相同,静态分配 + // 没有原子竞争且可保持相邻 lane 的合并访存。 + for (std::uint64_t quant_block = first_block; + quant_block < total_blocks; + quant_block += block_stride) { + const std::uint64_t row = quant_block / blocks_per_row; + const std::uint64_t block_in_row = quant_block % blocks_per_row; + const std::uint64_t first_column = + block_in_row * static_cast(kMxfp8BlockSize); + const std::uint64_t valid_element_count = num_cols - first_column; + const bool is_valid_lane = lane_id < valid_element_count; + const std::uint64_t linear_index = + row * num_cols + first_column + lane_id; + const float value = is_valid_lane ? input[linear_index] : 0.0F; + + // 所有 lane 都必须参与后续 shuffle。尾 block 的无效 lane 只贡献 +0, + // 因此不会改变 amax,也绝不访问最后一个真实元素之后的地址。 + const unsigned int nonfinite_lanes = __ballot_sync( + kAllWarpLanes, + is_valid_lane && !formats::fp32::is_finite(value)); + if (nonfinite_lanes != 0U) { + if (lane_id == 0U) { + local_scales[quant_block] = formats::kE8M0NaNCode; + } + + // 所有 lane 遵循相同条件分支,故可以安全继续处理下一个逻辑 block。 + // payload 不再有数值意义;D2H 会凭 0xff scale 哨兵拒绝整个量化结果。 + continue; + } + + float warp_amax = + is_valid_lane ? formats::fp32::absolute_value(value) : 0.0F; + for (int offset = kWarpSize / 2; offset > 0; offset /= 2) { + const float neighbor_amax = + __shfl_down_sync(kAllWarpLanes, warp_amax, offset); + warp_amax = warp_amax > neighbor_amax ? warp_amax : neighbor_amax; + } + + std::uint8_t scale_code = 0x00U; + if (lane_id == 0U) { + scale_code = formats::compute_mxfp8_scale_code(warp_amax); + local_scales[quant_block] = scale_code; + } + + // lane 0 的 E8M0 code 是本逻辑量化 block 的唯一 scale;广播后每个 + // lane 都用它把自己的 FP32 value 编码为 E4M3。 + scale_code = static_cast(__shfl_sync( + kAllWarpLanes, static_cast(scale_code), 0)); + + if (is_valid_lane) { + const float uniform_random = + rounding == RoundingMode::kStochastic + ? formats::mxfp8_stochastic_uniform_for_element( + stochastic_seed, linear_index) + : 0.0F; + payload[linear_index] = formats::encode_mxfp8_element( + value, scale_code, rounding, uniform_random); + } + } +} + +} // namespace + +void launch_mxfp8_block_quantize( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + pipeline::DeviceQuantizedTensor& output, + const cudaStream_t stream) { + // launcher 是 CUDA 后端的最后一道边界。即使某个内部调用绕开了 pipeline, + // 也不能让错误格式、错误 scale_mode 或不匹配的 device buffer 进入 kernel。 + validate_mxfp8_block_launch(input, config, output); + + // block mode 中一行被切为 ceil(num_cols / 32) 个逻辑量化 block;每个 + // 量化 block 恰好对应 output.local_scales 中的一项 E8M0 scale。 + const auto blocks_per_row = output.desc.blocksPerRow(); + if (!blocks_per_row.has_value() || *blocks_per_row == 0U) { + throw CudaPipelineError{ + "mxfp8 CUDA block-scale launcher 无法推导每行量化 block 数量。"}; + } + + // QuantizedTensorDesc 的一致性校验已保证: + // total_blocks == num_rows * blocks_per_row。 + // 这里直接使用 device scale 数组长度,确保 kernel 写入范围与实际分配内存 + // 完全相同,而不是重新计算一份可能与输出描述脱节的总数。 + const std::uint64_t total_blocks = + static_cast(output.local_scales.size()); + if (total_blocks == 0U) { + throw CudaPipelineError{ + "mxfp8 CUDA block-scale launcher 收到了空的 local scale 数组。"}; + } + + // DeviceQuantizationInput / DeviceQuantizedTensor 在“当前 CUDA device”上分配; + // 必须查询同一个 device,才能得到与这些 buffer、kernel occupancy 对应的 SM + // 数和资源上限。device_id 只是 CUDA runtime 中当前选中 GPU 的编号。 + int device_id = 0; + check_cuda(cudaGetDevice(&device_id), "查询当前 device"); + + // multiProcessorCount 就是当前 GPU 的 SM 数。不要在代码里写死 RTX 4060 的 + // SM 数;同一套 launcher 在其他 GPU 上应根据实际硬件自动调整网格。 + cudaDeviceProp device_properties{}; + check_cuda(cudaGetDeviceProperties(&device_properties, device_id), + "查询 device 属性"); + if (device_properties.multiProcessorCount <= 0) { + throw CudaPipelineError{ + "mxfp8 CUDA block-scale launcher 未发现可用 SM。"}; + } + + // `SM * 4` 是调度启发式,而非硬编码保证。实际 active CTA/SM 受每线程 + // register 数、每 CTA shared memory、CTA 线程数和架构上限影响。因此先询问 + // occupancy API:本 kernel、256 线程、0 字节动态 shared memory 时,一个 SM + // 最多能同时驻留多少个 CTA。 + int max_active_ctas_per_sm = 0; + check_cuda(cudaOccupancyMaxActiveBlocksPerMultiprocessor( + &max_active_ctas_per_sm, + mxfp8BlockQuantizeKernel, + kThreadsPerCta, + 0U), + "查询 kernel occupancy"); + if (max_active_ctas_per_sm <= 0) { + throw CudaPipelineError{ + "mxfp8 CUDA block-scale kernel 无法在当前 device 上驻留。"}; + } + + // 目标是每个 SM 最多保留 4 个 CTA;若 occupancy 表明最多只能放 2 个,则 + // 必须降为 2,不能因为持久化调度而超出硬件资源限制。 + const int ctas_per_sm = + max_active_ctas_per_sm < kTargetCtasPerSm + ? max_active_ctas_per_sm + : kTargetCtasPerSm; + + // resident_cta_count 是期望同时驻留的 CTA 总数,即: + // + // SM 数 * 每个 SM 实际允许的目标 CTA 数。 + // + // 例如 24 个 SM、每 SM 4 CTA 时为 96。它是持久化网格的“硬件侧上限”, + // 而不是矩阵逻辑工作量。 + const std::uint64_t resident_cta_count = + static_cast(device_properties.multiProcessorCount) * + static_cast(ctas_per_sm); + + // 一个 CTA 固定有 kWarpsPerCta == 8 个 warp,且每个 warp 的首个工作项是 + // 一个逻辑量化 block。因此首轮覆盖全部 total_blocks 至少需要: + // + // logical_cta_count = ceil(total_blocks / 8) + // + // 个 CTA。使用除法和余数写 ceil,避免 total_blocks + 7 的潜在 uint64 溢出。 + const std::uint64_t logical_cta_count = + total_blocks / static_cast(kWarpsPerCta) + + (total_blocks % static_cast(kWarpsPerCta) == 0U ? 0U : 1U); + + // 小矩阵不应该为了凑满 SM * 4 启动大量空 CTA;大矩阵则只启动可常驻的 + // resident_cta_count 个 CTA,剩余工作由 kernel 内的 grid-stride 循环领取。 + const std::uint64_t launch_cta_count = + logical_cta_count < resident_cta_count + ? logical_cta_count + : resident_cta_count; + + // CUDA 的 dim3 名称很容易和“量化 block”混淆: + // - grid.x:本次发射多少个 CUDA CTA; + // - block.x:每个 CUDA CTA 的线程数,即 8 warp * 32 = 256; + // - 一个 warp 才对应一个 MXFP8 的 32 元素逻辑量化 block。 + const dim3 grid{static_cast(launch_cta_count), 1U, 1U}; + const dim3 block{static_cast(kThreadsPerCta), 1U, 1U}; + + // thrust::device_vector 是 host 侧的 RAII 所有者,kernel 不能按值接收它。 + // raw_pointer_cast 只取出 non-owning 的 raw device pointer,不触发 H2D/D2H; + // H2D 已在 pipeline 构造 DeviceQuantizationInput 时完成。 + const auto* const input_ptr = thrust::raw_pointer_cast(input.values.data()); + auto* const payload_ptr = thrust::raw_pointer_cast(output.payload.data()); + auto* const local_scales_ptr = + thrust::raw_pointer_cast(output.local_scales.data()); + + // 将小型 metadata 与三个 device pointer 写入 kernel 参数区后异步发射。 + // kernel 内第一个任务编号为: + // + // quant_block = blockIdx.x * 8 + warp_id + // + // 每处理完一个量化 block,warp 再加 gridDim.x * 8,从而持续覆盖剩余工作。 + mxfp8BlockQuantizeKernel<<>>( + input_ptr, + payload_ptr, + local_scales_ptr, + input.desc.num_cols, + *blocks_per_row, + total_blocks, + config.rounding, + config.stochastic_seed); + + // cudaGetLastError 只检查“发射”本身,例如 grid/block 维度和参数是否有效; + // 它不等待 kernel 完成。真正的异步执行错误将在后续 D2H 或显式同步时出现。 + check_cuda(cudaGetLastError(), "发射 persistent quantize kernel"); +} + +std::size_t mxfp8_tensor_partial_count() { + // V7 reduction 采用 SM * 4 的固定 grid;它使每个 CTA 通过 grid-stride + // 覆盖更多输入,且 partial 数量足够小,第二阶段一个 CTA 即可完成最终规约。 + int device_id = 0; + check_cuda(cudaGetDevice(&device_id), "tensor-scale 查询当前 device"); + + cudaDeviceProp device_properties{}; + check_cuda(cudaGetDeviceProperties(&device_properties, device_id), + "tensor-scale 查询 device 属性"); + if (device_properties.multiProcessorCount <= 0 || + device_properties.maxGridSize[0] <= 0) { + throw CudaPipelineError{ + "mxfp8 CUDA tensor-scale 未发现有效的 SM 或 grid.x 上限。"}; + } + + const std::uint64_t desired_count = + static_cast(device_properties.multiProcessorCount) * + static_cast(kTargetCtasPerSm); + const std::uint64_t max_grid_x = + static_cast(device_properties.maxGridSize[0]); + const std::uint64_t partial_count = + desired_count < max_grid_x ? desired_count : max_grid_x; + if (partial_count == 0U || + partial_count > + static_cast(std::numeric_limits::max())) { + throw CudaPipelineError{ + "mxfp8 CUDA tensor-scale 无法构造可表示的 partial 规约数量。"}; + } + + return static_cast(partial_count); +} + +void launch_mxfp8_tensor_quantize( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + pipeline::DeviceQuantizedTensor& output, + pipeline::DeviceTensorQuantizationWorkspace& workspace, + const cudaStream_t stream) { + // 与 block-scale launcher 相同,格式专用边界必须独立验证。tensor mode 的 + // local_scales 长度应恰好为 1;workspace 的两条数组必须等长并且与第一阶段 + // persistent grid.x 一一对应。 + if (!input.isConsistent() || !output.isConsistent() || + !workspace.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA tensor-scale launcher 收到了不自洽的 device buffer。"}; + } + + if (!config.isValid() || config.format != QuantFormat::kMxfp8 || + config.scale_mode != ScaleMode::kTensor || + config.block_size != kMxfp8BlockSize || + output.desc.format != QuantFormat::kMxfp8 || + output.desc.scale_mode != ScaleMode::kTensor || + output.desc.block_size != kMxfp8BlockSize || + output.desc.rounding != config.rounding || + output.desc.stochastic_seed != config.stochastic_seed || + output.desc.source_desc.num_rows != input.desc.num_rows || + output.desc.source_desc.num_cols != input.desc.num_cols || + output.desc.source_desc.dtype != input.desc.dtype || + output.local_scales.size() != 1U) { + throw CudaPipelineError{ + "mxfp8 CUDA tensor-scale launcher 收到了不匹配的输入、输出或配置。"}; + } + + const std::size_t expected_partial_count = mxfp8_tensor_partial_count(); + if (workspace.partial_amax.size() != expected_partial_count) { + throw CudaPipelineError{ + "mxfp8 CUDA tensor-scale launcher 的 partial 工作区长度与执行网格不一致。"}; + } + + const auto element_count = input.desc.elementCount(); + if (!element_count.has_value() || *element_count == 0U) { + throw CudaPipelineError{ + "mxfp8 CUDA tensor-scale launcher 无法推导非空输入元素数量。"}; + } + + // mxfp8_tensor_partial_count 已用 maxGridSize[0] 限制该数量;当前 CUDA + // device 上的 unsigned dim3.x 可以安全承载它。第一阶段与编码阶段复用这 + // 个 CTA 数,使两次大规模 grid-stride 遍历均保持 SM * 4 的持久化调度。 + const dim3 persistent_grid{ + static_cast(expected_partial_count), 1U, 1U}; + const dim3 reduction_block{ + static_cast(kThreadsPerCta), 1U, 1U}; + + const auto* const input_ptr = thrust::raw_pointer_cast(input.values.data()); + auto* const payload_ptr = thrust::raw_pointer_cast(output.payload.data()); + auto* const local_scales_ptr = + thrust::raw_pointer_cast(output.local_scales.data()); + auto* const partial_amax_ptr = + thrust::raw_pointer_cast(workspace.partial_amax.data()); + auto* const partial_nonfinite_ptr = + thrust::raw_pointer_cast(workspace.partial_nonfinite.data()); + + // 第一阶段以 V7 的 float4 + grid-stride 输入遍历写出每 CTA partial。该 + // kernel 不可能直接得出全局 scale,因为不同 CTA 间没有同步原语。 + mxfp8TensorAmaxPartialKernel<<>>( + input_ptr, + partial_amax_ptr, + partial_nonfinite_ptr, + *element_count); + check_cuda(cudaGetLastError(), "tensor-scale 发射 partial amax kernel"); + + // 第二阶段只发射一个 CTA。每个线程可循环读取多个 partial,最后 thread 0 + // 直接写 local_scales[0];后续 encode kernel 因为是另一次同 stream 发射, + // 无需任何跨 CTA 或 host 同步即可安全读取该 scale。 + mxfp8TensorScaleFinalizeKernel<<<1U, reduction_block, 0U, stream>>>( + partial_amax_ptr, + partial_nonfinite_ptr, + local_scales_ptr, + static_cast(expected_partial_count)); + check_cuda(cudaGetLastError(), "tensor-scale 发射 scale finalize kernel"); + + mxfp8TensorEncodeKernel<<>>( + input_ptr, + payload_ptr, + local_scales_ptr, + *element_count, + config.rounding, + config.stochastic_seed); + check_cuda(cudaGetLastError(), "tensor-scale 发射 encode kernel"); +} + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cuh" new file mode 100644 index 00000000..18a283e1 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/mxfp8_quantize.cuh" @@ -0,0 +1,75 @@ +#pragma once + +#include + +#include + +#include "quant_dequant/types.hpp" + +namespace quant_dequant::pipeline { +struct DeviceQuantizationInput; +struct DeviceQuantizedTensor; +struct DeviceTensorQuantizationWorkspace; +} // namespace quant_dequant::pipeline + +namespace quant_dequant::cuda { + +/** + * @brief 启动 MXFP8 block-scale 量化的 CUDA 后端。 + * + * 一个 warp 处理一个 rowwise 32 元素逻辑量化 block:先在 warp 内规约 `amax`, + * 由 lane 0 写 E8M0 local scale,再使用该 scale 编码各 lane 对应的 E4M3 + * payload。固定数量 CTA 以 grid-stride 持续处理后续量化 block。它与 + * tensor-scale 路径不能共用同一个 launcher,因为 block-scale 不需要跨 CTA 的 + * 全局归约。 + * + * @param input 已完成 H2D 的 row-major FP32 device 输入,只读。 + * @param config 已验证的 MXFP8 block-scale 量化配置。 + * @param output 已按 MXFP8 描述分配的 device 输出;kernel 写入 payload 和 + * local_scales。 + * @param stream 与本次 H2D、CUDA Event 和 D2H 共用的非拥有 stream。 + * @throws CudaPipelineError CUDA runtime 调用、kernel launch 或后续数值检查失败时抛出。 + */ +void launch_mxfp8_block_quantize( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + pipeline::DeviceQuantizedTensor& output, + cudaStream_t stream); + +/** + * @brief 启动 MXFP8 tensor-scale 量化的 CUDA 后端。 + * + * 该路径以两个规约 kernel 求整张张量的全局 `amax`:第一阶段按固定数量 CTA + * 写 partial 结果,第二阶段由一个 CTA 规约所有 partial 并直接写唯一的 + * `output.local_scales[0]` E8M0 scale;随后启动独立编码 kernel。普通 CUDA + * kernel 不支持跨 CTA 同步,因此不能复用 block-scale 的单 kernel 流程。 + * + * @param input 已完成 H2D 的 row-major FP32 device 输入,只读。 + * @param config 已验证的 MXFP8 tensor-scale 量化配置。 + * @param output 已按 MXFP8 描述分配的 device 输出;kernel 写入 payload 和唯一的 + * local scale。 + * @param workspace 生命周期覆盖所有规约与编码 kernel 的临时 partial amax / + * 非有限标记 device 数组。 + * @param stream 与本次 H2D、CUDA Event 和 D2H 共用的非拥有 stream。 + * @throws CudaPipelineError CUDA runtime 调用、kernel launch 或后续数值检查失败时抛出。 + */ +void launch_mxfp8_tensor_quantize( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + pipeline::DeviceQuantizedTensor& output, + pipeline::DeviceTensorQuantizationWorkspace& workspace, + cudaStream_t stream); + +/** + * @brief 返回当前 CUDA device 上 tensor-scale 第一阶段规约应使用的 CTA 数量。 + * + * 数量使用与参考 V7 reduction 相同的 `SM 数 * 4` 持久化网格启发式,并受 + * `maxGridSize[0]` 约束。pipeline 据此分配 `DeviceTensorQuantizationWorkspace`, + * launcher 再验证 workspace 长度与该数量一致。 + * + * @return 大于 0 的第一阶段 CTA 数量。 + * @throws CudaPipelineError 查询当前 device 或其 grid 属性失败时抛出。 + */ +[[nodiscard]] std::size_t mxfp8_tensor_partial_count(); + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cu" new file mode 100644 index 00000000..eeb30eac --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cu" @@ -0,0 +1,211 @@ +#include "cuda/nvfp4_dequantize.cuh" + +#include + +#include +#include + +#include + +#include "formats/nvfp4_codec.cuh" +#include "pipeline/device_quantized_tensor.cuh" +#include "quant_dequant/quantize.hpp" + +namespace quant_dequant::cuda { +namespace { + +/** 单个 NVFP4 反量化 CTA 使用的线程数量。 */ +inline constexpr int kThreadsPerCta = 256; + +/** 每个 SM 的目标常驻 CTA 数量,用于限制持久化一维网格规模。 */ +inline constexpr int kTargetCtasPerSm = 4; + +static_assert(kThreadsPerCta > 0); + +/** + * @brief 将 CUDA runtime 返回值转换为带阶段说明的 pipeline 异常。 + * + * @param status CUDA runtime API 的返回状态。 + * @param operation 失败的 CUDA 操作名称。 + * @throws CudaPipelineError status 不是 cudaSuccess 时抛出。 + */ +void check_cuda(const cudaError_t status, const char* const operation) { + if (status != cudaSuccess) { + throw CudaPipelineError{ + "nvfp4 CUDA dequantize " + std::string{operation} + + " 失败:" + cudaGetErrorString(status)}; + } +} + +/** + * @brief 验证 NVFP4 dequantize launcher 的输入量化张量与输出 FP32 buffer。 + * + * @param input 包含 packed E2M1 payload、E4M3 local scale 与 FP32 global scale + * 的只读 device 张量。 + * @param output 待写入的 device FP32 输出张量。 + * @throws CudaPipelineError 格式、形状、block 规则或 device buffer 不匹配时抛出。 + */ +void validate_nvfp4_dequantize_launch( + const pipeline::DeviceQuantizedTensor& input, + const pipeline::DeviceDequantizationOutput& output) { + if (!input.isConsistent() || !output.isConsistent()) { + throw CudaPipelineError{ + "nvfp4 CUDA dequantize launcher 收到了不自洽的 device buffer。"}; + } + + if (input.desc.format != QuantFormat::kNvfp4 || + input.desc.block_size != kNvfp4BlockSize || + input.desc.scale_mode != ScaleMode::kBlock || + input.desc.scale_layout != ScaleLayout::kRowwise || + !input.global_scale.has_value() || input.global_scale->size() != 1U || + output.desc.num_rows != input.desc.source_desc.num_rows || + output.desc.num_cols != input.desc.source_desc.num_cols || + !is_supported_output_dtype(output.desc.dtype)) { + throw CudaPipelineError{ + "nvfp4 CUDA dequantize launcher 收到了不匹配的输入描述或输出描述。"}; + } +} + +/** + * @brief 以一维 grid-stride 映射解码完整的 packed NVFP4 row-major payload。 + * + * 每个线程处理一个或多个物理 payload byte。一个 byte 的低四位对应线性下标 + * `2 * payload_index`,高四位对应其后一项。两个元素分别恢复 `(row, column)`, + * 并使用 `row * blocks_per_row + column / 16` 索引自己的 E4M3 local scale。 + * 因而奇数列矩阵的跨行 byte 不会误共享 local scale;最后一个奇数长度张量的 + * padding 高 nibble 也绝不会解码或写入输出。 + * + * @param payload device 指针,指向 packed E2M1 byte payload,只读。 + * @param local_scales device 指针,指向 rowwise E4M3 scale code,只读。 + * @param global_scale device 指针,指向单个 FP32 global scale,只读。 + * @param output device 指针,指向连续 row-major FP32 结果,写入。 + * @param element_count 矩阵逻辑元素总数。 + * @param payload_byte_count 待读取的物理 packed payload byte 数。 + * @param num_cols 矩阵列数,用于从线性下标恢复 row/column。 + * @param blocks_per_row 每行的 16 元素 local-scale 数量。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void nvfp4DequantizeKernel( + const std::uint8_t* __restrict__ payload, + const std::uint8_t* __restrict__ local_scales, + const float* __restrict__ global_scale, + float* __restrict__ output, + const std::uint64_t element_count, + const std::uint64_t payload_byte_count, + const std::uint64_t num_cols, + const std::uint64_t blocks_per_row) { + const std::uint64_t first_payload_index = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const std::uint64_t payload_stride = + static_cast(gridDim.x) * blockDim.x; + const float decode_global_scale = global_scale[0U]; + + // payload、output 都按连续 row-major 次序访问。连续 warp 中每个线程读一个 + // 相邻 byte,且写相邻的两个 FP32 元素区间,保留自然的合并访存模式。 + for (std::uint64_t payload_index = first_payload_index; + payload_index < payload_byte_count; + payload_index += payload_stride) { + const std::uint64_t even_index = payload_index * 2U; + const std::uint64_t even_row = even_index / num_cols; + const std::uint64_t even_column = even_index % num_cols; + const std::uint64_t even_scale_index = + even_row * blocks_per_row + + even_column / static_cast(kNvfp4BlockSize); + const std::uint8_t packed_byte = payload[payload_index]; + + output[even_index] = formats::decode_nvfp4_element( + formats::unpack_e2m1_low_nibble(packed_byte), + local_scales[even_scale_index], + decode_global_scale); + + const std::uint64_t odd_index = even_index + 1U; + if (odd_index < element_count) { + const std::uint64_t odd_row = odd_index / num_cols; + const std::uint64_t odd_column = odd_index % num_cols; + const std::uint64_t odd_scale_index = + odd_row * blocks_per_row + + odd_column / static_cast(kNvfp4BlockSize); + output[odd_index] = formats::decode_nvfp4_element( + formats::unpack_e2m1_high_nibble(packed_byte), + local_scales[odd_scale_index], + decode_global_scale); + } + } +} + +} // namespace + +void launch_nvfp4_dequantize( + const pipeline::DeviceQuantizedTensor& input, + pipeline::DeviceDequantizationOutput& output, + const cudaStream_t stream) { + validate_nvfp4_dequantize_launch(input, output); + + const auto element_count = input.desc.elementCount(); + const auto blocks_per_row = input.desc.blocksPerRow(); + if (!element_count.has_value() || *element_count == 0U || + !blocks_per_row.has_value() || *blocks_per_row == 0U || + input.payload.empty()) { + throw CudaPipelineError{ + "nvfp4 CUDA dequantize launcher 无法推导非空张量或 rowwise scale 布局。"}; + } + + int device_id = 0; + check_cuda(cudaGetDevice(&device_id), "查询当前 device"); + + cudaDeviceProp device_properties{}; + check_cuda(cudaGetDeviceProperties(&device_properties, device_id), + "查询 device 属性"); + if (device_properties.multiProcessorCount <= 0 || + device_properties.maxGridSize[0] <= 0) { + throw CudaPipelineError{ + "nvfp4 CUDA dequantize launcher 未发现有效的 SM 或 grid.x 上限。"}; + } + + const std::uint64_t payload_byte_count = + static_cast(input.payload.size()); + const std::uint64_t logical_cta_count = + payload_byte_count / static_cast(kThreadsPerCta) + + (payload_byte_count % static_cast(kThreadsPerCta) == 0U + ? 0U + : 1U); + const std::uint64_t resident_cta_count = + static_cast(device_properties.multiProcessorCount) * + static_cast(kTargetCtasPerSm); + const std::uint64_t desired_cta_count = + logical_cta_count < resident_cta_count + ? logical_cta_count + : resident_cta_count; + const std::uint64_t max_grid_x = + static_cast(device_properties.maxGridSize[0]); + const std::uint64_t launch_cta_count = + desired_cta_count < max_grid_x ? desired_cta_count : max_grid_x; + if (launch_cta_count == 0U) { + throw CudaPipelineError{ + "nvfp4 CUDA dequantize launcher 无法构造非空执行网格。"}; + } + + const dim3 grid{static_cast(launch_cta_count), 1U, 1U}; + const dim3 block{static_cast(kThreadsPerCta), 1U, 1U}; + const auto* const payload_ptr = thrust::raw_pointer_cast(input.payload.data()); + const auto* const local_scales_ptr = + thrust::raw_pointer_cast(input.local_scales.data()); + const auto* const global_scale_ptr = + thrust::raw_pointer_cast(input.global_scale->data()); + auto* const output_ptr = thrust::raw_pointer_cast(output.values.data()); + + nvfp4DequantizeKernel<<>>( + payload_ptr, + local_scales_ptr, + global_scale_ptr, + output_ptr, + *element_count, + payload_byte_count, + input.desc.source_desc.num_cols, + *blocks_per_row); + + // 仅检查参数/发射错误。kernel 的异步执行期错误会在 pipeline 随后的 Thrust + // D2H 中报告;这里不主动同步,避免破坏上层的异步传输机会。 + check_cuda(cudaGetLastError(), "发射 dequantize kernel"); +} + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cuh" new file mode 100644 index 00000000..c9e21844 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_dequantize.cuh" @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace quant_dequant::pipeline { +struct DeviceDequantizationOutput; +struct DeviceQuantizedTensor; +} // namespace quant_dequant::pipeline + +namespace quant_dequant::cuda { + +/** + * @brief 启动 NVFP4 E2M1/E4M3/FP32 的 CUDA 反量化后端。 + * + * 一个 CUDA 线程独占读取一个 packed payload byte,解包其中低/高两个 E2M1 + * nibble,并分别按两个元素的 row-major 坐标读取 E4M3 local scale。该映射避免 + * 相邻线程竞争同一个 byte,且能正确处理奇数列矩阵中跨行的 packed byte。 + * + * @param input 已完成 H2D 的 NVFP4 packed payload、E4M3 local scale 与单元素 + * FP32 global scale,只读。 + * @param output 已分配的 row-major device FP32 输出,kernel 写入每个逻辑元素。 + * @param stream 与本次 H2D、CUDA Event 和 D2H 共用的非拥有 stream。 + * @throws CudaPipelineError 描述、device buffer 或 CUDA runtime/kernel launch + * 不合法时抛出。 + */ +void launch_nvfp4_dequantize( + const pipeline::DeviceQuantizedTensor& input, + pipeline::DeviceDequantizationOutput& output, + cudaStream_t stream); + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cu" new file mode 100644 index 00000000..f9bd865a --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cu" @@ -0,0 +1,584 @@ +#include "cuda/nvfp4_quantize.cuh" + +#include + +#include + +#include +#include +#include +#include + +#include + +#include "formats/nvfp4_codec.cuh" +#include "quant_dequant/quantize.hpp" +#include "pipeline/device_quantized_tensor.cuh" + +namespace cg = cooperative_groups; + +namespace quant_dequant::cuda { +namespace { + +/** 单个 CUDA warp 的固定 lane 数量。 */ +inline constexpr int kWarpSize = 32; + +/** 一个 CTA 的固定 warp 数量。 */ +inline constexpr int kWarpsPerCta = 8; + +/** local-scale kernel 中每个 CTA 的 16 元素 Cooperative Group tile 数量。 */ +inline constexpr int kNvfp4TilesPerCta = kWarpsPerCta * 2; + +/** 所有 NVFP4 quantize kernel 的固定 CTA 线程数。 */ +inline constexpr int kThreadsPerCta = kWarpSize * kWarpsPerCta; + +/** persistent grid 希望每个 SM 同时承载的 CTA 数量上限。 */ +inline constexpr int kTargetCtasPerSm = 4; + +static_assert(kNvfp4BlockSize == static_cast(kWarpSize / 2)); +static_assert(kThreadsPerCta % kWarpSize == 0); + +/** + * @brief 将 CUDA runtime 状态转换为格式专用的公共 pipeline 异常。 + * + * @param status CUDA runtime API 的返回值。 + * @param operation 失败操作的中文阶段说明。 + * @throws CudaPipelineError status 不是 cudaSuccess 时抛出。 + */ +void check_cuda(const cudaError_t status, const char* const operation) { + if (status != cudaSuccess) { + throw CudaPipelineError{ + "nvfp4 CUDA " + std::string{operation} + + " 失败:" + cudaGetErrorString(status)}; + } +} + +/** + * @brief 保存一个规约线程看见的有限 amax 与非有限值标记。 + * + * @note NVFP4 的 global_scale 必须来自整张张量的有限 amax;若只做 float max, + * NaN 可能被静默吞掉,因此必须和存在性标记一起规约。 + */ +struct TensorAmaxState { + /** 当前局部范围内有限元素的最大绝对值。 */ + float amax{0.0F}; + + /** 当前局部范围是否出现 NaN、+Inf 或 -Inf。 */ + std::uint32_t has_nonfinite{0U}; +}; + +/** + * @brief 将一个输入 FP32 累计到全局 amax 规约状态。 + * + * @param state 当前线程私有规约状态,原地更新。 + * @param value 刚从 global memory 加载的一个 FP32 输入。 + */ +__device__ __forceinline__ void accumulate_tensor_amax( + TensorAmaxState& state, + const float value) { + if (!formats::fp32::is_finite(value)) { + state.has_nonfinite = 1U; + return; + } + + const float magnitude = formats::fp32::absolute_value(value); + state.amax = state.amax > magnitude ? state.amax : magnitude; +} + +/** + * @brief 在一个完整 warp 内规约 NVFP4 全局 amax 状态。 + * + * @param state 当前 lane 的局部状态。 + * @return lane 0 得到该 warp 的完整状态;其余 lane 的返回值是中间结果。 + */ +__device__ __forceinline__ TensorAmaxState warp_reduce_tensor_amax( + TensorAmaxState state) { + constexpr unsigned int kAllWarpLanes = 0xffffffffU; + for (int offset = kWarpSize / 2; offset > 0; offset /= 2) { + const float neighbor_amax = + __shfl_down_sync(kAllWarpLanes, state.amax, offset); + const std::uint32_t neighbor_nonfinite = + __shfl_down_sync(kAllWarpLanes, state.has_nonfinite, offset); + state.amax = state.amax > neighbor_amax ? state.amax : neighbor_amax; + state.has_nonfinite |= neighbor_nonfinite; + } + + return state; +} + +/** + * @brief 以 warp shuffle 与 shared memory 规约一个固定 256-thread CTA。 + * + * @param state 当前线程的局部规约状态。 + * @param shared_amax 长度为 8 的 shared 数组,用于每 warp 一项 amax。 + * @param shared_nonfinite 长度为 8 的 shared 数组,用于每 warp 一项标记。 + * @return thread 0 得到完整 CTA 结果;其他线程的结果无需使用。 + */ +__device__ __forceinline__ TensorAmaxState block_reduce_tensor_amax( + TensorAmaxState state, + float* const shared_amax, + std::uint32_t* const shared_nonfinite) { + const std::uint32_t lane_id = threadIdx.x % kWarpSize; + const std::uint32_t warp_id = threadIdx.x / kWarpSize; + + state = warp_reduce_tensor_amax(state); + if (lane_id == 0U) { + shared_amax[warp_id] = state.amax; + shared_nonfinite[warp_id] = state.has_nonfinite; + } + + // warp 0 读取每 warp partial 前,必须等待全部 lane 0 写入 shared memory。 + __syncthreads(); + + if (warp_id == 0U) { + const std::uint32_t warp_count = + static_cast(blockDim.x / kWarpSize); + state = lane_id < warp_count + ? TensorAmaxState{ + shared_amax[lane_id], + shared_nonfinite[lane_id], + } + : TensorAmaxState{}; + state = warp_reduce_tensor_amax(state); + } + + return state; +} + +/** + * @brief 第一阶段:以 float4 grid-stride 遍历输入,写每 CTA 的全局 amax partial。 + * + * @param input 连续 row-major FP32 输入的 device 指针,只读。 + * @param partial_amax 长度等于 grid.x 的 device 输出,每 CTA 写一项。 + * @param partial_nonfinite 长度等于 grid.x 的 device 输出,每 CTA 写一项。 + * @param element_count 输入的逻辑元素数量。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void nvfp4TensorAmaxPartialKernel( + const float* __restrict__ input, + float* __restrict__ partial_amax, + std::uint32_t* __restrict__ partial_nonfinite, + const std::uint64_t element_count) { + __shared__ float shared_amax[kWarpsPerCta]; + __shared__ std::uint32_t shared_nonfinite[kWarpsPerCta]; + + TensorAmaxState state{}; + const auto* const input4 = reinterpret_cast(input); + const std::uint64_t vector_count = element_count / 4U; + const std::uint64_t first_vector_index = + static_cast(blockIdx.x) * blockDim.x + threadIdx.x; + const std::uint64_t vector_stride = + static_cast(gridDim.x) * blockDim.x; + + for (std::uint64_t vector_index = first_vector_index; + vector_index < vector_count; + vector_index += vector_stride) { + const float4 values = input4[vector_index]; + accumulate_tensor_amax(state, values.x); + accumulate_tensor_amax(state, values.y); + accumulate_tensor_amax(state, values.z); + accumulate_tensor_amax(state, values.w); + } + + const std::uint64_t tail_start = vector_count * 4U; + for (std::uint64_t linear_index = tail_start + first_vector_index; + linear_index < element_count; + linear_index += vector_stride) { + accumulate_tensor_amax(state, input[linear_index]); + } + + state = block_reduce_tensor_amax(state, shared_amax, shared_nonfinite); + if (threadIdx.x == 0U) { + partial_amax[blockIdx.x] = state.amax; + partial_nonfinite[blockIdx.x] = state.has_nonfinite; + } +} + +/** + * @brief 第二阶段:规约全部 partial 并直接写 NVFP4 的唯一 FP32 global scale。 + * + * @param partial_amax 第一阶段生成的 amax 数组,只读。 + * @param partial_nonfinite 第一阶段生成的非有限标记数组,只读。 + * @param global_scale 长度为 1 的 device 输出;thread 0 写 decode global scale。 + * @param partial_count 第一阶段 CTA 数量。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void nvfp4GlobalScaleFinalizeKernel( + const float* __restrict__ partial_amax, + const std::uint32_t* __restrict__ partial_nonfinite, + float* __restrict__ global_scale, + const std::uint64_t partial_count) { + __shared__ float shared_amax[kWarpsPerCta]; + __shared__ std::uint32_t shared_nonfinite[kWarpsPerCta]; + + TensorAmaxState state{}; + for (std::uint64_t partial_index = threadIdx.x; + partial_index < partial_count; + partial_index += blockDim.x) { + const float partial_value = partial_amax[partial_index]; + state.amax = state.amax > partial_value ? state.amax : partial_value; + state.has_nonfinite |= partial_nonfinite[partial_index]; + } + + state = block_reduce_tensor_amax(state, shared_amax, shared_nonfinite); + if (threadIdx.x == 0U) { + global_scale[0] = state.has_nonfinite != 0U + ? formats::fp32::canonical_quiet_nan() + : formats::compute_nvfp4_global_scale(state.amax); + } +} + +/** + * @brief 使用 16-thread Cooperative Group tile 计算每个 rowwise block 的 E4M3 scale。 + * + * 一个 256-thread CTA 含 16 个 `thread_block_tile<16>`,即每个物理 warp 同时 + * 覆盖两个 NVFP4 quantization block。每个 tile 的所有 lane 都参加 shuffle + * 规约;尾 block 的无效 lane 贡献零且不访问越界输入。 + * + * @param input 连续 row-major FP32 输入的 device 指针,只读。 + * @param local_scales 每个逻辑 16 元素 block 一项的 E4M3 device 输出。 + * @param global_scale 长度为 1 的 FP32 decode global scale,只读。 + * @param num_cols 输入矩阵列数。 + * @param blocks_per_row 每行的逻辑 NVFP4 block 数量。 + * @param total_blocks 整张矩阵的逻辑 block 总数。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void nvfp4LocalScaleKernel( + const float* __restrict__ input, + std::uint8_t* __restrict__ local_scales, + const float* __restrict__ global_scale, + const std::uint64_t num_cols, + const std::uint64_t blocks_per_row, + const std::uint64_t total_blocks) { + const cg::thread_block cta = cg::this_thread_block(); + const cg::thread_block_tile<16> tile = cg::tiled_partition<16>(cta); + const std::uint32_t lane_in_tile = tile.thread_rank(); + const std::uint64_t first_quant_block = + static_cast(blockIdx.x) * kNvfp4TilesPerCta + + tile.meta_group_rank(); + const std::uint64_t quant_block_stride = + static_cast(gridDim.x) * kNvfp4TilesPerCta; + const float tensor_global_scale = global_scale[0]; + + for (std::uint64_t quant_block = first_quant_block; + quant_block < total_blocks; + quant_block += quant_block_stride) { + const std::uint64_t row = quant_block / blocks_per_row; + const std::uint64_t block_in_row = quant_block % blocks_per_row; + const std::uint64_t first_column = + block_in_row * static_cast(kNvfp4BlockSize); + const std::uint64_t valid_count = num_cols - first_column; + const bool is_valid_lane = lane_in_tile < valid_count; + const std::uint64_t linear_index = + row * num_cols + first_column + lane_in_tile; + const float value = is_valid_lane ? input[linear_index] : 0.0F; + + std::uint32_t has_nonfinite = + is_valid_lane && !formats::fp32::is_finite(value) ? 1U : 0U; + float block_amax = is_valid_lane + ? formats::fp32::absolute_value(value) + : 0.0F; + + // tile 是编译期固定的 16 lane 子组;无需手写 low/high half-warp mask。 + for (int offset = 8; offset > 0; offset /= 2) { + const float neighbor_amax = tile.shfl_down(block_amax, offset); + const std::uint32_t neighbor_nonfinite = + tile.shfl_down(has_nonfinite, offset); + block_amax = block_amax > neighbor_amax ? block_amax : neighbor_amax; + has_nonfinite |= neighbor_nonfinite; + } + + if (lane_in_tile == 0U) { + local_scales[quant_block] = has_nonfinite != 0U + ? formats::kE4M3CanonicalNaNCode + : formats::compute_nvfp4_local_scale_code( + block_amax, tensor_global_scale); + } + } +} + +/** + * @brief 以一个完整 warp 处理连续 32 个元素,并由偶数 lane 独占 packed byte store。 + * + * 每个 lane 先以自己所属 rowwise 16 元素 block 的 E4M3 scale 编码一个 E2M1 + * nibble。所有 32 lane 均执行 `shfl`,偶数 lane 取相邻奇数 lane 的 code 后写 + * 一个 byte;因此没有两个线程对同一 payload byte 做 read-modify-write。连续的 + * 32 个线性元素映射还覆盖了奇数列矩阵的跨行 byte 配对。 + * + * @param input 连续 row-major FP32 输入的 device 指针,只读。 + * @param payload packed NVFP4 device 输出,每字节两个 E2M1 nibble。 + * @param local_scales rowwise E4M3 local scale 数组,只读。 + * @param global_scale 长度为 1 的 FP32 decode global scale,只读。 + * @param num_cols 输入矩阵列数。 + * @param blocks_per_row 每行的 NVFP4 logical block 数量。 + * @param element_count 逻辑输入元素总数。 + * @param rounding E2M1 元素编码使用的舍入模式。 + * @param stochastic_seed stochastic rounding 的确定性种子。 + */ +__global__ __launch_bounds__(kThreadsPerCta) void nvfp4PackedEncodeKernel( + const float* __restrict__ input, + std::uint8_t* __restrict__ payload, + const std::uint8_t* __restrict__ local_scales, + const float* __restrict__ global_scale, + const std::uint64_t num_cols, + const std::uint64_t blocks_per_row, + const std::uint64_t element_count, + const RoundingMode rounding, + const std::uint64_t stochastic_seed) { + const cg::thread_block cta = cg::this_thread_block(); + const cg::thread_block_tile<32> warp = cg::tiled_partition<32>(cta); + const std::uint32_t lane_in_warp = warp.thread_rank(); + const float tensor_global_scale = global_scale[0]; + + if (!formats::fp32::is_finite(tensor_global_scale) || + tensor_global_scale <= 0.0F) { + return; + } + + const std::uint64_t first_warp = + static_cast(blockIdx.x) * kWarpsPerCta + + warp.meta_group_rank(); + const std::uint64_t warp_stride = + static_cast(gridDim.x) * kWarpsPerCta; + + for (std::uint64_t logical_warp = first_warp;; logical_warp += warp_stride) { + const std::uint64_t first_linear_index = + logical_warp * static_cast(kWarpSize); + if (first_linear_index >= element_count) { + break; + } + + const std::uint64_t linear_index = first_linear_index + lane_in_warp; + const bool is_valid_lane = linear_index < element_count; + std::uint8_t own_code = 0x00U; + if (is_valid_lane) { + const std::uint64_t row = linear_index / num_cols; + const std::uint64_t column = linear_index % num_cols; + const std::uint64_t scale_index = + row * blocks_per_row + + column / static_cast(kNvfp4BlockSize); + const float uniform_random = rounding == RoundingMode::kStochastic + ? formats::nvfp4_stochastic_uniform_for_element( + stochastic_seed, linear_index) + : 0.0F; + own_code = formats::encode_nvfp4_element( + input[linear_index], + local_scales[scale_index], + tensor_global_scale, + rounding, + uniform_random); + } + + // shfl 是一个 tile 集体操作:包括不写 payload 的奇数 lane 在内,全部 + // 线程都必须参与。奇数 lane 取自身只是为了给它一个合法 source rank; + // 只有偶数 lane 使用从 lane+1 取得的结果并执行最终 packed store。 + const std::uint32_t partner_lane = (lane_in_warp & 1U) == 0U + ? lane_in_warp + 1U + : lane_in_warp; + const std::uint8_t partner_code = warp.shfl( + own_code, static_cast(partner_lane)); + + if ((lane_in_warp & 1U) == 0U) { + payload[linear_index / 2U] = formats::pack_e2m1_nibbles( + own_code, partner_code); + } + } +} + +/** + * @brief 验证 NVFP4 launcher 的输入、输出、工作区与配置完全匹配。 + * + * @param input 已完成 H2D 的 device 输入。 + * @param config NVFP4 量化配置。 + * @param output 待写 device 量化结果。 + * @param workspace 全局 amax 规约工作区。 + * @throws CudaPipelineError 任一结构、格式或长度约束不满足时抛出。 + */ +void validate_nvfp4_launch( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + const pipeline::DeviceQuantizedTensor& output, + const pipeline::DeviceTensorQuantizationWorkspace& workspace) { + if (!input.isConsistent() || !output.isConsistent() || !workspace.isConsistent()) { + throw CudaPipelineError{ + "nvfp4 CUDA launcher 收到了不自洽的 device buffer 或规约工作区。"}; + } + + if (!config.isValid() || config.format != QuantFormat::kNvfp4 || + config.scale_mode != ScaleMode::kBlock || + config.block_size != kNvfp4BlockSize || + output.desc.format != QuantFormat::kNvfp4 || + output.desc.scale_mode != ScaleMode::kBlock || + output.desc.block_size != kNvfp4BlockSize || + output.desc.rounding != config.rounding || + output.desc.stochastic_seed != config.stochastic_seed || + output.desc.source_desc.num_rows != input.desc.num_rows || + output.desc.source_desc.num_cols != input.desc.num_cols || + output.desc.source_desc.dtype != input.desc.dtype || + !output.global_scale.has_value() || output.global_scale->size() != 1U) { + throw CudaPipelineError{ + "nvfp4 CUDA launcher 收到了不匹配的输入、输出或 block-scale 配置。"}; + } +} + +/** + * @brief 查询 NVFP4 local-scale persistent kernel 的实际可驻留 CTA 数量。 + * + * @return 以 CTA 为单位的 `SM 数 × min(4, occupancy)` 网格上限。 + * @throws CudaPipelineError 当前 device 或 occupancy 查询失败时抛出。 + */ +[[nodiscard]] std::uint64_t resident_local_scale_cta_count() { + int device_id = 0; + check_cuda(cudaGetDevice(&device_id), "local-scale 查询当前 device"); + + cudaDeviceProp properties{}; + check_cuda(cudaGetDeviceProperties(&properties, device_id), + "local-scale 查询 device 属性"); + if (properties.multiProcessorCount <= 0) { + throw CudaPipelineError{ + "nvfp4 CUDA local-scale launcher 未发现可用 SM。"}; + } + + int max_active_ctas_per_sm = 0; + check_cuda(cudaOccupancyMaxActiveBlocksPerMultiprocessor( + &max_active_ctas_per_sm, + nvfp4LocalScaleKernel, + kThreadsPerCta, + 0U), + "查询 local-scale kernel occupancy"); + if (max_active_ctas_per_sm <= 0) { + throw CudaPipelineError{ + "nvfp4 CUDA local-scale kernel 无法在当前 device 上驻留。"}; + } + + const int ctas_per_sm = max_active_ctas_per_sm < kTargetCtasPerSm + ? max_active_ctas_per_sm + : kTargetCtasPerSm; + return static_cast(properties.multiProcessorCount) * + static_cast(ctas_per_sm); +} + +} // namespace + +std::size_t nvfp4_tensor_partial_count() { + int device_id = 0; + check_cuda(cudaGetDevice(&device_id), "global-amax 查询当前 device"); + + cudaDeviceProp properties{}; + check_cuda(cudaGetDeviceProperties(&properties, device_id), + "global-amax 查询 device 属性"); + if (properties.multiProcessorCount <= 0 || properties.maxGridSize[0] <= 0) { + throw CudaPipelineError{ + "nvfp4 CUDA global-amax 未发现有效的 SM 或 grid.x 上限。"}; + } + + const std::uint64_t desired_count = + static_cast(properties.multiProcessorCount) * + static_cast(kTargetCtasPerSm); + const std::uint64_t max_grid_x = + static_cast(properties.maxGridSize[0]); + const std::uint64_t partial_count = desired_count < max_grid_x + ? desired_count + : max_grid_x; + if (partial_count == 0U || + partial_count > + static_cast(std::numeric_limits::max())) { + throw CudaPipelineError{ + "nvfp4 CUDA global-amax 无法构造可表示的 partial 数量。"}; + } + + return static_cast(partial_count); +} + +void launch_nvfp4_block_quantize( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + pipeline::DeviceQuantizedTensor& output, + pipeline::DeviceTensorQuantizationWorkspace& workspace, + const cudaStream_t stream) { + validate_nvfp4_launch(input, config, output, workspace); + + const auto element_count = input.desc.elementCount(); + const auto blocks_per_row = output.desc.blocksPerRow(); + if (!element_count.has_value() || !blocks_per_row.has_value() || + *element_count == 0U || *blocks_per_row == 0U || + output.local_scales.empty()) { + throw CudaPipelineError{ + "nvfp4 CUDA launcher 无法推导非空输入和 rowwise local scale 布局。"}; + } + + const std::size_t partial_count = nvfp4_tensor_partial_count(); + if (workspace.partial_amax.size() != partial_count) { + throw CudaPipelineError{ + "nvfp4 CUDA launcher 的 global-amax 工作区长度与执行网格不一致。"}; + } + + const std::uint64_t total_blocks = + static_cast(output.local_scales.size()); + const std::uint64_t logical_local_scale_ctas = + total_blocks / static_cast(kNvfp4TilesPerCta) + + (total_blocks % static_cast(kNvfp4TilesPerCta) == 0U + ? 0U + : 1U); + const std::uint64_t resident_ctas = resident_local_scale_cta_count(); + const std::uint64_t local_scale_ctas = logical_local_scale_ctas < resident_ctas + ? logical_local_scale_ctas + : resident_ctas; + if (local_scale_ctas == 0U || + local_scale_ctas > std::numeric_limits::max() || + partial_count > std::numeric_limits::max()) { + throw CudaPipelineError{ + "nvfp4 CUDA launcher 无法构造可表示的 persistent grid。"}; + } + + const dim3 reduction_grid{static_cast(partial_count), 1U, 1U}; + const dim3 local_scale_grid{ + static_cast(local_scale_ctas), 1U, 1U}; + const dim3 cta_block{static_cast(kThreadsPerCta), 1U, 1U}; + + const auto* const input_ptr = thrust::raw_pointer_cast(input.values.data()); + auto* const payload_ptr = thrust::raw_pointer_cast(output.payload.data()); + auto* const local_scales_ptr = + thrust::raw_pointer_cast(output.local_scales.data()); + auto* const global_scale_ptr = + thrust::raw_pointer_cast(output.global_scale->data()); + auto* const partial_amax_ptr = + thrust::raw_pointer_cast(workspace.partial_amax.data()); + auto* const partial_nonfinite_ptr = + thrust::raw_pointer_cast(workspace.partial_nonfinite.data()); + + // 第一、二阶段由 default stream 顺序保证:finalize 读取所有 partial,随后 + // local-scale 与 packed-encode kernel 都读取已经写好的 global_scale[0]。 + nvfp4TensorAmaxPartialKernel<<>>( + input_ptr, partial_amax_ptr, partial_nonfinite_ptr, *element_count); + check_cuda(cudaGetLastError(), "发射 global-amax partial kernel"); + + nvfp4GlobalScaleFinalizeKernel<<<1U, cta_block, 0U, stream>>>( + partial_amax_ptr, + partial_nonfinite_ptr, + global_scale_ptr, + static_cast(partial_count)); + check_cuda(cudaGetLastError(), "发射 global-scale finalize kernel"); + + nvfp4LocalScaleKernel<<>>( + input_ptr, + local_scales_ptr, + global_scale_ptr, + input.desc.num_cols, + *blocks_per_row, + total_blocks); + check_cuda(cudaGetLastError(), "发射 local-scale tile kernel"); + + // packed encode 复用全局规约的 SM×4 grid。每 warp 对应连续 32 个元素, + // 相邻 lane 的两个 E2M1 nibble 最终仅由偶数 lane 写入一个 uint8_t。 + nvfp4PackedEncodeKernel<<>>( + input_ptr, + payload_ptr, + local_scales_ptr, + global_scale_ptr, + input.desc.num_cols, + *blocks_per_row, + *element_count, + config.rounding, + config.stochastic_seed); + check_cuda(cudaGetLastError(), "发射 packed E2M1 encode kernel"); +} + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cuh" new file mode 100644 index 00000000..8945707f --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/cuda/nvfp4_quantize.cuh" @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include + +#include "quant_dequant/types.hpp" + +namespace quant_dequant::pipeline { +struct DeviceQuantizationInput; +struct DeviceQuantizedTensor; +struct DeviceTensorQuantizationWorkspace; +} // namespace quant_dequant::pipeline + +namespace quant_dequant::cuda { + +/** + * @brief 返回 NVFP4 全局 amax 第一阶段规约应使用的 persistent CTA 数量。 + * + * NVFP4 即使只支持 `scale_mode=block`,仍须先对整张张量规约 amax 以构造 + * FP32 `global_scale`。该数量使用 `SM 数 × 4` 启发式并受当前 device 的 + * `maxGridSize[0]` 限制;pipeline 依此分配临时 partial 工作区。 + * + * @return 大于 0 的第一阶段 CTA 数量。 + * @throws CudaPipelineError 查询 CUDA device 或其属性失败时抛出。 + */ +[[nodiscard]] std::size_t nvfp4_tensor_partial_count(); + +/** + * @brief 启动严格 NVFP4 block-scale 量化的 CUDA 后端。 + * + * 路径依次执行:全局两阶段 amax reduction 并写 FP32 global scale、以 + * `thread_block_tile<16>` 计算每个 rowwise 16 元素 block 的 E4M3 local scale、 + * 再以 `thread_block_tile<32>` 让相邻 lane 编码两个 E2M1 code 并由偶数 lane + * 独占写一个 packed payload byte。后两阶段分离,避免相邻元素跨行时两个 + * quantization block 对同一 byte 产生写竞争。 + * + * @param input 已完成 H2D 的 row-major FP32 device 输入,只读。 + * @param config 已验证的 NVFP4 block-scale 量化配置。 + * @param output 已按 NVFP4 描述分配的 device 输出;kernel 写入 payload、 + * local_scales 和唯一的 global_scale。 + * @param workspace 生命周期覆盖全局 amax 规约、scale 与编码 kernel 的临时 + * partial amax / 非有限标记 device 数组。 + * @param stream 与本次 H2D、CUDA Event 和 D2H 共用的非拥有 stream。 + * @throws CudaPipelineError CUDA runtime 调用、launch 参数或 kernel 发射失败时抛出。 + */ +void launch_nvfp4_block_quantize( + const pipeline::DeviceQuantizationInput& input, + const QuantizationConfig& config, + pipeline::DeviceQuantizedTensor& output, + pipeline::DeviceTensorQuantizationWorkspace& workspace, + cudaStream_t stream); + +} // namespace quant_dequant::cuda diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/fp32_utils.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/fp32_utils.cuh" new file mode 100644 index 00000000..24913cf2 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/fp32_utils.cuh" @@ -0,0 +1,331 @@ +#pragma once + +#include + +#include +#include +#include + +namespace quant_dequant::formats::fp32 { + +static_assert(sizeof(float) == sizeof(std::uint32_t), + "本项目的 FP32 codec 要求 float 占 32 bit。"); +static_assert(std::numeric_limits::is_iec559, + "本项目的 FP32 codec 要求 IEEE 754 binary32 语义。"); + +/** + * @brief IEEE 754 binary32 的符号位掩码。 + * + * 实际 bit 为 `0x80000000`,即第 31 bit;置位表示负数,因而也可区分 + * `+0` 与 `-0`。 + */ +inline constexpr std::uint32_t kSignMask = 0x80000000U; + +/** + * @brief IEEE 754 binary32 的指数位掩码。 + * + * 实际 bit 为 `0x7f800000`,覆盖 bits `[30:23]`。指数全 1 表示 Inf 或 + * NaN;指数全 0 表示零或 subnormal。 + */ +inline constexpr std::uint32_t kExponentMask = 0x7f800000U; + +/** + * @brief IEEE 754 binary32 的 fraction 位掩码。 + * + * 实际 bit 为 `0x007fffff`,覆盖 bits `[22:0]`。 + */ +inline constexpr std::uint32_t kFractionMask = 0x007fffffU; + +/** + * @brief IEEE 754 binary32 正无穷的 bit pattern。 + * + * `std::numeric_limits::infinity()` 表达“正无穷”的 C++ 语义; + * 此常量则固定其 IEEE 754 binary32 表示为 `0x7f800000`。 + */ +inline constexpr std::uint32_t kPositiveInfinityBits = 0x7f800000U; + +/** + * @brief 项目使用的 canonical quiet NaN 的 bit pattern。 + * + * `std::numeric_limits::quiet_NaN()` 只保证返回 NaN,并不承诺 + * payload 的具体 bit。codec 需要 CPU/GPU 一致的确定性结果,因此固定 + * 选用 `0x7fc00000`:指数为全 1,quiet bit(fraction bit 22)为 1。 + */ +inline constexpr std::uint32_t kCanonicalQuietNaNBits = 0x7fc00000U; + +/** + * @brief FP32 的最大有限正数。 + * + * 使用 `numeric_limits` 表达数值语义;在 IEEE 754 binary32 中,它的 + * 实际 bit 是 `0x7f7fffff`,即 E=254、fraction 全 1。 + */ +inline constexpr float kMaxFinite = std::numeric_limits::max(); + +/** + * @brief FP32 的最小 normal 正数。 + * + * 使用 `numeric_limits::min()`,注意它不是最小正数,而是最小 + * normal 值 $2^{-126}$;实际 bit 是 `0x00800000`。 + */ +inline constexpr float kMinNormal = std::numeric_limits::min(); + +/** + * @brief FP32 的最小 subnormal 正数。 + * + * 使用 `numeric_limits::denorm_min()`;其数值是 $2^{-149}$, + * 实际 bit 是 `0x00000001`。 + */ +inline constexpr float kMinSubnormal = + std::numeric_limits::denorm_min(); + +/** + * @brief FP32 正无穷。 + * + * 使用标准库表达返回值语义;等价的 IEEE 754 bit pattern 为 + * `kPositiveInfinityBits`。 + */ +inline constexpr float kPositiveInfinity = + std::numeric_limits::infinity(); + +/** + * @brief 小于 1 的最大可表示 FP32。 + * + * 它是 $1-2^{-24}$,实际 bit 是 `0x3f7fffff`。stochastic rounding 用它 + * 将错误输入的 1.0 收敛至开区间右端,而不改变合法的 [0, 1) 输入。 + */ +inline constexpr float kLargestBelowOne = 0x1.fffffep-1F; + +/** + * @brief 将 FP32 按位转换为 uint32,不改变任何 bit。 + * + * device 编译时使用 CUDA intrinsic;host 编译时使用 memcpy,避免违反 + * strict aliasing 规则。两条路径都保留 NaN payload 与 -0 的符号位。 + * + * @param value 待转换的 FP32 值。 + * @return 与 value 具有相同 IEEE 754 binary32 bit pattern 的整数。 + */ +[[nodiscard]] __host__ __device__ inline std::uint32_t float_to_bits( + const float value) noexcept { +#if defined(__CUDA_ARCH__) + return __float_as_uint(value); +#else + std::uint32_t bits = 0U; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; +#endif +} + +/** + * @brief 将 uint32 按位转换为 FP32,不改变任何 bit。 + * + * @param bits IEEE 754 binary32 原始 bit pattern。 + * @return 与 bits 具有相同 bit pattern 的 FP32 值。 + */ +[[nodiscard]] __host__ __device__ inline float bits_to_float( + const std::uint32_t bits) noexcept { +#if defined(__CUDA_ARCH__) + return __uint_as_float(bits); +#else + float value = 0.0F; + std::memcpy(&value, &bits, sizeof(value)); + return value; +#endif +} + +/** + * @brief 返回 FP32 是否为有限值。 + * + * 这里不调用 `std::isfinite`,因为 codec 已经需要读取 exponent field, + * 直接判断能在 host/device 上给出完全相同的位级语义。 + * + * @param value 待判断的 FP32 值。 + * @return value 不是 NaN 且不是正负 Inf 时返回 true。 + */ +[[nodiscard]] __host__ __device__ inline bool is_finite( + const float value) noexcept { + return (float_to_bits(value) & kExponentMask) != kExponentMask; +} + +/** + * @brief 返回 FP32 的符号位是否为 1。 + * + * @param value 待检查的 FP32 值。 + * @return sign bit 为 1 时返回 true;-0 也会返回 true。 + */ +[[nodiscard]] __host__ __device__ inline bool has_negative_sign( + const float value) noexcept { + return (float_to_bits(value) & kSignMask) != 0U; +} + +/** + * @brief 清除 FP32 符号位,得到非负幅值。 + * + * @param value 任意 FP32 值。 + * @return value 的绝对值;NaN payload 保持不变。 + */ +[[nodiscard]] __host__ __device__ inline float absolute_value( + const float value) noexcept { + return bits_to_float(float_to_bits(value) & ~kSignMask); +} + +/** + * @brief 构造项目固定的 canonical FP32 quiet NaN。 + * + * @return bit pattern 为 `0x7fc00000` 的 quiet NaN。 + */ +[[nodiscard]] __host__ __device__ inline float canonical_quiet_nan() noexcept { + return bits_to_float(kCanonicalQuietNaNBits); +} + +/** + * @brief 构造带指定符号的 IEEE 754 零。 + * + * @param negative 为 true 时构造 -0,否则构造 +0。 + * @return 对应符号的 FP32 零。 + */ +[[nodiscard]] __host__ __device__ inline float signed_zero( + const bool negative) noexcept { + return bits_to_float(negative ? kSignMask : 0U); +} + +/** + * @brief 返回非零 uint32 的最高置位 bit 下标。 + * + * @param value 非零无符号整数。 + * @return 最高置位 bit 的零起始下标。 + */ +[[nodiscard]] __host__ __device__ inline int highest_set_bit_index( + std::uint32_t value) noexcept { + int index = 0; + + while (value > 1U) { + value >>= 1U; + ++index; + } + + return index; +} + +/** + * @brief 计算正且有限 FP32 的 floor(log2(value))。 + * + * normal 值的 unbiased exponent 是 exponent field 减 127。subnormal + * 没有隐含 1,真实数值为 `fraction * 2^-149`,故需要寻找 fraction 的 + * 最高置位 bit。 + * + * @param positive_value 必须为正且有限。 + * @return floor(log2(positive_value))。 + */ +[[nodiscard]] __host__ __device__ inline int floor_log2_positive( + const float positive_value) noexcept { + const std::uint32_t bits = float_to_bits(positive_value); + const std::uint32_t exponent_field = (bits >> 23U) & 0xffU; + + if (exponent_field != 0U) { + return static_cast(exponent_field) - 127; + } + + const std::uint32_t fraction = bits & kFractionMask; + return highest_set_bit_index(fraction) - 149; +} + +/** + * @brief 判断正且有限 FP32 是否恰为 2 的整数次幂。 + * + * normal 数要求 fraction 为 0;subnormal 数要求 fraction 恰有一个置位 + * bit,例如 $2^{-127}$ 的 bit pattern 是 `0x00400000`。 + * + * @param positive_value 必须为正且有限。 + * @return value 为 2^k 时返回 true。 + */ +[[nodiscard]] __host__ __device__ inline bool is_power_of_two_positive( + const float positive_value) noexcept { + const std::uint32_t bits = float_to_bits(positive_value); + const std::uint32_t exponent_field = (bits >> 23U) & 0xffU; + const std::uint32_t fraction = bits & kFractionMask; + + if (exponent_field != 0U) { + return fraction == 0U; + } + + return fraction != 0U && (fraction & (fraction - 1U)) == 0U; +} + +/** + * @brief 构造范围 [-127, 127] 内的精确 FP32 幂 $2^{exponent}$。 + * + * `numeric_limits::infinity()` 用于表达越过 FP32 范围后的结果。 + * 对 E8M0 关键的 $2^{-127}$,必须显式构造 bit `0x00400000`:它是 + * subnormal,不能简单令 exponent field 为 0 后得到正确值。 + * + * @param exponent 目标整数指数。 + * @return $2^{exponent}$;过小返回 +0,过大返回 +Inf。 + */ +[[nodiscard]] __host__ __device__ inline float power_of_two( + const int exponent) noexcept { + constexpr std::uint32_t kMinE8M0ScaleBits = 0x00400000U; + + if (exponent < -127) { + return 0.0F; + } + + if (exponent == -127) { + return bits_to_float(kMinE8M0ScaleBits); + } + + if (exponent > 127) { + return kPositiveInfinity; + } + + const std::uint32_t exponent_field = + static_cast(exponent + 127); + return bits_to_float(exponent_field << 23U); +} + +/** + * @brief 对非负且有限的较小值执行 RNE(ties-to-even)。 + * + * 此函数适合格式 codec 的 mantissa 与 subnormal 单位数舍入。调用方要 + * 保证结果不会超出 uint32 范围;它不适合作为任意 FP32 的通用整数转换。 + * + * @param nonnegative_value 待舍入的非负有限值。 + * @return 最近整数;恰好在中点时选择偶整数。 + */ +[[nodiscard]] __host__ __device__ inline std::uint32_t +round_to_nearest_even_nonnegative(const float nonnegative_value) noexcept { + const std::uint32_t truncated = + static_cast(nonnegative_value); + const float fractional = nonnegative_value - static_cast(truncated); + + if (fractional > 0.5F) { + return truncated + 1U; + } + + if (fractional == 0.5F && (truncated & 1U) != 0U) { + return truncated + 1U; + } + + return truncated; +} + +/** + * @brief 将随机数归一到 stochastic rounding 所需的 [0, 1) 区间。 + * + * @param uniform_random 候选随机数。 + * @return 合法随机数保持不变;NaN、负数映射为 0,1 及更大值映射为 + * `1-2^-24`。 + */ +[[nodiscard]] __host__ __device__ inline float clamp_uniform_random( + const float uniform_random) noexcept { + if (!is_finite(uniform_random) || uniform_random <= 0.0F) { + return 0.0F; + } + + if (uniform_random >= 1.0F) { + return kLargestBelowOne; + } + + return uniform_random; +} + +} // namespace quant_dequant::formats::fp32 diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/mxfp8_codec.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/mxfp8_codec.cuh" new file mode 100644 index 00000000..29380dca --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/mxfp8_codec.cuh" @@ -0,0 +1,480 @@ +#pragma once + +#include + +#include "fp32_utils.cuh" +#include "quant_dequant/types.hpp" + +namespace quant_dequant::formats { + +/** + * @brief E4M3 正最大有限值的 7-bit magnitude code。 + * + * 完整 E4M3 byte 还会在 bit 7 放置符号。`0x7e` 的 bit 字段为 + * `0 1111 110`,即 E=15、M=6,对应 +448。 + */ +inline constexpr std::uint8_t kE4M3PositiveMaxCode = 0x7eU; + +/** + * @brief E4M3 canonical NaN 的 7-bit magnitude code。 + * + * `0x7f` 的 bit 字段为 `0 1111 111`。添加符号位后的 `0x7f` 与 `0xff` + * 都按 E4M3 NaN 处理。 + */ +inline constexpr std::uint8_t kE4M3CanonicalNaNCode = 0x7fU; + +/** + * @brief E8M0 NaN 的编码。 + * + * 实际 bit 为 `0xff`,即八个指数 bit 全为 1。 + */ +inline constexpr std::uint8_t kE8M0NaNCode = 0xffU; + +/** + * @brief E4M3 可表示的最大有限绝对值。 + */ +inline constexpr float kE4M3MaxFinite = 448.0F; + +/** + * @brief E4M3 的最小 normal 正数,即 $2^{-6}$。 + */ +inline constexpr float kE4M3MinNormal = 0x1.0p-6F; + +/** + * @brief E4M3 的最小 subnormal 正数,即 $2^{-9}$。 + */ +inline constexpr float kE4M3MinSubnormal = 0x1.0p-9F; + +/** + * @brief 一个 MXFP8 rowwise block 的固定元素数量。 + */ +inline constexpr std::uint32_t kMxfp8ElementsPerBlock = kMxfp8BlockSize; + +/** + * @brief 使用 SplitMix64 混合一个无状态随机数状态。 + * + * stochastic rounding 必须按“量化配置 seed + 全局线性下标”独立生成随机数, + * 不能依赖线程调度顺序。将该规则放入 host/device codec,确保 CPU reference + * 与 CUDA kernel 对同一元素产生完全相同的随机 bit。 + * + * 无符号整数溢出按模 $2^{64}$ 定义,因此这个过程在 host 与 device 上一致。 + * + * @param state 待混合的 64-bit 状态。 + * @return 混合后的 64-bit 伪随机 bit。 + */ +[[nodiscard]] __host__ __device__ inline std::uint64_t mxfp8_splitmix64( + std::uint64_t state) noexcept { + state += 0x9e3779b97f4a7c15ULL; + state = (state ^ (state >> 30U)) * 0xbf58476d1ce4e5b9ULL; + state = (state ^ (state >> 27U)) * 0x94d049bb133111ebULL; + return state ^ (state >> 31U); +} + +/** + * @brief 为一个 row-major 全局线性元素下标生成 `[0, 1)` 内的随机数。 + * + * 仅提取 SplitMix64 输出的高 24 bit,再乘以 $2^{-24}$;结果严格小于 1.0F, + * 可直接传给 `encode_mxfp8_element()` 的 stochastic rounding 路径。 + * + * @param stochastic_seed 配置文件指定的确定性随机种子。 + * @param linear_index 元素在完整 row-major 张量中的零起始下标。 + * @return 位级可复现的 `[0, 1)` FP32 随机数。 + */ +[[nodiscard]] __host__ __device__ inline float +mxfp8_stochastic_uniform_for_element( + const std::uint64_t stochastic_seed, + const std::uint64_t linear_index) noexcept { + constexpr std::uint64_t kIndexStride = 0x9e3779b97f4a7c15ULL; + constexpr float kInvTwoTo24 = 0x1.0p-24F; + + const std::uint64_t state = + stochastic_seed + kIndexStride * (linear_index + 1U); + const std::uint64_t random_bits = mxfp8_splitmix64(state); + const std::uint32_t random_mantissa = + static_cast(random_bits >> 40U); + + return static_cast(random_mantissa) * kInvTwoTo24; +} + +namespace detail { + +/** + * @brief 找到小于等于给定幅值的最大 E4M3 有限 magnitude code。 + * + * 完整 E4M3 的 bit layout 是 `S EEEE MMM`。本函数刻意忽略符号 `S`, + * 只返回 `EEEE MMM`,因此结果是范围 `[0x00, 0x7e]` 内的 7-bit code。 + * 例如输入幅值为 1.30 时,返回 `0x3a`,它表示 +1.25;下一可表示值 + * `0x3b` 是 +1.375。stochastic rounding 需要这两个相邻端点来计算 + * 选择上界的概率,因此本函数返回下界,而不是最近值。 + * + * 调用前要求 magnitude 位于 `[0, 448)`。该范围保证返回值是有限 E4M3: + * `0x7f` 是 NaN,故不能作为普通上界。 + * + * @param magnitude 非负、有限且严格小于 448 的 FP32 幅值。 + * @return 小于等于 magnitude 的最大 E4M3 magnitude code。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t +find_e4m3_lower_magnitude_code(const float magnitude) noexcept { + if (magnitude < kE4M3MinNormal) { + // subnormal 的间距为 2^-9。除以间距等于乘 2^9,即乘 512: + // magnitude * 512 = magnitude / 2^-9。 + // 此分支保证结果落在 [0, 8),故转换为 uint32 后不会溢出。 + const float subnormal_units = magnitude * 512.0F; + + // 对非负浮点数,float -> uint32 的转换截断小数部分,等价于 floor。 + // 因而 lower_mantissa 正是最大的 M,使 M * 2^-9 <= magnitude。 + const std::uint32_t lower_mantissa = + static_cast(subnormal_units); + + // E=0 的 subnormal code 是 `0000 MMM`,所以 M 本身就是 7-bit code。 + return static_cast(lower_mantissa); + } + + // 此时 magnitude 是 E4M3 normal。floor(log2(magnitude)) 返回真实指数 e, + // 满足 2^e <= magnitude < 2^(e+1)。例如 magnitude=1.30 时 e=0; + // magnitude=0.30 时 e=-2。它不是任何格式中直接存储的指数 bit。 + const int unbiased_exponent = fp32::floor_log2_positive(magnitude); + + // E4M3 的 4-bit 指数位不能直接存负数,故实际存储 E=e+bias=e+7。 + // 例如 e=-2 编码为 E=5;e=0 编码为 E=7。 + const std::uint32_t e4m3_biased_exponent = + static_cast(unbiased_exponent + 7); + + // 除以 2^e 后,normalized 位于 [1, 2)。减一得到 [0, 1) 的小数部分; + // 乘 8 是因为 E4M3 只有 3 个 mantissa bit,可把该区间分成 8 格。 + const float normalized = + magnitude / fp32::power_of_two(unbiased_exponent); + const std::uint32_t lower_mantissa = static_cast( + (normalized - 1.0F) * 8.0F); + + // 拼接不含符号位的 `EEEE MMM`。lower_mantissa 向下截断,故结果正好是 + // 小于等于 magnitude 的同指数区间中最大 E4M3 值。 + return static_cast( + (e4m3_biased_exponent << 3U) | lower_mantissa); +} + +} // namespace detail + +/** + * @brief 判断 E4M3 原始 byte 是否表示 NaN。 + * + * @param encoded 完整 E4M3 byte,包含符号位。 + * @return E=15 且 M=7 时返回 true。 + */ +[[nodiscard]] __host__ __device__ inline bool is_e4m3_nan( + const std::uint8_t encoded) noexcept { + return (encoded & 0x7fU) == kE4M3CanonicalNaNCode; +} + +/** + * @brief 判断 E8M0 scale byte 是否表示 NaN。 + * + * @param encoded E8M0 scale byte。 + * @return encoded 为 `0xff` 时返回 true。 + */ +[[nodiscard]] __host__ __device__ inline bool is_e8m0_nan( + const std::uint8_t encoded) noexcept { + return encoded == kE8M0NaNCode; +} + +/** + * @brief 解码一个 E4M3 byte 为 FP32。 + * + * E=0 时,E4M3 是 subnormal,幅值为 $M\times2^{-9}$;E>0 时,幅值为 + * $(8+M)\times2^{E-10}$。E=15、M=7 是 NaN,不存在 Inf 编码。 + * + * @param encoded 完整 E4M3 byte,bit 7 为符号位。 + * @return 对应 FP32;E4M3 NaN 返回 bit 为 `0x7fc00000` 的 quiet NaN。 + */ +[[nodiscard]] __host__ __device__ inline float decode_e4m3( + const std::uint8_t encoded) noexcept { + const bool negative = (encoded & 0x80U) != 0U; + const std::uint8_t magnitude_code = encoded & 0x7fU; + const std::uint8_t exponent_field = magnitude_code >> 3U; + const std::uint8_t mantissa = magnitude_code & 0x07U; + + if (exponent_field == 0x0fU && mantissa == 0x07U) { + return fp32::canonical_quiet_nan(); + } + + if (exponent_field == 0U && mantissa == 0U) { + return fp32::signed_zero(negative); + } + + const float magnitude = exponent_field == 0U + ? static_cast(mantissa) * kE4M3MinSubnormal + : static_cast(8U + mantissa) * + fp32::power_of_two(static_cast(exponent_field) - 10); + + return negative ? -magnitude : magnitude; +} + +/** + * @brief 以 RNE(ties-to-even)和饱和规则将 FP32 编码为 E4M3。 + * + * 有限输入超过 448 以及 `+/-Inf` 均饱和到带相同符号的最大有限值 + * `+/-448`。NaN 编码为 canonical E4M3 NaN `0x7f`。 + * + * @param value 待编码的 FP32 值。 + * @return 完整 E4M3 byte,bit 7 为符号位。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t encode_e4m3_rne_sat( + const float value) noexcept { + const bool negative = fp32::has_negative_sign(value); + const std::uint8_t sign_bit = negative ? 0x80U : 0x00U; + + if (!fp32::is_finite(value)) { + const float magnitude = fp32::absolute_value(value); + + if (magnitude != magnitude) { + return kE4M3CanonicalNaNCode; + } + + return static_cast(sign_bit | kE4M3PositiveMaxCode); + } + + const float magnitude = fp32::absolute_value(value); + + if (magnitude == 0.0F) { + // 项目量化输出统一使用 +0(0x00),满足全零 block 的文件约定。 + // 解码器仍能正确读取外部文件中合法的 -0(0x80)。 + return 0x00U; + } + + if (magnitude >= kE4M3MaxFinite) { + return static_cast(sign_bit | kE4M3PositiveMaxCode); + } + + if (magnitude < kE4M3MinNormal) { + const std::uint32_t rounded_units = + fp32::round_to_nearest_even_nonnegative(magnitude * 512.0F); + + // code 8 正好是 E=1、M=0,即最小 normal,而不是 subnormal。 + return static_cast(sign_bit | rounded_units); + } + + int unbiased_exponent = fp32::floor_log2_positive(magnitude); + const float normalized = + magnitude / fp32::power_of_two(unbiased_exponent); + std::uint32_t mantissa = fp32::round_to_nearest_even_nonnegative( + (normalized - 1.0F) * 8.0F); + + // mantissa 从 7 向上舍入时进位到下一个 exponent。 + if (mantissa == 8U) { + mantissa = 0U; + ++unbiased_exponent; + } + + const int exponent_field = unbiased_exponent + 7; + + if (exponent_field > 15 || + (exponent_field == 15 && mantissa >= 7U)) { + // E=15、M=7 是 NaN,有限饱和必须停在 E=15、M=6。 + return static_cast(sign_bit | kE4M3PositiveMaxCode); + } + + return static_cast( + sign_bit | + (static_cast(exponent_field) << 3U) | + static_cast(mantissa)); +} + +/** + * @brief 以 stochastic rounding 和饱和规则将 FP32 编码为 E4M3。 + * + * 对相邻可表示幅值 $l$ 与 $h$,该函数以 `(abs(value)-l)/(h-l)` 的概率 + * 选择 $h$。调用方负责将固定 seed 和线性元素下标映射为 + * `uniform_random`,从而让 CPU/GPU 获得可复现的相同 payload。 + * + * @param value 待编码的 FP32 值。 + * @param uniform_random 均匀随机数;正常调用应落在 `[0, 1)`。 + * @return 完整 E4M3 byte,bit 7 为符号位。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t +encode_e4m3_stochastic_sat(const float value, + const float uniform_random) noexcept { + const bool negative = fp32::has_negative_sign(value); + const std::uint8_t sign_bit = negative ? 0x80U : 0x00U; + + if (!fp32::is_finite(value)) { + return encode_e4m3_rne_sat(value); + } + + const float magnitude = fp32::absolute_value(value); + + if (magnitude == 0.0F || magnitude >= kE4M3MaxFinite) { + return encode_e4m3_rne_sat(value); + } + + const std::uint8_t lower_code = + detail::find_e4m3_lower_magnitude_code(magnitude); + const float lower_value = decode_e4m3(lower_code); + + if (magnitude == lower_value || lower_code == kE4M3PositiveMaxCode) { + return static_cast(sign_bit | lower_code); + } + + const std::uint8_t upper_code = + static_cast(lower_code + 1U); + const float upper_value = decode_e4m3(upper_code); + const float probability_of_upper = + (magnitude - lower_value) / (upper_value - lower_value); + + const std::uint8_t selected_code = + fp32::clamp_uniform_random(uniform_random) < probability_of_upper + ? upper_code + : lower_code; + + return static_cast(sign_bit | selected_code); +} + +/** + * @brief 按配置舍入方式将 FP32 编码为 E4M3。 + * + * `RoundingMode::kUnknown` 理论上由配置校验拒绝。codec 不抛异常,为使 + * host/device 行为稳定,未知枚举值回退为 RNE。 + * + * @param value 待编码的 FP32 值。 + * @param rounding_mode 元素舍入模式。 + * @param uniform_random stochastic rounding 使用的随机数;RNE 下忽略。 + * @return 完整 E4M3 byte。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t encode_e4m3( + const float value, + const RoundingMode rounding_mode, + const float uniform_random = 0.0F) noexcept { + if (rounding_mode == RoundingMode::kStochastic) { + return encode_e4m3_stochastic_sat(value, uniform_random); + } + + return encode_e4m3_rne_sat(value); +} + +/** + * @brief 解码 E8M0 scale byte 为 FP32。 + * + * `encoded` 位于 `[0, 254]` 时表示 $2^{encoded-127}$。`0xff` 是 NaN, + * 返回项目固定的 FP32 quiet NaN bit `0x7fc00000`。 + * + * @param encoded E8M0 scale byte。 + * @return 正的 FP32 scale,或 NaN。 + */ +[[nodiscard]] __host__ __device__ inline float decode_e8m0( + const std::uint8_t encoded) noexcept { + if (encoded == kE8M0NaNCode) { + return fp32::canonical_quiet_nan(); + } + + return fp32::power_of_two(static_cast(encoded) - 127); +} + +/** + * @brief 将非负 FP32 scale 向上取整为 E8M0。 + * + * 返回 `clamp(ceil(log2(required_scale)) + 127, 0, 254)`。向上取整保证 + * scale 不会偏小;这是 MXFP8 block 最大值避免 E4M3 溢出的必要条件。 + * `+0` 与 `-0` 编码为 `0x00`,即最小有限 E8M0 scale $2^{-127}$;负 + * 非零值、NaN、Inf 均返回 E8M0 NaN `0xff`。 + * + * @param required_scale 需要向上取整的非负 FP32 scale。 + * @return E8M0 scale byte,非法输入返回 `0xff`。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t +encode_e8m0_round_up(const float required_scale) noexcept { + const float magnitude = fp32::absolute_value(required_scale); + + if (magnitude == 0.0F) { + return 0x00U; + } + + if (!fp32::is_finite(required_scale) || + fp32::has_negative_sign(required_scale)) { + return kE8M0NaNCode; + } + + const int floor_exponent = fp32::floor_log2_positive(required_scale); + const int ceil_exponent = floor_exponent + + (fp32::is_power_of_two_positive(required_scale) ? 0 : 1); + + int encoded_exponent = ceil_exponent + 127; + + if (encoded_exponent < 0) { + encoded_exponent = 0; + } else if (encoded_exponent > 254) { + encoded_exponent = 254; + } + + return static_cast(encoded_exponent); +} + +/** + * @brief 由 block 或 tensor 的 amax 计算 MXFP8 E8M0 scale byte。 + * + * 对 `scale_mode=block` 传入一个 rowwise 32 元素 block 的 amax;对 + * `scale_mode=tensor` 传入整张矩阵的 amax。两种模式都遵循 + * `round_up_to_e8m0(amax / 448)`。 + * + * @param amax 输入范围的最大绝对值。 + * @return 对应 E8M0 scale byte;非法 amax 返回 `0xff`。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t +compute_mxfp8_scale_code(const float amax) noexcept { + if (!fp32::is_finite(amax)) { + return kE8M0NaNCode; + } + + // FP32 的 +0 与 -0 数值相等;amax 为零时均应使用最小有限 scale。 + if (amax == 0.0F) { + return 0x00U; + } + + if (fp32::has_negative_sign(amax)) { + return kE8M0NaNCode; + } + + return encode_e8m0_round_up(amax / kE4M3MaxFinite); +} + +/** + * @brief 用 E8M0 scale 将一个 FP32 元素编码为 MXFP8 E4M3 payload。 + * + * 数学过程是 $z=value/decode\_e8m0(scale\_code)$,再执行 E4M3 编码。 + * scale 为 E8M0 NaN 时,返回 E4M3 canonical NaN `0x7f`。 + * + * @param value 待量化的 FP32 输入元素。 + * @param scale_code 当前 block 或 tensor 的 E8M0 scale byte。 + * @param rounding_mode E4M3 元素编码使用的舍入模式。 + * @param uniform_random stochastic rounding 使用的随机数;RNE 下忽略。 + * @return 一个 E4M3 payload byte。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t encode_mxfp8_element( + const float value, + const std::uint8_t scale_code, + const RoundingMode rounding_mode, + const float uniform_random = 0.0F) noexcept { + const float scale = decode_e8m0(scale_code); + + if (scale != scale) { + return kE4M3CanonicalNaNCode; + } + + return encode_e4m3(value / scale, rounding_mode, uniform_random); +} + +/** + * @brief 将一个 MXFP8 E4M3 payload 反量化为 FP32。 + * + * 数学过程是 $decode\_e4m3(payload)\times decode\_e8m0(scale\_code)$。 + * E4M3 或 E8M0 的 NaN 会遵循 IEEE FP32 乘法自然传播。 + * + * @param payload E4M3 元素 byte。 + * @param scale_code 当前 block 或 tensor 的 E8M0 scale byte。 + * @return 反量化后的 FP32 值。 + */ +[[nodiscard]] __host__ __device__ inline float decode_mxfp8_element( + const std::uint8_t payload, + const std::uint8_t scale_code) noexcept { + return decode_e4m3(payload) * decode_e8m0(scale_code); +} + +} // namespace quant_dequant::formats diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/nvfp4_codec.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/nvfp4_codec.cuh" new file mode 100644 index 00000000..c362e109 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/formats/nvfp4_codec.cuh" @@ -0,0 +1,449 @@ +#pragma once + +#include + +#include "mxfp8_codec.cuh" + +namespace quant_dequant::formats { + +/** + * @brief E2M1 payload 中有效的四个 bit 掩码。 + * + * NVFP4 的两个逻辑元素共用一个字节;单元素 codec 只处理其中一个 nibble, + * 因此任何 E2M1 code 都必须限制在 bits `[3:0]`。 + */ +inline constexpr std::uint8_t kE2M1NibbleMask = 0x0fU; + +/** + * @brief E2M1 正最大有限值的 magnitude code。 + * + * `0b0111` 的 bit layout 为 `S=0, EE=3, M=1`,表示 `+6`。 + */ +inline constexpr std::uint8_t kE2M1PositiveMaxCode = 0x07U; + +/** + * @brief E2M1 可表示的最大有限绝对值。 + */ +inline constexpr float kE2M1MaxFinite = 6.0F; + +/** + * @brief E2M1 的最小 normal 正数。 + */ +inline constexpr float kE2M1MinNormal = 1.0F; + +/** + * @brief E2M1 的最小 subnormal 正数。 + */ +inline constexpr float kE2M1MinSubnormal = 0.5F; + +/** + * @brief 一个严格 NVFP4 rowwise block 的固定元素数量。 + */ +inline constexpr std::uint32_t kNvfp4ElementsPerBlock = kNvfp4BlockSize; + +/** + * @brief 标准 NVFP4 decode 方向 global scale 的分母。 + * + * 对 tensor amax $a_g$,NVFP4 写入的 global scale 是 + * $a_g / (448 \times 6) = a_g / 2688$。448 是 E4M3 的最大有限值,6 是 + * E2M1 的最大有限值。 + */ +inline constexpr float kNvfp4GlobalScaleDenominator = + kE4M3MaxFinite * kE2M1MaxFinite; + +/** + * @brief 为一个 NVFP4 元素生成可复现的 stochastic rounding 随机数。 + * + * NVFP4 与 MXFP8 共用“配置 seed + row-major 线性下标”的无状态 SplitMix64 + * 映射;format 不参与随机状态,CPU reference 与 CUDA kernel 因而不会受执行 + * 顺序影响。这里保留 NVFP4 名称,避免调用方依赖另一格式的接口名称。 + * + * @param stochastic_seed 配置文件指定的确定性随机种子。 + * @param linear_index 元素在完整 row-major 张量中的零起始下标。 + * @return 严格位于 `[0, 1)` 的 FP32 均匀随机数。 + */ +[[nodiscard]] __host__ __device__ inline float +nvfp4_stochastic_uniform_for_element( + const std::uint64_t stochastic_seed, + const std::uint64_t linear_index) noexcept { + return mxfp8_stochastic_uniform_for_element(stochastic_seed, linear_index); +} + +namespace detail { + +/** + * @brief 将一个无符号 E2M1 magnitude code 解码为非负 FP32 幅值。 + * + * 低三位的 bit layout 为 `EE M`。E=0、M=1 是 subnormal `0.5`;E>0 + * 使用隐含 leading 1。E2M1 的全部 8 个 magnitude code 都是有限数, + * 不存在 Inf 或 NaN 保留码。 + * + * @param magnitude_code E2M1 的低三位;高位会被忽略。 + * @return 对应的非负 E2M1 幅值。 + */ +[[nodiscard]] __host__ __device__ inline float decode_e2m1_magnitude( + const std::uint8_t magnitude_code) noexcept { + switch (magnitude_code & kE2M1PositiveMaxCode) { + case 0x00U: + return 0.0F; + + case 0x01U: + return kE2M1MinSubnormal; + + case 0x02U: + return 1.0F; + + case 0x03U: + return 1.5F; + + case 0x04U: + return 2.0F; + + case 0x05U: + return 3.0F; + + case 0x06U: + return 4.0F; + + case 0x07U: + return kE2M1MaxFinite; + } + + // 输入已按低三位掩码收窄;此 return 只用于安静处理理论上不可达的路径。 + return 0.0F; +} + +/** + * @brief 找到不大于给定幅值的最大 E2M1 magnitude code。 + * + * E2M1 只有八个有限正幅值,且相邻间距不均匀:`0.5, 0.5, 0.5, 1, 1, 2`。 + * 直接比较 codebook 边界比把 IEEE 位字段反推为指数和尾数更清楚,也避免在 + * subnormal 与 normal 边界引入特殊算式。stochastic rounding 以该下界和 + * 下一个 code 构造概率;RNE 则用这对端点判断最近值。 + * + * 调用前要求 `magnitude` 非负、有限且严格小于 6。 + * + * @param magnitude 待定位的 E2M1 非负幅值。 + * @return 最大的 code,使其解码值不大于 `magnitude`。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t +find_e2m1_lower_magnitude_code(const float magnitude) noexcept { + if (magnitude < kE2M1MinSubnormal) { + return 0x00U; + } + + if (magnitude < 1.0F) { + return 0x01U; + } + + if (magnitude < 1.5F) { + return 0x02U; + } + + if (magnitude < 2.0F) { + return 0x03U; + } + + if (magnitude < 3.0F) { + return 0x04U; + } + + if (magnitude < 4.0F) { + return 0x05U; + } + + return 0x06U; +} + +} // namespace detail + +/** + * @brief 将一个 E2M1 nibble 解码为 FP32。 + * + * E2M1 bit layout 是 `S EE M`。其指数 bias 为 1:E=0 时没有隐含 1, + * `M=1` 表示 subnormal `0.5`;E>0 时,值为 + * $(1 + M/2) \times 2^{E-1}$。`+0` 与 `-0` 都是合法输入,解码时保留其 + * 符号位。不同于 IEEE FP32,E2M1 没有 Inf 或 NaN 编码。 + * + * @param encoded 包含一个 E2M1 code 的低 nibble;高四位会被忽略。 + * @return 对应 FP32 值。 + */ +[[nodiscard]] __host__ __device__ inline float decode_e2m1( + const std::uint8_t encoded) noexcept { + const bool negative = (encoded & 0x08U) != 0U; + const float magnitude = detail::decode_e2m1_magnitude(encoded); + + if (magnitude == 0.0F) { + return fp32::signed_zero(negative); + } + + return negative ? -magnitude : magnitude; +} + +/** + * @brief 以 RNE(ties-to-even)和饱和规则将 FP32 编码为一个 E2M1 nibble。 + * + * 有限输入超过 6 以及正负 Inf 均饱和到同符号的 `+/-6`。E2M1 没有 NaN + * 编码,所以 NaN 规范化为正零 `0x0`;完整量化 pipeline 会在计算 scale 前 + * 拒绝非有限原始输入,这一回退只保证单元素 codec 的 host/device 行为确定。 + * 与项目的 MXFP8 写入约定一致,`-0` 也编码为正零。 + * + * @param value 待编码的 FP32 值。 + * @return 低四位为 E2M1 `S EE M` 的 code,高四位恒为零。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t encode_e2m1_rne_sat( + const float value) noexcept { + const bool negative = fp32::has_negative_sign(value); + const std::uint8_t sign_bit = negative ? 0x08U : 0x00U; + + if (!fp32::is_finite(value)) { + const float magnitude = fp32::absolute_value(value); + if (magnitude != magnitude) { + return 0x00U; + } + + return static_cast(sign_bit | kE2M1PositiveMaxCode); + } + + const float magnitude = fp32::absolute_value(value); + if (magnitude == 0.0F) { + return 0x00U; + } + + if (magnitude >= kE2M1MaxFinite) { + return static_cast(sign_bit | kE2M1PositiveMaxCode); + } + + const std::uint8_t lower_code = + detail::find_e2m1_lower_magnitude_code(magnitude); + const float lower_value = detail::decode_e2m1_magnitude(lower_code); + if (magnitude == lower_value) { + return static_cast(sign_bit | lower_code); + } + + const std::uint8_t upper_code = + static_cast(lower_code + 1U); + const float upper_value = detail::decode_e2m1_magnitude(upper_code); + const float midpoint = lower_value + (upper_value - lower_value) * 0.5F; + + if (magnitude < midpoint || + (magnitude == midpoint && (lower_code & 1U) == 0U)) { + return static_cast(sign_bit | lower_code); + } + + return static_cast(sign_bit | upper_code); +} + +/** + * @brief 以 stochastic rounding 和饱和规则将 FP32 编码为一个 E2M1 nibble。 + * + * 若相邻可表示幅值为 $l$ 与 $h$,则以上界概率 + * $(|value|-l)/(h-l)$ 选择 $h$。调用方必须通过固定 seed 和全局线性下标生成 + * `uniform_random`,才能保证 CPU reference 和 CUDA kernel 的 payload 一致。 + * + * @param value 待编码的 FP32 值。 + * @param uniform_random 候选均匀随机数;内部会将其收窄至 `[0, 1)`。 + * @return 低四位为 E2M1 `S EE M` 的 code,高四位恒为零。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t +encode_e2m1_stochastic_sat(const float value, + const float uniform_random) noexcept { + if (!fp32::is_finite(value)) { + return encode_e2m1_rne_sat(value); + } + + const bool negative = fp32::has_negative_sign(value); + const std::uint8_t sign_bit = negative ? 0x08U : 0x00U; + const float magnitude = fp32::absolute_value(value); + + if (magnitude == 0.0F || magnitude >= kE2M1MaxFinite) { + return encode_e2m1_rne_sat(value); + } + + const std::uint8_t lower_code = + detail::find_e2m1_lower_magnitude_code(magnitude); + const float lower_value = detail::decode_e2m1_magnitude(lower_code); + if (magnitude == lower_value) { + return static_cast(sign_bit | lower_code); + } + + const std::uint8_t upper_code = + static_cast(lower_code + 1U); + const float upper_value = detail::decode_e2m1_magnitude(upper_code); + const float probability_of_upper = + (magnitude - lower_value) / (upper_value - lower_value); + + const std::uint8_t selected_code = + fp32::clamp_uniform_random(uniform_random) < probability_of_upper + ? upper_code + : lower_code; + return static_cast(sign_bit | selected_code); +} + +/** + * @brief 按配置选择 RNE 或 stochastic E2M1 元素编码。 + * + * `RoundingMode::kUnknown` 应由上层配置校验拒绝;codec 为保持 host/device + * 行为稳定,仍回退到 RNE。 + * + * @param value 待编码的 FP32 值。 + * @param rounding_mode 元素舍入模式。 + * @param uniform_random stochastic rounding 使用的随机数;RNE 下忽略。 + * @return 一个高四位为零的 E2M1 nibble。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t encode_e2m1( + const float value, + const RoundingMode rounding_mode, + const float uniform_random = 0.0F) noexcept { + if (rounding_mode == RoundingMode::kStochastic) { + return encode_e2m1_stochastic_sat(value, uniform_random); + } + + return encode_e2m1_rne_sat(value); +} + +/** + * @brief 根据整张张量的 amax 计算 NVFP4 的 FP32 decode global scale。 + * + * 对正有限 amax,返回 `amax / (448 * 6)`。全零张量返回 1.0,避免随后计算 + * `block_amax / (6 * global_scale)` 时出现除零。负 amax、NaN 与 Inf 都是 + * 调用方错误,返回 canonical FP32 quiet NaN 供上层检测。 + * + * @param tensor_amax 整张输入张量的最大绝对值。 + * @return 有限正的 FP32 global scale、全零时的 1.0,或非法输入的 NaN。 + */ +[[nodiscard]] __host__ __device__ inline float compute_nvfp4_global_scale( + const float tensor_amax) noexcept { + if (!fp32::is_finite(tensor_amax) || + (tensor_amax != 0.0F && fp32::has_negative_sign(tensor_amax))) { + return fp32::canonical_quiet_nan(); + } + + if (tensor_amax == 0.0F) { + return 1.0F; + } + + return tensor_amax / kNvfp4GlobalScaleDenominator; +} + +/** + * @brief 根据一个 16 元素 block 的 amax 计算其 E4M3 local scale code。 + * + * 严格 NVFP4 的 local decode scale 满足 + * $s_b=\operatorname{E4M3\_RNE\_SAT}(a_b/(6s_g))$。local scale 始终以 + * RNE 编码,和 E2M1 元素的 `rounding_mode` 无关。全零 block 写正零 + * `0x00`;非法 amax 或 global scale 返回 E4M3 canonical NaN `0x7f`。 + * + * @param block_amax 当前 16 元素 block 的最大绝对值。 + * @param global_scale 本张量的 FP32 decode global scale。 + * @return 一个 E4M3 local scale byte,非法输入返回 `0x7f`。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t +compute_nvfp4_local_scale_code(const float block_amax, + const float global_scale) noexcept { + if (!fp32::is_finite(block_amax) || + (block_amax != 0.0F && fp32::has_negative_sign(block_amax)) || + !fp32::is_finite(global_scale) || global_scale <= 0.0F) { + return kE4M3CanonicalNaNCode; + } + + if (block_amax == 0.0F) { + return 0x00U; + } + + return encode_e4m3_rne_sat( + block_amax / (kE2M1MaxFinite * global_scale)); +} + +/** + * @brief 将一个 FP32 元素编码为 NVFP4 E2M1 payload nibble。 + * + * 先按 $z=value/(s_b s_g)$ 缩放,再按调用方选择的舍入策略编码为 E2M1。 + * 若 local scale 是 E4M3 NaN、任一 scale 非有限或 local scale 为零,则返回 + * 正零 nibble。最后一种情况对应 E4M3 local scale 下溢的 block;项目规范 + * 要求该 block 的所有元素都量化为零。 + * + * @param value 待量化的 FP32 元素。 + * @param local_scale_code 当前 16 元素 block 的 E4M3 local scale code。 + * @param global_scale 本张量的 FP32 decode global scale。 + * @param rounding_mode E2M1 元素编码使用的舍入模式。 + * @param uniform_random stochastic rounding 使用的随机数;RNE 下忽略。 + * @return 一个高四位为零的 E2M1 nibble。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t encode_nvfp4_element( + const float value, + const std::uint8_t local_scale_code, + const float global_scale, + const RoundingMode rounding_mode, + const float uniform_random = 0.0F) noexcept { + const float local_scale = decode_e4m3(local_scale_code); + const float combined_scale = local_scale * global_scale; + + if (!fp32::is_finite(local_scale) || !fp32::is_finite(global_scale) || + local_scale <= 0.0F || global_scale <= 0.0F || + !fp32::is_finite(combined_scale) || combined_scale <= 0.0F) { + return 0x00U; + } + + return encode_e2m1(value / combined_scale, rounding_mode, uniform_random); +} + +/** + * @brief 将一个 NVFP4 E2M1 payload nibble 反量化为 FP32。 + * + * 数学过程为 $decode\_e2m1(payload) \times decode\_e4m3(local) \times global$。 + * local scale 的 E4M3 NaN 或 global FP32 NaN 会由 IEEE FP32 乘法自然传播。 + * + * @param payload 一个 E2M1 nibble;高四位会被忽略。 + * @param local_scale_code 当前 16 元素 block 的 E4M3 local scale code。 + * @param global_scale 本张量的 FP32 decode global scale。 + * @return 反量化后的 FP32 值。 + */ +[[nodiscard]] __host__ __device__ inline float decode_nvfp4_element( + const std::uint8_t payload, + const std::uint8_t local_scale_code, + const float global_scale) noexcept { + return decode_e2m1(payload) * decode_e4m3(local_scale_code) * global_scale; +} + +/** + * @brief 将两个 E2M1 nibble 按项目的 NVFP4 payload 顺序打包为一个字节。 + * + * 线性偶数下标元素写入低 nibble,紧随其后的奇数下标元素写入高 nibble。 + * 最后仅剩一个逻辑元素时,调用方应将 `high_nibble` 传为零。 + * + * @param low_nibble 偶数线性下标对应的 E2M1 code。 + * @param high_nibble 奇数线性下标对应的 E2M1 code。 + * @return 打包后的一个 payload 字节。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t pack_e2m1_nibbles( + const std::uint8_t low_nibble, + const std::uint8_t high_nibble) noexcept { + return static_cast( + (low_nibble & kE2M1NibbleMask) | + ((high_nibble & kE2M1NibbleMask) << 4U)); +} + +/** + * @brief 取出 packed NVFP4 payload byte 的低 E2M1 nibble。 + * + * @param packed_payload 一个含两个逻辑 E2M1 code 的 payload 字节。 + * @return 偶数线性下标元素的 E2M1 code。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t unpack_e2m1_low_nibble( + const std::uint8_t packed_payload) noexcept { + return packed_payload & kE2M1NibbleMask; +} + +/** + * @brief 取出 packed NVFP4 payload byte 的高 E2M1 nibble。 + * + * @param packed_payload 一个含两个逻辑 E2M1 code 的 payload 字节。 + * @return 奇数线性下标元素的 E2M1 code。 + */ +[[nodiscard]] __host__ __device__ inline std::uint8_t unpack_e2m1_high_nibble( + const std::uint8_t packed_payload) noexcept { + return static_cast(packed_payload >> 4U); +} + +} // namespace quant_dequant::formats diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/quantized_io.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/quantized_io.cpp" new file mode 100644 index 00000000..35bba5b7 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/quantized_io.cpp" @@ -0,0 +1,742 @@ +#include "quant_dequant/quantized_io.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace quant_dequant { +namespace { + +static_assert(sizeof(float) == sizeof(std::uint32_t)); +static_assert(std::numeric_limits::is_iec559); + +/** QDWGT v1 固定 header 的字节数。 */ +constexpr std::size_t kQuantizedHeaderBytes = 128U; + +/** QDWGT v1 文件格式版本。 */ +constexpr std::uint16_t kQuantizedFileVersion = 1U; + +/** QDWGT v1 写入器使用的固定 payload offset。 */ +constexpr std::uint64_t kCanonicalPayloadOffset = 128U; + +/** QDWGT v1 的 section 对齐粒度。 */ +constexpr std::uint64_t kSectionAlignment = 8U; + +/** NVFP4 的 flags bit 0:最后一个 payload 字节高 nibble 已清零。 */ +constexpr std::uint32_t kNvfp4TailNibbleZeroFlag = 1U; + +/** QDWGT 文件 magic,ASCII `QDWGT` 后补三个零字节。 */ +constexpr std::array kQuantizedMagic{ + 'Q', 'D', 'W', 'G', 'T', 0U, 0U, 0U, +}; + +/** QDWGT header 的固定字段 offset。 */ +constexpr std::size_t kMagicOffset = 0U; +constexpr std::size_t kVersionOffset = 8U; +constexpr std::size_t kHeaderBytesOffset = 10U; +constexpr std::size_t kByteOrderOffset = 12U; +constexpr std::size_t kFormatOffset = 13U; +constexpr std::size_t kScaleModeOffset = 14U; +constexpr std::size_t kRoundingOffset = 15U; +constexpr std::size_t kSourceDTypeOffset = 16U; +constexpr std::size_t kPayloadElementBitsOffset = 17U; +constexpr std::size_t kLocalScaleTypeOffset = 18U; +constexpr std::size_t kScaleLayoutOffset = 19U; +constexpr std::size_t kBlockSizeOffset = 20U; +constexpr std::size_t kRowsOffset = 24U; +constexpr std::size_t kColsOffset = 32U; +constexpr std::size_t kElementCountOffset = 40U; +constexpr std::size_t kPayloadOffsetOffset = 48U; +constexpr std::size_t kPayloadBytesOffset = 56U; +constexpr std::size_t kLocalScaleOffsetOffset = 64U; +constexpr std::size_t kLocalScaleCountOffset = 72U; +constexpr std::size_t kLocalScaleBytesOffset = 80U; +constexpr std::size_t kGlobalScaleOffset = 88U; +constexpr std::size_t kFlagsOffset = 92U; +constexpr std::size_t kStochasticSeedOffset = 96U; + +/** + * @brief 保存已完成 header 校验、但尚未读取 section 内容的 QDWGT 元数据。 + */ +struct ParsedQuantizedHeader { + QuantizedTensorDesc desc{}; + std::uint64_t payload_offset{0U}; + std::uint64_t payload_bytes{0U}; + std::uint64_t local_scale_offset{0U}; + std::uint64_t local_scale_bytes{0U}; + std::optional global_scale{}; +}; + +/** + * @brief 生成统一的 QuantizedIoError 文本。 + * + * @param file_path 发生错误的文件路径。 + * @param detail 具体错误说明。 + * @return 可传递给 `std::runtime_error` 的完整错误文本。 + */ +[[nodiscard]] std::string make_error_message( + const std::filesystem::path& file_path, + const std::string_view detail) { + return "量化权重文件 \"" + file_path.string() + "\":" + + std::string{detail}; +} + +/** + * @brief 抛出带文件路径的 QuantizedIoError。 + * + * @param file_path 发生错误的文件路径。 + * @param detail 具体错误说明。 + */ +[[noreturn]] void throw_quantized_io_error( + const std::filesystem::path& file_path, + std::string detail) { + throw QuantizedIoError(file_path, std::move(detail)); +} + +/** + * @brief 从 little-endian 字节区读取无符号整数。 + * + * @tparam UInt 目标无符号整数类型。 + * @param bytes 输入字节区。 + * @param offset 整数起始 offset。 + * @return 解码后的无符号整数。 + */ +template +[[nodiscard]] UInt load_unsigned_le( + const std::span bytes, + const std::size_t offset) noexcept { + static_assert(std::is_unsigned_v); + + UInt result{0U}; + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + result |= static_cast(bytes[offset + index]) + << static_cast(index * 8U); + } + + return result; +} + +/** + * @brief 向 little-endian 字节区写入无符号整数。 + * + * @tparam UInt 源无符号整数类型。 + * @param bytes 输出字节区。 + * @param offset 整数起始 offset。 + * @param value 要编码的整数。 + */ +template +void store_unsigned_le(std::span bytes, + const std::size_t offset, + const UInt value) noexcept { + static_assert(std::is_unsigned_v); + + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + bytes[offset + index] = static_cast( + value >> static_cast(index * 8U)); + } +} + +/** + * @brief 返回向上对齐到 8 字节后的值,并检测加法溢出。 + * + * @param value 待对齐的非负字节 offset。 + * @param file_path 当前文件路径,用于错误信息。 + * @return 满足 `result % 8 == 0` 且 `result >= value` 的 offset。 + * @throws QuantizedIoError 对齐所需加法溢出时抛出。 + */ +[[nodiscard]] std::uint64_t align_up_to_section_boundary( + const std::uint64_t value, + const std::filesystem::path& file_path) { + constexpr std::uint64_t kPaddingLimit = kSectionAlignment - 1U; + if (value > std::numeric_limits::max() - kPaddingLimit) { + throw_quantized_io_error(file_path, "section offset 对齐时发生 uint64 溢出。"); + } + + return (value + kPaddingLimit) & ~kPaddingLimit; +} + +/** + * @brief 安全计算两个 uint64 的和。 + * + * @param left 加数。 + * @param right 加数。 + * @param file_path 当前文件路径,用于错误信息。 + * @param context 错误信息中说明该和的用途。 + * @return `left + right`。 + * @throws QuantizedIoError 相加溢出时抛出。 + */ +[[nodiscard]] std::uint64_t checked_add( + const std::uint64_t left, + const std::uint64_t right, + const std::filesystem::path& file_path, + const std::string_view context) { + if (left > std::numeric_limits::max() - right) { + throw_quantized_io_error(file_path, + std::string{context} + " 发生 uint64 溢出。"); + } + + return left + right; +} + +/** + * @brief 检查 uint64 数值能否安全转换为 host 容器使用的 size_t。 + * + * @param value 待转换数值。 + * @param file_path 当前文件路径,用于错误信息。 + * @param field_name 对应 header 字段名。 + * @return 可安全用于 vector 大小和 stream I/O 的 size_t。 + */ +[[nodiscard]] std::size_t checked_size_cast( + const std::uint64_t value, + const std::filesystem::path& file_path, + const std::string_view field_name) { + if (value > std::numeric_limits::max()) { + throw_quantized_io_error( + file_path, + "字段 \"" + std::string{field_name} + + "\" 超出当前 host 的 size_t 表示范围。"); + } + + return static_cast(value); +} + +/** + * @brief 检查 uint64 offset 能否用于 `std::istream::seekg`。 + * + * @param offset 文件内目标 offset。 + * @param file_path 当前文件路径,用于错误信息。 + * @param field_name 对应 header 字段名。 + * @return 可安全转换的 streamoff。 + */ +[[nodiscard]] std::streamoff checked_streamoff_cast( + const std::uint64_t offset, + const std::filesystem::path& file_path, + const std::string_view field_name) { + if (offset > static_cast( + std::numeric_limits::max())) { + throw_quantized_io_error( + file_path, + "字段 \"" + std::string{field_name} + + "\" 超出 std::streamoff 表示范围。"); + } + + return static_cast(offset); +} + +/** + * @brief 从二进制流读取指定数量字节。 + * + * @param input_stream 已打开的二进制输入流。 + * @param bytes 输出字节区。 + * @param file_path 当前文件路径,用于错误信息。 + * @throws QuantizedIoError 文件截断或 I/O 失败时抛出。 + */ +void read_exact(std::ifstream& input_stream, + const std::span bytes, + const std::filesystem::path& file_path) { + if (bytes.size() > + static_cast(std::numeric_limits::max())) { + throw_quantized_io_error(file_path, "读取长度超出 std::streamsize 范围。"); + } + + input_stream.read(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + if (!input_stream) { + throw_quantized_io_error(file_path, "文件截断或读取失败。"); + } +} + +/** + * @brief 向二进制流写入指定数量字节。 + * + * @param output_stream 已打开的二进制输出流。 + * @param bytes 输入字节区。 + * @param file_path 当前文件路径,用于错误信息。 + * @throws QuantizedIoError 写入失败时抛出。 + */ +void write_exact(std::ofstream& output_stream, + const std::span bytes, + const std::filesystem::path& file_path) { + if (bytes.size() > + static_cast(std::numeric_limits::max())) { + throw_quantized_io_error(file_path, "写入长度超出 std::streamsize 范围。"); + } + + output_stream.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + if (!output_stream) { + throw_quantized_io_error(file_path, "写入文件失败。"); + } +} + +/** + * @brief 移动输入流到已通过范围验证的 section 起点。 + * + * @param input_stream 已打开的二进制输入流。 + * @param offset section 的文件内起点。 + * @param file_path 当前文件路径,用于错误信息。 + * @param field_name 对应 header 字段名。 + * @throws QuantizedIoError 定位失败时抛出。 + */ +void seek_to(std::ifstream& input_stream, + const std::uint64_t offset, + const std::filesystem::path& file_path, + const std::string_view field_name) { + input_stream.clear(); + input_stream.seekg(checked_streamoff_cast(offset, file_path, field_name), + std::ios::beg); + if (!input_stream) { + throw_quantized_io_error( + file_path, + "无法定位到字段 \"" + std::string{field_name} + "\" 指定的 section。"); + } +} + +/** + * @brief 验证一个 section 是否完全落在文件范围内。 + * + * @param offset section 起始 offset。 + * @param bytes section 长度。 + * @param file_size 文件总长度。 + * @param file_path 当前文件路径,用于错误信息。 + * @param section_name section 的说明名称。 + */ +void validate_section_range( + const std::uint64_t offset, + const std::uint64_t bytes, + const std::uint64_t file_size, + const std::filesystem::path& file_path, + const std::string_view section_name) { + if (offset > file_size || bytes > file_size - offset) { + throw_quantized_io_error( + file_path, + std::string{section_name} + " section 超出文件范围。"); + } +} + +/** + * @brief 从 header 解析 QDWGT v1 的全局 scale 字段。 + * + * @param header 完整固定 header。 + * @return 与文件格式无关的原始 FP32 值。 + */ +[[nodiscard]] float load_float32_le( + const std::span header) noexcept { + return std::bit_cast( + load_unsigned_le(header, kGlobalScaleOffset)); +} + +/** + * @brief 向 header 写入一个 little-endian FP32 bit pattern。 + * + * @param header 完整固定 header。 + * @param value 要写入的 FP32 值。 + */ +void store_float32_le(std::span header, + const float value) noexcept { + store_unsigned_le( + header, kGlobalScaleOffset, std::bit_cast(value)); +} + +/** + * @brief 根据内存量化结果生成 QDWGT v1 的 canonical header。 + * + * @param tensor 已通过 `isConsistent()` 校验的量化结果。 + * @param payload_offset 将写入 header 的 payload 起点。 + * @param local_scale_offset 将写入 header 的 local scale 起点。 + * @return 完整 128 字节 little-endian header。 + */ +[[nodiscard]] std::array +make_quantized_header(const QuantizedTensor& tensor, + const std::uint64_t payload_offset, + const std::uint64_t local_scale_offset) { + std::array header{}; + const QuantizedTensorDesc& desc = tensor.desc; + const std::uint64_t element_count = *desc.elementCount(); + const std::uint64_t payload_bytes = *desc.expectedPayloadBytes(); + const std::uint64_t local_scale_count = *desc.expectedLocalScaleCount(); + + std::copy(kQuantizedMagic.begin(), kQuantizedMagic.end(), header.begin()); + store_unsigned_le(header, kVersionOffset, kQuantizedFileVersion); + store_unsigned_le( + header, kHeaderBytesOffset, + static_cast(kQuantizedHeaderBytes)); + + header[kByteOrderOffset] = kLittleEndianByteOrder; + header[kFormatOffset] = static_cast(desc.format); + header[kScaleModeOffset] = static_cast(desc.scale_mode); + header[kRoundingOffset] = static_cast(desc.rounding); + header[kSourceDTypeOffset] = static_cast(desc.source_desc.dtype); + header[kPayloadElementBitsOffset] = payload_element_bits(desc.format); + header[kLocalScaleTypeOffset] = + static_cast(local_scale_type(desc.format)); + header[kScaleLayoutOffset] = static_cast(desc.scale_layout); + + store_unsigned_le(header, kBlockSizeOffset, desc.block_size); + store_unsigned_le(header, kRowsOffset, desc.source_desc.num_rows); + store_unsigned_le(header, kColsOffset, desc.source_desc.num_cols); + store_unsigned_le(header, kElementCountOffset, element_count); + store_unsigned_le(header, kPayloadOffsetOffset, payload_offset); + store_unsigned_le(header, kPayloadBytesOffset, payload_bytes); + store_unsigned_le( + header, kLocalScaleOffsetOffset, local_scale_offset); + store_unsigned_le( + header, kLocalScaleCountOffset, local_scale_count); + store_unsigned_le( + header, kLocalScaleBytesOffset, local_scale_count); + + const bool is_nvfp4 = desc.format == QuantFormat::kNvfp4; + store_float32_le(header, is_nvfp4 ? *tensor.global_scale : 1.0F); + + const bool has_odd_element_count = element_count % 2U != 0U; + const std::uint32_t flags = is_nvfp4 && has_odd_element_count + ? kNvfp4TailNibbleZeroFlag + : 0U; + store_unsigned_le(header, kFlagsOffset, flags); + store_unsigned_le( + header, kStochasticSeedOffset, desc.stochastic_seed); + + return header; +} + +/** + * @brief 解析并验证 QDWGT v1 固定 header 的数值和格式组合。 + * + * section 的文件范围和相互关系依赖总文件长度,在本函数之后由 + * `validate_file_layout()` 完成校验。 + * + * @param header 固定 128 字节 header。 + * @param file_path 当前文件路径,用于错误信息。 + * @return 已通过字段语义校验的 header 元数据。 + */ +[[nodiscard]] ParsedQuantizedHeader parse_quantized_header( + const std::array& header, + const std::filesystem::path& file_path) { + const std::span bytes{header}; + + if (!std::equal(kQuantizedMagic.begin(), kQuantizedMagic.end(), bytes.begin())) { + throw_quantized_io_error(file_path, "magic 不匹配,不是 QDWGT 文件。"); + } + + if (load_unsigned_le(bytes, kVersionOffset) != + kQuantizedFileVersion) { + throw_quantized_io_error(file_path, "不支持的 QDWGT 文件版本。"); + } + + if (load_unsigned_le(bytes, kHeaderBytesOffset) != + kQuantizedHeaderBytes) { + throw_quantized_io_error(file_path, "header_bytes 必须为 128。"); + } + + if (bytes[kByteOrderOffset] != kLittleEndianByteOrder) { + throw_quantized_io_error(file_path, "文件不是 little-endian 编码。"); + } + + QuantizedTensorDesc desc{ + .source_desc = { + .num_rows = load_unsigned_le(bytes, kRowsOffset), + .num_cols = load_unsigned_le(bytes, kColsOffset), + .dtype = static_cast(bytes[kSourceDTypeOffset]), + }, + .format = static_cast(bytes[kFormatOffset]), + .scale_mode = static_cast(bytes[kScaleModeOffset]), + .rounding = static_cast(bytes[kRoundingOffset]), + .stochastic_seed = load_unsigned_le(bytes, kStochasticSeedOffset), + .block_size = load_unsigned_le(bytes, kBlockSizeOffset), + .scale_layout = static_cast(bytes[kScaleLayoutOffset]), + }; + + if (!is_valid_scale_mode(desc.format, desc.scale_mode)) { + throw_quantized_io_error( + file_path, + "NVFP4 QDWGT 仅允许 scale_mode = block;tensor mode 不是本项目支持的 " + "NVFP4 文件语义。"); + } + + if (!desc.isMetadataValid()) { + throw_quantized_io_error(file_path, + "header 的格式、shape 或量化元数据组合非法。"); + } + + if (bytes[kPayloadElementBitsOffset] != payload_element_bits(desc.format)) { + throw_quantized_io_error(file_path, + "payload_element_bits 与量化格式不一致。"); + } + + if (bytes[kLocalScaleTypeOffset] != + static_cast(local_scale_type(desc.format))) { + throw_quantized_io_error(file_path, + "local_scale_type 与量化格式不一致。"); + } + + const std::uint64_t stored_element_count = + load_unsigned_le(bytes, kElementCountOffset); + const std::uint64_t stored_payload_bytes = + load_unsigned_le(bytes, kPayloadBytesOffset); + const std::uint64_t stored_local_scale_count = + load_unsigned_le(bytes, kLocalScaleCountOffset); + const std::uint64_t stored_local_scale_bytes = + load_unsigned_le(bytes, kLocalScaleBytesOffset); + + if (stored_element_count != *desc.elementCount()) { + throw_quantized_io_error(file_path, + "element_count 与 num_rows * num_cols 不一致。"); + } + + if (stored_payload_bytes != *desc.expectedPayloadBytes()) { + throw_quantized_io_error(file_path, + "payload_bytes 与 shape/format 推导结果不一致。"); + } + + if (stored_local_scale_count != *desc.expectedLocalScaleCount() || + stored_local_scale_bytes != stored_local_scale_count) { + throw_quantized_io_error(file_path, + "local scale 的数量或字节数与 scale_mode 不一致。"); + } + + const std::uint32_t flags = load_unsigned_le(bytes, kFlagsOffset); + const bool is_nvfp4 = desc.format == QuantFormat::kNvfp4; + const bool has_odd_element_count = *desc.elementCount() % 2U != 0U; + const std::uint32_t expected_flags = is_nvfp4 && has_odd_element_count + ? kNvfp4TailNibbleZeroFlag + : 0U; + if (flags != expected_flags) { + throw_quantized_io_error(file_path, + "flags 与量化格式或尾 nibble 状态不一致。"); + } + + const float file_global_scale = load_float32_le(bytes); + std::optional global_scale{}; + if (is_nvfp4) { + if (!std::isfinite(file_global_scale) || file_global_scale <= 0.0F) { + throw_quantized_io_error(file_path, + "NVFP4 的 global_scale 必须为有限正 FP32 数。"); + } + global_scale = file_global_scale; + } else if (std::bit_cast(file_global_scale) != 0x3F800000U) { + throw_quantized_io_error(file_path, + "MXFP8 的 global_scale 必须为 1.0F。"); + } + + return ParsedQuantizedHeader{ + .desc = desc, + .payload_offset = load_unsigned_le(bytes, kPayloadOffsetOffset), + .payload_bytes = stored_payload_bytes, + .local_scale_offset = + load_unsigned_le(bytes, kLocalScaleOffsetOffset), + .local_scale_bytes = stored_local_scale_bytes, + .global_scale = global_scale, + }; +} + +/** + * @brief 验证 QDWGT v1 section 位置、对齐、顺序和完整文件长度。 + * + * @param parsed_header 已通过字段语义校验的 header。 + * @param file_size 实际文件字节数。 + * @param file_path 当前文件路径,用于错误信息。 + */ +void validate_file_layout(const ParsedQuantizedHeader& parsed_header, + const std::uint64_t file_size, + const std::filesystem::path& file_path) { + if (parsed_header.payload_offset < kQuantizedHeaderBytes) { + throw_quantized_io_error(file_path, + "payload_offset 不能位于固定 header 内部。"); + } + + if (parsed_header.local_scale_offset % kSectionAlignment != 0U) { + throw_quantized_io_error(file_path, + "local_scale_offset 必须满足 8 字节对齐。"); + } + + validate_section_range(parsed_header.payload_offset, + parsed_header.payload_bytes, + file_size, + file_path, + "payload"); + validate_section_range(parsed_header.local_scale_offset, + parsed_header.local_scale_bytes, + file_size, + file_path, + "local scale"); + + const std::uint64_t payload_end = checked_add(parsed_header.payload_offset, + parsed_header.payload_bytes, + file_path, + "payload section 终点"); + if (parsed_header.local_scale_offset < payload_end) { + throw_quantized_io_error(file_path, + "local scale section 与 payload section 重叠。"); + } + + const std::uint64_t local_scale_end = + checked_add(parsed_header.local_scale_offset, + parsed_header.local_scale_bytes, + file_path, + "local scale section 终点"); + if (local_scale_end != file_size) { + throw_quantized_io_error( + file_path, + "文件总长度必须恰好等于 local scale section 的终点。"); + } +} + +/** + * @brief 获取文件总长度,并检查它能用 QDWGT 的 uint64 offset 表示。 + * + * @param file_path 待检查的文件路径。 + * @return 文件总字节数。 + * @throws QuantizedIoError 无法查询长度或长度过大时抛出。 + */ +[[nodiscard]] std::uint64_t get_file_size(const std::filesystem::path& file_path) { + std::error_code error_code{}; + const std::uintmax_t file_size = std::filesystem::file_size(file_path, error_code); + if (error_code) { + throw_quantized_io_error(file_path, "无法获取文件长度。"); + } + + if (file_size > std::numeric_limits::max()) { + throw_quantized_io_error(file_path, "文件长度超出 QDWGT v1 的 uint64 范围。"); + } + + return static_cast(file_size); +} + +/** + * @brief 分配读取 section 所需的 byte vector,并检查 host 容量。 + * + * @param byte_count 需要分配的字节数。 + * @param file_path 当前文件路径,用于错误信息。 + * @param field_name 对应 header 字段名。 + * @return 长度恰为 `byte_count` 的零初始化 byte vector。 + */ +[[nodiscard]] std::vector make_byte_vector( + const std::uint64_t byte_count, + const std::filesystem::path& file_path, + const std::string_view field_name) { + const std::size_t byte_count_size = + checked_size_cast(byte_count, file_path, field_name); + if (byte_count_size > std::vector{}.max_size()) { + throw_quantized_io_error( + file_path, + "字段 \"" + std::string{field_name} + + "\" 超出 payload 容器容量。"); + } + + return std::vector(byte_count_size); +} + +} // namespace + +QuantizedIoError::QuantizedIoError(std::filesystem::path file_path, + std::string detail) + : std::runtime_error(make_error_message(file_path, detail)), + mFilePath(std::move(file_path)) {} + +const std::filesystem::path& QuantizedIoError::filePath() const noexcept { + return mFilePath; +} + +void write_quantized_tensor(const std::filesystem::path& output_path, + const QuantizedTensor& tensor) { + if (!tensor.isConsistent()) { + throw_quantized_io_error(output_path, + "待写入的 QuantizedTensor 不满足格式不变量。"); + } + + const std::uint64_t payload_bytes = *tensor.desc.expectedPayloadBytes(); + const std::uint64_t payload_end = checked_add(kCanonicalPayloadOffset, + payload_bytes, + output_path, + "payload section 终点"); + const std::uint64_t local_scale_offset = + align_up_to_section_boundary(payload_end, output_path); + static_cast(checked_add(local_scale_offset, + *tensor.desc.expectedLocalScaleCount(), + output_path, + "local scale section 终点")); + + const auto header = make_quantized_header(tensor, + kCanonicalPayloadOffset, + local_scale_offset); + const std::uint64_t padding_bytes = local_scale_offset - payload_end; + constexpr std::array kZeroPadding{}; + + std::ofstream output_stream{ + output_path, + std::ios::binary | std::ios::trunc, + }; + if (!output_stream.is_open()) { + throw_quantized_io_error(output_path, "无法创建输出文件。"); + } + + write_exact(output_stream, header, output_path); + write_exact(output_stream, tensor.payload, output_path); + write_exact(output_stream, + std::span{kZeroPadding}.first( + static_cast(padding_bytes)), + output_path); + write_exact(output_stream, tensor.local_scales, output_path); + + output_stream.flush(); + if (!output_stream) { + throw_quantized_io_error(output_path, "刷新输出文件失败。"); + } +} + +QuantizedTensor read_quantized_tensor(const std::filesystem::path& input_path) { + std::ifstream input_stream{input_path, std::ios::binary}; + if (!input_stream.is_open()) { + throw_quantized_io_error(input_path, "无法打开输入文件。"); + } + + std::array header{}; + read_exact(input_stream, header, input_path); + + const ParsedQuantizedHeader parsed_header = + parse_quantized_header(header, input_path); + validate_file_layout(parsed_header, get_file_size(input_path), input_path); + + std::vector payload = make_byte_vector( + parsed_header.payload_bytes, input_path, "payload_bytes"); + std::vector local_scales = make_byte_vector( + parsed_header.local_scale_bytes, input_path, "local_scale_bytes"); + + seek_to(input_stream, parsed_header.payload_offset, input_path, "payload_offset"); + read_exact(input_stream, payload, input_path); + seek_to(input_stream, + parsed_header.local_scale_offset, + input_path, + "local_scale_offset"); + read_exact(input_stream, local_scales, input_path); + + QuantizedTensor tensor{ + .desc = parsed_header.desc, + .payload = std::move(payload), + .local_scales = std::move(local_scales), + .global_scale = parsed_header.global_scale, + }; + if (!tensor.isConsistent()) { + throw_quantized_io_error( + input_path, + "payload、local scale 或 NVFP4 尾 nibble 不满足格式不变量。"); + } + + return tensor; +} + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/tensor_io.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/tensor_io.cpp" new file mode 100644 index 00000000..20f8fd31 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/io/tensor_io.cpp" @@ -0,0 +1,685 @@ +#include "quant_dequant/tensor_io.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace quant_dequant { +namespace { +static_assert(sizeof(float) == sizeof(std::uint32_t)); +static_assert(std::numeric_limits::is_iec559); + + +/** + * @brief QDTENSOR v1 的固定 header 字节数。 + */ +constexpr std::size_t kTensorHeaderBytes = 64U; + +/** + * @brief QDTENSOR v1 的固定 data offset。 + */ +constexpr std::uint64_t kTensorDataOffset = 64U; + +/** + * @brief QDTENSOR 文件 magic。 + */ +constexpr std::array kTensorMagic{ + 'Q', 'D', 'T', 'E', 'N', 'S', 'O', 'R', +}; + +/** + * @brief QDTENSOR 文件格式版本。 + */ +constexpr std::uint16_t kTensorFileVersion = 1U; + + +/** + * @brief QDTENSOR header 中各字段的固定 offset。 + */ +constexpr std::size_t kMagicOffset = 0U; +constexpr std::size_t kVersionOffset = 8U; +constexpr std::size_t kHeaderBytesOffset = 10U; +constexpr std::size_t kByteOrderOffset = 12U; +constexpr std::size_t kDTypeOffset = 13U; +constexpr std::size_t kTensorRoleOffset = 14U; +constexpr std::size_t kRowsOffset = 16U; +constexpr std::size_t kColsOffset = 24U; +constexpr std::size_t kElementCountOffset = 32U; +constexpr std::size_t kDataOffsetOffset = 40U; +constexpr std::size_t kDataBytesOffset = 48U; + +/** + * @brief 保存已通过基本 header 校验的 QDTENSOR 元数据。 + */ +struct ParsedTensorHeader { + TensorDesc desc{}; + TensorRole role{TensorRole::kInput}; + std::uint64_t data_offset{0U}; + std::uint64_t data_bytes{0U}; +}; +/** + * @brief 生成统一的 TensorIoError 文本。 + * + * @param file_path 发生错误的文件路径。 + * @param detail 具体错误说明。 + * @return 可传递给 std::runtime_error 的完整错误文本。 + */ +[[nodiscard]] std::string make_error_message( + const std::filesystem::path& file_path, + const std::string_view detail) { + return "张量文件 \"" + file_path.string() + "\":" + std::string{detail}; +} + +/** + * @brief 抛出带文件路径的 TensorIoError。 + * + * @param file_path 发生错误的文件路径。 + * @param detail 具体错误说明。 + */ +[[noreturn]] void throw_tensor_io_error( + const std::filesystem::path& file_path, + std::string detail) { + throw TensorIoError(file_path, std::move(detail)); +} +/** + * @brief 确认当前 host 使用 little-endian。 + * + * 第一版文件格式只支持 little-endian host,避免在浮点 payload 上隐式 + * 执行 byte swap。 + * + * @param file_path 当前操作的文件路径,用于错误信息。 + */ +void ensure_little_endian_host(const std::filesystem::path& file_path) { + if constexpr (std::endian::native != std::endian::little) { + throw_tensor_io_error(file_path, + "当前 host 不是 little-endian,QDTENSOR v1 暂不支持。"); + } +} +/** + * @brief 从 little-endian 字节区读取无符号整数。 + * + * @tparam UInt 目标无符号整数类型。 + * @param bytes 输入字节区。 + * @param offset 整数起始 offset。 + * @return 解码后的无符号整数。 + */ +template +[[nodiscard]] UInt load_unsigned_le( + const std::span bytes, + const std::size_t offset) noexcept { + static_assert(std::is_unsigned_v); + + UInt result{0U}; + + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + result |= static_cast(bytes[offset + index]) + << static_cast(index * 8U); + } + + return result; +} + + +/** + * @brief 向 little-endian 字节区写入无符号整数。 + * + * @tparam UInt 源无符号整数类型。 + * @param bytes 输出字节区。 + * @param offset 整数起始 offset。 + * @param value 要编码的整数。 + */ +template +void store_unsigned_le(std::span bytes, + const std::size_t offset, + const UInt value) noexcept { + static_assert(std::is_unsigned_v); + + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + bytes[offset + index] = static_cast( + value >> static_cast(index * 8U)); + } +} +/** + * @brief 使用 CUDA 官方接口将 FP16 bit pattern 转换为 FP32。 + * + * @param half_bits little-endian 解码后的 FP16 bit pattern。 + * @return `__half2float` 产生的 FP32 数值。 + */ +[[nodiscard]] float half_bits_to_float(const std::uint16_t half_bits) noexcept { + const __half_raw raw_half{.x = half_bits}; + return __half2float(__half{raw_half}); +} + +/** + * @brief 使用 CUDA 官方 RNE 接口将 FP32 转换为 FP16 bit pattern。 + * + * @param value 要转换的 FP32 数值。 + * @return `__float2half_rn` 产生的 FP16 bit pattern。 + */ +[[nodiscard]] std::uint16_t float_to_half_bits(const float value) noexcept { + const __half half_value = __float2half_rn(value); + return static_cast<__half_raw>(half_value).x; +} + +/** + * @brief 使用 CUDA 官方接口将 BF16 bit pattern 转换为 FP32。 + * + * @param bfloat16_bits little-endian 解码后的 BF16 bit pattern。 + * @return `__bfloat162float` 产生的 FP32 数值。 + */ +[[nodiscard]] float bfloat16_bits_to_float( + const std::uint16_t bfloat16_bits) noexcept { + const __nv_bfloat16_raw raw_bfloat16{.x = bfloat16_bits}; + return __bfloat162float(__nv_bfloat16{raw_bfloat16}); +} + +/** + * @brief 使用 CUDA 官方 RNE 接口将 FP32 转换为 BF16 bit pattern。 + * + * @param value 要转换的 FP32 数值。 + * @return `__float2bfloat16_rn` 产生的 BF16 bit pattern。 + */ +[[nodiscard]] std::uint16_t float_to_bfloat16_bits( + const float value) noexcept { + const __nv_bfloat16 bfloat16_value = __float2bfloat16_rn(value); + return static_cast<__nv_bfloat16_raw>(bfloat16_value).x; +} + +/** + * @brief 检查 uint64 数值能否安全转换为 host 容器使用的 size_t。 + * + * @param value 待转换数值。 + * @param file_path 当前文件路径,用于错误信息。 + * @param field_name 对应 header 字段名。 + * @return 可安全用于 vector 大小和 stream 操作的 size_t。 + */ +[[nodiscard]] std::size_t checked_size_cast( + const std::uint64_t value, + const std::filesystem::path& file_path, + const std::string_view field_name) { + if (value > std::numeric_limits::max()) { + throw_tensor_io_error( + file_path, + "字段 \"" + std::string{field_name} + + "\" 超出当前 host 的 size_t 表示范围。"); + } + + return static_cast(value); +} + +/** + * @brief 从二进制流读取指定数量字节。 + * + * @param input_stream 已打开的二进制输入流。 + * @param bytes 输出字节区。 + * @param file_path 当前文件路径,用于错误信息。 + * @throws TensorIoError 文件截断或 I/O 失败时抛出。 + */ +void read_exact(std::ifstream& input_stream, + const std::span bytes, + const std::filesystem::path& file_path) { + if (bytes.size() > + static_cast(std::numeric_limits::max())) { + throw_tensor_io_error(file_path, "读取长度超出 std::streamsize 范围。"); + } + + input_stream.read(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + + if (!input_stream) { + throw_tensor_io_error(file_path, "文件截断或读取失败。"); + } +} + +/** + * @brief 向二进制流写入指定数量字节。 + * + * @param output_stream 已打开的二进制输出流。 + * @param bytes 输入字节区。 + * @param file_path 当前文件路径,用于错误信息。 + * @throws TensorIoError 写入失败时抛出。 + */ +void write_exact(std::ofstream& output_stream, + const std::span bytes, + const std::filesystem::path& file_path) { + if (bytes.size() > + static_cast(std::numeric_limits::max())) { + throw_tensor_io_error(file_path, "写入长度超出 std::streamsize 范围。"); + } + + output_stream.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + + if (!output_stream) { + throw_tensor_io_error(file_path, "写入文件失败。"); + } +} + +/** + * @brief 解析并验证 QDTENSOR v1 固定 header。 + * + * @param header 固定 64 字节 header。 + * @param file_path 当前文件路径,用于错误信息。 + * @return 已通过 header 语义校验的元数据。 + */ +[[nodiscard]] ParsedTensorHeader parse_tensor_header( + const std::array& header, + const std::filesystem::path& file_path) { + const std::span bytes{header}; + + if (!std::equal(kTensorMagic.begin(), kTensorMagic.end(), + bytes.begin() + static_cast(kMagicOffset))) { + throw_tensor_io_error(file_path, "magic 不匹配,不是 QDTENSOR 文件。"); + } + + if (load_unsigned_le(bytes, kVersionOffset) != + kTensorFileVersion) { + throw_tensor_io_error(file_path, "不支持的 QDTENSOR 文件版本。"); + } + + if (load_unsigned_le(bytes, kHeaderBytesOffset) != + kTensorHeaderBytes) { + throw_tensor_io_error(file_path, "header_bytes 必须为 64。"); + } + + if (bytes[kByteOrderOffset] != kLittleEndianByteOrder) { + throw_tensor_io_error(file_path, "文件不是 little-endian 编码。"); + } + + const DType dtype = static_cast(bytes[kDTypeOffset]); + if (dtype != DType::kFloat16 && dtype != DType::kBFloat16 && + dtype != DType::kFloat32) { + throw_tensor_io_error(file_path, "header 包含不支持的 dtype。"); + } + + const TensorRole role = static_cast(bytes[kTensorRoleOffset]); + if (role != TensorRole::kInput && + role != TensorRole::kDequantizedOutput) { + throw_tensor_io_error(file_path, "header 包含不支持的 tensor_role。"); + } + + TensorDesc desc{ + .num_rows = load_unsigned_le(bytes, kRowsOffset), + .num_cols = load_unsigned_le(bytes, kColsOffset), + .dtype = dtype, + }; + + const auto expected_element_count = desc.elementCount(); + const auto expected_data_bytes = desc.dataBytes(); + + if (!expected_element_count.has_value() || + !expected_data_bytes.has_value()) { + throw_tensor_io_error(file_path, "矩阵形状、dtype 或 payload 大小非法。"); + } + + const std::uint64_t stored_element_count = + load_unsigned_le(bytes, kElementCountOffset); + const std::uint64_t stored_data_offset = + load_unsigned_le(bytes, kDataOffsetOffset); + const std::uint64_t stored_data_bytes = + load_unsigned_le(bytes, kDataBytesOffset); + + if (stored_element_count != *expected_element_count) { + throw_tensor_io_error(file_path, + "element_count 与 num_rows * num_cols 不一致。"); + } + + if (stored_data_offset != kTensorDataOffset) { + throw_tensor_io_error(file_path, + "QDTENSOR v1 的 data_offset 必须为 64。"); + } + + if (stored_data_bytes != *expected_data_bytes) { + throw_tensor_io_error(file_path, + "data_bytes 与 shape/dtype 推导结果不一致。"); + } + + return ParsedTensorHeader{ + .desc = desc, + .role = role, + .data_offset = stored_data_offset, + .data_bytes = stored_data_bytes, + }; +} + +/** + * @brief 验证文件总长度恰好等于 QDTENSOR v1 header 与 payload 之和。 + * + * @param file_path 待检查的文件路径。 + * @param data_bytes header 中已验证的 payload 字节数。 + */ +void validate_file_size(const std::filesystem::path& file_path, + const std::uint64_t data_bytes) { + if (data_bytes > + std::numeric_limits::max() - kTensorDataOffset) { + throw_tensor_io_error(file_path, "data_bytes 导致文件长度溢出。"); + } + + std::error_code error_code{}; + const std::uintmax_t file_size = + std::filesystem::file_size(file_path, error_code); + + if (error_code) { + throw_tensor_io_error(file_path, "无法获取文件长度。"); + } + + const std::uint64_t expected_file_size = + kTensorDataOffset + data_bytes; + + if (file_size != static_cast(expected_file_size)) { + throw_tensor_io_error( + file_path, + "文件总长度与 header 声明的 data_bytes 不一致。"); + } +} + +/** + * @brief 将普通张量 payload 解码为连续 FP32 数组。 + * + * @param desc 已验证的张量描述。 + * @param payload little-endian row-major payload。 + * @param file_path 当前文件路径,用于错误信息。 + * @return 解码后的 FP32 元素。 + */ +[[nodiscard]] std::vector decode_tensor_payload( + const TensorDesc& desc, + const std::span payload, + const std::filesystem::path& file_path) { + const auto element_count = desc.elementCount(); + if (!element_count.has_value()) { + throw_tensor_io_error(file_path, "非法张量形状。"); + } + + const std::size_t element_count_size = + checked_size_cast(*element_count, file_path, "element_count"); + + if (element_count_size > std::vector{}.max_size()) { + throw_tensor_io_error(file_path, "element_count 超出 FP32 容器容量。"); + } + + std::vector values(element_count_size); + + for (std::size_t index = 0U; index < element_count_size; ++index) { + switch (desc.dtype) { + case DType::kFloat16: { + const std::uint16_t bits = + load_unsigned_le(payload, index * 2U); + values[index] = half_bits_to_float(bits); + break; + } + + case DType::kBFloat16: { + const std::uint16_t bits = + load_unsigned_le(payload, index * 2U); + values[index] = bfloat16_bits_to_float(bits); + break; + } + + case DType::kFloat32: { + const std::uint32_t bits = + load_unsigned_le(payload, index * 4U); + values[index] = std::bit_cast(bits); + break; + } + + case DType::kUnknown: + throw_tensor_io_error(file_path, "不支持的 dtype。"); + } + } + + return values; +} + +/** + * @brief 将 host FP32 数组编码成目标普通张量 payload。 + * + * @param output_desc 输出张量的形状和物理 dtype。 + * @param values 输入 FP32 数组,按 row-major 排列。 + * @param file_path 当前文件路径,用于错误信息。 + * @return little-endian 编码后的连续 payload。 + */ +[[nodiscard]] std::vector encode_tensor_payload( + const TensorDesc& output_desc, + const std::span values, + const std::filesystem::path& file_path) { + const auto element_count = output_desc.elementCount(); + const auto data_bytes = output_desc.dataBytes(); + + if (!element_count.has_value() || !data_bytes.has_value()) { + throw_tensor_io_error(file_path, "输出张量描述非法。"); + } + + const std::size_t element_count_size = + checked_size_cast(*element_count, file_path, "element_count"); + const std::size_t data_bytes_size = + checked_size_cast(*data_bytes, file_path, "data_bytes"); + + if (values.size() != element_count_size) { + throw_tensor_io_error(file_path, + "values 长度与输出张量元素数不一致。"); + } + + if (data_bytes_size > std::vector{}.max_size()) { + throw_tensor_io_error(file_path, "data_bytes 超出 payload 容器容量。"); + } + + std::vector payload(data_bytes_size); + + for (std::size_t index = 0U; index < element_count_size; ++index) { + switch (output_desc.dtype) { + case DType::kFloat16: + store_unsigned_le( + payload, index * 2U, float_to_half_bits(values[index])); + break; + + case DType::kBFloat16: + store_unsigned_le( + payload, index * 2U, float_to_bfloat16_bits(values[index])); + break; + + case DType::kFloat32: + store_unsigned_le( + payload, index * 4U, + std::bit_cast(values[index])); + break; + + case DType::kUnknown: + throw_tensor_io_error(file_path, "不支持的输出 dtype。"); + } + } + + return payload; +} + +/** + * @brief 生成 QDTENSOR v1 的反量化输出 header。 + * + * @param output_desc 输出形状与物理 dtype。 + * @param data_bytes 已验证的 payload 字节数。 + * @return 完整 64 字节 little-endian header。 + */ +[[nodiscard]] std::array +make_output_header(const TensorDesc& output_desc, + const std::uint64_t data_bytes) { + std::array header{}; + + std::copy(kTensorMagic.begin(), kTensorMagic.end(), + header.begin() + static_cast(kMagicOffset)); + + store_unsigned_le( + header, kVersionOffset, kTensorFileVersion); + store_unsigned_le( + header, kHeaderBytesOffset, + static_cast(kTensorHeaderBytes)); + + header[kByteOrderOffset] = kLittleEndianByteOrder; + header[kDTypeOffset] = static_cast(output_desc.dtype); + header[kTensorRoleOffset] = + static_cast(TensorRole::kDequantizedOutput); + + const std::uint64_t element_count = *output_desc.elementCount(); + + store_unsigned_le( + header, kRowsOffset, output_desc.num_rows); + store_unsigned_le( + header, kColsOffset, output_desc.num_cols); + store_unsigned_le( + header, kElementCountOffset, element_count); + store_unsigned_le( + header, kDataOffsetOffset, kTensorDataOffset); + store_unsigned_le( + header, kDataBytesOffset, data_bytes); + + return header; +} + +} // namespace + +TensorIoError::TensorIoError(std::filesystem::path file_path, + std::string detail) + : std::runtime_error(make_error_message(file_path, detail)), + mFilePath(std::move(file_path)) {} + +const std::filesystem::path& TensorIoError::filePath() const noexcept { + return mFilePath; +} + +namespace { + +/** + * @brief 读取指定用途、指定 dtype 范围的 QDTENSOR,并将 payload 解码为 FP32。 + * + * 输入文件与反量化输出文件的二进制 header/payload 规则完全相同,区别只在 + * `tensor_role` 与允许的 dtype。本内部函数集中完成一次严格读取,两个 public + * 入口只各自声明它们允许的角色和 dtype,避免格式校验逻辑分叉。 + * + * @param tensor_path 待读取的 QDTENSOR 文件。 + * @param expected_role 调用方所要求的 `TensorRole`。 + * @param require_input_dtype true 时仅接受 FP16/FP32;false 时接受 FP16/BF16/FP32。 + * @param function_name 用于说明哪个 public 入口拒绝了 role。 + * @return 保留物理 dtype、values 统一为 FP32 的 host 张量。 + * @throws TensorIoError 文件、角色、dtype、header 或 payload 不合法时抛出。 + */ +[[nodiscard]] HostTensor read_tensor_with_role( + const std::filesystem::path& tensor_path, + const TensorRole expected_role, + const bool require_input_dtype, + const std::string_view function_name) { + ensure_little_endian_host(tensor_path); + + std::ifstream input_stream{tensor_path, std::ios::binary}; + if (!input_stream.is_open()) { + throw_tensor_io_error(tensor_path, "无法打开输入文件。"); + } + + std::array header{}; + read_exact(input_stream, header, tensor_path); + + const ParsedTensorHeader parsed_header = + parse_tensor_header(header, tensor_path); + + if (parsed_header.role != expected_role) { + throw_tensor_io_error( + tensor_path, + std::string{function_name} + " 接收的 tensor_role 与文件不匹配。"); + } + + const bool valid_dtype = require_input_dtype + ? is_supported_input_dtype(parsed_header.desc.dtype) + : is_supported_output_dtype(parsed_header.desc.dtype); + if (!valid_dtype) { + throw_tensor_io_error( + tensor_path, + require_input_dtype + ? "输入张量 dtype 只能是 FP16 或 FP32。" + : "反量化输出 dtype 只能是 FP16、BF16 或 FP32。"); + } + + validate_file_size(tensor_path, parsed_header.data_bytes); + + const std::size_t payload_size = + checked_size_cast(parsed_header.data_bytes, tensor_path, "data_bytes"); + + if (payload_size > std::vector{}.max_size()) { + throw_tensor_io_error(tensor_path, "data_bytes 超出 payload 容器容量。"); + } + + std::vector payload(payload_size); + read_exact(input_stream, payload, tensor_path); + + return HostTensor{ + .desc = parsed_header.desc, + .values = decode_tensor_payload(parsed_header.desc, payload, tensor_path), + }; +} + +} // namespace + +HostTensor read_input_tensor(const std::filesystem::path& input_path) { + return read_tensor_with_role( + input_path, TensorRole::kInput, true, "read_input_tensor()"); +} + +HostTensor read_dequantized_tensor(const std::filesystem::path& output_path) { + return read_tensor_with_role( + output_path, TensorRole::kDequantizedOutput, false, + "read_dequantized_tensor()"); +} + +void write_dequantized_tensor( + const std::filesystem::path& output_path, + const HostTensor& tensor) { + ensure_little_endian_host(output_path); + + const TensorDesc& output_desc = tensor.desc; + + if (!output_desc.isValid()) { + throw_tensor_io_error(output_path, "输出张量描述非法。"); + } + + if (!is_supported_output_dtype(output_desc.dtype)) { + throw_tensor_io_error( + output_path, + "反量化输出 dtype 只能是 FP16、BF16 或 FP32。"); + } + + const auto data_bytes = output_desc.dataBytes(); + if (!data_bytes.has_value()) { + throw_tensor_io_error(output_path, "无法计算输出 payload 字节数。"); + } + + const std::vector payload = + encode_tensor_payload(output_desc, tensor.values, output_path); + const auto header = make_output_header(output_desc, *data_bytes); + + std::ofstream output_stream{ + output_path, + std::ios::binary | std::ios::trunc, + }; + + if (!output_stream.is_open()) { + throw_tensor_io_error(output_path, "无法创建输出文件。"); + } + + write_exact(output_stream, header, output_path); + write_exact(output_stream, payload, output_path); + + output_stream.flush(); + if (!output_stream) { + throw_tensor_io_error(output_path, "刷新输出文件失败。"); + } +} + +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/metrics/metrics.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/metrics/metrics.cpp" new file mode 100644 index 00000000..500cc1f4 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/metrics/metrics.cpp" @@ -0,0 +1,505 @@ +#include "quant_dequant/metrics.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace quant_dequant { +namespace { + +/** NVFP4 的单个 FP32 global scale 在逻辑布局中的字节数。 */ +constexpr std::uint64_t kNvfp4GlobalScaleBytes = sizeof(float); + +/** 十进制 GB/s 中 1 GB 对应的字节数量。 */ +constexpr double kBytesPerMillisecondToGigabytesPerSecond = 1.0e6; + +/** + * @brief 返回一种物理 dtype 的单元素字节数。 + * + * @param dtype 要查询的 QDTENSOR 物理类型。 + * @param require_input_dtype true 时只接受 FP16/FP32;false 时接受 FP16/BF16/FP32。 + * @return dtype 的物理字节宽度。 + * @throws MetricsError dtype 不属于所请求的输入或输出范围时抛出。 + */ +[[nodiscard]] std::uint64_t dtype_byte_width(const DType dtype, + const bool require_input_dtype) { + if (require_input_dtype && !is_supported_input_dtype(dtype)) { + throw MetricsError{"指标计算要求 FP16/FP32 输入 dtype。"}; + } + if (!require_input_dtype && !is_supported_output_dtype(dtype)) { + throw MetricsError{"指标计算要求 FP16/BF16/FP32 输出 dtype。"}; + } + + switch (dtype) { + case DType::kFloat16: + case DType::kBFloat16: + return 2U; + case DType::kFloat32: + return 4U; + case DType::kUnknown: + break; + } + + throw MetricsError{"指标计算遇到了未知 dtype。"}; +} + +/** + * @brief 安全计算元素数和每元素字节数的乘积。 + * + * @param element_count 已验证的非零元素数量。 + * @param byte_width 单元素物理字节数。 + * @param field_name 用于异常诊断的结果字段名。 + * @return 未发生 uint64 溢出的总字节数。 + * @throws MetricsError 乘法无法在 uint64 中表示时抛出。 + */ +[[nodiscard]] std::uint64_t checked_byte_product( + const std::uint64_t element_count, + const std::uint64_t byte_width, + const std::string_view field_name) { + constexpr std::uint64_t kMaxValue = std::numeric_limits::max(); + if (element_count == 0U || byte_width == 0U || + element_count > kMaxValue / byte_width) { + throw MetricsError{ + "无法推导 \"" + std::string{field_name} + "\" 的合法字节数。"}; + } + + return element_count * byte_width; +} + +/** + * @brief 对多个 uint64 字节字段执行不溢出的求和。 + * + * @param first 求和初值。 + * @param second 要加入的第二项。 + * @param field_name 用于异常诊断的总和字段名。 + * @return first + second。 + * @throws MetricsError 加法溢出时抛出。 + */ +[[nodiscard]] std::uint64_t checked_add(const std::uint64_t first, + const std::uint64_t second, + const std::string_view field_name) { + constexpr std::uint64_t kMaxValue = std::numeric_limits::max(); + if (first > kMaxValue - second) { + throw MetricsError{ + "\"" + std::string{field_name} + "\" 的字节数加法溢出。"}; + } + + return first + second; +} + +/** + * @brief 把字符串安全写为 JSON 字符串内容,不写两侧双引号。 + * + * @param value 待转义的 UTF-8 字节串。 + * @return 可嵌入 JSON 双引号中的 ASCII 转义文本。 + */ +[[nodiscard]] std::string escape_json_string(const std::string_view value) { + std::ostringstream stream{}; + for (const unsigned char character : value) { + switch (character) { + case '"': + stream << "\\\""; + break; + case '\\': + stream << "\\\\"; + break; + case '\b': + stream << "\\b"; + break; + case '\f': + stream << "\\f"; + break; + case '\n': + stream << "\\n"; + break; + case '\r': + stream << "\\r"; + break; + case '\t': + stream << "\\t"; + break; + default: + if (character < 0x20U) { + stream << "\\u00" << std::hex << std::setw(2) + << std::setfill('0') + << static_cast(character) << std::dec + << std::setfill(' '); + } else { + stream << static_cast(character); + } + break; + } + } + + return stream.str(); +} + +/** + * @brief 向 JSON 输出流写入一个 optional double 字段。 + * + * @param stream 已打开的 JSON 文本输出流。 + * @param value 待写值;无值时写 JSON null。 + */ +void write_optional_json_number(std::ostringstream& stream, + const std::optional& value) { + if (value.has_value()) { + stream << *value; + } else { + stream << "null"; + } +} + +/** + * @brief 验证 RunReport 中的配置、数值和派生字段彼此一致。 + * + * @param report 待写入 JSON 的完整报告。 + * @throws MetricsError 任一基础元数据、误差、压缩率或可选性能字段不合法时抛出。 + */ +void validate_run_report(const RunReport& report) { + if (!report.input_desc.isValid() || + !is_supported_input_dtype(report.input_desc.dtype) || + !report.quantization.isValid() || !report.dequantization.isValid() || + report.target_gpu.empty()) { + throw MetricsError{"运行报告的输入描述、配置或 target_gpu 不合法。"}; + } + + const auto input_payload_bytes = compute_input_payload_bytes(report.input_desc); + const auto logical_quantized_bytes = report.compression.logical_quantized_bytes; + if (report.artifacts.input_payload_bytes != input_payload_bytes || + report.artifacts.payload_bytes == 0U || + report.artifacts.local_scale_bytes == 0U || + report.artifacts.quantized_file_bytes == 0U || + logical_quantized_bytes == 0U || + !std::isfinite(report.error.max_abs) || + !std::isfinite(report.error.mae) || !std::isfinite(report.error.mse) || + report.error.max_abs < 0.0 || report.error.mae < 0.0 || report.error.mse < 0.0 || + !std::isfinite(report.compression.logical_compression_ratio) || + !std::isfinite(report.compression.on_disk_compression_ratio) || + report.compression.logical_compression_ratio <= 0.0 || + report.compression.on_disk_compression_ratio <= 0.0) { + throw MetricsError{"运行报告包含不合法的指标或字节统计。"}; + } + + const bool has_quant_timing = report.performance.quant_kernel_ms.has_value(); + const bool has_quant_bandwidth = + report.performance.quant_effective_bandwidth_gbps.has_value(); + const bool has_dequant_timing = report.performance.dequant_kernel_ms.has_value(); + const bool has_dequant_bandwidth = + report.performance.dequant_effective_bandwidth_gbps.has_value(); + if ((!has_quant_timing && has_quant_bandwidth) || + (!has_dequant_timing && has_dequant_bandwidth)) { + throw MetricsError{ + "有效带宽存在时必须同时提供对应的 kernel 时间。"}; + } + + for (const std::optional& value : { + report.performance.quant_kernel_ms, + report.performance.dequant_kernel_ms, + }) { + if (value.has_value() && (!std::isfinite(*value) || *value < 0.0)) { + throw MetricsError{"运行报告的 CUDA kernel 时间必须为有限非负数。"}; + } + } + + for (const std::optional& value : { + report.performance.quant_effective_bandwidth_gbps, + report.performance.dequant_effective_bandwidth_gbps, + }) { + if (value.has_value() && (!std::isfinite(*value) || *value <= 0.0)) { + throw MetricsError{"运行报告的 CUDA 时间或有效带宽必须为有限正数。"}; + } + } +} + +} // namespace + +MetricsError::MetricsError(std::string detail) + : std::runtime_error(std::move(detail)) {} + +ErrorMetrics compute_error_metrics(const std::span reference, + const std::span actual) { + if (reference.empty() || actual.empty() || reference.size() != actual.size()) { + throw MetricsError{"误差统计要求两个非空且等长的 FP32 数组。"}; + } + + double max_abs = 0.0; + double absolute_sum = 0.0; + double squared_sum = 0.0; + for (std::size_t index = 0U; index < reference.size(); ++index) { + const float reference_value = reference[index]; + const float actual_value = actual[index]; + if (!std::isfinite(reference_value) || !std::isfinite(actual_value)) { + throw MetricsError{ + "误差统计不接受 NaN 或 Inf;发现线性下标为 " + + std::to_string(index) + " 的非有限值。"}; + } + + const double difference = static_cast(reference_value) - + static_cast(actual_value); + const double absolute_difference = std::abs(difference); + max_abs = max_abs > absolute_difference ? max_abs : absolute_difference; + absolute_sum += absolute_difference; + squared_sum += difference * difference; + } + + const double count = static_cast(reference.size()); + return { + .max_abs = max_abs, + .mae = absolute_sum / count, + .mse = squared_sum / count, + }; +} + +std::uint64_t compute_input_payload_bytes(const TensorDesc& input_desc) { + const auto element_count = input_desc.elementCount(); + if (!input_desc.isValid() || !element_count.has_value()) { + throw MetricsError{"无法从非法输入 TensorDesc 推导 payload 字节数。"}; + } + + return checked_byte_product( + *element_count, dtype_byte_width(input_desc.dtype, true), "输入 payload"); +} + +std::uint64_t compute_dequantized_payload_bytes(const TensorDesc& output_desc) { + const auto element_count = output_desc.elementCount(); + if (!output_desc.isValid() || !element_count.has_value()) { + throw MetricsError{"无法从非法输出 TensorDesc 推导 payload 字节数。"}; + } + + return checked_byte_product( + *element_count, dtype_byte_width(output_desc.dtype, false), "反量化输出 payload"); +} + +std::uint64_t compute_logical_quantized_bytes(const QuantizedTensor& quantized) { + if (!quantized.isConsistent()) { + throw MetricsError{"逻辑量化字节统计要求自洽的 QuantizedTensor。"}; + } + + const std::uint64_t payload_bytes = + static_cast(quantized.payload.size()); + const std::uint64_t local_scale_bytes = + static_cast(quantized.local_scales.size()); + const std::uint64_t global_scale_bytes = quantized.desc.usesGlobalScale() + ? kNvfp4GlobalScaleBytes + : 0U; + return checked_add( + checked_add(payload_bytes, local_scale_bytes, "逻辑量化字节数"), + global_scale_bytes, + "逻辑量化字节数"); +} + +ArtifactMetrics make_artifact_metrics( + const TensorDesc& input_desc, + const QuantizedTensor& quantized, + const TensorDesc& dequantized_output_desc, + const std::uint64_t quantized_file_bytes, + const std::uint64_t dequantized_file_bytes) { + if (!quantized.isConsistent()) { + throw MetricsError{"产物统计要求自洽的 QuantizedTensor。"}; + } + + if (input_desc.num_rows != quantized.desc.source_desc.num_rows || + input_desc.num_cols != quantized.desc.source_desc.num_cols || + input_desc.dtype != quantized.desc.source_desc.dtype || + dequantized_output_desc.num_rows != input_desc.num_rows || + dequantized_output_desc.num_cols != input_desc.num_cols || + !is_supported_output_dtype(dequantized_output_desc.dtype) || + quantized_file_bytes == 0U || dequantized_file_bytes == 0U) { + throw MetricsError{"产物统计的输入、量化、输出描述或文件大小不匹配。"}; + } + + return { + .input_payload_bytes = compute_input_payload_bytes(input_desc), + .quantized_file_bytes = quantized_file_bytes, + .dequantized_file_bytes = dequantized_file_bytes, + .payload_bytes = static_cast(quantized.payload.size()), + .local_scale_bytes = + static_cast(quantized.local_scales.size()), + .global_scale_bytes = quantized.desc.usesGlobalScale() + ? kNvfp4GlobalScaleBytes + : 0U, + }; +} + +CompressionMetrics compute_compression_metrics(const ArtifactMetrics& artifacts) { + const std::uint64_t logical_quantized_bytes = checked_add( + checked_add(artifacts.payload_bytes, + artifacts.local_scale_bytes, + "逻辑量化字节数"), + artifacts.global_scale_bytes, + "逻辑量化字节数"); + if (artifacts.input_payload_bytes == 0U || logical_quantized_bytes == 0U || + artifacts.quantized_file_bytes == 0U) { + throw MetricsError{"压缩率要求输入、逻辑量化与 QDWGT 文件字节数均非零。"}; + } + + return { + .logical_quantized_bytes = logical_quantized_bytes, + .logical_compression_ratio = + static_cast(artifacts.input_payload_bytes) / + static_cast(logical_quantized_bytes), + .on_disk_compression_ratio = + static_cast(artifacts.input_payload_bytes) / + static_cast(artifacts.quantized_file_bytes), + }; +} + +double compute_effective_bandwidth_gbps(const std::uint64_t logical_bytes, + const double kernel_ms) { + if (logical_bytes == 0U || !std::isfinite(kernel_ms) || kernel_ms <= 0.0) { + throw MetricsError{ + "有效带宽要求非零逻辑字节数及有限正的 kernel 时间。"}; + } + + return static_cast(logical_bytes) / + (kernel_ms * kBytesPerMillisecondToGigabytesPerSecond); +} + +KernelPerformance make_kernel_performance( + const ArtifactMetrics& artifacts, + const QuantizedTensor& quantized, + const TensorDesc& dequantized_output_desc, + const std::optional quant_kernel_ms, + const std::optional dequant_kernel_ms) { + const std::uint64_t logical_quantized_bytes = + compute_logical_quantized_bytes(quantized); + if (artifacts.input_payload_bytes == 0U) { + throw MetricsError{ + "性能指标要求非零的输入 payload 字节统计。"}; + } + + // 反量化 kernel 的逻辑写入量是 QDTENSOR payload,而不是包含 64-byte header + // 的实际文件长度。显式接收输出描述可避免把固定 header 大小等文件格式细节 + // 泄漏到 kernel 指标公式中。 + const std::uint64_t dequantized_payload_bytes = + compute_dequantized_payload_bytes(dequantized_output_desc); + const std::uint64_t quant_logical_bytes = checked_add( + artifacts.input_payload_bytes, + logical_quantized_bytes, + "量化 kernel 逻辑读写字节数"); + const std::uint64_t dequant_logical_bytes = checked_add( + logical_quantized_bytes, + dequantized_payload_bytes, + "反量化 kernel 逻辑读写字节数"); + + const auto make_optional_bandwidth = []( + const std::uint64_t logical_bytes, + const std::optional kernel_ms) + -> std::optional { + if (!kernel_ms.has_value()) { + return std::nullopt; + } + if (!std::isfinite(*kernel_ms) || *kernel_ms < 0.0) { + throw MetricsError{"CUDA kernel 时间必须为有限非负数。"}; + } + if (*kernel_ms == 0.0) { + // CUDA Event 的有限分辨率可能把极短工作量表示为 0 ms。保留时间, + // 但不把有限逻辑字节除以 0 伪造成无限带宽。 + return std::nullopt; + } + return compute_effective_bandwidth_gbps(logical_bytes, *kernel_ms); + }; + + return { + .quant_kernel_ms = quant_kernel_ms, + .dequant_kernel_ms = dequant_kernel_ms, + .quant_effective_bandwidth_gbps = + make_optional_bandwidth(quant_logical_bytes, quant_kernel_ms), + .dequant_effective_bandwidth_gbps = + make_optional_bandwidth(dequant_logical_bytes, dequant_kernel_ms), + }; +} + +std::string serialize_run_report_json(const RunReport& report) { + validate_run_report(report); + + std::ostringstream stream{}; + stream << std::setprecision(std::numeric_limits::max_digits10); + stream << "{\n" + << " \"schema_version\": 1,\n" + << " \"input\": {\n" + << " \"rows\": " << report.input_desc.num_rows << ",\n" + << " \"cols\": " << report.input_desc.num_cols << ",\n" + << " \"dtype\": \"" << to_string(report.input_desc.dtype) + << "\"\n" + << " },\n" + << " \"config\": {\n" + << " \"format\": \"" << to_string(report.quantization.format) + << "\",\n" + << " \"block_size\": " << report.quantization.block_size << ",\n" + << " \"scale_mode\": \"" + << to_string(report.quantization.scale_mode) << "\",\n" + << " \"rounding\": \"" << to_string(report.quantization.rounding) + << "\",\n" + << " \"stochastic_seed\": " + << report.quantization.stochastic_seed << ",\n" + << " \"output_type\": \"" + << to_string(report.dequantization.output_type) << "\",\n" + << " \"target_gpu\": \"" << escape_json_string(report.target_gpu) + << "\"\n" + << " },\n" + << " \"artifacts\": {\n" + << " \"quantized_file_bytes\": " + << report.artifacts.quantized_file_bytes << ",\n" + << " \"dequantized_file_bytes\": " + << report.artifacts.dequantized_file_bytes << ",\n" + << " \"payload_bytes\": " << report.artifacts.payload_bytes << ",\n" + << " \"local_scale_bytes\": " + << report.artifacts.local_scale_bytes << ",\n" + << " \"global_scale_bytes\": " + << report.artifacts.global_scale_bytes << "\n" + << " },\n" + << " \"error\": {\n" + << " \"max_abs\": " << report.error.max_abs << ",\n" + << " \"mae\": " << report.error.mae << ",\n" + << " \"mse\": " << report.error.mse << "\n" + << " },\n" + << " \"compression\": {\n" + << " \"input_payload_bytes\": " + << report.artifacts.input_payload_bytes << ",\n" + << " \"logical_quantized_bytes\": " + << report.compression.logical_quantized_bytes << ",\n" + << " \"logical_compression_ratio\": " + << report.compression.logical_compression_ratio << ",\n" + << " \"on_disk_compression_ratio\": " + << report.compression.on_disk_compression_ratio << "\n" + << " },\n" + << " \"performance\": {\n" + << " \"quant_kernel_ms\": "; + write_optional_json_number(stream, report.performance.quant_kernel_ms); + stream << ",\n" + << " \"dequant_kernel_ms\": "; + write_optional_json_number(stream, report.performance.dequant_kernel_ms); + stream << ",\n" + << " \"quant_effective_bandwidth_gbps\": "; + write_optional_json_number( + stream, report.performance.quant_effective_bandwidth_gbps); + stream << ",\n" + << " \"dequant_effective_bandwidth_gbps\": "; + write_optional_json_number( + stream, report.performance.dequant_effective_bandwidth_gbps); + stream << "\n }\n}\n"; + return stream.str(); +} + +void write_run_report_json(const std::filesystem::path& output_path, + const RunReport& report) { + const std::string json = serialize_run_report_json(report); + std::ofstream stream{output_path, std::ios::binary | std::ios::trunc}; + if (!stream.is_open()) { + throw MetricsError{ + "无法打开运行报告输出文件:" + output_path.string()}; + } + + stream << json; + if (!stream.good()) { + throw MetricsError{ + "写入运行报告 JSON 失败:" + output_path.string()}; + } +} + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/dequantize.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/dequantize.cpp" new file mode 100644 index 00000000..a28d8af2 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/dequantize.cpp" @@ -0,0 +1,368 @@ +#include "quant_dequant/quantize.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "cuda/mxfp8_dequantize.cuh" +#include "cuda/nvfp4_dequantize.cuh" +#include "common/cuda_stream.cuh" +#include "common/cuda_timer.cuh" +#include "pipeline/device_quantized_tensor.cuh" +#include "pipeline/pipeline_detail.hpp" + +#include + +namespace quant_dequant { +namespace { + +/** + * @brief 验证 CUDA 反量化入口能够安全接收的 host 侧低精度张量。 + * + * 量化文件读入后由 `QuantizedTensor` 保存其 payload、local scale 和可选 global + * scale。实际实现会在通过该校验后将这些数组传输到 GPU;因此不能允许不自洽的 + * 描述或 buffer 进入格式专用路径。 + * + * @param input 调用方传入的 host 侧量化张量。 + * @throws CudaPipelineError 张量不自洽时抛出。 + */ +void validate_cuda_dequantization_input(const QuantizedTensor& input) { + if (!input.isConsistent()) { + throw CudaPipelineError{ + "CUDA pipeline 反量化要求自洽的 QuantizedTensor。"}; + } +} + +/** + * @brief 在指定 stream 上异步复制连续 host 数组到 device 数组。 + * + * @tparam T 两端元素类型,必须具有相同的平凡二进制表示。 + * @param host_source 指向只读 host 数据的非拥有指针。 + * @param device_destination 指向已分配 device 数据的非拥有指针。 + * @param count 复制元素数。 + * @param stream 本次 H2D 所属 CUDA stream。 + * @param field_name 用于异常诊断的数组名称。 + * @throws CudaPipelineError CUDA runtime 复制失败或范围非法时抛出。 + */ +template +void copy_host_to_device_async(const T* const host_source, + T* const device_destination, + const std::size_t count, + const cudaStream_t stream, + const char* const field_name) { + if (host_source == nullptr || device_destination == nullptr || count == 0U || + count > std::numeric_limits::max() / sizeof(T)) { + throw CudaPipelineError{ + "CUDA pipeline 无法构造 \"" + std::string{field_name} + + "\" 的 H2D 复制范围。"}; + } + const cudaError_t status = cudaMemcpyAsync( + device_destination, host_source, count * sizeof(T), cudaMemcpyHostToDevice, stream); + if (status != cudaSuccess) { + throw CudaPipelineError{ + "CUDA pipeline H2D 复制 \"" + std::string{field_name} + "\" 失败:" + + cudaGetErrorString(status)}; + } +} + +/** + * @brief 在指定 stream 上异步复制连续 device 数组到 host 数组。 + * + * @tparam T 两端元素类型,必须具有相同的平凡二进制表示。 + * @param device_source 指向只读 device 数据的非拥有指针。 + * @param host_destination 指向已分配 host 数据的非拥有指针。 + * @param count 复制元素数。 + * @param stream 本次 D2H 所属 CUDA stream。 + * @param field_name 用于异常诊断的数组名称。 + * @throws CudaPipelineError CUDA runtime 复制失败或范围非法时抛出。 + */ +template +void copy_device_to_host_async(const T* const device_source, + T* const host_destination, + const std::size_t count, + const cudaStream_t stream, + const char* const field_name) { + if (device_source == nullptr || host_destination == nullptr || count == 0U || + count > std::numeric_limits::max() / sizeof(T)) { + throw CudaPipelineError{ + "CUDA pipeline 无法构造 \"" + std::string{field_name} + + "\" 的 D2H 复制范围。"}; + } + const cudaError_t status = cudaMemcpyAsync( + host_destination, device_source, count * sizeof(T), cudaMemcpyDeviceToHost, stream); + if (status != cudaSuccess) { + throw CudaPipelineError{ + "CUDA pipeline D2H 复制 \"" + std::string{field_name} + "\" 失败:" + + cudaGetErrorString(status)}; + } +} + +} // namespace + +HostTensor dequantize_cuda( + const QuantizedTensor& input, + const DequantizationConfig& config) { + return dequantize_cuda_profiled(input, config).tensor; +} + +ProfiledDequantizationResult dequantize_cuda_profiled( + const QuantizedTensor& input, + const DequantizationConfig& config) { + validate_cuda_dequantization_input(input); + + if (!config.isValid()) { + throw CudaPipelineError{"CUDA pipeline 反量化配置不合法。"}; + } + + switch (input.desc.format) { + case QuantFormat::kMxfp8: + return pipeline_detail::dequantize_mxfp8_cuda_profiled(input, config); + case QuantFormat::kNvfp4: + return pipeline_detail::dequantize_nvfp4_cuda_profiled(input, config); + + case QuantFormat::kUnknown: + break; + } + + throw CudaPipelineError{"CUDA pipeline 反量化格式不合法。"}; +} + +} // namespace quant_dequant + +namespace quant_dequant::pipeline_detail { +namespace { + +/** + * @brief 推导 CUDA 反量化后应返回的统一 host 输出描述。 + * + * 低精度 QDWGT 记录的是量化前的 FP16/FP32 `source_desc`;反量化不改变矩阵 + * 形状,仅由方向配置指定最终 QDTENSOR 的物理输出类型。无论目标是 FP16、 + * BF16 还是 FP32,CUDA kernel 和 HostTensor 在内存中都先使用 FP32。 + * + * @param input 已验证的 host 量化张量。 + * @param config 已验证的反量化方向配置。 + * @return 形状不变、dtype 为 config.output_type 的输出描述。 + * @throws CudaPipelineError 输出描述或元素数量不能安全表示时抛出。 + */ +[[nodiscard]] TensorDesc make_cuda_dequantized_output_desc( + const QuantizedTensor& input, + const DequantizationConfig& config) { + TensorDesc output_desc = input.desc.source_desc; + output_desc.dtype = config.output_type; + + const auto element_count = output_desc.elementCount(); + if (!output_desc.isValid() || + !is_supported_output_dtype(output_desc.dtype) || + !element_count.has_value() || + *element_count > + static_cast(std::numeric_limits::max())) { + throw CudaPipelineError{ + "CUDA pipeline 无法构造合法的反量化输出描述。"}; + } + + return output_desc; +} + +} // namespace + +HostTensor dequantize_mxfp8_cuda( + const QuantizedTensor& input, + const DequantizationConfig& config) { + return dequantize_mxfp8_cuda_profiled(input, config).tensor; +} + +ProfiledDequantizationResult dequantize_mxfp8_cuda_profiled( + const QuantizedTensor& input, + const DequantizationConfig& config) { + // 阶段 0:public dispatcher 已完成同样的检查;格式专用入口仍保持自己的 + // 不变量,避免内部调用绕过 API 后把 NVFP4 或不合法的目标 dtype 传入。 + if (!input.isConsistent() || !config.isValid() || + input.desc.format != QuantFormat::kMxfp8) { + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 反量化收到了非法输入或输出配置。"}; + } + + const TensorDesc output_desc = + make_cuda_dequantized_output_desc(input, config); + + try { + common::CudaStream stream{}; + const cudaStream_t cuda_stream = stream.get(); + + // 阶段 1:用与 quantize 输出相同的 device 所有权对象容纳 QDWGT 中的 + // E4M3 payload 与 E8M0 local scale。MXFP8 不使用 global_scale, + // allocate() 会相应保持它为空;因此不能也无需从 host 拷贝该字段。 + pipeline::DeviceQuantizedTensor device_input = + pipeline::DeviceQuantizedTensor::allocate(input.desc); + copy_host_to_device_async( + input.payload.data(), thrust::raw_pointer_cast(device_input.payload.data()), + input.payload.size(), cuda_stream, "反量化 payload"); + copy_host_to_device_async( + input.local_scales.data(), + thrust::raw_pointer_cast(device_input.local_scales.data()), + input.local_scales.size(), cuda_stream, "反量化 local_scales"); + if (!device_input.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 构造出了不自洽的反量化 device 输入。"}; + } + + // 阶段 2:分配统一的 device FP32 输出。output_desc.dtype 仅标记将来 + // QDTENSOR 的物理 payload 类型;kernel 始终写 float,以使一个实现 + // 覆盖 FP16、BF16、FP32 三种输出请求。 + pipeline::DeviceDequantizationOutput device_output = + pipeline::DeviceDequantizationOutput::allocate(output_desc); + if (!device_output.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 构造出了不自洽的反量化 device 输出。"}; + } + + // 阶段 3:一个线程解码一个 E4M3 payload;launcher 根据 input.desc 的 + // scale_mode 在 kernel 内选择唯一 tensor scale 或 rowwise block scale。 + // 因而 pipeline 不需要为了两种 scale 模式复制 H2D/D2H 流程。 + common::CudaEventTimer timer{cuda_stream}; + timer.start(); + cuda::launch_mxfp8_dequantize(device_input, device_output, cuda_stream); + timer.stop(); + const double kernel_ms = static_cast(timer.elapsedMilliseconds()); + + // 阶段 4:把 D2H 提交给相同 stream,再等待此前 kernel 与复制都完成。 + // HostTensor 固定保存 FP32 values;真正写成配置请求的 FP16/BF16/FP32 + // 字节由 tensor_io 的 write_dequantized_tensor() 完成。 + HostTensor result{ + .desc = output_desc, + .values = std::vector(device_output.values.size()), + }; + copy_device_to_host_async( + thrust::raw_pointer_cast(device_output.values.data()), result.values.data(), + result.values.size(), cuda_stream, "反量化 values"); + stream.synchronize(); + const auto expected_count = result.desc.elementCount(); + if (!expected_count.has_value() || + *expected_count > + static_cast(std::numeric_limits::max()) || + result.values.size() != static_cast(*expected_count)) { + throw CudaPipelineError{ + "mxfp8 CUDA pipeline D2H 后得到了长度不自洽的反量化结果。"}; + } + + return { + .tensor = std::move(result), + .kernel_ms = kernel_ms, + }; + } catch (const CudaPipelineError&) { + throw; + } catch (const std::exception& error) { + // Thrust 的 device allocation、H2D 或 D2H 通常以标准异常传播。统一转换 + // 为公共 pipeline 错误,调用方无需依赖 Thrust 的异常类型层次。 + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 反量化执行 H2D、kernel 或 D2H 时失败:" + + std::string{error.what()}}; + } +} + +HostTensor dequantize_nvfp4_cuda( + const QuantizedTensor& input, + const DequantizationConfig& config) { + return dequantize_nvfp4_cuda_profiled(input, config).tensor; +} + +ProfiledDequantizationResult dequantize_nvfp4_cuda_profiled( + const QuantizedTensor& input, + const DequantizationConfig& config) { + // 阶段 0:NVFP4 的 global_scale 是每次 decode 都必需的第三层 scale,不能 + // 像 MXFP8 一样只依赖 payload/local scale。QuantizedTensor::isConsistent() + // 已保证它存在、有限且为正;这里再次固定格式与 block 规则,阻止内部调用 + // 绕过 public dispatcher 后落入错误的 packed-byte kernel。 + if (!input.isConsistent() || !config.isValid() || + input.desc.format != QuantFormat::kNvfp4 || + input.desc.scale_mode != ScaleMode::kBlock || + input.desc.block_size != kNvfp4BlockSize || + !input.global_scale.has_value()) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline 反量化收到了非法输入或输出配置。"}; + } + + const TensorDesc output_desc = + make_cuda_dequantized_output_desc(input, config); + + try { + common::CudaStream stream{}; + const cudaStream_t cuda_stream = stream.get(); + + // 阶段 1:把 QDWGT 的 packed E2M1 bytes、E4M3 local scale 和单元素 + // FP32 global scale 都交由同一个 DeviceQuantizedTensor 持有。`assign()` + // 使用 host iterator 触发 H2D;对象将在 kernel 与 D2H 完成前持续存活。 + pipeline::DeviceQuantizedTensor device_input = + pipeline::DeviceQuantizedTensor::allocate(input.desc); + copy_host_to_device_async( + input.payload.data(), thrust::raw_pointer_cast(device_input.payload.data()), + input.payload.size(), cuda_stream, "反量化 payload"); + copy_host_to_device_async( + input.local_scales.data(), + thrust::raw_pointer_cast(device_input.local_scales.data()), + input.local_scales.size(), cuda_stream, "反量化 local_scales"); + copy_host_to_device_async( + &*input.global_scale, + thrust::raw_pointer_cast(device_input.global_scale->data()), + 1U, cuda_stream, "反量化 global_scale"); + if (!device_input.isConsistent()) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline 构造出了不自洽的反量化 device 输入。"}; + } + + // 阶段 2:与 MXFP8 统一,device kernel 仅写 FP32。output_desc.dtype + // 只是后续 tensor I/O 写出 FP16/BF16/FP32 的物理格式请求。 + pipeline::DeviceDequantizationOutput device_output = + pipeline::DeviceDequantizationOutput::allocate(output_desc); + if (!device_output.isConsistent()) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline 构造出了不自洽的反量化 device 输出。"}; + } + + // 阶段 3:一个线程解包一个物理 byte,并分别为 low/high nibble 推导 + // local scale;奇数列导致的跨行 byte 不会错误共用一个 scale 下标。 + common::CudaEventTimer timer{cuda_stream}; + timer.start(); + cuda::launch_nvfp4_dequantize(device_input, device_output, cuda_stream); + timer.stop(); + const double kernel_ms = static_cast(timer.elapsedMilliseconds()); + + // 阶段 4:Thrust range 构造函数执行 D2H 并等待此前 kernel。统一的 + // HostTensor 继续保存 FP32 数值,物理窄化仍留在 QDTENSOR 写出边界。 + HostTensor result{ + .desc = output_desc, + .values = std::vector(device_output.values.size()), + }; + copy_device_to_host_async( + thrust::raw_pointer_cast(device_output.values.data()), result.values.data(), + result.values.size(), cuda_stream, "反量化 values"); + stream.synchronize(); + const auto expected_count = result.desc.elementCount(); + if (!expected_count.has_value() || + *expected_count > + static_cast(std::numeric_limits::max()) || + result.values.size() != static_cast(*expected_count)) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline D2H 后得到了长度不自洽的反量化结果。"}; + } + + return { + .tensor = std::move(result), + .kernel_ms = kernel_ms, + }; + } catch (const CudaPipelineError&) { + throw; + } catch (const std::exception& error) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline 反量化执行 H2D、kernel 或 D2H 时失败:" + + std::string{error.what()}}; + } +} + +} // namespace quant_dequant::pipeline_detail diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/device_quantized_tensor.cuh" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/device_quantized_tensor.cuh" new file mode 100644 index 00000000..b0896b03 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/device_quantized_tensor.cuh" @@ -0,0 +1,347 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "quant_dequant/quantized_tensor.hpp" + +namespace quant_dequant::pipeline { +namespace detail { + +/** + * @brief 将文件/描述中的无符号长度安全转换为 Thrust 容器使用的 size_t。 + * + * 本函数只检查 host 侧容器长度是否可表示;实际 CUDA allocation 是否成功由 + * `thrust::device_vector` 自身报告。它被刻意放在 pipeline 内部,避免把 Thrust + * 相关的长度约束泄漏到公共的数据模型中。 + * + * @param count 要转换的元素或字节数量。 + * @param buffer_name 用于异常信息的 device buffer 名称。 + * @return 可安全传给 `thrust::device_vector` 构造函数的长度。 + * @throws std::overflow_error count 超出当前平台 size_t 范围时抛出。 + */ +[[nodiscard]] inline std::size_t checked_device_buffer_size( + const std::uint64_t count, + const char* const buffer_name) { + constexpr std::uint64_t kMaxSize = + static_cast(std::numeric_limits::max()); + + if (count > kMaxSize) { + throw std::overflow_error{ + "device buffer \"" + std::string{buffer_name} + + "\" 的长度超出当前平台 size_t 范围。"}; + } + + return static_cast(count); +} + +} // namespace detail + +/** + * @brief 量化 pipeline 在 device 端持有的统一 FP32 输入张量。 + * + * QDTENSOR 输入文件可以是 FP16 或 FP32,但在 host I/O 边界已经统一扩展为 FP32。 + * 因而 CUDA quantize kernel 始终读取 `values` 中的 FP32;`desc.dtype` 仍保留 + * 原始文件的物理 dtype,以便最终 QDWGT 正确记录 `source_dtype`。 + * + * 这是量化流程专用对象,不是泛用 GPU Tensor。它只能在 `pipeline/` 层使用, + * CUDA kernel 由 pipeline 通过 `thrust::raw_pointer_cast(values.data())` 取得 + * 非拥有 raw device pointer,不应直接接收本对象。 + */ +struct DeviceQuantizationInput { + /** 原始输入文件的形状和物理 dtype;只在 host 侧解释。 */ + TensorDesc desc{}; + + /** 按 row-major 顺序保存的 device FP32 输入值。 */ + thrust::device_vector values{}; + + DeviceQuantizationInput() = default; + DeviceQuantizationInput(const DeviceQuantizationInput&) = delete; + DeviceQuantizationInput& operator=(const DeviceQuantizationInput&) = delete; + DeviceQuantizationInput(DeviceQuantizationInput&&) = default; + DeviceQuantizationInput& operator=(DeviceQuantizationInput&&) = default; + ~DeviceQuantizationInput() = default; + + /** + * @brief 检查 host 描述和 device FP32 输入数组的结构是否匹配。 + * + * 本检查不会访问 device 数据,因此不会触发 H2D/D2H 拷贝或 CUDA 同步;它只 + * 验证 shape、第一版允许的物理输入 dtype 与 device vector 长度。 + * + * @return 描述合法、输入 dtype 为 FP16/FP32 且 values 长度正确时返回 true。 + */ + [[nodiscard]] bool isConsistent() const noexcept { + const auto element_count = desc.elementCount(); + if (!desc.isValid() || !is_supported_input_dtype(desc.dtype) || + !element_count.has_value() || + *element_count > + static_cast( + std::numeric_limits::max())) { + return false; + } + + return values.size() == static_cast(*element_count); + } +}; + +/** + * @brief MXFP8 tensor-scale 量化的临时 device 规约工作区。 + * + * tensor mode 的唯一 E8M0 scale 依赖整张矩阵的 `amax`,不能像 block mode 那样 + * 在单个 warp 内完成。第一阶段 persistent reduction kernel 为每个 CTA 写一项 + * partial `amax` 与“是否发现 NaN/Inf”的标记;第二阶段单 CTA 再规约这两个数组, + * 并直接写 `local_scales[0]`。 + * + * 工作区只在一次 `quantize_mxfp8_cuda()` 调用内存活,既不属于最终 QDWGT,也 + * 不应被序列化。它用两个简单的 device vector 而非一个 struct vector,使两种 + * 标量的对齐、访问和最终 warp 规约都保持直接。 + */ +struct DeviceTensorQuantizationWorkspace { + /** 每个第一阶段 CTA 写出的有限输入最大绝对值。 */ + thrust::device_vector partial_amax{}; + + /** 每个第一阶段 CTA 写出的非有限输入标记;0 表示无,非 0 表示存在。 */ + thrust::device_vector partial_nonfinite{}; + + DeviceTensorQuantizationWorkspace() = default; + DeviceTensorQuantizationWorkspace( + const DeviceTensorQuantizationWorkspace&) = delete; + DeviceTensorQuantizationWorkspace& operator=( + const DeviceTensorQuantizationWorkspace&) = delete; + DeviceTensorQuantizationWorkspace(DeviceTensorQuantizationWorkspace&&) = default; + DeviceTensorQuantizationWorkspace& operator=( + DeviceTensorQuantizationWorkspace&&) = default; + ~DeviceTensorQuantizationWorkspace() = default; + + /** + * @brief 分配具有指定 partial CTA 数量的临时规约数组。 + * + * `partial_count` 必须与 tensor-scale launcher 将要发射的第一阶段 grid.x + * 相同。pipeline 先向 launcher 查询该数量再分配,使工作区在最终编码和 + * D2H 完成前都持续拥有 kernel 所借用的 device 内存。 + * + * @param partial_count 第一阶段 persistent reduction CTA 数量。 + * @return 持有同长度 amax 和非有限标记数组的 move-only 工作区。 + * @throws std::invalid_argument partial_count 为 0 时抛出。 + * @throws thrust::system_error CUDA runtime 或 device allocation 失败时抛出。 + */ + [[nodiscard]] static DeviceTensorQuantizationWorkspace allocate( + const std::size_t partial_count) { + if (partial_count == 0U) { + throw std::invalid_argument{ + "DeviceTensorQuantizationWorkspace 需要至少一项 partial 结果。"}; + } + + DeviceTensorQuantizationWorkspace workspace{}; + workspace.partial_amax = thrust::device_vector{partial_count}; + workspace.partial_nonfinite = + thrust::device_vector{partial_count}; + return workspace; + } + + /** + * @brief 检查两个临时规约数组的长度是否匹配且非空。 + * + * 该检查只读取 Thrust 容器元数据,不访问 device 数值或同步 CUDA stream。 + * + * @return 两个数组均非空且长度相同返回 true。 + */ + [[nodiscard]] bool isConsistent() const noexcept { + return !partial_amax.empty() && + partial_amax.size() == partial_nonfinite.size(); + } +}; + +/** + * @brief CUDA quantize pipeline 在 device 端持有的低精度量化结果。 + * + * 该对象的 host 元数据复用 `QuantizedTensorDesc`,使 CPU reference、QDWGT I/O + * 与 CUDA 路径对 payload 字节数、局部 scale 数量、block 布局和格式约束使用同一 + * 套规则。真正位于 device 的 payload、local_scales 与 NVFP4 global_scale 全部 + * 使用 `thrust::device_vector` 管理,遵循 RAII(资源获取即初始化)生命周期。 + * + * MXFP8 中 `global_scale` 必须为空;NVFP4 中它必须存在且只含一个 FP32 元素。 + * 这个元素的数值由 future NVFP4 quantize kernel 写入。`isConsistent()` 只做 + * 不同步的结构检查,不能也不应读取该 device scalar 验证其数值。 + */ +struct DeviceQuantizedTensor { + /** 与三个 device buffer 共同解释的 host 侧量化元数据。 */ + QuantizedTensorDesc desc{}; + + /** 真实位宽的 device payload:MXFP8 为 byte,NVFP4 为 packed nibble byte。 */ + thrust::device_vector payload{}; + + /** device 局部 scale code:MXFP8 为 E8M0,NVFP4 为 E4M3。 */ + thrust::device_vector local_scales{}; + + /** NVFP4 的单元素 device FP32 解码方向 global scale;MXFP8 不存在。 */ + std::optional> global_scale{}; + + DeviceQuantizedTensor() = default; + DeviceQuantizedTensor(const DeviceQuantizedTensor&) = delete; + DeviceQuantizedTensor& operator=(const DeviceQuantizedTensor&) = delete; + DeviceQuantizedTensor(DeviceQuantizedTensor&&) = default; + DeviceQuantizedTensor& operator=(DeviceQuantizedTensor&&) = default; + ~DeviceQuantizedTensor() = default; + + /** + * @brief 按已验证的量化描述分配所有需要的 device buffer。 + * + * MXFP8 只分配 payload 和 local_scales;NVFP4 额外分配长度为 1 的 + * `global_scale`。本函数不初始化量化 code,也不执行 H2D 拷贝;调用方应在 + * 后续 kernel 或显式拷贝中填充各 buffer。 + * + * @param tensor_desc 已知格式、shape、block size 和 scale 布局的量化描述。 + * @return 持有精确长度 device buffer 的 move-only DeviceQuantizedTensor。 + * @throws std::invalid_argument 描述不是合法的量化结果元数据时抛出。 + * @throws std::overflow_error 推导长度超出当前平台 size_t 时抛出。 + * @throws thrust::system_error CUDA runtime 或 device allocation 失败时抛出。 + */ + [[nodiscard]] static DeviceQuantizedTensor allocate( + const QuantizedTensorDesc& tensor_desc) { + const auto expected_payload_bytes = tensor_desc.expectedPayloadBytes(); + const auto expected_local_scale_count = + tensor_desc.expectedLocalScaleCount(); + + if (!tensor_desc.isMetadataValid() || !expected_payload_bytes.has_value() || + !expected_local_scale_count.has_value()) { + throw std::invalid_argument{ + "DeviceQuantizedTensor 需要合法的 QuantizedTensorDesc。"}; + } + + DeviceQuantizedTensor output{}; + output.desc = tensor_desc; + output.payload = thrust::device_vector{ + detail::checked_device_buffer_size( + *expected_payload_bytes, "payload")}; + output.local_scales = thrust::device_vector{ + detail::checked_device_buffer_size( + *expected_local_scale_count, "local_scales")}; + + if (tensor_desc.usesGlobalScale()) { + output.global_scale.emplace(1U); + } + + return output; + } + + /** + * @brief 检查 host 元数据和 device buffer 长度的结构是否匹配。 + * + * 此函数绝不解引用 device pointer,因此不会同步 CUDA stream。对 NVFP4, + * 它只要求 global_scale buffer 存在且长度为 1;其中是否已经写入有限正值 + * 由量化 kernel 完成后、D2H 构造 Host QuantizedTensor 时验证。 + * + * @return 所有 buffer 长度及 MXFP8/NVFP4 的 global scale 存在性正确时返回 true。 + */ + [[nodiscard]] bool isConsistent() const noexcept { + const auto expected_payload_bytes = desc.expectedPayloadBytes(); + const auto expected_local_scale_count = + desc.expectedLocalScaleCount(); + constexpr std::uint64_t kMaxSize = + static_cast(std::numeric_limits::max()); + + if (!desc.isMetadataValid() || !expected_payload_bytes.has_value() || + !expected_local_scale_count.has_value() || + *expected_payload_bytes > kMaxSize || + *expected_local_scale_count > kMaxSize || + payload.size() != static_cast(*expected_payload_bytes) || + local_scales.size() != + static_cast(*expected_local_scale_count)) { + return false; + } + + if (desc.format == QuantFormat::kMxfp8) { + return !global_scale.has_value(); + } + + return desc.format == QuantFormat::kNvfp4 && global_scale.has_value() && + global_scale->size() == 1U; + } +}; + +/** + * @brief CUDA dequantize pipeline 在 device 端持有的统一 FP32 输出张量。 + * + * 反量化 kernel 的计算结果始终先写为 FP32:E4M3 payload 与 E8M0 scale 的 + * 解码、相乘都可以直接在 FP32 中完成,避免为 FP16、BF16、FP32 维护三套 kernel。 + * `desc.dtype` 仅记录随后 QDTENSOR I/O 应写出的物理类型;D2H 后的 + * `HostTensor::values` 仍保持 FP32,实际窄化由 `write_dequantized_tensor()` + * 统一完成。 + * + * 这是 dequantize 流程专用对象,而非泛用 GPU Tensor。CUDA kernel 通过 + * `thrust::raw_pointer_cast(values.data())` 取得不拥有所有权的 raw device + * pointer,不应按值接收本对象。 + */ +struct DeviceDequantizationOutput { + /** 输出矩阵的 row-major 形状与目标 QDTENSOR 物理 dtype。 */ + TensorDesc desc{}; + + /** 按 row-major 顺序保存的 device FP32 反量化数值。 */ + thrust::device_vector values{}; + + DeviceDequantizationOutput() = default; + DeviceDequantizationOutput(const DeviceDequantizationOutput&) = delete; + DeviceDequantizationOutput& operator=(const DeviceDequantizationOutput&) = delete; + DeviceDequantizationOutput(DeviceDequantizationOutput&&) = default; + DeviceDequantizationOutput& operator=(DeviceDequantizationOutput&&) = default; + ~DeviceDequantizationOutput() = default; + + /** + * @brief 按目标输出描述分配 device FP32 结果数组。 + * + * 虽然 `desc.dtype` 可以是 FP16、BF16 或 FP32,device 端数组始终使用 + * `float`。这与 HostTensor 的统一 FP32 表示一致,也使格式 kernel 与最终 + * 文件的物理类型转换解耦。 + * + * @param output_desc 已知形状和目标物理输出类型的张量描述。 + * @return 持有精确数量 FP32 device 元素的 move-only 输出对象。 + * @throws std::invalid_argument 描述、输出 dtype 或元素数量不合法时抛出。 + * @throws std::overflow_error 元素数量超出当前平台 size_t 时抛出。 + * @throws thrust::system_error CUDA runtime 或 device allocation 失败时抛出。 + */ + [[nodiscard]] static DeviceDequantizationOutput allocate( + const TensorDesc& output_desc) { + const auto element_count = output_desc.elementCount(); + if (!output_desc.isValid() || + !is_supported_output_dtype(output_desc.dtype) || + !element_count.has_value()) { + throw std::invalid_argument{ + "DeviceDequantizationOutput 需要合法的 FP16/BF16/FP32 输出描述。"}; + } + + DeviceDequantizationOutput output{}; + output.desc = output_desc; + output.values = thrust::device_vector{ + detail::checked_device_buffer_size(*element_count, "dequantized values")}; + return output; + } + + /** + * @brief 检查输出描述和 device FP32 数组的结构是否匹配。 + * + * 本函数不会读取 device 数值,也不会同步 CUDA stream。 + * + * @return 形状、目标输出 dtype 和 device 数组长度均正确时返回 true。 + */ + [[nodiscard]] bool isConsistent() const noexcept { + const auto element_count = desc.elementCount(); + if (!desc.isValid() || !is_supported_output_dtype(desc.dtype) || + !element_count.has_value() || + *element_count > + static_cast( + std::numeric_limits::max())) { + return false; + } + + return values.size() == static_cast(*element_count); + } +}; + +} // namespace quant_dequant::pipeline diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/pipeline_detail.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/pipeline_detail.hpp" new file mode 100644 index 00000000..d41831c5 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/pipeline_detail.hpp" @@ -0,0 +1,87 @@ +#pragma once + +#include "quant_dequant/quantize.hpp" + +namespace quant_dequant::pipeline_detail { + +/** + * @brief 执行 MXFP8 CUDA 量化路径的内部入口。 + * + * 该入口将 host 输入传输为 `DeviceQuantizationInput`,分配 + * `DeviceQuantizedTensor`,按 tensor/block scale 调用 MXFP8 E8M0/E4M3 专用 + * kernel,再 D2H 为统一 host 结果。此声明不属于公共 API;调用方必须使用 + * `quantize_cuda()` 完成格式分派。 + * + * @param input 已由公共入口验证的 host 输入。 + * @param config 已由公共入口验证的 MXFP8 量化配置。 + * @return 已完成 D2H 的 host 侧量化张量。 + */ +[[nodiscard]] QuantizedTensor quantize_mxfp8_cuda( + const HostTensor& input, + const QuantizationConfig& config); + +/** @brief 执行 MXFP8 CUDA 量化并保留同一 stream 上的 kernel Event 时间。 */ +[[nodiscard]] ProfiledQuantizationResult quantize_mxfp8_cuda_profiled( + const HostTensor& input, + const QuantizationConfig& config); + +/** + * @brief 执行 NVFP4 CUDA 量化路径的内部入口。 + * + * 该入口会处理 FP4 nibble packed store、E4M3 local scale、FP32 global scale + * 与 NVFP4 专用量化 kernel。调用方必须使用 `quantize_cuda()` 完成格式分派。 + * + * @param input 已由公共入口验证的 host 输入。 + * @param config 已由公共入口验证的 NVFP4 量化配置。 + * @return 已完成 D2H 的 host 侧 NVFP4 量化张量。 + */ +[[nodiscard]] QuantizedTensor quantize_nvfp4_cuda( + const HostTensor& input, + const QuantizationConfig& config); + +/** @brief 执行 NVFP4 CUDA 量化并保留同一 stream 上的 kernel Event 时间。 */ +[[nodiscard]] ProfiledQuantizationResult quantize_nvfp4_cuda_profiled( + const HostTensor& input, + const QuantizationConfig& config); + +/** + * @brief 执行 MXFP8 CUDA 反量化路径的内部入口。 + * + * 该入口传输 MXFP8 payload 与 E8M0 scale,调用格式专用反量化 kernel,并回传 + * 统一保存为 FP32 的 `HostTensor::values`。输出描述中的 dtype 设为请求的 + * FP16、BF16 或 FP32,实际物理窄化留给 QDTENSOR I/O 边界。 + * + * @param input 已由公共入口验证的 MXFP8 量化张量。 + * @param config 已由公共入口验证的反量化配置。 + * @return 已完成 D2H 的 host FP32 张量。 + */ +[[nodiscard]] HostTensor dequantize_mxfp8_cuda( + const QuantizedTensor& input, + const DequantizationConfig& config); + +/** @brief 执行 MXFP8 CUDA 反量化并保留同一 stream 上的 kernel Event 时间。 */ +[[nodiscard]] ProfiledDequantizationResult dequantize_mxfp8_cuda_profiled( + const QuantizedTensor& input, + const DequantizationConfig& config); + +/** + * @brief 执行 NVFP4 CUDA 反量化路径的内部入口。 + * + * 该入口传输 packed E2M1 payload、E4M3 local scale 和单个 FP32 global scale; + * 格式专用 kernel 以一个线程处理一个物理 byte,分别解码两个 nibble 并为其恢复 + * 各自的 rowwise local-scale 下标,随后回传统一保存为 FP32 的 `HostTensor::values`。 + * + * @param input 已由公共入口验证的 NVFP4 量化张量。 + * @param config 已由公共入口验证的反量化配置。 + * @return 已完成 D2H 的 host FP32 张量。 + */ +[[nodiscard]] HostTensor dequantize_nvfp4_cuda( + const QuantizedTensor& input, + const DequantizationConfig& config); + +/** @brief 执行 NVFP4 CUDA 反量化并保留同一 stream 上的 kernel Event 时间。 */ +[[nodiscard]] ProfiledDequantizationResult dequantize_nvfp4_cuda_profiled( + const QuantizedTensor& input, + const DequantizationConfig& config); + +} // namespace quant_dequant::pipeline_detail diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/quantize.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/quantize.cpp" new file mode 100644 index 00000000..72c461fa --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/pipeline/quantize.cpp" @@ -0,0 +1,598 @@ +#include "quant_dequant/quantize.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "cuda/mxfp8_quantize.cuh" +#include "cuda/nvfp4_quantize.cuh" +#include "common/cuda_stream.cuh" +#include "common/cuda_timer.cuh" +#include "formats/mxfp8_codec.cuh" +#include "formats/nvfp4_codec.cuh" +#include "pipeline/device_quantized_tensor.cuh" +#include "pipeline/pipeline_detail.hpp" + +#include + +namespace quant_dequant { +namespace { + +/** + * @brief 验证 CUDA 量化入口能够安全接收的统一 host 输入。 + * + * `HostTensor` 在内存中始终使用 FP32 `values`,而 `desc.dtype` 仅保存输入文件 + * 的物理类型。因此即使原始输入是 FP16,也应当在进入 CUDA pipeline 前由 + * tensor I/O 层扩展为 FP32。 + * + * @param input 调用方传入的 host 张量。 + * @throws CudaPipelineError 描述、元素数或输入物理类型不符合约定时抛出。 + */ +void validate_cuda_quantization_input(const HostTensor& input) { + if (!input.desc.isValid() || + (input.desc.dtype != DType::kFloat16 && input.desc.dtype != DType::kFloat32)) { + throw CudaPipelineError{ + "CUDA pipeline 量化要求合法的 FP16/FP32 输入描述。"}; + } + + const auto expected_count = input.desc.elementCount(); + if (!expected_count.has_value() || + *expected_count > std::numeric_limits::max() || + input.values.size() != static_cast(*expected_count)) { + throw CudaPipelineError{ + "CUDA pipeline 量化输入的 FP32 values 长度与张量描述不一致。"}; + } +} + +/** + * @brief 在指定 stream 上异步复制一段 host 数组到 device 数组。 + * + * @tparam T 两端元素类型,必须具有相同的平凡二进制表示。 + * @param host_source 指向只读连续 host 数组的非拥有指针。 + * @param device_destination 指向已分配连续 device 数组的非拥有指针。 + * @param count 要复制的元素数量。 + * @param stream 承担本次 H2D 的 non-blocking CUDA stream。 + * @param field_name 用于诊断的数组名称。 + * @throws CudaPipelineError 数量、地址或 cudaMemcpyAsync 调用不合法时抛出。 + */ +template +void copy_host_to_device_async(const T* const host_source, + T* const device_destination, + const std::size_t count, + const cudaStream_t stream, + const char* const field_name) { + if (host_source == nullptr || device_destination == nullptr || count == 0U || + count > std::numeric_limits::max() / sizeof(T)) { + throw CudaPipelineError{ + "CUDA pipeline 无法构造 \"" + std::string{field_name} + + "\" 的 H2D 复制范围。"}; + } + + const cudaError_t status = cudaMemcpyAsync( + device_destination, + host_source, + count * sizeof(T), + cudaMemcpyHostToDevice, + stream); + if (status != cudaSuccess) { + throw CudaPipelineError{ + "CUDA pipeline H2D 复制 \"" + std::string{field_name} + "\" 失败:" + + cudaGetErrorString(status)}; + } +} + +/** + * @brief 在指定 stream 上异步复制一段 device 数组到 host 数组。 + * + * @tparam T 两端元素类型,必须具有相同的平凡二进制表示。 + * @param device_source 指向只读连续 device 数组的非拥有指针。 + * @param host_destination 指向已分配连续 host 数组的非拥有指针。 + * @param count 要复制的元素数量。 + * @param stream 承担本次 D2H 的 non-blocking CUDA stream。 + * @param field_name 用于诊断的数组名称。 + * @throws CudaPipelineError 数量、地址或 cudaMemcpyAsync 调用不合法时抛出。 + */ +template +void copy_device_to_host_async(const T* const device_source, + T* const host_destination, + const std::size_t count, + const cudaStream_t stream, + const char* const field_name) { + if (device_source == nullptr || host_destination == nullptr || count == 0U || + count > std::numeric_limits::max() / sizeof(T)) { + throw CudaPipelineError{ + "CUDA pipeline 无法构造 \"" + std::string{field_name} + + "\" 的 D2H 复制范围。"}; + } + + const cudaError_t status = cudaMemcpyAsync( + host_destination, + device_source, + count * sizeof(T), + cudaMemcpyDeviceToHost, + stream); + if (status != cudaSuccess) { + throw CudaPipelineError{ + "CUDA pipeline D2H 复制 \"" + std::string{field_name} + "\" 失败:" + + cudaGetErrorString(status)}; + } +} + +} // namespace + +CudaPipelineError::CudaPipelineError(std::string detail) + : std::runtime_error(std::move(detail)) {} + +QuantizedTensor quantize_cuda( + const HostTensor& input, + const QuantizationConfig& config) { + return quantize_cuda_profiled(input, config).tensor; +} + +ProfiledQuantizationResult quantize_cuda_profiled( + const HostTensor& input, + const QuantizationConfig& config) { + validate_cuda_quantization_input(input); + + if (!config.isValid()) { + throw CudaPipelineError{"CUDA pipeline 量化配置不合法。"}; + } + + switch (config.format) { + case QuantFormat::kMxfp8: + return pipeline_detail::quantize_mxfp8_cuda_profiled(input, config); + case QuantFormat::kNvfp4: + return pipeline_detail::quantize_nvfp4_cuda_profiled(input, config); + + case QuantFormat::kUnknown: + break; + } + + throw CudaPipelineError{"CUDA pipeline 量化格式不合法。"}; +} + +} // namespace quant_dequant + +namespace quant_dequant::pipeline_detail { +namespace { + +/** + * @brief 验证 MXFP8 CUDA 专用入口可安全使用的 host 输入。 + * + * 公共 `quantize_cuda()` 已执行相同的检查。这里仍重复验证,使内部调用者无法 + * 绕过公共 dispatcher 后把不完整的 `HostTensor` 传给未来的 H2D 路径。 + * + * @param input 待量化的统一 host FP32 张量。 + * @throws CudaPipelineError 描述、物理输入类型或 FP32 元素数量不符合约定时抛出。 + */ +void validate_mxfp8_cuda_input(const HostTensor& input) { + if (!input.desc.isValid() || !is_supported_input_dtype(input.desc.dtype)) { + throw CudaPipelineError{ + "MXFP8 CUDA pipeline 收到了不合法的 FP16/FP32 输入描述。"}; + } + + const auto element_count = input.desc.elementCount(); + if (!element_count.has_value() || + *element_count > std::numeric_limits::max() || + input.values.size() != static_cast(*element_count)) { + throw CudaPipelineError{ + "MXFP8 CUDA pipeline 输入的 FP32 values 长度与张量描述不一致。"}; + } +} + +/** + * @brief 验证 MXFP8 格式专用入口的量化配置。 + * + * 虽然 `QuantizationConfig::isValid()` 已经通过格式映射间接检查 block size, + * 这里显式比较 `kMxfp8BlockSize`,将未来 warp-per-quantization-block kernel + * 的固定 32 元素前提固定在该格式入口,而非仅隐含在通用配置校验中。 + * + * @param config 待验证的量化方向配置。 + * @throws CudaPipelineError 配置不是合法 MXFP8 配置,或 block size 不为 32 时抛出。 + */ +void validate_mxfp8_cuda_config(const QuantizationConfig& config) { + if (!config.isValid() || config.format != QuantFormat::kMxfp8 || + config.block_size != kMxfp8BlockSize) { + throw CudaPipelineError{ + "MXFP8 CUDA pipeline 收到了非法量化配置;" + "该格式要求 format = mxfp8 且 block_size = 32。"}; + } +} + +/** + * @brief 验证未来 MXFP8 CUDA 输出所需的元数据和 buffer 长度。 + * + * 该函数只推导 metadata,不分配 `thrust::device_vector`。它提前确保后续 + * `DeviceQuantizedTensor::allocate()` 可以得到一个 E4M3 一字节一元素的 payload + * 和按 tensor/block 模式组织的 E8M0 local scale 数组。 + * + * @param input 已通过输入校验的 host 张量。 + * @param config 已通过 MXFP8 配置校验的量化配置。 + * @return 供 `DeviceQuantizedTensor::allocate()` 使用的 MXFP8 输出描述。 + * @throws CudaPipelineError 元数据不可推导,或数组长度超出 host/device 容器可表示 + * 范围时抛出。 + */ +[[nodiscard]] QuantizedTensorDesc make_mxfp8_cuda_output_desc( + const HostTensor& input, + const QuantizationConfig& config) { + QuantizedTensorDesc output_desc{}; + output_desc.source_desc = input.desc; + output_desc.format = QuantFormat::kMxfp8; + output_desc.scale_mode = config.scale_mode; + output_desc.rounding = config.rounding; + output_desc.stochastic_seed = config.stochastic_seed; + output_desc.block_size = config.block_size; + output_desc.scale_layout = ScaleLayout::kRowwise; + + const auto payload_bytes = output_desc.expectedPayloadBytes(); + const auto local_scale_count = output_desc.expectedLocalScaleCount(); + if (!output_desc.isMetadataValid() || !payload_bytes.has_value() || + !local_scale_count.has_value()) { + throw CudaPipelineError{ + "MXFP8 CUDA pipeline 无法构造合法的量化输出元数据。"}; + } + + constexpr std::uint64_t kMaxContainerSize = + static_cast(std::numeric_limits::max()); + if (*payload_bytes > kMaxContainerSize || + *local_scale_count > kMaxContainerSize) { + throw CudaPipelineError{ + "MXFP8 CUDA pipeline 的 payload 或 local scale 数量超出" + "当前 host/device 容器可表示范围。"}; + } + + return output_desc; +} + +/** + * @brief 验证 NVFP4 CUDA 专用入口的 host 输入。 + * + * @param input 待量化的统一 host FP32 张量。 + * @throws CudaPipelineError 描述、输入 dtype 或 FP32 values 长度不合法时抛出。 + */ +void validate_nvfp4_cuda_input(const HostTensor& input) { + if (!input.desc.isValid() || !is_supported_input_dtype(input.desc.dtype)) { + throw CudaPipelineError{ + "NVFP4 CUDA pipeline 收到了不合法的 FP16/FP32 输入描述。"}; + } + + const auto element_count = input.desc.elementCount(); + if (!element_count.has_value() || + *element_count > std::numeric_limits::max() || + input.values.size() != static_cast(*element_count)) { + throw CudaPipelineError{ + "NVFP4 CUDA pipeline 输入的 FP32 values 长度与张量描述不一致。"}; + } +} + +/** + * @brief 验证严格 NVFP4 rowwise 16 元素 block 量化配置。 + * + * @param config 待验证的量化方向配置。 + * @throws CudaPipelineError 格式、block size 或 scale_mode 不符合 NVFP4 规则时抛出。 + */ +void validate_nvfp4_cuda_config(const QuantizationConfig& config) { + if (!config.isValid() || config.format != QuantFormat::kNvfp4 || + config.block_size != kNvfp4BlockSize || + config.scale_mode != ScaleMode::kBlock) { + throw CudaPipelineError{ + "NVFP4 CUDA pipeline 要求 format = nvfp4、block_size = 16 且 " + "scale_mode = block。"}; + } +} + +/** + * @brief 构造 NVFP4 CUDA 输出的共享 QuantizedTensorDesc。 + * + * @param input 已通过输入校验的 host 张量。 + * @param config 已通过 NVFP4 专用校验的量化配置。 + * @return 可用于分配 packed payload、E4M3 local scale 与 global scalar 的描述。 + * @throws CudaPipelineError 元数据或容器长度无法安全推导时抛出。 + */ +[[nodiscard]] QuantizedTensorDesc make_nvfp4_cuda_output_desc( + const HostTensor& input, + const QuantizationConfig& config) { + QuantizedTensorDesc output_desc{}; + output_desc.source_desc = input.desc; + output_desc.format = QuantFormat::kNvfp4; + output_desc.scale_mode = ScaleMode::kBlock; + output_desc.rounding = config.rounding; + output_desc.stochastic_seed = config.stochastic_seed; + output_desc.block_size = kNvfp4BlockSize; + output_desc.scale_layout = ScaleLayout::kRowwise; + + const auto payload_bytes = output_desc.expectedPayloadBytes(); + const auto local_scale_count = output_desc.expectedLocalScaleCount(); + if (!output_desc.isMetadataValid() || !payload_bytes.has_value() || + !local_scale_count.has_value()) { + throw CudaPipelineError{ + "NVFP4 CUDA pipeline 无法构造合法的量化输出元数据。"}; + } + + constexpr std::uint64_t kMaxContainerSize = + static_cast(std::numeric_limits::max()); + if (*payload_bytes > kMaxContainerSize || + *local_scale_count > kMaxContainerSize) { + throw CudaPipelineError{ + "NVFP4 CUDA pipeline 的 packed payload 或 local scale 数量超出" + "当前 host/device 容器可表示范围。"}; + } + + return output_desc; +} + +} // namespace + +QuantizedTensor quantize_mxfp8_cuda( + const HostTensor& input, + const QuantizationConfig& config) { + return quantize_mxfp8_cuda_profiled(input, config).tensor; +} + +ProfiledQuantizationResult quantize_mxfp8_cuda_profiled( + const HostTensor& input, + const QuantizationConfig& config) { + // 阶段 0:即使经由内部入口调用,也保持与 CPU reference 相同的“先验证、 + // 再计算”约束。这样未来接入 H2D 与 kernel 后不会因错误描述或错误配置 + // 留下半成品 device buffer。 + validate_mxfp8_cuda_input(input); + validate_mxfp8_cuda_config(config); + + // 阶段 1:根据输入描述和方向配置验证未来输出的精确 layout。MXFP8 的 + // block_size 在上一步已固定为 32,因此 block mode 下每个逻辑量化 block + // 都能稳定映射为一个 warp 的 scale 工作单元。 + const QuantizedTensorDesc output_desc = + make_mxfp8_cuda_output_desc(input, config); + + try { + // 每次 profile 调用独占一条 non-blocking stream。所有 H2D、event、kernel + // 和 D2H 都显式提交到它,避免默认 stream 与 Thrust 隐式绑定导致计时区间 + // 覆盖其他任务,或发生不必要的跨 stream 同步。 + common::CudaStream stream{}; + const cudaStream_t cuda_stream = stream.get(); + + // 阶段 2:QDTENSOR I/O 已把输入统一为 host FP32;device_vector 只负责 + // 取得 device 所有权,随后 cudaMemcpyAsync 显式把 H2D 提交到本调用的 + // stream。原始 FP16/FP32 物理 dtype 仍保留在 desc 中,以便最终 QDWGT + // 正确记录 source_dtype。 + pipeline::DeviceQuantizationInput device_input{}; + device_input.desc = input.desc; + device_input.values.resize(input.values.size()); + copy_host_to_device_async( + input.values.data(), + thrust::raw_pointer_cast(device_input.values.data()), + input.values.size(), cuda_stream, "量化输入 values"); + if (!device_input.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 构造出了不自洽的 device 输入。"}; + } + + // 阶段 3:按已验证的精确字节数分配 E4M3 payload 和 E8M0 local scale。 + // MXFP8 不含 NVFP4 的 global_scale;allocate() 会将其保持为空。 + pipeline::DeviceQuantizedTensor device_output = + pipeline::DeviceQuantizedTensor::allocate(output_desc); + if (!device_output.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 构造出了不自洽的 device 输出。"}; + } + + // 阶段 4:block mode 能在一个 warp 内完成 amax、E8M0 scale 和 E4M3 + // 编码;tensor mode 则需要跨 CTA 的全局 amax reduction。二者共享同样的 + // 输入/输出 device 所有权对象,但 tensor mode 额外需要一次调用期间始终 + // 存活的 partial 规约工作区,因此必须进入不同的 CUDA launcher。 + std::optional + tensor_workspace{}; + // start event 排在 H2D 后面,因此后续 elapsed time 不包含输入传输;同一 + // stream 的顺序语义保证 event 一定在 copy 完成后才被记录。 + common::CudaEventTimer timer{cuda_stream}; + timer.start(); + switch (config.scale_mode) { + case ScaleMode::kBlock: + cuda::launch_mxfp8_block_quantize( + device_input, config, device_output, cuda_stream); + break; + + case ScaleMode::kTensor: + tensor_workspace.emplace( + pipeline::DeviceTensorQuantizationWorkspace::allocate( + cuda::mxfp8_tensor_partial_count())); + cuda::launch_mxfp8_tensor_quantize( + device_input, config, device_output, *tensor_workspace, cuda_stream); + break; + + case ScaleMode::kUnknown: + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 收到了未知的 scale_mode。"}; + } + timer.stop(); + const double kernel_ms = static_cast(timer.elapsedMilliseconds()); + + // 阶段 5:把 D2H 显式提交给同一条 stream,并在读取 host 数据前同步该 + // stream。先回传 scale:kernel 用 E8M0 的 0xff 标记包含 NaN/Inf 的逻辑 + // block;一旦发现该哨兵,就与 CPU reference 一样拒绝整个量化请求,避免 + // 把无数值意义的 payload 暴露出去。 + std::vector host_local_scales( + device_output.local_scales.size()); + copy_device_to_host_async( + thrust::raw_pointer_cast(device_output.local_scales.data()), + host_local_scales.data(), host_local_scales.size(), cuda_stream, + "量化 local_scales"); + stream.synchronize(); + for (std::size_t block_index = 0; + block_index < host_local_scales.size(); + ++block_index) { + if (formats::is_e8m0_nan(host_local_scales[block_index])) { + throw CudaPipelineError{ + "mxfp8 CUDA 量化不接受 NaN 或 Inf 输入;发现非法 " + "E8M0 scale 的量化 block 为 " + + std::to_string(block_index) + "。"}; + } + } + + // 所有 local scale 合法后,再回传同样由 kernel 写入的 E4M3 payload。 + // QuantizedTensor 是 CPU/GPU 共用的持久化结果类型;此处不额外转换编码, + // 因而 QDWGT writer 可直接写出这些真实字节。 + QuantizedTensor result{ + .desc = output_desc, + .payload = std::vector(device_output.payload.size()), + .local_scales = std::move(host_local_scales), + .global_scale = std::nullopt, + }; + copy_device_to_host_async( + thrust::raw_pointer_cast(device_output.payload.data()), + result.payload.data(), result.payload.size(), cuda_stream, + "量化 payload"); + stream.synchronize(); + if (!result.isConsistent()) { + throw CudaPipelineError{ + "mxfp8 CUDA pipeline D2H 后得到了不自洽的量化结果。"}; + } + + return { + .tensor = std::move(result), + .kernel_ms = kernel_ms, + }; + } catch (const CudaPipelineError&) { + throw; + } catch (const std::exception& error) { + // thrust 的 H2D/分配失败以 system_error 等标准异常报告。统一转换为公共 + // CUDA pipeline 错误,避免调用方必须了解 Thrust 的异常层级。D2H 的 + // device iterator 回传也遵循同一异常转换边界。 + throw CudaPipelineError{ + "mxfp8 CUDA pipeline 构造 device buffer 或执行 H2D/D2H 时失败:" + + std::string{error.what()}}; + } + +} + +QuantizedTensor quantize_nvfp4_cuda( + const HostTensor& input, + const QuantizationConfig& config) { + return quantize_nvfp4_cuda_profiled(input, config).tensor; +} + +ProfiledQuantizationResult quantize_nvfp4_cuda_profiled( + const HostTensor& input, + const QuantizationConfig& config) { + // 阶段 0:NVFP4 只能是 block mode,但同时必有整张张量的 FP32 global + // scale。先固定这两个看似相反、实际分属不同层次的约束。 + validate_nvfp4_cuda_input(input); + validate_nvfp4_cuda_config(config); + const QuantizedTensorDesc output_desc = + make_nvfp4_cuda_output_desc(input, config); + + try { + common::CudaStream stream{}; + const cudaStream_t cuda_stream = stream.get(); + + // 阶段 1:原始 QDTENSOR 的 FP16/FP32 已在 I/O 边界统一扩展为 host + // FP32;从迭代器构造 device_vector 即执行一次 H2D。 + pipeline::DeviceQuantizationInput device_input{}; + device_input.desc = input.desc; + device_input.values.resize(input.values.size()); + copy_host_to_device_async( + input.values.data(), + thrust::raw_pointer_cast(device_input.values.data()), + input.values.size(), cuda_stream, "量化输入 values"); + if (!device_input.isConsistent()) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline 构造出了不自洽的 device 输入。"}; + } + + // 阶段 2:allocate() 按 QuantizedTensorDesc 精确分配 ceil(N/2) 个 + // payload byte、每个 16 元素 block 一个 E4M3 scale,以及一个 FP32 + // global_scale device scalar。 + pipeline::DeviceQuantizedTensor device_output = + pipeline::DeviceQuantizedTensor::allocate(output_desc); + if (!device_output.isConsistent() || !device_output.global_scale.has_value()) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline 构造出了不自洽的 device 输出。"}; + } + + // 阶段 3:NVFP4 即使配置为 block,也不能像 MXFP8 block mode 那样在 + // 单 kernel 内完成全部量化,因为每个 local scale 还依赖 tensor-wide + // global scale。工作区必须覆盖三阶段 kernel 及后续 D2H。 + pipeline::DeviceTensorQuantizationWorkspace workspace = + pipeline::DeviceTensorQuantizationWorkspace::allocate( + cuda::nvfp4_tensor_partial_count()); + common::CudaEventTimer timer{cuda_stream}; + timer.start(); + cuda::launch_nvfp4_block_quantize( + device_input, config, device_output, workspace, cuda_stream); + timer.stop(); + const double kernel_ms = static_cast(timer.elapsedMilliseconds()); + + // 阶段 4:首先 D2H 唯一的 global scalar。NaN/Inf 输入会在全局规约 + // finalize kernel 中写 canonical NaN;必须在复制 payload 前拒绝。 + std::vector host_global_scale(device_output.global_scale->size()); + copy_device_to_host_async( + thrust::raw_pointer_cast(device_output.global_scale->data()), + host_global_scale.data(), host_global_scale.size(), cuda_stream, + "量化 global_scale"); + stream.synchronize(); + if (host_global_scale.size() != 1U || + !formats::fp32::is_finite(host_global_scale.front()) || + host_global_scale.front() <= 0.0F) { + throw CudaPipelineError{ + "nvfp4 CUDA 量化不接受 NaN 或 Inf 输入,或生成了非法的 " + "FP32 global_scale。"}; + } + + std::vector host_local_scales( + device_output.local_scales.size()); + copy_device_to_host_async( + thrust::raw_pointer_cast(device_output.local_scales.data()), + host_local_scales.data(), host_local_scales.size(), cuda_stream, + "量化 local_scales"); + stream.synchronize(); + for (std::size_t block_index = 0U; + block_index < host_local_scales.size(); + ++block_index) { + if (formats::is_e4m3_nan(host_local_scales[block_index])) { + throw CudaPipelineError{ + "nvfp4 CUDA 量化不接受 NaN 或 Inf 输入;发现非法 " + "E4M3 local scale 的量化 block 为 " + + std::to_string(block_index) + "。"}; + } + } + + // 阶段 5:所有 scale 均已通过 D2H 校验后才回传 packed payload。每个 + // 字节已经是两个 E2M1 code 的最终磁盘布局,无须 CPU 再打包。 + QuantizedTensor result{ + .desc = output_desc, + .payload = std::vector(device_output.payload.size()), + .local_scales = std::move(host_local_scales), + .global_scale = host_global_scale.front(), + }; + copy_device_to_host_async( + thrust::raw_pointer_cast(device_output.payload.data()), + result.payload.data(), result.payload.size(), cuda_stream, + "量化 payload"); + stream.synchronize(); + if (!result.isConsistent()) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline D2H 后得到了不自洽的量化结果。"}; + } + + return { + .tensor = std::move(result), + .kernel_ms = kernel_ms, + }; + } catch (const CudaPipelineError&) { + throw; + } catch (const std::exception& error) { + throw CudaPipelineError{ + "nvfp4 CUDA pipeline 构造 device buffer 或执行 H2D/D2H 时失败:" + + std::string{error.what()}}; + } +} + +} // namespace quant_dequant::pipeline_detail diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/quant_dequant.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/quant_dequant.cpp" new file mode 100644 index 00000000..ca4c1475 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/quant_dequant.cpp" @@ -0,0 +1,9 @@ +#include "quant_dequant/version.hpp" + +namespace quant_dequant { + +const char* version() noexcept { + return "0.1.0"; +} + +} // namespace quant_dequant diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/mxfp8_reference.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/mxfp8_reference.cpp" new file mode 100644 index 00000000..e7359f5b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/mxfp8_reference.cpp" @@ -0,0 +1,365 @@ +#include "reference_detail.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "formats/mxfp8_codec.cuh" +namespace quant_dequant::reference_detail { + + +namespace { +/** + * @brief 将无符号计数安全转换为 vector 所使用的 std::size_t。 + * + * `TensorDesc` 中的维度使用 uint64_t,而 host 容器使用 std::size_t。 + * 正常情况下 dispatcher 已经保证输入 values 可以被 host 容器持有; + * 此处仍做一次检查,使 reference 后端即使被内部错误调用也不会发生截断。 + * + * @param count 待转换的非负计数。 + * @param field_name 用于异常信息的字段名称。 + * @return 可安全作为 vector 长度或下标的 std::size_t。 + * @throws ReferenceError count 超出当前平台 std::size_t 范围时抛出。 + */ +[[nodiscard]] std::size_t checked_size( + const std::uint64_t count, + const std::string_view field_name) { + constexpr std::uint64_t kMaxSize = + static_cast(std::numeric_limits::max()); + + if (count > kMaxSize) { + throw ReferenceError{ + std::string{"MXFP8 CPU reference 的 \""} + + std::string{field_name} + + "\" 超出当前 host 容器可表示范围。"}; + } + + return static_cast(count); +} +/** + * @brief 计算一段连续输入数据的最大绝对值,并拒绝非有限输入。 + * + * 题目和 format_spec.md 规定第一版只接受有限 FP16/FP32 输入。HostTensor + * 的 values 虽然统一是 FP32,但仍可能包含由外部调用方构造的 NaN 或 Inf; + * 必须在写出任何量化结果前拒绝它们。 + * + * @param values 待扫描的连续 row-major 元素区间,不能为空。 + * @param first_linear_index values[0] 在完整张量中的线性下标,用于错误定位。 + * @return 区间内所有元素绝对值的最大值;全零区间返回 +0。 + * @throws ReferenceError values 中存在 NaN、+Inf 或 -Inf 时抛出。 + */ +[[nodiscard]] float calculate_finite_amax( + const std::span values, + const std::uint64_t first_linear_index) { + float amax = 0.0F; + + for (std::size_t offset = 0U; offset < values.size(); ++offset) { + const float value = values[offset]; + + if (!formats::fp32::is_finite(value)) { + throw ReferenceError{ + "MXFP8 CPU reference 不接受 NaN 或 Inf 输入;" + "发现非有限值的线性下标为 " + + std::to_string(first_linear_index + + static_cast(offset)) + + "。"}; + } + + // absolute_value 通过清除 FP32 符号位求幅值,能正确处理 -0。 + const float magnitude = formats::fp32::absolute_value(value); + if (magnitude > amax) { + amax = magnitude; + } + } + + return amax; +} +/** + * @brief 根据一段有限 FP32 数据生成一个 MXFP8 E8M0 scale code。 + * + * 复用 codec 的 `compute_mxfp8_scale_code()`,其数值语义为: + * + * amax -> ceil_to_e8m0(amax / 448) + * + * 因而得到的 scale 不会小于该范围实际所需 scale,避免最大元素在 E4M3 + * 编码阶段因 scale 偏小而额外溢出。 + * + * @param values 一个 tensor 或一个有效 rowwise block 的数据。 + * @param first_linear_index values[0] 的全局线性下标。 + * @return 合法的 E8M0 scale byte;全零区间返回 0x00。 + * @throws ReferenceError 输入出现非有限值或内部得到非法 E8M0 code 时抛出。 + */ +[[nodiscard]] std::uint8_t make_mxfp8_scale_code( + const std::span values, + const std::uint64_t first_linear_index) { + const float amax = calculate_finite_amax(values, first_linear_index); + const std::uint8_t scale_code = + formats::compute_mxfp8_scale_code(amax); + + // 有限输入按当前公式绝不应产生 0xff;保留检查以避免内部规则被修改后 + // 静默写出 NaN scale。 + if (formats::is_e8m0_nan(scale_code)) { + throw ReferenceError{ + "MXFP8 CPU reference 为有限输入生成了非法 E8M0 NaN scale。"}; + } + + return scale_code; +} +/** + * @brief 根据量化配置和输入描述构造 MXFP8 QuantizedTensor 元数据。 + * + * @param input_desc 原始 FP16/FP32 输入张量的物理描述。 + * @param config 已经由 dispatcher 校验过的量化方向配置。 + * @return 尚未填充 payload 和 local_scales 的 MXFP8 描述。 + */ +[[nodiscard]] QuantizedTensorDesc make_mxfp8_desc( + const TensorDesc& input_desc, + const QuantizationConfig& config) { + return { + .source_desc = input_desc, + .format = QuantFormat::kMxfp8, + .scale_mode = config.scale_mode, + .rounding = config.rounding, + .stochastic_seed = config.stochastic_seed, + .block_size = config.block_size, + .scale_layout = ScaleLayout::kRowwise, + }; +} +}// namespace + +QuantizedTensor quantize_mxfp8_reference( + const HostTensor& input, + const QuantizationConfig& config) { + // 阶段 0:dispatcher 已验证输入张量的 shape、dtype 与 values 长度; + // 此处再验证格式专用实现的前提,避免内部调用绕过 public API 时产生歧义。 + if (!config.isValid() || config.format != QuantFormat::kMxfp8) { + throw ReferenceError{"MXFP8 CPU reference 收到了非法量化配置。"}; + } + + // 阶段 1:把方向配置和输入文件描述组合为 QuantizedTensor 的元数据。 + // 这一步尚未计算任何数值;desc 决定 payload 和 local_scales 的精确长度。 + const QuantizedTensorDesc desc = make_mxfp8_desc(input.desc,config); + const auto expected_payload_bytes = desc.expectedPayloadBytes(); + const auto expected_local_scale_count = desc.expectedLocalScaleCount(); + + // expectedPayloadBytes() 与 expectedLocalScaleCount() 同时完成格式、shape、 + // rowwise block 组织和溢出检查。任何一项无法推导都不能继续分配输出数组。 + if (!desc.isMetadataValid() || !expected_payload_bytes.has_value() || + !expected_local_scale_count.has_value()) { + throw ReferenceError{"MXFP8 CPU reference 无法构造合法量化元数据。"}; + } + + // 阶段 2:一次性分配最终结果。MXFP8 每个逻辑元素恰好占一个 payload byte; + // local_scales 在 tensor 模式有一项,在 block 模式按 rowwise block 排列。 + // MXFP8 没有 NVFP4 式 global_scale,因此必须保持 std::nullopt。 + QuantizedTensor output{ + .desc = desc, + .payload = std::vector( + checked_size(*expected_payload_bytes, "payload 字节数"), + std::uint8_t{0U}), + .local_scales = std::vector( + checked_size(*expected_local_scale_count, "local scale 数量"), + std::uint8_t{0U}), + .global_scale = std::nullopt, + }; + + // 这些是 host vector 下标使用的 size_t 版本。checked_size() 保证由 uint64 + // 文件元数据转换而来时不会在当前平台截断;row_count * column_count 已由 + // dispatcher 的 values 长度检查间接保证可安全表示。 + const std::size_t row_count = + checked_size(input.desc.num_rows, "行数"); + const std::size_t column_count = + checked_size(input.desc.num_cols, "列数"); + const std::size_t block_size = + static_cast(config.block_size); + + // 阶段 3:先完成所有 local scale 的计算,再编码任何元素。这样每个 payload + // 元素在阶段 4 都能按唯一确定的 scale 索引编码;calculate_finite_amax() 也会 + // 在这个阶段拒绝 NaN/Inf,函数不会向调用方返回半成品 QuantizedTensor。 + if (config.scale_mode == ScaleMode::kTensor) { + // tensor mode 仅保存一个 scale,覆盖整个 row-major values 数组。 + // 线性起点是 0,供非有限值异常报告原始元素位置。 + output.local_scales.front() = make_mxfp8_scale_code( + std::span{input.values.data(), input.values.size()}, + 0U); + } else { + const auto blocks_per_row_u64 = desc.blocksPerRow(); + if (!blocks_per_row_u64.has_value()) { + throw ReferenceError{ + "MXFP8 block 模式无法推导每行的 block 数量。"}; + } + + const std::size_t blocks_per_row = + checked_size(*blocks_per_row_u64, "每行 block 数量"); + + for (std::size_t row = 0U; row < row_count; ++row) { + // row_offset 是当前行第 0 列在完整 row-major values 中的线性下标。 + const std::size_t row_offset = row * column_count; + + for (std::size_t block = 0U; block < blocks_per_row; ++block) { + // block_column_offset 是该 block 在本行中的起始列;它不会跨行。 + const std::size_t block_column_offset = block * block_size; + // 最后一个 block 可以不足 32 元素。只扫描真实元素即可:逻辑补的 + // 零不会改变 amax,也不应该写入 payload。 + const std::size_t valid_block_elements = std::min( + block_size, column_count - block_column_offset); + // first_element 是当前 block 第一个真实元素的全局线性下标。 + const std::size_t first_element = + row_offset + block_column_offset; + // local_scales 的 rowwise 顺序是先整行第 0 block、第 1 block, + // 再进入下一行;它必须与后续 payload pass 的索引公式一致。 + const std::size_t scale_index = + row * blocks_per_row + block; + + // 尾 block 的 span 只包含真实数据。规范中的零填充只是逻辑概念, + // 全零不会改变 amax,所以无需真的分配或扫描 padding。 + output.local_scales[scale_index] = make_mxfp8_scale_code( + std::span{ + input.values.data() + first_element, + valid_block_elements, + }, + static_cast(first_element)); + } + } + } + + // 阶段 4:逐元素生成 E4M3 payload。block 模式需要再次得到每行 block 数, + // 以把任意列映射回阶段 3 已写入的 local scale;tensor 模式固定使用索引 0。 + std::size_t blocks_per_row = 0U; + if (config.scale_mode == ScaleMode::kBlock) { + blocks_per_row = checked_size( + *desc.blocksPerRow(), "每行 block 数量"); + } + for (std::size_t row = 0U; row < row_count; ++row) { + const std::size_t row_offset = row * column_count; + + for (std::size_t column = 0U; column < column_count; ++column) { + // linear_index 同时是 input.values 和 MXFP8 output.payload 的下标, + // 因为两者都采用无 padding 的 row-major 连续布局。 + const std::size_t linear_index = row_offset + column; + // block 模式的 column / block_size 给出当前元素在该行属于第几个 + // block;row * blocks_per_row 再把它平移到 local_scales 全局下标。 + const std::size_t scale_index = + config.scale_mode == ScaleMode::kTensor + ? 0U + : row * blocks_per_row + column / block_size; + + // nearest 模式不读取随机数。stochastic 模式将配置 seed 与全局线性 + // 下标映射为无状态 [0, 1) 随机数,保证未来 CPU/CUDA 可逐元素复现。 + const float uniform_random = + config.rounding == RoundingMode::kStochastic + ? formats::mxfp8_stochastic_uniform_for_element( + config.stochastic_seed, + static_cast(linear_index)) + : 0.0F; + + // codec 内部负责 E8M0 decode、value / scale、E4M3 的 RNE 或 + // stochastic rounding、饱和,以及 -0 -> +0 的项目规范化。 + output.payload[linear_index] = formats::encode_mxfp8_element( + input.values[linear_index], + output.local_scales[scale_index], + config.rounding, + uniform_random); + } + } + + // 阶段 5:在返回前再次验证元数据、数组长度和 MXFP8 专属不变量。这个检查 + // 还能防止未来重构索引公式时把不自洽的结果交给 QDWGT 写入器。 + if (!output.isConsistent()) { + throw ReferenceError{ + "MXFP8 CPU reference 构造出了不自洽的 QuantizedTensor。"}; + } + + return output; +} + +HostTensor dequantize_mxfp8_reference( + const QuantizedTensor& input, + const DequantizationConfig& config) { + // 阶段 0:public dispatcher 已完成这两项检查;这里仍保留格式专用的 + // 前置条件验证,使内部调用无法绕过 API 时也不会把其他格式误作 MXFP8。 + if (!input.isConsistent() || !config.isValid() || + input.desc.format != QuantFormat::kMxfp8) { + throw ReferenceError{ + "MXFP8 CPU reference 反量化收到了非法输入或输出配置。"}; + } + + // 阶段 1:反量化不改变矩阵形状,只把 QDWGT 中记录的原始输入 dtype 替换为 + // 调用者所要求的输出物理 dtype。HostTensor 的 values 仍统一持有 FP32, + // 真正转写为 FP16/BF16/FP32 payload 的工作由 write_dequantized_tensor() + // 在 QDTENSOR I/O 边界完成。 + TensorDesc output_desc = input.desc.source_desc; + output_desc.dtype = config.output_type; + const auto element_count = output_desc.elementCount(); + if (!output_desc.isValid() || !element_count.has_value()) { + throw ReferenceError{"MXFP8 CPU reference 无法构造合法输出张量描述。"}; + } + + HostTensor output{ + .desc = output_desc, + .values = std::vector( + checked_size(*element_count, "反量化元素数量"), 0.0F), + }; + + // 这些 size_t 版本只用于 host vector 下标。input.isConsistent() 已保证 + // payload 的逻辑长度正确,checked_size() 额外防止 uint64_t 元数据在当前 + // 平台截断。 + const std::size_t row_count = + checked_size(input.desc.source_desc.num_rows, "行数"); + const std::size_t column_count = + checked_size(input.desc.source_desc.num_cols, "列数"); + const std::size_t block_size = + static_cast(input.desc.block_size); + + // 阶段 2:建立与量化阶段完全相同的 local scale 索引规则。tensor 模式中 + // 所有元素共享 local_scales[0];block 模式中第 (row, column) 个元素使用 + // row * blocks_per_row + column / block_size。尾 block 不含 padding,仍共享 + // 自己对应的一项 scale。 + std::size_t blocks_per_row = 0U; + if (input.desc.scale_mode == ScaleMode::kBlock) { + const auto blocks_per_row_u64 = input.desc.blocksPerRow(); + if (!blocks_per_row_u64.has_value()) { + throw ReferenceError{ + "MXFP8 block 模式无法推导每行的 block 数量。"}; + } + + blocks_per_row = checked_size( + *blocks_per_row_u64, "每行 block 数量"); + } + + // 阶段 3:payload 与 output.values 都是无 padding 的连续 row-major 数组, + // 所以 linear_index 同时是两者下标。codec 解码 E4M3 后乘以 E8M0 scale; + // 若外部 QDWGT 带有合法长度但含 NaN code,IEEE FP32 运算会自然生成 NaN, + // 这符合 format_spec.md 对特殊编码的解码语义,不应在此静默改写。 + for (std::size_t row = 0U; row < row_count; ++row) { + const std::size_t row_offset = row * column_count; + + for (std::size_t column = 0U; column < column_count; ++column) { + const std::size_t linear_index = row_offset + column; + const std::size_t scale_index = + input.desc.scale_mode == ScaleMode::kTensor + ? 0U + : row * blocks_per_row + column / block_size; + + output.values[linear_index] = formats::decode_mxfp8_element( + input.payload[linear_index], input.local_scales[scale_index]); + } + } + + // 阶段 4:在返回前核对 HostTensor 自己的形状、物理 dtype 与 FP32 数组长度。 + // 这与 dispatcher 的输入检查相呼应,能及早发现未来修改索引或元数据逻辑时的 + // 意外错误。 + if (output.values.size() != checked_size(*element_count, "反量化元素数量")) { + throw ReferenceError{ + "MXFP8 CPU reference 构造出了长度不自洽的反量化结果。"}; + } + + return output; +} + +} // namespace quant_dequant::reference_detail diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/nvfp4_reference.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/nvfp4_reference.cpp" new file mode 100644 index 00000000..84a44085 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/nvfp4_reference.cpp" @@ -0,0 +1,329 @@ +#include "reference_detail.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "formats/nvfp4_codec.cuh" + +namespace quant_dequant::reference_detail { +namespace { + +/** + * @brief 将 uint64 元数据长度安全转换为 host vector 使用的 size_t。 + * + * @param count 待转换的元素或字节数量。 + * @param field_name 用于诊断的字段名称。 + * @return 可安全用于 host 容器长度或下标的 size_t。 + * @throws ReferenceError count 超出当前平台 host 容器可表示范围时抛出。 + */ +[[nodiscard]] std::size_t checked_size(const std::uint64_t count, + const std::string_view field_name) { + constexpr std::uint64_t kMaxSize = + static_cast(std::numeric_limits::max()); + if (count > kMaxSize) { + throw ReferenceError{ + "NVFP4 CPU reference 的 \"" + std::string{field_name} + + "\" 超出当前 host 容器可表示范围。"}; + } + + return static_cast(count); +} + +/** + * @brief 计算连续有限 FP32 值的最大绝对值,并定位非有限输入。 + * + * @param values 待扫描的连续 row-major 元素区间。 + * @param first_linear_index values[0] 在完整张量中的线性下标。 + * @return 区间的 amax;全零区间返回正零。 + * @throws ReferenceError 发现 NaN、+Inf 或 -Inf 时抛出。 + */ +[[nodiscard]] float calculate_finite_amax(const std::span values, + const std::uint64_t first_linear_index) { + float amax = 0.0F; + for (std::size_t offset = 0U; offset < values.size(); ++offset) { + const float value = values[offset]; + if (!formats::fp32::is_finite(value)) { + throw ReferenceError{ + "NVFP4 CPU reference 不接受 NaN 或 Inf 输入;" + "发现非有限值的线性下标为 " + + std::to_string(first_linear_index + + static_cast(offset)) + + "。"}; + } + + const float magnitude = formats::fp32::absolute_value(value); + amax = amax > magnitude ? amax : magnitude; + } + + return amax; +} + +/** + * @brief 从已验证的输入描述和量化配置构造严格 NVFP4 输出元数据。 + * + * @param input_desc 原始 QDTENSOR 的形状和物理输入 dtype。 + * @param config 已通过公共 dispatcher 校验的 NVFP4 配置。 + * @return 尚未填充 payload、scale 的 QuantizedTensorDesc。 + */ +[[nodiscard]] QuantizedTensorDesc make_nvfp4_desc( + const TensorDesc& input_desc, + const QuantizationConfig& config) { + return { + .source_desc = input_desc, + .format = QuantFormat::kNvfp4, + .scale_mode = ScaleMode::kBlock, + .rounding = config.rounding, + .stochastic_seed = config.stochastic_seed, + .block_size = kNvfp4BlockSize, + .scale_layout = ScaleLayout::kRowwise, + }; +} + +/** + * @brief 按 row-major 元素坐标返回其 rowwise 16 元素 block 的 scale 下标。 + * + * @param row 当前元素所在行。 + * @param column 当前元素所在列。 + * @param blocks_per_row 每行的 NVFP4 logical block 数量。 + * @return local_scales 内的零起始下标。 + */ +[[nodiscard]] constexpr std::size_t local_scale_index( + const std::size_t row, + const std::size_t column, + const std::size_t blocks_per_row) noexcept { + return row * blocks_per_row + + column / static_cast(kNvfp4BlockSize); +} + +/** + * @brief 为一个线性元素下标生成与 CUDA 相同的 stochastic rounding 随机数。 + * + * 当前项目让 MXFP8 与 NVFP4 共用同一 seed/index 到 `[0,1)` 的无状态映射; + * format 不参与随机数状态,因此同一元素在两条后端中不会受线程调度影响。 + * + * @param config 当前 NVFP4 量化配置。 + * @param linear_index 元素的 row-major 线性下标。 + * @return nearest 模式返回 0,stochastic 模式返回确定性 `[0,1)` 随机数。 + */ +[[nodiscard]] float element_uniform_random(const QuantizationConfig& config, + const std::uint64_t linear_index) noexcept { + return config.rounding == RoundingMode::kStochastic + ? formats::nvfp4_stochastic_uniform_for_element( + config.stochastic_seed, linear_index) + : 0.0F; +} + +} // namespace + +QuantizedTensor quantize_nvfp4_reference( + const HostTensor& input, + const QuantizationConfig& config) { + // 阶段 0:NVFP4 没有 tensor local-scale 模式。公共配置校验也会拒绝它, + // 此处额外固定格式前提,防止内部调用绕过 dispatcher。 + if (!config.isValid() || config.format != QuantFormat::kNvfp4 || + config.scale_mode != ScaleMode::kBlock || + config.block_size != kNvfp4BlockSize) { + throw ReferenceError{ + "NVFP4 CPU reference 要求 format = nvfp4、block_size = 16 且 " + "scale_mode = block。"}; + } + + // 阶段 1:先验证并构造由文件、CPU reference 与 CUDA pipeline 共同使用的 + // 描述。NVFP4 的 expectedPayloadBytes() 在这里确保 payload 真正按两元素 + // 每字节分配,而非以一个 uint8_t 保存一个逻辑 FP4 元素。 + const QuantizedTensorDesc desc = make_nvfp4_desc(input.desc, config); + const auto expected_payload_bytes = desc.expectedPayloadBytes(); + const auto expected_local_scale_count = desc.expectedLocalScaleCount(); + const auto blocks_per_row_u64 = desc.blocksPerRow(); + if (!desc.isMetadataValid() || !expected_payload_bytes.has_value() || + !expected_local_scale_count.has_value() || !blocks_per_row_u64.has_value()) { + throw ReferenceError{ + "NVFP4 CPU reference 无法构造合法的量化输出元数据。"}; + } + + const std::size_t row_count = checked_size(input.desc.num_rows, "行数"); + const std::size_t column_count = checked_size(input.desc.num_cols, "列数"); + const std::size_t blocks_per_row = + checked_size(*blocks_per_row_u64, "每行 block 数量"); + const std::size_t element_count = input.values.size(); + + // 阶段 2:全张量扫描同时完成 amax 和非有限输入拒绝。global_scale 不是 + // scale_mode=tensor:它始终与后续每个 16 元素 local scale 相乘。 + const float tensor_amax = calculate_finite_amax( + std::span{input.values.data(), input.values.size()}, 0U); + const float global_scale = formats::compute_nvfp4_global_scale(tensor_amax); + if (!formats::fp32::is_finite(global_scale) || global_scale <= 0.0F) { + throw ReferenceError{ + "NVFP4 CPU reference 无法为有限输入构造有限正的 global_scale。"}; + } + + QuantizedTensor output{ + .desc = desc, + .payload = std::vector( + checked_size(*expected_payload_bytes, "payload 字节数"), 0U), + .local_scales = std::vector( + checked_size(*expected_local_scale_count, "local scale 数量"), 0U), + .global_scale = global_scale, + }; + + // 阶段 3:每行独立切为 1×16 block。尾 block 只扫描真实元素;逻辑补零 + // 不会改变 amax,也绝不能出现在 payload 中。 + for (std::size_t row = 0U; row < row_count; ++row) { + const std::size_t row_offset = row * column_count; + for (std::size_t block_in_row = 0U; + block_in_row < blocks_per_row; + ++block_in_row) { + const std::size_t first_column = + block_in_row * static_cast(kNvfp4BlockSize); + const std::size_t valid_count = std::min( + static_cast(kNvfp4BlockSize), + column_count - first_column); + const std::size_t first_linear_index = row_offset + first_column; + const float block_amax = calculate_finite_amax( + std::span{ + input.values.data() + first_linear_index, valid_count}, + static_cast(first_linear_index)); + const std::uint8_t local_scale = + formats::compute_nvfp4_local_scale_code( + block_amax, global_scale); + if (formats::is_e4m3_nan(local_scale)) { + throw ReferenceError{ + "NVFP4 CPU reference 为有限输入生成了非法 E4M3 local scale。"}; + } + + output.local_scales[row * blocks_per_row + block_in_row] = + local_scale; + } + } + + // 阶段 4:两个相邻线性元素共享一个物理 payload byte。先分别用各自行/列 + // 推导 local scale,再由 pack_e2m1_nibbles() 一次性写出低/高 nibble;这 + // 与后续 CUDA 的“偶数 lane 独占 packed store”拥有同一个字节布局。 + for (std::size_t even_index = 0U; + even_index < element_count; + even_index += 2U) { + const std::size_t even_row = even_index / column_count; + const std::size_t even_column = even_index % column_count; + const std::size_t even_scale_index = local_scale_index( + even_row, even_column, blocks_per_row); + const std::uint8_t low_nibble = formats::encode_nvfp4_element( + input.values[even_index], + output.local_scales[even_scale_index], + global_scale, + config.rounding, + element_uniform_random(config, static_cast(even_index))); + + const std::size_t odd_index = even_index + 1U; + std::uint8_t high_nibble = 0x00U; + if (odd_index < element_count) { + const std::size_t odd_row = odd_index / column_count; + const std::size_t odd_column = odd_index % column_count; + const std::size_t odd_scale_index = local_scale_index( + odd_row, odd_column, blocks_per_row); + high_nibble = formats::encode_nvfp4_element( + input.values[odd_index], + output.local_scales[odd_scale_index], + global_scale, + config.rounding, + element_uniform_random(config, static_cast(odd_index))); + } + + output.payload[even_index / 2U] = formats::pack_e2m1_nibbles( + low_nibble, high_nibble); + } + + if (!output.isConsistent()) { + throw ReferenceError{ + "NVFP4 CPU reference 构造出了不自洽的 QuantizedTensor。"}; + } + + return output; +} + +HostTensor dequantize_nvfp4_reference( + const QuantizedTensor& input, + const DequantizationConfig& config) { + // 阶段 0:public dispatcher 会执行同样的基本校验;格式专用函数仍自行固定 + // NVFP4 的不变量,防止内部调用把其他量化布局误派发到 E2M1 解码规则。 + if (!input.isConsistent() || !config.isValid() || + input.desc.format != QuantFormat::kNvfp4 || + input.desc.scale_mode != ScaleMode::kBlock || + input.desc.block_size != kNvfp4BlockSize || + !input.global_scale.has_value()) { + throw ReferenceError{ + "NVFP4 CPU reference 反量化收到了非法输入或输出配置。"}; + } + + // 阶段 1:QDWGT 的 source_desc 记录量化前形状;反量化只替换请求的物理 + // 输出 dtype。所有 reference 数值仍保存在统一 FP32 HostTensor::values 中。 + TensorDesc output_desc = input.desc.source_desc; + output_desc.dtype = config.output_type; + const auto element_count_u64 = output_desc.elementCount(); + const auto blocks_per_row_u64 = input.desc.blocksPerRow(); + if (!output_desc.isValid() || !is_supported_output_dtype(output_desc.dtype) || + !element_count_u64.has_value() || !blocks_per_row_u64.has_value()) { + throw ReferenceError{ + "NVFP4 CPU reference 无法构造合法的反量化输出描述或 scale 布局。"}; + } + + const std::size_t element_count = + checked_size(*element_count_u64, "反量化元素数量"); + const std::size_t column_count = + checked_size(input.desc.source_desc.num_cols, "列数"); + const std::size_t blocks_per_row = + checked_size(*blocks_per_row_u64, "每行 block 数量"); + const float global_scale = *input.global_scale; + + HostTensor output{ + .desc = output_desc, + .values = std::vector(element_count, 0.0F), + }; + + // 阶段 2:一个物理 byte 刚好描述两个相邻的 row-major 逻辑元素。每次先 + // 解包 low/high nibble,再为偶数和奇数元素分别恢复坐标与 local-scale 下标。 + // 因此列数为奇数时,行尾 low nibble 和下一行开头 high nibble 仍会使用各自 + // 正确的 rowwise 16 元素 block scale;最后单元素 tail 的高 nibble 不读取。 + for (std::size_t payload_index = 0U; + payload_index < input.payload.size(); + ++payload_index) { + const std::size_t even_index = payload_index * 2U; + const std::size_t even_row = even_index / column_count; + const std::size_t even_column = even_index % column_count; + const std::size_t even_scale_index = local_scale_index( + even_row, even_column, blocks_per_row); + const std::uint8_t packed_byte = input.payload[payload_index]; + + output.values[even_index] = formats::decode_nvfp4_element( + formats::unpack_e2m1_low_nibble(packed_byte), + input.local_scales[even_scale_index], + global_scale); + + const std::size_t odd_index = even_index + 1U; + if (odd_index < element_count) { + const std::size_t odd_row = odd_index / column_count; + const std::size_t odd_column = odd_index % column_count; + const std::size_t odd_scale_index = local_scale_index( + odd_row, odd_column, blocks_per_row); + output.values[odd_index] = formats::decode_nvfp4_element( + formats::unpack_e2m1_high_nibble(packed_byte), + input.local_scales[odd_scale_index], + global_scale); + } + } + + if (output.values.size() != element_count) { + throw ReferenceError{ + "NVFP4 CPU reference 构造出了长度不自洽的反量化结果。"}; + } + + return output; +} + +} // namespace quant_dequant::reference_detail diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_detail.hpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_detail.hpp" new file mode 100644 index 00000000..806f1320 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_detail.hpp" @@ -0,0 +1,72 @@ +#pragma once + +#include "quant_dequant/quantize.hpp" + +namespace quant_dequant::reference_detail { + +/** + * @brief 使用 MXFP8-E4M3/E8M0 规则量化一个 host FP32 张量。 + * + * block 模式下,每行独立以 32 元素切分;尾 block 只扫描真实元素,不在 + * payload 中写入逻辑补零。tensor 模式下,整张矩阵共享一个 E8M0 scale。 + * + * 量化结果的 payload 是一个元素一个 E4M3 byte,local_scales 则保存一个 + * tensor scale 或按 row-major block 顺序保存多个 E8M0 code。 + * + * @param input 已通过 dispatcher 形状、dtype 和 values 长度校验的 host 张量。 + * @param config 已通过 dispatcher 校验、且 format 为 kMxfp8 的量化配置。 + * @return 与 input 具有相同逻辑形状的完整 MXFP8 QuantizedTensor。 + * @throws ReferenceError 输入包含 NaN/Inf、元数据无法构造或内部结果不自洽时抛出。 + */ +[[nodiscard]] QuantizedTensor quantize_mxfp8_reference( + const HostTensor& input, + const QuantizationConfig& config); + +/** + * @brief 使用严格 NVFP4-E2M1/E4M3/FP32 规则量化一个 host FP32 张量。 + * + * NVFP4 始终使用 rowwise 16 元素 block:先以整张张量 amax 构造一个 FP32 + * decode global scale,再以每个 block 的 amax 构造一个 E4M3 local scale,最后 + * 将两个 E2M1 元素按低/高 nibble 打包为一个 payload byte。 + * + * @param input 已通过 dispatcher 形状、dtype 和 values 长度校验的 host 张量。 + * @param config 已通过 dispatcher 校验、且 format 为 kNvfp4 的量化配置。 + * @return 与 input 具有相同逻辑形状的完整 NVFP4 QuantizedTensor。 + * @throws ReferenceError 输入包含 NaN/Inf、元数据无法构造或内部结果不自洽时抛出。 + */ +[[nodiscard]] QuantizedTensor quantize_nvfp4_reference( + const HostTensor& input, + const QuantizationConfig& config); + +/** + * @brief 使用 MXFP8 的 CPU reference 反量化一个低精度张量。 + * + * 此接口仅供 `reference_dispatch.cpp` 调用。它解码 E8M0 local scale 与 E4M3 + * payload,生成 FP32 `HostTensor::values`。 + * + * @param input 已通过 dispatcher 校验、且 `format == kMxfp8` 的量化结果。 + * @param config 已通过 dispatcher 校验的反量化配置。 + * @return 带有目标输出 dtype 描述的 host FP32 张量。 + * @throws ReferenceError 输入元数据、payload/scale 数量或输出配置不合法时抛出。 + */ +[[nodiscard]] HostTensor dequantize_mxfp8_reference( + const QuantizedTensor& input, + const DequantizationConfig& config); + +/** + * @brief 使用严格 NVFP4-E2M1/E4M3/FP32 规则反量化一个 host 量化张量。 + * + * 该实现按物理 packed payload byte 遍历:低四位解码一个偶数线性元素,高四位 + * 解码下一个奇数线性元素。即使矩阵列数为奇数、一个 byte 跨越两行,也会分别由 + * 两个元素自己的 `(row, column)` 推导 rowwise local-scale 下标。 + * + * @param input 已通过 dispatcher 校验、且 `format == kNvfp4` 的量化结果。 + * @param config 已通过 dispatcher 校验的反量化配置。 + * @return 带有目标输出 dtype 描述的 host FP32 张量。 + * @throws ReferenceError 输入元数据、global scale、输出配置不合法时抛出。 + */ +[[nodiscard]] HostTensor dequantize_nvfp4_reference( + const QuantizedTensor& input, + const DequantizationConfig& config); + +} // namespace quant_dequant::reference_detail diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_dispatch.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_dispatch.cpp" new file mode 100644 index 00000000..90ee69cc --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/src/reference/reference_dispatch.cpp" @@ -0,0 +1,80 @@ +#include "quant_dequant/quantize.hpp" + +#include +#include +#include +#include + +#include "reference_detail.hpp" + +namespace quant_dequant { +namespace { + +/** + * @brief 验证 CPU reference 量化入口使用的 host 张量。 + * + * @param input 待量化的 host 张量。 + * @throws ReferenceError shape、输入 dtype 或 FP32 values 长度不合法时抛出。 + */ +void validate_reference_quantization_input(const HostTensor& input) { + const auto element_count = input.desc.elementCount(); + if (!input.desc.isValid() || !is_supported_input_dtype(input.desc.dtype) || + !element_count.has_value() || + *element_count > std::numeric_limits::max() || + input.values.size() != static_cast(*element_count)) { + throw ReferenceError{ + "CPU reference 量化要求合法的 FP16/FP32 输入描述和等长的 FP32 values。"}; + } +} + +} // namespace + +ReferenceError::ReferenceError(std::string detail) + : std::runtime_error(std::move(detail)) {} + +QuantizedTensor quantize_reference(const HostTensor& input, + const QuantizationConfig& config) { + validate_reference_quantization_input(input); + if (!config.isValid()) { + throw ReferenceError{"CPU reference 量化配置不合法。"}; + } + + switch (config.format) { + case QuantFormat::kMxfp8: + return reference_detail::quantize_mxfp8_reference(input, config); + + case QuantFormat::kNvfp4: + return reference_detail::quantize_nvfp4_reference(input, config); + + case QuantFormat::kUnknown: + break; + } + + throw ReferenceError{"CPU reference 量化格式不合法。"}; +} + +HostTensor dequantize_reference(const QuantizedTensor& input, + const DequantizationConfig& config) { + if (!input.isConsistent()) { + throw ReferenceError{"CPU reference 反量化要求自洽的 QuantizedTensor。"}; + } + + if (!config.isValid()) { + throw ReferenceError{"CPU reference 反量化配置不合法。"}; + } + + switch (input.desc.format) { + case QuantFormat::kMxfp8: + return reference_detail::dequantize_mxfp8_reference(input, config); + + case QuantFormat::kNvfp4: + return reference_detail::dequantize_nvfp4_reference(input, config); + + case QuantFormat::kUnknown: + break; + } + + throw ReferenceError{"CPU reference 反量化格式不合法。"}; +} + +} // namespace quant_dequant From 1ab7e094b2af4725806f6f0e9cf31959610ae6e3 Mon Sep 17 00:00:00 2001 From: hxy21211319 <2249818804@qq.com> Date: Fri, 18 Sep 2026 15:01:13 +0800 Subject: [PATCH 2/2] test: add quantization and dequantization test suite --- .../tests/CMakeLists.txt" | 79 +++ .../tests/test_config.cpp" | 251 ++++++++ .../tests/test_cuda_dispatch.cpp" | 282 +++++++++ .../tests/test_cuda_profile.cu" | 168 ++++++ .../tests/test_cuda_timer.cu" | 112 ++++ .../tests/test_device_quantized_tensor.cu" | 212 +++++++ .../tests/test_entry_main.cpp" | 23 + .../tests/test_metrics.cpp" | 281 +++++++++ .../tests/test_mxfp8_codec.cu" | 157 +++++ .../tests/test_mxfp8_cuda.cu" | 318 ++++++++++ .../tests/test_mxfp8_dequantize_cuda.cu" | 227 +++++++ .../tests/test_mxfp8_reference.cpp" | 514 ++++++++++++++++ .../tests/test_nvfp4_codec.cu" | 240 ++++++++ .../tests/test_nvfp4_cuda.cu" | 247 ++++++++ .../tests/test_nvfp4_dequantize_cuda.cu" | 174 ++++++ .../tests/test_nvfp4_reference.cpp" | 252 ++++++++ .../tests/test_quantized_io.cpp" | 483 +++++++++++++++ .../tests/test_quantized_tensor.cpp" | 147 +++++ .../tests/test_reference_dispatch.cpp" | 139 +++++ .../tests/test_tensor_io.cpp" | 556 ++++++++++++++++++ 20 files changed, 4862 insertions(+) create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/CMakeLists.txt" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_config.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_dispatch.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_profile.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_timer.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_device_quantized_tensor.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_entry_main.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_metrics.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_codec.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_cuda.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_dequantize_cuda.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_reference.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_codec.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_cuda.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_dequantize_cuda.cu" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_reference.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_io.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_tensor.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_reference_dispatch.cpp" create mode 100644 "02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_tensor_io.cpp" diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/CMakeLists.txt" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/CMakeLists.txt" new file mode 100644 index 00000000..364cecea --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/CMakeLists.txt" @@ -0,0 +1,79 @@ +# 构建并注册一个可由 CTest 独立执行的项目测试模块。 +# +# test_entry_main.cpp 通过编译定义调用对应测试源的 run_*_tests()。每个模块都链接 +# 同一份核心静态库,却拥有独立进程、CTest 名称和标签;失败时 CTest 能精确报告 +# 出错模块,且 ctest -L 可筛选 CUDA、格式或 I/O 类测试。 +# +# 参数: +# - test_name:测试可执行文件名,也是 CTest 条目名; +# - test_source:定义唯一 run_*_tests() 函数的测试源文件; +# - test_entry:由测试入口文件调用的函数名; +# - ARGN:可选 CTest 标签列表。 +function(add_quant_dequant_test test_name test_source test_entry) + add_executable(${test_name} + test_entry_main.cpp + ${test_source} + ) + + target_link_libraries(${test_name} PRIVATE quant_dequant::core) + + # format codec 是内部实现;codec 和格式专用测试需要直接包含 src/formats + # 下的 .cuh,因此所有测试目标统一获得 src 私有 include 路径。 + target_include_directories(${test_name} PRIVATE + ${PROJECT_SOURCE_DIR}/src + ) + + target_compile_definitions(${test_name} PRIVATE + QUANT_DEQUANT_TEST_ENTRY=${test_entry} + ) + quant_dequant_enable_warnings(${test_name}) + + add_test(NAME ${test_name} COMMAND ${test_name}) + if (ARGN) + set_tests_properties(${test_name} PROPERTIES LABELS "${ARGN}") + endif() +endfunction() + +# 纯 host 模块:不要求实际 CUDA device,适合任何开发环境快速运行。 +add_quant_dequant_test(quant_dequant_config_tests + test_config.cpp run_config_parser_tests "unit;config") +add_quant_dequant_test(quant_dequant_tensor_io_tests + test_tensor_io.cpp run_tensor_io_tests "unit;io") +add_quant_dequant_test(quant_dequant_quantized_io_tests + test_quantized_io.cpp run_quantized_io_tests "unit;io") +add_quant_dequant_test(quant_dequant_quantized_tensor_tests + test_quantized_tensor.cpp run_quantized_tensor_tests "unit;model") +add_quant_dequant_test(quant_dequant_metrics_tests + test_metrics.cpp run_metrics_tests "unit;metrics") +add_quant_dequant_test(quant_dequant_reference_dispatch_tests + test_reference_dispatch.cpp run_reference_dispatch_tests "unit;reference") +add_quant_dequant_test(quant_dequant_mxfp8_reference_tests + test_mxfp8_reference.cpp run_mxfp8_reference_tests "unit;reference;mxfp8") +add_quant_dequant_test(quant_dequant_nvfp4_reference_tests + test_nvfp4_reference.cpp run_nvfp4_reference_tests "unit;reference;nvfp4") + +# codec 测试会编译少量 device 函数;无 CUDA runtime 时其 device 子断言自行跳过。 +add_quant_dequant_test(quant_dequant_mxfp8_codec_tests + test_mxfp8_codec.cu run_mxfp8_codec_tests "unit;codec;mxfp8;cuda") +add_quant_dequant_test(quant_dequant_nvfp4_codec_tests + test_nvfp4_codec.cu run_nvfp4_codec_tests "unit;codec;nvfp4;cuda") + +# CUDA pipeline 测试在无 device/driver 时明确跳过数值 kernel 对照,而不是失败。 +add_quant_dequant_test(quant_dequant_cuda_dispatch_tests + test_cuda_dispatch.cpp run_cuda_dispatch_tests "integration;cuda") +add_quant_dequant_test(quant_dequant_device_quantized_tensor_tests + test_device_quantized_tensor.cu run_device_quantized_tensor_tests "integration;cuda") +add_quant_dequant_test(quant_dequant_cuda_timer_tests + test_cuda_timer.cu run_cuda_timer_tests "integration;cuda;common") +add_quant_dequant_test(quant_dequant_cuda_profile_tests + test_cuda_profile.cu run_cuda_profile_tests "integration;cuda;profile;mxfp8") +add_quant_dequant_test(quant_dequant_mxfp8_cuda_tests + test_mxfp8_cuda.cu run_mxfp8_cuda_tests "integration;cuda;mxfp8") +add_quant_dequant_test(quant_dequant_mxfp8_dequantize_cuda_tests + test_mxfp8_dequantize_cuda.cu run_mxfp8_dequantize_cuda_tests + "integration;cuda;mxfp8") +add_quant_dequant_test(quant_dequant_nvfp4_cuda_tests + test_nvfp4_cuda.cu run_nvfp4_cuda_tests "integration;cuda;nvfp4") +add_quant_dequant_test(quant_dequant_nvfp4_dequantize_cuda_tests + test_nvfp4_dequantize_cuda.cu run_nvfp4_dequantize_cuda_tests + "integration;cuda;nvfp4") diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_config.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_config.cpp" new file mode 100644 index 00000000..3c9edabc --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_config.cpp" @@ -0,0 +1,251 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/config.hpp" + +namespace { + +/** + * @brief 删除测试过程中创建的临时配置文件。 + */ +class TemporaryFileCleanup final { +public: + /** + * @brief 记录需要在析构时删除的文件路径。 + * + * @param file_path 测试创建的临时文件路径。 + */ + explicit TemporaryFileCleanup(std::filesystem::path file_path) + : mFilePath(std::move(file_path)) {} + + TemporaryFileCleanup(const TemporaryFileCleanup&) = delete; + TemporaryFileCleanup& operator=(const TemporaryFileCleanup&) = delete; + TemporaryFileCleanup(TemporaryFileCleanup&&) = delete; + TemporaryFileCleanup& operator=(TemporaryFileCleanup&&) = delete; + + /** + * @brief 尽力清理临时文件;清理失败不应覆盖原始测试结果。 + */ + ~TemporaryFileCleanup() { + std::error_code error_code{}; + std::filesystem::remove(mFilePath, error_code); + } + +private: + std::filesystem::path mFilePath; +}; + +/** + * @brief 向指定路径写入 UTF-8 配置文本。 + * + * @param file_path 输出文件路径。 + * @param content 要写入的完整文本。 + * @return 文件完全写入时返回 true。 + */ +[[nodiscard]] bool write_text_file(const std::filesystem::path& file_path, + const std::string_view content) { + std::ofstream output_stream{file_path}; + output_stream << content; + return output_stream.good(); +} + +} // namespace + +/** + * @brief 验证完整 app 与两个单方向配置入口的解析和诊断。 + * + * @return 所有断言通过时返回 0;失败时打印原因并返回 1。 + */ +int run_config_parser_tests() { + const auto timestamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::filesystem::path config_path = + std::filesystem::temp_directory_path() / + ("quant_dequant_config_test_" + std::to_string(timestamp) + ".toml"); + const TemporaryFileCleanup cleanup{config_path}; + + constexpr std::string_view kFullAppConfig = R"( +# 注释行应被忽略。 +format = "nvfp4" +block_size = 16 +scale_mode = "block" # 行尾注释应被忽略。 +output_type = "bf16" +rounding = "stochastic" +target_gpu = "RTX 4060 # 引号内不是注释" +stochastic_seed = 42 +)"; + + if (!write_text_file(config_path, kFullAppConfig)) { + std::cerr << "无法创建配置解析测试文件。\n"; + return 1; + } + + try { + const quant_dequant::AppConfig app_config = + quant_dequant::load_app_config(config_path); + const bool valid_app_values = + app_config.quantization.format == quant_dequant::QuantFormat::kNvfp4 && + app_config.quantization.block_size == quant_dequant::kNvfp4BlockSize && + app_config.quantization.scale_mode == quant_dequant::ScaleMode::kBlock && + app_config.quantization.rounding == + quant_dequant::RoundingMode::kStochastic && + app_config.quantization.stochastic_seed == 42U && + app_config.dequantization.output_type == + quant_dequant::DType::kBFloat16 && + app_config.report.target_gpu == "RTX 4060 # 引号内不是注释"; + + if (!valid_app_values) { + std::cerr << "完整 app 配置未被正确拆分。\n"; + return 1; + } + + const quant_dequant::QuantizationConfig quantization_config = + quant_dequant::load_quantization_config(config_path); + const quant_dequant::DequantizationConfig dequantization_config = + quant_dequant::load_dequantization_config(config_path); + if (quantization_config.format != quant_dequant::QuantFormat::kNvfp4 || + dequantization_config.output_type != quant_dequant::DType::kBFloat16) { + std::cerr << "完整配置不能正确投影为单方向配置。\n"; + return 1; + } + } catch (const quant_dequant::ConfigError& error) { + std::cerr << "完整配置意外解析失败:" << error.what() << '\n'; + return 1; + } + + constexpr std::string_view kQuantizationOnlyConfig = R"( +format = "mxfp8" +block_size = 32 +scale_mode = "tensor" +rounding = "nearest" +)"; + + if (!write_text_file(config_path, kQuantizationOnlyConfig)) { + std::cerr << "无法写入单方向量化配置。\n"; + return 1; + } + + try { + const quant_dequant::QuantizationConfig config = + quant_dequant::load_quantization_config(config_path); + if (config.format != quant_dequant::QuantFormat::kMxfp8 || + config.block_size != quant_dequant::kMxfp8BlockSize || + config.scale_mode != quant_dequant::ScaleMode::kTensor || + config.rounding != quant_dequant::RoundingMode::kNearest || + config.stochastic_seed != 0U) { + std::cerr << "单方向量化配置未被正确解析。\n"; + return 1; + } + } catch (const quant_dequant::ConfigError& error) { + std::cerr << "单方向量化配置意外解析失败:" << error.what() << '\n'; + return 1; + } + + try { + static_cast(quant_dequant::load_app_config(config_path)); + std::cerr << "缺少反量化和报告字段的配置被错误接受为完整 app 配置。\n"; + return 1; + } catch (const quant_dequant::ConfigError&) { + } + + constexpr std::string_view kDequantizationOnlyConfig = R"( +output_type = "fp32" +)"; + + if (!write_text_file(config_path, kDequantizationOnlyConfig)) { + std::cerr << "无法写入单方向反量化配置。\n"; + return 1; + } + + try { + const quant_dequant::DequantizationConfig config = + quant_dequant::load_dequantization_config(config_path); + if (config.output_type != quant_dequant::DType::kFloat32) { + std::cerr << "单方向反量化配置未被正确解析。\n"; + return 1; + } + } catch (const quant_dequant::ConfigError& error) { + std::cerr << "单方向反量化配置意外解析失败:" << error.what() << '\n'; + return 1; + } + + constexpr std::string_view kInvalidNearestSeedConfig = R"( +format = "mxfp8" +block_size = 32 +scale_mode = "block" +rounding = "nearest" +stochastic_seed = 7 +)"; + + if (!write_text_file(config_path, kInvalidNearestSeedConfig)) { + std::cerr << "无法写入非法 seed 配置。\n"; + return 1; + } + + try { + static_cast(quant_dequant::load_quantization_config(config_path)); + std::cerr << "nearest 配置的非零 stochastic_seed 未被拒绝。\n"; + return 1; + } catch (const quant_dequant::ConfigError& error) { + if (error.lineNumber() != 6U) { + std::cerr << "非法 stochastic_seed 的错误行号不正确。\n"; + return 1; + } + } + + constexpr std::string_view kInvalidNvfp4TensorConfig = R"( +format = "nvfp4" +block_size = 16 +scale_mode = "tensor" +rounding = "nearest" +)"; + + if (!write_text_file(config_path, kInvalidNvfp4TensorConfig)) { + std::cerr << "无法写入非法 NVFP4 tensor 配置。\n"; + return 1; + } + + try { + static_cast(quant_dequant::load_quantization_config(config_path)); + std::cerr << "NVFP4 tensor scale 配置未被拒绝。\n"; + return 1; + } catch (const quant_dequant::ConfigError& error) { + if (error.lineNumber() != 4U) { + std::cerr << "NVFP4 tensor scale 的错误行号不正确。\n"; + return 1; + } + } + + constexpr std::string_view kDuplicateConfig = R"( +format = "mxfp8" +format = "mxfp8" +block_size = 32 +scale_mode = "block" +output_type = "fp16" +rounding = "nearest" +target_gpu = "RTX 4060" +)"; + + if (!write_text_file(config_path, kDuplicateConfig)) { + std::cerr << "无法写入重复字段测试配置。\n"; + return 1; + } + + try { + static_cast(quant_dequant::load_app_config(config_path)); + } catch (const quant_dequant::ConfigError& error) { + if (error.lineNumber() == 3U) { + return 0; + } + + std::cerr << "重复字段错误行号不正确。\n"; + return 1; + } + + std::cerr << "重复字段未被拒绝。\n"; + return 1; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_dispatch.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_dispatch.cpp" new file mode 100644 index 00000000..fdfa8e74 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_dispatch.cpp" @@ -0,0 +1,282 @@ +#include + +#include +#include +#include +#include + +#include "quant_dequant/quantize.hpp" +#include "pipeline/pipeline_detail.hpp" + +namespace { + +using quant_dequant::CudaPipelineError; +using quant_dequant::DequantizationConfig; +using quant_dequant::DType; +using quant_dequant::HostTensor; +using quant_dequant::QuantFormat; +using quant_dequant::QuantizationConfig; +using quant_dequant::QuantizedTensor; +using quant_dequant::QuantizedTensorDesc; +using quant_dequant::RoundingMode; +using quant_dequant::ScaleMode; +using quant_dequant::TensorDesc; +using quant_dequant::kMxfp8BlockSize; +using quant_dequant::kNvfp4BlockSize; + +/** + * @brief 验证调用以包含指定文本的 `CudaPipelineError` 失败。 + * + * 本测试不检查 CUDA runtime,也不要求机器有可用 GPU。它验证公共入口会拒绝 + * 无效参数,并在无 device 时确认合法请求确实抵达格式专用路径。 + * + * @tparam Callable 无参且会调用 CUDA pipeline 公共接口的可调用对象。 + * @param callable 待验证的调用。 + * @param expected_detail 错误诊断中应出现的阶段或格式名称。 + * @return 预期异常类型及诊断均匹配时为 `true`。 + */ +template +[[nodiscard]] bool throws_cuda_pipeline_error( + Callable&& callable, + std::string_view expected_detail) { + try { + callable(); + } catch (const CudaPipelineError& error) { + return std::string_view{error.what()}.find(expected_detail) != + std::string_view::npos; + } catch (const std::exception&) { + return false; + } + + return false; +} + +/** + * @brief 判断本机是否能执行会实际分配 device memory 的 MXFP8 路径。 + * + * 无 CUDA device 时,公共 MXFP8 入口会在 H2D / allocation 阶段失败,无法到达 + * block/tensor launcher;这种环境仍可验证其公共错误边界,但不能断言 launcher + * 的专用错误文本。 + * + * @return CUDA runtime 成功发现至少一个 device 时返回 true。 + */ +[[nodiscard]] bool has_cuda_device() { + int device_count = 0; + return cudaGetDeviceCount(&device_count) == cudaSuccess && device_count > 0; +} + +/** + * @brief 构造一元素的合法 host FP32 输入。 + * + * @return 可供 MXFP8 与 NVFP4 格式分派测试共享的输入张量。 + */ +[[nodiscard]] HostTensor make_input() { + return HostTensor{ + .desc = TensorDesc{ + .num_rows = 1, + .num_cols = 1, + .dtype = DType::kFloat32, + }, + .values = {1.0F}, + }; +} + +/** + * @brief 构造可通过一致性校验的最小 MXFP8 host 量化张量。 + * + * @return 一元素、一个 E8M0 local scale 的 MXFP8 张量。 + */ +[[nodiscard]] QuantizedTensor make_mxfp8_tensor() { + return QuantizedTensor{ + .desc = QuantizedTensorDesc{ + .source_desc = TensorDesc{ + .num_rows = 1, + .num_cols = 1, + .dtype = DType::kFloat32, + }, + .format = QuantFormat::kMxfp8, + .scale_mode = ScaleMode::kBlock, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0, + .block_size = kMxfp8BlockSize, + }, + .payload = {0x00U}, + .local_scales = {0x00U}, + .global_scale = std::nullopt, + }; +} + +/** + * @brief 构造可通过一致性校验的最小 NVFP4 host 量化张量。 + * + * 一字节保存两个 FP4 nibble;一元素张量中高四位是 tail padding。其数值对于 + * 本测试无关;有 CUDA device 时的数值正确性由专门对照测试覆盖。 + * + * @return 一元素、一个 E4M3 local scale 与一个 FP32 global scale 的 NVFP4 张量。 + */ +[[nodiscard]] QuantizedTensor make_nvfp4_tensor() { + return QuantizedTensor{ + .desc = QuantizedTensorDesc{ + .source_desc = TensorDesc{ + .num_rows = 1, + .num_cols = 1, + .dtype = DType::kFloat16, + }, + .format = QuantFormat::kNvfp4, + .scale_mode = ScaleMode::kBlock, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0, + .block_size = kNvfp4BlockSize, + }, + .payload = {0x00U}, + .local_scales = {0x00U}, + .global_scale = 1.0F, + }; +} + +} // namespace + +int run_cuda_dispatch_tests() { + const HostTensor input = make_input(); + const bool cuda_device_available = has_cuda_device(); + + QuantizationConfig mxfp8_config{}; + mxfp8_config.format = QuantFormat::kMxfp8; + mxfp8_config.block_size = kMxfp8BlockSize; + mxfp8_config.scale_mode = ScaleMode::kBlock; + mxfp8_config.rounding = RoundingMode::kNearest; + + // MXFP8 block-scale 已是可执行 CUDA 路径。无 CUDA device 的环境只能在 + // H2D/分配阶段验证错误边界;有 device 时的实际数值对照由 + // test_mxfp8_cuda.cu 负责,避免此分派测试重复执行 kernel。 + if (!cuda_device_available && !throws_cuda_pipeline_error( + [&] { + static_cast( + quant_dequant::quantize_cuda( + input, mxfp8_config)); + }, + "mxfp8")) { + std::cerr << "CUDA MXFP8 量化没有分派到预期的格式专用入口。\n"; + return 1; + } + + QuantizationConfig mxfp8_tensor_config = mxfp8_config; + mxfp8_tensor_config.scale_mode = ScaleMode::kTensor; + // MXFP8 tensor-scale 现在也会实际完成两阶段 amax reduction 和编码;有 + // device 时由 test_mxfp8_cuda.cu 比较 CPU reference。这里仅在无 device + // 环境确认公共入口到达 CUDA 格式专用路径后正确报告 runtime/H2D 边界错误。 + if (!cuda_device_available && !throws_cuda_pipeline_error( + [&] { + static_cast( + quant_dequant::quantize_cuda( + input, mxfp8_tensor_config)); + }, + "mxfp8")) { + std::cerr << "CUDA MXFP8 tensor-scale 量化没有分派到预期的 launcher。\n"; + return 1; + } + + QuantizationConfig nvfp4_config{}; + nvfp4_config.format = QuantFormat::kNvfp4; + nvfp4_config.block_size = kNvfp4BlockSize; + nvfp4_config.scale_mode = ScaleMode::kBlock; + nvfp4_config.rounding = RoundingMode::kNearest; + + // NVFP4 量化现已可执行。无 device 时它应和 MXFP8 一样在 H2D、allocation 或 + // launcher 查询边界报告 nvfp4 CUDA 异常;有 device 时的逐字节 CPU 对照由 + // test_nvfp4_cuda.cu 负责,避免这个 dispatcher 测试重复发射 kernel。 + if (!cuda_device_available && !throws_cuda_pipeline_error( + [&] { + static_cast( + quant_dequant::quantize_cuda( + input, nvfp4_config)); + }, + "nvfp4")) { + std::cerr << "CUDA NVFP4 量化没有分派到预期的格式专用入口。\n"; + return 1; + } + + const DequantizationConfig dequantization_config{ + .output_type = DType::kFloat32, + }; + const QuantizedTensor mxfp8 = make_mxfp8_tensor(); + // MXFP8 反量化已是可执行 CUDA 路径。无 CUDA device 时这里只验证 H2D/ + // 分配阶段的错误边界;有 device 时的 CPU reference 数值对照由 + // test_mxfp8_dequantize_cuda.cu 覆盖。 + if (!cuda_device_available && !throws_cuda_pipeline_error( + [&] { + static_cast( + quant_dequant::dequantize_cuda( + mxfp8, dequantization_config)); + }, + "mxfp8")) { + std::cerr << "CUDA MXFP8 反量化没有分派到预期的格式专用入口。\n"; + return 1; + } + + const QuantizedTensor nvfp4 = make_nvfp4_tensor(); + if (!cuda_device_available && !throws_cuda_pipeline_error( + [&] { + static_cast( + quant_dequant::dequantize_cuda( + nvfp4, dequantization_config)); + }, + "nvfp4")) { + std::cerr << "CUDA NVFP4 反量化没有分派到预期的格式专用入口。\n"; + return 1; + } + + const HostTensor invalid_input{ + .desc = TensorDesc{ + .num_rows = 1, + .num_cols = 1, + .dtype = DType::kBFloat16, + }, + .values = {1.0F}, + }; + if (!throws_cuda_pipeline_error( + [&] { + static_cast( + quant_dequant::quantize_cuda(invalid_input, mxfp8_config)); + }, + "FP16/FP32")) { + std::cerr << "CUDA 量化入口没有拒绝不支持的 BF16 输入描述。\n"; + return 1; + } + + // `pipeline_detail` 不是公共 API,但内部入口也必须自行维护 MXFP8 的固定 + // 格式前提,避免将来某个内部调用绕过 dispatcher 后进入错误的 kernel 映射。 + QuantizationConfig invalid_mxfp8_config = mxfp8_config; + invalid_mxfp8_config.block_size = kNvfp4BlockSize; + if (!throws_cuda_pipeline_error( + [&] { + static_cast(quant_dequant::pipeline_detail::quantize_mxfp8_cuda( + input, invalid_mxfp8_config)); + }, + "block_size = 32")) { + std::cerr << "MXFP8 CUDA 内部入口没有拒绝非 32 元素 block 配置。\n"; + return 1; + } + + if (!throws_cuda_pipeline_error( + [&] { + static_cast(quant_dequant::pipeline_detail::quantize_mxfp8_cuda( + invalid_input, mxfp8_config)); + }, + "FP16/FP32")) { + std::cerr << "MXFP8 CUDA 内部入口没有拒绝非法输入描述。\n"; + return 1; + } + + if (!throws_cuda_pipeline_error( + [&] { + static_cast(quant_dequant::dequantize_cuda( + QuantizedTensor{}, dequantization_config)); + }, + "自洽")) { + std::cerr << "CUDA 反量化入口没有拒绝不自洽的量化张量。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_profile.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_profile.cu" new file mode 100644 index 00000000..a6a95af7 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_profile.cu" @@ -0,0 +1,168 @@ +#include + +#include +#include +#include +#include +#include + +#include "quant_dequant/quantize.hpp" + +namespace { + +using quant_dequant::DequantizationConfig; +using quant_dequant::DType; +using quant_dequant::HostTensor; +using quant_dequant::QuantFormat; +using quant_dequant::QuantizationConfig; +using quant_dequant::RoundingMode; +using quant_dequant::ScaleMode; +using quant_dequant::TensorDesc; +using quant_dequant::kMxfp8BlockSize; + +/** + * @brief 判断当前环境是否实际可发射 CUDA kernel。 + * + * @return 检测到至少一个可用 CUDA device 时为 true;无 driver 或无 device 时 + * 为 false。其他 runtime 错误由调用者作为失败处理。 + */ +[[nodiscard]] bool has_cuda_device() { + int device_count = 0; + return cudaGetDeviceCount(&device_count) == cudaSuccess && device_count > 0; +} + +/** + * @brief 构造跨越 MXFP8 block 边界的确定性有限输入。 + * + * 33 列使每行都含一个完整 32 元素 block 与一个 tail block;tensor-scale 配置 + * 会额外覆盖“全局 amax 规约 + scale 写入 + 编码”三段 kernel 被同一组 event + * 包住的场景。 + * + * @return 合法的 3×33 FP32 host 张量。 + */ +[[nodiscard]] HostTensor make_input() { + constexpr std::uint64_t kRows = 3U; + constexpr std::uint64_t kCols = 33U; + std::vector values; + values.reserve(static_cast(kRows * kCols)); + + for (std::uint64_t index = 0U; index < kRows * kCols; ++index) { + const int bucket = static_cast((index * 19U + 7U) % 59U) - 29; + values.push_back(static_cast(bucket) * 0.25F); + } + values.front() = 448.0F; + + return HostTensor{ + .desc = TensorDesc{ + .num_rows = kRows, + .num_cols = kCols, + .dtype = DType::kFloat32, + }, + .values = std::move(values), + }; +} + +/** + * @brief 判断 CUDA Event 计时结果是否适合交给 metrics 层记录。 + * + * CUDA event 的有限分辨率允许极短 kernel 报告 0 ms,因此这里不强制大于零; + * 只要求它是有限且非负的纯 device 时间。 + * + * @param kernel_ms `*_cuda_profiled()` 返回的 kernel 时间。 + * @return 数值可用时为 true。 + */ +[[nodiscard]] bool is_valid_kernel_time(const double kernel_ms) { + return std::isfinite(kernel_ms) && kernel_ms >= 0.0; +} + +/** + * @brief 比较 profile CUDA pipeline 与 CPU reference 的端到端结果和计时。 + * + * @return 量化后的持久化字段、反量化 FP32 值均与 reference 一致,且两个 Event + * 时间均有效时返回 true。 + */ +[[nodiscard]] bool test_profiled_mxfp8_pipeline() { + const HostTensor input = make_input(); + const QuantizationConfig quantization_config{ + .format = QuantFormat::kMxfp8, + .block_size = kMxfp8BlockSize, + .scale_mode = ScaleMode::kTensor, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0U, + }; + const DequantizationConfig dequantization_config{ + .output_type = DType::kFloat32, + }; + + try { + const auto expected_quantized = + quant_dequant::quantize_reference(input, quantization_config); + const auto profiled_quantized = + quant_dequant::quantize_cuda_profiled(input, quantization_config); + + if (!is_valid_kernel_time(profiled_quantized.kernel_ms)) { + std::cerr << "MXFP8 profile 量化 kernel_ms 非法。\n"; + return false; + } + if (profiled_quantized.tensor.payload != expected_quantized.payload || + profiled_quantized.tensor.local_scales != + expected_quantized.local_scales || + profiled_quantized.tensor.global_scale != + expected_quantized.global_scale) { + std::cerr << "MXFP8 profile 量化结果与 CPU reference 不一致。\n"; + return false; + } + + const auto expected_dequantized = quant_dequant::dequantize_reference( + expected_quantized, dequantization_config); + const auto profiled_dequantized = quant_dequant::dequantize_cuda_profiled( + profiled_quantized.tensor, dequantization_config); + + if (!is_valid_kernel_time(profiled_dequantized.kernel_ms)) { + std::cerr << "MXFP8 profile 反量化 kernel_ms 非法。\n"; + return false; + } + if (profiled_dequantized.tensor.desc.num_rows != + expected_dequantized.desc.num_rows || + profiled_dequantized.tensor.desc.num_cols != + expected_dequantized.desc.num_cols || + profiled_dequantized.tensor.desc.dtype != + expected_dequantized.desc.dtype || + profiled_dequantized.tensor.values != expected_dequantized.values) { + std::cerr << "MXFP8 profile 反量化结果与 CPU reference 不一致。\n"; + return false; + } + } catch (const std::exception& error) { + std::cerr << "CUDA profile pipeline 测试意外失败:" << error.what() << '\n'; + return false; + } + + return true; +} + +} // namespace + +/** + * @brief 运行 CUDA profile pipeline 的 MXFP8 端到端测试。 + * + * 无 CUDA device 或 driver 时按照项目 CUDA 测试约定跳过;有 GPU 时验证 profile + * 接口确实完成 H2D、同 stream kernel 计时与 D2H,并保留与 CPU reference 相同的 + * 功能结果。 + * + * @return 通过或跳过时为 0,失败时为 1。 + */ +int run_cuda_profile_tests() { + int device_count = 0; + const cudaError_t status = cudaGetDeviceCount(&device_count); + if (status == cudaErrorNoDevice || status == cudaErrorInsufficientDriver || + (status == cudaSuccess && device_count == 0)) { + std::cout << "CUDA runtime 不可用,跳过 CUDA profile pipeline 测试。\n"; + return 0; + } + if (status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" << cudaGetErrorString(status) << '\n'; + return 1; + } + + return has_cuda_device() && test_profiled_mxfp8_pipeline() ? 0 : 1; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_timer.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_timer.cu" new file mode 100644 index 00000000..61d0b25b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_cuda_timer.cu" @@ -0,0 +1,112 @@ +#include + +#include +#include +#include +#include + +#include "common/cuda_stream.cuh" +#include "common/cuda_timer.cuh" + +namespace { + +/** + * @brief 判断当前进程是否能创建 CUDA event 并在 default stream 中记录它们。 + * + * @return 存在可用 CUDA device 时返回 true;无 device/driver 时打印跳过信息并 + * 返回 false;其他 runtime 错误同样返回 false,但调用方会按错误处理。 + */ +[[nodiscard]] bool has_cuda_device() { + int device_count = 0; + const cudaError_t status = cudaGetDeviceCount(&device_count); + if (status == cudaErrorNoDevice || status == cudaErrorInsufficientDriver || + (status == cudaSuccess && device_count == 0)) { + std::cout << "CUDA runtime 不可用,跳过 CUDA Event 计时器测试。\n"; + return false; + } + if (status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" << cudaGetErrorString(status) << '\n'; + return false; + } + + return true; +} + +/** + * @brief 验证独占 CudaStream 与其上 CudaEventTimer 的状态机和空区间计时。 + * + * @return 非法调用被拒绝、正常 start/stop/elapsed 可重复执行时返回 true。 + */ +[[nodiscard]] bool test_cuda_event_timer() { + using quant_dequant::common::CudaStream; + using quant_dequant::common::CudaEventTimer; + using quant_dequant::common::CudaTimerError; + + try { + // pipeline 使用的也是这对对象:stream 先拥有非阻塞 stream,再把借出的 + // handle 交给 event。此处验证二者的生命周期和顺序可以安全配合。 + CudaStream stream{}; + if (stream.get() == nullptr) { + return false; + } + CudaEventTimer timer{stream.get()}; + try { + static_cast(timer.elapsedMilliseconds()); + return false; + } catch (const CudaTimerError&) { + // 未 stop 时读取 elapsed 是明确的状态机错误。 + } + + timer.start(); + try { + timer.start(); + return false; + } catch (const CudaTimerError&) { + // running 状态不允许嵌套 start。 + } + timer.stop(); + const float first_elapsed_ms = timer.elapsedMilliseconds(); + if (!std::isfinite(first_elapsed_ms) || first_elapsed_ms < 0.0F) { + return false; + } + + // 同一对象完成一个区间后可以安全复用;这正是 app 先量化、再反量化时 + // 希望复用 event 对而不重复分配的行为。 + timer.start(); + timer.stop(); + const float second_elapsed_ms = timer.elapsedMilliseconds(); + stream.synchronize(); + return std::isfinite(second_elapsed_ms) && second_elapsed_ms >= 0.0F; + } catch (const std::exception& error) { + std::cerr << "CUDA Event 计时器测试意外失败:" << error.what() << '\n'; + return false; + } +} + +} // namespace + +/** + * @brief 运行 CUDA Event 计时器的 device 测试。 + * + * @return 无 CUDA device/driver 时跳过并返回 0;有 device 时状态机和 event 计时 + * 正确返回 0;失败返回 1。 + */ +int run_cuda_timer_tests() { + int device_count = 0; + const cudaError_t status = cudaGetDeviceCount(&device_count); + if (status == cudaErrorNoDevice || status == cudaErrorInsufficientDriver || + (status == cudaSuccess && device_count == 0)) { + std::cout << "CUDA runtime 不可用,跳过 CUDA Event 计时器测试。\n"; + return 0; + } + if (status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" << cudaGetErrorString(status) << '\n'; + return 1; + } + + if (!has_cuda_device() || !test_cuda_event_timer()) { + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_device_quantized_tensor.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_device_quantized_tensor.cu" new file mode 100644 index 00000000..48620d23 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_device_quantized_tensor.cu" @@ -0,0 +1,212 @@ +#include + +#include +#include +#include +#include + +#include "pipeline/device_quantized_tensor.cuh" + +namespace { + +/** + * @brief 构造一个包含尾 block 的合法 MXFP8 device 输出描述。 + * + * @return 形状为 1 x 33、block 模式的 MXFP8 描述。 + */ +[[nodiscard]] quant_dequant::QuantizedTensorDesc make_mxfp8_desc() { + return { + .source_desc = { + .num_rows = 1U, + .num_cols = 33U, + .dtype = quant_dequant::DType::kFloat32, + }, + .format = quant_dequant::QuantFormat::kMxfp8, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + .block_size = quant_dequant::kMxfp8BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }; +} + +/** + * @brief 构造一个包含奇数 payload 尾元素的合法 NVFP4 device 输出描述。 + * + * @return 形状为 1 x 17、block 模式的 NVFP4 描述。 + */ +[[nodiscard]] quant_dequant::QuantizedTensorDesc make_nvfp4_desc() { + return { + .source_desc = { + .num_rows = 1U, + .num_cols = 17U, + .dtype = quant_dequant::DType::kFloat16, + }, + .format = quant_dequant::QuantFormat::kNvfp4, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + .block_size = quant_dequant::kNvfp4BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }; +} + +/** + * @brief 构造一个合法的 BF16 反量化 device 输出描述。 + * + * device 上仍将分配 FP32 数组;BF16 只记录最终 QDTENSOR 应写出的物理类型。 + * + * @return 形状为 1 x 33、目标物理类型为 BF16 的输出描述。 + */ +[[nodiscard]] quant_dequant::TensorDesc make_dequantized_output_desc() { + return { + .num_rows = 1U, + .num_cols = 33U, + .dtype = quant_dequant::DType::kBFloat16, + }; +} + +/** + * @brief 判断当前运行环境是否可以执行会分配 device memory 的测试。 + * + * @return 已发现可用 CUDA device 时返回 true;无 driver 或无 device 时返回 false。 + */ +[[nodiscard]] bool has_cuda_device() { + int device_count = 0; + const cudaError_t status = cudaGetDeviceCount(&device_count); + + if (status == cudaErrorNoDevice || status == cudaErrorInsufficientDriver) { + std::cout << "CUDA runtime 不可用,跳过 device buffer 分配测试。\n"; + return false; + } + + if (status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" << cudaGetErrorString(status) << '\n'; + return false; + } + + if (device_count == 0) { + std::cout << "未发现 CUDA device,跳过 device buffer 分配测试。\n"; + return false; + } + + return true; +} + +} // namespace + +/** + * @brief 验证 pipeline 内部 Thrust device 所有权对象的结构不变量。 + * + * 无 device 的环境仍会验证 move-only 语义、默认对象与非法描述拒绝;可用 GPU + * 上会进一步验证 MXFP8/NVFP4 的精确 buffer 长度和 global scale 存在性。 + * + * @return 所有检查通过时返回 0;否则打印诊断并返回 1。 + */ +int run_device_quantized_tensor_tests() { + using quant_dequant::pipeline::DeviceQuantizationInput; + using quant_dequant::pipeline::DeviceDequantizationOutput; + using quant_dequant::pipeline::DeviceQuantizedTensor; + using quant_dequant::pipeline::DeviceTensorQuantizationWorkspace; + + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_move_constructible_v); + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_move_constructible_v); + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_move_constructible_v); + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_move_constructible_v); + + const DeviceQuantizationInput empty_input{}; + const DeviceDequantizationOutput empty_dequantized_output{}; + const DeviceQuantizedTensor empty_output{}; + const DeviceTensorQuantizationWorkspace empty_tensor_workspace{}; + if (empty_input.isConsistent() || empty_dequantized_output.isConsistent() || + empty_output.isConsistent() || empty_tensor_workspace.isConsistent()) { + std::cerr << "默认 device 数据对象不应被识别为有效张量。\n"; + return 1; + } + + try { + static_cast( + DeviceQuantizedTensor::allocate(quant_dequant::QuantizedTensorDesc{})); + std::cerr << "非法量化描述未被 device buffer 分配器拒绝。\n"; + return 1; + } catch (const std::invalid_argument&) { + } catch (const std::exception& error) { + std::cerr << "非法描述产生了错误的异常类型:" << error.what() << '\n'; + return 1; + } + + try { + static_cast(DeviceTensorQuantizationWorkspace::allocate(0U)); + std::cerr << "零长度 tensor-scale partial 工作区未被拒绝。\n"; + return 1; + } catch (const std::invalid_argument&) { + } catch (const std::exception& error) { + std::cerr << "非法 tensor-scale 工作区产生了错误异常类型:" + << error.what() << '\n'; + return 1; + } + + if (!has_cuda_device()) { + return 0; + } + + try { + const DeviceQuantizedTensor mxfp8_output = + DeviceQuantizedTensor::allocate(make_mxfp8_desc()); + if (!mxfp8_output.isConsistent() || mxfp8_output.payload.size() != 33U || + mxfp8_output.local_scales.size() != 2U || + mxfp8_output.global_scale.has_value()) { + std::cerr << "MXFP8 device buffer 结构或长度不符合预期。\n"; + return 1; + } + + const DeviceQuantizedTensor nvfp4_output = + DeviceQuantizedTensor::allocate(make_nvfp4_desc()); + if (!nvfp4_output.isConsistent() || nvfp4_output.payload.size() != 9U || + nvfp4_output.local_scales.size() != 2U || + !nvfp4_output.global_scale.has_value() || + nvfp4_output.global_scale->size() != 1U) { + std::cerr << "NVFP4 device buffer 结构或长度不符合预期。\n"; + return 1; + } + + const DeviceDequantizationOutput dequantized_output = + DeviceDequantizationOutput::allocate(make_dequantized_output_desc()); + if (!dequantized_output.isConsistent() || + dequantized_output.values.size() != 33U || + dequantized_output.desc.dtype != quant_dequant::DType::kBFloat16) { + std::cerr << "反量化 device FP32 输出结构或长度不符合预期。\n"; + return 1; + } + + const DeviceTensorQuantizationWorkspace tensor_workspace = + DeviceTensorQuantizationWorkspace::allocate(3U); + if (!tensor_workspace.isConsistent() || + tensor_workspace.partial_amax.size() != 3U || + tensor_workspace.partial_nonfinite.size() != 3U) { + std::cerr << "tensor-scale partial 工作区结构或长度不符合预期。\n"; + return 1; + } + + DeviceQuantizationInput input{}; + input.desc = make_mxfp8_desc().source_desc; + input.values.resize(33U); + if (!input.isConsistent()) { + std::cerr << "合法的 device FP32 输入没有通过结构校验。\n"; + return 1; + } + } catch (const std::exception& error) { + std::cerr << "device 数据结构测试意外失败:" << error.what() << '\n'; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_entry_main.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_entry_main.cpp" new file mode 100644 index 00000000..cdda8133 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_entry_main.cpp" @@ -0,0 +1,23 @@ +/** + * @brief 以编译期指定的单个测试入口构造独立测试可执行文件。 + * + * 每个测试源文件继续只定义一个 `run_*_tests()` 函数;CMake 为它单独构建一个 + * 可执行文件,并通过 `QUANT_DEQUANT_TEST_ENTRY` 选择此处调用的函数。这样 CTest + * 可以独立报告和筛选每个模块,同时避免为每个测试手写重复的 `main()`。 + */ + +#ifndef QUANT_DEQUANT_TEST_ENTRY +#error "每个测试可执行文件必须通过 CMake 定义 QUANT_DEQUANT_TEST_ENTRY。" +#endif + +/** @brief 当前 CTest 可执行文件对应的唯一测试函数。 */ +int QUANT_DEQUANT_TEST_ENTRY(); + +/** + * @brief 执行由 CMake 注入的单个模块测试入口。 + * + * @return 测试函数的进程退出码;0 表示通过,非 0 表示失败。 + */ +int main() { + return QUANT_DEQUANT_TEST_ENTRY(); +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_metrics.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_metrics.cpp" new file mode 100644 index 00000000..59cc2ae0 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_metrics.cpp" @@ -0,0 +1,281 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/metrics.hpp" + +namespace { + +using quant_dequant::ArtifactMetrics; +using quant_dequant::CompressionMetrics; +using quant_dequant::DequantizationConfig; +using quant_dequant::DType; +using quant_dequant::ErrorMetrics; +using quant_dequant::KernelPerformance; +using quant_dequant::MetricsError; +using quant_dequant::QuantFormat; +using quant_dequant::QuantizationConfig; +using quant_dequant::QuantizedTensor; +using quant_dequant::QuantizedTensorDesc; +using quant_dequant::RoundingMode; +using quant_dequant::RunReport; +using quant_dequant::ScaleMode; +using quant_dequant::TensorDesc; +using quant_dequant::kNvfp4BlockSize; + +/** + * @brief 判断两个 double 是否在适合指标测试的绝对误差范围内相等。 + * + * @param left 待比较的第一个数。 + * @param right 待比较的第二个数。 + * @return 差值不超过 1e-12 时返回 true。 + */ +[[nodiscard]] bool nearly_equal(const double left, const double right) { + return std::abs(left - right) <= 1.0e-12; +} + +/** + * @brief 构造一个 2×17、严格 NVFP4 block-scale 的自洽量化张量。 + * + * 34 个元素恰好占 17 个 packed byte;每行需要两个 16 元素 local scale,因此 + * local_scales 长度为 4。数值 code 对本模块字节统计无关,只需满足数据模型。 + * + * @return 可用于压缩率、带宽和报告 JSON 测试的 NVFP4 张量。 + */ +[[nodiscard]] QuantizedTensor make_nvfp4_tensor() { + return { + .desc = QuantizedTensorDesc{ + .source_desc = TensorDesc{2U, 17U, DType::kFloat16}, + .format = QuantFormat::kNvfp4, + .scale_mode = ScaleMode::kBlock, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0U, + .block_size = kNvfp4BlockSize, + }, + .payload = std::vector(17U, 0x00U), + .local_scales = std::vector(4U, 0x00U), + .global_scale = 0.5F, + }; +} + +/** + * @brief 验证误差三指标使用 double 累积、并拒绝不合法比较数组。 + * + * @return 全部断言满足时返回 true。 + */ +[[nodiscard]] bool test_error_metrics() { + const std::vector reference{1.0F, -2.0F, 3.0F}; + const std::vector actual{0.0F, -4.0F, 6.0F}; + try { + const ErrorMetrics metrics = quant_dequant::compute_error_metrics( + reference, actual); + if (!nearly_equal(metrics.max_abs, 3.0) || + !nearly_equal(metrics.mae, 2.0) || + !nearly_equal(metrics.mse, 14.0 / 3.0)) { + return false; + } + } catch (const MetricsError& error) { + std::cerr << "metrics 正常误差统计意外失败:" << error.what() << '\n'; + return false; + } + + try { + static_cast(quant_dequant::compute_error_metrics( + reference, std::vector{1.0F})); + } catch (const MetricsError&) { + // 第二次调用必须因长度不同失败。 + return true; + } + + return false; +} + +/** + * @brief 验证 NVFP4 逻辑字节、压缩率和由时间推导的带宽。 + * + * @return 所有 payload、scale 和公式结果匹配时返回 true。 + */ +[[nodiscard]] bool test_artifact_compression_and_performance_metrics() { + const QuantizedTensor quantized = make_nvfp4_tensor(); + const TensorDesc output_desc{2U, 17U, DType::kBFloat16}; + try { + const ArtifactMetrics artifacts = quant_dequant::make_artifact_metrics( + quantized.desc.source_desc, + quantized, + output_desc, + 146U, + 132U); + const CompressionMetrics compression = + quant_dequant::compute_compression_metrics(artifacts); + const KernelPerformance performance = + quant_dequant::make_kernel_performance( + artifacts, quantized, output_desc, 0.5, 0.25); + + // FP16 输入 34 项共 68 bytes;NVFP4 逻辑量化布局为 17-byte payload、 + // 4-byte local scale 和 4-byte global scale,共 25 bytes。 + const bool correct_artifacts = + artifacts.input_payload_bytes == 68U && + artifacts.quantized_file_bytes == 146U && + artifacts.dequantized_file_bytes == 132U && + artifacts.payload_bytes == 17U && artifacts.local_scale_bytes == 4U && + artifacts.global_scale_bytes == 4U; + const bool correct_compression = + compression.logical_quantized_bytes == 25U && + nearly_equal(compression.logical_compression_ratio, 68.0 / 25.0) && + nearly_equal(compression.on_disk_compression_ratio, 68.0 / 146.0); + const bool correct_performance = + performance.quant_kernel_ms == std::optional{0.5} && + performance.dequant_kernel_ms == std::optional{0.25} && + performance.quant_effective_bandwidth_gbps.has_value() && + performance.dequant_effective_bandwidth_gbps.has_value() && + nearly_equal(*performance.quant_effective_bandwidth_gbps, + 93.0 / (0.5 * 1.0e6)) && + nearly_equal(*performance.dequant_effective_bandwidth_gbps, + 93.0 / (0.25 * 1.0e6)); + return correct_artifacts && correct_compression && correct_performance; + } catch (const MetricsError& error) { + std::cerr << "metrics 字节/压缩率/性能测试意外失败:" + << error.what() << '\n'; + return false; + } +} + +/** + * @brief 验证无 CUDA kernel 时间时性能 JSON 字段保留 null,且文本会正确转义。 + * + * @return JSON 字段、转义内容和实际文件写入均正确时返回 true。 + */ +[[nodiscard]] bool test_json_report_serialization_and_write() { + const QuantizedTensor quantized = make_nvfp4_tensor(); + const TensorDesc output_desc{2U, 17U, DType::kBFloat16}; + try { + const ArtifactMetrics artifacts = quant_dequant::make_artifact_metrics( + quantized.desc.source_desc, quantized, output_desc, 146U, 132U); + const CompressionMetrics compression = + quant_dequant::compute_compression_metrics(artifacts); + const KernelPerformance performance = + quant_dequant::make_kernel_performance( + artifacts, quantized, output_desc, std::nullopt, std::nullopt); + const RunReport report{ + .input_desc = quantized.desc.source_desc, + .quantization = QuantizationConfig{ + .format = QuantFormat::kNvfp4, + .block_size = kNvfp4BlockSize, + .scale_mode = ScaleMode::kBlock, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0U, + }, + .dequantization = DequantizationConfig{DType::kBFloat16}, + .target_gpu = "RTX \"4060\"\nSM89", + .artifacts = artifacts, + .error = ErrorMetrics{.max_abs = 1.0, .mae = 0.5, .mse = 0.375}, + .compression = compression, + .performance = performance, + }; + const std::string json = quant_dequant::serialize_run_report_json(report); + if (json.find("\"quant_kernel_ms\": null") == std::string::npos || + json.find("\"stochastic_seed\": 0") == std::string::npos || + json.find("RTX \\\"4060\\\"\\nSM89") == std::string::npos || + json.find("\"logical_quantized_bytes\": 25") == std::string::npos) { + return false; + } + + const std::filesystem::path report_path = + std::filesystem::temp_directory_path() / + "quant_dequant_metrics_report_test.json"; + std::error_code remove_error{}; + std::filesystem::remove(report_path, remove_error); + quant_dequant::write_run_report_json(report_path, report); + std::ifstream stream{report_path, std::ios::binary}; + const std::string file_json{ + std::istreambuf_iterator{stream}, std::istreambuf_iterator{}}; + std::filesystem::remove(report_path, remove_error); + return stream.good() && file_json == json; + } catch (const MetricsError& error) { + std::cerr << "metrics JSON 报告测试意外失败:" << error.what() << '\n'; + return false; + } +} + +/** + * @brief 验证极短 CUDA kernel 的 0 ms Event 时间仍可写入报告。 + * + * Event 计时分辨率不足时,时间本身是有效观测值;只有带宽因除以零而未定义, + * 应写为 null,不能让完整 app 因一个很小的输入失败。 + * + * @return 时间保留为 0、两项带宽均为空时返回 true。 + */ +[[nodiscard]] bool test_zero_duration_performance_metrics() { + const QuantizedTensor quantized = make_nvfp4_tensor(); + const TensorDesc output_desc{2U, 17U, DType::kFloat32}; + try { + const ArtifactMetrics artifacts = quant_dequant::make_artifact_metrics( + quantized.desc.source_desc, quantized, output_desc, 146U, 200U); + const CompressionMetrics compression = + quant_dequant::compute_compression_metrics(artifacts); + const KernelPerformance performance = quant_dequant::make_kernel_performance( + artifacts, quantized, output_desc, 0.0, 0.0); + const RunReport report{ + .input_desc = quantized.desc.source_desc, + .quantization = QuantizationConfig{ + .format = QuantFormat::kNvfp4, + .block_size = kNvfp4BlockSize, + .scale_mode = ScaleMode::kBlock, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0U, + }, + .dequantization = DequantizationConfig{DType::kFloat32}, + .target_gpu = "RTX 4060", + .artifacts = artifacts, + .error = ErrorMetrics{}, + .compression = compression, + .performance = performance, + }; + const std::string json = quant_dequant::serialize_run_report_json(report); + return performance.quant_kernel_ms == std::optional{0.0} && + performance.dequant_kernel_ms == std::optional{0.0} && + !performance.quant_effective_bandwidth_gbps.has_value() && + !performance.dequant_effective_bandwidth_gbps.has_value() && + json.find("\"quant_kernel_ms\": 0") != std::string::npos && + json.find("\"quant_effective_bandwidth_gbps\": null") != + std::string::npos; + } catch (const MetricsError& error) { + std::cerr << "metrics 0 ms CUDA Event 处理意外失败:" << error.what() << '\n'; + return false; + } +} + +} // namespace + +/** + * @brief 运行误差、压缩率、性能与 JSON 报告的 metrics 单元测试。 + * + * @return 所有断言通过时返回 0;否则返回 1。 + */ +int run_metrics_tests() { + if (!test_error_metrics()) { + std::cerr << "metrics 误差统计测试失败。\n"; + return 1; + } + if (!test_artifact_compression_and_performance_metrics()) { + std::cerr << "metrics 字节、压缩率或带宽测试失败。\n"; + return 1; + } + if (!test_json_report_serialization_and_write()) { + std::cerr << "metrics JSON 报告测试失败。\n"; + return 1; + } + if (!test_zero_duration_performance_metrics()) { + std::cerr << "metrics 0 ms CUDA Event 测试失败。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_codec.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_codec.cu" new file mode 100644 index 00000000..bdb21c4b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_codec.cu" @@ -0,0 +1,157 @@ +#include +#include +#include + +#include + +#include "formats/mxfp8_codec.cuh" + +namespace { + +/** + * @brief 检查 CUDA runtime 调用状态,并在失败时打印诊断信息。 + * + * @param status 待检查的 CUDA runtime 返回值。 + * @param operation 正在执行的 CUDA 操作说明。 + * @return status 为 cudaSuccess 时返回 true。 + */ +[[nodiscard]] bool check_cuda(const cudaError_t status, const char* operation) { + if (status == cudaSuccess) { + return true; + } + + std::cerr << operation << " 失败:" << cudaGetErrorString(status) << '\n'; + return false; +} + +/** + * @brief 在 device 路径验证 -0 的 scale 与 payload 规范化规则。 + * + * grid 只包含一个 CTA 和一个线程:本测试不测并行性能,只确认 + * `__device__` 版本的 codec 与 host 版本具有相同的零语义。 + * + * @param output device 指针,指向至少 4 字节的输出数组。 + */ +__global__ void encodeCanonicalZeroKernel(std::uint8_t* __restrict__ output) { + output[0] = quant_dequant::formats::compute_mxfp8_scale_code(-0.0F); + output[1] = quant_dequant::formats::encode_e4m3_rne_sat(-0.0F); + output[2] = quant_dequant::formats::encode_e4m3_stochastic_sat(-0.0F, 0.5F); + output[3] = quant_dequant::formats::encode_mxfp8_element( + -0.0F, 0x00U, quant_dequant::RoundingMode::kNearest); +} + +/** + * @brief 验证 host codec 对 -0 的项目规范化规则。 + * + * @return 三个编码接口都产生正零 code 时返回 true。 + */ +[[nodiscard]] bool test_host_canonical_zero() { + using quant_dequant::RoundingMode; + using namespace quant_dequant::formats; + + const bool scale_is_min_finite = + compute_mxfp8_scale_code(-0.0F) == 0x00U; + const bool rne_payload_is_positive_zero = + encode_e4m3_rne_sat(-0.0F) == 0x00U; + const bool stochastic_payload_is_positive_zero = + encode_e4m3_stochastic_sat(-0.0F, 0.5F) == 0x00U; + const bool mxfp8_payload_is_positive_zero = + encode_mxfp8_element(-0.0F, 0x00U, RoundingMode::kNearest) == 0x00U; + + return scale_is_min_finite && rne_payload_is_positive_zero && + stochastic_payload_is_positive_zero && mxfp8_payload_is_positive_zero; +} + +/** + * @brief 验证解码器仍能保留外部文件中的合法 E4M3 -0。 + * + * 编码器选择 canonical +0 是项目输出约定;读取已有文件时,`0x80` 仍应 + * 解码为带负号的 FP32 零,不能把两种编码混为一谈。 + * + * @return `0x80` 解码为 -0 时返回 true。 + */ +[[nodiscard]] bool test_signed_zero_decode() { + const float decoded = quant_dequant::formats::decode_e4m3(0x80U); + return decoded == 0.0F && + quant_dequant::formats::fp32::has_negative_sign(decoded); +} + +/** + * @brief 验证 device codec 对 -0 的项目规范化规则。 + * + * @return device 计算出的 scale 与三个 payload 均为 `0x00` 时返回 true。 + */ +[[nodiscard]] bool test_device_canonical_zero() { + int device_count = 0; + const cudaError_t device_query_status = cudaGetDeviceCount(&device_count); + + if (device_query_status == cudaErrorNoDevice || + device_query_status == cudaErrorInsufficientDriver) { + std::cout << "CUDA runtime 不可用,跳过 MXFP8 device 零边界测试。\n"; + return true; + } + + if (!check_cuda(device_query_status, "cudaGetDeviceCount")) { + return false; + } + + if (device_count == 0) { + std::cout << "未发现 CUDA 设备,跳过 MXFP8 device 零边界测试。\n"; + return true; + } + + std::uint8_t* device_output = nullptr; + constexpr std::size_t kOutputBytes = 4U; + + if (!check_cuda(cudaMalloc(&device_output, kOutputBytes), "cudaMalloc")) { + return false; + } + + encodeCanonicalZeroKernel<<<1, 1>>>(device_output); + if (!check_cuda(cudaGetLastError(), "encodeCanonicalZeroKernel 发射")) { + static_cast(cudaFree(device_output)); + return false; + } + + std::array host_output{}; + if (!check_cuda(cudaMemcpy(host_output.data(), device_output, kOutputBytes, + cudaMemcpyDeviceToHost), + "cudaMemcpy(DeviceToHost)")) { + static_cast(cudaFree(device_output)); + return false; + } + + if (!check_cuda(cudaFree(device_output), "cudaFree")) { + return false; + } + + return host_output == std::array{ + 0x00U, 0x00U, 0x00U, 0x00U, + }; +} + +} // namespace + +/** + * @brief 运行 MXFP8 codec 的零边界规则测试。 + * + * @return 所有 host/device 零边界测试通过时返回 0;失败时返回 1。 + */ +int run_mxfp8_codec_tests() { + if (!test_host_canonical_zero()) { + std::cerr << "MXFP8 host 的 -0 规范化测试失败。\n"; + return 1; + } + + if (!test_signed_zero_decode()) { + std::cerr << "MXFP8 E4M3 -0 解码测试失败。\n"; + return 1; + } + + if (!test_device_canonical_zero()) { + std::cerr << "MXFP8 device 的 -0 规范化测试失败。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_cuda.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_cuda.cu" new file mode 100644 index 00000000..12289157 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_cuda.cu" @@ -0,0 +1,318 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/quantize.hpp" + +namespace { + +using quant_dequant::CudaPipelineError; +using quant_dequant::DequantizationConfig; +using quant_dequant::DType; +using quant_dequant::HostTensor; +using quant_dequant::QuantFormat; +using quant_dequant::QuantizationConfig; +using quant_dequant::QuantizedTensor; +using quant_dequant::RoundingMode; +using quant_dequant::ScaleMode; +using quant_dequant::TensorDesc; +using quant_dequant::kMxfp8BlockSize; + +/** 每个 CTA 中并行处理的 MXFP8 逻辑量化 block 数。 */ +constexpr std::uint64_t kWarpsPerCta = 8U; + +/** CUDA launcher 为每个 SM 期望常驻的 CTA 数量。 */ +constexpr std::uint64_t kTargetCtasPerSm = 4U; + +/** + * @brief 返回测试应使用的持久化 kernel 工作行数。 + * + * CUDA block-scale launcher 的一个 warp 处理一个 32 元素量化 block,且最多 + * 启动 `SM 数 * 4` 个 CTA,每个 CTA 有 8 个 warp。这里构造比该首轮容量多 + * 3 行的 35 列矩阵;每行有两个量化 block,因而无论实际 occupancy 是否低于 + * 目标值,至少一部分 warp 都必须通过 grid-stride 循环领取第二轮工作。同时 + * 35 列也覆盖了每行最后一个不足 32 元素的 tail block。 + * + * @param properties 当前 CUDA device 的硬件属性。 + * @return 足以覆盖持久化循环与 tail block 的合法行数。 + */ +[[nodiscard]] std::uint64_t persistent_test_row_count( + const cudaDeviceProp& properties) { + const std::uint64_t sm_count = + static_cast(properties.multiProcessorCount); + return sm_count * kTargetCtasPerSm * kWarpsPerCta + 3U; +} + +/** + * @brief 构造包含多种幅值、零和 block 边界值的有限 FP32 测试输入。 + * + * 值的生成不依赖随机数,因此 CPU reference 与 CUDA 的逐字节比较可稳定复现。 + * 其中较大的正负值会使不同 block 选择不同 E8M0 scale,小值覆盖 E4M3 的 + * normal/subnormal 编码区域;所有值保持有限,正常路径不应触发错误哨兵。 + * + * @param element_count 所需 row-major FP32 元素数量。 + * @return 可直接装入 `HostTensor::values` 的确定性数据。 + */ +[[nodiscard]] std::vector make_finite_input_values( + const std::size_t element_count) { + std::vector values(element_count, 0.0F); + + for (std::size_t index = 0U; index < values.size(); ++index) { + const int bucket = + static_cast((index * 17U + 11U) % 97U) - 48; + values[index] = static_cast(bucket) * 0.125F; + + if (index % 113U == 0U) { + values[index] = 0.0F; + } else if (index % 257U == 0U) { + values[index] = 448.0F; + } else if (index % 521U == 0U) { + values[index] = -896.0F; + } else if (index % 347U == 0U) { + values[index] = 0x1.0p-9F; + } + } + + return values; +} + +/** + * @brief 比较 CPU 和 CUDA 返回的 MXFP8 量化结果全部持久化字段。 + * + * 若 payload 和 E8M0 local scale 都逐字节一致,二者代表完全相同的低精度 + * 张量;额外比较元数据和 `global_scale`,可发现 D2H 构造结果时的布局错误。 + * + * @param cpu CPU reference 量化结果。 + * @param cuda CUDA pipeline D2H 量化结果。 + * @param case_name 用于失败诊断的舍入模式名称。 + * @return 所有持久化字段完全一致时为 true。 + */ +[[nodiscard]] bool equal_quantized_results( + const QuantizedTensor& cpu, + const QuantizedTensor& cuda, + const std::string_view case_name) { + const bool same_desc = + cpu.desc.source_desc.num_rows == cuda.desc.source_desc.num_rows && + cpu.desc.source_desc.num_cols == cuda.desc.source_desc.num_cols && + cpu.desc.source_desc.dtype == cuda.desc.source_desc.dtype && + cpu.desc.format == cuda.desc.format && + cpu.desc.scale_mode == cuda.desc.scale_mode && + cpu.desc.rounding == cuda.desc.rounding && + cpu.desc.stochastic_seed == cuda.desc.stochastic_seed && + cpu.desc.block_size == cuda.desc.block_size && + cpu.desc.scale_layout == cuda.desc.scale_layout; + + if (!same_desc || cpu.payload != cuda.payload || + cpu.local_scales != cuda.local_scales || + cpu.global_scale != cuda.global_scale) { + std::cerr << "MXFP8 CUDA " << case_name + << " 量化结果与 CPU reference 不一致。\n"; + return false; + } + + return true; +} + +/** + * @brief 对同一输入执行一次 MXFP8 CUDA block-scale 量化到反量化的端到端对照。 + * + * 此测试刻意将 `quantize_cuda()` D2H 返回的 `cuda_result` 直接传入 + * `dequantize_cuda()`,而不在两个 CUDA 阶段之间替换成 CPU reference 的低精度 + * 张量。这样同时覆盖 GPU 量化结果的 host 持久化布局、GPU 反量化输入 H2D、 + * E4M3/E8M0 解码 kernel 与最终 D2H。CPU reference 量化结果仅用于逐字节检查, + * CPU reference 反量化结果则是最终 FP32 数值真值。 + * + * @param input 有限 FP32 host 输入。 + * @param config 合法的 MXFP8 block-scale 或 tensor-scale 量化配置。 + * @param case_name 用于失败诊断的 scale 模式与舍入模式名称。 + * @return CPU/CUDA 低精度字节和端到端反量化 FP32 数值均一致时为 true。 + */ +[[nodiscard]] bool compare_with_cpu_reference( + const HostTensor& input, + const QuantizationConfig& config, + const std::string_view case_name) { + QuantizedTensor cpu_result{}; + QuantizedTensor cuda_result{}; + try { + cpu_result = quant_dequant::quantize_reference(input, config); + cuda_result = quant_dequant::quantize_cuda(input, config); + } catch (const std::exception& error) { + std::cerr << "MXFP8 CUDA " << case_name + << " 路径意外失败:" << error.what() << '\n'; + return false; + } + + if (!equal_quantized_results(cpu_result, cuda_result, case_name)) { + return false; + } + + DequantizationConfig dequantization_config{}; + dequantization_config.output_type = DType::kFloat32; + + try { + const HostTensor cpu_dequantized = + quant_dequant::dequantize_reference(cpu_result, dequantization_config); + const HostTensor cuda_dequantized = + quant_dequant::dequantize_cuda(cuda_result, dequantization_config); + + if (cpu_dequantized.desc.num_rows != cuda_dequantized.desc.num_rows || + cpu_dequantized.desc.num_cols != cuda_dequantized.desc.num_cols || + cpu_dequantized.desc.dtype != cuda_dequantized.desc.dtype || + cpu_dequantized.values != cuda_dequantized.values) { + std::cerr << "MXFP8 CUDA " << case_name + << " GPU 量化→GPU 反量化结果与 CPU reference 不一致。\n"; + return false; + } + } catch (const std::exception& error) { + std::cerr << "MXFP8 CUDA " << case_name + << " GPU 量化→GPU 反量化路径意外失败:" << error.what() << '\n'; + return false; + } + + return true; +} + +/** + * @brief 验证 CUDA kernel 经由 E8M0 NaN 哨兵拒绝非有限输入。 + * + * CPU reference 会在扫描 amax 时立即拒绝 NaN/Inf;CUDA 版本在 kernel 内由 + * warp ballot 发现非有限 lane、将对应 scale 写为 `0xff`,再由 D2H 阶段转为 + * `CudaPipelineError`。这个测试覆盖后者,确保不会返回带无意义 payload 的 + * `QuantizedTensor`。 + * + * @param input 正常有限的输入,将复制后注入一个 NaN。 + * @param config 合法的 MXFP8 block-scale 配置。 + * @return CUDA 路径以包含 NaN/Inf 诊断的 CudaPipelineError 失败时为 true。 + */ +[[nodiscard]] bool rejects_nonfinite_input( + HostTensor input, + const QuantizationConfig& config) { + input.values.at(input.values.size() / 2U) = + std::numeric_limits::quiet_NaN(); + + try { + static_cast(quant_dequant::quantize_cuda(input, config)); + } catch (const CudaPipelineError& error) { + return std::string_view{error.what()}.find("NaN 或 Inf") != + std::string_view::npos; + } catch (const std::exception& error) { + std::cerr << "MXFP8 CUDA 非有限输入产生了错误异常类型:" + << error.what() << '\n'; + return false; + } + + std::cerr << "MXFP8 CUDA 没有拒绝 NaN 输入。\n"; + return false; +} + +} // namespace + +/** + * @brief 运行 MXFP8 CUDA tensor/block-scale 与 CPU reference 的端到端对照测试。 + * + * 无 CUDA device 或当前机器缺少可用 driver 时,测试明确跳过并返回成功;其余 + * CUDA runtime 错误应被视为环境或实现失败。可用 GPU 上依次比较 nearest 与 + * stochastic rounding,覆盖 block-scale 的 persistent grid-stride、尾 block、 + * tensor-scale 的两阶段全局 amax reduction、量化 D2H、GPU 量化结果直接传入 + * GPU 反量化,以及两种模式的非有限输入错误哨兵。 + * + * @return 所有可执行的 CUDA 对照项通过时返回 0,否则返回 1。 + */ +int run_mxfp8_cuda_tests() { + int device_count = 0; + const cudaError_t device_count_status = cudaGetDeviceCount(&device_count); + if (device_count_status == cudaErrorNoDevice || + device_count_status == cudaErrorInsufficientDriver) { + std::cout << "CUDA runtime 不可用,跳过 MXFP8 CUDA/CPU 对照测试。\n"; + return 0; + } + + if (device_count_status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" + << cudaGetErrorString(device_count_status) << '\n'; + return 1; + } + + if (device_count == 0) { + std::cout << "未发现 CUDA 设备,跳过 MXFP8 CUDA/CPU 对照测试。\n"; + return 0; + } + + int device_id = 0; + cudaDeviceProp properties{}; + if (cudaGetDevice(&device_id) != cudaSuccess || + cudaGetDeviceProperties(&properties, device_id) != cudaSuccess || + properties.multiProcessorCount <= 0) { + std::cerr << "无法查询当前 CUDA device 的有效 SM 数量。\n"; + return 1; + } + + constexpr std::uint64_t kNumCols = 35U; + const std::uint64_t num_rows = persistent_test_row_count(properties); + const std::uint64_t element_count = num_rows * kNumCols; + if (element_count > std::numeric_limits::max()) { + std::cerr << "MXFP8 CUDA 测试矩阵超出 host 容器范围。\n"; + return 1; + } + + HostTensor input{}; + input.desc = TensorDesc{ + num_rows, + kNumCols, + DType::kFloat32, + }; + input.values = make_finite_input_values( + static_cast(element_count)); + + QuantizationConfig nearest_config{}; + nearest_config.format = QuantFormat::kMxfp8; + nearest_config.block_size = kMxfp8BlockSize; + nearest_config.scale_mode = ScaleMode::kBlock; + nearest_config.rounding = RoundingMode::kNearest; + + if (!compare_with_cpu_reference(input, nearest_config, "nearest")) { + return 1; + } + + QuantizationConfig stochastic_config = nearest_config; + stochastic_config.rounding = RoundingMode::kStochastic; + stochastic_config.stochastic_seed = 0x9e3779b97f4a7c15ULL; + if (!compare_with_cpu_reference(input, stochastic_config, "stochastic")) { + return 1; + } + + if (!rejects_nonfinite_input(input, nearest_config)) { + return 1; + } + + // tensor mode 使用同一 E8M0 scale 覆盖整个矩阵。这里复用足以触发多轮 + // grid-stride 的测试矩阵,分别验证手写两阶段 amax reduction、scale[0] + // 写入和独立 encode kernel 在 nearest/stochastic 下都与 CPU reference 一致。 + QuantizationConfig tensor_nearest_config = nearest_config; + tensor_nearest_config.scale_mode = ScaleMode::kTensor; + if (!compare_with_cpu_reference( + input, tensor_nearest_config, "tensor-nearest")) { + return 1; + } + + QuantizationConfig tensor_stochastic_config = tensor_nearest_config; + tensor_stochastic_config.rounding = RoundingMode::kStochastic; + tensor_stochastic_config.stochastic_seed = 0xd1b54a32d192ed03ULL; + if (!compare_with_cpu_reference( + input, tensor_stochastic_config, "tensor-stochastic")) { + return 1; + } + + if (!rejects_nonfinite_input(input, tensor_nearest_config)) { + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_dequantize_cuda.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_dequantize_cuda.cu" new file mode 100644 index 00000000..537a3fdf --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_dequantize_cuda.cu" @@ -0,0 +1,227 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/quantize.hpp" + +namespace { + +using quant_dequant::DequantizationConfig; +using quant_dequant::DType; +using quant_dequant::HostTensor; +using quant_dequant::QuantFormat; +using quant_dequant::QuantizationConfig; +using quant_dequant::RoundingMode; +using quant_dequant::ScaleMode; +using quant_dequant::TensorDesc; +using quant_dequant::kMxfp8BlockSize; + +/** CUDA device 查询的三态结果,避免把 runtime 故障误作可跳过的无硬件环境。 */ +enum class CudaAvailability { + /** 当前进程可以使用至少一个 CUDA device。 */ + kAvailable, + + /** 没有 device 或 driver,测试应明确跳过。 */ + kUnavailable, + + /** CUDA runtime 返回了其他应报告的错误。 */ + kError, +}; + +/** + * @brief 判断当前测试进程是否能实际启动 CUDA kernel。 + * + * 无 GPU 或缺少 driver 是本项目 CI/开发容器的正常情况;这些环境应跳过数值 + * 对照,而不是把“没有硬件”误报为反量化逻辑错误。 + * + * @return 可执行、可跳过和应报告失败三种状态之一。 + */ +[[nodiscard]] CudaAvailability query_cuda_availability() { + int device_count = 0; + const cudaError_t status = cudaGetDeviceCount(&device_count); + if (status == cudaErrorNoDevice || status == cudaErrorInsufficientDriver) { + std::cout << "CUDA runtime 不可用,跳过 MXFP8 CUDA 反量化对照测试。\n"; + return CudaAvailability::kUnavailable; + } + + if (status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" << cudaGetErrorString(status) << '\n'; + return CudaAvailability::kError; + } + + if (device_count == 0) { + std::cout << "未发现 CUDA device,跳过 MXFP8 CUDA 反量化对照测试。\n"; + return CudaAvailability::kUnavailable; + } + + return CudaAvailability::kAvailable; +} + +/** + * @brief 构造带有两个完整 block 和一个 tail block 的有限 FP32 输入。 + * + * 三行各有 35 列,因而 block-scale 模式每行需要两个 local scale,最后 3 个 + * 元素验证 `column / 32` 对 tail block 的索引。每一行刻意包含不同幅值,避免 + * 所有 block 都退化为同一个 E8M0 scale。 + * + * @return 用于 CPU reference 生成合法 MXFP8 payload/scale 的 host 输入。 + */ +[[nodiscard]] HostTensor make_input() { + constexpr std::uint64_t kRows = 3U; + constexpr std::uint64_t kCols = 35U; + + HostTensor input{}; + input.desc = TensorDesc{kRows, kCols, DType::kFloat32}; + input.values.resize(static_cast(kRows * kCols), 0.0F); + + for (std::size_t index = 0U; index < input.values.size(); ++index) { + const int bucket = + static_cast((index * 13U + 7U) % 41U) - 20; + input.values[index] = static_cast(bucket) * 0.25F; + } + + input.values[0U] = 448.0F; + input.values[31U] = -448.0F; + input.values[32U] = 0x1.0p-9F; + input.values[34U] = -0x1.0p-8F; + input.values[35U] = 896.0F; + input.values[70U] = -896.0F; + return input; +} + +/** + * @brief 比较 CPU reference 与 CUDA 反量化得到的 host FP32 张量。 + * + * CPU、CUDA 都调用相同的无状态 codec 规则;对同一 payload/scale 的结果应逐 + * 元素位级相同。因此这里用 `vector` 的精确比较,能够同时发现 scale + * 下标、payload 下标、D2H 长度和输出描述 dtype 的错误。 + * + * @param expected CPU reference 反量化结果。 + * @param actual CUDA kernel 经 D2H 回传的结果。 + * @param scale_mode_name 用于失败诊断的 scale 模式名称。 + * @param output_type_name 用于失败诊断的输出类型名称。 + * @return 描述和每个 FP32 数值均一致时返回 true。 + */ +[[nodiscard]] bool equal_dequantized_tensors( + const HostTensor& expected, + const HostTensor& actual, + const std::string_view scale_mode_name, + const std::string_view output_type_name) { + if (expected.desc.num_rows != actual.desc.num_rows || + expected.desc.num_cols != actual.desc.num_cols || + expected.desc.dtype != actual.desc.dtype || + expected.values != actual.values) { + std::cerr << "MXFP8 CUDA 反量化与 CPU reference 不一致:scale_mode=" + << scale_mode_name << ",output_type=" << output_type_name + << "。\n"; + return false; + } + + return true; +} + +/** + * @brief 用一种 scale 模式和一种输出类型执行完整 CPU/CUDA 反量化对照。 + * + * 低精度输入由 CPU reference 量化产生,以确保 payload、E8M0 scale 和 metadata + * 均符合 QDWGT 规范;被测对象是 `dequantize_cuda()` 的 H2D、kernel scale 索引、 + * E4M3/E8M0 解码、D2H 以及目标 dtype 描述传递。 + * + * @param input 原始有限 FP32 数据,仅用于生成合法的 MXFP8 输入。 + * @param scale_mode tensor 或 block。 + * @param output_type 请求返回 HostTensor 描述记录的物理输出类型。 + * @param scale_mode_name 用于失败诊断的 scale 模式名称。 + * @param output_type_name 用于失败诊断的输出类型名称。 + * @return CPU reference 与 CUDA 输出完全一致时返回 true。 + */ +[[nodiscard]] bool compare_dequantization_with_cpu( + const HostTensor& input, + const ScaleMode scale_mode, + const DType output_type, + const std::string_view scale_mode_name, + const std::string_view output_type_name) { + QuantizationConfig quantization_config{}; + quantization_config.format = QuantFormat::kMxfp8; + quantization_config.block_size = kMxfp8BlockSize; + quantization_config.scale_mode = scale_mode; + quantization_config.rounding = RoundingMode::kNearest; + + DequantizationConfig dequantization_config{}; + dequantization_config.output_type = output_type; + + try { + const auto quantized = + quant_dequant::quantize_reference(input, quantization_config); + const HostTensor expected = + quant_dequant::dequantize_reference(quantized, dequantization_config); + const HostTensor actual = + quant_dequant::dequantize_cuda(quantized, dequantization_config); + return equal_dequantized_tensors( + expected, actual, scale_mode_name, output_type_name); + } catch (const std::exception& error) { + std::cerr << "MXFP8 CUDA 反量化对照意外失败:scale_mode=" + << scale_mode_name << ",output_type=" << output_type_name + << ",原因:" << error.what() << '\n'; + return false; + } +} + +} // namespace + +/** + * @brief 运行 MXFP8 CUDA 反量化与 CPU reference 的正确性对照。 + * + * 测试分别覆盖 tensor-scale 和 rowwise block-scale,并对 FP16、BF16、FP32 三种 + * 输出类型检查:数值内存始终为 FP32,但 `HostTensor::desc.dtype` 必须正确保留 + * 请求类型,供后续 QDTENSOR 写入器进行物理转换。 + * + * @return 有 CUDA device 时所有对照通过返回 0;无可用 CUDA runtime 时跳过并 + * 返回 0;其他错误返回 1。 + */ +int run_mxfp8_dequantize_cuda_tests() { + const CudaAvailability cuda_availability = query_cuda_availability(); + if (cuda_availability == CudaAvailability::kUnavailable) { + return 0; + } + if (cuda_availability == CudaAvailability::kError) { + return 1; + } + + const HostTensor input = make_input(); + constexpr DType kOutputTypes[]{ + DType::kFloat16, + DType::kBFloat16, + DType::kFloat32, + }; + constexpr std::string_view kOutputTypeNames[]{"fp16", "bf16", "fp32"}; + + constexpr ScaleMode kScaleModes[]{ + ScaleMode::kTensor, + ScaleMode::kBlock, + }; + constexpr std::string_view kScaleModeNames[]{"tensor", "block"}; + + for (std::size_t scale_mode_index = 0U; + scale_mode_index < std::size(kScaleModes); + ++scale_mode_index) { + for (std::size_t output_type_index = 0U; + output_type_index < std::size(kOutputTypes); + ++output_type_index) { + if (!compare_dequantization_with_cpu( + input, + kScaleModes[scale_mode_index], + kOutputTypes[output_type_index], + kScaleModeNames[scale_mode_index], + kOutputTypeNames[output_type_index])) { + return 1; + } + } + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_reference.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_reference.cpp" new file mode 100644 index 00000000..f4b61fb2 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_mxfp8_reference.cpp" @@ -0,0 +1,514 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/quantize.hpp" +#include "quant_dequant/quantized_io.hpp" +#include "quant_dequant/tensor_io.hpp" + +namespace { + +constexpr std::size_t kQdTensorHeaderBytes = 64U; +constexpr std::size_t kQdTensorVersionOffset = 8U; +constexpr std::size_t kQdTensorHeaderBytesOffset = 10U; +constexpr std::size_t kQdTensorByteOrderOffset = 12U; +constexpr std::size_t kQdTensorDTypeOffset = 13U; +constexpr std::size_t kQdTensorRoleOffset = 14U; +constexpr std::size_t kQdTensorRowsOffset = 16U; +constexpr std::size_t kQdTensorColsOffset = 24U; +constexpr std::size_t kQdTensorElementCountOffset = 32U; +constexpr std::size_t kQdTensorDataOffsetOffset = 40U; +constexpr std::size_t kQdTensorDataBytesOffset = 48U; + +constexpr std::array kQdTensorMagic{ + 'Q', 'D', 'T', 'E', 'N', 'S', 'O', 'R', +}; + +/** + * @brief 在析构时删除 CPU reference 端到端测试创建的临时目录。 + */ +class TemporaryDirectoryCleanup final { +public: + /** + * @brief 保存要在析构时清理的目录。 + * + * @param directory_path 本测试独占创建的临时目录。 + */ + explicit TemporaryDirectoryCleanup(std::filesystem::path directory_path) + : mDirectoryPath(std::move(directory_path)) {} + + TemporaryDirectoryCleanup(const TemporaryDirectoryCleanup&) = delete; + TemporaryDirectoryCleanup& operator=(const TemporaryDirectoryCleanup&) = delete; + TemporaryDirectoryCleanup(TemporaryDirectoryCleanup&&) = delete; + TemporaryDirectoryCleanup& operator=(TemporaryDirectoryCleanup&&) = delete; + + /** + * @brief 尽力删除临时目录,不让清理错误掩盖测试结果。 + */ + ~TemporaryDirectoryCleanup() { + std::error_code error_code{}; + std::filesystem::remove_all(mDirectoryPath, error_code); + } + +private: + std::filesystem::path mDirectoryPath; +}; + +/** + * @brief 向字节数组指定位置写入一个 little-endian 无符号整数。 + * + * @tparam UInt 待写入的无符号整数类型。 + * @param bytes 目标文件字节数组。 + * @param offset 整数在 bytes 中的起始下标。 + * @param value 待写入的数值。 + */ +template +void store_unsigned_le(std::span bytes, + const std::size_t offset, + const UInt value) noexcept { + static_assert(std::is_unsigned_v); + + for (std::size_t byte_index = 0U; byte_index < sizeof(UInt); ++byte_index) { + bytes[offset + byte_index] = static_cast( + value >> static_cast(byte_index * 8U)); + } +} + +/** + * @brief 从字节数组的指定位置读取一个 little-endian 无符号整数。 + * + * @tparam UInt 要读取的无符号整数类型。 + * @param bytes 包含完整 QDTENSOR 文件的字节数组。 + * @param offset 整数在 bytes 中的起始下标。 + * @return 由 little-endian 字节序还原的整数。 + */ +template +[[nodiscard]] UInt load_unsigned_le(const std::span bytes, + const std::size_t offset) noexcept { + static_assert(std::is_unsigned_v); + + UInt result{0U}; + for (std::size_t byte_index = 0U; byte_index < sizeof(UInt); ++byte_index) { + result |= static_cast(bytes[offset + byte_index]) + << static_cast(byte_index * 8U); + } + + return result; +} + +/** + * @brief 构造独立于 tensor_io 写入器的 FP32 输入 QDTENSOR v1 文件。 + * + * 项目目前只公开了“反量化输出 QDTENSOR”的写接口,而输入读取器必须校验 + * tensor_role=input。因此端到端测试在这里按 file_format.md 手工生成输入 + * 文件,确保 `read_input_tensor()` 的实际解析路径也被覆盖。 + * + * @param rows 输入矩阵行数。 + * @param cols 输入矩阵列数。 + * @param values 连续 row-major FP32 输入数据。 + * @return 完整的 little-endian QDTENSOR 文件字节。 + */ +[[nodiscard]] std::vector make_fp32_input_file( + const std::uint64_t rows, + const std::uint64_t cols, + const std::span values) { + const std::size_t payload_bytes = values.size() * sizeof(float); + std::vector file_bytes(kQdTensorHeaderBytes + payload_bytes, + 0U); + + std::copy(kQdTensorMagic.begin(), kQdTensorMagic.end(), file_bytes.begin()); + store_unsigned_le(file_bytes, 8U, 1U); + store_unsigned_le( + file_bytes, 10U, static_cast(kQdTensorHeaderBytes)); + file_bytes[12U] = quant_dequant::kLittleEndianByteOrder; + file_bytes[13U] = static_cast(quant_dequant::DType::kFloat32); + file_bytes[14U] = static_cast(quant_dequant::TensorRole::kInput); + store_unsigned_le(file_bytes, 16U, rows); + store_unsigned_le(file_bytes, 24U, cols); + store_unsigned_le(file_bytes, 32U, rows * cols); + store_unsigned_le( + file_bytes, 40U, static_cast(kQdTensorHeaderBytes)); + store_unsigned_le( + file_bytes, 48U, static_cast(payload_bytes)); + + for (std::size_t index = 0U; index < values.size(); ++index) { + store_unsigned_le( + file_bytes, + kQdTensorHeaderBytes + index * sizeof(float), + std::bit_cast(values[index])); + } + + return file_bytes; +} + +/** + * @brief 将完整二进制文件写到测试目录。 + * + * @param file_path 目标文件路径。 + * @param bytes 待写入的完整文件内容。 + * @return 所有字节都成功写入时返回 true。 + */ +[[nodiscard]] bool write_binary_file( + const std::filesystem::path& file_path, + const std::span bytes) { + std::ofstream output_stream{file_path, std::ios::binary | std::ios::trunc}; + if (!output_stream.is_open()) { + return false; + } + + output_stream.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + output_stream.flush(); + return output_stream.good(); +} + +/** + * @brief 将完整二进制文件读入测试用字节数组。 + * + * 此辅助函数不调用 QDTENSOR 读取器,因为输出文件带有 + * `tensor_role = dequantized_output`,公共输入读取接口必须拒绝该 role。 + * 测试需要直接检查其 header 和物理 payload。 + * + * @param file_path 待读取的文件路径。 + * @param bytes 输出参数,成功后保存文件全部字节。 + * @return 文件大小可表示且所有字节读取成功时返回 true。 + */ +[[nodiscard]] bool read_binary_file( + const std::filesystem::path& file_path, + std::vector* const bytes) { + std::error_code error_code{}; + const std::uintmax_t file_size = + std::filesystem::file_size(file_path, error_code); + if (error_code || file_size > std::numeric_limits::max()) { + return false; + } + + bytes->assign(static_cast(file_size), 0U); + std::ifstream input_stream{file_path, std::ios::binary}; + if (!input_stream.is_open()) { + return false; + } + + input_stream.read(reinterpret_cast(bytes->data()), + static_cast(bytes->size())); + return input_stream.good() || input_stream.eof(); +} + +/** + * @brief 构造本 MXFP8 测试向量反量化后应写出的物理 payload。 + * + * 测试矩阵除 `+1`、`-1`、`+448`、`-896` 外均为零,且这四个非零值恰好都能由 + * FP16、BF16 和 FP32 精确表示。因此这里以手工固定的 IEEE bit pattern 校验 + * 写入器,避免测试再次调用与被测代码相同的转换接口。 + * + * @param dtype 要验证的反量化输出物理类型。 + * @param values 已验证等于本测试源数据的 FP32 反量化结果。 + * @return 对应 dtype 的 little-endian row-major payload;未知类型返回空数组。 + */ +[[nodiscard]] std::vector make_expected_output_payload( + const quant_dequant::DType dtype, + const std::span values) { + switch (dtype) { + case quant_dequant::DType::kFloat16: { + std::vector payload(values.size() * 2U, 0U); + store_unsigned_le(payload, 1U * 2U, 0x3c00U); + store_unsigned_le(payload, 2U * 2U, 0xbc00U); + store_unsigned_le(payload, 3U * 2U, 0x5f00U); + store_unsigned_le(payload, 32U * 2U, 0xe300U); + return payload; + } + + case quant_dequant::DType::kBFloat16: { + std::vector payload(values.size() * 2U, 0U); + store_unsigned_le(payload, 1U * 2U, 0x3f80U); + store_unsigned_le(payload, 2U * 2U, 0xbf80U); + store_unsigned_le(payload, 3U * 2U, 0x43e0U); + store_unsigned_le(payload, 32U * 2U, 0xc460U); + return payload; + } + + case quant_dequant::DType::kFloat32: { + std::vector payload(values.size() * 4U, 0U); + for (std::size_t index = 0U; index < values.size(); ++index) { + store_unsigned_le( + payload, + index * 4U, + std::bit_cast(values[index])); + } + return payload; + } + + case quant_dequant::DType::kUnknown: + return {}; + } + + return {}; +} + +/** + * @brief 检查 CPU 反量化结果写出的完整 QDTENSOR 文件。 + * + * @param file_bytes 实际读回的完整文件字节。 + * @param dtype 期望的输出 dtype。 + * @param rows 期望矩阵行数。 + * @param cols 期望矩阵列数。 + * @param expected_payload 期望的 little-endian row-major payload。 + * @return QDTENSOR header、文件长度和 payload 全部匹配时返回 true。 + */ +[[nodiscard]] bool is_expected_dequantized_output_file( + const std::span file_bytes, + const quant_dequant::DType dtype, + const std::uint64_t rows, + const std::uint64_t cols, + const std::span expected_payload) { + if (file_bytes.size() != kQdTensorHeaderBytes + expected_payload.size()) { + return false; + } + + const bool valid_header = + std::equal(kQdTensorMagic.begin(), kQdTensorMagic.end(), + file_bytes.begin()) && + load_unsigned_le(file_bytes, kQdTensorVersionOffset) == + 1U && + load_unsigned_le(file_bytes, kQdTensorHeaderBytesOffset) == + kQdTensorHeaderBytes && + file_bytes[kQdTensorByteOrderOffset] == + quant_dequant::kLittleEndianByteOrder && + file_bytes[kQdTensorDTypeOffset] == static_cast(dtype) && + file_bytes[kQdTensorRoleOffset] == + static_cast( + quant_dequant::TensorRole::kDequantizedOutput) && + load_unsigned_le(file_bytes, kQdTensorRowsOffset) == rows && + load_unsigned_le(file_bytes, kQdTensorColsOffset) == cols && + load_unsigned_le(file_bytes, kQdTensorElementCountOffset) == + rows * cols && + load_unsigned_le(file_bytes, kQdTensorDataOffsetOffset) == + kQdTensorHeaderBytes && + load_unsigned_le(file_bytes, kQdTensorDataBytesOffset) == + expected_payload.size(); + + return valid_header && std::equal( + expected_payload.begin(), expected_payload.end(), + file_bytes.begin() + static_cast( + kQdTensorHeaderBytes)); +} + +/** + * @brief 比较端到端测试关心的 MXFP8 量化结果字段。 + * + * @param left 量化器刚生成的结果。 + * @param right 经 QDWGT 写入并读取后的结果。 + * @return 元数据、payload、local scale 与 global scale 语义均一致时返回 true。 + */ +[[nodiscard]] bool same_quantized_tensor( + const quant_dequant::QuantizedTensor& left, + const quant_dequant::QuantizedTensor& right) { + const auto& left_desc = left.desc; + const auto& right_desc = right.desc; + + return left_desc.source_desc.num_rows == right_desc.source_desc.num_rows && + left_desc.source_desc.num_cols == right_desc.source_desc.num_cols && + left_desc.source_desc.dtype == right_desc.source_desc.dtype && + left_desc.format == right_desc.format && + left_desc.scale_mode == right_desc.scale_mode && + left_desc.rounding == right_desc.rounding && + left_desc.stochastic_seed == right_desc.stochastic_seed && + left_desc.block_size == right_desc.block_size && + left_desc.scale_layout == right_desc.scale_layout && + left.payload == right.payload && + left.local_scales == right.local_scales && + !left.global_scale.has_value() && !right.global_scale.has_value(); +} + +/** + * @brief 验证 FP32 输入到 MXFP8 QDWGT 再到三种 QDTENSOR 输出的 CPU 链路。 + * + * 输入形状刻意取 1 x 33:前 32 个元素形成一个完整 block,最后一个元素形成 + * 尾 block。前一 block 的 amax 是 448,故 E8M0 scale 为 1(0x7f);尾 + * block 的唯一元素为 -896,故 scale 为 2(0x80)。这同时锁定 rowwise + * block 分组、scale 计算、E4M3 编码、尾 block 和 QDWGT I/O。读回 QDWGT 后 + * 还会经 public 反量化入口恢复为 FP32,并立即写出 FP16、BF16、FP32 三种 + * QDTENSOR 文件。这里的测试值全都恰好可表示,因此三种文件的 payload 都可 + * 以固定 bit pattern 验证。 + * + * @param directory 测试专用临时目录。 + * @return 完整文件链路及全部位级和数值期望一致时返回 true。 + */ +[[nodiscard]] bool test_mxfp8_cpu_round_trip_with_io( + const std::filesystem::path& directory) { + constexpr std::uint64_t kRows = 1U; + constexpr std::uint64_t kCols = 33U; + + std::vector source_values(static_cast(kRows * kCols), + 0.0F); + source_values[0U] = 0.0F; + source_values[1U] = 1.0F; + source_values[2U] = -1.0F; + source_values[3U] = 448.0F; + source_values[32U] = -896.0F; + + const std::filesystem::path input_path = directory / "input.qdtensor"; + const std::vector input_file = make_fp32_input_file( + kRows, kCols, source_values); + if (!write_binary_file(input_path, input_file)) { + std::cerr << "无法写入 CPU MXFP8 端到端测试输入文件。\n"; + return false; + } + + const quant_dequant::HostTensor input = + quant_dequant::read_input_tensor(input_path); + if (input.desc.num_rows != kRows || input.desc.num_cols != kCols || + input.desc.dtype != quant_dequant::DType::kFloat32 || + input.values != source_values) { + std::cerr << "QDTENSOR 输入读取结果与原始 FP32 数据不一致。\n"; + return false; + } + + const quant_dequant::QuantizationConfig config{ + .format = quant_dequant::QuantFormat::kMxfp8, + .block_size = quant_dequant::kMxfp8BlockSize, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + }; + const quant_dequant::QuantizedTensor quantized = + quant_dequant::quantize_reference(input, config); + + std::vector expected_payload(33U, 0x00U); + expected_payload[1U] = 0x38U; + expected_payload[2U] = 0xb8U; + expected_payload[3U] = 0x7eU; + expected_payload[32U] = 0xfeU; + + if (!quantized.isConsistent() || + quantized.desc.source_desc.dtype != quant_dequant::DType::kFloat32 || + quantized.desc.format != quant_dequant::QuantFormat::kMxfp8 || + quantized.desc.scale_mode != quant_dequant::ScaleMode::kBlock || + quantized.local_scales != std::vector{0x7fU, 0x80U} || + quantized.payload != expected_payload || quantized.global_scale.has_value()) { + std::cerr << "MXFP8 CPU 量化的 scale、payload 或元数据与预期不一致。\n"; + return false; + } + + const std::filesystem::path quantized_path = directory / "output.qdwgt"; + quant_dequant::write_quantized_tensor(quantized_path, quantized); + + std::error_code error_code{}; + const std::uintmax_t output_file_size = + std::filesystem::file_size(quantized_path, error_code); + if (error_code || output_file_size != 170U) { + std::cerr << "MXFP8 QDWGT 文件大小或 section 对齐不符合预期。\n"; + return false; + } + + const quant_dequant::QuantizedTensor loaded = + quant_dequant::read_quantized_tensor(quantized_path); + if (!same_quantized_tensor(quantized, loaded)) { + std::cerr << "MXFP8 QDWGT 写入并读取后量化结果不一致。\n"; + return false; + } + + // CPU reference 始终以 FP32 保存计算结果;三个 output_type 的差异在随后 + // write_dequantized_tensor() 写 QDTENSOR 时才落实为不同物理 payload。这里 + // 不仅检查 desc.dtype,还会读回文件并比对固定 header 和实际 payload。 + constexpr std::array kOutputTypes{ + quant_dequant::DType::kFloat16, + quant_dequant::DType::kBFloat16, + quant_dequant::DType::kFloat32, + }; + for (const quant_dequant::DType output_type : kOutputTypes) { + const quant_dequant::DequantizationConfig dequantization_config{ + .output_type = output_type, + }; + const quant_dequant::HostTensor dequantized = + quant_dequant::dequantize_reference(loaded, dequantization_config); + if (dequantized.desc.num_rows != kRows || + dequantized.desc.num_cols != kCols || + dequantized.desc.dtype != output_type || + dequantized.values != source_values) { + std::cerr << "MXFP8 CPU 反量化结果或输出类型与预期不一致。\n"; + return false; + } + + const std::filesystem::path dequantized_path = + directory / + ("dequantized_" + std::string{quant_dequant::to_string(output_type)} + + ".qdtensor"); + quant_dequant::write_dequantized_tensor(dequantized_path, dequantized); + + const std::vector expected_output_payload = + make_expected_output_payload(output_type, dequantized.values); + std::vector output_file_bytes{}; + if (!read_binary_file(dequantized_path, &output_file_bytes) || + !is_expected_dequantized_output_file( + output_file_bytes, + output_type, + kRows, + kCols, + expected_output_payload)) { + std::cerr << "MXFP8 CPU 反量化输出 QDTENSOR 的 header 或 payload 不正确。\n"; + return false; + } + } + + return true; +} + +} // namespace + +/** + * @brief 运行 CPU MXFP8 量化、QDWGT I/O 与反量化端到端测试。 + * + * @return 所有 QDTENSOR -> CPU quantize -> QDWGT -> CPU dequantize 步骤通过时返回 0。 + */ +int run_mxfp8_reference_tests() { + const auto timestamp = + std::chrono::steady_clock::now().time_since_epoch().count(); + const std::filesystem::path directory = + std::filesystem::temp_directory_path() / + ("quant_dequant_mxfp8_reference_test_" + std::to_string(timestamp)); + + std::error_code error_code{}; + if (!std::filesystem::create_directory(directory, error_code) || error_code) { + std::cerr << "无法创建 MXFP8 CPU reference 测试临时目录。\n"; + return 1; + } + const TemporaryDirectoryCleanup cleanup{directory}; + + try { + if (!test_mxfp8_cpu_round_trip_with_io(directory)) { + return 1; + } + } catch (const quant_dequant::TensorIoError& error) { + std::cerr << "MXFP8 CPU reference 测试发生 Tensor I/O 错误:" + << error.what() << '\n'; + return 1; + } catch (const quant_dequant::QuantizedIoError& error) { + std::cerr << "MXFP8 CPU reference 测试发生 Quantized I/O 错误:" + << error.what() << '\n'; + return 1; + } catch (const quant_dequant::ReferenceError& error) { + std::cerr << "MXFP8 CPU reference 测试发生量化错误:" + << error.what() << '\n'; + return 1; + } catch (const std::exception& error) { + std::cerr << "MXFP8 CPU reference 测试发生未预期异常:" + << error.what() << '\n'; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_codec.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_codec.cu" new file mode 100644 index 00000000..cea7f717 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_codec.cu" @@ -0,0 +1,240 @@ +#include +#include +#include +#include + +#include + +#include "formats/nvfp4_codec.cuh" + +namespace { + +/** + * @brief 检查 CUDA runtime 调用,并在失败时打印诊断。 + * + * @param status 待检查的 CUDA runtime 返回值。 + * @param operation 正在执行的 CUDA 操作说明。 + * @return `status == cudaSuccess` 时返回 true。 + */ +[[nodiscard]] bool check_cuda(const cudaError_t status, const char* operation) { + if (status == cudaSuccess) { + return true; + } + + std::cerr << operation << " 失败:" << cudaGetErrorString(status) << '\n'; + return false; +} + +/** + * @brief 比较两个有限 FP32 值是否在小误差范围内相等。 + * + * @param left 左操作数。 + * @param right 右操作数。 + * @return 绝对误差不超过 `1e-6` 时返回 true。 + */ +[[nodiscard]] bool nearly_equal(const float left, const float right) { + return std::fabs(left - right) <= 1.0e-6F; +} + +/** + * @brief 在 device 路径验证 E2M1 编码、NVFP4 scale 和 nibble 打包。 + * + * 单线程 kernel 只验证 codec 的 `__device__` 版本是否可调用且与 host 结果 + * 一致,并不涉及真实量化 kernel 的并行映射。 + * + * @param output device 指针,指向至少 5 字节可写空间。 + */ +__global__ void encodeNvfp4CodecKernel(std::uint8_t* __restrict__ output) { + using quant_dequant::RoundingMode; + using namespace quant_dequant::formats; + + const float global_scale = compute_nvfp4_global_scale(6.0F); + const std::uint8_t local_scale = + compute_nvfp4_local_scale_code(6.0F, global_scale); + const std::uint8_t positive = encode_nvfp4_element( + 6.0F, local_scale, global_scale, RoundingMode::kNearest); + const std::uint8_t negative = encode_nvfp4_element( + -6.0F, local_scale, global_scale, RoundingMode::kNearest); + + output[0] = encode_e2m1_rne_sat(5.0F); + output[1] = local_scale; + output[2] = positive; + output[3] = pack_e2m1_nibbles(positive, negative); + output[4] = unpack_e2m1_high_nibble(output[3]); +} + +/** + * @brief 验证 E2M1 的完整正 codebook、符号位和正零规范化。 + * + * @return 全部 eight magnitude code 的解码及零规则正确时返回 true。 + */ +[[nodiscard]] bool test_e2m1_codebook_and_zero() { + using namespace quant_dequant::formats; + + constexpr std::array kExpectedMagnitude{ + 0.0F, 0.5F, 1.0F, 1.5F, 2.0F, 3.0F, 4.0F, 6.0F, + }; + + for (std::uint8_t code = 0U; code <= kE2M1PositiveMaxCode; ++code) { + if (decode_e2m1(code) != kExpectedMagnitude[code] || + decode_e2m1(static_cast(code | 0x08U)) != + -kExpectedMagnitude[code]) { + return false; + } + } + + const float negative_zero = decode_e2m1(0x08U); + return negative_zero == 0.0F && fp32::has_negative_sign(negative_zero) && + encode_e2m1_rne_sat(-0.0F) == 0x00U; +} + +/** + * @brief 验证 E2M1 RNE 的不等间距中点、stochastic 端点和非有限回退。 + * + * @return 所有编码边界符合项目规则时返回 true。 + */ +[[nodiscard]] bool test_e2m1_rounding_and_saturation() { + using namespace quant_dequant::formats; + + const bool rne_ties_to_even = + encode_e2m1_rne_sat(0.25F) == 0x00U && + encode_e2m1_rne_sat(0.75F) == 0x02U && + encode_e2m1_rne_sat(1.25F) == 0x02U && + encode_e2m1_rne_sat(1.75F) == 0x04U && + encode_e2m1_rne_sat(2.5F) == 0x04U && + encode_e2m1_rne_sat(3.5F) == 0x06U && + encode_e2m1_rne_sat(5.0F) == 0x06U; + + const bool stochastic_endpoints = + // codec 与 MXFP8 的约定相同:uniform_random < p_upper 时选上界。 + encode_e2m1_stochastic_sat(1.25F, 0.0F) == 0x03U && + encode_e2m1_stochastic_sat(1.25F, 0.999F) == 0x02U && + encode_e2m1_stochastic_sat(-1.25F, 0.999F) == 0x0aU; + + const bool nonfinite_policy = + encode_e2m1_rne_sat(fp32::canonical_quiet_nan()) == 0x00U && + encode_e2m1_rne_sat(fp32::kPositiveInfinity) == 0x07U && + encode_e2m1_rne_sat(-fp32::kPositiveInfinity) == 0x0fU; + + return rne_ties_to_even && stochastic_endpoints && nonfinite_policy; +} + +/** + * @brief 验证标准 NVFP4 双层 scale、单元素变换和真实 nibble 打包。 + * + * 使用格式规范中的 `amax = 6` 向量:global scale 应为 `1/448`,local + * E4M3 scale 应为 `0x7e`(448),`{+6, -6}` 应编码并打包为 `0xf7`。 + * + * @return scale、payload 与反量化值均正确时返回 true。 + */ +[[nodiscard]] bool test_nvfp4_scaling_and_packing() { + using quant_dequant::RoundingMode; + using namespace quant_dequant::formats; + + const float global_scale = compute_nvfp4_global_scale(6.0F); + const std::uint8_t local_scale = + compute_nvfp4_local_scale_code(6.0F, global_scale); + const std::uint8_t positive = encode_nvfp4_element( + 6.0F, local_scale, global_scale, RoundingMode::kNearest); + const std::uint8_t negative = encode_nvfp4_element( + -6.0F, local_scale, global_scale, RoundingMode::kNearest); + const std::uint8_t packed = pack_e2m1_nibbles(positive, negative); + + const bool standard_vector = nearly_equal(global_scale, 1.0F / 448.0F) && + local_scale == 0x7eU && positive == 0x07U && negative == 0x0fU && + packed == 0xf7U && unpack_e2m1_low_nibble(packed) == positive && + unpack_e2m1_high_nibble(packed) == negative && + nearly_equal(decode_nvfp4_element(positive, local_scale, global_scale), 6.0F) && + nearly_equal(decode_nvfp4_element(negative, local_scale, global_scale), -6.0F); + + const bool zero_and_invalid_scales = + compute_nvfp4_global_scale(0.0F) == 1.0F && + compute_nvfp4_local_scale_code(0.0F, 1.0F) == 0x00U && + compute_nvfp4_local_scale_code(1.0F, 0.0F) == kE4M3CanonicalNaNCode && + encode_nvfp4_element(1.0F, 0x00U, 1.0F, RoundingMode::kNearest) == 0x00U; + + return standard_vector && zero_and_invalid_scales; +} + +/** + * @brief 验证 device 版本的 NVFP4 codec 与已知 host bit pattern 一致。 + * + * @return 无 CUDA device 时跳过并返回 true;否则全部结果相符时返回 true。 + */ +[[nodiscard]] bool test_device_nvfp4_codec() { + int device_count = 0; + const cudaError_t device_query_status = cudaGetDeviceCount(&device_count); + if (device_query_status == cudaErrorNoDevice || + device_query_status == cudaErrorInsufficientDriver) { + std::cout << "CUDA runtime 不可用,跳过 NVFP4 device codec 测试。\n"; + return true; + } + + if (!check_cuda(device_query_status, "cudaGetDeviceCount")) { + return false; + } + + if (device_count == 0) { + std::cout << "未发现 CUDA 设备,跳过 NVFP4 device codec 测试。\n"; + return true; + } + + constexpr std::size_t kOutputBytes = 5U; + std::uint8_t* device_output = nullptr; + if (!check_cuda(cudaMalloc(&device_output, kOutputBytes), "cudaMalloc")) { + return false; + } + + encodeNvfp4CodecKernel<<<1, 1>>>(device_output); + if (!check_cuda(cudaGetLastError(), "encodeNvfp4CodecKernel 发射")) { + static_cast(cudaFree(device_output)); + return false; + } + + std::array host_output{}; + if (!check_cuda(cudaMemcpy(host_output.data(), device_output, kOutputBytes, + cudaMemcpyDeviceToHost), + "cudaMemcpy(DeviceToHost)")) { + static_cast(cudaFree(device_output)); + return false; + } + + if (!check_cuda(cudaFree(device_output), "cudaFree")) { + return false; + } + + return host_output == std::array{ + 0x06U, 0x7eU, 0x07U, 0xf7U, 0x0fU, + }; +} + +} // namespace + +/** + * @brief 运行 NVFP4 codec 的 host/device 数值语义测试。 + * + * @return 所有测试通过时返回 0;否则打印失败原因并返回 1。 + */ +int run_nvfp4_codec_tests() { + if (!test_e2m1_codebook_and_zero()) { + std::cerr << "E2M1 codebook 或 signed-zero 规则测试失败。\n"; + return 1; + } + + if (!test_e2m1_rounding_and_saturation()) { + std::cerr << "E2M1 舍入、饱和或非有限回退测试失败。\n"; + return 1; + } + + if (!test_nvfp4_scaling_and_packing()) { + std::cerr << "NVFP4 双层 scale、编码或 nibble 打包测试失败。\n"; + return 1; + } + + if (!test_device_nvfp4_codec()) { + std::cerr << "NVFP4 device codec 测试失败。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_cuda.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_cuda.cu" new file mode 100644 index 00000000..33be3ebf --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_cuda.cu" @@ -0,0 +1,247 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "formats/fp32_utils.cuh" +#include "quant_dequant/quantize.hpp" + +namespace { + +using quant_dequant::CudaPipelineError; +using quant_dequant::DType; +using quant_dequant::HostTensor; +using quant_dequant::QuantFormat; +using quant_dequant::QuantizationConfig; +using quant_dequant::QuantizedTensor; +using quant_dequant::RoundingMode; +using quant_dequant::ScaleMode; +using quant_dequant::TensorDesc; +using quant_dequant::kNvfp4BlockSize; + +/** NVFP4 local-scale persistent grid 的目标 CTA/SM 数量。 */ +constexpr std::uint64_t kTargetCtasPerSm = 4U; + +/** 每个 256-thread CTA 包含的 16 元素 NVFP4 logical block 数量。 */ +constexpr std::uint64_t kNvfp4TilesPerCta = 16U; + +/** + * @brief 生成足以触发 local-scale 与 packed-encode grid-stride 循环的行数。 + * + * 35 列矩阵每行有 3 个 NVFP4 local block,且为奇数列,因而同时覆盖尾 block + * 和跨行 packed byte。此行数至少超过 local-scale persistent 首轮容量。 + * + * @param properties 当前 CUDA device 属性。 + * @return 合法且非零的测试矩阵行数。 + */ +[[nodiscard]] std::uint64_t persistent_test_row_count( + const cudaDeviceProp& properties) { + return static_cast(properties.multiProcessorCount) * + kTargetCtasPerSm * kNvfp4TilesPerCta + + 3U; +} + +/** + * @brief 构造确定性有限 FP32 输入,覆盖多种 E2M1 和 scale 边界。 + * + * @param element_count 所需 row-major 元素数量。 + * @return 可供 CPU reference 与 CUDA pipeline 重复使用的输入数值。 + */ +[[nodiscard]] std::vector make_input_values( + const std::size_t element_count) { + std::vector values(element_count, 0.0F); + for (std::size_t index = 0U; index < values.size(); ++index) { + const int bucket = + static_cast((index * 29U + 7U) % 113U) - 56; + values[index] = static_cast(bucket) * 0.125F; + + if (index % 131U == 0U) { + values[index] = 0.0F; + } else if (index % 251U == 0U) { + values[index] = 6.0F; + } else if (index % 509U == 0U) { + values[index] = -18.0F; + } else if (index % 347U == 0U) { + values[index] = 0.5F; + } + } + + return values; +} + +/** + * @brief 比较 CPU reference 与 CUDA 量化结果的所有持久化字段。 + * + * @param cpu CPU reference 量化结果。 + * @param cuda CUDA pipeline 经 D2H 得到的量化结果。 + * @param case_name 用于失败诊断的舍入模式名称。 + * @return 描述、packed payload、E4M3 local scale 和 FP32 global scale 位级一致时返回 true。 + */ +[[nodiscard]] bool equal_quantized_results( + const QuantizedTensor& cpu, + const QuantizedTensor& cuda, + const std::string_view case_name) { + const bool same_desc = + cpu.desc.source_desc.num_rows == cuda.desc.source_desc.num_rows && + cpu.desc.source_desc.num_cols == cuda.desc.source_desc.num_cols && + cpu.desc.source_desc.dtype == cuda.desc.source_desc.dtype && + cpu.desc.format == cuda.desc.format && + cpu.desc.scale_mode == cuda.desc.scale_mode && + cpu.desc.rounding == cuda.desc.rounding && + cpu.desc.stochastic_seed == cuda.desc.stochastic_seed && + cpu.desc.block_size == cuda.desc.block_size && + cpu.desc.scale_layout == cuda.desc.scale_layout; + + const bool same_global_scale = cpu.global_scale.has_value() && + cuda.global_scale.has_value() && + quant_dequant::formats::fp32::float_to_bits(*cpu.global_scale) == + quant_dequant::formats::fp32::float_to_bits(*cuda.global_scale); + if (!same_desc || cpu.payload != cuda.payload || + cpu.local_scales != cuda.local_scales || !same_global_scale) { + std::cerr << "NVFP4 CUDA " << case_name + << " 的量化结果与 CPU reference 不一致。\n"; + return false; + } + + return true; +} + +/** + * @brief 执行一次 NVFP4 CPU/CUDA 逐字节量化对照。 + * + * @param input 有限 FP32 host 输入。 + * @param config 严格 NVFP4 block-scale 配置。 + * @param case_name 用于失败诊断的测试名称。 + * @return CPU 与 CUDA 量化结果逐字段一致时返回 true。 + */ +[[nodiscard]] bool compare_with_cpu_reference( + const HostTensor& input, + const QuantizationConfig& config, + const std::string_view case_name) { + try { + const QuantizedTensor cpu_result = + quant_dequant::quantize_reference(input, config); + const QuantizedTensor cuda_result = + quant_dequant::quantize_cuda(input, config); + return equal_quantized_results(cpu_result, cuda_result, case_name); + } catch (const std::exception& error) { + std::cerr << "NVFP4 CUDA " << case_name + << " 路径意外失败:" << error.what() << '\n'; + return false; + } +} + +/** + * @brief 验证 CUDA 全局 amax 规约会拒绝 NaN/Inf,而不会交付半成品 payload。 + * + * @param input 正常有限输入的副本,将在中间位置注入 NaN。 + * @param config 严格 NVFP4 block-scale 配置。 + * @return 抛出包含 NaN/Inf 诊断的 CudaPipelineError 时返回 true。 + */ +[[nodiscard]] bool rejects_nonfinite_input( + HostTensor input, + const QuantizationConfig& config) { + input.values[input.values.size() / 2U] = + std::numeric_limits::quiet_NaN(); + try { + static_cast(quant_dequant::quantize_cuda(input, config)); + } catch (const CudaPipelineError& error) { + return std::string_view{error.what()}.find("NaN 或 Inf") != + std::string_view::npos; + } catch (const std::exception& error) { + std::cerr << "NVFP4 CUDA 非有限输入产生了错误异常类型:" + << error.what() << '\n'; + return false; + } + + return false; +} + +} // namespace + +/** + * @brief 运行 NVFP4 CUDA block-scale 与 CPU reference 的逐字节对照测试。 + * + * 无 CUDA device 或 driver 不可用时明确跳过。可用 GPU 上覆盖 nearest、 + * stochastic、全局两阶段 amax reduction、16-lane local-scale tile、32-lane + * packed store、奇数列跨行 byte 配对、尾 block、persistent grid-stride 和 + * 非有限输入拒绝。 + * + * @return 所有可执行测试通过时返回 0,否则返回 1。 + */ +int run_nvfp4_cuda_tests() { + int device_count = 0; + const cudaError_t device_count_status = cudaGetDeviceCount(&device_count); + if (device_count_status == cudaErrorNoDevice || + device_count_status == cudaErrorInsufficientDriver) { + std::cout << "CUDA runtime 不可用,跳过 NVFP4 CUDA/CPU 对照测试。\n"; + return 0; + } + + if (device_count_status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" + << cudaGetErrorString(device_count_status) << '\n'; + return 1; + } + + if (device_count == 0) { + std::cout << "未发现 CUDA 设备,跳过 NVFP4 CUDA/CPU 对照测试。\n"; + return 0; + } + + int device_id = 0; + cudaDeviceProp properties{}; + if (cudaGetDevice(&device_id) != cudaSuccess || + cudaGetDeviceProperties(&properties, device_id) != cudaSuccess || + properties.multiProcessorCount <= 0) { + std::cerr << "无法查询当前 CUDA device 的有效 SM 数量。\n"; + return 1; + } + + constexpr std::uint64_t kNumCols = 35U; + const std::uint64_t num_rows = persistent_test_row_count(properties); + const std::uint64_t element_count = num_rows * kNumCols; + if (element_count > std::numeric_limits::max()) { + std::cerr << "NVFP4 CUDA 测试矩阵超出 host 容器范围。\n"; + return 1; + } + + HostTensor input{ + .desc = TensorDesc{ + .num_rows = num_rows, + .num_cols = kNumCols, + .dtype = DType::kFloat32, + }, + .values = make_input_values(static_cast(element_count)), + }; + QuantizationConfig nearest_config{ + .format = QuantFormat::kNvfp4, + .block_size = kNvfp4BlockSize, + .scale_mode = ScaleMode::kBlock, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0U, + }; + if (!compare_with_cpu_reference(input, nearest_config, "nearest")) { + return 1; + } + + QuantizationConfig stochastic_config = nearest_config; + stochastic_config.rounding = RoundingMode::kStochastic; + stochastic_config.stochastic_seed = 0x9e3779b97f4a7c15ULL; + if (!compare_with_cpu_reference(input, stochastic_config, "stochastic")) { + return 1; + } + + if (!rejects_nonfinite_input(input, nearest_config)) { + std::cerr << "NVFP4 CUDA 未拒绝 NaN 输入。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_dequantize_cuda.cu" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_dequantize_cuda.cu" new file mode 100644 index 00000000..722600fb --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_dequantize_cuda.cu" @@ -0,0 +1,174 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/quantize.hpp" + +namespace { + +using quant_dequant::DequantizationConfig; +using quant_dequant::DType; +using quant_dequant::HostTensor; +using quant_dequant::QuantFormat; +using quant_dequant::QuantizationConfig; +using quant_dequant::RoundingMode; +using quant_dequant::ScaleMode; +using quant_dequant::TensorDesc; +using quant_dequant::kNvfp4BlockSize; + +/** CUDA device 查询的三态结果,避免把 runtime 故障误作可跳过的无硬件环境。 */ +enum class CudaAvailability { + /** 当前进程可以使用至少一个 CUDA device。 */ + kAvailable, + + /** 没有 device 或 driver,测试应明确跳过。 */ + kUnavailable, + + /** CUDA runtime 返回了其他应报告的错误。 */ + kError, +}; + +/** + * @brief 判断当前测试进程是否能实际启动 CUDA kernel。 + * + * @return 可执行、可跳过和应报告失败三种状态之一。 + */ +[[nodiscard]] CudaAvailability query_cuda_availability() { + int device_count = 0; + const cudaError_t status = cudaGetDeviceCount(&device_count); + if (status == cudaErrorNoDevice || status == cudaErrorInsufficientDriver) { + std::cout << "CUDA runtime 不可用,跳过 NVFP4 CUDA 反量化对照测试。\n"; + return CudaAvailability::kUnavailable; + } + if (status != cudaSuccess) { + std::cerr << "cudaGetDeviceCount 失败:" << cudaGetErrorString(status) << '\n'; + return CudaAvailability::kError; + } + if (device_count == 0) { + std::cout << "未发现 CUDA device,跳过 NVFP4 CUDA 反量化对照测试。\n"; + return CudaAvailability::kUnavailable; + } + + return CudaAvailability::kAvailable; +} + +/** + * @brief 构造跨行 packed byte、16 元素 tail block 和奇数总元素数的测试输入。 + * + * 3×35 同时保证每行有三个 16 元素 scale block、相邻线性元素可跨行配对,并让 + * 总元素 105 为奇数,以测试最后 payload byte 未使用的高 nibble 不会写越界。 + * + * @return 有限的 row-major FP32 输入。 + */ +[[nodiscard]] HostTensor make_input() { + constexpr std::uint64_t kRows = 3U; + constexpr std::uint64_t kCols = 35U; + HostTensor input{ + .desc = TensorDesc{kRows, kCols, DType::kFloat32}, + .values = std::vector(static_cast(kRows * kCols), 0.0F), + }; + + for (std::size_t index = 0U; index < input.values.size(); ++index) { + const int bucket = static_cast((index * 17U + 5U) % 47U) - 23; + input.values[index] = static_cast(bucket) * 0.375F; + } + input.values[0U] = 6.0F; + input.values[15U] = -6.0F; + input.values[16U] = 0.5F; + input.values[34U] = -3.0F; + input.values[35U] = 1.5F; + input.values.back() = -0.5F; + return input; +} + +/** + * @brief 以一种输出 dtype 完整比较 NVFP4 CPU 与 CUDA 反量化路径。 + * + * 被测 CUDA 路径包括 QDWGT 三类 buffer 的 H2D、一个线程一个 packed byte 的 + * 解包、跨行 low/high nibble 的独立 scale 索引和 FP32 结果 D2H。 + * + * @param input 用于 CPU reference 产生合法 NVFP4 QDWGT 内容的有限 host 张量。 + * @param output_type 请求返回描述记录的物理输出 dtype。 + * @param output_type_name 用于失败诊断的 dtype 文本。 + * @return CPU reference 和 CUDA 回传的描述及 FP32 数值逐项一致时返回 true。 + */ +[[nodiscard]] bool compare_dequantization_with_cpu( + const HostTensor& input, + const DType output_type, + const std::string_view output_type_name) { + const QuantizationConfig quantization_config{ + .format = QuantFormat::kNvfp4, + .block_size = kNvfp4BlockSize, + .scale_mode = ScaleMode::kBlock, + .rounding = RoundingMode::kNearest, + .stochastic_seed = 0U, + }; + const DequantizationConfig dequantization_config{ + .output_type = output_type, + }; + + try { + const auto quantized = quant_dequant::quantize_reference( + input, quantization_config); + const HostTensor expected = quant_dequant::dequantize_reference( + quantized, dequantization_config); + const HostTensor actual = quant_dequant::dequantize_cuda( + quantized, dequantization_config); + if (expected.desc.num_rows != actual.desc.num_rows || + expected.desc.num_cols != actual.desc.num_cols || + expected.desc.dtype != actual.desc.dtype || + expected.values != actual.values) { + std::cerr << "NVFP4 CUDA 反量化与 CPU reference 不一致:output_type=" + << output_type_name << "。\n"; + return false; + } + } catch (const std::exception& error) { + std::cerr << "NVFP4 CUDA 反量化对照意外失败:output_type=" + << output_type_name << ",原因:" << error.what() << '\n'; + return false; + } + + return true; +} + +} // namespace + +/** + * @brief 运行 NVFP4 CUDA 反量化与 CPU reference 的正确性对照。 + * + * 三种输出请求共享一个 FP32 device kernel;测试检查数值结果始终与 reference + * 位级一致,同时确认 HostTensor 描述保留 FP16、BF16、FP32 的目标物理类型。 + * + * @return 有 CUDA device 时所有对照通过返回 0;无可用 CUDA runtime 时跳过并 + * 返回 0;其他错误返回 1。 + */ +int run_nvfp4_dequantize_cuda_tests() { + const CudaAvailability cuda_availability = query_cuda_availability(); + if (cuda_availability == CudaAvailability::kUnavailable) { + return 0; + } + if (cuda_availability == CudaAvailability::kError) { + return 1; + } + + const HostTensor input = make_input(); + constexpr DType kOutputTypes[]{ + DType::kFloat16, + DType::kBFloat16, + DType::kFloat32, + }; + constexpr std::string_view kOutputTypeNames[]{"fp16", "bf16", "fp32"}; + for (std::size_t index = 0U; index < std::size(kOutputTypes); ++index) { + if (!compare_dequantization_with_cpu( + input, kOutputTypes[index], kOutputTypeNames[index])) { + return 1; + } + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_reference.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_reference.cpp" new file mode 100644 index 00000000..fe29f276 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_nvfp4_reference.cpp" @@ -0,0 +1,252 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "formats/nvfp4_codec.cuh" +#include "quant_dequant/quantize.hpp" + +namespace { + +/** + * @brief 构造包含完整 block、尾 block 和跨行 packed-byte 边界的 NVFP4 输入。 + * + * 单行 17 个元素使第 17 个元素占一行末尾 byte 的低 nibble,下一行第一个元素 + * 占同一 byte 的高 nibble;这验证 CPU reference 的 payload 配对按线性 row-major + * 顺序,而不是错误地把每行尾部 padding 成偶数长度。 + * + * @return 2×17、元素总数为偶数的有限 FP32 输入张量。 + */ +[[nodiscard]] quant_dequant::HostTensor make_reference_input() { + std::vector values(34U, 0.0F); + values[0U] = 6.0F; + values[1U] = -6.0F; + values[15U] = 3.0F; + values[16U] = 1.0F; + values[17U] = -1.0F; + values[18U] = 0.5F; + values[33U] = -2.0F; + + return { + .desc = { + .num_rows = 2U, + .num_cols = 17U, + .dtype = quant_dequant::DType::kFloat32, + }, + .values = std::move(values), + }; +} + +/** + * @brief 构造严格 NVFP4 block-scale 量化配置。 + * + * @param rounding 目标 E2M1 舍入模式。 + * @param seed stochastic rounding 的确定性种子。 + * @return 可传给 public CPU reference API 的合法配置。 + */ +[[nodiscard]] quant_dequant::QuantizationConfig make_nvfp4_config( + const quant_dequant::RoundingMode rounding, + const std::uint64_t seed = 0U) { + return { + .format = quant_dequant::QuantFormat::kNvfp4, + .block_size = quant_dequant::kNvfp4BlockSize, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = rounding, + .stochastic_seed = seed, + }; +} + +/** + * @brief 验证 nearest reference 的层次 scale、byte 打包和跨行配对。 + * + * @return 所有 NVFP4 持久化字段满足预期时返回 true。 + */ +[[nodiscard]] bool test_nearest_reference_layout() { + using namespace quant_dequant; + + const HostTensor input = make_reference_input(); + QuantizedTensor result{}; + try { + result = quantize_reference( + input, make_nvfp4_config(RoundingMode::kNearest)); + } catch (const ReferenceError& error) { + std::cerr << "NVFP4 nearest CPU reference 意外失败:" << error.what() << '\n'; + return false; + } + + const bool metadata_is_valid = result.isConsistent() && + result.desc.format == QuantFormat::kNvfp4 && + result.desc.scale_mode == ScaleMode::kBlock && + result.desc.block_size == kNvfp4BlockSize && + result.payload.size() == 17U && result.local_scales.size() == 4U && + result.global_scale.has_value(); + if (!metadata_is_valid) { + return false; + } + + // 全张量 amax 为 6,所以 global_scale 为 1/448;第一行第一个完整 block + // 的 local scale 是 E4M3(448)=0x7e,{+6,-6} 打包为 0xf7。 + const bool standard_vector = + result.global_scale == std::optional{1.0F / 448.0F} && + result.local_scales[0U] == 0x7eU && result.payload[0U] == 0xf7U; + + // 线性下标 16 与 17 处于不同行、不同 16 元素 local block,却必须共用 + // payload[8] 的低/高 nibble。它们的实际 code 由各自 scale 决定;这里用 + // codec 复算并验证打包位置,避免把跨行 pair 错误分成两个 byte。 + const std::uint8_t row0_tail = formats::encode_nvfp4_element( + input.values[16U], result.local_scales[1U], *result.global_scale, + RoundingMode::kNearest); + const std::uint8_t row1_head = formats::encode_nvfp4_element( + input.values[17U], result.local_scales[2U], *result.global_scale, + RoundingMode::kNearest); + const bool cross_row_pair = result.payload[8U] == + formats::pack_e2m1_nibbles(row0_tail, row1_head); + + return standard_vector && cross_row_pair; +} + +/** + * @brief 验证 stochastic reference 可由 seed 完全复现。 + * + * @return 同 seed 两次量化的 payload、local scale 与 global scale 全部一致时返回 true。 + */ +[[nodiscard]] bool test_stochastic_reference_is_deterministic() { + using namespace quant_dequant; + + const HostTensor input = make_reference_input(); + const QuantizationConfig config = make_nvfp4_config( + RoundingMode::kStochastic, 0xd1b54a32d192ed03ULL); + try { + const QuantizedTensor first = quantize_reference(input, config); + const QuantizedTensor second = quantize_reference(input, config); + return first.isConsistent() && second.isConsistent() && + first.payload == second.payload && + first.local_scales == second.local_scales && + first.global_scale == second.global_scale; + } catch (const ReferenceError& error) { + std::cerr << "NVFP4 stochastic CPU reference 意外失败:" + << error.what() << '\n'; + return false; + } +} + +/** + * @brief 验证 NVFP4 CPU reference 在写出结果前拒绝非有限输入。 + * + * @return 出现包含 NaN/Inf 诊断的 ReferenceError 时返回 true。 + */ +[[nodiscard]] bool test_rejects_nonfinite_input() { + using namespace quant_dequant; + + HostTensor input = make_reference_input(); + input.values[9U] = std::numeric_limits::infinity(); + try { + static_cast(quantize_reference( + input, make_nvfp4_config(RoundingMode::kNearest))); + } catch (const ReferenceError& error) { + return std::string_view{error.what()}.find("NaN 或 Inf") != + std::string_view::npos; + } + + return false; +} + +/** + * @brief 验证 NVFP4 CPU reference 能按 packed-byte 顺序完整反量化。 + * + * 测试复用 2×17 的奇数列输入:payload[8] 的低/high nibble 分属不同行。若实现 + * 错把两个 nibble 套用同一个 local scale,或未跳过奇数总元素尾 byte 的高 nibble, + * 此处会产生与逐元素 codec 解码不同的结果。 + * + * @return 输出描述、长度和所有 FP32 解码数值正确时返回 true。 + */ +[[nodiscard]] bool test_reference_dequantization() { + using namespace quant_dequant; + + const HostTensor input = make_reference_input(); + const DequantizationConfig config{ + .output_type = DType::kBFloat16, + }; + try { + const QuantizedTensor quantized = quantize_reference( + input, make_nvfp4_config(RoundingMode::kNearest)); + const HostTensor result = dequantize_reference(quantized, config); + if (result.desc.num_rows != input.desc.num_rows || + result.desc.num_cols != input.desc.num_cols || + result.desc.dtype != DType::kBFloat16 || + result.values.size() != input.values.size()) { + return false; + } + + const auto blocks_per_row_u64 = quantized.desc.blocksPerRow(); + if (!blocks_per_row_u64.has_value()) { + return false; + } + const std::size_t blocks_per_row = + static_cast(*blocks_per_row_u64); + + for (std::size_t linear_index = 0U; + linear_index < result.values.size(); + ++linear_index) { + const std::size_t row = linear_index / + static_cast(quantized.desc.source_desc.num_cols); + const std::size_t column = linear_index % + static_cast(quantized.desc.source_desc.num_cols); + const std::size_t scale_index = + row * blocks_per_row + + column / static_cast(kNvfp4BlockSize); + const std::uint8_t packed_byte = quantized.payload[linear_index / 2U]; + const std::uint8_t e2m1_code = linear_index % 2U == 0U + ? formats::unpack_e2m1_low_nibble(packed_byte) + : formats::unpack_e2m1_high_nibble(packed_byte); + const float expected = formats::decode_nvfp4_element( + e2m1_code, + quantized.local_scales[scale_index], + *quantized.global_scale); + if (result.values[linear_index] != expected) { + return false; + } + } + } catch (const ReferenceError& error) { + std::cerr << "NVFP4 CPU reference 反量化意外失败:" + << error.what() << '\n'; + return false; + } + + return true; +} + +} // namespace + +/** + * @brief 运行严格 NVFP4 CPU reference 的量化正确性测试。 + * + * @return 所有测试通过时返回 0;否则打印诊断并返回 1。 + */ +int run_nvfp4_reference_tests() { + if (!test_nearest_reference_layout()) { + std::cerr << "NVFP4 nearest reference 的层次 scale 或 packed layout 测试失败。\n"; + return 1; + } + + if (!test_stochastic_reference_is_deterministic()) { + std::cerr << "NVFP4 stochastic reference 的确定性测试失败。\n"; + return 1; + } + + if (!test_rejects_nonfinite_input()) { + std::cerr << "NVFP4 CPU reference 未拒绝 Inf 输入。\n"; + return 1; + } + + if (!test_reference_dequantization()) { + std::cerr << "NVFP4 CPU reference packed 反量化测试失败。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_io.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_io.cpp" new file mode 100644 index 00000000..5a238f97 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_io.cpp" @@ -0,0 +1,483 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/quantized_io.hpp" + +namespace { + +constexpr std::size_t kHeaderBytes = 128U; +constexpr std::size_t kFormatOffset = 13U; +constexpr std::size_t kScaleModeOffset = 14U; +constexpr std::size_t kPayloadElementBitsOffset = 17U; +constexpr std::size_t kPayloadOffsetOffset = 48U; +constexpr std::size_t kLocalScaleOffsetOffset = 64U; +constexpr std::size_t kGlobalScaleOffset = 88U; +constexpr std::size_t kFlagsOffset = 92U; + +constexpr std::array kQuantizedMagic{ + 'Q', 'D', 'W', 'G', 'T', 0U, 0U, 0U, +}; + +/** + * @brief 在析构时删除量化 I/O 测试专用的临时目录。 + */ +class TemporaryDirectoryCleanup final { +public: + /** + * @brief 记录需要清理的临时目录。 + * + * @param directory_path 测试创建的目录路径。 + */ + explicit TemporaryDirectoryCleanup(std::filesystem::path directory_path) + : mDirectoryPath(std::move(directory_path)) {} + + TemporaryDirectoryCleanup(const TemporaryDirectoryCleanup&) = delete; + TemporaryDirectoryCleanup& operator=(const TemporaryDirectoryCleanup&) = delete; + TemporaryDirectoryCleanup(TemporaryDirectoryCleanup&&) = delete; + TemporaryDirectoryCleanup& operator=(TemporaryDirectoryCleanup&&) = delete; + + /** + * @brief 尽力清理临时目录,不覆盖原始测试结果。 + */ + ~TemporaryDirectoryCleanup() { + std::error_code error_code{}; + std::filesystem::remove_all(mDirectoryPath, error_code); + } + +private: + std::filesystem::path mDirectoryPath; +}; + +/** + * @brief 从 little-endian 字节区读取无符号整数。 + * + * @tparam UInt 目标无符号整数类型。 + * @param bytes 输入字节区。 + * @param offset 整数起始 offset。 + * @return 解码后的无符号整数。 + */ +template +[[nodiscard]] UInt load_unsigned_le(const std::span bytes, + const std::size_t offset) noexcept { + UInt result{0U}; + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + result |= static_cast(bytes[offset + index]) + << static_cast(index * 8U); + } + + return result; +} + +/** + * @brief 向 little-endian 字节区写入无符号整数。 + * + * @tparam UInt 源无符号整数类型。 + * @param bytes 输出字节区。 + * @param offset 整数起始 offset。 + * @param value 要编码的整数。 + */ +template +void store_unsigned_le(const std::span bytes, + const std::size_t offset, + const UInt value) noexcept { + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + bytes[offset + index] = static_cast( + value >> static_cast(index * 8U)); + } +} + +/** + * @brief 读取测试生成的完整二进制文件。 + * + * @param file_path 待读取的文件路径。 + * @param bytes 输出字节数组。 + * @return 文件完整读取成功时返回 true。 + */ +[[nodiscard]] bool read_binary_file(const std::filesystem::path& file_path, + std::vector* bytes) { + std::error_code error_code{}; + const std::uintmax_t file_size = std::filesystem::file_size(file_path, error_code); + if (error_code || file_size > std::numeric_limits::max()) { + return false; + } + + bytes->assign(static_cast(file_size), 0U); + std::ifstream input_stream{file_path, std::ios::binary}; + if (!input_stream.is_open()) { + return false; + } + + input_stream.read(reinterpret_cast(bytes->data()), + static_cast(bytes->size())); + return input_stream.good() || input_stream.eof(); +} + +/** + * @brief 将完整二进制字节写入测试临时目录。 + * + * @param file_path 目标文件路径。 + * @param bytes 完整文件内容。 + * @return 文件完整写入成功时返回 true。 + */ +[[nodiscard]] bool write_binary_file(const std::filesystem::path& file_path, + const std::span bytes) { + std::ofstream output_stream{file_path, std::ios::binary | std::ios::trunc}; + if (!output_stream.is_open()) { + return false; + } + + output_stream.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + output_stream.flush(); + return output_stream.good(); +} + +/** + * @brief 判断给定文件是否被 QDWGT 读取器正确拒绝。 + * + * @param file_path 待读取的损坏文件。 + * @return 抛出 `QuantizedIoError` 时返回 true。 + */ +[[nodiscard]] bool is_rejected_by_reader(const std::filesystem::path& file_path) { + try { + static_cast(quant_dequant::read_quantized_tensor(file_path)); + } catch (const quant_dequant::QuantizedIoError&) { + return true; + } + + return false; +} + +/** + * @brief 构造覆盖 rowwise 尾 block 的 MXFP8 量化结果。 + * + * @return 形状为 2 x 33、具有四个 E8M0 local scale 的合法结果。 + */ +[[nodiscard]] quant_dequant::QuantizedTensor make_mxfp8_block_tensor() { + quant_dequant::QuantizedTensor tensor{ + .desc = { + .source_desc = { + .num_rows = 2U, + .num_cols = 33U, + .dtype = quant_dequant::DType::kFloat32, + }, + .format = quant_dequant::QuantFormat::kMxfp8, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + .block_size = quant_dequant::kMxfp8BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }, + .payload = std::vector(66U), + .local_scales = {0x7EU, 0x7FU, 0x80U, 0x81U}, + .global_scale = std::nullopt, + }; + + for (std::size_t index = 0U; index < tensor.payload.size(); ++index) { + tensor.payload[index] = static_cast(index * 17U + 3U); + } + + return tensor; +} + +/** + * @brief 构造覆盖奇数元素尾 nibble 的 NVFP4 量化结果。 + * + * @return 形状为 1 x 17、具有两个 E4M3 local scale 的合法结果。 + */ +[[nodiscard]] quant_dequant::QuantizedTensor make_nvfp4_block_tensor() { + return { + .desc = { + .source_desc = { + .num_rows = 1U, + .num_cols = 17U, + .dtype = quant_dequant::DType::kFloat16, + }, + .format = quant_dequant::QuantFormat::kNvfp4, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kStochastic, + .stochastic_seed = 123456U, + .block_size = quant_dequant::kNvfp4BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }, + .payload = { + 0x21U, 0x43U, 0x65U, 0x07U, 0x89U, 0xABU, 0xCDU, 0xEFU, 0x05U, + }, + .local_scales = {0x38U, 0x40U}, + .global_scale = 0.25F, + }; +} + +/** + * @brief 构造 tensor scale 模式下的 MXFP8 量化结果。 + * + * @return 一个只有一个 local scale 的合法结果。 + */ +[[nodiscard]] quant_dequant::QuantizedTensor make_mxfp8_tensor_scale_tensor() { + return { + .desc = { + .source_desc = { + .num_rows = 2U, + .num_cols = 3U, + .dtype = quant_dequant::DType::kFloat32, + }, + .format = quant_dequant::QuantFormat::kMxfp8, + .scale_mode = quant_dequant::ScaleMode::kTensor, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + .block_size = quant_dequant::kMxfp8BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }, + .payload = {0x00U, 0x01U, 0x02U, 0x03U, 0x04U, 0x05U}, + .local_scales = {0x7FU}, + .global_scale = std::nullopt, + }; +} + +/** + * @brief 比较两个量化结果中的全部公共元数据和二进制数组。 + * + * 测试样本中的 global scale 均是可精确表示的有限数,因此可直接比较 FP32 + * bit pattern,而不是使用误差阈值。 + * + * @param left 左侧量化结果。 + * @param right 右侧量化结果。 + * @return 全部字段一致时返回 true。 + */ +[[nodiscard]] bool quantized_tensors_equal( + const quant_dequant::QuantizedTensor& left, + const quant_dequant::QuantizedTensor& right) { + const auto& left_desc = left.desc; + const auto& right_desc = right.desc; + + const bool same_metadata = + left_desc.source_desc.num_rows == right_desc.source_desc.num_rows && + left_desc.source_desc.num_cols == right_desc.source_desc.num_cols && + left_desc.source_desc.dtype == right_desc.source_desc.dtype && + left_desc.format == right_desc.format && + left_desc.scale_mode == right_desc.scale_mode && + left_desc.rounding == right_desc.rounding && + left_desc.stochastic_seed == right_desc.stochastic_seed && + left_desc.block_size == right_desc.block_size && + left_desc.scale_layout == right_desc.scale_layout; + + if (!same_metadata || left.payload != right.payload || + left.local_scales != right.local_scales || + left.global_scale.has_value() != right.global_scale.has_value()) { + return false; + } + + return !left.global_scale.has_value() || + std::bit_cast(*left.global_scale) == + std::bit_cast(*right.global_scale); +} + +} // namespace + +/** + * @brief 验证 QDWGT 的真实文件 round-trip、物理 header 和损坏文件拒绝逻辑。 + * + * @return 所有断言通过时返回 0;否则打印错误并返回 1。 + */ +int run_quantized_io_tests() { + const auto timestamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::filesystem::path test_directory = + std::filesystem::temp_directory_path() / + ("quant_dequant_quantized_io_test_" + std::to_string(timestamp)); + const TemporaryDirectoryCleanup cleanup{test_directory}; + + std::error_code error_code{}; + if (!std::filesystem::create_directories(test_directory, error_code) || error_code) { + std::cerr << "无法创建量化 I/O 测试临时目录。\n"; + return 1; + } + + const std::filesystem::path mxfp8_path = test_directory / "mxfp8_block.qdwgt"; + const quant_dequant::QuantizedTensor mxfp8_tensor = make_mxfp8_block_tensor(); + if (!mxfp8_tensor.isConsistent()) { + std::cerr << "MXFP8 测试输入不自洽。\n"; + return 1; + } + + try { + quant_dequant::write_quantized_tensor(mxfp8_path, mxfp8_tensor); + } catch (const quant_dequant::QuantizedIoError& error) { + std::cerr << "MXFP8 QDWGT 写入意外失败:" << error.what() << '\n'; + return 1; + } + + std::vector mxfp8_bytes{}; + if (!read_binary_file(mxfp8_path, &mxfp8_bytes) || mxfp8_bytes.size() != 204U || + !std::equal(kQuantizedMagic.begin(), kQuantizedMagic.end(), + mxfp8_bytes.begin()) || + mxfp8_bytes[kFormatOffset] != + static_cast(quant_dequant::QuantFormat::kMxfp8) || + mxfp8_bytes[kPayloadElementBitsOffset] != 8U || + load_unsigned_le(mxfp8_bytes, kPayloadOffsetOffset) != 128U || + load_unsigned_le(mxfp8_bytes, kLocalScaleOffsetOffset) != 200U || + load_unsigned_le(mxfp8_bytes, kGlobalScaleOffset) != 0x3F800000U || + load_unsigned_le(mxfp8_bytes, kFlagsOffset) != 0U || + !std::equal(mxfp8_tensor.payload.begin(), mxfp8_tensor.payload.end(), + mxfp8_bytes.begin() + static_cast(kHeaderBytes)) || + !std::all_of(mxfp8_bytes.begin() + 194, mxfp8_bytes.begin() + 200, + [](const std::uint8_t value) { return value == 0U; }) || + !std::equal(mxfp8_tensor.local_scales.begin(), mxfp8_tensor.local_scales.end(), + mxfp8_bytes.begin() + 200)) { + std::cerr << "MXFP8 QDWGT 的实际 header、section 或对齐布局错误。\n"; + return 1; + } + + try { + if (!quantized_tensors_equal(mxfp8_tensor, + quant_dequant::read_quantized_tensor(mxfp8_path))) { + std::cerr << "MXFP8 QDWGT round-trip 后内容不一致。\n"; + return 1; + } + } catch (const quant_dequant::QuantizedIoError& error) { + std::cerr << "MXFP8 QDWGT 读取意外失败:" << error.what() << '\n'; + return 1; + } + + const std::filesystem::path nvfp4_path = test_directory / "nvfp4_block.qdwgt"; + const quant_dequant::QuantizedTensor nvfp4_tensor = make_nvfp4_block_tensor(); + if (!nvfp4_tensor.isConsistent()) { + std::cerr << "NVFP4 测试输入不自洽。\n"; + return 1; + } + + try { + quant_dequant::write_quantized_tensor(nvfp4_path, nvfp4_tensor); + } catch (const quant_dequant::QuantizedIoError& error) { + std::cerr << "NVFP4 QDWGT 写入意外失败:" << error.what() << '\n'; + return 1; + } + + std::vector nvfp4_bytes{}; + if (!read_binary_file(nvfp4_path, &nvfp4_bytes) || nvfp4_bytes.size() != 146U || + nvfp4_bytes[kFormatOffset] != + static_cast(quant_dequant::QuantFormat::kNvfp4) || + nvfp4_bytes[kPayloadElementBitsOffset] != 4U || + load_unsigned_le(nvfp4_bytes, kPayloadOffsetOffset) != 128U || + load_unsigned_le(nvfp4_bytes, kLocalScaleOffsetOffset) != 144U || + load_unsigned_le(nvfp4_bytes, kGlobalScaleOffset) != 0x3E800000U || + load_unsigned_le(nvfp4_bytes, kFlagsOffset) != 1U || + !std::equal(nvfp4_tensor.payload.begin(), nvfp4_tensor.payload.end(), + nvfp4_bytes.begin() + static_cast(kHeaderBytes)) || + !std::all_of(nvfp4_bytes.begin() + 137, nvfp4_bytes.begin() + 144, + [](const std::uint8_t value) { return value == 0U; }) || + !std::equal(nvfp4_tensor.local_scales.begin(), nvfp4_tensor.local_scales.end(), + nvfp4_bytes.begin() + 144)) { + std::cerr << "NVFP4 QDWGT 的实际 header、section 或对齐布局错误。\n"; + return 1; + } + + try { + if (!quantized_tensors_equal(nvfp4_tensor, + quant_dequant::read_quantized_tensor(nvfp4_path))) { + std::cerr << "NVFP4 QDWGT round-trip 后内容不一致。\n"; + return 1; + } + } catch (const quant_dequant::QuantizedIoError& error) { + std::cerr << "NVFP4 QDWGT 读取意外失败:" << error.what() << '\n'; + return 1; + } + + const std::filesystem::path tensor_scale_path = test_directory / "mxfp8_tensor.qdwgt"; + const quant_dequant::QuantizedTensor tensor_scale_tensor = + make_mxfp8_tensor_scale_tensor(); + try { + quant_dequant::write_quantized_tensor(tensor_scale_path, tensor_scale_tensor); + if (!quantized_tensors_equal( + tensor_scale_tensor, + quant_dequant::read_quantized_tensor(tensor_scale_path))) { + std::cerr << "MXFP8 tensor scale 模式 round-trip 后内容不一致。\n"; + return 1; + } + } catch (const quant_dequant::QuantizedIoError& error) { + std::cerr << "MXFP8 tensor scale 模式 I/O 意外失败:" << error.what() << '\n'; + return 1; + } + + const std::filesystem::path malformed_path = test_directory / "malformed.qdwgt"; + std::vector malformed_bytes = nvfp4_bytes; + + malformed_bytes[0U] = 'X'; + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "损坏 magic 未被 QDWGT 读取器拒绝。\n"; + return 1; + } + + malformed_bytes = nvfp4_bytes; + malformed_bytes[kScaleModeOffset] = + static_cast(quant_dequant::ScaleMode::kTensor); + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "NVFP4 tensor scale QDWGT 未被读取器拒绝。\n"; + return 1; + } + + malformed_bytes = nvfp4_bytes; + malformed_bytes[kPayloadElementBitsOffset] = 8U; + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "错误 payload_element_bits 未被 QDWGT 读取器拒绝。\n"; + return 1; + } + + malformed_bytes = nvfp4_bytes; + malformed_bytes[kHeaderBytes + 8U] = 0xF5U; + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "NVFP4 尾部高 nibble 非零未被 QDWGT 读取器拒绝。\n"; + return 1; + } + + malformed_bytes = nvfp4_bytes; + store_unsigned_le(malformed_bytes, kFlagsOffset, 0U); + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "NVFP4 尾 nibble flags 错误未被 QDWGT 读取器拒绝。\n"; + return 1; + } + + malformed_bytes = nvfp4_bytes; + store_unsigned_le(malformed_bytes, kLocalScaleOffsetOffset, 145U); + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "未对齐 local_scale_offset 未被 QDWGT 读取器拒绝。\n"; + return 1; + } + + malformed_bytes = mxfp8_bytes; + store_unsigned_le(malformed_bytes, kGlobalScaleOffset, 0U); + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "MXFP8 非 1.0F global_scale 未被 QDWGT 读取器拒绝。\n"; + return 1; + } + + malformed_bytes = nvfp4_bytes; + malformed_bytes.pop_back(); + if (!write_binary_file(malformed_path, malformed_bytes) || + !is_rejected_by_reader(malformed_path)) { + std::cerr << "截断 QDWGT 文件未被读取器拒绝。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_tensor.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_tensor.cpp" new file mode 100644 index 00000000..43b6e0ee --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_quantized_tensor.cpp" @@ -0,0 +1,147 @@ +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/quantized_tensor.hpp" + +namespace { + +/** + * @brief 构造一个可复用的 MXFP8 block 模式元数据。 + * + * @return 形状为 1 x 33 的合法 MXFP8 描述;它特意包含一个尾 block。 + */ +[[nodiscard]] quant_dequant::QuantizedTensorDesc make_mxfp8_block_desc() { + return { + .source_desc = { + .num_rows = 1U, + .num_cols = 33U, + .dtype = quant_dequant::DType::kFloat32, + }, + .format = quant_dequant::QuantFormat::kMxfp8, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + .block_size = quant_dequant::kMxfp8BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }; +} + +/** + * @brief 构造一个可复用的 NVFP4 block 模式元数据。 + * + * @return 形状为 1 x 17 的合法 NVFP4 描述;它覆盖尾 block 和奇数 nibble。 + */ +[[nodiscard]] quant_dequant::QuantizedTensorDesc make_nvfp4_block_desc() { + return { + .source_desc = { + .num_rows = 1U, + .num_cols = 17U, + .dtype = quant_dequant::DType::kFloat16, + }, + .format = quant_dequant::QuantFormat::kNvfp4, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kStochastic, + .stochastic_seed = 2026U, + .block_size = quant_dequant::kNvfp4BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }; +} + +} // namespace + +/** + * @brief 验证统一量化结果对象的尺寸推导和 MXFP8/NVFP4 格式不变量。 + * + * @return 所有断言通过时返回 0;否则打印错误并返回 1。 + */ +int run_quantized_tensor_tests() { + const quant_dequant::QuantizedTensorDesc mxfp8_desc = make_mxfp8_block_desc(); + if (!mxfp8_desc.isMetadataValid() || + mxfp8_desc.expectedPayloadBytes() != std::optional{33U} || + mxfp8_desc.blocksPerRow() != std::optional{2U} || + mxfp8_desc.expectedLocalScaleCount() != std::optional{2U} || + mxfp8_desc.usesGlobalScale()) { + std::cerr << "MXFP8 描述的长度推导或元数据校验错误。\n"; + return 1; + } + + quant_dequant::QuantizedTensor mxfp8_tensor{ + .desc = mxfp8_desc, + .payload = std::vector(33U, 0U), + .local_scales = {0U, 0U}, + .global_scale = std::nullopt, + }; + if (!mxfp8_tensor.isConsistent()) { + std::cerr << "合法 MXFP8 量化结果被错误拒绝。\n"; + return 1; + } + + mxfp8_tensor.global_scale = 1.0F; + if (mxfp8_tensor.isConsistent()) { + std::cerr << "MXFP8 不应接受内存语义上的 global_scale。\n"; + return 1; + } + + const quant_dequant::QuantizedTensorDesc nvfp4_desc = make_nvfp4_block_desc(); + if (!nvfp4_desc.isMetadataValid() || + nvfp4_desc.expectedPayloadBytes() != std::optional{9U} || + nvfp4_desc.blocksPerRow() != std::optional{2U} || + nvfp4_desc.expectedLocalScaleCount() != std::optional{2U} || + !nvfp4_desc.usesGlobalScale()) { + std::cerr << "NVFP4 描述的长度推导或元数据校验错误。\n"; + return 1; + } + + quant_dequant::QuantizedTensor nvfp4_tensor{ + .desc = nvfp4_desc, + .payload = std::vector(9U, 0x21U), + .local_scales = {0x38U, 0x40U}, + .global_scale = 0.5F, + }; + // 第 17 个元素只使用最后字节的低 nibble;高 nibble 必须清零。 + nvfp4_tensor.payload.back() = 0x01U; + if (!nvfp4_tensor.isConsistent()) { + std::cerr << "合法 NVFP4 量化结果被错误拒绝。\n"; + return 1; + } + + nvfp4_tensor.payload.back() = 0xF1U; + if (nvfp4_tensor.isConsistent()) { + std::cerr << "NVFP4 奇数尾元素的未使用高 nibble 未被拒绝。\n"; + return 1; + } + + nvfp4_tensor.payload.back() = 0x01U; + nvfp4_tensor.global_scale.reset(); + if (nvfp4_tensor.isConsistent()) { + std::cerr << "NVFP4 缺少 global_scale 未被拒绝。\n"; + return 1; + } + + nvfp4_tensor.global_scale = std::numeric_limits::infinity(); + if (nvfp4_tensor.isConsistent()) { + std::cerr << "NVFP4 无穷 global_scale 未被拒绝。\n"; + return 1; + } + + nvfp4_tensor.global_scale = 0.5F; + nvfp4_tensor.local_scales.pop_back(); + if (nvfp4_tensor.isConsistent()) { + std::cerr << "NVFP4 local scale 数量错误未被拒绝。\n"; + return 1; + } + + quant_dequant::QuantizedTensorDesc nvfp4_tensor_scale_desc = nvfp4_desc; + nvfp4_tensor_scale_desc.scale_mode = quant_dequant::ScaleMode::kTensor; + if (nvfp4_tensor_scale_desc.isMetadataValid() || + nvfp4_tensor_scale_desc.expectedLocalScaleCount().has_value()) { + std::cerr << "NVFP4 tensor scale 元数据未被拒绝。\n"; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_reference_dispatch.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_reference_dispatch.cpp" new file mode 100644 index 00000000..cfe634c5 --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_reference_dispatch.cpp" @@ -0,0 +1,139 @@ +#include +#include +#include +#include + +#include "quant_dequant/quantize.hpp" + +namespace { + +/** + * @brief 验证调用会以包含指定文本的 ReferenceError 失败。 + * + * 此测试只锁定 public API 的格式分发行为;MXFP8 的位级量化与实际文件 + * round-trip 由 test_mxfp8_reference.cpp 单独覆盖。 + * + * @tparam Callable 不接受参数、可能抛出 ReferenceError 的可调用对象类型。 + * @param callable 待调用的 CPU reference API。 + * @param expected_text 错误文本中必须出现的格式或阶段说明。 + * @return 接收到符合预期的 ReferenceError 时返回 true。 + */ +template +[[nodiscard]] bool throws_expected_reference_error( + Callable&& callable, + const std::string_view expected_text) { + try { + callable(); + } catch (const quant_dequant::ReferenceError& error) { + return std::string_view{error.what()}.find(expected_text) != + std::string_view::npos; + } + + return false; +} + +/** + * @brief 构造可通过 CPU reference 入口校验的最小 MXFP8 量化结果。 + * + * @return 形状为 1 x 1 的合法 MXFP8 QuantizedTensor。 + */ +[[nodiscard]] quant_dequant::QuantizedTensor make_mxfp8_tensor() { + return { + .desc = { + .source_desc = { + .num_rows = 1U, + .num_cols = 1U, + .dtype = quant_dequant::DType::kFloat32, + }, + .format = quant_dequant::QuantFormat::kMxfp8, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + .block_size = quant_dequant::kMxfp8BlockSize, + .scale_layout = quant_dequant::ScaleLayout::kRowwise, + }, + .payload = {0x00U}, + .local_scales = {0x00U}, + .global_scale = std::nullopt, + }; +} + +} // namespace + +/** + * @brief 验证 CPU reference 的格式分派和两个已实现量化路径。 + * + * @return 所有骨架断言通过时返回 0;否则打印错误并返回 1。 + */ +int run_reference_dispatch_tests() { + const quant_dequant::HostTensor input{ + .desc = { + .num_rows = 1U, + .num_cols = 1U, + .dtype = quant_dequant::DType::kFloat32, + }, + .values = {1.0F}, + }; + const quant_dequant::QuantizationConfig mxfp8_config{ + .format = quant_dequant::QuantFormat::kMxfp8, + .block_size = quant_dequant::kMxfp8BlockSize, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + }; + const quant_dequant::QuantizationConfig nvfp4_config{ + .format = quant_dequant::QuantFormat::kNvfp4, + .block_size = quant_dequant::kNvfp4BlockSize, + .scale_mode = quant_dequant::ScaleMode::kBlock, + .rounding = quant_dequant::RoundingMode::kNearest, + .stochastic_seed = 0U, + }; + const quant_dequant::DequantizationConfig dequantization_config{ + .output_type = quant_dequant::DType::kFloat32, + }; + + try { + const quant_dequant::QuantizedTensor result = + quant_dequant::quantize_reference(input, mxfp8_config); + if (!result.isConsistent() || result.desc.format != + quant_dequant::QuantFormat::kMxfp8) { + std::cerr << "MXFP8 CPU reference 量化未返回自洽结果。\n"; + return 1; + } + } catch (const quant_dequant::ReferenceError& error) { + std::cerr << "MXFP8 CPU reference 量化意外失败:" << error.what() << '\n'; + return 1; + } + + try { + const quant_dequant::QuantizedTensor result = + quant_dequant::quantize_reference(input, nvfp4_config); + if (!result.isConsistent() || result.desc.format != + quant_dequant::QuantFormat::kNvfp4 || + !result.global_scale.has_value()) { + std::cerr << "NVFP4 CPU reference 量化未返回自洽结果。\n"; + return 1; + } + } catch (const quant_dequant::ReferenceError& error) { + std::cerr << "NVFP4 CPU reference 量化意外失败:" << error.what() << '\n'; + return 1; + } + + const quant_dequant::QuantizedTensor mxfp8_tensor = make_mxfp8_tensor(); + try { + const quant_dequant::HostTensor result = + quant_dequant::dequantize_reference( + mxfp8_tensor, dequantization_config); + if (result.desc.num_rows != 1U || result.desc.num_cols != 1U || + result.desc.dtype != quant_dequant::DType::kFloat32 || + result.values != std::vector{0.0F}) { + std::cerr << "MXFP8 CPU reference 反量化返回结果不符合预期。\n"; + return 1; + } + } catch (const quant_dequant::ReferenceError& error) { + std::cerr << "MXFP8 CPU reference 反量化意外失败:" << error.what() << '\n'; + return 1; + } + + return 0; +} diff --git "a/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_tensor_io.cpp" "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_tensor_io.cpp" new file mode 100644 index 00000000..d80bac9b --- /dev/null +++ "b/02_quant_dequant/\351\273\204\346\226\260\351\242\226/tests/test_tensor_io.cpp" @@ -0,0 +1,556 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "quant_dequant/tensor_io.hpp" + +namespace { + +constexpr std::size_t kHeaderBytes = 64U; +constexpr std::uint64_t kDataOffset = 64U; + +constexpr std::array kTensorMagic{ + 'Q', 'D', 'T', 'E', 'N', 'S', 'O', 'R', +}; + +constexpr std::size_t kVersionOffset = 8U; +constexpr std::size_t kHeaderBytesOffset = 10U; +constexpr std::size_t kDTypeOffset = 13U; +constexpr std::size_t kTensorRoleOffset = 14U; +constexpr std::size_t kRowsOffset = 16U; +constexpr std::size_t kColsOffset = 24U; +constexpr std::size_t kElementCountOffset = 32U; +constexpr std::size_t kDataOffsetOffset = 40U; +constexpr std::size_t kDataBytesOffset = 48U; + +/** + * @brief 在析构时删除测试专用临时目录。 + */ +class TemporaryDirectoryCleanup final { +public: + /** + * @brief 记录需要清理的临时目录。 + * + * @param directory_path 测试创建的目录路径。 + */ + explicit TemporaryDirectoryCleanup(std::filesystem::path directory_path) + : mDirectoryPath(std::move(directory_path)) {} + + TemporaryDirectoryCleanup(const TemporaryDirectoryCleanup&) = delete; + TemporaryDirectoryCleanup& operator=(const TemporaryDirectoryCleanup&) = delete; + TemporaryDirectoryCleanup(TemporaryDirectoryCleanup&&) = delete; + TemporaryDirectoryCleanup& operator=(TemporaryDirectoryCleanup&&) = delete; + + /** + * @brief 尽力清理临时目录,不覆盖原始测试结果。 + */ + ~TemporaryDirectoryCleanup() { + std::error_code error_code{}; + std::filesystem::remove_all(mDirectoryPath, error_code); + } + +private: + std::filesystem::path mDirectoryPath; +}; + +/** + * @brief 向 little-endian 字节区写入无符号整数。 + * + * @tparam UInt 要写入的无符号整数类型。 + * @param bytes 目标字节区。 + * @param offset 起始字节 offset。 + * @param value 要写入的数值。 + */ +template +void store_unsigned_le(std::span bytes, const std::size_t offset, + const UInt value) noexcept { + static_assert(std::is_unsigned_v); + + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + bytes[offset + index] = static_cast( + value >> static_cast(index * 8U)); + } +} + +/** + * @brief 从 little-endian 字节区读取无符号整数。 + * + * @tparam UInt 目标无符号整数类型。 + * @param bytes 源字节区。 + * @param offset 起始字节 offset。 + * @return 解码后的整数。 + */ +template +[[nodiscard]] UInt load_unsigned_le(std::span bytes, + const std::size_t offset) noexcept { + static_assert(std::is_unsigned_v); + + UInt result{0U}; + for (std::size_t index = 0U; index < sizeof(UInt); ++index) { + result |= static_cast(bytes[offset + index]) + << static_cast(index * 8U); + } + + return result; +} + +/** + * @brief 向 vector 末尾附加 little-endian 无符号整数。 + * + * @tparam UInt 要附加的无符号整数类型。 + * @param bytes 要扩展的字节数组。 + * @param value 要编码的数值。 + */ +template +void append_unsigned_le(std::vector& bytes, const UInt value) { + static_assert(std::is_unsigned_v); + + const std::size_t previous_size = bytes.size(); + bytes.resize(previous_size + sizeof(UInt)); + store_unsigned_le(bytes, previous_size, value); +} + +/** + * @brief 构造独立于被测实现的 QDTENSOR v1 文件字节。 + * + * 该帮助函数直接按 `docs/file_format.md` 编码 header,因此能够验证读取器, + * 而不是用被测写入器生成再由被测读取器读取。 + * + * @param dtype 文件中元素的物理 dtype。 + * @param role 文件用途。 + * @param rows 矩阵行数。 + * @param cols 矩阵列数。 + * @param payload 连续 row-major payload。 + * @return 完整 QDTENSOR 文件的字节数组。 + */ +[[nodiscard]] std::vector make_qdtensor_file( + const quant_dequant::DType dtype, const quant_dequant::TensorRole role, + const std::uint64_t rows, const std::uint64_t cols, + const std::span payload) { + std::vector file_bytes(kHeaderBytes + payload.size(), 0U); + std::copy(kTensorMagic.begin(), kTensorMagic.end(), file_bytes.begin()); + + store_unsigned_le(file_bytes, kVersionOffset, 1U); + store_unsigned_le(file_bytes, kHeaderBytesOffset, + static_cast(kHeaderBytes)); + file_bytes[12U] = quant_dequant::kLittleEndianByteOrder; + file_bytes[kDTypeOffset] = static_cast(dtype); + file_bytes[kTensorRoleOffset] = static_cast(role); + + const std::uint64_t element_count = rows * cols; + store_unsigned_le(file_bytes, kRowsOffset, rows); + store_unsigned_le(file_bytes, kColsOffset, cols); + store_unsigned_le(file_bytes, kElementCountOffset, + element_count); + store_unsigned_le(file_bytes, kDataOffsetOffset, kDataOffset); + store_unsigned_le(file_bytes, kDataBytesOffset, + static_cast(payload.size())); + + std::copy(payload.begin(), payload.end(), + file_bytes.begin() + static_cast(kHeaderBytes)); + return file_bytes; +} + +/** + * @brief 将完整二进制文件字节写入测试临时目录。 + * + * @param file_path 目标文件路径。 + * @param bytes 待写入的完整文件内容。 + * @return 写入成功时返回 true。 + */ +[[nodiscard]] bool write_binary_file(const std::filesystem::path& file_path, + const std::span bytes) { + std::ofstream output_stream{file_path, std::ios::binary | std::ios::trunc}; + if (!output_stream.is_open()) { + return false; + } + + output_stream.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); + output_stream.flush(); + return output_stream.good(); +} + +/** + * @brief 读取完整测试输出文件的原始字节。 + * + * @param file_path 待读取的文件路径。 + * @param bytes 输出字节数组。 + * @return 完整读取成功时返回 true。 + */ +[[nodiscard]] bool read_binary_file(const std::filesystem::path& file_path, + std::vector* bytes) { + std::error_code error_code{}; + const std::uintmax_t file_size = std::filesystem::file_size(file_path, error_code); + if (error_code || file_size > std::numeric_limits::max()) { + return false; + } + + bytes->assign(static_cast(file_size), 0U); + std::ifstream input_stream{file_path, std::ios::binary}; + if (!input_stream.is_open()) { + return false; + } + + input_stream.read(reinterpret_cast(bytes->data()), + static_cast(bytes->size())); + return input_stream.good() || input_stream.eof(); +} + +/** + * @brief 验证输出 QDTENSOR header 的公共字段。 + * + * @param file_bytes 读取到的完整文件字节。 + * @param dtype 期望的输出 dtype。 + * @param expected_data_bytes 期望 payload 字节数。 + * @return header 与 QDTENSOR v1 约定一致时返回 true。 + */ +[[nodiscard]] bool has_valid_output_header( + const std::span file_bytes, + const quant_dequant::DType dtype, + const std::uint64_t expected_data_bytes) { + if (file_bytes.size() != kHeaderBytes + expected_data_bytes) { + return false; + } + + if (!std::equal(kTensorMagic.begin(), kTensorMagic.end(), file_bytes.begin())) { + return false; + } + + return load_unsigned_le(file_bytes, kVersionOffset) == 1U && + load_unsigned_le(file_bytes, kHeaderBytesOffset) == + kHeaderBytes && + file_bytes[12U] == quant_dequant::kLittleEndianByteOrder && + file_bytes[kDTypeOffset] == static_cast(dtype) && + file_bytes[kTensorRoleOffset] == + static_cast(quant_dequant::TensorRole::kDequantizedOutput) && + load_unsigned_le(file_bytes, kRowsOffset) == 2U && + load_unsigned_le(file_bytes, kColsOffset) == 3U && + load_unsigned_le(file_bytes, kElementCountOffset) == 6U && + load_unsigned_le(file_bytes, kDataOffsetOffset) == + kDataOffset && + load_unsigned_le(file_bytes, kDataBytesOffset) == + expected_data_bytes; +} + +/** + * @brief 验证手工构造的 FP32 输入能够被正确读取。 + * + * @param directory 测试临时目录。 + * @return 读取结果完全匹配时返回 true。 + */ +[[nodiscard]] bool test_fp32_input_read(const std::filesystem::path& directory) { + constexpr std::array kExpectedValues{ + 0.0F, 1.0F, -1.0F, 0.5F, -2.0F, 448.0F, + }; + + std::vector payload{}; + payload.reserve(kExpectedValues.size() * sizeof(float)); + for (const float value : kExpectedValues) { + append_unsigned_le(payload, + std::bit_cast(value)); + } + + const std::filesystem::path input_path = directory / "input_fp32.bin"; + const auto file_bytes = make_qdtensor_file( + quant_dequant::DType::kFloat32, quant_dequant::TensorRole::kInput, + 2U, 3U, payload); + + if (!write_binary_file(input_path, file_bytes)) { + std::cerr << "无法写入 FP32 输入测试文件。\n"; + return false; + } + + const quant_dequant::HostTensor tensor = + quant_dequant::read_input_tensor(input_path); + + return tensor.desc.num_rows == 2U && tensor.desc.num_cols == 3U && + tensor.desc.dtype == quant_dequant::DType::kFloat32 && + tensor.values == std::vector{kExpectedValues.begin(), + kExpectedValues.end()}; +} + +/** + * @brief 验证 FP16 normal、subnormal 与最大有限值能被正确扩展为 FP32。 + * + * @param directory 测试临时目录。 + * @return 读取结果完全匹配时返回 true。 + */ +[[nodiscard]] bool test_fp16_input_read(const std::filesystem::path& directory) { + constexpr std::array kHalfBits{ + 0x0000U, 0x3C00U, 0xBC00U, 0x3800U, 0x0001U, 0x7BFFU, + }; + const std::array expected_values{ + 0.0F, 1.0F, -1.0F, 0.5F, std::ldexp(1.0F, -24), 65504.0F, + }; + + std::vector payload{}; + payload.reserve(kHalfBits.size() * sizeof(std::uint16_t)); + for (const std::uint16_t bits : kHalfBits) { + append_unsigned_le(payload, bits); + } + + const std::filesystem::path input_path = directory / "input_fp16.bin"; + const auto file_bytes = make_qdtensor_file( + quant_dequant::DType::kFloat16, quant_dequant::TensorRole::kInput, + 2U, 3U, payload); + + if (!write_binary_file(input_path, file_bytes)) { + std::cerr << "无法写入 FP16 输入测试文件。\n"; + return false; + } + + const quant_dequant::HostTensor tensor = + quant_dequant::read_input_tensor(input_path); + + return tensor.desc.dtype == quant_dequant::DType::kFloat16 && + tensor.values == std::vector{expected_values.begin(), + expected_values.end()}; +} + +/** + * @brief 验证反量化输出的 header、payload 编码及输出用途文件读取。 + * + * @param directory 测试临时目录。 + * @param dtype 目标输出 dtype。 + * @param expected_payload 期望的 little-endian payload。 + * @return 写入字节、输出 role/dtype 和重新扩展为 FP32 的数值均匹配时返回 true。 + */ +[[nodiscard]] bool test_dequantized_output_write( + const std::filesystem::path& directory, const quant_dequant::DType dtype, + const std::span expected_payload) { + constexpr std::array kValues{ + 0.0F, 1.5F, -2.25F, 0.5F, 3.5F, 0.0078125F, + }; + + const quant_dequant::TensorDesc output_desc{ + .num_rows = 2U, + .num_cols = 3U, + .dtype = dtype, + }; + const std::filesystem::path output_path = + directory / ("output_" + std::string{quant_dequant::to_string(dtype)} + ".bin"); + + const quant_dequant::HostTensor output_tensor{ + .desc = output_desc, + .values = {kValues.begin(), kValues.end()}, + }; + quant_dequant::write_dequantized_tensor(output_path, output_tensor); + + std::vector file_bytes{}; + if (!read_binary_file(output_path, &file_bytes)) { + std::cerr << "无法读取反量化输出测试文件。\n"; + return false; + } + + if (!has_valid_output_header(file_bytes, dtype, + static_cast(expected_payload.size()))) { + return false; + } + + if (!std::equal(expected_payload.begin(), expected_payload.end(), + file_bytes.begin() + static_cast(kHeaderBytes))) { + return false; + } + + // 完整 app 以此接口读回实际写出的物理 payload,确保 FP16/BF16 的窄化误差 + // 也计入最终指标。当前样例值在三种输出 dtype 中都可精确表示。 + const quant_dequant::HostTensor reread_tensor = + quant_dequant::read_dequantized_tensor(output_path); + return reread_tensor.desc.num_rows == output_desc.num_rows && + reread_tensor.desc.num_cols == output_desc.num_cols && + reread_tensor.desc.dtype == output_desc.dtype && + reread_tensor.values == std::vector{kValues.begin(), kValues.end()}; +} + +/** + * @brief 验证读取器能拒绝损坏 magic 与截断 payload。 + * + * @param directory 测试临时目录。 + * @return 两种损坏文件均被 TensorIoError 拒绝时返回 true。 + */ +[[nodiscard]] bool test_invalid_input_rejection( + const std::filesystem::path& directory) { + constexpr std::array kValues{ + 0.0F, 1.0F, 2.0F, 3.0F, 4.0F, 5.0F, + }; + std::vector payload{}; + payload.reserve(kValues.size() * sizeof(float)); + for (const float value : kValues) { + append_unsigned_le(payload, + std::bit_cast(value)); + } + + auto corrupt_magic = make_qdtensor_file( + quant_dequant::DType::kFloat32, quant_dequant::TensorRole::kInput, + 2U, 3U, payload); + corrupt_magic[0U] = 0U; + + const std::filesystem::path magic_path = directory / "invalid_magic.bin"; + if (!write_binary_file(magic_path, corrupt_magic)) { + return false; + } + + try { + static_cast(quant_dequant::read_input_tensor(magic_path)); + return false; + } catch (const quant_dequant::TensorIoError&) { + } + + const std::filesystem::path truncated_path = directory / "truncated.bin"; + const auto valid_file = make_qdtensor_file( + quant_dequant::DType::kFloat32, quant_dequant::TensorRole::kInput, + 2U, 3U, payload); + if (!write_binary_file(truncated_path, valid_file)) { + return false; + } + + std::error_code error_code{}; + std::filesystem::resize_file(truncated_path, kHeaderBytes + 1U, error_code); + if (error_code) { + return false; + } + + try { + static_cast(quant_dequant::read_input_tensor(truncated_path)); + return false; + } catch (const quant_dequant::TensorIoError&) { + return true; + } +} + +/** + * @brief 验证反量化输出文件不会被误当作输入张量读取。 + * + * @param directory 测试临时目录。 + * @return role 不匹配被正确拒绝时返回 true。 + */ +[[nodiscard]] bool test_output_role_rejection( + const std::filesystem::path& directory) { + constexpr std::array kValues{ + 0.0F, 1.0F, -1.0F, 0.5F, -0.5F, 2.0F, + }; + const quant_dequant::TensorDesc output_desc{ + .num_rows = 2U, + .num_cols = 3U, + .dtype = quant_dequant::DType::kFloat32, + }; + const std::filesystem::path output_path = directory / "dequantized.bin"; + + const quant_dequant::HostTensor output_tensor{ + .desc = output_desc, + .values = {kValues.begin(), kValues.end()}, + }; + quant_dequant::write_dequantized_tensor(output_path, output_tensor); + + try { + static_cast(quant_dequant::read_input_tensor(output_path)); + return false; + } catch (const quant_dequant::TensorIoError&) { + return true; + } +} + +} // namespace + +/** + * @brief 运行 QDTENSOR 输入读取、输出写入与损坏文件拒绝测试。 + * + * @return 所有 tensor I/O 测试通过时返回 0;失败时打印原因并返回 1。 + */ +int run_tensor_io_tests() { + const auto timestamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::filesystem::path directory = + std::filesystem::temp_directory_path() / + ("quant_dequant_tensor_io_test_" + std::to_string(timestamp)); + + std::error_code error_code{}; + if (!std::filesystem::create_directory(directory, error_code) || error_code) { + std::cerr << "无法创建 tensor I/O 测试临时目录。\n"; + return 1; + } + const TemporaryDirectoryCleanup cleanup{directory}; + + try { + if (!test_fp32_input_read(directory)) { + std::cerr << "FP32 输入读取测试失败。\n"; + return 1; + } + + if (!test_fp16_input_read(directory)) { + std::cerr << "FP16 输入读取测试失败。\n"; + return 1; + } + + constexpr std::array kFp16Payload{ + 0x00U, 0x00U, 0x00U, 0x3EU, 0x80U, 0xC0U, + 0x00U, 0x38U, 0x00U, 0x43U, 0x00U, 0x20U, + }; + if (!test_dequantized_output_write(directory, + quant_dequant::DType::kFloat16, + kFp16Payload)) { + std::cerr << "FP16 输出写入测试失败。\n"; + return 1; + } + + constexpr std::array kBf16Payload{ + 0x00U, 0x00U, 0xC0U, 0x3FU, 0x10U, 0xC0U, + 0x00U, 0x3FU, 0x60U, 0x40U, 0x00U, 0x3CU, + }; + if (!test_dequantized_output_write(directory, + quant_dequant::DType::kBFloat16, + kBf16Payload)) { + std::cerr << "BF16 输出写入测试失败。\n"; + return 1; + } + + constexpr std::array kFp32Values{ + 0.0F, 1.5F, -2.25F, 0.5F, 3.5F, 0.0078125F, + }; + std::vector fp32_payload{}; + fp32_payload.reserve(kFp32Values.size() * sizeof(float)); + for (const float value : kFp32Values) { + append_unsigned_le(fp32_payload, + std::bit_cast(value)); + } + if (!test_dequantized_output_write(directory, + quant_dequant::DType::kFloat32, + fp32_payload)) { + std::cerr << "FP32 输出写入测试失败。\n"; + return 1; + } + + if (!test_invalid_input_rejection(directory)) { + std::cerr << "损坏输入文件拒绝测试失败。\n"; + return 1; + } + + if (!test_output_role_rejection(directory)) { + std::cerr << "输出 role 拒绝测试失败。\n"; + return 1; + } + } catch (const quant_dequant::TensorIoError& error) { + std::cerr << "Tensor I/O 测试意外抛出异常:" << error.what() << '\n'; + return 1; + } catch (const std::exception& error) { + std::cerr << "Tensor I/O 测试发生未预期异常:" << error.what() << '\n'; + return 1; + } + + return 0; +}